diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 37a64fbe33..5e4dbd3f7c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2119,7 +2119,7 @@ }, "PluginInstallParams": { "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -2129,19 +2129,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "type": "object" }, "PluginListParams": { @@ -2161,7 +2167,7 @@ }, "PluginReadParams": { "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -2171,19 +2177,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "type": "object" }, "PluginShareDeleteParams": { 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 f856b43d66..a748919981 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 @@ -12028,7 +12028,7 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/v2/AbsolutePathBuf" @@ -12038,19 +12038,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginInstallParams", "type": "object" }, @@ -12301,7 +12307,7 @@ "PluginReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/v2/AbsolutePathBuf" @@ -12311,19 +12317,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginReadParams", "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 c17efe7a45..e80c756110 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 @@ -8681,7 +8681,7 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -8691,19 +8691,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginInstallParams", "type": "object" }, @@ -8954,7 +8960,7 @@ "PluginReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -8964,19 +8970,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginReadParams", "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json index ad3c0c1079..9c8290b4aa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -7,7 +7,7 @@ } }, "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -17,19 +17,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginInstallParams", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json index 5cc3e5cab5..7601c72a30 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json @@ -7,7 +7,7 @@ } }, "properties": { - "marketplacePath": { + "localMarketplacePath": { "anyOf": [ { "$ref": "#/definitions/AbsolutePathBuf" @@ -17,19 +17,25 @@ } ] }, - "pluginName": { - "type": "string" + "localPluginName": { + "type": [ + "string", + "null" + ] }, "remoteMarketplaceName": { "type": [ "string", "null" ] + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] } }, - "required": [ - "pluginName" - ], "title": "PluginReadParams", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts index 257dc47a1e..834f9996f7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; +export type PluginInstallParams = { localMarketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, localPluginName?: string | null, remotePluginId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts index 8c4394f0da..de1738d8d7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginReadParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; +export type PluginReadParams = { localMarketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, localPluginName?: string | null, remotePluginId?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c5a7d61f01..9e8002c19c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1595,9 +1595,10 @@ mod tests { let plugin_install = ClientRequest::PluginInstall { request_id: request_id(), params: v2::PluginInstallParams { - marketplace_path: Some(absolute_path("/tmp/marketplace")), + local_marketplace_path: Some(absolute_path("/tmp/marketplace")), remote_marketplace_name: None, - plugin_name: "plugin-a".to_string(), + local_plugin_name: Some("plugin-a".to_string()), + remote_plugin_id: None, }, }; assert_eq!( diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 963ac69000..cf0cd58255 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4597,10 +4597,13 @@ pub struct MarketplaceLoadErrorInfo { #[ts(export_to = "v2/")] pub struct PluginReadParams { #[ts(optional = nullable)] - pub marketplace_path: Option, + pub local_marketplace_path: Option, #[ts(optional = nullable)] pub remote_marketplace_name: Option, - pub plugin_name: String, + #[ts(optional = nullable)] + pub local_plugin_name: Option, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4987,10 +4990,13 @@ pub struct SkillsConfigWriteResponse { #[ts(export_to = "v2/")] pub struct PluginInstallParams { #[ts(optional = nullable)] - pub marketplace_path: Option, + pub local_marketplace_path: Option, #[ts(optional = nullable)] pub remote_marketplace_name: Option, - pub plugin_name: String, + #[ts(optional = nullable)] + pub local_plugin_name: Option, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -10658,42 +10664,46 @@ mod tests { let marketplace_path_json = marketplace_path.as_path().display().to_string(); assert_eq!( serde_json::to_value(PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), + local_marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: "gmail".to_string(), + local_plugin_name: Some("gmail".to_string()), + remote_plugin_id: None, }) .unwrap(), json!({ - "marketplacePath": marketplace_path_json, + "localMarketplacePath": marketplace_path_json, "remoteMarketplaceName": null, - "pluginName": "gmail", + "localPluginName": "gmail", + "remotePluginId": null, }), ); assert_eq!( serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", + "localMarketplacePath": marketplace_path_json, + "localPluginName": "gmail", "forceRemoteSync": true, })) .unwrap(), PluginReadParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "gmail".to_string(), + local_plugin_name: Some("gmail".to_string()), + remote_plugin_id: None, }, ); assert_eq!( serde_json::from_value::(json!({ "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", + "remotePluginId": "plugins~Plugin_gmail", })) .unwrap(), PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("plugins~Plugin_gmail".to_string()), }, ); } @@ -10709,43 +10719,47 @@ mod tests { let marketplace_path_json = marketplace_path.as_path().display().to_string(); assert_eq!( serde_json::to_value(PluginInstallParams { - marketplace_path: Some(marketplace_path.clone()), + local_marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: "gmail".to_string(), + local_plugin_name: Some("gmail".to_string()), + remote_plugin_id: None, }) .unwrap(), json!({ - "marketplacePath": marketplace_path_json, + "localMarketplacePath": marketplace_path_json, "remoteMarketplaceName": null, - "pluginName": "gmail", + "localPluginName": "gmail", + "remotePluginId": null, }), ); assert_eq!( serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", + "localMarketplacePath": marketplace_path_json, + "localPluginName": "gmail", "forceRemoteSync": true, })) .unwrap(), PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "gmail".to_string(), + local_plugin_name: Some("gmail".to_string()), + remote_plugin_id: None, }, ); assert_eq!( serde_json::from_value::(json!({ "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", + "remotePluginId": "plugins~Plugin_gmail", "forceRemoteSync": true, })) .unwrap(), PluginInstallParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("plugins~Plugin_gmail".to_string()), }, ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dab47ec3a2..13fa09cef0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -202,7 +202,7 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). -- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/read` — read one plugin by `localMarketplacePath` plus `localPluginName`, or by `remoteMarketplaceName` plus `remotePluginId`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. @@ -211,7 +211,7 @@ Example with notification opt-out: - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. -- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). +- `plugin/install` — install a local plugin from a discovered marketplace entry by `localMarketplacePath` plus `localPluginName`, or install a remote ChatGPT plugin by `remoteMarketplaceName` plus backend `remotePluginId`, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 5bab115517..3c97af7cf2 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -188,30 +188,35 @@ impl CodexMessageProcessor { ) -> Result { let plugins_manager = self.thread_manager.plugins_manager(); let PluginReadParams { - marketplace_path, + local_marketplace_path, remote_marketplace_name, - plugin_name, + local_plugin_name, + remote_plugin_id, } = 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) => { - return Err(invalid_request( - "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName", - )); - } + let read_source = plugin_request_source( + "plugin/read", + local_marketplace_path, + remote_marketplace_name, + local_plugin_name, + remote_plugin_id, + )?; + let config_cwd = match &read_source { + PluginRequestSource::Local { + marketplace_path, .. + } => marketplace_path.as_path().parent().map(Path::to_path_buf), + PluginRequestSource::Remote { .. } => None, }; - let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { - marketplace_path.as_path().parent().map(Path::to_path_buf) - }); let config = self.load_latest_config(config_cwd).await?; let plugins_input = config.plugins_config_input(); let plugin = match read_source { - Ok(marketplace_path) => { + PluginRequestSource::Local { + marketplace_path, + local_plugin_name, + } => { let request = PluginReadRequest { - plugin_name, + plugin_name: local_plugin_name, marketplace_path, }; let outcome = plugins_manager @@ -259,7 +264,10 @@ impl CodexMessageProcessor { mcp_servers: outcome.plugin.mcp_server_names, } } - Err(remote_marketplace_name) => { + PluginRequestSource::Remote { + remote_marketplace_name, + remote_plugin_id, + } => { if !config.features.enabled(Feature::Plugins) || !config.features.enabled(Feature::RemotePlugin) { @@ -271,12 +279,12 @@ impl CodexMessageProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - validate_remote_plugin_id(&plugin_name)?; + validate_remote_plugin_id(&remote_plugin_id)?; let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, auth.as_ref(), &remote_marketplace_name, - &plugin_name, + &remote_plugin_id, ) .await .map_err(|err| { @@ -503,22 +511,31 @@ impl CodexMessageProcessor { params: PluginInstallParams, ) -> Result { let PluginInstallParams { - marketplace_path, + local_marketplace_path, remote_marketplace_name, - plugin_name, + local_plugin_name, + remote_plugin_id, } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { + let install_source = plugin_request_source( + "plugin/install", + local_marketplace_path, + remote_marketplace_name, + local_plugin_name, + remote_plugin_id, + )?; + let (marketplace_path, local_plugin_name) = match install_source { + PluginRequestSource::Local { + marketplace_path, + local_plugin_name, + } => (marketplace_path, local_plugin_name), + PluginRequestSource::Remote { + remote_marketplace_name, + remote_plugin_id, + } => { return self - .remote_plugin_install_response(remote_marketplace_name, plugin_name) + .remote_plugin_install_response(remote_marketplace_name, remote_plugin_id) .await; } - (Some(_), Some(_)) | (None, None) => { - return Err(invalid_request( - "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName", - )); - } }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let config = self.load_latest_config(config_cwd.clone()).await?; @@ -535,7 +552,7 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); let request = PluginInstallRequest { - plugin_name, + plugin_name: local_plugin_name, marketplace_path, }; @@ -907,13 +924,70 @@ impl CodexMessageProcessor { } } -fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool { - is_valid_remote_plugin_id(plugin_name) - && (plugin_name.starts_with("plugins~") - || plugin_name.starts_with("plugins_") - || plugin_name.starts_with("app_") - || plugin_name.starts_with("asdk_app_") - || plugin_name.starts_with("connector_")) +enum PluginRequestSource { + Local { + marketplace_path: AbsolutePathBuf, + local_plugin_name: String, + }, + Remote { + remote_marketplace_name: String, + remote_plugin_id: String, + }, +} + +fn plugin_request_source( + method: &str, + local_marketplace_path: Option, + remote_marketplace_name: Option, + local_plugin_name: Option, + remote_plugin_id: Option, +) -> Result { + match (local_marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => { + if remote_plugin_id.is_some() { + return Err(invalid_request(format!( + "{method} with localMarketplacePath requires localPluginName, not remotePluginId" + ))); + } + let Some(local_plugin_name) = local_plugin_name else { + return Err(invalid_request(format!( + "{method} with localMarketplacePath requires localPluginName" + ))); + }; + Ok(PluginRequestSource::Local { + marketplace_path, + local_plugin_name, + }) + } + (None, Some(remote_marketplace_name)) => { + if local_plugin_name.is_some() { + return Err(invalid_request(format!( + "{method} with remoteMarketplaceName requires remotePluginId, not localPluginName" + ))); + } + let Some(remote_plugin_id) = remote_plugin_id else { + return Err(invalid_request(format!( + "{method} with remoteMarketplaceName requires remotePluginId" + ))); + }; + Ok(PluginRequestSource::Remote { + remote_marketplace_name, + remote_plugin_id, + }) + } + (Some(_), Some(_)) | (None, None) => Err(invalid_request(format!( + "{method} requires exactly one of localMarketplacePath or remoteMarketplaceName" + ))), + } +} + +fn is_valid_remote_uninstall_plugin_id(plugin_id: &str) -> bool { + is_valid_remote_plugin_id(plugin_id) + && (plugin_id.starts_with("plugins~") + || plugin_id.starts_with("plugins_") + || plugin_id.starts_with("app_") + || plugin_id.starts_with("asdk_app_") + || plugin_id.starts_with("connector_")) } fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2b2f781368..baeee0cc0a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -75,8 +75,8 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { .send_raw_request( "plugin/install", Some(serde_json::json!({ - "marketplacePath": "relative-marketplace.json", - "pluginName": "missing-plugin", + "localMarketplacePath": "relative-marketplace.json", + "localPluginName": "missing-plugin", })), ) .await?; @@ -100,9 +100,10 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -116,7 +117,7 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> { assert!( err.error .message - .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + .contains("requires exactly one of localMarketplacePath or remoteMarketplaceName") ); Ok(()) } @@ -129,11 +130,12 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "sample-plugin".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("sample-plugin".to_string()), }) .await?; @@ -147,7 +149,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { assert!( err.error .message - .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + .contains("requires exactly one of localMarketplacePath or remoteMarketplaceName") ); Ok(()) } @@ -160,9 +162,10 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("plugins~Plugin_22222222222222222222222222222222".to_string()), }) .await?; @@ -383,7 +386,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { } #[tokio::test] -async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { +async fn plugin_install_rejects_invalid_remote_plugin_id() -> 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?; @@ -391,9 +394,10 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "linear/../../oops".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("linear/../../oops".to_string()), }) .await?; @@ -514,9 +518,10 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -543,11 +548,12 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() - let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("missing-marketplace.json"), )?), remote_marketplace_name: None, - plugin_name: "missing-plugin".to_string(), + local_plugin_name: Some("missing-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -584,9 +590,10 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -634,9 +641,10 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -683,9 +691,10 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; let response: JSONRPCResponse = timeout( @@ -890,9 +899,10 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -974,9 +984,10 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -1041,9 +1052,10 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; let response: JSONRPCResponse = timeout( @@ -1459,9 +1471,10 @@ async fn send_remote_plugin_install_request( remote_plugin_id: &str, ) -> Result { mcp.send_plugin_install_request(PluginInstallParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()), - plugin_name: remote_plugin_id.to_string(), + local_plugin_name: None, + remote_plugin_id: Some(remote_plugin_id.to_string()), }) .await } 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 fd082ab412..323cf84e4e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -63,9 +63,10 @@ async fn plugin_read_rejects_missing_read_source() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -79,7 +80,7 @@ async fn plugin_read_rejects_missing_read_source() -> Result<()> { assert!( err.error .message - .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + .contains("requires exactly one of localMarketplacePath or remoteMarketplaceName") ); Ok(()) } @@ -92,11 +93,12 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "sample-plugin".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("sample-plugin".to_string()), }) .await?; @@ -110,7 +112,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { assert!( err.error .message - .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + .contains("requires exactly one of localMarketplacePath or remoteMarketplaceName") ); Ok(()) } @@ -123,9 +125,10 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "sample-plugin".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("sample-plugin".to_string()), }) .await?; @@ -254,9 +257,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), }) .await?; @@ -383,9 +387,10 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_missing".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("plugins~Plugin_missing".to_string()), }) .await?; @@ -435,9 +440,10 @@ remote_plugin = true let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "linear".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("linear".to_string()), }) .await?; @@ -457,7 +463,7 @@ remote_plugin = true } #[tokio::test] -async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { +async fn plugin_read_rejects_invalid_remote_plugin_id() -> 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?; @@ -465,9 +471,10 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: None, + local_marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "linear/../../oops".to_string(), + local_plugin_name: None, + remote_plugin_id: Some("linear/../../oops".to_string()), }) .await?; @@ -525,9 +532,10 @@ enabled = true 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()), + local_marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: "demo-plugin".to_string(), + local_plugin_name: Some("demo-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -679,9 +687,10 @@ enabled = true 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()), + local_marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: "demo-plugin".to_string(), + local_plugin_name: Some("demo-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -827,9 +836,10 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + local_plugin_name: Some("sample-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -894,11 +904,12 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), )?), remote_marketplace_name: None, - plugin_name: "demo-plugin".to_string(), + local_plugin_name: Some("demo-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -956,11 +967,12 @@ async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Resul let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), )?), remote_marketplace_name: None, - plugin_name: "toolkit".to_string(), + local_plugin_name: Some("toolkit".to_string()), + remote_plugin_id: None, }) .await?; @@ -1019,11 +1031,12 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), )?), remote_marketplace_name: None, - plugin_name: "missing-plugin".to_string(), + local_plugin_name: Some("missing-plugin".to_string()), + remote_plugin_id: None, }) .await?; @@ -1072,11 +1085,12 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: Some(AbsolutePathBuf::try_from( + local_marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), )?), remote_marketplace_name: None, - plugin_name: "demo-plugin".to_string(), + local_plugin_name: Some("demo-plugin".to_string()), + remote_plugin_id: None, }) .await?; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 36155fb339..26572865a3 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -733,9 +733,10 @@ pub(super) async fn fetch_plugin_install( .request_typed(ClientRequest::PluginInstall { request_id, params: PluginInstallParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name, + local_plugin_name: Some(plugin_name), + remote_plugin_id: None, }, }) .await diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 6bdc413725..a755a61be5 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -578,9 +578,10 @@ impl App { app_server, cwd, PluginReadParams { - marketplace_path: Some(marketplace_path), + local_marketplace_path: Some(marketplace_path), remote_marketplace_name: None, - plugin_name, + local_plugin_name: Some(plugin_name), + remote_plugin_id: None, }, ); } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 3bca3d5d86..90d0bae3b4 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1818,9 +1818,10 @@ impl ChatWidget { tx.send(AppEvent::FetchPluginDetail { cwd: cwd.clone(), params: codex_app_server_protocol::PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), + local_marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: plugin_name.clone(), + local_plugin_name: Some(plugin_name.clone()), + remote_plugin_id: None, }, }); })] diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index ae85d122cc..f1e7c2f14a 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -2428,17 +2428,23 @@ class PluginAuthPolicy(Enum): on_use = "ON_USE" +class PluginAvailability(Enum): + disabled_by_admin = "DISABLED_BY_ADMIN" + available = "AVAILABLE" + + class PluginInstallParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - marketplace_path: Annotated[ - AbsolutePathBuf | None, Field(alias="marketplacePath") + local_marketplace_path: Annotated[ + AbsolutePathBuf | None, Field(alias="localMarketplacePath") ] = None - plugin_name: Annotated[str, Field(alias="pluginName")] + local_plugin_name: Annotated[str | None, Field(alias="localPluginName")] = None remote_marketplace_name: Annotated[ str | None, Field(alias="remoteMarketplaceName") ] = None + remote_plugin_id: Annotated[str | None, Field(alias="remotePluginId")] = None class PluginInstallPolicy(Enum): @@ -2531,13 +2537,14 @@ class PluginReadParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - marketplace_path: Annotated[ - AbsolutePathBuf | None, Field(alias="marketplacePath") + local_marketplace_path: Annotated[ + AbsolutePathBuf | None, Field(alias="localMarketplacePath") ] = None - plugin_name: Annotated[str, Field(alias="pluginName")] + local_plugin_name: Annotated[str | None, Field(alias="localPluginName")] = None remote_marketplace_name: Annotated[ str | None, Field(alias="remoteMarketplaceName") ] = None + remote_plugin_id: Annotated[str | None, Field(alias="remotePluginId")] = None class PluginShareDeleteParams(BaseModel): @@ -2631,6 +2638,10 @@ class PluginSummary(BaseModel): populate_by_name=True, ) auth_policy: Annotated[PluginAuthPolicy, Field(alias="authPolicy")] + availability: Annotated[ + PluginAvailability | None, + Field(description="Availability state for installing and using the plugin."), + ] = "AVAILABLE" enabled: bool id: str install_policy: Annotated[PluginInstallPolicy, Field(alias="installPolicy")] @@ -6443,11 +6454,22 @@ class PluginReadResponse(BaseModel): plugin: PluginDetail +class PluginShareListItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + local_plugin_path: Annotated[ + AbsolutePathBuf | None, Field(alias="localPluginPath") + ] = None + plugin: PluginSummary + share_url: Annotated[str, Field(alias="shareUrl")] + + class PluginShareListResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - data: list[PluginSummary] + data: list[PluginShareListItem] class RateLimitSnapshot(BaseModel):