From 520ed724d2f598999b5e1cd330ab130ff9b3ffbd Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 5 Mar 2026 21:58:50 -0500 Subject: [PATCH] support plugin/list. (#13540) Introduce a plugin/list which reads from local marketplace.json. Also update the signature for plugin/install. --- .../schema/json/ClientRequest.json | 51 +- .../codex_app_server_protocol.schemas.json | 134 +++++- .../codex_app_server_protocol.v2.schemas.json | 134 +++++- .../schema/json/v2/PluginInstallParams.json | 18 +- .../schema/json/v2/PluginListParams.json | 23 + .../schema/json/v2/PluginListResponse.json | 83 ++++ .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/PluginInstallParams.ts | 3 +- .../schema/typescript/v2/PluginListParams.ts | 11 + .../typescript/v2/PluginListResponse.ts | 6 + .../typescript/v2/PluginMarketplaceEntry.ts | 6 + .../schema/typescript/v2/PluginSource.ts | 5 + .../schema/typescript/v2/PluginSummary.ts | 6 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 49 +- codex-rs/app-server/README.md | 3 +- .../app-server/src/codex_message_processor.rs | 113 ++++- .../app-server/tests/common/mcp_process.rs | 29 ++ codex-rs/app-server/tests/suite/v2/mod.rs | 2 + .../tests/suite/v2/plugin_install.rs | 66 +++ .../app-server/tests/suite/v2/plugin_list.rs | 299 ++++++++++++ codex-rs/core/src/plugins/manager.rs | 289 +++++++++++- codex-rs/core/src/plugins/marketplace.rs | 444 ++++++++++++++---- codex-rs/core/src/plugins/mod.rs | 4 + 25 files changed, 1645 insertions(+), 145 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts create mode 100644 codex-rs/app-server/tests/suite/v2/plugin_install.rs create mode 100644 codex-rs/app-server/tests/suite/v2/plugin_list.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 65a28ad2b..ff25b72a3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -953,25 +953,34 @@ }, "PluginInstallParams": { "properties": { - "cwd": { - "type": [ - "string", - "null" - ] - }, - "marketplaceName": { - "type": "string" + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" }, "pluginName": { "type": "string" } }, "required": [ - "marketplaceName", + "marketplacePath", "pluginName" ], "type": "object" }, + "PluginListParams": { + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, "ProductSurface": { "enum": [ "chatgpt", @@ -3264,6 +3273,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/list" + ], + "title": "Plugin/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/listRequest", + "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 c14ca1acd..16a182f8d 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 @@ -716,6 +716,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/list" + ], + "title": "Plugin/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -11174,21 +11198,15 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cwd": { - "type": [ - "string", - "null" - ] - }, - "marketplaceName": { - "type": "string" + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "pluginName": { "type": "string" } }, "required": [ - "marketplaceName", + "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -11199,6 +11217,104 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginListParams", + "type": "object" + }, + "PluginListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaces": { + "items": { + "$ref": "#/definitions/v2/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginListResponse", + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "plugins": { + "items": { + "$ref": "#/definitions/v2/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "path", + "plugins" + ], + "type": "object" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/v2/PluginSource" + } + }, + "required": [ + "enabled", + "name", + "source" + ], + "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 5a783a538..d74bdcc16 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 @@ -1211,6 +1211,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/list" + ], + "title": "Plugin/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -8371,21 +8395,15 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "cwd": { - "type": [ - "string", - "null" - ] - }, - "marketplaceName": { - "type": "string" + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" }, "pluginName": { "type": "string" } }, "required": [ - "marketplaceName", + "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -8396,6 +8414,104 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginListParams", + "type": "object" + }, + "PluginListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginListResponse", + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "path", + "plugins" + ], + "type": "object" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "enabled", + "name", + "source" + ], + "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 index 3aaaadf44..9e9bf6de8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -1,21 +1,21 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "cwd": { - "type": [ - "string", - "null" - ] - }, - "marketplaceName": { + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + } + }, + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" }, "pluginName": { "type": "string" } }, "required": [ - "marketplaceName", + "marketplacePath", "pluginName" ], "title": "PluginInstallParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json new file mode 100644 index 000000000..a54a30a34 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json new file mode 100644 index 000000000..d4ac31664 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginMarketplaceEntry": { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "path", + "plugins" + ], + "type": "object" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "enabled", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginListResponse", + "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 cd8fd7584..d402cf87b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -23,6 +23,7 @@ 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 { PluginListParams } from "./v2/PluginListParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -49,4 +50,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": "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, }; +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": "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 index 2d17c1701..86326b130 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -1,5 +1,6 @@ // 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. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, }; +export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts new file mode 100644 index 000000000..7f6f4e5e6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -0,0 +1,11 @@ +// 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. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginListParams = { +/** + * Optional working directories used to discover repo marketplaces. When omitted, + * only home-scoped marketplaces are considered. + */ +cwds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts new file mode 100644 index 000000000..7c3cc692c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; + +export type PluginListResponse = { marketplaces: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts new file mode 100644 index 000000000..5fa44a486 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginSummary } from "./PluginSummary"; + +export type PluginMarketplaceEntry = { name: string, path: string, plugins: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts new file mode 100644 index 000000000..e70243b10 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.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 PluginSource = { "type": "local", path: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts new file mode 100644 index 000000000..2f87a1462 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginSource } from "./PluginSource"; + +export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, }; 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 b34d28e5b..daa8f7113 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -129,6 +129,11 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginListParams } from "./PluginListParams"; +export type { PluginListResponse } from "./PluginListResponse"; +export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; +export type { PluginSource } from "./PluginSource"; +export type { PluginSummary } from "./PluginSummary"; 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 3cb0806cd..da1feb496 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -248,6 +248,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + PluginList => "plugin/list" { + params: v2::PluginListParams, + response: v2::PluginListResponse, + }, SkillsRemoteList => "skills/remote/list" { params: v2::SkillsRemoteReadParams, response: v2::SkillsRemoteReadResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a0b16b128..f7f5adf64 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2371,6 +2371,23 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListParams { + /// Optional working directories used to discover repo marketplaces. When omitted, + /// only home-scoped marketplaces are considered. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListResponse { + pub marketplaces: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2534,6 +2551,34 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginMarketplaceEntry { + pub name: String, + pub path: PathBuf, + pub plugins: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSummary { + pub name: String, + pub source: PluginSource, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PluginSource { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Local { path: PathBuf }, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2553,10 +2598,8 @@ pub struct SkillsConfigWriteResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginInstallParams { - pub marketplace_name: String, + pub marketplace_path: AbsolutePathBuf, pub plugin_name: String, - #[ts(optional = nullable)] - pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 72c35b4ec..8b7500ecf 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -148,12 +148,13 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). - `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**). +- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath` (**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 44a74a067..213709b9d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -79,6 +79,11 @@ 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::PluginListParams; +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::ProductSurface as ApiProductSurface; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; @@ -198,6 +203,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::MarketplaceError; +use codex_core::plugins::MarketplacePluginSourceSummary; use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; use codex_core::read_head_for_summary; @@ -646,6 +653,10 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } + ClientRequest::PluginList { request_id, params } => { + self.plugin_list(to_connection_request_id(request_id), params) + .await; + } ClientRequest::SkillsRemoteList { request_id, params } => { self.skills_remote_list(to_connection_request_id(request_id), params) .await; @@ -4318,6 +4329,30 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } + async fn send_marketplace_error( + &self, + request_id: ConnectionRequestId, + err: MarketplaceError, + action: &str, + ) { + match err { + MarketplaceError::MarketplaceNotFound { .. } => { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + } + MarketplaceError::Io { .. } => { + self.send_internal_error(request_id, format!("failed to {action}: {err}")) + .await; + } + MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::InvalidPlugin(_) => { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + } + } + } + async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { match thread.submit(Op::Shutdown).await { Ok(_) => { @@ -4924,6 +4959,66 @@ impl CodexMessageProcessor { .await; } + async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { + let plugins_manager = self.thread_manager.plugins_manager(); + let roots = params.cwds.unwrap_or_default(); + + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + + let data = match tokio::task::spawn_blocking(move || { + let marketplaces = plugins_manager.list_marketplaces_for_config(&config, &roots)?; + Ok::, MarketplaceError>( + marketplaces + .into_iter() + .map(|marketplace| PluginMarketplaceEntry { + name: marketplace.name, + path: marketplace.path, + plugins: marketplace + .plugins + .into_iter() + .map(|plugin| PluginSummary { + enabled: plugin.enabled, + name: plugin.name, + source: match plugin.source { + MarketplacePluginSourceSummary::Local { path } => { + PluginSource::Local { path } + } + }, + }) + .collect(), + }) + .collect(), + ) + }) + .await + { + Ok(Ok(data)) => data, + Ok(Err(err)) => { + self.send_marketplace_error(request_id, err, "list marketplace plugins") + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to list marketplace plugins: {err}"), + ) + .await; + return; + } + }; + + self.outgoing + .send_response(request_id, PluginListResponse { marketplaces: data }) + .await; + } + async fn skills_remote_list( &self, request_id: ConnectionRequestId, @@ -5034,16 +5129,14 @@ impl CodexMessageProcessor { async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { let PluginInstallParams { - marketplace_name, + marketplace_path, 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()), + marketplace_path, }; match plugins_manager.install_plugin(request).await { @@ -5062,6 +5155,10 @@ impl CodexMessageProcessor { } match err { + CorePluginInstallError::Marketplace(err) => { + self.send_marketplace_error(request_id, err, "install plugin") + .await; + } CorePluginInstallError::Config(err) => { self.send_internal_error( request_id, @@ -5076,7 +5173,13 @@ impl CodexMessageProcessor { ) .await; } - CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {} + CorePluginInstallError::Store(err) => { + self.send_internal_error( + request_id, + format!("failed to install plugin: {err}"), + ) + .await; + } } } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 753816b8d..40df460ad 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -35,6 +35,8 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountParams; 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::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ServerRequest; @@ -439,6 +441,33 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `plugin/install` JSON-RPC request. + pub async fn send_plugin_install_request( + &mut self, + params: PluginInstallParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/install", params).await + } + + /// Send a `plugin/list` JSON-RPC request. + pub async fn send_plugin_list_request( + &mut self, + params: PluginListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/list", params).await + } + + /// Send a JSON-RPC request with raw params for protocol-level validation tests. + pub async fn send_raw_request( + &mut self, + method: &str, + params: Option, + ) -> anyhow::Result { + self.send_request(method, params).await + } + /// Send a `collaborationMode/list` JSON-RPC request. pub async fn send_list_collaboration_modes_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 ce029b48b..283928d6a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -15,6 +15,8 @@ mod mcp_server_elicitation; mod model_list; mod output_schema; mod plan_item; +mod plugin_install; +mod plugin_list; mod rate_limits; mod realtime_conversation; mod request_user_input; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs new file mode 100644 index 000000000..811e789ed --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +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_install_rejects_relative_marketplace_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/install", + Some(serde_json::json!({ + "marketplacePath": "relative-marketplace.json", + "pluginName": "missing-plugin", + })), + ) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("Invalid request")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: AbsolutePathBuf::try_from( + codex_home.path().join("missing-marketplace.json"), + )?, + plugin_name: "missing-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("marketplace file")); + assert!(err.error.message.contains("does not exist")); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs new file mode 100644 index 000000000..843550099 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -0,0 +1,299 @@ +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::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::RequestId; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_utils_absolute_path::AbsolutePathBuf; +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_list_returns_invalid_request_for_invalid_marketplace_file() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + "{not json", + )?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid marketplace file")); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_rejects_relative_cwds() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/list", + Some(serde_json::json!({ + "cwds": ["relative-root"], + })), + ) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("Invalid request")); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_accepts_omitted_cwds() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "home-plugin", + "source": { + "source": "local", + "path": "./home-plugin" + } + } + ] +}"#, + )?; + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { cwds: None }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: PluginListResponse = to_response(response)?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_enabled_state_from_config() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + }, + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."enabled-plugin@codex-curated"] +enabled = true + +[plugins."disabled-plugin@codex-curated"] +enabled = false +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json") + }) + .expect("expected repo marketplace entry"); + + assert_eq!(marketplace.name, "codex-curated"); + assert_eq!(marketplace.plugins.len(), 2); + assert_eq!(marketplace.plugins[0].name, "enabled-plugin"); + assert_eq!(marketplace.plugins[0].enabled, true); + assert_eq!(marketplace.plugins[1].name, "disabled-plugin"); + assert_eq!(marketplace.plugins[1].enabled, false); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./shared-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."shared-plugin@codex-curated"] +enabled = true +"#, + )?; + + let workspace_enabled = TempDir::new()?; + std::fs::create_dir_all(workspace_enabled.path().join(".git"))?; + std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?; + std::fs::write( + workspace_enabled + .path() + .join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./shared-plugin" + } + } + ] +}"#, + )?; + std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?; + std::fs::write( + workspace_enabled.path().join(".codex/config.toml"), + r#"[plugins."shared-plugin@codex-curated"] +enabled = false +"#, + )?; + set_project_trust_level( + codex_home.path(), + workspace_enabled.path(), + TrustLevel::Trusted, + )?; + + let workspace_default = TempDir::new()?; + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![ + AbsolutePathBuf::try_from(workspace_enabled.path())?, + AbsolutePathBuf::try_from(workspace_default.path())?, + ]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let shared_plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "shared-plugin") + .expect("expected shared-plugin entry"); + assert_eq!(shared_plugin.enabled, true); + Ok(()) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index b710f1333..6f46bf00d 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1,5 +1,7 @@ use super::load_plugin_manifest; use super::marketplace::MarketplaceError; +use super::marketplace::MarketplacePluginSourceSummary; +use super::marketplace::list_marketplaces; use super::marketplace::resolve_marketplace_plugin; use super::plugin_manifest_name; use super::store::DEFAULT_PLUGIN_VERSION; @@ -26,6 +28,7 @@ use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; use serde_json::json; use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -42,8 +45,21 @@ pub struct AppConnectorId(pub String); #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallRequest { pub plugin_name: String, - pub marketplace_name: String, - pub cwd: PathBuf, + pub marketplace_path: AbsolutePathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplaceSummary { + pub name: String, + pub path: PathBuf, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfiguredMarketplacePluginSummary { + pub name: String, + pub source: MarketplacePluginSourceSummary, + pub enabled: bool, } #[derive(Debug, Clone, PartialEq)] @@ -234,11 +250,7 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { - let resolved = resolve_marketplace_plugin( - &request.cwd, - &request.plugin_name, - &request.marketplace_name, - )?; + let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; let store = self.store.clone(); let result = tokio::task::spawn_blocking(move || { store.install(resolved.source_path.into_path_buf(), resolved.plugin_id) @@ -262,6 +274,56 @@ impl PluginsManager { Ok(result) } + + pub fn list_marketplaces_for_config( + &self, + config: &Config, + additional_roots: &[AbsolutePathBuf], + ) -> Result, MarketplaceError> { + let configured_plugins = self + .plugins_for_config(config) + .plugins() + .iter() + .map(|plugin| (plugin.config_name.clone(), plugin.enabled)) + .collect::>(); + let marketplaces = list_marketplaces(additional_roots)?; + let mut seen_plugin_keys = HashSet::new(); + + Ok(marketplaces + .into_iter() + .filter_map(|marketplace| { + let marketplace_name = marketplace.name.clone(); + let plugins = marketplace + .plugins + .into_iter() + .filter_map(|plugin| { + let plugin_key = format!("{}@{marketplace_name}", plugin.name); + if !seen_plugin_keys.insert(plugin_key.clone()) { + return None; + } + + Some(ConfiguredMarketplacePluginSummary { + // Enabled state is keyed by `@`, so duplicate + // plugin entries from duplicate marketplace files intentionally + // resolve to the first discovered source. + enabled: configured_plugins + .get(&plugin_key) + .copied() + .unwrap_or(false), + name: plugin.name, + source: plugin.source, + }) + }) + .collect::>(); + + (!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary { + name: marketplace.name, + path: marketplace.path, + plugins, + }) + }) + .collect()) + } } #[derive(Debug, thiserror::Error)] @@ -288,9 +350,9 @@ impl PluginInstallError { matches!( self, Self::Marketplace( - MarketplaceError::InvalidMarketplaceFile { .. } + MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } | MarketplaceError::PluginNotFound { .. } - | MarketplaceError::DuplicatePlugin { .. } | MarketplaceError::InvalidPlugin(_) ) | Self::Store(PluginStoreError::Invalid(_)) ) @@ -1086,8 +1148,10 @@ mod tests { let result = PluginsManager::new(tmp.path().to_path_buf()) .install_plugin(PluginInstallRequest { plugin_name: "sample-plugin".to_string(), - marketplace_name: "debug".to_string(), - cwd: repo_root.clone(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), }) .await .unwrap(); @@ -1106,4 +1170,207 @@ mod tests { assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); assert!(config.contains("enabled = true")); } + + #[tokio::test] + async fn list_marketplaces_for_config_includes_enabled_state() { + let tmp = tempfile::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": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + }, + { + "name": "disabled-plugin", + "source": { + "source": "local", + "path": "./disabled-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true + +[plugins."disabled-plugin@debug"] +enabled = false +"#, + ); + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("config should load"); + + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap(); + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path == tmp.path().join("repo/.agents/plugins/marketplace.json") + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplaceSummary { + name: "debug".to_string(), + path: tmp.path().join("repo/.agents/plugins/marketplace.json"), + plugins: vec![ + ConfiguredMarketplacePluginSummary { + name: "enabled-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: tmp.path().join("repo/.agents/plugins/enabled-plugin"), + }, + enabled: true, + }, + ConfiguredMarketplacePluginSummary { + name: "disabled-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: tmp.path().join("repo/.agents/plugins/disabled-plugin"), + }, + enabled: false, + }, + ], + } + ); + } + + #[tokio::test] + async fn list_marketplaces_for_config_uses_first_duplicate_plugin_entry() { + let tmp = tempfile::tempdir().unwrap(); + let repo_a_root = tmp.path().join("repo-a"); + let repo_b_root = tmp.path().join("repo-b"); + fs::create_dir_all(repo_a_root.join(".git")).unwrap(); + fs::create_dir_all(repo_b_root.join(".git")).unwrap(); + fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_a_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-a" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_b_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "dup-plugin", + "source": { + "source": "local", + "path": "./from-b" + } + }, + { + "name": "b-only-plugin", + "source": { + "source": "local", + "path": "./from-b-only" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."dup-plugin@debug"] +enabled = true + +[plugins."b-only-plugin@debug"] +enabled = false +"#, + ); + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("config should load"); + + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config( + &config, + &[ + AbsolutePathBuf::try_from(repo_a_root).unwrap(), + AbsolutePathBuf::try_from(repo_b_root).unwrap(), + ], + ) + .unwrap(); + + let repo_a_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path == tmp.path().join("repo-a/.agents/plugins/marketplace.json") + }) + .expect("repo-a marketplace should be listed"); + assert_eq!( + repo_a_marketplace.plugins, + vec![ConfiguredMarketplacePluginSummary { + name: "dup-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: tmp.path().join("repo-a/.agents/plugins/from-a"), + }, + enabled: true, + }] + ); + + let repo_b_marketplace = marketplaces + .iter() + .find(|marketplace| { + marketplace.path == tmp.path().join("repo-b/.agents/plugins/marketplace.json") + }) + .expect("repo-b marketplace should be listed"); + assert_eq!( + repo_b_marketplace.plugins, + vec![ConfiguredMarketplacePluginSummary { + name: "b-only-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: tmp.path().join("repo-b/.agents/plugins/from-b-only"), + }, + enabled: false, + }] + ); + + let duplicate_plugin_count = marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .filter(|plugin| plugin.name == "dup-plugin") + .count(); + assert_eq!(duplicate_plugin_count, 1); + } } diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 9051c8fad..56e8b15dd 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -18,6 +18,24 @@ pub struct ResolvedMarketplacePlugin { pub source_path: AbsolutePathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceSummary { + pub name: String, + pub path: PathBuf, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplacePluginSummary { + pub name: String, + pub source: MarketplacePluginSourceSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MarketplacePluginSourceSummary { + Local { path: PathBuf }, +} + #[derive(Debug, thiserror::Error)] pub enum MarketplaceError { #[error("{context}: {source}")] @@ -27,6 +45,9 @@ pub enum MarketplaceError { source: io::Error, }, + #[error("marketplace file `{path}` does not exist")] + MarketplaceNotFound { path: PathBuf }, + #[error("invalid marketplace file `{path}`: {message}")] InvalidMarketplaceFile { path: PathBuf, message: String }, @@ -36,14 +57,6 @@ pub enum MarketplaceError { 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), } @@ -54,77 +67,97 @@ impl MarketplaceError { } } -// For now, marketplace discovery always reads from disk so installs see the latest -// marketplace.json contents without any in-memory cache invalidation. +// Always read the specified marketplace file from disk so installs see the +// latest marketplace.json contents without any in-memory cache invalidation. pub fn resolve_marketplace_plugin( - cwd: &Path, + marketplace_path: &AbsolutePathBuf, plugin_name: &str, - marketplace_name: &str, ) -> Result { - resolve_marketplace_plugin_from_paths( - &discover_marketplace_paths(cwd), - plugin_name, - marketplace_name, - ) -} + let marketplace = load_marketplace(marketplace_path.as_path())?; + let marketplace_name = marketplace.name; + let plugin = marketplace + .plugins + .into_iter() + .find(|plugin| plugin.name == plugin_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::>(); + let Some(plugin) = plugin else { + return Err(MarketplaceError::PluginNotFound { + plugin_name: plugin_name.to_string(), + marketplace_name, + }); + }; - 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(), + let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err { + PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), + })?; + Ok(ResolvedMarketplacePlugin { + plugin_id, + source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?, }) } -fn discover_marketplace_paths(cwd: &Path) -> Vec { +pub fn list_marketplaces( + additional_roots: &[AbsolutePathBuf], +) -> Result, MarketplaceError> { + list_marketplaces_with_home(additional_roots, home_dir().as_deref()) +} + +fn list_marketplaces_with_home( + additional_roots: &[AbsolutePathBuf], + home_dir: Option<&Path>, +) -> Result, MarketplaceError> { + let mut marketplaces = Vec::new(); + + for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { + let marketplace = load_marketplace(marketplace_path.as_path())?; + let mut plugins = Vec::new(); + + for plugin in marketplace.plugins { + let source = match plugin.source { + MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local { + path: resolve_plugin_source_path( + marketplace_path.as_path(), + MarketplacePluginSource::Local { path }, + )? + .into_path_buf(), + }, + }; + + plugins.push(MarketplacePluginSummary { + name: plugin.name, + source, + }); + } + + marketplaces.push(MarketplaceSummary { + name: marketplace.name, + path: marketplace_path, + plugins, + }); + } + + Ok(marketplaces) +} + +fn discover_marketplace_paths_from_roots( + additional_roots: &[AbsolutePathBuf], + home_dir: Option<&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 let Some(home) = home_dir { + let path = home.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); + for root in additional_roots { + if let Some(repo_root) = get_git_repo_root(root.as_path()) { + let path = repo_root.join(MARKETPLACE_RELATIVE_PATH); + if path.is_file() && !paths.contains(&path) { + paths.push(path); + } } } @@ -132,8 +165,15 @@ fn discover_marketplace_paths(cwd: &Path) -> Vec { } fn load_marketplace(path: &Path) -> Result { - let contents = fs::read_to_string(path) - .map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?; + let contents = fs::read_to_string(path).map_err(|err| { + if err.kind() == io::ErrorKind::NotFound { + MarketplaceError::MarketplaceNotFound { + path: path.to_path_buf(), + } + } else { + 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(), @@ -233,9 +273,11 @@ mod tests { ) .unwrap(); - let resolved = - resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated") - .unwrap(); + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); assert_eq!( resolved, @@ -260,7 +302,11 @@ mod tests { ) .unwrap(); - let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err(); + let err = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "missing", + ) + .unwrap_err(); assert_eq!( err.to_string(), @@ -269,7 +315,112 @@ mod tests { } #[test] - fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() { + fn list_marketplaces_returns_home_and_repo_marketplaces() { + let tmp = tempdir().unwrap(); + let home_root = tmp.path().join("home"); + let repo_root = tmp.path().join("repo"); + + 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_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./home-shared" + } + }, + { + "name": "home-only", + "source": { + "source": "local", + "path": "./home-only" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "shared-plugin", + "source": { + "source": "local", + "path": "./repo-shared" + } + }, + { + "name": "repo-only", + "source": { + "source": "local", + "path": "./repo-only" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), + ) + .unwrap(); + + assert_eq!( + marketplaces, + vec![ + MarketplaceSummary { + name: "codex-curated".to_string(), + path: home_root.join(".agents/plugins/marketplace.json"), + plugins: vec![ + MarketplacePluginSummary { + name: "shared-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: home_root.join(".agents/plugins/home-shared"), + }, + }, + MarketplacePluginSummary { + name: "home-only".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: home_root.join(".agents/plugins/home-only"), + }, + }, + ], + }, + MarketplaceSummary { + name: "codex-curated".to_string(), + path: repo_root.join(".agents/plugins/marketplace.json"), + plugins: vec![ + MarketplacePluginSummary { + name: "shared-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: repo_root.join(".agents/plugins/repo-shared"), + }, + }, + MarketplacePluginSummary { + name: "repo-only".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: repo_root.join(".agents/plugins/repo-only"), + }, + }, + ], + }, + ] + ); + } + + #[test] + fn list_marketplaces_keeps_distinct_entries_for_same_name() { let tmp = tempdir().unwrap(); let home_root = tmp.path().join("home"); let repo_root = tmp.path().join("repo"); @@ -313,23 +464,97 @@ mod tests { ) .unwrap(); - let resolved = resolve_marketplace_plugin_from_paths( - &[repo_marketplace, home_marketplace], - "local-plugin", - "codex-curated", + let marketplaces = list_marketplaces_with_home( + &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], + Some(&home_root), ) .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(), - } + marketplaces, + vec![ + MarketplaceSummary { + name: "codex-curated".to_string(), + path: home_marketplace, + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: home_root.join(".agents/plugins/home-plugin"), + }, + }], + }, + MarketplaceSummary { + name: "codex-curated".to_string(), + path: repo_marketplace.clone(), + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: repo_root.join(".agents/plugins/repo-plugin"), + }, + }], + }, + ] + ); + + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_marketplace).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source_path, + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap() + ); + } + + #[test] + fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let nested_root = repo_root.join("nested/project"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(&nested_root).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(repo_root.clone()).unwrap(), + AbsolutePathBuf::try_from(nested_root).unwrap(), + ], + None, + ) + .unwrap(); + + assert_eq!( + marketplaces, + vec![MarketplaceSummary { + name: "codex-curated".to_string(), + path: repo_root.join(".agents/plugins/marketplace.json"), + plugins: vec![MarketplacePluginSummary { + name: "local-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: repo_root.join(".agents/plugins/plugin"), + }, + }], + }] ); } @@ -356,8 +581,11 @@ mod tests { ) .unwrap(); - let err = - resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err(); + let err = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap_err(); assert_eq!( err.to_string(), @@ -367,4 +595,46 @@ mod tests { ) ); } + + #[test] + fn resolve_marketplace_plugin_uses_first_duplicate_entry() { + 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": "./first" + } + }, + { + "name": "local-plugin", + "source": { + "source": "local", + "path": "./second" + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "local-plugin", + ) + .unwrap(); + + assert_eq!( + resolved.source_path, + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap() + ); + } } diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index faa90dc28..c0bec248f 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -7,6 +7,8 @@ mod store; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; +pub use manager::ConfiguredMarketplacePluginSummary; +pub use manager::ConfiguredMarketplaceSummary; pub use manager::LoadedPlugin; pub use manager::PluginCapabilitySummary; pub use manager::PluginInstallError; @@ -16,6 +18,8 @@ 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 marketplace::MarketplaceError; +pub use marketplace::MarketplacePluginSourceSummary; pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use render::render_plugins_section; pub use store::PluginId;