diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 36596807b..0bef76c74 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2144,6 +2144,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "properties": { "cwds": { @@ -2155,6 +2163,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "type": "object" 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 acb4b16d9..24b8ea9cc 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 @@ -12353,6 +12353,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12365,6 +12373,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/v2/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", @@ -12481,6 +12499,29 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12845,6 +12886,17 @@ "name": { "type": "string" }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/v2/PluginSource" } 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 ded3dc2b8..76d81c729 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 @@ -8964,6 +8964,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8976,6 +8984,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", @@ -9092,6 +9110,29 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9456,6 +9497,17 @@ "name": { "type": "string" }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 27ea8c4df..65b1b4e88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -4,6 +4,14 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" } }, "properties": { @@ -16,6 +24,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", 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 bf1ce4575..a3ecfd3d4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -232,6 +232,29 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginSource": { "oneOf": [ { @@ -357,6 +380,17 @@ "name": { "type": "string" }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } 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 c1736cdcc..b1ffd2f4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -251,6 +251,29 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginSource": { "oneOf": [ { @@ -376,6 +399,17 @@ "name": { "type": "string" }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } 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 bae3eb034..051918f71 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,29 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -317,6 +340,17 @@ "name": { "type": "string" }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts new file mode 100644 index 000000000..6ff6161f3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -0,0 +1,5 @@ +// 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. + +export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index dcf23796d..6dd86b8a4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -2,10 +2,16 @@ // 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 { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type PluginListParams = { /** * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + * the default remote catalog when enabled by feature flag. + */ +marketplaceKinds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts new file mode 100644 index 000000000..f8a8a5e0d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -0,0 +1,5 @@ +// 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. + +export type PluginShareContext = { remotePluginId: string, creatorAccountUserId: string | null, creatorName: string | 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 8a760e2e5..d855f3d31 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -5,9 +5,14 @@ import type { PluginAuthPolicy } from "./PluginAuthPolicy"; import type { PluginAvailability } from "./PluginAvailability"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; import type { PluginInterface } from "./PluginInterface"; +import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +export type PluginSummary = { id: string, name: string, +/** + * Remote sharing context associated with this plugin when available. + */ +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, /** * Availability state for installing and using the plugin. */ 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 6901e2a04..e624d704e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -278,11 +278,13 @@ export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; export type { PluginInterface } from "./PluginInterface"; +export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type { PluginListParams } from "./PluginListParams"; export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareContext } from "./PluginShareContext"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; 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 3a7651724..b2d5d85c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -132,6 +132,24 @@ pub struct PluginListParams { /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] pub cwds: Option>, + /// Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + /// the default remote catalog when enabled by feature flag. + #[ts(optional = nullable)] + pub marketplace_kinds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginListMarketplaceKind { + #[serde(rename = "local")] + #[ts(rename = "local")] + Local, + #[serde(rename = "workspace-directory")] + #[ts(rename = "workspace-directory")] + WorkspaceDirectory, + #[serde(rename = "shared-with-me")] + #[ts(rename = "shared-with-me")] + SharedWithMe, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -501,6 +519,8 @@ pub enum PluginAvailability { pub struct PluginSummary { pub id: String, pub name: String, + /// Remote sharing context associated with this plugin when available. + pub share_context: Option, pub source: PluginSource, pub installed: bool, pub enabled: bool, @@ -514,6 +534,15 @@ pub struct PluginSummary { pub keywords: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareContext { + pub remote_plugin_id: String, + pub creator_account_user_id: Option, + pub creator_name: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 9b82120c9..ba6f4e0ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2843,7 +2843,33 @@ fn plugin_list_params_ignore_removed_force_remote_sync_field() { "forceRemoteSync": true, })) .unwrap(), - PluginListParams { cwds: None }, + PluginListParams { + cwds: None, + marketplace_kinds: None, + }, + ); +} + +#[test] +fn plugin_list_params_serializes_marketplace_kind_filter() { + assert_eq!( + serde_json::to_value(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![ + PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::WorkspaceDirectory, + PluginListMarketplaceKind::SharedWithMe, + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "marketplaceKinds": [ + "local", + "workspace-directory", + "shared-with-me", + ], + }), ); } @@ -3099,6 +3125,7 @@ fn plugin_share_list_response_serializes_share_items() { plugin: PluginSummary { id: "plugins~Plugin_00000000000000000000000000000000".to_string(), name: "gmail".to_string(), + share_context: None, source: PluginSource::Remote, installed: false, enabled: false, @@ -3118,6 +3145,7 @@ fn plugin_share_list_response_serializes_share_items() { "plugin": { "id": "plugins~Plugin_00000000000000000000000000000000", "name": "gmail", + "shareContext": null, "source": { "type": "remote" }, "installed": false, "enabled": false, @@ -3149,6 +3177,7 @@ fn plugin_summary_defaults_missing_availability_to_available() { .unwrap(); assert_eq!(summary.availability, PluginAvailability::Available); + assert_eq!(summary.share_context, None); } #[test] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 48e0c6dea..84e16f726 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -109,11 +109,13 @@ use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareDiscoverability; @@ -302,9 +304,11 @@ use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_core_plugins::remote::RemoteMarketplace; +use codex_core_plugins::remote::RemoteMarketplaceSource; 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::RemotePluginShareContext as RemoteCatalogPluginShareContext; use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 7d542edf3..bb1ebb611 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -86,6 +86,35 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS } } +fn load_shared_plugin_ids_by_local_path( + config: &Config, +) -> std::collections::BTreeMap { + codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path( + config.codex_home.as_path(), + ) + .unwrap_or_else(|err| { + warn!("failed to load plugin share local path mapping: {err}"); + std::collections::BTreeMap::new() + }) +} + +fn share_context_for_source( + source: &MarketplacePluginSource, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> Option { + match source { + MarketplacePluginSource::Local { path } => shared_plugin_ids_by_local_path + .get(path) + .cloned() + .map(|remote_plugin_id| PluginShareContext { + remote_plugin_id, + creator_account_user_id: None, + creator_name: None, + }), + MarketplacePluginSource::Git { .. } => None, + } +} + fn remote_plugin_share_discoverability( discoverability: PluginShareDiscoverability, ) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { @@ -320,8 +349,15 @@ impl PluginRequestProcessor { params: PluginListParams, ) -> Result { let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { cwds } = params; + let PluginListParams { + cwds, + marketplace_kinds, + } = params; let roots = cwds.unwrap_or_default(); + let explicit_marketplace_kinds = marketplace_kinds.is_some(); + let marketplace_kinds = + marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]); + let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local); let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let empty_response = || PluginListResponse { @@ -340,80 +376,109 @@ impl PluginRequestProcessor { return Ok(empty_response()); } let plugins_input = config.plugins_config_input(); - plugins_manager.maybe_start_plugin_list_background_tasks_for_config( - &plugins_input, - auth.clone(), - &roots, - Some(self.effective_plugins_changed_callback()), - ); + let (mut data, marketplace_load_errors) = if include_local { + plugins_manager.maybe_start_plugin_list_background_tasks_for_config( + &plugins_input, + auth.clone(), + &roots, + Some(self.effective_plugins_changed_callback()), + ); - let config_for_marketplace_listing = plugins_input.clone(); - let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { - let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; - Ok::< - ( - Vec, - Vec, - ), - MarketplaceError, - >(( - outcome - .marketplaces - .into_iter() - .map(|marketplace| PluginMarketplaceEntry { - name: marketplace.name, - path: Some(marketplace.path), - interface: marketplace.interface.map(|interface| MarketplaceInterface { - display_name: interface.display_name, - }), - plugins: marketplace - .plugins - .into_iter() - .map(|plugin| PluginSummary { - id: plugin.id, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: plugin.interface.map(local_plugin_interface_to_info), - keywords: plugin.keywords, - }) - .collect(), - }) - .collect(), - outcome - .errors - .into_iter() - .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { - marketplace_path: err.path, - message: err.message, - }) - .collect(), - )) - }) - .await - { - Ok(Ok(outcome)) => outcome, - Ok(Err(err)) => return Err(Self::marketplace_error(err, "list marketplace plugins")), - Err(err) => { - return Err(internal_error(format!( - "failed to list marketplace plugins: {err}" - ))); + let config_for_marketplace_listing = plugins_input.clone(); + let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .map(|marketplace| PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins: marketplace + .plugins + .into_iter() + .map(|plugin| { + let share_context = share_context_for_source( + &plugin.source, + &shared_plugin_ids_by_local_path, + ); + PluginSummary { + id: plugin.id, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin + .interface + .map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } + }) + .collect(), + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => outcome, + Ok(Err(err)) => { + return Err(Self::marketplace_error(err, "list marketplace plugins")); + } + Err(err) => { + return Err(internal_error(format!( + "failed to list marketplace plugins: {err}" + ))); + } } + } else { + (Vec::new(), Vec::new()) }; - if config.features.enabled(Feature::RemotePlugin) { + let mut remote_sources = Vec::new(); + if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { + remote_sources.push(RemoteMarketplaceSource::Global); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { + remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { + remote_sources.push(RemoteMarketplaceSource::SharedWithMe); + } + if !remote_sources.is_empty() { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; match codex_core_plugins::remote::fetch_remote_marketplaces( &remote_plugin_service_config, auth.as_ref(), + &remote_sources, ) .await { @@ -509,6 +574,11 @@ impl PluginRequestProcessor { .read_plugin_for_config(&plugins_input, &request) .await .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config); + let share_context = share_context_for_source( + &outcome.plugin.source, + &shared_plugin_ids_by_local_path, + ); let environment_manager = self.thread_manager.environment_manager(); let app_summaries = load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager) @@ -530,6 +600,7 @@ impl PluginRequestProcessor { summary: PluginSummary { id: outcome.plugin.id, name: outcome.plugin.name, + share_context, source: marketplace_plugin_source_to_info(outcome.plugin.source), installed: outcome.plugin.installed, enabled: outcome.plugin.enabled, @@ -549,9 +620,7 @@ impl PluginRequestProcessor { } } Err(remote_marketplace_name) => { - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin read is not enabled for marketplace {remote_marketplace_name}" ))); @@ -598,9 +667,7 @@ impl PluginRequestProcessor { } = params; let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin skill read is not enabled for marketplace {remote_marketplace_name}" ))); @@ -778,9 +845,7 @@ impl PluginRequestProcessor { &self, ) -> Result<(Config, Option), JSONRPCErrorError> { let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !config.features.enabled(Feature::Plugins) { return Err(invalid_request("plugin sharing is not enabled")); } let auth = self.auth_manager.auth().await; @@ -873,9 +938,7 @@ impl PluginRequestProcessor { remote_plugin_id: String, ) -> Result { let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin install is not enabled for marketplace {remote_marketplace_name}" ))); @@ -1222,9 +1285,7 @@ impl PluginRequestProcessor { plugin_id: String, ) -> Result { let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { + if !config.features.enabled(Feature::Plugins) { return Err(invalid_request("remote plugin uninstall is not enabled")); } validate_remote_plugin_id(&plugin_id)?; @@ -1384,6 +1445,9 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS PluginSummary { id: summary.id, name: summary.name, + share_context: summary + .share_context + .map(remote_plugin_share_context_to_info), source: PluginSource::Remote, installed: summary.installed, enabled: summary.enabled, @@ -1395,6 +1459,16 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS } } +fn remote_plugin_share_context_to_info( + context: RemoteCatalogPluginShareContext, +) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: context.remote_plugin_id, + creator_account_user_id: context.creator_account_user_id, + creator_name: context.creator_name, + } +} + fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index e63aad9da..f5f74c023 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -155,7 +155,10 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2b2f78136..6adcd9219 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -153,8 +153,14 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { } #[tokio::test] -async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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 e82ebe9fe..176aeebee 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,6 +9,7 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; @@ -90,6 +91,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -218,6 +220,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ AbsolutePathBuf::try_from(valid_repo_root.path())?, AbsolutePathBuf::try_from(invalid_repo_root.path())?, ]), + marketplace_kinds: None, }) .await?; @@ -237,6 +240,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), name: "valid-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: valid_plugin_path, }, @@ -328,6 +332,7 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -419,6 +424,7 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -502,6 +508,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -522,6 +529,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "valid-plugin@alternate-marketplace".to_string(), name: "valid-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: valid_plugin_path, }, @@ -554,6 +562,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), name: "missing-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: AbsolutePathBuf::try_from( repo_root.path().join("plugins/missing-plugin"), @@ -606,7 +615,10 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -618,6 +630,73 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + write_plugin_share_local_path_mapping( + codex_home.path(), + "plugins_123", + &AbsolutePathBuf::try_from(plugin_root)?, + )?; + + 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: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + 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.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + Ok(()) +} + #[tokio::test] async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -677,6 +756,7 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -833,6 +913,7 @@ enabled = false AbsolutePathBuf::try_from(workspace_enabled.path())?, AbsolutePathBuf::try_from(workspace_default.path())?, ]), + marketplace_kinds: None, }) .await?; @@ -916,6 +997,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -1028,6 +1110,7 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -1101,7 +1184,10 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) .await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1234,7 +1320,10 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1391,7 +1480,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1445,6 +1537,208 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Ok(()) } +#[tokio::test] +async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit() +-> Result<()> { + let codex_home = 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, + )?; + + 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::Local]), + }) + .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!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "chatgpt-global") + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + 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, + )?; + + let workspace_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + /*enabled*/ Some(false), + ); + mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + 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::WorkspaceDirectory]), + }) + .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.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-directory"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Workspace Directory") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].name, "workspace-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query() + .is_some_and(|query| query.contains("scope=GLOBAL"))) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + 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, + )?; + + let shared_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + /*enabled*/ Some(true), + ); + mount_shared_workspace_plugins(&server, &shared_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + 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.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "shared-with-me"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].name, "shared-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, true); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_22222222222222222222222222222222" + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let codex_home = TempDir::new()?; @@ -1550,7 +1844,10 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1675,7 +1972,10 @@ async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_nam timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1731,7 +2031,10 @@ remote_plugin = true timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1764,7 +2067,10 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1801,7 +2107,10 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1902,6 +2211,17 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/shared")) + .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_string(body)) + .mount(server) + .await; +} + async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) @@ -1923,6 +2243,44 @@ fn empty_remote_installed_plugins_body() -> &'static str { }"# } +fn workspace_remote_plugin_page_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + enabled: Option, +) -> String { + let enabled_field = enabled + .map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#)) + .unwrap_or_default(); + format!( + r#"{{ + "plugins": [ + {{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "WORKSPACE", + "creator_account_user_id": "user-gavin__account-123", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "creator_name": "Gavin", + "release": {{ + "display_name": "{display_name}", + "description": "Track work", + "app_ids": [], + "interface": {{}}, + "skills": [] + }}{enabled_field} + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + fn remote_installed_plugin_body( bundle_download_url: &str, release_version: &str, @@ -2121,3 +2479,24 @@ fn write_openai_curated_marketplace( )?; Ok(()) } + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::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(&serde_json::json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} 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 bb42714d3..53a3b3d29 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -116,8 +116,74 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_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 +"#, + 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 detail_body = r#"{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": [] + }, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -125,23 +191,121 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() .send_plugin_read_request(PluginReadParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "sample-plugin".to_string(), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; - let err = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let response: PluginReadResponse = to_response(response)?; - assert_eq!(err.error.code, -32600); - assert!( - err.error - .message - .contains("remote plugin read is not enabled") + assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!( + response.plugin.summary.id, + "plugins~Plugin_00000000000000000000000000000000" ); - assert!(err.error.message.contains("chatgpt-global")); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.share_context, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<()> { + let codex_home = 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, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_11111111111111111111111111111111", + "name": "shared-linear", + "scope": "WORKSPACE", + "creator_account_user_id": "user-gavin__account-123", + "creator_name": "Gavin", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Shared Linear", + "description": "Track shared work", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .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_string(installed_body)) + .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: None, + remote_marketplace_name: Some("shared-with-me".to_string()), + plugin_name: "plugins~Plugin_11111111111111111111111111111111".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)?; + + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_11111111111111111111111111111111" + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); Ok(()) } @@ -553,6 +717,59 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + 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"}"#, + )?; + write_plugins_enabled_config(&codex_home)?; + 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)?; + + 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)?; + + 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.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> { let codex_home = TempDir::new()?; @@ -1348,3 +1565,24 @@ fn write_plugin_source( )?; Ok(()) } + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::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)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} 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 8175ecd45..a59183c1d 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::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; @@ -161,6 +162,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { plugin: PluginSummary { id: "plugins_123".to_string(), name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -385,6 +387,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { plugin: PluginSummary { id: "plugins_123".to_string(), name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -565,6 +568,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { plugin: PluginSummary { id: "plugins_123".to_string(), name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -655,6 +659,14 @@ fn expected_plugin_interface() -> PluginInterface { } } +fn expected_share_context(plugin_id: &str) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: plugin_id.to_string(), + creator_account_user_id: None, + creator_name: None, + } +} + fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result { let plugin_path = root.join(plugin_name); write_file( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index c47a6aac9..5679234d2 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -153,8 +153,14 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { } #[tokio::test] -async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -285,7 +291,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> let workspace_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-workspace/linear"); + .join("plugins/cache/workspace-directory/linear"); std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"), @@ -363,7 +369,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<( let remote_plugin_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-workspace/skill-improver"); + .join("plugins/cache/workspace-directory/skill-improver"); std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 94bcc08fa..88960ec85 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -299,7 +299,10 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result } let plugin_list_request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let plugin_list_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 397f0ad74..3a17048b4 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -38,13 +38,16 @@ pub use share::RemotePluginShareTarget; pub use share::RemotePluginShareUpdateTargetsResult; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; +pub use share::load_plugin_share_remote_ids_by_local_path; pub use share::save_remote_plugin_share; pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; -pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; +pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; +pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "shared-with-me"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; -pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins"; +pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; +pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; @@ -63,6 +66,13 @@ pub struct RemoteMarketplace { pub plugins: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteMarketplaceSource { + Global, + WorkspaceDirectory, + SharedWithMe, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemoteInstalledPlugin { pub marketplace_name: String, @@ -75,6 +85,7 @@ pub struct RemoteInstalledPlugin { pub struct RemotePluginSummary { pub id: String, pub name: String, + pub share_context: Option, pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, @@ -84,6 +95,13 @@ pub struct RemotePluginSummary { pub keywords: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareContext { + pub remote_plugin_id: String, + pub creator_account_user_id: Option, + pub creator_name: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, @@ -237,10 +255,6 @@ enum RemotePluginScope { } impl RemotePluginScope { - fn all() -> [Self; 2] { - [Self::Global, Self::Workspace] - } - fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", @@ -265,7 +279,9 @@ impl RemotePluginScope { fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), - REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace), + REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => { + Some(Self::Workspace) + } _ => None, } } @@ -342,6 +358,10 @@ struct RemotePluginDirectoryItem { name: String, scope: RemotePluginScope, #[serde(default)] + creator_account_user_id: Option, + #[serde(default)] + creator_name: Option, + #[serde(default)] share_url: Option, installation_policy: PluginInstallPolicy, authentication_policy: PluginAuthPolicy, @@ -380,101 +400,119 @@ struct RemotePluginMutationResponse { pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, + sources: &[RemoteMarketplaceSource], ) -> Result, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; - let mut directory_by_scope = - BTreeMap::>::new(); - let mut installed_by_scope = - BTreeMap::>::new(); - - let global = async { - let scope = RemotePluginScope::Global; - let (directory_plugins, installed_plugins) = tokio::try_join!( - fetch_directory_plugins_for_scope(config, auth, scope), - fetch_installed_plugins_for_scope(config, auth, scope), - )?; - Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) - }; - let workspace = async { - let scope = RemotePluginScope::Workspace; - let (directory_plugins, installed_plugins) = tokio::try_join!( - fetch_directory_plugins_for_scope(config, auth, scope), - fetch_installed_plugins_for_scope(config, auth, scope), - )?; - Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) - }; - - let (global, workspace) = tokio::try_join!(global, workspace)?; - for (scope, directory_plugins, installed_plugins) in [global, workspace] { - if !directory_plugins.is_empty() { - directory_by_scope.insert( - scope, - directory_plugins - .into_iter() - .map(|plugin| (plugin.id.clone(), plugin)) - .collect(), - ); - } - if !installed_plugins.is_empty() { - installed_by_scope.insert( - scope, - installed_plugins - .into_iter() - .map(|plugin| (plugin.plugin.id.clone(), plugin)) - .collect(), - ); - } - } - let mut marketplaces = Vec::new(); - for scope in RemotePluginScope::all() { - let directory_plugins = directory_by_scope.get(&scope); - let installed_plugins = installed_by_scope.get(&scope); - let plugin_ids = directory_plugins - .into_iter() - .flat_map(|plugins| plugins.keys()) - .chain( - installed_plugins - .into_iter() - .flat_map(|plugins| plugins.keys()), - ) - .cloned() - .collect::>(); - if plugin_ids.is_empty() { - continue; - } + let needs_workspace_installed = sources.iter().any(|source| { + matches!( + source, + RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe + ) + }); + let workspace_installed_plugins = if needs_workspace_installed { + Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?) + } else { + None + }; - let mut plugins = plugin_ids - .into_iter() - .filter_map(|plugin_id| { - let directory_plugin = - directory_plugins.and_then(|plugins| plugins.get(&plugin_id)); - let installed_plugin = - installed_plugins.and_then(|plugins| plugins.get(&plugin_id)); - directory_plugin - .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) - .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) - }) - .collect::>(); - plugins.sort_by(|left, right| { - remote_plugin_display_name(left) - .to_ascii_lowercase() - .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) - .then_with(|| { - remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)) - }) - .then_with(|| left.id.cmp(&right.id)) - }); - marketplaces.push(RemoteMarketplace { - name: scope.marketplace_name().to_string(), - display_name: scope.marketplace_display_name().to_string(), - plugins, - }); + for source in sources { + let marketplace = match source { + RemoteMarketplaceSource::Global => { + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ true, + ) + } + RemoteMarketplaceSource::WorkspaceDirectory => { + let scope = RemotePluginScope::Workspace; + let directory_plugins = + fetch_directory_plugins_for_scope(config, auth, scope).await?; + build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + ) + } + RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME, + fetch_shared_workspace_plugins(config, auth).await?, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + ), + }; + if let Some(marketplace) = marketplace { + marketplaces.push(marketplace); + } } Ok(marketplaces) } +fn build_remote_marketplace( + name: &str, + display_name: &str, + directory_plugins: Vec, + installed_plugins: Vec, + include_installed_only: bool, +) -> Option { + let directory_plugins = directory_plugins + .into_iter() + .map(|plugin| (plugin.id.clone(), plugin)) + .collect::>(); + let installed_plugins = installed_plugins + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect::>(); + let plugin_ids = directory_plugins + .keys() + .chain( + include_installed_only + .then_some(&installed_plugins) + .into_iter() + .flat_map(|plugins| plugins.keys()), + ) + .cloned() + .collect::>(); + if plugin_ids.is_empty() { + return None; + } + + let mut plugins = plugin_ids + .into_iter() + .filter_map(|plugin_id| { + let directory_plugin = directory_plugins.get(&plugin_id); + let installed_plugin = installed_plugins.get(&plugin_id); + directory_plugin + .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) + .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) + }) + .collect::>(); + plugins.sort_by(|left, right| { + remote_plugin_display_name(left) + .to_ascii_lowercase() + .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) + .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) + .then_with(|| left.id.cmp(&right.id)) + }); + Some(RemoteMarketplace { + name: name.to_string(), + display_name: display_name.to_string(), + plugins, + }) +} + pub async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -775,6 +813,7 @@ fn build_remote_plugin_summary( RemotePluginSummary { id: plugin.id.clone(), name: plugin.name.clone(), + share_context: remote_plugin_share_context(plugin), installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, @@ -785,6 +824,19 @@ fn build_remote_plugin_summary( } } +fn remote_plugin_share_context( + plugin: &RemotePluginDirectoryItem, +) -> Option { + match plugin.scope { + RemotePluginScope::Global => None, + RemotePluginScope::Workspace => Some(RemotePluginShareContext { + remote_plugin_id: plugin.id.clone(), + creator_account_user_id: plugin.creator_account_user_id.clone(), + creator_name: plugin.creator_name.clone(), + }), + } +} + fn remote_installed_plugin_to_info( scope: RemotePluginScope, installed_plugin: &RemotePluginInstalledItem, @@ -906,6 +958,24 @@ async fn fetch_directory_plugins_for_scope( Ok(plugins) } +async fn fetch_shared_workspace_plugins( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_shared_workspace_plugins_page(config, auth, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + async fn fetch_installed_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, @@ -961,6 +1031,22 @@ async fn get_remote_plugin_list_page( send_and_decode(request, &url).await } +async fn get_remote_shared_workspace_plugins_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/workspace/shared"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + async fn get_remote_plugin_installed_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 0a254a173..22751a7ad 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -209,6 +209,16 @@ pub async fn list_remote_plugin_shares( .collect()) } +pub fn load_plugin_share_remote_ids_by_local_path( + codex_home: &Path, +) -> io::Result> { + let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?; + Ok(local_paths + .into_iter() + .map(|(remote_plugin_id, local_plugin_path)| (local_plugin_path, remote_plugin_id)) + .collect()) +} + pub async fn delete_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 31ad5dd3f..ea48286ba 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -538,6 +538,11 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { summary: RemotePluginSummary { id: "plugins_123".to_string(), name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_123".to_string(), + creator_account_user_id: None, + creator_name: None, + }), installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, @@ -553,6 +558,11 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { summary: RemotePluginSummary { id: "plugins_456".to_string(), name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_456".to_string(), + creator_account_user_id: None, + creator_name: None, + }), installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 3233db827..82cbd9f30 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -666,6 +666,7 @@ pub(super) async fn fetch_plugins_list( request_id, params: PluginListParams { cwds: Some(vec![cwd]), + marketplace_kinds: None, }, }) .await diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 307e9d7fa..b5a6dad82 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1439,6 +1439,7 @@ pub(super) fn plugins_test_summary( PluginSummary { id: id.to_string(), name: name.to_string(), + share_context: None, source: PluginSource::Local { path: plugins_test_absolute_path(&format!("plugins/{name}")), },