diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index de908f05fce3..7f338adf6e5d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1474,6 +1474,7 @@ dependencies = [ "codex-model-provider-info", "codex-models-manager", "codex-otel", + "codex-plugin", "codex-protocol", "codex-rmcp-client", "codex-rollout", 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 d1518ce28f16..b60efc70cf6d 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 @@ -10965,7 +10965,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -10986,7 +10993,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -13356,7 +13362,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -13368,8 +13381,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" }, 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 82c990533ac3..6ae8703cd24e 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 @@ -7727,7 +7727,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -7748,7 +7755,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -11250,7 +11256,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -11262,8 +11275,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" }, 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 5ec07f00f117..6e04ef74b00f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -62,7 +62,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -83,7 +90,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -423,7 +429,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -435,8 +448,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 4bfd35fe7095..eb0f38caa6a1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -6,4 +6,4 @@ import type { AppSummary } from "./AppSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts index 05aa4031a85e..4999a0728ac1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts @@ -4,4 +4,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { SkillInterface } from "./SkillInterface"; -export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf, enabled: boolean, }; +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf | null, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7162eb4deee..dc7178fa190a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4018,7 +4018,7 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginDetail { pub marketplace_name: String, - pub marketplace_path: AbsolutePathBuf, + pub marketplace_path: Option, pub summary: PluginSummary, pub description: Option, pub skills: Vec, @@ -4034,7 +4034,7 @@ pub struct SkillSummary { pub description: String, pub short_description: Option, pub interface: Option, - pub path: AbsolutePathBuf, + pub path: Option, pub enabled: bool, } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index bec863eb6ad1..339bc20f10f0 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -40,6 +40,7 @@ codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-git-utils = { workspace = true } codex-otel = { workspace = true } +codex-plugin = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-pty = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8f31288cd54d..1890ced65616 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; @@ -260,6 +261,11 @@ use codex_core_plugins::loader::load_plugin_mcp_servers; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_core_plugins::remote::RemoteMarketplace; +use codex_core_plugins::remote::RemotePluginCatalogError; +use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; @@ -373,6 +379,7 @@ use codex_app_server_protocol::ServerRequest; mod apps_list_helpers; mod plugin_app_helpers; mod plugin_mcp_oauth; +mod plugins; mod token_usage_replay; use crate::filters::compute_source_filters; @@ -6522,7 +6529,6 @@ impl CodexMessageProcessor { .send_response(request_id, SkillsListResponse { data }) .await; } - async fn marketplace_remove( &self, request_id: ConnectionRequestId, @@ -6556,120 +6562,6 @@ impl CodexMessageProcessor { } } } - - async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { - let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { cwds } = params; - let roots = cwds.unwrap_or_default(); - plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); - - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - - let config_for_marketplace_listing = config.clone(); - let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let (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(), - interface: plugin.interface.map(local_plugin_interface_to_info), - }) - .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)) => { - self.send_marketplace_error(request_id, err, "list marketplace plugins") - .await; - return; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to list marketplace plugins: {err}"), - ) - .await; - return; - } - }; - - let featured_plugin_ids = if data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - { - match plugins_manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) - .await - { - Ok(featured_plugin_ids) => featured_plugin_ids, - Err(err) => { - warn!( - error = %err, - "plugin/list featured plugin fetch failed; returning empty featured ids" - ); - Vec::new() - } - } - } else { - Vec::new() - }; - - self.outgoing - .send_response( - request_id, - PluginListResponse { - marketplaces: data, - marketplace_load_errors, - featured_plugin_ids, - }, - ) - .await; - } - async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) { let result = add_marketplace_to_codex_home( self.config.codex_home.to_path_buf(), @@ -6703,111 +6595,6 @@ impl CodexMessageProcessor { } } - async fn plugin_read(&self, request_id: ConnectionRequestId, params: PluginReadParams) { - let plugins_manager = self.thread_manager.plugins_manager(); - let PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin read is not supported yet for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; - } - (Some(_), Some(_)) | (None, None) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), - data: None, - }, - ) - .await; - return; - } - }; - let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - - let config = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - - let request = PluginReadRequest { - plugin_name, - marketplace_path, - }; - let outcome = match plugins_manager - .read_plugin_for_config(&config, &request) - .await - { - Ok(outcome) => outcome, - Err(err) => { - self.send_marketplace_error(request_id, err, "read plugin details") - .await; - return; - } - }; - let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = plugin_app_helpers::load_plugin_app_summaries( - &config, - &outcome.plugin.apps, - &environment_manager, - ) - .await; - let visible_skills = outcome - .plugin - .skills - .iter() - .filter(|skill| { - skill.matches_product_restriction_for_product( - self.thread_manager.session_source().restriction_product(), - ) - }) - .cloned() - .collect::>(); - let plugin = PluginDetail { - marketplace_name: outcome.marketplace_name, - marketplace_path: outcome.marketplace_path, - summary: PluginSummary { - id: outcome.plugin.id, - name: outcome.plugin.name, - source: marketplace_plugin_source_to_info(outcome.plugin.source), - installed: outcome.plugin.installed, - enabled: outcome.plugin.enabled, - install_policy: outcome.plugin.policy.installation.into(), - auth_policy: outcome.plugin.policy.authentication.into(), - interface: outcome.plugin.interface.map(local_plugin_interface_to_info), - }, - description: outcome.plugin.description, - skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), - apps: app_summaries, - mcp_servers: outcome.plugin.mcp_server_names, - }; - - self.outgoing - .send_response(request_id, PluginReadResponse { plugin }) - .await; - } - async fn skills_config_write( &self, request_id: ConnectionRequestId, @@ -6866,260 +6653,6 @@ impl CodexMessageProcessor { } } - async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { - let PluginInstallParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin install is not supported yet for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; - } - (Some(_), Some(_)) | (None, None) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), - data: None, - }, - ) - .await; - return; - } - }; - let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - - let plugins_manager = self.thread_manager.plugins_manager(); - let request = PluginInstallRequest { - plugin_name, - marketplace_path, - }; - - let install_result = plugins_manager.install_plugin(request).await; - - match install_result { - Ok(result) => { - let config = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - warn!( - "failed to reload config after plugin install, using current config: {err:?}" - ); - self.config.as_ref().clone() - } - }; - - self.clear_plugin_related_caches(); - - let plugin_mcp_servers = - load_plugin_mcp_servers(result.installed_path.as_path()).await; - - if !plugin_mcp_servers.is_empty() { - if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { - warn!( - plugin = result.plugin_id.as_key(), - "failed to queue MCP refresh after plugin install: {err:?}" - ); - } - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; - } - - let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; - let auth = self.auth_manager.auth().await; - let apps_needing_auth = if plugin_apps.is_empty() - || !config.features.apps_enabled_for_auth( - auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - ) { - Vec::new() - } else { - let environment_manager = self.thread_manager.environment_manager(); - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, /*force_refetch*/ true, &environment_manager - ), - ); - - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - warn!( - plugin = result.plugin_id.as_key(), - "failed to load app metadata after plugin install: {err:#}" - ); - connectors::list_cached_all_connectors(&config) - .await - .unwrap_or_default() - } - }; - let all_connectors = - connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps); - let (accessible_connectors, codex_apps_ready) = - match accessible_connectors_result { - Ok(status) => (status.connectors, status.codex_apps_ready), - Err(err) => { - warn!( - plugin = result.plugin_id.as_key(), - "failed to load accessible apps after plugin install: {err:#}" - ); - ( - connectors::list_cached_accessible_connectors_from_mcp_tools( - &config, - ) - .await - .unwrap_or_default(), - false, - ) - } - }; - if !codex_apps_ready { - warn!( - plugin = result.plugin_id.as_key(), - "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" - ); - } - - plugin_app_helpers::plugin_apps_needing_auth( - &all_connectors, - &accessible_connectors, - &plugin_apps, - codex_apps_ready, - ) - }; - - self.outgoing - .send_response( - request_id, - PluginInstallResponse { - auth_policy: result.auth_policy.into(), - apps_needing_auth, - }, - ) - .await; - } - Err(err) => { - if err.is_invalid_request() { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - - match err { - CorePluginInstallError::Marketplace(err) => { - self.send_marketplace_error(request_id, err, "install plugin") - .await; - } - CorePluginInstallError::Config(err) => { - self.send_internal_error( - request_id, - format!("failed to persist installed plugin config: {err}"), - ) - .await; - } - CorePluginInstallError::Remote(err) => { - self.send_internal_error( - request_id, - format!("failed to enable remote plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Join(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Store(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - } - } - } - } - - async fn plugin_uninstall( - &self, - request_id: ConnectionRequestId, - params: PluginUninstallParams, - ) { - let PluginUninstallParams { plugin_id } = params; - let plugins_manager = self.thread_manager.plugins_manager(); - - let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; - - match uninstall_result { - Ok(()) => { - self.clear_plugin_related_caches(); - self.outgoing - .send_response(request_id, PluginUninstallResponse {}) - .await; - } - Err(err) => { - if err.is_invalid_request() { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - - match err { - CorePluginUninstallError::Config(err) => { - self.send_internal_error( - request_id, - format!("failed to clear plugin config: {err}"), - ) - .await; - } - CorePluginUninstallError::Remote(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall remote plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::Join(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::Store(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::InvalidPluginId(_) => { - unreachable!("invalid plugin ids are handled above"); - } - } - } - } - } - async fn turn_start( &self, request_id: ConnectionRequestId, @@ -9234,7 +8767,7 @@ fn plugin_skills_to_info( default_prompt: interface.default_prompt, } }), - path: skill.path_to_skills_md.clone(), + path: Some(skill.path_to_skills_md.clone()), enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md), }) .collect() diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs new file mode 100644 index 000000000000..e96ef4af30ee --- /dev/null +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -0,0 +1,692 @@ +use super::*; +use codex_plugin::validate_plugin_segment; + +impl CodexMessageProcessor { + pub(super) async fn plugin_list( + &self, + request_id: ConnectionRequestId, + params: PluginListParams, + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginListParams { cwds } = params; + let roots = cwds.unwrap_or_default(); + + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + if !config.features.enabled(Feature::Plugins) { + self.outgoing + .send_response( + request_id, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }, + ) + .await; + return; + } + plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); + let auth = self.auth_manager.auth().await; + + let config_for_marketplace_listing = config.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(), + interface: plugin.interface.map(local_plugin_interface_to_info), + }) + .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)) => { + self.send_marketplace_error(request_id, err, "list marketplace plugins") + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to list marketplace plugins: {err}"), + ) + .await; + return; + } + }; + + if config.features.enabled(Feature::RemotePlugin) { + 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(), + ) + .await + { + Ok(remote_marketplaces) => { + for remote_marketplace in remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + { + if let Some(existing) = data + .iter_mut() + .find(|marketplace| marketplace.name == remote_marketplace.name) + { + *existing = remote_marketplace; + } else { + data.push(remote_marketplace); + } + } + } + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "plugin/list remote plugin catalog fetch failed; returning local marketplaces only" + ); + } + } + } + + let featured_plugin_ids = if data + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + { + match plugins_manager + .featured_plugin_ids_for_config(&config, auth.as_ref()) + .await + { + Ok(featured_plugin_ids) => featured_plugin_ids, + Err(err) => { + warn!( + error = %err, + "plugin/list featured plugin fetch failed; returning empty featured ids" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + self.outgoing + .send_response( + request_id, + PluginListResponse { + marketplaces: data, + marketplace_load_errors, + featured_plugin_ids, + }, + ) + .await; + } + + pub(super) async fn plugin_read( + &self, + request_id: ConnectionRequestId, + params: PluginReadParams, + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let read_source = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => Ok(marketplace_path), + (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; + let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { + marketplace_path.as_path().parent().map(Path::to_path_buf) + }); + + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + + let plugin = match read_source { + Ok(marketplace_path) => { + let request = PluginReadRequest { + plugin_name, + marketplace_path, + }; + let outcome = match plugins_manager + .read_plugin_for_config(&config, &request) + .await + { + Ok(outcome) => outcome, + Err(err) => { + self.send_marketplace_error(request_id, err, "read plugin details") + .await; + return; + } + }; + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = plugin_app_helpers::load_plugin_app_summaries( + &config, + &outcome.plugin.apps, + &environment_manager, + ) + .await; + let visible_skills = outcome + .plugin + .skills + .iter() + .filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }) + .cloned() + .collect::>(); + PluginDetail { + marketplace_name: outcome.marketplace_name, + marketplace_path: outcome.marketplace_path, + summary: PluginSummary { + id: outcome.plugin.id, + name: outcome.plugin.name, + source: marketplace_plugin_source_to_info(outcome.plugin.source), + installed: outcome.plugin.installed, + enabled: outcome.plugin.enabled, + install_policy: outcome.plugin.policy.installation.into(), + auth_policy: outcome.plugin.policy.authentication.into(), + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + }, + description: outcome.plugin.description, + skills: plugin_skills_to_info( + &visible_skills, + &outcome.plugin.disabled_skill_paths, + ), + apps: app_summaries, + mcp_servers: outcome.plugin.mcp_server_names, + } + } + Err(remote_marketplace_name) => { + if !config.features.enabled(Feature::Plugins) + || !config.features.enabled(Feature::RemotePlugin) + { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin read is not enabled for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + if let Err(err) = validate_plugin_segment(&plugin_name, "plugin name") { + self.send_invalid_request_error( + request_id, + format!("invalid remote plugin id: {err}"), + ) + .await; + return; + } + let remote_plugin_id = format!("{plugin_name}@{remote_marketplace_name}"); + let remote_detail = match codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + ) + .await + { + Ok(remote_detail) => remote_detail, + Err(err) => { + self.outgoing + .send_error( + request_id, + remote_plugin_catalog_error_to_jsonrpc( + err, + "read remote plugin details", + ), + ) + .await; + return; + } + }; + let plugin_apps = remote_detail + .app_ids + .iter() + .cloned() + .map(codex_core::plugins::AppConnectorId) + .collect::>(); + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = plugin_app_helpers::load_plugin_app_summaries( + &config, + &plugin_apps, + &environment_manager, + ) + .await; + remote_plugin_detail_to_info(remote_detail, app_summaries) + } + }; + + self.outgoing + .send_response(request_id, PluginReadResponse { plugin }) + .await; + } + + pub(super) async fn plugin_install( + &self, + request_id: ConnectionRequestId, + params: PluginInstallParams, + ) { + let PluginInstallParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin install is not supported yet for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; + let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); + + let plugins_manager = self.thread_manager.plugins_manager(); + let request = PluginInstallRequest { + plugin_name, + marketplace_path, + }; + + let install_result = plugins_manager.install_plugin(request).await; + + match install_result { + Ok(result) => { + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + warn!( + "failed to reload config after plugin install, using current config: {err:?}" + ); + self.config.as_ref().clone() + } + }; + + self.clear_plugin_related_caches(); + + let plugin_mcp_servers = + load_plugin_mcp_servers(result.installed_path.as_path()).await; + + if !plugin_mcp_servers.is_empty() { + if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { + warn!( + plugin = result.plugin_id.as_key(), + "failed to queue MCP refresh after plugin install: {err:?}" + ); + } + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let auth = self.auth_manager.auth().await; + let apps_needing_auth = if plugin_apps.is_empty() + || !config.features.apps_enabled_for_auth( + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + ) { + Vec::new() + } else { + let environment_manager = self.thread_manager.environment_manager(); + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, /*force_refetch*/ true, &environment_manager + ), + ); + + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + warn!( + plugin = result.plugin_id.as_key(), + "failed to load app metadata after plugin install: {err:#}" + ); + connectors::list_cached_all_connectors(&config) + .await + .unwrap_or_default() + } + }; + let all_connectors = + connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps); + let (accessible_connectors, codex_apps_ready) = + match accessible_connectors_result { + Ok(status) => (status.connectors, status.codex_apps_ready), + Err(err) => { + warn!( + plugin = result.plugin_id.as_key(), + "failed to load accessible apps after plugin install: {err:#}" + ); + ( + connectors::list_cached_accessible_connectors_from_mcp_tools( + &config, + ) + .await + .unwrap_or_default(), + false, + ) + } + }; + if !codex_apps_ready { + warn!( + plugin = result.plugin_id.as_key(), + "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" + ); + } + + plugin_app_helpers::plugin_apps_needing_auth( + &all_connectors, + &accessible_connectors, + &plugin_apps, + codex_apps_ready, + ) + }; + + self.outgoing + .send_response( + request_id, + PluginInstallResponse { + auth_policy: result.auth_policy.into(), + apps_needing_auth, + }, + ) + .await; + } + Err(err) => { + if err.is_invalid_request() { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + + match err { + CorePluginInstallError::Marketplace(err) => { + self.send_marketplace_error(request_id, err, "install plugin") + .await; + } + CorePluginInstallError::Config(err) => { + self.send_internal_error( + request_id, + format!("failed to persist installed plugin config: {err}"), + ) + .await; + } + CorePluginInstallError::Remote(err) => { + self.send_internal_error( + request_id, + format!("failed to enable remote plugin: {err}"), + ) + .await; + } + CorePluginInstallError::Join(err) => { + self.send_internal_error( + request_id, + format!("failed to install plugin: {err}"), + ) + .await; + } + CorePluginInstallError::Store(err) => { + self.send_internal_error( + request_id, + format!("failed to install plugin: {err}"), + ) + .await; + } + } + } + } + } + + pub(super) async fn plugin_uninstall( + &self, + request_id: ConnectionRequestId, + params: PluginUninstallParams, + ) { + let PluginUninstallParams { plugin_id } = params; + let plugins_manager = self.thread_manager.plugins_manager(); + + let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; + + match uninstall_result { + Ok(()) => { + self.clear_plugin_related_caches(); + self.outgoing + .send_response(request_id, PluginUninstallResponse {}) + .await; + } + Err(err) => { + if err.is_invalid_request() { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + + match err { + CorePluginUninstallError::Config(err) => { + self.send_internal_error( + request_id, + format!("failed to clear plugin config: {err}"), + ) + .await; + } + CorePluginUninstallError::Remote(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall remote plugin: {err}"), + ) + .await; + } + CorePluginUninstallError::Join(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall plugin: {err}"), + ) + .await; + } + CorePluginUninstallError::Store(err) => { + self.send_internal_error( + request_id, + format!("failed to uninstall plugin: {err}"), + ) + .await; + } + CorePluginUninstallError::InvalidPluginId(_) => { + unreachable!("invalid plugin ids are handled above"); + } + } + } + } + } +} + +fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: marketplace.name, + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(marketplace.display_name), + }), + plugins: marketplace + .plugins + .into_iter() + .map(remote_plugin_summary_to_info) + .collect(), + } +} + +fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { + PluginSummary { + id: summary.id, + name: summary.name, + source: PluginSource::Remote, + installed: summary.installed, + enabled: summary.enabled, + install_policy: summary.install_policy, + auth_policy: summary.auth_policy, + interface: summary.interface, + } +} + +fn remote_plugin_detail_to_info( + detail: RemoteCatalogPluginDetail, + apps: Vec, +) -> PluginDetail { + PluginDetail { + marketplace_name: detail.marketplace_name, + marketplace_path: None, + summary: remote_plugin_summary_to_info(detail.summary), + description: detail.description, + skills: detail + .skills + .into_iter() + .map(|skill| SkillSummary { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface, + path: None, + enabled: skill.enabled, + }) + .collect(), + apps, + mcp_servers: Vec::new(), + } +} + +fn remote_plugin_catalog_error_to_jsonrpc( + err: RemotePluginCatalogError, + context: &str, +) -> JSONRPCErrorError { + match err { + RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + } + } + RemotePluginCatalogError::UnknownMarketplace { .. } + | RemotePluginCatalogError::MarketplaceMismatch { .. } => JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + }, + RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + } + } + RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::Request { .. } + | RemotePluginCatalogError::UnexpectedStatus { .. } + | RemotePluginCatalogError::Decode { .. } => JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + }, + } +} 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 9718761e3359..0c22e8dadb04 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -943,6 +943,336 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> 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 global_directory_body = r#"{ + "plugins": [ + { + "id": "linear@chatgpt-global", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "linear@chatgpt-global", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .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(global_directory_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .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(empty_page_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(global_installed_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(empty_page_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_list_request(PluginListParams { cwds: 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 remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected ChatGPT remote marketplace"); + assert_eq!(remote_marketplace.path, None); + assert_eq!( + remote_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("ChatGPT Plugins") + ); + assert_eq!(remote_marketplace.plugins.len(), 1); + assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global"); + assert_eq!(remote_marketplace.plugins[0].name, "linear"); + assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); + assert_eq!(remote_marketplace.plugins[0].installed, true); + assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0] + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!(response.featured_plugin_ids, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> 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 local_plugin_root = codex_home + .path() + .join(".agents/plugins/plugins/local-linear/.codex-plugin"); + std::fs::create_dir_all(&local_plugin_root)?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "chatgpt-global", + "plugins": [ + { + "name": "local-linear", + "source": { + "source": "local", + "path": "./plugins/local-linear" + } + } + ] +}"#, + )?; + std::fs::write( + local_plugin_root.join("plugin.json"), + r#"{"name":"local-linear"}"#, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "linear@chatgpt-global", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + for (path_suffix, scope, body) in [ + ( + "/backend-api/ps/plugins/list", + "GLOBAL", + global_directory_body, + ), + ("/backend-api/ps/plugins/list", "WORKSPACE", empty_page_body), + ( + "/backend-api/ps/plugins/installed", + "GLOBAL", + empty_page_body, + ), + ( + "/backend-api/ps/plugins/installed", + "WORKSPACE", + empty_page_body, + ), + ] { + Mock::given(method("GET")) + .and(path(path_suffix)) + .and(query_param("scope", scope)) + .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; + } + + 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 }) + .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 matching_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.name == "chatgpt-global") + .collect::>(); + + assert_eq!(matching_marketplaces.len(), 1); + assert_eq!(matching_marketplaces[0].path, None); + assert_eq!(matching_marketplaces[0].plugins.len(), 1); + assert_eq!( + matching_marketplaces[0].plugins[0].source, + PluginSource::Remote + ); + assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_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 = false +remote_plugin = 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 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 }) + .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.is_empty()); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -1108,6 +1438,24 @@ enabled = true ) } +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + fn write_openai_curated_marketplace( codex_home: &std::path::Path, plugin_names: &[&str], 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 e79a72a9207c..d3697be10c14 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; @@ -42,6 +43,13 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -106,7 +114,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> { +async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -114,7 +122,7 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("chatgpt-global".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -129,9 +137,336 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() assert!( err.error .message - .contains("remote plugin read is not supported yet") + .contains("remote plugin read is not enabled") ); - assert!(err.error.message.contains("openai-curated")); + assert!(err.error.message.contains("chatgpt-global")); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> 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": "linear@chatgpt-global", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + } +}"#; + let installed_body = r#"{ + "plugins": [ + { + "id": "linear@chatgpt-global", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + }, + "enabled": false, + "disabled_skill_names": ["plan-work"] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/linear@chatgpt-global")) + .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??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear".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.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.marketplace_path, None); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.installed, true); + assert_eq!(response.plugin.summary.enabled, false); + assert_eq!( + response.plugin.description.as_deref(), + Some("Track work in Linear") + ); + assert_eq!(response.plugin.skills.len(), 1); + assert_eq!(response.plugin.skills[0].name, "plan-work"); + assert_eq!(response.plugin.skills[0].path, None); + assert_eq!(response.plugin.skills[0].enabled, false); + assert_eq!(response.plugin.apps.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> 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, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/missing@chatgpt-global")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#)) + .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("chatgpt-global".to_string()), + plugin_name: "missing".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("read remote plugin details: remote plugin catalog request") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_remote_marketplace_when_plugins_are_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 = false +remote_plugin = 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 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("chatgpt-global".to_string()), + plugin_name: "linear".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin read is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { + let codex_home = TempDir::new()?; + write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; + 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("chatgpt-global".to_string()), + plugin_name: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + assert!(err.error.message.contains("invalid plugin name")); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "openai-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", + "description": "OpenAI curated plugin" +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."demo-plugin@openai-curated"] +enabled = true +"#, + )?; + write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + 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.marketplace_name, "openai-curated"); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); + assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated"); + assert_eq!(response.plugin.summary.name, "demo-plugin"); Ok(()) } @@ -283,7 +618,7 @@ enabled = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "codex-curated"); - assert_eq!(response.plugin.marketplace_path, marketplace_path); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated"); assert_eq!(response.plugin.summary.name, "demo-plugin"); assert_eq!( @@ -858,6 +1193,24 @@ connectors = true ) } +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 82ff4df3c7de..ffb45bc61279 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -3,5 +3,6 @@ pub mod manifest; pub mod marketplace; pub mod marketplace_upgrade; pub mod remote; +pub mod remote_legacy; pub mod store; pub mod toggles; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 34ece0880aee..89dabc841b2c 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -1,317 +1,656 @@ +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; -use codex_protocol::protocol::Product; +use reqwest::RequestBuilder; use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashSet; use std::time::Duration; -use url::Url; -const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); -const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); -const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); +pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; +pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; +pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; +pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins"; + +const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RemotePluginStatusSummary { +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteMarketplace { pub name: String, - #[serde(default = "default_remote_marketplace_name")] - pub marketplace_name: String, - pub enabled: bool, + pub display_name: String, + pub plugins: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemotePluginMutationResponse { +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSummary { pub id: String, + pub name: String, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub interface: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginDetail { + pub marketplace_name: String, + pub marketplace_display_name: String, + pub summary: RemotePluginSummary, + pub description: Option, + pub skills: Vec, + pub app_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSkill { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, pub enabled: bool, } #[derive(Debug, thiserror::Error)] -pub enum RemotePluginMutationError { - #[error("chatgpt authentication required for remote plugin mutation")] +pub enum RemotePluginCatalogError { + #[error("chatgpt authentication required for remote plugin catalog")] AuthRequired, #[error( - "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + "chatgpt authentication required for remote plugin catalog; api key auth is not supported" )] UnsupportedAuthMode, - #[error("failed to read auth token for remote plugin mutation: {0}")] + #[error("failed to read auth token for remote plugin catalog: {0}")] AuthToken(#[source] std::io::Error), - #[error("invalid chatgpt base url for remote plugin mutation: {0}")] - InvalidBaseUrl(#[source] url::ParseError), - - #[error("chatgpt base url cannot be used for plugin mutation")] - InvalidBaseUrlPath, - - #[error("failed to send remote plugin mutation request to {url}: {source}")] + #[error("failed to send remote plugin catalog request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, - #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, - #[error("failed to parse remote plugin mutation response from {url}: {source}")] + #[error("failed to parse remote plugin catalog response from {url}: {source}")] Decode { url: String, #[source] source: serde_json::Error, }, - #[error( - "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" - )] - UnexpectedPluginId { expected: String, actual: String }, + #[error("remote marketplace `{marketplace_name}` is not supported")] + UnknownMarketplace { marketplace_name: String }, #[error( - "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + "remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`" )] - UnexpectedEnabledState { + MarketplaceMismatch { plugin_id: String, - expected_enabled: bool, - actual_enabled: bool, + expected_marketplace_name: String, + actual_marketplace_name: String, }, } -#[derive(Debug, thiserror::Error)] -pub enum RemotePluginFetchError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +enum RemotePluginScope { + #[serde(rename = "GLOBAL")] + Global, + #[serde(rename = "WORKSPACE")] + Workspace, +} - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, +impl RemotePluginScope { + fn all() -> [Self; 2] { + [Self::Global, Self::Workspace] + } - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), + fn api_value(self) -> &'static str { + match self { + Self::Global => "GLOBAL", + Self::Workspace => "WORKSPACE", + } + } - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, + fn marketplace_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME, + } + } - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, + fn marketplace_display_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + } + } - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, + fn from_marketplace_name(name: &str) -> Option { + match name { + REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), + REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginPagination { + #[serde(alias = "nextPageToken")] + next_page_token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillInterfaceResponse { + #[serde(alias = "displayName")] + display_name: Option, + #[serde(alias = "shortDescription")] + short_description: Option, + #[serde(alias = "brandColor")] + brand_color: Option, + #[serde(alias = "defaultPrompt")] + default_prompt: Option, + #[serde(alias = "iconSmallUrl")] + icon_small_url: Option, + #[serde(alias = "iconLargeUrl")] + icon_large_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillResponse { + name: String, + description: String, + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseInterfaceResponse { + #[serde(alias = "shortDescription")] + short_description: Option, + #[serde(alias = "longDescription")] + long_description: Option, + #[serde(alias = "developerName")] + developer_name: Option, + category: Option, + #[serde(default)] + capabilities: Vec, + #[serde(alias = "websiteUrl")] + website_url: Option, + #[serde(alias = "privacyPolicyUrl")] + privacy_policy_url: Option, + #[serde(alias = "termsOfServiceUrl")] + terms_of_service_url: Option, + #[serde(alias = "brandColor")] + brand_color: Option, + #[serde(alias = "defaultPrompt")] + default_prompt: Option, + #[serde(alias = "composerIconUrl")] + composer_icon_url: Option, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(default)] + #[serde(alias = "screenshotUrls")] + screenshot_urls: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseResponse { + #[serde(alias = "displayName")] + display_name: String, + description: String, + #[serde(default)] + #[serde(alias = "appIds")] + app_ids: Vec, + interface: RemotePluginReleaseInterfaceResponse, + #[serde(default)] + skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginDirectoryItem { + id: String, + name: String, + scope: RemotePluginScope, + #[serde(alias = "installationPolicy")] + installation_policy: PluginInstallPolicy, + #[serde(alias = "authenticationPolicy")] + authentication_policy: PluginAuthPolicy, + release: RemotePluginReleaseResponse, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledItem { + #[serde(flatten)] + plugin: RemotePluginDirectoryItem, + enabled: bool, + #[serde(default)] + #[serde(alias = "disabledSkillNames")] + disabled_skill_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginListResponse { + plugins: Vec, + pagination: RemotePluginPagination, } -pub async fn fetch_remote_plugin_status( +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledResponse { + plugins: Vec, + pagination: RemotePluginPagination, +} + +pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, -) -> Result, RemotePluginFetchError> { - let Some(auth) = auth else { - return Err(RemotePluginFetchError::AuthRequired); +) -> 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)) }; - if !auth.is_chatgpt_auth() { - return Err(RemotePluginFetchError::UnsupportedAuthMode); - } - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let token = auth - .get_token() - .map_err(RemotePluginFetchError::AuthToken)?; - let mut request = client - .get(&url) - .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) - .bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); + 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 response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + 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 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, + }); } - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, - }) + Ok(marketplaces) } -pub async fn fetch_remote_featured_plugin_ids( +pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, - product: Option, -) -> Result, RemotePluginFetchError> { - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/featured"); - let client = build_reqwest_client(); - let mut request = client - .get(&url) - .query(&[( - "platform", - product.unwrap_or(Product::Codex).to_app_platform(), - )]) - .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); - - if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) { - let token = auth - .get_token() - .map_err(RemotePluginFetchError::AuthToken)?; - request = request.bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); + marketplace_name: &str, + plugin_id: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| { + RemotePluginCatalogError::UnknownMarketplace { + marketplace_name: marketplace_name.to_string(), } + })?; + let plugin = fetch_plugin_detail(config, auth, plugin_id).await?; + let actual_marketplace_name = plugin.scope.marketplace_name(); + if actual_marketplace_name != marketplace_name { + return Err(RemotePluginCatalogError::MarketplaceMismatch { + plugin_id: plugin_id.to_string(), + expected_marketplace_name: marketplace_name.to_string(), + actual_marketplace_name: actual_marketplace_name.to_string(), + }); } - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope) + .await? + .into_iter() + .find(|installed_plugin| installed_plugin.plugin.id == plugin_id); + let disabled_skill_names = installed_plugin + .as_ref() + .map(|plugin| { + plugin + .disabled_skill_names + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let skills = plugin + .release + .skills + .iter() + .map(|skill| RemotePluginSkill { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()), + interface: remote_skill_interface_to_info(skill.interface.clone()), + enabled: !disabled_skill_names.contains(&skill.name), + }) + .collect(); + + Ok(RemotePluginDetail { + marketplace_name: marketplace_name.to_string(), + marketplace_display_name: scope.marketplace_display_name().to_string(), + summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), + description: non_empty_string(Some(&plugin.release.description)), + skills, + app_ids: plugin.release.app_ids, + }) +} + +fn build_remote_plugin_summary( + plugin: &RemotePluginDirectoryItem, + installed_plugin: Option<&RemotePluginInstalledItem>, +) -> RemotePluginSummary { + RemotePluginSummary { + id: plugin.id.clone(), + name: plugin.name.clone(), + installed: installed_plugin.is_some(), + enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + interface: remote_plugin_interface_to_info(plugin), } +} - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, +fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { + let interface = &plugin.release.interface; + let display_name = non_empty_string(Some(&plugin.release.display_name)); + let default_prompt = interface + .default_prompt + .as_ref() + .and_then(|prompt| normalize_remote_default_prompt(prompt)); + let result = PluginInterface { + display_name, + short_description: interface.short_description.clone(), + long_description: interface.long_description.clone(), + developer_name: interface.developer_name.clone(), + category: interface.category.clone(), + capabilities: interface.capabilities.clone(), + website_url: interface.website_url.clone(), + privacy_policy_url: interface.privacy_policy_url.clone(), + terms_of_service_url: interface.terms_of_service_url.clone(), + default_prompt, + brand_color: interface.brand_color.clone(), + composer_icon: None, + composer_icon_url: interface.composer_icon_url.clone(), + logo: None, + logo_url: interface.logo_url.clone(), + screenshots: Vec::new(), + screenshot_urls: interface.screenshot_urls.clone(), + }; + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.long_description.is_some() + || result.developer_name.is_some() + || result.category.is_some() + || !result.capabilities.is_empty() + || result.website_url.is_some() + || result.privacy_policy_url.is_some() + || result.terms_of_service_url.is_some() + || result.default_prompt.is_some() + || result.brand_color.is_some() + || result.composer_icon_url.is_some() + || result.logo_url.is_some() + || !result.screenshot_urls.is_empty(); + has_fields.then_some(result) +} + +fn remote_skill_interface_to_info( + interface: Option, +) -> Option { + interface.and_then(|interface| { + let result = SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: None, + icon_large: None, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + }; + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.brand_color.is_some() + || result.default_prompt.is_some(); + has_fields.then_some(result) }) } -pub async fn enable_remote_plugin( +fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&plugin.name) +} + +fn non_empty_string(value: Option<&str>) -> Option { + value.and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +fn normalize_remote_default_prompt(prompt: &str) -> Option> { + let prompt = prompt.trim(); + if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { + return None; + } + Some(vec![prompt.to_string()]) +} + +async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, - plugin_id: &str, -) -> Result<(), RemotePluginMutationError> { - post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; - Ok(()) + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_plugin_list_page(config, auth, scope, 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) } -pub async fn uninstall_remote_plugin( +async fn fetch_installed_plugins_for_scope( config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, - plugin_id: &str, -) -> Result<(), RemotePluginMutationError> { - post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; - Ok(()) + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_plugin_installed_page(config, auth, scope, 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) } -fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> { - let Some(auth) = auth else { - return Err(RemotePluginMutationError::AuthRequired); - }; - if !auth.is_chatgpt_auth() { - return Err(RemotePluginMutationError::UnsupportedAuthMode); +async fn get_remote_plugin_list_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/list"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); } - Ok(auth) + send_and_decode(request, &url).await } -fn default_remote_marketplace_name() -> String { - DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +async fn get_remote_plugin_installed_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/installed"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await } -async fn post_remote_plugin_mutation( +async fn fetch_plugin_detail( config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, + auth: &CodexAuth, plugin_id: &str, - action: &str, -) -> Result { - let auth = ensure_chatgpt_auth(auth)?; - let url = remote_plugin_mutation_url(config, plugin_id, action)?; +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{plugin_id}"); let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)?; + send_and_decode(request, &url).await +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> { + let Some(auth) = auth else { + return Err(RemotePluginCatalogError::AuthRequired); + }; + if !auth.is_chatgpt_auth() { + return Err(RemotePluginCatalogError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn authenticated_request( + request: RequestBuilder, + auth: &CodexAuth, +) -> Result { let token = auth .get_token() - .map_err(RemotePluginMutationError::AuthToken)?; - let mut request = client - .post(url.clone()) - .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) + .map_err(RemotePluginCatalogError::AuthToken)?; + let mut request = request + .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) .bearer_auth(token); if let Some(account_id) = auth.get_account_id() { request = request.header("chatgpt-account-id", account_id); } + Ok(request) +} +async fn send_and_decode Deserialize<'de>>( + request: RequestBuilder, + url: &str, +) -> Result { let response = request .send() .await - .map_err(|source| RemotePluginMutationError::Request { - url: url.clone(), + .map_err(|source| RemotePluginCatalogError::Request { + url: url.to_string(), source, })?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if !status.is_success() { - return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); - } - - let parsed: RemotePluginMutationResponse = - serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { - url: url.clone(), - source, - })?; - let expected_enabled = action == "enable"; - if parsed.id != plugin_id { - return Err(RemotePluginMutationError::UnexpectedPluginId { - expected: plugin_id.to_string(), - actual: parsed.id, - }); - } - if parsed.enabled != expected_enabled { - return Err(RemotePluginMutationError::UnexpectedEnabledState { - plugin_id: plugin_id.to_string(), - expected_enabled, - actual_enabled: parsed.enabled, + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: url.to_string(), + status, + body, }); } - Ok(parsed) -} - -fn remote_plugin_mutation_url( - config: &RemotePluginServiceConfig, - plugin_id: &str, - action: &str, -) -> Result { - let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) - .map_err(RemotePluginMutationError::InvalidBaseUrl)?; - { - let mut segments = url - .path_segments_mut() - .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; - segments.pop_if_empty(); - segments.push("plugins"); - segments.push(plugin_id); - segments.push(action); - } - Ok(url.to_string()) + serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode { + url: url.to_string(), + source, + }) } diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs new file mode 100644 index 000000000000..7b57ab1320a9 --- /dev/null +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -0,0 +1,313 @@ +use crate::remote::RemotePluginServiceConfig; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use codex_protocol::protocol::Product; +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; +const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginStatusSummary { + pub name: String, + #[serde(default = "default_remote_marketplace_name")] + pub marketplace_name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemotePluginMutationResponse { + pub id: String, + pub enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginMutationError { + #[error("chatgpt authentication required for remote plugin mutation")] + AuthRequired, + + #[error( + "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin mutation: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("invalid chatgpt base url for remote plugin mutation: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("chatgpt base url cannot be used for plugin mutation")] + InvalidBaseUrlPath, + + #[error("failed to send remote plugin mutation request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin mutation response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error( + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + )] + UnexpectedPluginId { expected: String, actual: String }, + + #[error( + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + )] + UnexpectedEnabledState { + plugin_id: String, + expected_enabled: bool, + actual_enabled: bool, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginFetchError { + #[error("chatgpt authentication required to sync remote plugins")] + AuthRequired, + + #[error( + "chatgpt authentication required to sync remote plugins; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin sync: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin sync request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin sync response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, +} + +pub async fn fetch_remote_plugin_status( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginFetchError> { + let Some(auth) = auth else { + return Err(RemotePluginFetchError::AuthRequired); + }; + if !auth.is_chatgpt_auth() { + return Err(RemotePluginFetchError::UnsupportedAuthMode); + } + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/list"); + let client = build_reqwest_client(); + let token = auth + .get_token() + .map_err(RemotePluginFetchError::AuthToken)?; + let mut request = client + .get(&url) + .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) + .bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn fetch_remote_featured_plugin_ids( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + product: Option, +) -> Result, RemotePluginFetchError> { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/featured"); + let client = build_reqwest_client(); + let mut request = client + .get(&url) + .query(&[( + "platform", + product.unwrap_or(Product::Codex).to_app_platform(), + )]) + .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); + + if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) { + let token = auth + .get_token() + .map_err(RemotePluginFetchError::AuthToken)?; + request = request.bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn enable_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; + Ok(()) +} + +pub async fn uninstall_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; + Ok(()) +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> { + let Some(auth) = auth else { + return Err(RemotePluginMutationError::AuthRequired); + }; + if !auth.is_chatgpt_auth() { + return Err(RemotePluginMutationError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn default_remote_marketplace_name() -> String { + DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +} + +async fn post_remote_plugin_mutation( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, + action: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let url = remote_plugin_mutation_url(config, plugin_id, action)?; + let client = build_reqwest_client(); + let token = auth + .get_token() + .map_err(RemotePluginMutationError::AuthToken)?; + let mut request = client + .post(url.clone()) + .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) + .bearer_auth(token); + if let Some(account_id) = auth.get_account_id() { + request = request.header("chatgpt-account-id", account_id); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginMutationError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); + } + + let parsed: RemotePluginMutationResponse = + serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { + url: url.clone(), + source, + })?; + let expected_enabled = action == "enable"; + if parsed.id != plugin_id { + return Err(RemotePluginMutationError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: parsed.id, + }); + } + if parsed.enabled != expected_enabled { + return Err(RemotePluginMutationError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled, + actual_enabled: parsed.enabled, + }); + } + + Ok(parsed) +} + +fn remote_plugin_mutation_url( + config: &RemotePluginServiceConfig, + plugin_id: &str, + action: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginMutationError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("plugins"); + segments.push(plugin_id); + segments.push(action); + } + Ok(url.to_string()) +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3f973599176c..73b66ebe98cb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -452,6 +452,9 @@ "remote_models": { "type": "boolean" }, + "remote_plugin": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, @@ -2448,6 +2451,9 @@ "remote_models": { "type": "boolean" }, + "remote_plugin": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index efa91edc7ce5..7d9b426b2608 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -41,9 +41,9 @@ use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names; use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces; -use codex_core_plugins::remote::RemotePluginFetchError; -use codex_core_plugins::remote::RemotePluginMutationError; use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote_legacy::RemotePluginFetchError; +use codex_core_plugins::remote_legacy::RemotePluginMutationError; use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult; use codex_core_plugins::store::PluginStore; use codex_core_plugins::store::PluginStoreError; @@ -71,7 +71,6 @@ use tracing::info; use tracing::warn; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; -pub const OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration = @@ -167,7 +166,7 @@ pub struct PluginInstallOutcome { #[derive(Debug, Clone, PartialEq)] pub struct PluginReadOutcome { pub marketplace_name: String, - pub marketplace_path: AbsolutePathBuf, + pub marketplace_path: Option, pub plugin: PluginDetail, } @@ -526,12 +525,13 @@ impl PluginsManager { if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) { return Ok(featured_plugin_ids); } - let featured_plugin_ids = codex_core_plugins::remote::fetch_remote_featured_plugin_ids( - &remote_plugin_service_config(config), - auth, - self.restriction_product, - ) - .await?; + let featured_plugin_ids = + codex_core_plugins::remote_legacy::fetch_remote_featured_plugin_ids( + &remote_plugin_service_config(config), + auth, + self.restriction_product, + ) + .await?; self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids); Ok(featured_plugin_ids) } @@ -561,7 +561,7 @@ impl PluginsManager { )?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - codex_core_plugins::remote::enable_remote_plugin( + codex_core_plugins::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, @@ -652,7 +652,7 @@ impl PluginsManager { let plugin_id = PluginId::parse(&plugin_id)?; let plugin_key = plugin_id.as_key(); // This only forwards the backend mutation before the local uninstall flow. - codex_core_plugins::remote::uninstall_remote_plugin( + codex_core_plugins::remote_legacy::uninstall_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_key, @@ -709,7 +709,7 @@ impl PluginsManager { } info!("starting remote plugin sync"); - let remote_plugins = codex_core_plugins::remote::fetch_remote_plugin_status( + let remote_plugins = codex_core_plugins::remote_legacy::fetch_remote_plugin_status( &remote_plugin_service_config(config), auth, ) @@ -968,13 +968,7 @@ impl PluginsManager { (!plugins.is_empty()).then_some(ConfiguredMarketplace { name: marketplace.name, path: marketplace.path, - interface: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { - Some(MarketplaceInterface { - display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()), - }) - } else { - marketplace.interface - }, + interface: marketplace.interface, plugins, }) }) @@ -1023,12 +1017,8 @@ impl PluginsManager { .await?; Ok(PluginReadOutcome { - marketplace_name: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { - OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string() - } else { - marketplace_name - }, - marketplace_path: request.marketplace_path.clone(), + marketplace_name, + marketplace_path: Some(request.marketplace_path.clone()), plugin, }) } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index e43490803d1d..c455b0887775 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1781,9 +1781,6 @@ plugins = true curated_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "openai-curated", - "interface": { - "displayName": "ChatGPT Official" - }, "plugins": [ { "name": "linear", @@ -1819,9 +1816,7 @@ plugins = true name: "openai-curated".to_string(), path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json")) .unwrap(), - interface: Some(MarketplaceInterface { - display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()), - }), + interface: None, plugins: vec![ConfiguredMarketplacePlugin { id: "linear@openai-curated".to_string(), name: "linear".to_string(), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index a5a2ef7a304c..b8a062f0548b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -156,6 +156,8 @@ pub enum Feature { ToolSuggest, /// Enable plugins. Plugins, + /// Temporary internal-only flag for PS-backed remote plugin catalog development. + RemotePlugin, /// Show the startup prompt for migrating external agent config into Codex. ExternalMigration, /// Allow the model to invoke the built-in image generation tool. @@ -846,6 +848,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::RemotePlugin, + key: "remote_plugin", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ExternalMigration, key: "external_migration", diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index f4dfe6dbd972..f330a80f22a6 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1013,9 +1013,8 @@ impl ChatWidget { is_disabled: true, ..Default::default() }); - } else { + } else if let Some(marketplace_path) = plugin.marketplace_path.clone() { let install_cwd = self.config.cwd.to_path_buf(); - let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; items.push(SelectionItem { @@ -1035,6 +1034,13 @@ impl ChatWidget { })], ..Default::default() }); + } else { + items.push(SelectionItem { + name: "Install plugin".to_string(), + description: Some("Installing remote plugins is not supported yet.".to_string()), + is_disabled: true, + ..Default::default() + }); } items.push(SelectionItem { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 72ed31a30bba..7b2f46aec6ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -958,7 +958,7 @@ pub(super) fn plugins_test_detail( ) -> PluginDetail { PluginDetail { marketplace_name: "ChatGPT Marketplace".to_string(), - marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"), + marketplace_path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), summary, description: description.map(str::to_string), skills: skills @@ -968,7 +968,9 @@ pub(super) fn plugins_test_detail( description: format!("{name} description"), short_description: None, interface: None, - path: plugins_test_absolute_path(&format!("skills/{name}/SKILL.md")), + path: Some(plugins_test_absolute_path(&format!( + "skills/{name}/SKILL.md" + ))), enabled: true, }) .collect(),