diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046c4..a6fe99b35e98 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2015,6 +2015,17 @@ ], "type": "object" }, + "PluginShareCheckoutParams": { + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "properties": { "remotePluginId": { @@ -5075,6 +5086,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "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 fc75cd8465f8..667607d43e68 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 @@ -877,6 +877,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "type": "object" + }, { "properties": { "id": { @@ -12268,6 +12292,58 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareCheckoutParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" + }, + "PluginShareCheckoutResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "type": "object" + }, "PluginShareContext": { "properties": { "creatorAccountUserId": { 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 4242bc6e37bb..c1a99eddda9c 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 @@ -1617,6 +1617,30 @@ "title": "Plugin/share/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/checkout" + ], + "title": "Plugin/share/checkoutRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareCheckoutParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/checkoutRequest", + "type": "object" + }, { "properties": { "id": { @@ -8817,6 +8841,58 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareCheckoutParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" + }, + "PluginShareCheckoutResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "type": "object" + }, "PluginShareContext": { "properties": { "creatorAccountUserId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json new file mode 100644 index 000000000000..dc7e2bdfe684 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareCheckoutParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json new file mode 100644 index 000000000000..ace02c59eab9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareCheckoutResponse.json @@ -0,0 +1,45 @@ +{ + "$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": { + "marketplaceName": { + "type": "string" + }, + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "pluginId": { + "type": "string" + }, + "pluginName": { + "type": "string" + }, + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "marketplaceName", + "marketplacePath", + "pluginId", + "pluginName", + "pluginPath", + "remotePluginId" + ], + "title": "PluginShareCheckoutResponse", + "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 a12185b50103..28371c71067c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -45,6 +45,7 @@ import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapa import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; +import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; @@ -79,4 +80,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/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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/PluginShareCheckoutParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutParams.ts new file mode 100644 index 000000000000..5bd14aa608f2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutParams.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 PluginShareCheckoutParams = { remotePluginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.ts new file mode 100644 index 000000000000..d27af9e2a5e2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareCheckoutResponse.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 PluginShareCheckoutResponse = { remotePluginId: string, pluginId: string, pluginName: string, pluginPath: AbsolutePathBuf, marketplaceName: string, marketplacePath: AbsolutePathBuf, remoteVersion: string | null, }; 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 a6b961366e0f..ab6eaefb5a14 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -277,6 +277,8 @@ export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams"; +export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse"; export type { PluginShareContext } from "./PluginShareContext"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b7365..204f4724530c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -649,6 +649,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareListResponse, }, + PluginShareCheckout => "plugin/share/checkout" { + params: v2::PluginShareCheckoutParams, + serialization: global("config"), + response: v2::PluginShareCheckoutResponse, + }, PluginShareDelete => "plugin/share/delete" { params: v2::PluginShareDeleteParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 2349e8bc21f6..3ffab53218c0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -242,6 +242,26 @@ pub struct PluginShareListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareCheckoutParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareCheckoutResponse { + pub remote_plugin_id: String, + pub plugin_id: String, + pub plugin_name: String, + pub plugin_path: AbsolutePathBuf, + pub marketplace_name: String, + pub marketplace_path: AbsolutePathBuf, + pub remote_version: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 6e93fffd2ca0..f7041cc721a8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2987,6 +2987,52 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { PluginShareListParams {}, ); + assert_eq!( + serde_json::to_value(PluginShareCheckoutParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); + + let plugin_path = if cfg!(windows) { + r"C:\Users\me\plugins\gmail" + } else { + "/Users/me/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + let marketplace_path = if cfg!(windows) { + r"C:\Users\me\.agents\plugins\marketplace.json" + } else { + "/Users/me/.agents/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginShareCheckoutResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + plugin_id: "gmail@codex-curated".to_string(), + plugin_name: "gmail".to_string(), + plugin_path, + marketplace_name: "codex-curated".to_string(), + marketplace_path, + remote_version: Some("1.2.3".to_string()), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "pluginId": "gmail@codex-curated", + "pluginName": "gmail", + "pluginPath": plugin_path_json, + "marketplaceName": "codex-curated", + "marketplacePath": marketplace_path_json, + "remoteVersion": "1.2.3", + }), + ); + assert_eq!( serde_json::to_value(PluginShareDeleteParams { remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d4ad42ffc6eb..86a3de09e929 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1099,6 +1099,9 @@ impl MessageProcessor { ClientRequest::PluginShareList { params, .. } => { self.plugin_processor.plugin_share_list(params).await } + ClientRequest::PluginShareCheckout { params, .. } => { + self.plugin_processor.plugin_share_checkout(params).await + } ClientRequest::PluginShareDelete { params, .. } => { self.plugin_processor.plugin_share_delete(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index d02aa6087b27..cf68f638ba2c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareCheckoutParams; +use codex_app_server_protocol::PluginShareCheckoutResponse; use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 61336aa713b8..6980945ae2c6 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -313,6 +313,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_share_checkout( + &self, + params: PluginShareCheckoutParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_checkout_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_share_delete( &self, params: PluginShareDeleteParams, @@ -973,6 +982,42 @@ impl PluginRequestProcessor { Ok(PluginShareListResponse { data }) } + async fn plugin_share_checkout_response( + &self, + params: PluginShareCheckoutParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } + let PluginShareCheckoutParams { remote_plugin_id } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::checkout_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareCheckoutResponse { + remote_plugin_id: result.remote_plugin_id, + plugin_id: result.plugin_id, + plugin_name: result.plugin_name, + plugin_path: result.plugin_path, + marketplace_name: result.marketplace_name, + marketplace_path: result.marketplace_path, + remote_version: result.remote_version, + }) + } + async fn plugin_share_delete_response( &self, params: PluginShareDeleteParams, @@ -1694,6 +1739,7 @@ fn remote_plugin_catalog_error_to_jsonrpc( invalid_request(message) } RemotePluginCatalogError::InvalidPluginPath { .. } + | RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } | RemotePluginCatalogError::ArchiveTooLarge { .. } | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), RemotePluginCatalogError::AuthToken(_) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 8614e83f8d04..efe3d291a49c 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -12,6 +12,9 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginShareCheckoutResponse; use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareDiscoverability; @@ -27,6 +30,8 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -41,6 +46,8 @@ use wiremock::matchers::path; use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; #[tokio::test] async fn plugin_share_save_uploads_local_plugin() -> Result<()> { @@ -587,6 +594,335 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "demo-plugin", + remote_plugin_bundle_tar_gz_bytes("demo-plugin")?, + ) + .await; + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_123", + "demo-plugin", + &bundle_url, + "WORKSPACE", + ) + .await; + mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareCheckoutResponse = to_response(response)?; + + let plugin_path = AbsolutePathBuf::try_from(home.path().join("plugins/demo-plugin"))?; + let marketplace_path = + AbsolutePathBuf::try_from(home.path().join(".agents/plugins/marketplace.json"))?; + assert_eq!( + response, + PluginShareCheckoutResponse { + remote_plugin_id: "plugins_123".to_string(), + plugin_id: "demo-plugin@codex-curated".to_string(), + plugin_name: "demo-plugin".to_string(), + plugin_path: plugin_path.clone(), + marketplace_name: "codex-curated".to_string(), + marketplace_path: marketplace_path.clone(), + remote_version: Some("1.2.3".to_string()), + } + ); + assert!( + plugin_path + .as_path() + .join(".codex-plugin/plugin.json") + .is_file() + ); + + let marketplace: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(marketplace_path.as_path())?)?; + assert_eq!( + marketplace, + json!({ + "name": "codex-curated", + "interface": { + "displayName": "Personal", + }, + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE", + }, + }, + ], + }) + ); + + let mapping: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + )?)?; + assert_eq!( + mapping, + json!({ + "localPluginPathsByRemotePluginId": { + "plugins_123": plugin_path.clone(), + }, + }) + ); + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![ + codex_app_server_protocol::PluginListMarketplaceKind::Local, + ]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "codex-curated"); + assert_eq!(response.marketplaces[0].plugins[0].name, "demo-plugin"); + assert_eq!( + response.marketplaces[0].plugins[0] + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.as_str()), + Some("plugins_123") + ); + + std::fs::write(plugin_path.as_path().join("local-edit.txt"), "keep")?; + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareCheckoutResponse = to_response(response)?; + assert_eq!(response.plugin_path, plugin_path); + assert_eq!( + std::fs::read_to_string(plugin_path.as_path().join("local-edit.txt"))?, + "keep" + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let bundle_url = format!("{}/bundles/global-plugin.tar.gz", server.uri()); + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_global", + "global-plugin", + &bundle_url, + "GLOBAL", + ) + .await; + mount_empty_remote_installed_plugins(&server, "GLOBAL").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_global", + })), + ) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert!( + error + .error + .message + .contains("not available for plugin/share/checkout") + ); + assert!(!home.path().join("plugins/global-plugin").exists()); + + Ok(()) +} + +#[tokio::test] +async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let marketplace_path = home.path().join(".agents/plugins/marketplace.json"); + std::fs::create_dir_all( + marketplace_path + .parent() + .expect("marketplace path has parent"), + )?; + std::fs::write( + &marketplace_path, + serde_json::to_string_pretty(&json!({ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./other/demo-plugin", + }, + }, + ], + }))?, + )?; + + let bundle_url = mount_remote_plugin_bundle( + &server, + "demo-plugin", + remote_plugin_bundle_tar_gz_bytes("demo-plugin")?, + ) + .await; + mount_remote_plugin_detail_with_bundle( + &server, + "plugins_123", + "demo-plugin", + &bundle_url, + "WORKSPACE", + ) + .await; + mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; + + let home_env = home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_env.as_str())), + ("USERPROFILE", Some(home_env.as_str())), + (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "plugin/share/checkout", + Some(json!({ + "remotePluginId": "plugins_123", + })), + ) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert!( + error + .error + .message + .contains("marketplace already contains plugin `demo-plugin`") + ); + assert!(!home.path().join("plugins/demo-plugin").exists()); + assert!( + !codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json") + .exists() + ); + + Ok(()) +} + #[tokio::test] async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { let codex_home = TempDir::new()?; @@ -823,6 +1159,85 @@ remote_plugin = true ) } +async fn mount_remote_plugin_bundle( + server: &MockServer, + plugin_name: &str, + body: Vec, +) -> String { + let bundle_path = format!("/bundles/{plugin_name}.tar.gz"); + Mock::given(method("GET")) + .and(path(bundle_path.clone())) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .expect(1) + .mount(server) + .await; + format!("{}{}", server.uri(), bundle_path) +} + +async fn mount_remote_plugin_detail_with_bundle( + server: &MockServer, + remote_plugin_id: &str, + plugin_name: &str, + bundle_url: &str, + scope: &str, +) { + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(query_param("includeDownloadUrls", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": remote_plugin_id, + "name": plugin_name, + "scope": scope, + "discoverability": "PRIVATE", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner", + }, + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.3", + "bundle_download_url": bundle_url, + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"], + }, + "skills": [], + }, + }))) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer, scope: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "next_page_token": null, + }, + }))) + .mount(server) + .await; +} + fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { json!({ "id": plugin_id, @@ -935,6 +1350,32 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result Ok(plugin_path) } +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + let skill = "# Example\n\nA test skill.\n"; + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/example/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} + fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> { write_file( &codex_home.join(".tmp/plugin-share-local-paths-v1.json"), diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 15959a7b559d..d85cf2c16dc6 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -7,7 +7,6 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; -use dirs::home_dir; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; @@ -225,6 +224,16 @@ pub fn list_marketplaces( list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } +pub(crate) fn home_dir() -> Option { + ["HOME", "USERPROFILE"] + .into_iter() + .filter_map(std::env::var_os) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .find(|path| path.is_absolute()) + .or_else(dirs::home_dir) +} + pub fn validate_marketplace_root(root: &Path) -> Result { let Some(path) = find_marketplace_manifest_path(root) else { return Err(MarketplaceError::InvalidMarketplaceFile { diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 47563b95b800..fc6fedbe2329 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -39,6 +39,7 @@ pub use share::RemotePluginShareTarget; pub use share::RemotePluginShareTargetRole; pub use share::RemotePluginShareUpdateDiscoverability; pub use share::RemotePluginShareUpdateTargetsResult; +pub use share::checkout_remote_plugin_share; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; pub use share::load_plugin_share_remote_ids_by_local_path; @@ -233,6 +234,9 @@ pub enum RemotePluginCatalogError { #[error("invalid plugin path `{path}`: {reason}")] InvalidPluginPath { path: PathBuf, reason: String }, + #[error("remote plugin `{remote_plugin_id}` is not available for plugin/share/checkout")] + PluginShareCheckoutNotAvailable { remote_plugin_id: String }, + #[error("failed to archive plugin at `{path}`: {source}")] Archive { path: PathBuf, diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 6afeab74b1ff..9b5a2572a471 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -16,10 +16,13 @@ use std::io::Write; use std::path::Path; use tracing::warn; +mod checkout; mod local_paths; const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024; +pub use checkout::checkout_remote_plugin_share; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareSaveResult { pub remote_plugin_id: String, diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs new file mode 100644 index 000000000000..9ccdbee851eb --- /dev/null +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -0,0 +1,467 @@ +use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use super::super::RemotePluginCatalogError; +use super::super::RemotePluginServiceConfig; +use super::local_paths; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_login::CodexAuth; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Component; +use std::path::Path; + +const PERSONAL_MARKETPLACE_NAME: &str = "codex-curated"; +const PERSONAL_MARKETPLACE_DISPLAY_NAME: &str = "Personal"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareCheckoutResult { + pub remote_plugin_id: String, + pub plugin_id: String, + pub plugin_name: String, + pub plugin_path: AbsolutePathBuf, + pub marketplace_name: String, + pub marketplace_path: AbsolutePathBuf, + pub remote_version: Option, +} + +pub async fn checkout_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + codex_home: &Path, + remote_plugin_id: &str, +) -> Result { + let detail = super::super::fetch_remote_plugin_detail_with_download_urls( + config, + auth, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + remote_plugin_id, + ) + .await?; + let plugin_name = detail.summary.name.clone(); + let remote_version = detail.release_version.clone(); + validate_plugin_segment(&plugin_name, "plugin name").map_err(|reason| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "remote plugin `{remote_plugin_id}` returned invalid plugin name: {reason}" + )) + })?; + if !is_checkout_supported_share_marketplace(&detail.marketplace_name) + || detail.summary.share_context.is_none() + { + return Err(RemotePluginCatalogError::PluginShareCheckoutNotAvailable { + remote_plugin_id: remote_plugin_id.to_string(), + }); + } + + let home = crate::marketplace::home_dir().ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse( + "could not determine home directory for personal plugin marketplace".to_string(), + ) + })?; + let home = AbsolutePathBuf::try_from(home).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to resolve home directory for personal plugin marketplace: {err}" + )) + })?; + + let local_paths = load_share_local_paths_for_checkout(codex_home)?; + let (local_plugin_path, already_checked_out) = + editable_plugin_path_for_checkout(&home, &plugin_name, remote_plugin_id, &local_paths)?; + + let mut created_checkout_path = false; + if !already_checked_out { + let bundle = crate::remote_bundle::validate_remote_plugin_bundle( + remote_plugin_id, + &detail.marketplace_name, + &plugin_name, + detail.release_version.as_deref(), + detail.bundle_download_url.as_deref(), + ) + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to prepare remote plugin bundle checkout: {err}" + )) + })?; + crate::remote_bundle::download_and_extract_remote_plugin_bundle_to_path( + bundle, + local_plugin_path.clone(), + ) + .await + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to check out remote plugin bundle: {err}" + )) + })?; + created_checkout_path = true; + } + + let marketplace = match update_personal_marketplace( + &home, + &plugin_name, + &local_plugin_path, + detail.summary.install_policy, + detail.summary.auth_policy, + detail + .summary + .interface + .as_ref() + .and_then(|interface| interface.category.clone()), + ) { + Ok(marketplace) => marketplace, + Err(err) => { + return Err(clean_up_created_checkout_path( + created_checkout_path, + &local_plugin_path, + err, + )); + } + }; + + if let Err(err) = local_paths::record_plugin_share_local_path( + codex_home, + remote_plugin_id, + local_plugin_path.clone(), + ) { + let err = RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to record plugin share local path mapping: {err}" + )); + return Err(clean_up_created_checkout_path( + created_checkout_path, + &local_plugin_path, + err, + )); + } + + let plugin_id = PluginId::new(plugin_name.clone(), marketplace.name.clone()) + .map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to build checked out plugin id: {err}" + )) + })? + .as_key(); + + Ok(RemotePluginShareCheckoutResult { + remote_plugin_id: remote_plugin_id.to_string(), + plugin_id, + plugin_name, + plugin_path: local_plugin_path, + marketplace_name: marketplace.name, + marketplace_path: marketplace.path, + remote_version, + }) +} + +fn is_checkout_supported_share_marketplace(marketplace_name: &str) -> bool { + matches!( + marketplace_name, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) +} + +fn load_share_local_paths_for_checkout( + codex_home: &Path, +) -> Result, RemotePluginCatalogError> { + match local_paths::load_plugin_share_local_paths(codex_home) { + Ok(paths) => Ok(paths), + Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()), + Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to load plugin share local path mapping: {err}" + ))), + } +} + +fn editable_plugin_path_for_checkout( + home: &AbsolutePathBuf, + plugin_name: &str, + remote_plugin_id: &str, + local_paths: &BTreeMap, +) -> Result<(AbsolutePathBuf, bool), RemotePluginCatalogError> { + if let Some(existing_path) = local_paths.get(remote_plugin_id) + && existing_path.as_path().exists() + { + ensure_path_can_be_listed_in_personal_marketplace(home, existing_path)?; + return Ok((existing_path.clone(), true)); + } + + let local_plugin_path = local_paths + .get(remote_plugin_id) + .cloned() + .unwrap_or_else(|| home.join("plugins").join(plugin_name)); + ensure_path_can_be_listed_in_personal_marketplace(home, &local_plugin_path)?; + + if local_plugin_path.as_path().exists() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: format!( + "cannot check out remote plugin `{remote_plugin_id}` because the local plugin path already exists" + ), + }); + } + + Ok((local_plugin_path, false)) +} + +fn clean_up_created_checkout_path( + created_checkout_path: bool, + local_plugin_path: &AbsolutePathBuf, + original_err: RemotePluginCatalogError, +) -> RemotePluginCatalogError { + if !created_checkout_path { + return original_err; + } + + match remove_created_checkout_path(local_plugin_path) { + Ok(()) => original_err, + Err(cleanup_err) => RemotePluginCatalogError::UnexpectedResponse(format!( + "{original_err}; additionally failed to clean up checked out plugin path `{}`: {cleanup_err}", + local_plugin_path.display() + )), + } +} + +fn remove_created_checkout_path(local_plugin_path: &AbsolutePathBuf) -> io::Result<()> { + if local_plugin_path.as_path().is_dir() { + fs::remove_dir_all(local_plugin_path.as_path()) + } else { + fs::remove_file(local_plugin_path.as_path()) + } +} + +fn ensure_path_can_be_listed_in_personal_marketplace( + home: &AbsolutePathBuf, + path: &AbsolutePathBuf, +) -> Result<(), RemotePluginCatalogError> { + personal_marketplace_relative_plugin_path(home, path).map(|_| ()) +} + +struct PersonalMarketplaceUpdate { + name: String, + path: AbsolutePathBuf, +} + +fn update_personal_marketplace( + home: &AbsolutePathBuf, + plugin_name: &str, + local_plugin_path: &AbsolutePathBuf, + install_policy: PluginInstallPolicy, + auth_policy: PluginAuthPolicy, + category: Option, +) -> Result { + let marketplace_path = home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH); + let relative_plugin_path = personal_marketplace_relative_plugin_path(home, local_plugin_path)?; + let mut marketplace = read_or_create_personal_marketplace(marketplace_path.as_path())?; + let Some(marketplace_object) = marketplace.as_object_mut() else { + return Err(invalid_marketplace_file( + marketplace_path.as_path(), + "personal marketplace file must contain a JSON object", + )); + }; + let marketplace_name = marketplace_object + .entry("name") + .or_insert_with(|| json!(PERSONAL_MARKETPLACE_NAME)) + .as_str() + .ok_or_else(|| { + invalid_marketplace_file( + marketplace_path.as_path(), + "marketplace name must be a string", + ) + })? + .to_string(); + validate_plugin_segment(&marketplace_name, "marketplace name").map_err(|reason| { + invalid_marketplace_file( + marketplace_path.as_path(), + &format!("marketplace name is invalid: {reason}"), + ) + })?; + + let plugins = marketplace_object + .entry("plugins") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(|| { + invalid_marketplace_file( + marketplace_path.as_path(), + "marketplace plugins must be an array", + ) + })?; + + let new_entry = personal_marketplace_plugin_entry( + plugin_name, + &relative_plugin_path, + install_policy, + auth_policy, + category, + ); + + if let Some(existing_entry) = plugins + .iter_mut() + .find(|entry| entry.get("name").and_then(JsonValue::as_str) == Some(plugin_name)) + { + let existing_path = existing_entry + .get("source") + .and_then(|source| source.get("path")) + .and_then(JsonValue::as_str); + if existing_path != Some(relative_plugin_path.as_str()) { + return Err(invalid_marketplace_file( + marketplace_path.as_path(), + &format!( + "marketplace already contains plugin `{plugin_name}` with a different source path" + ), + )); + } + *existing_entry = new_entry; + } else { + plugins.push(new_entry); + } + + let contents = serde_json::to_string_pretty(&marketplace) + .map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?; + write_json_atomically(marketplace_path.as_path(), &format!("{contents}\n")).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to update personal plugin marketplace: {err}" + )) + })?; + + Ok(PersonalMarketplaceUpdate { + name: marketplace_name, + path: marketplace_path, + }) +} + +fn read_or_create_personal_marketplace( + marketplace_path: &Path, +) -> Result { + match std::fs::read_to_string(marketplace_path) { + Ok(contents) => serde_json::from_str(&contents).map_err(|err| { + invalid_marketplace_file( + marketplace_path, + &format!("failed to parse personal marketplace file: {err}"), + ) + }), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(json!({ + "name": PERSONAL_MARKETPLACE_NAME, + "interface": { + "displayName": PERSONAL_MARKETPLACE_DISPLAY_NAME, + }, + "plugins": [], + })), + Err(err) => Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to read personal plugin marketplace: {err}" + ))), + } +} + +fn personal_marketplace_plugin_entry( + plugin_name: &str, + relative_plugin_path: &str, + install_policy: PluginInstallPolicy, + auth_policy: PluginAuthPolicy, + category: Option, +) -> JsonValue { + let mut entry = json!({ + "name": plugin_name, + "source": { + "source": "local", + "path": relative_plugin_path, + }, + "policy": { + "installation": plugin_install_policy_value(install_policy), + "authentication": plugin_auth_policy_value(auth_policy), + }, + }); + if let Some(category) = category + && !category.trim().is_empty() + && let Some(object) = entry.as_object_mut() + { + object.insert("category".to_string(), json!(category)); + } + entry +} + +fn plugin_install_policy_value(policy: PluginInstallPolicy) -> &'static str { + match policy { + PluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", + PluginInstallPolicy::Available => "AVAILABLE", + PluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", + } +} + +fn plugin_auth_policy_value(policy: PluginAuthPolicy) -> &'static str { + match policy { + PluginAuthPolicy::OnInstall => "ON_INSTALL", + PluginAuthPolicy::OnUse => "ON_USE", + } +} + +fn personal_marketplace_relative_plugin_path( + home: &AbsolutePathBuf, + local_plugin_path: &AbsolutePathBuf, +) -> Result { + let relative = local_plugin_path + .as_path() + .strip_prefix(home.as_path()) + .map_err(|_| RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path must be inside the home directory to be listed in the personal marketplace".to_string(), + })?; + let mut segments = Vec::new(); + for component in relative.components() { + match component { + Component::Normal(segment) => { + let segment = segment.to_str().ok_or_else(|| { + RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path contains non-UTF-8 segments".to_string(), + } + })?; + segments.push(segment.to_string()); + } + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: + "local plugin path cannot be represented as a personal marketplace path" + .to_string(), + }); + } + } + } + if segments.is_empty() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: local_plugin_path.to_path_buf(), + reason: "local plugin path must not be the home directory".to_string(), + }); + } + Ok(format!("./{}", segments.join("/"))) +} + +fn invalid_marketplace_file(path: &Path, message: &str) -> RemotePluginCatalogError { + RemotePluginCatalogError::InvalidPluginPath { + path: path.to_path_buf(), + reason: message.to_string(), + } +} + +fn write_json_atomically(write_path: &Path, contents: &str) -> io::Result<()> { + let parent = write_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path {} has no parent directory", write_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(contents.as_bytes())?; + tmp.persist(write_path).map_err(|err| err.error)?; + Ok(()) +} diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 92d561384694..ed7cd56a86c7 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -239,6 +239,26 @@ pub async fn download_and_install_remote_plugin_bundle( })? } +pub(crate) async fn download_and_extract_remote_plugin_bundle_to_path( + bundle: ValidatedRemotePluginBundle, + destination: AbsolutePathBuf, +) -> Result { + let bundle_bytes = download_remote_plugin_bundle_with_limit( + &bundle.bundle_download_url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + ) + .await?; + tokio::task::spawn_blocking(move || { + extract_remote_plugin_bundle_to_path(bundle, bundle_bytes, destination) + }) + .await + .map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to join remote plugin bundle extraction task: {err}" + )) + })? +} + async fn download_remote_plugin_bundle_with_limit( bundle_download_url: &str, max_bytes: u64, @@ -359,6 +379,63 @@ fn install_remote_plugin_bundle( .map_err(RemotePluginBundleInstallError::from) } +fn extract_remote_plugin_bundle_to_path( + bundle: ValidatedRemotePluginBundle, + bundle_bytes: Vec, + destination: AbsolutePathBuf, +) -> Result { + if destination.as_path().exists() { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin checkout destination already exists: {}", + destination.display() + ))); + } + + let parent = destination.as_path().parent().ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin checkout destination has no parent: {}", + destination.display() + )) + })?; + fs::create_dir_all(parent).map_err(|source| { + RemotePluginBundleInstallError::io("failed to create plugin checkout directory", source) + })?; + + let extract_dir = tempfile::Builder::new() + .prefix("remote-plugin-checkout-") + .tempdir_in(parent) + .map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; + let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + let manifest = crate::manifest::load_plugin_manifest(&plugin_root).ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a valid plugin.json".to_string(), + ) + })?; + if manifest.name != bundle.plugin_id.plugin_name { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "plugin.json name `{}` does not match remote plugin name `{}`", + manifest.name, bundle.plugin_id.plugin_name + ))); + } + + let staged_path = extract_dir.keep(); + fs::rename(&staged_path, destination.as_path()).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to activate checked out plugin directory", + source, + ) + })?; + + Ok(destination) +} + fn extract_plugin_bundle_tar_gz( bytes: &[u8], destination: &Path,