From ff584c5a4bff27adbd689d997010880b989645b9 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 13 Apr 2026 20:37:11 -0700 Subject: [PATCH] [codex] Refactor marketplace add into shared core flow (#17717) ## Summary Move `codex marketplace add` onto a shared core implementation so the CLI and app-server path can use one source of truth. This change: - adds shared marketplace-add orchestration in `codex-core` - switches the CLI command to call that shared implementation - removes duplicated CLI-only marketplace add helpers - preserves focused parser and add-path coverage while moving the shared behavior into core tests ## Why The new `marketplace/add` RPC should reuse the same underlying marketplace-add flow as the CLI. This refactor lands that consolidation first so the follow-up app-server PR can be mostly protocol and handler wiring. ## Validation - `cargo test -p codex-core marketplace_add` - `cargo test -p codex-cli marketplace_cmd` - `just fix -p codex-core` - `just fix -p codex-cli` - `just fmt` --- .../schema/json/ClientRequest.json | 50 ++ .../codex_app_server_protocol.schemas.json | 73 +++ .../codex_app_server_protocol.v2.schemas.json | 73 +++ .../schema/json/v2/MarketplaceAddParams.json | 28 ++ .../json/v2/MarketplaceAddResponse.json | 27 + .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/MarketplaceAddParams.ts | 5 + .../typescript/v2/MarketplaceAddResponse.ts | 6 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 51 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 41 ++ .../app-server/tests/common/mcp_process.rs | 10 + .../tests/suite/v2/marketplace_add.rs | 40 ++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/cli/src/marketplace_cmd.rs | 474 +----------------- codex-rs/cli/src/marketplace_cmd/metadata.rs | 150 ------ codex-rs/cli/src/marketplace_cmd/ops.rs | 118 ----- codex-rs/core/src/plugins/marketplace_add.rs | 257 ++++++++++ .../src/plugins/marketplace_add/install.rs | 137 +++++ .../src/plugins/marketplace_add/metadata.rs | 230 +++++++++ .../src/plugins/marketplace_add/source.rs | 255 ++++++++++ codex-rs/core/src/plugins/mod.rs | 5 + 24 files changed, 1321 insertions(+), 720 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/marketplace_add.rs delete mode 100644 codex-rs/cli/src/marketplace_cmd/metadata.rs delete mode 100644 codex-rs/cli/src/marketplace_cmd/ops.rs create mode 100644 codex-rs/core/src/plugins/marketplace_add.rs create mode 100644 codex-rs/core/src/plugins/marketplace_add/install.rs create mode 100644 codex-rs/core/src/plugins/marketplace_add/metadata.rs create mode 100644 codex-rs/core/src/plugins/marketplace_add/source.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 094e631f2..5e36c9aa5 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1233,6 +1233,32 @@ } ] }, + "MarketplaceAddParams": { + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "type": "object" + }, "McpResourceReadParams": { "properties": { "server": { @@ -4025,6 +4051,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/add" + ], + "title": "Marketplace/addRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceAddParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/addRequest", + "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 52b1152c4..8ad93be03 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 @@ -627,6 +627,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "marketplace/add" + ], + "title": "Marketplace/addRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/MarketplaceAddParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/addRequest", + "type": "object" + }, { "properties": { "id": { @@ -9060,6 +9084,55 @@ "title": "LogoutAccountResponse", "type": "object" }, + "MarketplaceAddParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "title": "MarketplaceAddParams", + "type": "object" + }, + "MarketplaceAddResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alreadyAdded": { + "type": "boolean" + }, + "installedRoot": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "type": "object" + }, "MarketplaceInterface": { "properties": { "displayName": { 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 44bb50822..fd06afaee 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 @@ -1209,6 +1209,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/add" + ], + "title": "Marketplace/addRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceAddParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/addRequest", + "type": "object" + }, { "properties": { "id": { @@ -5856,6 +5880,55 @@ "title": "LogoutAccountResponse", "type": "object" }, + "MarketplaceAddParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "title": "MarketplaceAddParams", + "type": "object" + }, + "MarketplaceAddResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "alreadyAdded": { + "type": "boolean" + }, + "installedRoot": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "type": "object" + }, "MarketplaceInterface": { "properties": { "displayName": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json new file mode 100644 index 000000000..704e5bbc2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddParams.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refName": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + }, + "sparsePaths": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "source" + ], + "title": "MarketplaceAddParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json new file mode 100644 index 000000000..d00db0d6b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceAddResponse.json @@ -0,0 +1,27 @@ +{ + "$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": { + "alreadyAdded": { + "type": "boolean" + }, + "installedRoot": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "marketplaceName": { + "type": "string" + } + }, + "required": [ + "alreadyAdded", + "installedRoot", + "marketplaceName" + ], + "title": "MarketplaceAddResponse", + "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 0eedda3e1..9d9a82340 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -33,6 +33,7 @@ import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; +import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; import type { McpResourceReadParams } from "./v2/McpResourceReadParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; @@ -67,4 +68,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/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "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": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "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": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.ts new file mode 100644 index 000000000..23d160481 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddParams.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 MarketplaceAddParams = { source: string, refName?: string | null, sparsePaths?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.ts new file mode 100644 index 000000000..8657d44c3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceAddResponse.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceAddResponse = { marketplaceName: string, installedRoot: AbsolutePathBuf, alreadyAdded: 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 961592db3..2b8fd187f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -149,6 +149,8 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse" export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; +export type { MarketplaceAddParams } from "./MarketplaceAddParams"; +export type { MarketplaceAddResponse } from "./MarketplaceAddResponse"; export type { MarketplaceInterface } from "./MarketplaceInterface"; export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; export type { McpAuthStatus } from "./McpAuthStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7334a964e..571832610 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -331,6 +331,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + MarketplaceAdd => "marketplace/add" { + params: v2::MarketplaceAddParams, + response: v2::MarketplaceAddResponse, + }, PluginList => "plugin/list" { params: v2::PluginListParams, response: v2::PluginListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2e92061e8..7914a49b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3326,6 +3326,26 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddParams { + pub source: String, + #[ts(optional = nullable)] + pub ref_name: Option, + #[ts(optional = nullable)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddResponse { + pub marketplace_name: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -8352,6 +8372,37 @@ mod tests { ); } + #[test] + fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: None, + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": null, + "sparsePaths": null, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: Some("main".to_string()), + sparse_paths: Some(vec!["plugins/foo".to_string()]), + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": "main", + "sparsePaths": ["plugins/foo"], + }), + ); + } + #[test] fn plugin_install_params_serialization_uses_force_remote_sync() { let marketplace_path = if cfg!(windows) { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 97220f0d4..8d463b833 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -179,6 +179,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `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`). +- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d0b835280..5c70cdb9d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -76,6 +76,8 @@ use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceInterface; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpResourceReadResponse; @@ -228,6 +230,7 @@ use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; use codex_core::parse_cursor; use codex_core::path_utils; +use codex_core::plugins::MarketplaceAddError; use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSource; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -235,6 +238,7 @@ use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; use codex_core::plugins::PluginReadRequest; use codex_core::plugins::PluginUninstallError as CorePluginUninstallError; +use codex_core::plugins::add_marketplace as add_marketplace_to_codex_home; use codex_core::plugins::load_plugin_apps; use codex_core::plugins::load_plugin_mcp_servers; use codex_core::read_head_for_summary; @@ -927,6 +931,10 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } + ClientRequest::MarketplaceAdd { request_id, params } => { + self.marketplace_add(to_connection_request_id(request_id), params) + .await; + } ClientRequest::PluginList { request_id, params } => { self.plugin_list(to_connection_request_id(request_id), params) .await; @@ -6483,6 +6491,39 @@ impl CodexMessageProcessor { .await; } + async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) { + let result = add_marketplace_to_codex_home( + self.config.codex_home.to_path_buf(), + codex_core::plugins::MarketplaceAddRequest { + source: params.source, + ref_name: params.ref_name, + sparse_paths: params.sparse_paths.unwrap_or_default(), + }, + ) + .await; + + match result { + Ok(outcome) => { + self.outgoing + .send_response( + request_id, + MarketplaceAddResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root, + already_added: outcome.already_added, + }, + ) + .await; + } + Err(MarketplaceAddError::InvalidRequest(message)) => { + self.send_invalid_request_error(request_id, message).await; + } + Err(MarketplaceAddError::Internal(message)) => { + self.send_internal_error(request_id, message).await; + } + } + } + async fn plugin_read(&self, request_id: ConnectionRequestId, params: PluginReadParams) { let plugins_manager = self.thread_manager.plugins_manager(); let PluginReadParams { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 51e3d48d9..afb095aa7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; @@ -514,6 +515,15 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `marketplace/add` JSON-RPC request. + pub async fn send_marketplace_add_request( + &mut self, + params: MarketplaceAddParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("marketplace/add", params).await + } + /// Send a `plugin/install` JSON-RPC request. pub async fn send_plugin_install_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs new file mode 100644 index 000000000..8e81d6865 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::RequestId; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn marketplace_add_rejects_local_directory_source() -> 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_marketplace_add_request(MarketplaceAddParams { + source: "./marketplace".to_string(), + ref_name: None, + sparse_paths: None, + }) + .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( + "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo" + ), + "unexpected error: {}", + err.error.message + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 617c05f57..95e07c031 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -15,6 +15,7 @@ mod experimental_api; mod experimental_feature_list; mod fs; mod initialize; +mod marketplace_add; mod mcp_resource; mod mcp_server_elicitation; mod mcp_server_status; diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 6a898c9ad..b21e25a39 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -1,22 +1,10 @@ use anyhow::Context; use anyhow::Result; -use anyhow::bail; use clap::Parser; -use codex_config::MarketplaceConfigUpdate; -use codex_config::record_user_marketplace; use codex_core::config::find_codex_home; -use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; -use codex_core::plugins::marketplace_install_root; -use codex_core::plugins::validate_marketplace_root; -use codex_core::plugins::validate_plugin_segment; +use codex_core::plugins::MarketplaceAddRequest; +use codex_core::plugins::add_marketplace; use codex_utils_cli::CliConfigOverrides; -use std::fs; -use std::path::Path; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; - -mod metadata; -mod ops; #[derive(Debug, Parser)] pub struct MarketplaceCli { @@ -51,14 +39,6 @@ struct AddMarketplaceArgs { sparse_paths: Vec, } -#[derive(Debug, PartialEq, Eq)] -pub(super) enum MarketplaceSource { - Git { - url: String, - ref_name: Option, - }, -} - impl MarketplaceCli { pub async fn run(self) -> Result<()> { let MarketplaceCli { @@ -87,449 +67,41 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { sparse_paths, } = args; - let source = parse_marketplace_source(&source, ref_name)?; - let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; - let install_root = marketplace_install_root(&codex_home); - fs::create_dir_all(&install_root).with_context(|| { - format!( - "failed to create marketplace install directory {}", - install_root.display() - ) - })?; - let install_metadata = - metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths); - if let Some(existing_root) = metadata::installed_marketplace_root_for_source( - &codex_home, - &install_root, - &install_metadata, - )? { - let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| { - format!( - "failed to validate installed marketplace at {}", - existing_root.display() - ) - })?; - record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?; + let outcome = add_marketplace( + codex_home.to_path_buf(), + MarketplaceAddRequest { + source, + ref_name, + sparse_paths, + }, + ) + .await?; + + if outcome.already_added { println!( - "Marketplace `{marketplace_name}` is already added from {}.", - source.display() + "Marketplace `{}` is already added from {}.", + outcome.marketplace_name, outcome.source_display ); - println!("Installed marketplace root: {}", existing_root.display()); - return Ok(()); - } - - let staging_root = ops::marketplace_staging_root(&install_root); - fs::create_dir_all(&staging_root).with_context(|| { - format!( - "failed to create marketplace staging directory {}", - staging_root.display() - ) - })?; - let staged_dir = tempfile::Builder::new() - .prefix("marketplace-add-") - .tempdir_in(&staging_root) - .with_context(|| { - format!( - "failed to create temporary marketplace directory in {}", - staging_root.display() - ) - })?; - let staged_root = staged_dir.path().to_path_buf(); - - let MarketplaceSource::Git { url, ref_name } = &source; - ops::clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?; - - let marketplace_name = validate_marketplace_source_root(&staged_root) - .with_context(|| format!("failed to validate marketplace from {}", source.display()))?; - if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { - bail!( - "marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}", - source.display() - ); - } - let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?); - ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?; - if destination.exists() { - bail!( - "marketplace `{marketplace_name}` is already added from a different source; remove it before adding {}", - source.display() - ); - } - ops::replace_marketplace_root(&staged_root, &destination) - .with_context(|| format!("failed to install marketplace at {}", destination.display()))?; - if let Err(err) = record_added_marketplace(&codex_home, &marketplace_name, &install_metadata) { - if let Err(rollback_err) = fs::rename(&destination, &staged_root) { - bail!( - "{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}", - destination.display() - ); - } - return Err(err); - } - - println!( - "Added marketplace `{marketplace_name}` from {}.", - source.display() - ); - println!("Installed marketplace root: {}", destination.display()); - - Ok(()) -} - -fn record_added_marketplace( - codex_home: &Path, - marketplace_name: &str, - install_metadata: &metadata::MarketplaceInstallMetadata, -) -> Result<()> { - let source = install_metadata.config_source(); - let last_updated = utc_timestamp_now()?; - let update = MarketplaceConfigUpdate { - last_updated: &last_updated, - source_type: install_metadata.config_source_type(), - source: &source, - ref_name: install_metadata.ref_name(), - sparse_paths: install_metadata.sparse_paths(), - }; - record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| { - format!("failed to add marketplace `{marketplace_name}` to user config.toml") - })?; - Ok(()) -} - -fn validate_marketplace_source_root(root: &Path) -> Result { - let marketplace_name = validate_marketplace_root(root)?; - validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?; - Ok(marketplace_name) -} - -fn parse_marketplace_source( - source: &str, - explicit_ref: Option, -) -> Result { - let source = source.trim(); - if source.is_empty() { - bail!("marketplace source must not be empty"); - } - - let (base_source, parsed_ref) = split_source_ref(source); - let ref_name = explicit_ref.or(parsed_ref); - - if looks_like_local_path(&base_source) { - bail!( - "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo" - ); - } - - if is_ssh_git_url(&base_source) || is_git_url(&base_source) { - let url = normalize_git_url(&base_source); - return Ok(MarketplaceSource::Git { url, ref_name }); - } - - if looks_like_github_shorthand(&base_source) { - let url = format!("https://github.com/{base_source}.git"); - return Ok(MarketplaceSource::Git { url, ref_name }); - } - - bail!("invalid marketplace source format: {source}"); -} - -fn split_source_ref(source: &str) -> (String, Option) { - if let Some((base, ref_name)) = source.rsplit_once('#') { - return (base.to_string(), non_empty_ref(ref_name)); - } - if !source.contains("://") - && !is_ssh_git_url(source) - && let Some((base, ref_name)) = source.rsplit_once('@') - { - return (base.to_string(), non_empty_ref(ref_name)); - } - (source.to_string(), None) -} - -fn non_empty_ref(ref_name: &str) -> Option { - let ref_name = ref_name.trim(); - (!ref_name.is_empty()).then(|| ref_name.to_string()) -} - -fn normalize_git_url(url: &str) -> String { - let url = url.trim_end_matches('/'); - if url.starts_with("https://github.com/") && !url.ends_with(".git") { - format!("{url}.git") } else { - url.to_string() - } -} - -fn looks_like_local_path(source: &str) -> bool { - source.starts_with("./") - || source.starts_with("../") - || source.starts_with('/') - || source.starts_with("~/") - || source == "." - || source == ".." -} - -fn is_ssh_git_url(source: &str) -> bool { - source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':') -} - -fn is_git_url(source: &str) -> bool { - source.starts_with("http://") || source.starts_with("https://") -} - -fn looks_like_github_shorthand(source: &str) -> bool { - let mut segments = source.split('/'); - let owner = segments.next(); - let repo = segments.next(); - let extra = segments.next(); - owner.is_some_and(is_github_shorthand_segment) - && repo.is_some_and(is_github_shorthand_segment) - && extra.is_none() -} - -fn is_github_shorthand_segment(segment: &str) -> bool { - !segment.is_empty() - && segment - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) -} - -fn safe_marketplace_dir_name(marketplace_name: &str) -> Result { - let safe = marketplace_name - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { - ch - } else { - '-' - } - }) - .collect::(); - let safe = safe.trim_matches('.').to_string(); - if safe.is_empty() || safe == ".." { - bail!("marketplace name `{marketplace_name}` cannot be used as an install directory"); - } - Ok(safe) -} - -fn ensure_marketplace_destination_is_inside_install_root( - install_root: &Path, - destination: &Path, -) -> Result<()> { - let install_root = install_root.canonicalize().with_context(|| { - format!( - "failed to resolve marketplace install root {}", - install_root.display() - ) - })?; - let destination_parent = destination - .parent() - .context("marketplace destination has no parent")? - .canonicalize() - .with_context(|| { - format!( - "failed to resolve marketplace destination parent {}", - destination.display() - ) - })?; - if !destination_parent.starts_with(&install_root) { - bail!( - "marketplace destination {} is outside install root {}", - destination.display(), - install_root.display() + println!( + "Added marketplace `{}` from {}.", + outcome.marketplace_name, outcome.source_display ); } + println!( + "Installed marketplace root: {}", + outcome.installed_root.as_path().display() + ); + Ok(()) } -fn utc_timestamp_now() -> Result { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")?; - Ok(format_utc_timestamp(duration.as_secs() as i64)) -} - -fn format_utc_timestamp(seconds_since_epoch: i64) -> String { - const SECONDS_PER_DAY: i64 = 86_400; - let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY); - let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY); - let (year, month, day) = civil_from_days(days); - let hour = seconds_of_day / 3_600; - let minute = (seconds_of_day % 3_600) / 60; - let second = seconds_of_day % 60; - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") -} - -fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { - let days = days_since_epoch + 719_468; - let era = if days >= 0 { days } else { days - 146_096 } / 146_097; - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += if month <= 2 { 1 } else { 0 }; - (year, month, day) -} - -impl MarketplaceSource { - fn display(&self) -> String { - match self { - Self::Git { url, ref_name } => { - if let Some(ref_name) = ref_name { - format!("{url}#{ref_name}") - } else { - url.clone() - } - } - } - } -} - #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; - #[test] - fn github_shorthand_parses_ref_suffix() { - assert_eq!( - parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: Some("main".to_string()), - } - ); - } - - #[test] - fn git_url_parses_fragment_ref() { - assert_eq!( - parse_marketplace_source( - "https://example.com/team/repo.git#v1", - /*explicit_ref*/ None, - ) - .unwrap(), - MarketplaceSource::Git { - url: "https://example.com/team/repo.git".to_string(), - ref_name: Some("v1".to_string()), - } - ); - } - - #[test] - fn explicit_ref_overrides_source_ref() { - assert_eq!( - parse_marketplace_source( - "owner/repo@main", - /*explicit_ref*/ Some("release".to_string()), - ) - .unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: Some("release".to_string()), - } - ); - } - - #[test] - fn github_shorthand_and_git_url_normalize_to_same_source() { - let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap(); - let git_url = parse_marketplace_source( - "https://github.com/owner/repo.git", - /*explicit_ref*/ None, - ) - .unwrap(); - - assert_eq!(shorthand, git_url); - assert_eq!( - shorthand, - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() { - assert_eq!( - parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None) - .unwrap(), - MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn non_github_https_source_parses_as_git_url() { - assert_eq!( - parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None) - .unwrap(), - MarketplaceSource::Git { - url: "https://gitlab.com/owner/repo".to_string(), - ref_name: None, - } - ); - } - - #[test] - fn file_url_source_is_rejected() { - let err = - parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None) - .unwrap_err(); - - assert!( - err.to_string() - .contains("invalid marketplace source format"), - "unexpected error: {err}" - ); - } - - #[test] - fn local_path_source_is_rejected() { - let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err(); - - assert!( - err.to_string() - .contains("local marketplace sources are not supported yet"), - "unexpected error: {err}" - ); - } - - #[test] - fn ssh_url_parses_as_git_url() { - assert_eq!( - parse_marketplace_source( - "ssh://git@github.com/owner/repo.git#main", - /*explicit_ref*/ None, - ) - .unwrap(), - MarketplaceSource::Git { - url: "ssh://git@github.com/owner/repo.git".to_string(), - ref_name: Some("main".to_string()), - } - ); - } - - #[test] - fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() { - assert_eq!( - format_utc_timestamp(/*seconds_since_epoch*/ 0), - "1970-01-01T00:00:00Z" - ); - assert_eq!( - format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200), - "2026-04-10T00:00:00Z" - ); - } - #[test] fn sparse_paths_parse_before_or_after_source() { let sparse_before_source = diff --git a/codex-rs/cli/src/marketplace_cmd/metadata.rs b/codex-rs/cli/src/marketplace_cmd/metadata.rs deleted file mode 100644 index db268840b..000000000 --- a/codex-rs/cli/src/marketplace_cmd/metadata.rs +++ /dev/null @@ -1,150 +0,0 @@ -use super::MarketplaceSource; -use anyhow::Context; -use anyhow::Result; -use codex_config::CONFIG_TOML_FILE; -use codex_core::plugins::validate_marketplace_root; -use std::io::ErrorKind; -use std::path::Path; -use std::path::PathBuf; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct MarketplaceInstallMetadata { - source: InstalledMarketplaceSource, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum InstalledMarketplaceSource { - Git { - url: String, - ref_name: Option, - sparse_paths: Vec, - }, -} - -pub(super) fn installed_marketplace_root_for_source( - codex_home: &Path, - install_root: &Path, - install_metadata: &MarketplaceInstallMetadata, -) -> Result> { - let config_path = codex_home.join(CONFIG_TOML_FILE); - let config = match std::fs::read_to_string(&config_path) { - Ok(config) => config, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => { - return Err(err) - .with_context(|| format!("failed to read user config {}", config_path.display())); - } - }; - let config: toml::Value = toml::from_str(&config) - .with_context(|| format!("failed to parse user config {}", config_path.display()))?; - let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else { - return Ok(None); - }; - - for (marketplace_name, marketplace) in marketplaces { - if !install_metadata.matches_config(marketplace) { - continue; - } - let root = install_root.join(marketplace_name); - if validate_marketplace_root(&root).is_ok() { - return Ok(Some(root)); - } - } - - Ok(None) -} - -impl MarketplaceInstallMetadata { - pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { - let source = match source { - MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git { - url: url.clone(), - ref_name: ref_name.clone(), - sparse_paths: sparse_paths.to_vec(), - }, - }; - Self { source } - } - - pub(super) fn config_source_type(&self) -> &'static str { - match &self.source { - InstalledMarketplaceSource::Git { .. } => "git", - } - } - - pub(super) fn config_source(&self) -> String { - match &self.source { - InstalledMarketplaceSource::Git { url, .. } => url.clone(), - } - } - - pub(super) fn ref_name(&self) -> Option<&str> { - match &self.source { - InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(), - } - } - - pub(super) fn sparse_paths(&self) -> &[String] { - match &self.source { - InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths, - } - } - - fn matches_config(&self, marketplace: &toml::Value) -> bool { - marketplace.get("source_type").and_then(toml::Value::as_str) - == Some(self.config_source_type()) - && marketplace.get("source").and_then(toml::Value::as_str) - == Some(self.config_source().as_str()) - && marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name() - && config_sparse_paths(marketplace) == self.sparse_paths() - } -} - -fn config_sparse_paths(marketplace: &toml::Value) -> Vec { - marketplace - .get("sparse_paths") - .and_then(toml::Value::as_array) - .map(|paths| { - paths - .iter() - .filter_map(toml::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - #[test] - fn installed_marketplace_root_for_source_propagates_config_read_errors() -> Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - std::fs::create_dir(&config_path)?; - - let install_root = codex_home.path().join("marketplaces"); - let source = MarketplaceSource::Git { - url: "https://github.com/owner/repo.git".to_string(), - ref_name: None, - }; - let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); - - let err = installed_marketplace_root_for_source( - codex_home.path(), - &install_root, - &install_metadata, - ) - .unwrap_err(); - - assert_eq!( - err.to_string(), - format!("failed to read user config {}", config_path.display()) - ); - - Ok(()) - } -} diff --git a/codex-rs/cli/src/marketplace_cmd/ops.rs b/codex-rs/cli/src/marketplace_cmd/ops.rs deleted file mode 100644 index ffb777fdb..000000000 --- a/codex-rs/cli/src/marketplace_cmd/ops.rs +++ /dev/null @@ -1,118 +0,0 @@ -use anyhow::Context; -use anyhow::Result; -use anyhow::bail; -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; - -pub(super) fn clone_git_source( - url: &str, - ref_name: Option<&str>, - sparse_paths: &[String], - destination: &Path, -) -> Result<()> { - let destination = destination.to_string_lossy().to_string(); - if sparse_paths.is_empty() { - run_git(&["clone", url, destination.as_str()], /*cwd*/ None)?; - if let Some(ref_name) = ref_name { - run_git(&["checkout", ref_name], Some(Path::new(&destination)))?; - } - return Ok(()); - } - - run_git( - &[ - "clone", - "--filter=blob:none", - "--no-checkout", - url, - destination.as_str(), - ], - /*cwd*/ None, - )?; - let mut sparse_args = vec!["sparse-checkout", "set"]; - sparse_args.extend(sparse_paths.iter().map(String::as_str)); - let destination = Path::new(&destination); - run_git(&sparse_args, Some(destination))?; - run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?; - Ok(()) -} - -fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> { - let mut command = Command::new("git"); - command.args(args); - command.env("GIT_TERMINAL_PROMPT", "0"); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - - let output = command - .output() - .with_context(|| format!("failed to run git {}", args.join(" ")))?; - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - bail!( - "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", - args.join(" "), - output.status, - stdout.trim(), - stderr.trim() - ); -} - -pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> { - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - if destination.exists() { - bail!( - "marketplace destination already exists: {}", - destination.display() - ); - } - - fs::rename(staged_root, destination).map_err(Into::into) -} - -pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { - install_root.join(".staging") -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - #[test] - fn replace_marketplace_root_rejects_existing_destination() { - let temp_dir = TempDir::new().unwrap(); - let staged_root = temp_dir.path().join("staged"); - let destination = temp_dir.path().join("destination"); - fs::create_dir_all(&staged_root).unwrap(); - fs::write(staged_root.join("marker.txt"), "staged").unwrap(); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("marker.txt"), "installed").unwrap(); - - let err = replace_marketplace_root(&staged_root, &destination).unwrap_err(); - - assert!( - err.to_string() - .contains("marketplace destination already exists"), - "unexpected error: {err}" - ); - assert_eq!( - fs::read_to_string(staged_root.join("marker.txt")).unwrap(), - "staged" - ); - assert_eq!( - fs::read_to_string(destination.join("marker.txt")).unwrap(), - "installed" - ); - } -} diff --git a/codex-rs/core/src/plugins/marketplace_add.rs b/codex-rs/core/src/plugins/marketplace_add.rs new file mode 100644 index 000000000..55c50099a --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add.rs @@ -0,0 +1,257 @@ +use super::OPENAI_CURATED_MARKETPLACE_NAME; +use super::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use tempfile::Builder; + +mod install; +mod metadata; +mod source; + +use install::clone_git_source; +use install::ensure_marketplace_destination_is_inside_install_root; +use install::marketplace_staging_root; +use install::replace_marketplace_root; +use install::safe_marketplace_dir_name; +use metadata::MarketplaceInstallMetadata; +use metadata::installed_marketplace_root_for_source; +use metadata::record_added_marketplace_entry; +use source::MarketplaceSource; +use source::parse_marketplace_source; +use source::validate_marketplace_source_root; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceAddRequest { + pub source: String, + pub ref_name: Option, + pub sparse_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceAddOutcome { + pub marketplace_name: String, + pub source_display: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum MarketplaceAddError { + #[error("{0}")] + InvalidRequest(String), + #[error("{0}")] + Internal(String), +} + +pub async fn add_marketplace( + codex_home: PathBuf, + request: MarketplaceAddRequest, +) -> Result { + tokio::task::spawn_blocking(move || add_marketplace_sync(codex_home.as_path(), request)) + .await + .map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))? +} + +fn add_marketplace_sync( + codex_home: &Path, + request: MarketplaceAddRequest, +) -> Result { + add_marketplace_sync_with_cloner(codex_home, request, clone_git_source) +} + +fn add_marketplace_sync_with_cloner( + codex_home: &Path, + request: MarketplaceAddRequest, + clone_source: F, +) -> Result +where + F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>, +{ + let MarketplaceAddRequest { + source, + ref_name, + sparse_paths, + } = request; + let source = parse_marketplace_source(&source, ref_name)?; + + let install_root = marketplace_install_root(codex_home); + fs::create_dir_all(&install_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create marketplace install directory {}: {err}", + install_root.display() + )) + })?; + + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &sparse_paths); + if let Some(existing_root) = + installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)? + { + let marketplace_name = validate_marketplace_source_root(&existing_root)?; + record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?; + return Ok(MarketplaceAddOutcome { + marketplace_name, + source_display: source.display(), + installed_root: AbsolutePathBuf::try_from(existing_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve installed marketplace root: {err}" + )) + })?, + already_added: true, + }); + } + + let staging_root = marketplace_staging_root(&install_root); + fs::create_dir_all(&staging_root).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create marketplace staging directory {}: {err}", + staging_root.display() + )) + })?; + let staged_root = Builder::new() + .prefix("marketplace-add-") + .tempdir_in(&staging_root) + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to create temporary marketplace directory in {}: {err}", + staging_root.display() + )) + })?; + let staged_root = staged_root.keep(); + + let MarketplaceSource::Git { url, ref_name } = &source; + clone_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?; + + let marketplace_name = validate_marketplace_source_root(&staged_root)?; + if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}", + source.display() + ))); + } + + let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?); + ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?; + if destination.exists() { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}", + source.display() + ))); + } + + replace_marketplace_root(&staged_root, &destination).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to install marketplace at {}: {err}", + destination.display() + )) + })?; + if let Err(err) = + record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata) + { + if let Err(rollback_err) = fs::rename(&destination, &staged_root) { + return Err(MarketplaceAddError::Internal(format!( + "{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}", + destination.display() + ))); + } + return Err(err); + } + + Ok(MarketplaceAddOutcome { + marketplace_name, + source_display: source.display(), + installed_root: AbsolutePathBuf::try_from(destination).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve installed marketplace root: {err}" + )) + })?, + already_added: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> { + let codex_home = TempDir::new()?; + let source_root = TempDir::new()?; + write_marketplace_source(source_root.path(), "remote copy")?; + + let result = add_marketplace_sync_with_cloner( + codex_home.path(), + MarketplaceAddRequest { + source: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + sparse_paths: Vec::new(), + }, + |_url, _ref_name, _sparse_paths, destination| { + copy_dir_all(source_root.path(), destination) + .map_err(|err| MarketplaceAddError::Internal(err.to_string())) + }, + )?; + + assert_eq!(result.marketplace_name, "debug"); + assert_eq!(result.source_display, "https://github.com/owner/repo.git"); + assert!(!result.already_added); + assert!( + result + .installed_root + .as_path() + .join(".agents/plugins/marketplace.json") + .is_file() + ); + + let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?; + assert!(config.contains("[marketplaces.debug]")); + assert!(config.contains("source_type = \"git\"")); + assert!(config.contains("source = \"https://github.com/owner/repo.git\"")); + Ok(()) + } + + fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> { + fs::create_dir_all(source.join(".agents/plugins"))?; + fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + fs::write(source.join("plugins/sample/marker.txt"), marker)?; + Ok(()) + } + + fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> { + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_dir_all(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path)?; + } + } + Ok(()) + } +} diff --git a/codex-rs/core/src/plugins/marketplace_add/install.rs b/codex-rs/core/src/plugins/marketplace_add/install.rs new file mode 100644 index 000000000..1ecfa050d --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/install.rs @@ -0,0 +1,137 @@ +use super::MarketplaceAddError; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +pub(super) fn clone_git_source( + url: &str, + ref_name: Option<&str>, + sparse_paths: &[String], + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let destination_string = destination.to_string_lossy().to_string(); + if sparse_paths.is_empty() { + run_git( + &["clone", url, destination_string.as_str()], + /*cwd*/ None, + )?; + if let Some(ref_name) = ref_name { + run_git( + &["checkout", ref_name], + Some(Path::new(&destination_string)), + )?; + } + return Ok(()); + } + + run_git( + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + url, + destination_string.as_str(), + ], + /*cwd*/ None, + )?; + let mut sparse_args = vec!["sparse-checkout", "set"]; + sparse_args.extend(sparse_paths.iter().map(String::as_str)); + run_git(&sparse_args, Some(destination))?; + run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?; + Ok(()) +} + +pub(super) fn safe_marketplace_dir_name( + marketplace_name: &str, +) -> Result { + let safe = marketplace_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + let safe = safe.trim_matches('.').to_string(); + if safe.is_empty() || safe == ".." { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace name '{marketplace_name}' cannot be used as an install directory" + ))); + } + Ok(safe) +} + +pub(super) fn ensure_marketplace_destination_is_inside_install_root( + install_root: &Path, + destination: &Path, +) -> Result<(), MarketplaceAddError> { + let install_root = install_root.canonicalize().map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace install root {}: {err}", + install_root.display() + )) + })?; + let destination_parent = destination + .parent() + .ok_or_else(|| { + MarketplaceAddError::Internal("marketplace destination has no parent".to_string()) + })? + .canonicalize() + .map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to resolve marketplace destination parent {}: {err}", + destination.display() + )) + })?; + if !destination_parent.starts_with(&install_root) { + return Err(MarketplaceAddError::InvalidRequest(format!( + "marketplace destination {} is outside install root {}", + destination.display(), + install_root.display() + ))); + } + Ok(()) +} + +pub(super) fn replace_marketplace_root( + staged_root: &Path, + destination: &Path, +) -> std::io::Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + fs::rename(staged_root, destination) +} + +pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { + install_root.join(".staging") +} + +fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> { + let mut command = Command::new("git"); + command.args(args); + command.env("GIT_TERMINAL_PROMPT", "0"); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let output = command.output().map_err(|err| { + MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" "))) + })?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(MarketplaceAddError::Internal(format!( + "git {} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + output.status, + stdout.trim(), + stderr.trim() + ))) +} diff --git a/codex-rs/core/src/plugins/marketplace_add/metadata.rs b/codex-rs/core/src/plugins/marketplace_add/metadata.rs new file mode 100644 index 000000000..9ee27a9df --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/metadata.rs @@ -0,0 +1,230 @@ +use super::MarketplaceAddError; +use super::MarketplaceSource; +use crate::plugins::validate_marketplace_root; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MarketplaceInstallMetadata { + source: InstalledMarketplaceSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InstalledMarketplaceSource { + Git { + url: String, + ref_name: Option, + sparse_paths: Vec, + }, +} + +pub(super) fn record_added_marketplace_entry( + codex_home: &Path, + marketplace_name: &str, + install_metadata: &MarketplaceInstallMetadata, +) -> Result<(), MarketplaceAddError> { + let source = install_metadata.config_source(); + let timestamp = utc_timestamp_now()?; + let update = MarketplaceConfigUpdate { + last_updated: ×tamp, + source_type: install_metadata.config_source_type(), + source: &source, + ref_name: install_metadata.ref_name(), + sparse_paths: install_metadata.sparse_paths(), + }; + + record_user_marketplace(codex_home, marketplace_name, &update).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to add marketplace '{marketplace_name}' to user config.toml: {err}" + )) + }) +} + +pub(super) fn installed_marketplace_root_for_source( + codex_home: &Path, + install_root: &Path, + install_metadata: &MarketplaceInstallMetadata, +) -> Result, MarketplaceAddError> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let config = match fs::read_to_string(&config_path) { + Ok(config) => config, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(MarketplaceAddError::Internal(format!( + "failed to read user config {}: {err}", + config_path.display() + ))); + } + }; + let config: toml::Value = toml::from_str(&config).map_err(|err| { + MarketplaceAddError::Internal(format!( + "failed to parse user config {}: {err}", + config_path.display() + )) + })?; + let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else { + return Ok(None); + }; + + for (marketplace_name, marketplace) in marketplaces { + if !install_metadata.matches_config(marketplace) { + continue; + } + let root = install_root.join(marketplace_name); + if validate_marketplace_root(&root).is_ok() { + return Ok(Some(root)); + } + } + + Ok(None) +} + +impl MarketplaceInstallMetadata { + pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self { + let source = match source { + MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git { + url: url.clone(), + ref_name: ref_name.clone(), + sparse_paths: sparse_paths.to_vec(), + }, + }; + Self { source } + } + + fn config_source_type(&self) -> &'static str { + match &self.source { + InstalledMarketplaceSource::Git { .. } => "git", + } + } + + fn config_source(&self) -> String { + match &self.source { + InstalledMarketplaceSource::Git { url, .. } => url.clone(), + } + } + + fn ref_name(&self) -> Option<&str> { + match &self.source { + InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(), + } + } + + fn sparse_paths(&self) -> &[String] { + match &self.source { + InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths, + } + } + + fn matches_config(&self, marketplace: &toml::Value) -> bool { + marketplace.get("source_type").and_then(toml::Value::as_str) + == Some(self.config_source_type()) + && marketplace.get("source").and_then(toml::Value::as_str) + == Some(self.config_source().as_str()) + && marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name() + && config_sparse_paths(marketplace) == self.sparse_paths() + } +} + +fn config_sparse_paths(marketplace: &toml::Value) -> Vec { + marketplace + .get("sparse_paths") + .and_then(toml::Value::as_array) + .map(|paths| { + paths + .iter() + .filter_map(toml::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn utc_timestamp_now() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| { + MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}")) + })?; + Ok(format_utc_timestamp(duration.as_secs() as i64)) +} + +fn format_utc_timestamp(seconds_since_epoch: i64) -> String { + const SECONDS_PER_DAY: i64 = 86_400; + let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY); + let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY); + let (year, month, day) = civil_from_days(days); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year, month, day) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() { + assert_eq!( + format_utc_timestamp(/*seconds_since_epoch*/ 0), + "1970-01-01T00:00:00Z" + ); + assert_eq!( + format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200), + "2026-04-10T00:00:00Z" + ); + } + + #[test] + fn installed_marketplace_root_for_source_propagates_config_read_errors() { + let codex_home = TempDir::new().unwrap(); + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + fs::create_dir(&config_path).unwrap(); + + let install_root = codex_home.path().join("marketplaces"); + let source = MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + }; + let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]); + + let err = installed_marketplace_root_for_source( + codex_home.path(), + &install_root, + &install_metadata, + ) + .unwrap_err(); + + assert!( + err.to_string().contains(&format!( + "failed to read user config {}:", + config_path.display() + )), + "unexpected error: {err}" + ); + } +} diff --git a/codex-rs/core/src/plugins/marketplace_add/source.rs b/codex-rs/core/src/plugins/marketplace_add/source.rs new file mode 100644 index 000000000..bb3d746a0 --- /dev/null +++ b/codex-rs/core/src/plugins/marketplace_add/source.rs @@ -0,0 +1,255 @@ +use super::MarketplaceAddError; +use crate::plugins::validate_marketplace_root; +use crate::plugins::validate_plugin_segment; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum MarketplaceSource { + Git { + url: String, + ref_name: Option, + }, +} + +pub(super) fn parse_marketplace_source( + source: &str, + explicit_ref: Option, +) -> Result { + let source = source.trim(); + if source.is_empty() { + return Err(MarketplaceAddError::InvalidRequest( + "marketplace source must not be empty".to_string(), + )); + } + + let (base_source, parsed_ref) = split_source_ref(source); + let ref_name = explicit_ref.or(parsed_ref); + + if looks_like_local_path(&base_source) { + return Err(MarketplaceAddError::InvalidRequest( + "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo".to_string(), + )); + } + + if is_ssh_git_url(&base_source) || is_git_url(&base_source) { + return Ok(MarketplaceSource::Git { + url: normalize_git_url(&base_source), + ref_name, + }); + } + + if looks_like_github_shorthand(&base_source) { + return Ok(MarketplaceSource::Git { + url: format!("https://github.com/{base_source}.git"), + ref_name, + }); + } + + Err(MarketplaceAddError::InvalidRequest(format!( + "invalid marketplace source format: {source}" + ))) +} + +pub(super) fn validate_marketplace_source_root(root: &Path) -> Result { + let marketplace_name = validate_marketplace_root(root) + .map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(MarketplaceAddError::InvalidRequest)?; + Ok(marketplace_name) +} + +fn split_source_ref(source: &str) -> (String, Option) { + if let Some((base, ref_name)) = source.rsplit_once('#') { + return (base.to_string(), non_empty_ref(ref_name)); + } + if !source.contains("://") + && !is_ssh_git_url(source) + && let Some((base, ref_name)) = source.rsplit_once('@') + { + return (base.to_string(), non_empty_ref(ref_name)); + } + (source.to_string(), None) +} + +fn non_empty_ref(ref_name: &str) -> Option { + let ref_name = ref_name.trim(); + (!ref_name.is_empty()).then(|| ref_name.to_string()) +} + +fn normalize_git_url(url: &str) -> String { + let url = url.trim_end_matches('/'); + if url.starts_with("https://github.com/") && !url.ends_with(".git") { + format!("{url}.git") + } else { + url.to_string() + } +} + +fn looks_like_local_path(source: &str) -> bool { + source.starts_with("./") + || source.starts_with("../") + || source.starts_with('/') + || source.starts_with("~/") + || source == "." + || source == ".." +} + +fn is_ssh_git_url(source: &str) -> bool { + source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':') +} + +fn is_git_url(source: &str) -> bool { + source.starts_with("http://") || source.starts_with("https://") +} + +fn looks_like_github_shorthand(source: &str) -> bool { + let mut segments = source.split('/'); + let owner = segments.next(); + let repo = segments.next(); + let extra = segments.next(); + owner.is_some_and(is_github_shorthand_segment) + && repo.is_some_and(is_github_shorthand_segment) + && extra.is_none() +} + +fn is_github_shorthand_segment(segment: &str) -> bool { + !segment.is_empty() + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) +} + +impl MarketplaceSource { + pub(super) fn display(&self) -> String { + match self { + Self::Git { url, ref_name } => match ref_name { + Some(ref_name) => format!("{url}#{ref_name}"), + None => url.clone(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn github_shorthand_parses_ref_suffix() { + assert_eq!( + parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } + + #[test] + fn git_url_parses_fragment_ref() { + assert_eq!( + parse_marketplace_source( + "https://example.com/team/repo.git#v1", + /*explicit_ref*/ None + ) + .unwrap(), + MarketplaceSource::Git { + url: "https://example.com/team/repo.git".to_string(), + ref_name: Some("v1".to_string()), + } + ); + } + + #[test] + fn explicit_ref_overrides_source_ref() { + assert_eq!( + parse_marketplace_source("owner/repo@main", Some("release".to_string())).unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: Some("release".to_string()), + } + ); + } + + #[test] + fn github_shorthand_and_git_url_normalize_to_same_source() { + let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap(); + let git_url = parse_marketplace_source( + "https://github.com/owner/repo.git", + /*explicit_ref*/ None, + ) + .unwrap(); + + assert_eq!(shorthand, git_url); + assert_eq!( + shorthand, + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() { + assert_eq!( + parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None) + .unwrap(), + MarketplaceSource::Git { + url: "https://github.com/owner/repo.git".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn non_github_https_source_parses_as_git_url() { + assert_eq!( + parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None) + .unwrap(), + MarketplaceSource::Git { + url: "https://gitlab.com/owner/repo".to_string(), + ref_name: None, + } + ); + } + + #[test] + fn file_url_source_is_rejected() { + let err = + parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None) + .unwrap_err(); + + assert!( + err.to_string() + .contains("invalid marketplace source format"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_marketplace_source_rejects_local_directory_source() { + let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err(); + + assert_eq!( + err.to_string(), + "local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo" + ); + } + + #[test] + fn ssh_url_parses_as_git_url() { + assert_eq!( + parse_marketplace_source( + "ssh://git@github.com/owner/repo.git#main", + /*explicit_ref*/ None, + ) + .unwrap(), + MarketplaceSource::Git { + url: "ssh://git@github.com/owner/repo.git".to_string(), + ref_name: Some("main".to_string()), + } + ); + } +} diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 5115c3f7e..7ffb80b44 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -6,6 +6,7 @@ mod installed_marketplaces; mod manager; mod manifest; mod marketplace; +mod marketplace_add; mod mentions; mod remote; mod render; @@ -58,6 +59,10 @@ pub use marketplace::MarketplacePluginInstallPolicy; pub use marketplace::MarketplacePluginPolicy; pub use marketplace::MarketplacePluginSource; pub use marketplace::validate_marketplace_root; +pub use marketplace_add::MarketplaceAddError; +pub use marketplace_add::MarketplaceAddOutcome; +pub use marketplace_add::MarketplaceAddRequest; +pub use marketplace_add::add_marketplace; pub use remote::RemotePluginFetchError; pub use remote::fetch_remote_featured_plugin_ids; pub(crate) use render::render_explicit_plugin_instructions;