From 1e877ccdd2ecf19936398120400bcc813eded06e Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 4 Mar 2026 19:08:18 -0500 Subject: [PATCH] plugin: support local-based marketplace.json + install endpoint. (#13422) Support marketplace.json that points to a local file, with ``` "source": { "source": "local", "path": "./plugin-1" }, ``` Add a new plugin/install endpoint which add the plugin to the cache folder and enable it in config.toml. --- .../schema/json/ClientRequest.json | 45 +++ .../codex_app_server_protocol.schemas.json | 52 +++ .../codex_app_server_protocol.v2.schemas.json | 52 +++ .../schema/json/v2/PluginInstallParams.json | 23 ++ .../schema/json/v2/PluginInstallResponse.json | 5 + .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/PluginInstallParams.ts | 5 + .../typescript/v2/PluginInstallResponse.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 15 + codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 58 +++ codex-rs/core/src/plugins/manager.rs | 68 +++- codex-rs/core/src/plugins/marketplace.rs | 370 ++++++++++++++++++ codex-rs/core/src/plugins/mod.rs | 3 +- codex-rs/core/src/plugins/store.rs | 114 +++--- 17 files changed, 756 insertions(+), 69 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts create mode 100644 codex-rs/core/src/plugins/marketplace.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 0e3c68de3..65a28ad2b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -951,6 +951,27 @@ ], "type": "string" }, + "PluginInstallParams": { + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "marketplaceName": { + "type": "string" + }, + "pluginName": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "pluginName" + ], + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -3339,6 +3360,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/install" + ], + "title": "Plugin/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installRequest", + "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 86cbda281..0c51006bd 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 @@ -826,6 +826,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/install" + ], + "title": "Plugin/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installRequest", + "type": "object" + }, { "properties": { "id": { @@ -10897,6 +10921,34 @@ ], "type": "string" }, + "PluginInstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "marketplaceName": { + "type": "string" + }, + "pluginName": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" + }, + "PluginInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginInstallResponse", + "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 342820599..64ae44807 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 @@ -1300,6 +1300,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/install" + ], + "title": "Plugin/installRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installRequest", + "type": "object" + }, { "properties": { "id": { @@ -8235,6 +8259,34 @@ ], "type": "string" }, + "PluginInstallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "marketplaceName": { + "type": "string" + }, + "pluginName": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" + }, + "PluginInstallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginInstallResponse", + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json new file mode 100644 index 000000000..3aaaadf44 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "type": [ + "string", + "null" + ] + }, + "marketplaceName": { + "type": "string" + }, + "pluginName": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "pluginName" + ], + "title": "PluginInstallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json new file mode 100644 index 000000000..d430a6056 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginInstallResponse", + "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 379ab414c..cd8fd7584 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -22,6 +22,7 @@ import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; +import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -48,4 +49,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": "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": "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": "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": "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": "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/PluginInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts new file mode 100644 index 000000000..2d17c1701 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.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 PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts new file mode 100644 index 000000000..843d9d4b7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.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 PluginInstallResponse = 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 0c9b2dc58..9776cea4d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -124,6 +124,8 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; +export type { PluginInstallParams } from "./PluginInstallParams"; +export type { PluginInstallResponse } from "./PluginInstallResponse"; 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 19e1fb177..fa95cbf24 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -264,6 +264,10 @@ client_request_definitions! { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, }, + PluginInstall => "plugin/install" { + params: v2::PluginInstallParams, + response: v2::PluginInstallResponse, + }, 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 185d74fad..cc7cbf44a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2546,6 +2546,21 @@ pub struct SkillsConfigWriteResponse { pub effective_enabled: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallParams { + pub marketplace_name: String, + pub plugin_name: String, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallResponse {} + 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 f7d762876..41a3bc5f5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -153,6 +153,7 @@ Example with notification opt-out: - `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `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 by `pluginName` and `marketplaceName` (**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 8d9f08ce6..4ad15a960 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -77,6 +77,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; @@ -196,6 +198,8 @@ use codex_core::mcp::collect_mcp_snapshot; use codex_core::mcp::group_tools_by_server; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::parse_cursor; +use codex_core::plugins::PluginInstallError as CorePluginInstallError; +use codex_core::plugins::PluginInstallRequest; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; @@ -658,6 +662,10 @@ impl CodexMessageProcessor { self.skills_config_write(to_connection_request_id(request_id), params) .await; } + ClientRequest::PluginInstall { request_id, params } => { + self.plugin_install(to_connection_request_id(request_id), params) + .await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start( to_connection_request_id(request_id), @@ -4984,6 +4992,56 @@ impl CodexMessageProcessor { } } + async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { + let PluginInstallParams { + marketplace_name, + plugin_name, + cwd, + } = params; + + let plugins_manager = self.thread_manager.plugins_manager(); + let request = PluginInstallRequest { + plugin_name, + marketplace_name, + cwd: cwd.unwrap_or_else(|| self.config.cwd.clone()), + }; + + match plugins_manager.install_plugin(request).await { + Ok(_) => { + plugins_manager.clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + self.outgoing + .send_response(request_id, PluginInstallResponse {}) + .await; + } + Err(err) => { + if err.is_invalid_request() { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + + match err { + CorePluginInstallError::Config(err) => { + self.send_internal_error( + request_id, + format!("failed to persist installed plugin config: {err}"), + ) + .await; + } + CorePluginInstallError::Join(err) => { + self.send_internal_error( + request_id, + format!("failed to install plugin: {err}"), + ) + .await; + } + CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {} + } + } + } + } + async fn turn_start( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index c5a7b894a..1b49bf46a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1,8 +1,9 @@ use super::load_plugin_manifest; +use super::marketplace::MarketplaceError; +use super::marketplace::resolve_marketplace_plugin; use super::plugin_manifest_name; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; -use super::store::PluginInstallRequest; use super::store::PluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; @@ -38,6 +39,13 @@ const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AppConnectorId(pub String); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstallRequest { + pub plugin_name: String, + pub marketplace_name: String, + pub cwd: PathBuf, +} + #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { pub config_name: String, @@ -169,10 +177,17 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { + let resolved = resolve_marketplace_plugin( + &request.cwd, + &request.plugin_name, + &request.marketplace_name, + )?; let store = self.store.clone(); - let result = tokio::task::spawn_blocking(move || store.install(request)) - .await - .map_err(PluginInstallError::join)??; + let result = tokio::task::spawn_blocking(move || { + store.install(resolved.source_path.into_path_buf(), resolved.plugin_id) + }) + .await + .map_err(PluginInstallError::join)??; ConfigService::new_with_defaults(self.codex_home.clone()) .write_value(ConfigValueWriteParams { @@ -194,6 +209,9 @@ impl PluginsManager { #[derive(Debug, thiserror::Error)] pub enum PluginInstallError { + #[error("{0}")] + Marketplace(#[from] MarketplaceError), + #[error("{0}")] Store(#[from] PluginStoreError), @@ -208,6 +226,18 @@ impl PluginInstallError { fn join(source: tokio::task::JoinError) -> Self { Self::Join(source) } + + pub fn is_invalid_request(&self) -> bool { + matches!( + self, + Self::Marketplace( + MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::DuplicatePlugin { .. } + | MarketplaceError::InvalidPlugin(_) + ) | Self::Store(PluginStoreError::Invalid(_)) + ) + } } fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool { @@ -879,12 +909,36 @@ mod tests { #[tokio::test] async fn install_plugin_updates_config_with_relative_path_and_plugin_key() { let tmp = tempfile::tempdir().unwrap(); - write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin( + &repo_root.join(".agents/plugins"), + "sample-plugin", + "sample-plugin", + ); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); let result = PluginsManager::new(tmp.path().to_path_buf()) .install_plugin(PluginInstallRequest { - source_path: tmp.path().join("sample-plugin"), - marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + marketplace_name: "debug".to_string(), + cwd: repo_root.clone(), }) .await .unwrap(); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs new file mode 100644 index 000000000..9051c8fad --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -0,0 +1,370 @@ +use super::store::PluginId; +use super::store::PluginIdError; +use crate::git_info::get_git_repo_root; +use codex_utils_absolute_path::AbsolutePathBuf; +use dirs::home_dir; +use serde::Deserialize; +use std::fs; +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedMarketplacePlugin { + pub plugin_id: PluginId, + pub source_path: AbsolutePathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceError { + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("invalid marketplace file `{path}`: {message}")] + InvalidMarketplaceFile { path: PathBuf, message: String }, + + #[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")] + PluginNotFound { + plugin_name: String, + marketplace_name: String, + }, + + #[error( + "multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`" + )] + DuplicatePlugin { + plugin_name: String, + marketplace_name: String, + }, + + #[error("{0}")] + InvalidPlugin(String), +} + +impl MarketplaceError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +// For now, marketplace discovery always reads from disk so installs see the latest +// marketplace.json contents without any in-memory cache invalidation. +pub fn resolve_marketplace_plugin( + cwd: &Path, + plugin_name: &str, + marketplace_name: &str, +) -> Result { + resolve_marketplace_plugin_from_paths( + &discover_marketplace_paths(cwd), + plugin_name, + marketplace_name, + ) +} + +fn resolve_marketplace_plugin_from_paths( + marketplace_paths: &[PathBuf], + plugin_name: &str, + marketplace_name: &str, +) -> Result { + for marketplace_path in marketplace_paths { + let marketplace = load_marketplace(marketplace_path)?; + let discovered_marketplace_name = marketplace.name; + let mut matches = marketplace + .plugins + .into_iter() + .filter(|plugin| plugin.name == plugin_name) + .collect::>(); + + if discovered_marketplace_name != marketplace_name || matches.is_empty() { + continue; + } + + if matches.len() > 1 { + return Err(MarketplaceError::DuplicatePlugin { + plugin_name: plugin_name.to_string(), + marketplace_name: marketplace_name.to_string(), + }); + } + + if let Some(plugin) = matches.pop() { + let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err( + |err| match err { + PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), + }, + )?; + return Ok(ResolvedMarketplacePlugin { + plugin_id, + source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?, + }); + } + } + + Err(MarketplaceError::PluginNotFound { + plugin_name: plugin_name.to_string(), + marketplace_name: marketplace_name.to_string(), + }) +} + +fn discover_marketplace_paths(cwd: &Path) -> Vec { + let mut paths = Vec::new(); + if let Some(repo_root) = get_git_repo_root(cwd) { + let path = repo_root.join(MARKETPLACE_RELATIVE_PATH); + if path.is_file() { + paths.push(path); + } + } + + if let Some(home) = home_dir() { + let path = home.join(MARKETPLACE_RELATIVE_PATH); + if path.is_file() { + paths.push(path); + } + } + + paths +} + +fn load_marketplace(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?; + serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile { + path: path.to_path_buf(), + message: err.to_string(), + }) +} + +fn resolve_plugin_source_path( + marketplace_path: &Path, + source: MarketplacePluginSource, +) -> Result { + match source { + MarketplacePluginSource::Local { path } => { + let Some(path) = path.strip_prefix("./") else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must start with `./`".to_string(), + }); + }; + if path.is_empty() { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must not be empty".to_string(), + }); + } + + let relative_source_path = Path::new(path); + if relative_source_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "local plugin source path must stay within the marketplace directory" + .to_string(), + }); + } + + let source_path = marketplace_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(relative_source_path); + AbsolutePathBuf::try_from(source_path).map_err(|err| { + MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: format!("plugin source path must resolve to an absolute path: {err}"), + } + }) + } + } +} + +#[derive(Debug, Deserialize)] +struct MarketplaceFile { + name: String, + plugins: Vec, +} + +#[derive(Debug, Deserialize)] +struct MarketplacePlugin { + name: String, + source: MarketplacePluginSource, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "lowercase")] +enum MarketplacePluginSource { + Local { path: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join("nested")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = + resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated") + .unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) + .unwrap(), + source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1")) + .unwrap(), + } + ); + } + + #[test] + fn resolve_marketplace_plugin_reports_missing_plugin() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{"name":"codex-curated","plugins":[]}"#, + ) + .unwrap(); + + let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `missing` was not found in marketplace `codex-curated`" + ); + } + + #[test] + fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + let home_marketplace = home_root.join(".agents/plugins/marketplace.json"); + let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(home_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + + fs::write( + home_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./home-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_marketplace.clone(), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./repo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = resolve_marketplace_plugin_from_paths( + &[repo_marketplace, home_marketplace], + "local-plugin", + "codex-curated", + ) + .unwrap(); + + assert_eq!( + resolved, + ResolvedMarketplacePlugin { + plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) + .unwrap(), + source_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/repo-plugin"), + ) + .unwrap(), + } + ); + } + + #[test] + fn resolve_marketplace_plugin_rejects_non_relative_local_paths() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "../plugin-1" + } + } + ] +}"#, + ) + .unwrap(); + + let err = + resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err(); + + assert_eq!( + err.to_string(), + format!( + "invalid marketplace file `{}`: local plugin source path must start with `./`", + repo_root.join(".agents/plugins/marketplace.json").display() + ) + ); + } +} diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 37b4009bc..914963b09 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,15 +1,16 @@ mod manager; mod manifest; +mod marketplace; mod store; pub use manager::AppConnectorId; pub use manager::LoadedPlugin; pub use manager::PluginInstallError; +pub use manager::PluginInstallRequest; pub use manager::PluginLoadOutcome; pub use manager::PluginsManager; pub(crate) use manager::plugin_namespace_for_skill_path; pub(crate) use manifest::load_plugin_manifest; pub(crate) use manifest::plugin_manifest_name; pub use store::PluginId; -pub use store::PluginInstallRequest; pub use store::PluginInstallResult; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 96c355805..74ae60515 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -7,14 +7,13 @@ use std::io; use std::path::Path; use std::path::PathBuf; -const DEFAULT_MARKETPLACE_NAME: &str = "debug"; pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local"; pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache"; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginInstallRequest { - pub source_path: PathBuf, - pub marketplace_name: Option, +#[derive(Debug, thiserror::Error)] +pub enum PluginIdError { + #[error("{0}")] + Invalid(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,34 +23,32 @@ pub struct PluginId { } impl PluginId { - pub fn new(plugin_name: String, marketplace_name: String) -> Result { - validate_plugin_segment(&plugin_name, "plugin name") - .map_err(PluginStoreError::InvalidPluginKey)?; + pub fn new(plugin_name: String, marketplace_name: String) -> Result { + validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?; validate_plugin_segment(&marketplace_name, "marketplace name") - .map_err(PluginStoreError::InvalidPluginKey)?; + .map_err(PluginIdError::Invalid)?; Ok(Self { plugin_name, marketplace_name, }) } - pub fn parse(plugin_key: &str) -> Result { + pub fn parse(plugin_key: &str) -> Result { let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else { - return Err(PluginStoreError::InvalidPluginKey(format!( + return Err(PluginIdError::Invalid(format!( "invalid plugin key `{plugin_key}`; expected @" ))); }; if plugin_name.is_empty() || marketplace_name.is_empty() { - return Err(PluginStoreError::InvalidPluginKey(format!( + return Err(PluginIdError::Invalid(format!( "invalid plugin key `{plugin_key}`; expected @" ))); } Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err { - PluginStoreError::InvalidPluginKey(message) => { - PluginStoreError::InvalidPluginKey(format!("{message} in `{plugin_key}`")) + PluginIdError::Invalid(message) => { + PluginIdError::Invalid(format!("{message} in `{plugin_key}`")) } - other => other, }) } @@ -97,29 +94,24 @@ impl PluginStore { pub fn install( &self, - request: PluginInstallRequest, + source_path: PathBuf, + plugin_id: PluginId, ) -> Result { - let source_path = request.source_path; if !source_path.is_dir() { - return Err(PluginStoreError::InvalidPlugin(format!( + return Err(PluginStoreError::Invalid(format!( "plugin source path is not a directory: {}", source_path.display() ))); } let plugin_name = plugin_name_for_source(&source_path)?; - let marketplace_name = request - .marketplace_name - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_MARKETPLACE_NAME.to_string()); + if plugin_name != plugin_id.plugin_name { + return Err(PluginStoreError::Invalid(format!( + "plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`", + plugin_id.plugin_name + ))); + } let plugin_version = DEFAULT_PLUGIN_VERSION.to_string(); - let plugin_id = match PluginId::new(plugin_name, marketplace_name) { - Ok(plugin_id) => plugin_id, - Err(PluginStoreError::InvalidPluginKey(message)) => { - return Err(PluginStoreError::InvalidPlugin(message)); - } - Err(err) => return Err(err), - }; let installed_path = self .plugin_root(&plugin_id, &plugin_version) .into_path_buf(); @@ -151,10 +143,7 @@ pub enum PluginStoreError { }, #[error("{0}")] - InvalidPlugin(String), - - #[error("{0}")] - InvalidPluginKey(String), + Invalid(String), } impl PluginStoreError { @@ -166,14 +155,14 @@ impl PluginStoreError { fn plugin_name_for_source(source_path: &Path) -> Result { let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH); if !manifest_path.is_file() { - return Err(PluginStoreError::InvalidPlugin(format!( + return Err(PluginStoreError::Invalid(format!( "missing plugin manifest: {}", manifest_path.display() ))); } let manifest = load_plugin_manifest(source_path).ok_or_else(|| { - PluginStoreError::InvalidPlugin(format!( + PluginStoreError::Invalid(format!( "missing or invalid plugin manifest: {}", manifest_path.display() )) @@ -181,7 +170,7 @@ fn plugin_name_for_source(source_path: &Path) -> Result