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 9934e36940..16a4e7baa4 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 @@ -12289,6 +12289,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" @@ -12705,6 +12713,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, 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 1926b22091..06b19a0af8 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 @@ -8838,6 +8838,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -9254,6 +9262,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 72b1f7c9aa..d756f61a68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -259,6 +259,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -449,6 +457,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 945b4897cb..0c9f897dc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -313,6 +313,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -503,6 +511,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, 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 ed2beacada..7cffdeab9a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -194,6 +194,14 @@ "remotePluginId": { "type": "string" }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -405,6 +413,14 @@ }, "type": "array" }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts index 86d610bf5a..99b8f46601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -4,4 +4,8 @@ import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; import type { PluginSharePrincipal } from "./PluginSharePrincipal"; -export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; +export type PluginShareContext = { remotePluginId: string, +/** + * Version of the remote shared plugin release when available. + */ +remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index 8fae7ae865..268349cb9b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -12,7 +12,11 @@ export type PluginSummary = { id: string, /** * Backend remote plugin identifier when available. */ -remotePluginId: string | null, name: string, +remotePluginId: string | null, +/** + * Version of the locally materialized plugin package when available. + */ +localVersion: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 82568446b7..2349e8bc21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -540,6 +540,9 @@ pub struct PluginSummary { pub id: String, /// Backend remote plugin identifier when available. pub remote_plugin_id: Option, + /// Version of the locally materialized plugin package when available. + #[serde(default)] + pub local_version: Option, pub name: String, /// Remote sharing context associated with this plugin when available. pub share_context: Option, @@ -561,6 +564,9 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginShareContext { pub remote_plugin_id: String, + /// Version of the remote shared plugin release when available. + #[serde(default)] + pub remote_version: Option, pub discoverability: Option, pub share_url: Option, pub creator_account_user_id: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 73b22cecb4..26cd2b0057 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3007,6 +3007,7 @@ fn plugin_share_list_response_serializes_share_items() { remote_plugin_id: Some( "plugins~Plugin_00000000000000000000000000000000".to_string(), ), + local_version: None, name: "gmail".to_string(), share_context: None, source: PluginSource::Remote, @@ -3027,6 +3028,7 @@ fn plugin_share_list_response_serializes_share_items() { "plugin": { "id": "gmail@chatgpt-global", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "localVersion": null, "name": "gmail", "shareContext": null, "source": { "type": "remote" }, @@ -3059,6 +3061,7 @@ fn plugin_summary_defaults_missing_availability_to_available() { .unwrap(); assert_eq!(summary.availability, PluginAvailability::Available); + assert_eq!(summary.local_version, None); assert_eq!(summary.share_context, None); } diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 125accad82..61336aa713 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -111,6 +111,7 @@ fn share_context_for_source( .cloned() .map(|remote_plugin_id| PluginShareContext { remote_plugin_id, + remote_version: None, discoverability: None, share_url: None, creator_account_user_id: None, @@ -483,6 +484,7 @@ impl PluginRequestProcessor { PluginSummary { id: plugin.id, remote_plugin_id: None, + local_version: plugin.local_version, installed: plugin.installed, enabled: plugin.enabled, name: plugin.name, @@ -533,7 +535,9 @@ impl PluginRequestProcessor { if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); } - if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { + if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) + && config.features.enabled(Feature::PluginSharing) + { remote_sources.push(RemoteMarketplaceSource::SharedWithMe); } if !remote_sources.is_empty() { @@ -673,17 +677,21 @@ impl PluginRequestProcessor { ) .await { - Ok(Some(remote_share_context)) - if remote_share_context.share_principals.is_some() => - { - Some(remote_plugin_share_context_to_info(remote_share_context)) - } - Ok(Some(_)) => { - warn!( - remote_plugin_id = %context.remote_plugin_id, - "remote shared plugin detail did not include share principals; returning local share mapping context" - ); - Some(context) + Ok(Some(remote_share_context)) => { + if remote_share_context.share_principals.is_some() { + Some(remote_plugin_share_context_to_info(remote_share_context)) + } else { + let remote_version = remote_share_context.remote_version; + let remote_plugin_id = context.remote_plugin_id.clone(); + warn!( + remote_plugin_id = %remote_plugin_id, + "remote shared plugin detail did not include share principals; returning local share mapping context with remote version" + ); + Some(PluginShareContext { + remote_version, + ..context + }) + } } Ok(None) => { warn!( @@ -725,6 +733,7 @@ impl PluginRequestProcessor { summary: PluginSummary { id: outcome.plugin.id, remote_plugin_id: None, + local_version: outcome.plugin.local_version, name: outcome.plugin.name, share_context, source: marketplace_plugin_source_to_info(outcome.plugin.source), @@ -840,6 +849,9 @@ impl PluginRequestProcessor { params: PluginShareSaveParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } let PluginShareSaveParams { plugin_path, remote_plugin_id, @@ -1589,6 +1601,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS PluginSummary { id: summary.id, remote_plugin_id: Some(summary.remote_plugin_id), + local_version: None, name: summary.name, share_context: summary .share_context @@ -1609,6 +1622,7 @@ fn remote_plugin_share_context_to_info( ) -> PluginShareContext { PluginShareContext { remote_plugin_id: context.remote_plugin_id, + remote_version: context.remote_version, discoverability: Some(remote_plugin_share_discoverability_to_info( context.discoverability, )), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 6576ba87b9..6bcefbb588 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -241,6 +241,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -531,6 +532,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "valid-plugin@alternate-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -565,6 +567,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), remote_plugin_id: None, + local_version: None, name: "missing-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -660,7 +663,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( )?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"demo-plugin"}"#, + r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; write_plugin_share_local_path_mapping( codex_home.path(), @@ -692,11 +695,13 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( .find(|plugin| plugin.name == "demo-plugin") .expect("expected demo-plugin entry"); assert_eq!(plugin.remote_plugin_id, None); + assert_eq!(plugin.local_version.as_deref(), Some("1.2.3")); let share_context = plugin .share_context .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); assert_eq!(share_context.discoverability, None); assert_eq!(share_context.share_url, None); assert_eq!(share_context.creator_account_user_id, None); @@ -1873,6 +1878,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.remote_plugin_id, "plugins~Plugin_22222222222222222222222222222222" ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Private) @@ -1891,6 +1897,65 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + wait_for_remote_plugin_request_count( + &server, + "/ps/plugins/workspace/shared", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let codex_home = TempDir::new()?; @@ -2434,6 +2499,7 @@ fn workspace_remote_plugin_page_body( }} ], "release": {{ + "version": "1.2.3", "display_name": "{display_name}", "description": "Track work", "app_ids": [], diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 62294b2f95..7c93ac5d8a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -262,6 +262,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "2.3.4", "display_name": "Shared Linear", "description": "Track shared work", "app_ids": [], @@ -330,6 +331,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< share_context.remote_plugin_id, "plugins~Plugin_11111111111111111111111111111111" ); + assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Private) @@ -800,7 +802,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( repo_root .path() .join("demo-plugin/.codex-plugin/plugin.json"), - r#"{"name":"demo-plugin"}"#, + r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; @@ -833,6 +835,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "1.2.4", "display_name": "Demo Plugin", "description": "Shared local plugin", "app_ids": [], @@ -865,6 +868,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); let share_context = response .plugin .summary @@ -872,6 +879,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); assert_eq!( share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) @@ -905,6 +913,107 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( Ok(()) } +#[tokio::test] +async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin","version":"1.2.3"}"#, + )?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "plugins_123", + "name": "demo-plugin", + "scope": "WORKSPACE", + "discoverability": "UNLISTED", + "creator_account_user_id": "user-owner__account-123", + "creator_name": "Owner", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": null, + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.4", + "display_name": "Demo Plugin", + "description": "Shared local plugin", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } + }))) + .expect(1) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); + assert_eq!(share_context.discoverability, None); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + #[tokio::test] async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -941,6 +1050,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!(response.plugin.summary.local_version, None); let share_context = response .plugin .summary @@ -948,6 +1058,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); assert_eq!(share_context.discoverability, None); assert_eq!(share_context.share_url, None); assert_eq!(share_context.creator_account_user_id, None); 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 46f3dceb5e..299c1eff25 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -164,6 +164,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { plugin: PluginSummary { id: "demo-plugin@shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -322,6 +323,64 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "plugin sharing is disabled"); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .is_empty() + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { let codex_home = TempDir::new()?; @@ -509,6 +568,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { plugin: PluginSummary { id: "demo-plugin@shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -729,6 +789,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { plugin: PluginSummary { id: "demo-plugin@shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -786,6 +847,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -838,6 +900,7 @@ fn expected_plugin_interface() -> PluginInterface { fn expected_share_context(plugin_id: &str) -> PluginShareContext { PluginShareContext { remote_plugin_id: plugin_id.to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: Some(PluginShareDiscoverability::Private), share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), creator_account_user_id: None, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 42f5ac73db..2df35b401f 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -222,6 +222,7 @@ pub struct PluginReadOutcome { pub struct PluginDetail { pub id: String, pub name: String, + pub local_version: Option, pub description: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, @@ -260,6 +261,7 @@ pub struct ConfiguredMarketplace { pub struct ConfiguredMarketplacePlugin { pub id: String, pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, @@ -1199,6 +1201,7 @@ impl PluginsManager { let installed = installed_plugins.contains(&plugin_key); let enabled = enabled_plugins.contains(&plugin_key); let mut interface = plugin.interface; + let mut local_version = plugin.local_version; if installed && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) && let Ok(plugin_id) = @@ -1206,6 +1209,7 @@ impl PluginsManager { && let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) { + local_version = manifest.version.clone(); let marketplace_category = interface .as_ref() .and_then(|interface| interface.category.clone()); @@ -1223,6 +1227,7 @@ impl PluginsManager { installed, enabled, name: plugin.name, + local_version, source: plugin.source, policy: plugin.policy, keywords: plugin.keywords, @@ -1273,6 +1278,10 @@ impl PluginsManager { ConfiguredMarketplacePlugin { id: plugin_key.clone(), name: plugin.plugin_id.plugin_name, + local_version: plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()), source: plugin.source, policy: plugin.policy, interface: plugin.interface, @@ -1319,6 +1328,7 @@ impl PluginsManager { return Ok(PluginDetail { id: plugin_key, name: plugin.name, + local_version: None, description: Some(description), source: plugin.source, policy: plugin.policy, @@ -1411,6 +1421,7 @@ impl PluginsManager { Ok(PluginDetail { id: plugin.id, name: plugin.name, + local_version: manifest.version.clone(), description, source: plugin.source, policy: plugin.policy, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index e1c0f1b121..d04852ea13 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1541,6 +1541,7 @@ enabled = false ConfiguredMarketplacePlugin { id: "enabled-plugin@debug".to_string(), name: "enabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) .unwrap(), @@ -1558,6 +1559,7 @@ enabled = false ConfiguredMarketplacePlugin { id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) .unwrap(), @@ -1678,6 +1680,7 @@ plugins = true vec![ConfiguredMarketplacePlugin { id: "default-plugin@debug".to_string(), name: "default-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), }, @@ -2109,6 +2112,7 @@ enabled = true vec![ConfiguredMarketplacePlugin { id: "toolkit@debug".to_string(), name: "toolkit".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: missing_remote_repo_url, path: Some("plugins/toolkit".to_string()), @@ -2225,6 +2229,7 @@ plugins = true plugins: vec![ConfiguredMarketplacePlugin { id: "linear@openai-curated".to_string(), name: "linear".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), }, @@ -2519,6 +2524,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "dup-plugin@debug".to_string(), name: "dup-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), }, @@ -2549,6 +2555,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "b-only-plugin@debug".to_string(), name: "b-only-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), }, @@ -2633,6 +2640,7 @@ enabled = true plugins: vec![ConfiguredMarketplacePlugin { id: "sample-plugin@debug".to_string(), name: "sample-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), }, diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index f66b5d1b22..15959a7b55 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -59,6 +59,7 @@ pub struct MarketplaceInterface { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, @@ -289,15 +290,22 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result return Err(err), }; + let local_version = plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()); + let keywords = plugin + .manifest + .map(|manifest| manifest.keywords) + .unwrap_or_default(); + plugins.push(MarketplacePlugin { name: plugin.plugin_id.plugin_name, + local_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, - keywords: plugin - .manifest - .map(|manifest| manifest.keywords) - .unwrap_or_default(), + keywords, }); } diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 2e93df375a..e134157177 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -388,6 +388,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { interface: None, plugins: vec![MarketplacePlugin { name: "string-source-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin")) .unwrap(), @@ -453,6 +454,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { interface: None, plugins: vec![MarketplacePlugin { name: "missing-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),) .unwrap(), @@ -595,6 +597,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(), }, @@ -608,6 +611,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, MarketplacePlugin { name: "home-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(), }, @@ -630,6 +634,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(), }, @@ -643,6 +648,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { }, MarketplacePlugin { name: "repo-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(), }, @@ -721,6 +727,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), }, @@ -739,6 +746,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), }, @@ -813,6 +821,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), }, @@ -976,6 +985,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { interface: None, plugins: vec![MarketplacePlugin { name: "valid-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(), }, @@ -1093,6 +1103,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { vec![ MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin")) .unwrap(), @@ -1107,6 +1118,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, MarketplacePlugin { name: "url-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/example/plugin.git".to_string(), path: None, @@ -1123,6 +1135,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { }, MarketplacePlugin { name: "git-subdir-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/owner/repo.git".to_string(), path: Some("plugins/example".to_string()), diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index bb0e13a79d..6822e6e197 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -102,6 +102,7 @@ pub struct RemotePluginSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareContext { pub remote_plugin_id: String, + pub remote_version: Option, pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option, pub creator_account_user_id: Option, @@ -897,6 +898,7 @@ fn remote_plugin_share_context( let discoverability = workspace_plugin_discoverability(plugin)?; Ok(Some(RemotePluginShareContext { remote_plugin_id: plugin.id.clone(), + remote_version: plugin.release.version.clone(), discoverability, share_url: plugin.share_url.clone(), creator_account_user_id: plugin.creator_account_user_id.clone(), diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 8b27b28c3f..23bcfbfb06 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -97,6 +97,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -590,6 +591,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_123".to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: RemotePluginShareDiscoverability::Private, share_url: Some( "https://chatgpt.example/plugins/share/share-key-1".to_string(), @@ -628,6 +630,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_456".to_string(), + remote_version: Some("0.1.0".to_string()), discoverability: RemotePluginShareDiscoverability::Private, share_url: None, creator_account_user_id: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b6e25638ad..7601f01377 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -496,6 +496,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -4173,6 +4176,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index db2dcfead7..8af3033ae3 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -188,6 +188,8 @@ pub enum Feature { ComputerUse, /// Temporary internal-only flag for PS-backed remote plugin catalog development. RemotePlugin, + /// Enable remote plugin sharing flows. + PluginSharing, /// Show the startup prompt for migrating external agent config into Codex. ExternalMigration, /// Allow the model to invoke the built-in image generation tool. @@ -1007,6 +1009,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::PluginSharing, + key: "plugin_sharing", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ExternalMigration, key: "external_migration", diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f5007e3faf..28ed69139b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1315,6 +1315,7 @@ pub(super) fn plugins_test_summary( PluginSummary { id: id.to_string(), remote_plugin_id: None, + local_version: None, name: name.to_string(), share_context: None, source: PluginSource::Local {