diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 059ba533d..5b33c5a8a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1131,6 +1131,17 @@ }, "type": "object" }, + "PluginUninstallParams": { + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -3571,6 +3582,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { 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 7aec38076..93b864561 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 @@ -860,6 +860,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { @@ -12565,6 +12589,24 @@ ], "type": "object" }, + "PluginUninstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", 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 39408567d..9493430f3 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 @@ -1383,6 +1383,30 @@ "title": "Plugin/installRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/uninstall" + ], + "title": "Plugin/uninstallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginUninstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/uninstallRequest", + "type": "object" + }, { "properties": { "id": { @@ -9049,6 +9073,24 @@ ], "type": "object" }, + "PluginUninstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" + }, + "PluginUninstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json new file mode 100644 index 000000000..5b7e0a592 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginId": { + "type": "string" + } + }, + "required": [ + "pluginId" + ], + "title": "PluginUninstallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json new file mode 100644 index 000000000..5c0e37bd9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginUninstallResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 715a51d57..5fa8f27b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -27,6 +27,7 @@ import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; +import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -53,4 +54,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts new file mode 100644 index 000000000..e7f52c0eb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginUninstallParams = { pluginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts new file mode 100644 index 000000000..5d02c2f71 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginUninstallResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a4be44f40..37e54471b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -172,6 +172,8 @@ export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginSource } from "./PluginSource"; export type { PluginSummary } from "./PluginSummary"; +export type { PluginUninstallParams } from "./PluginUninstallParams"; +export type { PluginUninstallResponse } from "./PluginUninstallResponse"; export type { ProductSurface } from "./ProductSurface"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c41fb977f..98903e88a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -292,6 +292,10 @@ client_request_definitions! { params: v2::PluginInstallParams, response: v2::PluginInstallResponse, }, + PluginUninstall => "plugin/uninstall" { + params: v2::PluginUninstallParams, + response: v2::PluginUninstallResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, inspect_params: true, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4536c690a..1ec397f18 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2921,6 +2921,18 @@ pub struct PluginInstallResponse { pub apps_needing_auth: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + impl From for SkillMetadata { fn from(value: CoreSkillMetadata) -> Self { Self { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index da6362b4d..6913649e3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -159,6 +159,7 @@ Example with notification opt-out: - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. - `plugin/install` — install a plugin from a discovered marketplace entry and return any apps that still need auth (**under development; do not call from production clients yet**). +- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**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). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b2d5c0a75..2cb02916f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -90,6 +90,8 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; @@ -217,6 +219,7 @@ use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSourceSummary; use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; +use codex_core::plugins::PluginUninstallError as CorePluginUninstallError; use codex_core::plugins::load_plugin_apps; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; @@ -711,6 +714,10 @@ impl CodexMessageProcessor { self.plugin_install(to_connection_request_id(request_id), params) .await; } + ClientRequest::PluginUninstall { request_id, params } => { + self.plugin_uninstall(to_connection_request_id(request_id), params) + .await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start( to_connection_request_id(request_id), @@ -5516,6 +5523,57 @@ impl CodexMessageProcessor { } } + async fn plugin_uninstall( + &self, + request_id: ConnectionRequestId, + params: PluginUninstallParams, + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + + match plugins_manager.uninstall_plugin(params.plugin_id).await { + 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::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, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index a52338b2a..2398cd8fc 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ServerRequest; @@ -454,6 +455,15 @@ impl McpProcess { self.send_request("plugin/install", params).await } + /// Send a `plugin/uninstall` JSON-RPC request. + pub async fn send_plugin_uninstall_request( + &mut self, + params: PluginUninstallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/uninstall", params).await + } + /// Send a `plugin/list` JSON-RPC request. pub async fn send_plugin_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1b62fac43..20e975879 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -19,6 +19,7 @@ mod output_schema; mod plan_item; mod plugin_install; mod plugin_list; +mod plugin_uninstall; mod rate_limits; mod realtime_conversation; mod request_permissions; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs new file mode 100644 index 000000000..fb8cf582a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let params = PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + }; + + let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + assert!( + !codex_home + .path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + + let request_id = mcp.send_plugin_uninstall_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + Ok(()) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local/.codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 9e425bcfe..153e4ee4d 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -10,6 +10,7 @@ use super::plugin_manifest_name; use super::plugin_manifest_paths; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; +use super::store::PluginIdError; use super::store::PluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; @@ -18,6 +19,8 @@ use crate::config::Config; use crate::config::ConfigService; use crate::config::ConfigServiceError; use crate::config::ConfigToml; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; use crate::config::profile::ConfigProfile; use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; @@ -290,6 +293,24 @@ impl PluginsManager { Ok(result) } + pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { + let plugin_id = PluginId::parse(&plugin_id)?; + let store = self.store.clone(); + let plugin_id_for_store = plugin_id.clone(); + tokio::task::spawn_blocking(move || store.uninstall(&plugin_id_for_store)) + .await + .map_err(PluginUninstallError::join)??; + + ConfigEditsBuilder::new(&self.codex_home) + .with_edits([ConfigEdit::ClearPath { + segments: vec!["plugins".to_string(), plugin_id.as_key()], + }]) + .apply() + .await?; + + Ok(()) + } + pub fn list_marketplaces_for_config( &self, config: &Config, @@ -428,6 +449,31 @@ impl PluginInstallError { } } +#[derive(Debug, thiserror::Error)] +pub enum PluginUninstallError { + #[error("{0}")] + InvalidPluginId(#[from] PluginIdError), + + #[error("{0}")] + Store(#[from] PluginStoreError), + + #[error("{0}")] + Config(#[from] anyhow::Error), + + #[error("failed to join plugin uninstall task: {0}")] + Join(#[from] tokio::task::JoinError), +} + +impl PluginUninstallError { + fn join(source: tokio::task::JoinError) -> Self { + Self::Join(source) + } + + pub fn is_invalid_request(&self) -> bool { + matches!(self, Self::InvalidPluginId(_)) + } +} + fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool { // Plugins are intentionally opt-in from the persisted user config only. Project config // layers should not be able to enable plugin loading for a checkout. @@ -1553,6 +1599,43 @@ mod tests { assert!(config.contains("enabled = true")); } + #[tokio::test] + async fn uninstall_plugin_removes_cache_and_config_entry() { + let tmp = tempfile::tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + manager + .uninstall_plugin("sample-plugin@debug".to_string()) + .await + .unwrap(); + + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin") + .exists() + ); + let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); + assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); + } + #[tokio::test] async fn list_marketplaces_includes_enabled_state() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 179647854..8a34ba9ad 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -17,6 +17,7 @@ pub use manager::PluginCapabilitySummary; pub use manager::PluginInstallError; pub use manager::PluginInstallRequest; pub use manager::PluginLoadOutcome; +pub use manager::PluginUninstallError; pub use manager::PluginsManager; pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 60da25ecb..0611df9a0 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -135,6 +135,15 @@ impl PluginStore { installed_path, }) } + + pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { + let plugin_path = self + .root + .as_path() + .join(&plugin_id.marketplace_name) + .join(&plugin_id.plugin_name); + remove_existing_target(&plugin_path) + } } #[derive(Debug, thiserror::Error)]