diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1facd9ca9..f856b43d6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12358,6 +12358,31 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/v2/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, "PluginShareListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PluginShareListParams", @@ -12368,7 +12393,7 @@ "properties": { "data": { "items": { - "$ref": "#/definitions/v2/PluginSummary" + "$ref": "#/definitions/v2/PluginShareListItem" }, "type": "array" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7efd9b4be..c17efe7a4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9011,6 +9011,31 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, "PluginShareListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PluginShareListParams", @@ -9021,7 +9046,7 @@ "properties": { "data": { "items": { - "$ref": "#/definitions/PluginSummary" + "$ref": "#/definitions/PluginShareListItem" }, "type": "array" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 6753db3d2..adb5021be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -167,6 +167,31 @@ ], "type": "object" }, + "PluginShareListItem": { + "properties": { + "localPluginPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "plugin": { + "$ref": "#/definitions/PluginSummary" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "plugin", + "shareUrl" + ], + "type": "object" + }, "PluginSource": { "oneOf": [ { @@ -304,7 +329,7 @@ "properties": { "data": { "items": { - "$ref": "#/definitions/PluginSummary" + "$ref": "#/definitions/PluginShareListItem" }, "type": "array" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts new file mode 100644 index 000000000..b63738aac --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginSummary } from "./PluginSummary"; + +export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts index 32b6c50c4..50b324f5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts @@ -1,6 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PluginSummary } from "./PluginSummary"; +import type { PluginShareListItem } from "./PluginShareListItem"; -export type PluginShareListResponse = { data: Array, }; +export type PluginShareListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 1a087c4f1..d369ba342 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -283,6 +283,7 @@ export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 18234cb8b..cbcc12c3a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4651,7 +4651,7 @@ pub struct PluginShareListParams {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginShareListResponse { - pub data: Vec, + pub data: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4666,6 +4666,15 @@ pub struct PluginShareDeleteParams { #[ts(export_to = "v2/")] pub struct PluginShareDeleteResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListItem { + pub plugin: PluginSummary, + pub share_url: String, + pub local_plugin_path: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -10765,33 +10774,41 @@ mod tests { } #[test] - fn plugin_share_list_response_serializes_plugin_summaries() { + fn plugin_share_list_response_serializes_share_items() { assert_eq!( serde_json::to_value(PluginShareListResponse { - data: vec![PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - name: "gmail".to_string(), - source: PluginSource::Remote, - installed: false, - enabled: false, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: None, + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + name: "gmail".to_string(), + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: None, + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, }], }) .unwrap(), json!({ "data": [{ - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "availability": "AVAILABLE", - "interface": null, + "plugin": { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "availability": "AVAILABLE", + "interface": null, + }, + "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", + "localPluginPath": null, }], }), ); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3baee6be3..f026eac6b 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -122,6 +122,7 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListParams; use codex_app_server_protocol::PluginShareListResponse; use codex_app_server_protocol::PluginShareSaveParams; @@ -299,6 +300,7 @@ use codex_core_plugins::remote::RemoteMarketplace; use codex_core_plugins::remote::RemotePluginCatalogError; use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; use codex_exec_server::LOCAL_FS; diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index a7c665555..5bab11551 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -387,14 +387,16 @@ impl CodexMessageProcessor { let result = codex_core_plugins::remote::save_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), - plugin_path.as_path(), + config.codex_home.as_path(), + &plugin_path, remote_plugin_id.as_deref(), ) .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; + let remote_plugin_id = result.remote_plugin_id; self.clear_plugin_related_caches(); Ok(PluginShareSaveResponse { - remote_plugin_id: result.remote_plugin_id, + remote_plugin_id, share_url: result.share_url.unwrap_or_default(), }) } @@ -418,11 +420,24 @@ impl CodexMessageProcessor { let data = codex_core_plugins::remote::list_remote_plugin_shares( &remote_plugin_service_config, auth.as_ref(), + config.codex_home.as_path(), ) .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))? .into_iter() - .map(remote_plugin_summary_to_info) + .map(|summary| { + let RemoteCatalogPluginShareSummary { + summary, + share_url, + local_plugin_path, + } = summary; + let plugin = remote_plugin_summary_to_info(summary); + PluginShareListItem { + plugin, + share_url: share_url.unwrap_or_default(), + local_plugin_path, + } + }) .collect(); Ok(PluginShareListResponse { data }) } @@ -452,6 +467,7 @@ impl CodexMessageProcessor { codex_core_plugins::remote::delete_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), + config.codex_home.as_path(), &remote_plugin_id, ) .await diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 62e2ee18e..a44a64be7 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; use codex_app_server_protocol::PluginShareSaveResponse; use codex_app_server_protocol::PluginSource; @@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; + write_corrupt_plugin_share_local_path_mapping(codex_home.path())?; Mock::given(method("POST")) .and(path("/backend-api/public/plugins/workspace/upload-url")) @@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?; let request_id = mcp .send_raw_request( "plugin/share/save", Some(json!({ - "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "pluginPath": expected_plugin_path.clone(), })), ) .await?; @@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), } ); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: Some(expected_plugin_path), + }], + } + ); Ok(()) } @@ -169,16 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { assert_eq!( response, PluginShareListResponse { - data: vec![PluginSummary { - id: "plugins_123".to_string(), - name: "demo-plugin".to_string(), - source: PluginSource::Remote, - installed: true, - enabled: true, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: codex_app_server_protocol::PluginAvailability::Available, - interface: Some(expected_plugin_interface()), + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, }], } ); @@ -198,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; + let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?; Mock::given(method("DELETE")) .and(path("/backend-api/public/plugins/workspace/plugins_123")) @@ -227,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let response: PluginShareDeleteResponse = to_response(response)?; assert_eq!(response, PluginShareDeleteResponse {}); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + } + ); Ok(()) } @@ -250,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "id": plugin_id, "name": "demo-plugin", "scope": "WORKSPACE", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { @@ -315,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result Ok(plugin_path) } +fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> { + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + "not-json", + ) +} + +fn write_plugin_share_local_path_mapping( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + &format!("{contents}\n"), + ) +} + fn write_file(path: &Path, contents: &str) -> std::io::Result<()> { let Some(parent) = path.parent() else { return Err(std::io::Error::other(format!( diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 7f0c7dd3a..72335c476 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; use codex_plugin::PluginId; +use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; use std::collections::BTreeMap; @@ -75,6 +76,13 @@ pub struct RemotePluginSummary { pub interface: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginShareSummary { + pub summary: RemotePluginSummary, + pub share_url: Option, + pub local_plugin_path: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginDetail { pub marketplace_name: String, @@ -323,6 +331,8 @@ struct RemotePluginDirectoryItem { id: String, name: String, scope: RemotePluginScope, + #[serde(default)] + share_url: Option, installation_policy: PluginInstallPolicy, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 25f66dabc..58df033cf 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -1,6 +1,7 @@ use super::*; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; +use codex_utils_absolute_path::AbsolutePathBuf; use flate2::Compression; use flate2::write::GzEncoder; use reqwest::RequestBuilder; @@ -13,6 +14,9 @@ use std::fs; use std::io; use std::io::Write; use std::path::Path; +use tracing::warn; + +mod local_paths; const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024; @@ -53,14 +57,15 @@ struct RemoteWorkspacePluginCreateResponse { pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, - plugin_path: &Path, + codex_home: &Path, + plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; - let plugin_path = plugin_path.to_path_buf(); + let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); let (filename, archive_bytes) = tokio::task::spawn_blocking(move || { - let filename = archive_filename(&plugin_path)?; - let archive_bytes = archive_plugin_for_upload(&plugin_path)?; + let filename = archive_filename(&plugin_path_for_archive)?; + let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?; Ok::<_, RemotePluginCatalogError>((filename, archive_bytes)) }) .await @@ -93,6 +98,17 @@ pub async fn save_remote_plugin_share( )); } + if let Err(err) = local_paths::record_plugin_share_local_path( + codex_home, + &response.plugin_id, + plugin_path.clone(), + ) { + warn!( + remote_plugin_id = %response.plugin_id, + "failed to record plugin share local path mapping: {err}" + ); + } + Ok(RemotePluginShareSaveResult { remote_plugin_id: response.plugin_id, share_url: response.share_url, @@ -102,7 +118,8 @@ pub async fn save_remote_plugin_share( pub async fn list_remote_plugin_shares( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, -) -> Result, RemotePluginCatalogError> { + codex_home: &Path, +) -> Result, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let created_plugins = fetch_created_workspace_plugins(config, auth).await?; if created_plugins.is_empty() { @@ -115,16 +132,30 @@ pub async fn list_remote_plugin_shares( .into_iter() .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::>(); + let local_plugin_paths = + local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| { + warn!("failed to load plugin share local path mapping: {err}"); + BTreeMap::new() + }); Ok(created_plugins .into_iter() - .map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id))) + .map(|plugin| { + let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)); + let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); + RemotePluginShareSummary { + summary, + share_url: plugin.share_url, + local_plugin_path, + } + }) .collect()) } pub async fn delete_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, + codex_home: &Path, remote_plugin_id: &str, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; @@ -132,7 +163,14 @@ pub async fn delete_remote_plugin_share( let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); let client = build_reqwest_client(); let request = authenticated_request(client.delete(&url), auth)?; - send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await + send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; + if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) { + warn!( + remote_plugin_id = %remote_plugin_id, + "failed to remove plugin share local path mapping: {err}" + ); + } + Ok(()) } async fn fetch_created_workspace_plugins( diff --git a/codex-rs/core-plugins/src/remote/share/local_paths.rs b/codex-rs/core-plugins/src/remote/share/local_paths.rs new file mode 100644 index 000000000..50e8fba89 --- /dev/null +++ b/codex-rs/core-plugins/src/remote/share/local_paths.rs @@ -0,0 +1,124 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::io; +use std::io::Write; +use std::path::Path; +use std::sync::Mutex; + +const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json"; +static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PluginShareLocalPaths { + #[serde(default)] + local_plugin_paths_by_remote_plugin_id: BTreeMap, +} + +pub(crate) fn load_plugin_share_local_paths( + codex_home: &Path, +) -> io::Result> { + let _guard = lock_plugin_share_local_paths()?; + read_plugin_share_local_paths(codex_home) +} + +pub(crate) fn record_plugin_share_local_path( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: AbsolutePathBuf, +) -> io::Result<()> { + let _guard = lock_plugin_share_local_paths()?; + let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?; + mapping.insert(remote_plugin_id.to_string(), plugin_path); + write_plugin_share_local_paths(codex_home, mapping) +} + +pub(crate) fn remove_plugin_share_local_path( + codex_home: &Path, + remote_plugin_id: &str, +) -> io::Result<()> { + let _guard = lock_plugin_share_local_paths()?; + let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?; + mapping.remove(remote_plugin_id); + write_plugin_share_local_paths(codex_home, mapping) +} + +fn lock_plugin_share_local_paths() -> io::Result> { + PLUGIN_SHARE_LOCAL_PATHS_LOCK + .lock() + .map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}"))) +} + +fn read_plugin_share_local_paths( + codex_home: &Path, +) -> io::Result> { + let path = plugin_share_local_paths_path(codex_home); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err), + }; + + let mapping = serde_json::from_str::(&contents).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "failed to parse plugin share local path mapping {}: {err}", + path.display() + ), + ) + })?; + Ok(mapping.local_plugin_paths_by_remote_plugin_id) +} + +fn read_plugin_share_local_paths_for_update( + codex_home: &Path, +) -> io::Result> { + match read_plugin_share_local_paths(codex_home) { + Ok(mapping) => Ok(mapping), + // This is a best-effort cache under .tmp, so malformed state should not + // permanently block future share saves or deletes. + Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()), + Err(err) => Err(err), + } +} + +fn write_plugin_share_local_paths( + codex_home: &Path, + mapping: BTreeMap, +) -> io::Result<()> { + let path = plugin_share_local_paths_path(codex_home); + if mapping.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + } + } + + let contents = serde_json::to_string_pretty(&PluginShareLocalPaths { + local_plugin_paths_by_remote_plugin_id: mapping, + }) + .map_err(io::Error::other)?; + write_atomically(&path, &format!("{contents}\n")) +} + +fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(contents.as_bytes())?; + tmp.persist(write_path).map_err(|err| err.error)?; + Ok(()) +} + +fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf { + codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE) +} diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index afed08e0a..efdecdbbb 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -3,6 +3,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_login::CodexAuth; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; @@ -49,6 +50,25 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf { plugin_path } +fn write_plugin_share_local_path_mapping( + codex_home: &Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) { + write_file( + &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + &format!( + "{}\n", + serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": { + remote_plugin_id: plugin_path, + }, + })) + .unwrap() + ), + ); +} + fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap> { let decoder = flate2::read::GzDecoder::new(archive_bytes); let mut archive = tar::Archive::new(decoder); @@ -87,6 +107,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { }) } +fn remote_plugin_json_with_share_url( + plugin_id: &str, + share_url: Option<&str>, +) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("share_url".to_string(), json!(share_url)); + plugin +} + fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value { let mut plugin = remote_plugin_json(plugin_id); let serde_json::Value::Object(fields) = &mut plugin else { @@ -127,9 +159,13 @@ fn expected_plugin_interface() -> PluginInterface { #[tokio::test] async fn save_remote_plugin_share_creates_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap(); - let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); - let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len(); + let plugin_path = + AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap(); + let archive_size = archive_plugin_for_upload(plugin_path.as_path()) + .unwrap() + .len(); let server = MockServer::start().await; let config = test_config(&server); let auth = test_auth(); @@ -178,6 +214,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { let result = save_remote_plugin_share( &config, Some(&auth), + codex_home.path(), &plugin_path, /*remote_plugin_id*/ None, ) @@ -191,6 +228,10 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), } ); + assert_eq!( + local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(), + BTreeMap::from([("plugins_123".to_string(), plugin_path)]) + ); let requests = server.received_requests().await.unwrap_or_default(); let upload_request = requests @@ -261,9 +302,13 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() { #[tokio::test] async fn save_remote_plugin_share_updates_existing_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap(); - let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); - let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len(); + let plugin_path = + AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap(); + let archive_size = archive_plugin_for_upload(plugin_path.as_path()) + .unwrap() + .len(); let server = MockServer::start().await; let config = test_config(&server); let auth = test_auth(); @@ -303,9 +348,15 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { .mount(&server) .await; - let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123")) - .await - .unwrap(); + let result = save_remote_plugin_share( + &config, + Some(&auth), + codex_home.path(), + &plugin_path, + Some("plugins_123"), + ) + .await + .unwrap(); assert_eq!( result, @@ -318,6 +369,10 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { + let codex_home = TempDir::new().unwrap(); + let local_plugin_path = + AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap(); + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path); let server = MockServer::start().await; let config = test_config(&server); let auth = test_auth(); @@ -332,7 +387,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { )) .and(query_param_is_missing("pageToken")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_plugin_json("plugins_123")], + "plugins": [remote_plugin_json_with_share_url( + "plugins_123", + Some("https://chatgpt.example/plugins/share/share-key-1"), + )], "pagination": { "next_page_token": "page-2" }, @@ -350,7 +408,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { )) .and(query_param("pageToken", "page-2")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_plugin_json("plugins_456")], + "plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)], "pagination": empty_pagination_json(), }))) .expect(1) @@ -367,32 +425,40 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { .mount(&server) .await; - let result = list_remote_plugin_shares(&config, Some(&auth)) + let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path()) .await .unwrap(); assert_eq!( result, vec![ - RemotePluginSummary { - id: "plugins_123".to_string(), - name: "demo-plugin".to_string(), - installed: false, - enabled: false, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: Some(expected_plugin_interface()), + RemotePluginShareSummary { + summary: RemotePluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + }, + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + local_plugin_path: Some(local_plugin_path), }, - RemotePluginSummary { - id: "plugins_456".to_string(), - name: "demo-plugin".to_string(), - installed: true, - enabled: true, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: Some(expected_plugin_interface()), + RemotePluginShareSummary { + summary: RemotePluginSummary { + id: "plugins_456".to_string(), + name: "demo-plugin".to_string(), + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: Some(expected_plugin_interface()), + }, + share_url: None, + local_plugin_path: None, } ] ); @@ -400,6 +466,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { #[tokio::test] async fn delete_remote_plugin_share_deletes_workspace_plugin() { + let codex_home = TempDir::new().unwrap(); + let local_plugin_path = + AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap(); + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path); let server = MockServer::start().await; let config = test_config(&server); let auth = test_auth(); @@ -413,7 +483,11 @@ async fn delete_remote_plugin_share_deletes_workspace_plugin() { .mount(&server) .await; - delete_remote_plugin_share(&config, Some(&auth), "plugins_123") + delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123") .await .unwrap(); + assert_eq!( + local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(), + BTreeMap::new() + ); }