From 87d0cf1a62e99d10be62b57d0f1725f7ebdd0a3a Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 29 Apr 2026 23:49:20 -0700 Subject: [PATCH] feat: Add workspace plugin sharing APIs (#20278) 1. Adds v2 plugin/share/save, plugin/share/list, and plugin/share/delete RPCs. 2. Implements save by archiving a local plugin root, enforcing a size limit, uploading through the workspace upload flow, and supporting updates via remotePluginId. 3. Lists created workspace plugins 4. Deletes a previously uploaded/shared plugin. --- .../schema/json/ClientRequest.json | 103 +++++ .../codex_app_server_protocol.schemas.json | 147 ++++++ .../codex_app_server_protocol.v2.schemas.json | 147 ++++++ .../json/v2/PluginShareDeleteParams.json | 13 + .../json/v2/PluginShareDeleteResponse.json | 5 + .../schema/json/v2/PluginShareListParams.json | 5 + .../json/v2/PluginShareListResponse.json | 291 ++++++++++++ .../schema/json/v2/PluginShareSaveParams.json | 25 ++ .../json/v2/PluginShareSaveResponse.json | 17 + .../schema/typescript/ClientRequest.ts | 5 +- .../typescript/v2/PluginShareDeleteParams.ts | 5 + .../v2/PluginShareDeleteResponse.ts | 5 + .../typescript/v2/PluginShareListParams.ts | 5 + .../typescript/v2/PluginShareListResponse.ts | 6 + .../typescript/v2/PluginShareSaveParams.ts | 6 + .../typescript/v2/PluginShareSaveResponse.ts | 5 + .../schema/typescript/v2/index.ts | 6 + .../src/protocol/common.rs | 15 + .../app-server-protocol/src/protocol/v2.rs | 136 ++++++ .../app-server/src/codex_message_processor.rs | 18 + .../src/codex_message_processor/plugins.rs | 178 ++++++-- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/plugin_install.rs | 8 +- .../app-server/tests/suite/v2/plugin_read.rs | 8 +- .../app-server/tests/suite/v2/plugin_share.rs | 326 ++++++++++++++ .../tests/suite/v2/plugin_uninstall.rs | 99 ++++- codex-rs/core-plugins/src/remote.rs | 90 ++-- codex-rs/core-plugins/src/remote/share.rs | 418 ++++++++++++++++++ .../core-plugins/src/remote/share/tests.rs | 417 +++++++++++++++++ 29 files changed, 2402 insertions(+), 108 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/plugin_share.rs create mode 100644 codex-rs/core-plugins/src/remote/share.rs create mode 100644 codex-rs/core-plugins/src/remote/share/tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 06eaf9c8d..4baab4081 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2186,6 +2186,37 @@ ], "type": "object" }, + "PluginShareDeleteParams": { + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareListParams": { + "type": "object" + }, + "PluginShareSaveParams": { + "properties": { + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "type": "object" + }, "PluginUninstallParams": { "properties": { "pluginId": { @@ -5075,6 +5106,78 @@ "title": "Plugin/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/save" + ], + "title": "Plugin/share/saveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareSaveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/saveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/list" + ], + "title": "Plugin/share/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/delete" + ], + "title": "Plugin/share/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/deleteRequest", + "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 59c653096..ec024d1f0 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 @@ -786,6 +786,78 @@ "title": "Plugin/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/save" + ], + "title": "Plugin/share/saveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareSaveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/saveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/list" + ], + "title": "Plugin/share/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/delete" + ], + "title": "Plugin/share/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/deleteRequest", + "type": "object" + }, { "properties": { "id": { @@ -12249,6 +12321,81 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" + }, + "PluginShareDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" + }, + "PluginShareListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" + }, + "PluginShareListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PluginShareListResponse", + "type": "object" + }, + "PluginShareSaveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginPath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" + }, + "PluginShareSaveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "type": "object" + }, "PluginSource": { "oneOf": [ { 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 45a6147bf..3a18b7cd2 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 @@ -1545,6 +1545,78 @@ "title": "Plugin/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/save" + ], + "title": "Plugin/share/saveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareSaveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/saveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/list" + ], + "title": "Plugin/share/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/delete" + ], + "title": "Plugin/share/deleteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareDeleteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/deleteRequest", + "type": "object" + }, { "properties": { "id": { @@ -8903,6 +8975,81 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareDeleteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" + }, + "PluginShareDeleteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" + }, + "PluginShareListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" + }, + "PluginShareListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PluginShareListResponse", + "type": "object" + }, + "PluginShareSaveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" + }, + "PluginShareSaveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "type": "object" + }, "PluginSource": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json new file mode 100644 index 000000000..2dbdab8ee --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + } + }, + "required": [ + "remotePluginId" + ], + "title": "PluginShareDeleteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json new file mode 100644 index 000000000..95068869a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareDeleteResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareDeleteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json new file mode 100644 index 000000000..101136d90 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PluginShareListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json new file mode 100644 index 000000000..eb33b3ce1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -0,0 +1,291 @@ +{ + "$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" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "PluginShareListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json new file mode 100644 index 000000000..ee1ae4873 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -0,0 +1,25 @@ +{ + "$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": { + "pluginPath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "remotePluginId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pluginPath" + ], + "title": "PluginShareSaveParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json new file mode 100644 index 000000000..dbfe091b7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareUrl": { + "type": "string" + } + }, + "required": [ + "remotePluginId", + "shareUrl" + ], + "title": "PluginShareSaveResponse", + "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 a86297e3a..026db95f7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -48,6 +48,9 @@ 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 { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; +import type { PluginShareListParams } from "./v2/PluginShareListParams"; +import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; @@ -78,4 +81,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/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "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": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "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": "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/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "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/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "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": "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/PluginShareDeleteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.ts new file mode 100644 index 000000000..b0adaf2da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteParams.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 PluginShareDeleteParams = { remotePluginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.ts new file mode 100644 index 000000000..231026836 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDeleteResponse.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 PluginShareDeleteResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.ts new file mode 100644 index 000000000..167ace7ac --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListParams.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 PluginShareListParams = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts new file mode 100644 index 000000000..32b6c50c4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSummary } from "./PluginSummary"; + +export type PluginShareListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts new file mode 100644 index 000000000..d2011984e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.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 PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.ts new file mode 100644 index 000000000..b53ace0ef --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveResponse.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 PluginShareSaveResponse = { remotePluginId: string, shareUrl: string, }; 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 34534c5c8..28de6b108 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -280,6 +280,12 @@ export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; +export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareListParams } from "./PluginShareListParams"; +export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginShareSaveParams } from "./PluginShareSaveParams"; +export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; export type { PluginSource } from "./PluginSource"; export type { PluginSummary } from "./PluginSummary"; export type { PluginUninstallParams } from "./PluginUninstallParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index cdea769f0..062b40d22 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -611,6 +611,21 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginReadResponse, }, + PluginShareSave => "plugin/share/save" { + params: v2::PluginShareSaveParams, + serialization: global("config"), + response: v2::PluginShareSaveResponse, + }, + PluginShareList => "plugin/share/list" { + params: v2::PluginShareListParams, + serialization: global("config"), + response: v2::PluginShareListResponse, + }, + PluginShareDelete => "plugin/share/delete" { + params: v2::PluginShareDeleteParams, + serialization: global("config"), + response: v2::PluginShareDeleteResponse, + }, AppsList => "app/list" { params: v2::AppsListParams, serialization: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2c1f2abc3..900351fee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4607,6 +4607,47 @@ pub struct PluginReadResponse { pub plugin: PluginDetail, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveParams { + pub plugin_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveResponse { + pub remote_plugin_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -10603,6 +10644,101 @@ mod tests { ); } + #[test] + fn plugin_share_params_and_response_serialization_use_camel_case_fields() { + let plugin_path = if cfg!(windows) { + r"C:\plugins\gmail" + } else { + "/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path: plugin_path.clone(), + remote_plugin_id: None, + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path, + remote_plugin_id: Some( + "plugins~Plugin_00000000000000000000000000000000".to_string(), + ), + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_url: String::new(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareUrl": "", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + PluginShareListParams {}, + ); + + assert_eq!( + serde_json::to_value(PluginShareDeleteParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); + } + + #[test] + fn plugin_share_list_response_serializes_plugin_summaries() { + assert_eq!( + serde_json::to_value(PluginShareListResponse { + data: vec![PluginSummary { + id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + name: "gmail".to_string(), + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + interface: None, + }], + }) + .unwrap(), + json!({ + "data": [{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + }], + }), + ); + } + #[test] fn plugin_uninstall_params_serialization_omits_force_remote_sync() { assert_eq!( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ea82c306b..16de9ba7a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -120,6 +120,12 @@ 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::PluginShareDeleteParams; +use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareListParams; +use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginShareSaveParams; +use codex_app_server_protocol::PluginShareSaveResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallParams; @@ -1122,6 +1128,18 @@ impl CodexMessageProcessor { self.plugin_read(to_connection_request_id(request_id), params) .await; } + ClientRequest::PluginShareSave { request_id, params } => { + self.plugin_share_save(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::PluginShareList { request_id, params } => { + self.plugin_share_list(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::PluginShareDelete { request_id, params } => { + self.plugin_share_delete(to_connection_request_id(request_id), params) + .await; + } ClientRequest::AppsList { request_id, params } => { self.apps_list(to_connection_request_id(request_id), params) .await; diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index cb11e20e6..654b942c3 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -256,22 +256,14 @@ impl CodexMessageProcessor { if !config.features.enabled(Feature::Plugins) || !config.features.enabled(Feature::RemotePlugin) { - return Err(invalid_request(format!( - "remote plugin read is not enabled for marketplace {remote_marketplace_name}" - ))); + return Err(invalid_request("remote plugin read is not enabled")); } let auth = self.auth_manager.auth().await; let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - if plugin_name.is_empty() - || !plugin_name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') - { - return Err(invalid_request( - "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", - )); + if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) { + return Err(invalid_request("invalid remote plugin id")); } let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, @@ -303,6 +295,122 @@ impl CodexMessageProcessor { Ok(PluginReadResponse { plugin }) } + pub(super) async fn plugin_share_save( + &self, + request_id: ConnectionRequestId, + params: PluginShareSaveParams, + ) { + let result = self.plugin_share_save_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_share_save_response( + &self, + params: PluginShareSaveParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareSaveParams { + plugin_path, + remote_plugin_id, + } = params; + if let Some(remote_plugin_id) = remote_plugin_id.as_ref() + && (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::save_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + plugin_path.as_path(), + remote_plugin_id.as_deref(), + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareSaveResponse { + remote_plugin_id: result.remote_plugin_id, + share_url: result.share_url.unwrap_or_default(), + }) + } + + pub(super) async fn plugin_share_list( + &self, + request_id: ConnectionRequestId, + _params: PluginShareListParams, + ) { + let result = self.plugin_share_list_response().await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_share_list_response( + &self, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let data = codex_core_plugins::remote::list_remote_plugin_shares( + &remote_plugin_service_config, + auth.as_ref(), + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))? + .into_iter() + .map(remote_plugin_summary_to_info) + .collect(); + Ok(PluginShareListResponse { data }) + } + + pub(super) async fn plugin_share_delete( + &self, + request_id: ConnectionRequestId, + params: PluginShareDeleteParams, + ) { + let result = self.plugin_share_delete_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_share_delete_response( + &self, + params: PluginShareDeleteParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareDeleteParams { 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(), + }; + codex_core_plugins::remote::delete_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareDeleteResponse {}) + } + + async fn load_plugin_share_config_and_auth( + &self, + ) -> Result<(Config, Option), JSONRPCErrorError> { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) + || !config.features.enabled(Feature::RemotePlugin) + { + return Err(invalid_request("plugin sharing is not enabled")); + } + let auth = self.auth_manager.auth().await; + Ok((config, auth)) + } + pub(super) async fn plugin_install( &self, request_id: ConnectionRequestId, @@ -401,14 +509,10 @@ impl CodexMessageProcessor { if !config.features.enabled(Feature::Plugins) || !config.features.enabled(Feature::RemotePlugin) { - return Err(invalid_request(format!( - "remote plugin install is not enabled for marketplace {remote_marketplace_name}" - ))); + return Err(invalid_request("remote plugin install is not enabled")); } if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) { - return Err(invalid_request( - "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", - )); + return Err(invalid_request("invalid remote plugin id")); } let auth = self.auth_manager.auth().await; @@ -434,9 +538,10 @@ impl CodexMessageProcessor { "remote plugin {plugin_name} is not available for install" ))); } + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( &plugin_name, - &remote_marketplace_name, + &actual_remote_marketplace_name, &remote_detail.summary.name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), @@ -456,7 +561,7 @@ impl CodexMessageProcessor { codex_core_plugins::remote::install_remote_plugin( &remote_plugin_service_config, auth.as_ref(), - &remote_marketplace_name, + &actual_remote_marketplace_name, &plugin_name, ) .await @@ -571,13 +676,13 @@ impl CodexMessageProcessor { ) -> Result { let PluginUninstallParams { plugin_id } = params; if codex_core::plugins::PluginId::parse(&plugin_id).is_err() - && !is_valid_remote_uninstall_plugin_id(&plugin_id) + && (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id)) { return Err(invalid_request( - "invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `app_`, `asdk_app_`, or `connector_`", + "invalid plugin id: expected a local plugin id or remote plugin id", )); } - if is_valid_remote_uninstall_plugin_id(&plugin_id) { + if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) { return self.remote_plugin_uninstall_response(plugin_id).await; } let plugins_manager = self.thread_manager.plugins_manager(); @@ -669,9 +774,7 @@ impl CodexMessageProcessor { return Err(invalid_request("remote plugin uninstall is not enabled")); } if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) { - return Err(invalid_request( - "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", - )); + return Err(invalid_request("invalid remote plugin id")); } let auth = self.auth_manager.auth().await; @@ -714,15 +817,6 @@ fn is_valid_remote_plugin_id(plugin_name: &str) -> bool { .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') } -fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool { - !plugin_name.is_empty() - && is_valid_remote_plugin_id(plugin_name) - && (plugin_name.starts_with("plugins~") - || plugin_name.starts_with("app_") - || plugin_name.starts_with("asdk_app_") - || plugin_name.starts_with("connector_")) -} - fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: marketplace.name, @@ -789,12 +883,6 @@ fn remote_plugin_catalog_error_to_jsonrpc( data: None, } } - RemotePluginCatalogError::UnknownMarketplace { .. } - | RemotePluginCatalogError::MarketplaceMismatch { .. } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{context}: {err}"), - data: None, - }, RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -802,12 +890,22 @@ fn remote_plugin_catalog_error_to_jsonrpc( data: None, } } + RemotePluginCatalogError::InvalidPluginPath { .. } + | RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + }, RemotePluginCatalogError::AuthToken(_) | RemotePluginCatalogError::Request { .. } | RemotePluginCatalogError::UnexpectedStatus { .. } | RemotePluginCatalogError::Decode { .. } | RemotePluginCatalogError::UnexpectedPluginId { .. } | RemotePluginCatalogError::UnexpectedEnabledState { .. } + | RemotePluginCatalogError::Archive { .. } + | RemotePluginCatalogError::ArchiveJoin(_) + | RemotePluginCatalogError::MissingUploadEtag + | RemotePluginCatalogError::UnexpectedResponse(_) | RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("{context}: {err}"), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 59bfd2aa7..a951257cc 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -33,6 +33,7 @@ mod plan_item; mod plugin_install; mod plugin_list; mod plugin_read; +mod plugin_share; mod plugin_uninstall; mod rate_limits; mod realtime_conversation; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 1b8069c7b..47607a436 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -177,7 +177,6 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable .message .contains("remote plugin install is not enabled") ); - assert!(err.error.message.contains("chatgpt-global")); Ok(()) } @@ -405,11 +404,6 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("invalid remote plugin id")); - assert!( - err.error - .message - .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed") - ); Ok(()) } @@ -1314,7 +1308,7 @@ async fn send_remote_plugin_install_request( ) -> Result { mcp.send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()), plugin_name: remote_plugin_id.to_string(), }) .await diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 753468345..e77f9dc56 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -139,7 +139,6 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() .message .contains("remote plugin read is not enabled") ); - assert!(err.error.message.contains("chatgpt-global")); Ok(()) } @@ -253,7 +252,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()), plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -413,11 +412,6 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("invalid remote plugin id")); - assert!( - err.error - .message - .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed") - ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs new file mode 100644 index 000000000..e783f58fa --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -0,0 +1,326 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +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::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn plugin_share_save_uploads_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + 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, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .and(header("x-ms-blob-type", "BlockBlob")) + .and(header("content-type", "application/gzip")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { + let codex_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, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_123")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request("plugin/share/list", Some(json!({}))) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareListResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareListResponse { + data: vec![PluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + interface: Some(expected_plugin_interface()), + }], + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { + let codex_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, + )?; + + Mock::given(method("DELETE")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/delete", + 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: PluginShareDeleteResponse = to_response(response)?; + + assert_eq!(response, PluginShareDeleteResponse {}); + Ok(()) +} + +fn write_remote_plugin_config(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { + json!({ + "id": plugin_id, + "name": "demo-plugin", + "scope": "WORKSPACE", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + }) +} + +fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("enabled".to_string(), json!(true)); + fields.insert("disabled_skill_names".to_string(), json!([])); + plugin +} + +fn empty_pagination_json() -> serde_json::Value { + json!({ + "next_page_token": null + }) +} + +fn expected_plugin_interface() -> PluginInterface { + PluginInterface { + display_name: Some("Demo Plugin".to_string()), + short_description: Some("A demo plugin".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Read".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + } +} + +fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result { + let plugin_path = root.join(plugin_name); + write_file( + &plugin_path.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + write_file( + &plugin_path.join("skills/example/SKILL.md"), + "# Example\n\nA test skill.\n", + )?; + Ok(plugin_path) +} + +fn write_file(path: &Path, contents: &str) -> std::io::Result<()> { + let Some(parent) = path.parent() else { + return Err(std::io::Error::other(format!( + "file path `{}` should have a parent", + path.display() + ))); + }; + std::fs::create_dir_all(parent)?; + std::fs::write(path, contents) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 5a05816b0..26d1e2f88 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -26,6 +26,7 @@ use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear"; +const WORKSPACE_REMOTE_PLUGIN_ID: &str = "plugins_69f27c3e67848191a45cbaa5f2adb39d"; #[tokio::test] async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { @@ -323,6 +324,79 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> Ok(()) } +#[tokio::test] +async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_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, + )?; + mount_remote_plugin_detail_with_name( + &server, + WORKSPACE_REMOTE_PLUGIN_ID, + "skill-improver", + "1.0.0", + "WORKSPACE", + ) + .await; + + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"id":"{WORKSPACE_REMOTE_PLUGIN_ID}","enabled":false}}"# + ))) + .mount(&server) + .await; + + let remote_plugin_cache_root = codex_home + .path() + .join("plugins/cache/chatgpt-workspace/skill-improver"); + std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; + std::fs::write( + remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), + r#"{"name":"skill-improver","version":"1.0.0"}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: WORKSPACE_REMOTE_PLUGIN_ID.to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + + assert_eq!(response, PluginUninstallResponse {}); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"), + /*expected_count*/ 1, + ) + .await?; + assert!(!remote_plugin_cache_root.exists()); + Ok(()) +} + #[tokio::test] async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> { let codex_home = TempDir::new()?; @@ -380,7 +454,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - } #[tokio::test] -async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> { +async fn plugin_uninstall_rejects_invalid_plugin_id_before_remote_path() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( @@ -392,7 +466,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { - plugin_id: "sample-plugin".to_string(), + plugin_id: "sample plugin".to_string(), }) .await?; @@ -407,7 +481,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() wait_for_remote_plugin_request_count( &server, "POST", - "/plugins/sample-plugin/uninstall", + "/plugins/sample plugin/uninstall", /*expected_count*/ 0, ) .await?; @@ -519,11 +593,28 @@ async fn mount_remote_plugin_detail( remote_plugin_id: &str, release_version: &str, scope: &str, +) { + mount_remote_plugin_detail_with_name( + server, + remote_plugin_id, + "linear", + release_version, + scope, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_name( + server: &MockServer, + remote_plugin_id: &str, + plugin_name: &str, + release_version: &str, + scope: &str, ) { let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", - "name": "linear", + "name": "{plugin_name}", "scope": "{scope}", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index e6256691f..42ff572a8 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -16,6 +16,13 @@ use std::fs; use std::path::PathBuf; use std::time::Duration; +mod share; + +pub use share::RemotePluginShareSaveResult; +pub use share::delete_remote_plugin_share; +pub use share::list_remote_plugin_shares; +pub use share::save_remote_plugin_share; + pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; @@ -111,18 +118,6 @@ pub enum RemotePluginCatalogError { source: serde_json::Error, }, - #[error("remote marketplace `{marketplace_name}` is not supported")] - UnknownMarketplace { marketplace_name: String }, - - #[error( - "remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`" - )] - MarketplaceMismatch { - plugin_id: String, - expected_marketplace_name: String, - actual_marketplace_name: String, - }, - #[error( "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" )] @@ -137,6 +132,30 @@ pub enum RemotePluginCatalogError { actual_enabled: bool, }, + #[error("invalid plugin path `{path}`: {reason}")] + InvalidPluginPath { path: PathBuf, reason: String }, + + #[error("failed to archive plugin at `{path}`: {source}")] + Archive { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to join plugin archive task: {0}")] + ArchiveJoin(#[source] tokio::task::JoinError), + + #[error( + "plugin archive would be {bytes} bytes, exceeding the maximum upload size of {max_bytes} bytes" + )] + ArchiveTooLarge { bytes: usize, max_bytes: usize }, + + #[error("workspace plugin upload response did not include an etag")] + MissingUploadEtag, + + #[error("{0}")] + UnexpectedResponse(String), + #[error("{0}")] CacheRemove(String), } @@ -174,14 +193,6 @@ impl RemotePluginScope { Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, } } - - fn from_marketplace_name(name: &str) -> Option { - match name { - REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), - REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace), - _ => None, - } - } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -443,35 +454,19 @@ pub async fn fetch_remote_plugin_detail_with_download_urls( async fn fetch_remote_plugin_detail_with_download_url_option( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, - marketplace_name: &str, + _marketplace_name: &str, plugin_id: &str, include_download_urls: bool, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; - let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| { - RemotePluginCatalogError::UnknownMarketplace { - marketplace_name: marketplace_name.to_string(), - } - })?; let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; - let actual_marketplace_name = plugin.scope.marketplace_name(); - if actual_marketplace_name != marketplace_name { - return Err(RemotePluginCatalogError::MarketplaceMismatch { - plugin_id: plugin_id.to_string(), - expected_marketplace_name: marketplace_name.to_string(), - actual_marketplace_name: actual_marketplace_name.to_string(), - }); - } + let scope = plugin.scope; + let marketplace_name = scope.marketplace_name().to_string(); + // Remote plugin IDs uniquely identify remote plugins, so the caller-provided + // marketplace name is not validated here. The backend detail response is the + // source of truth for the plugin's actual scope/marketplace. - build_remote_plugin_detail( - config, - auth, - scope, - marketplace_name.to_string(), - plugin_id, - plugin, - ) - .await + build_remote_plugin_detail(config, auth, scope, marketplace_name, plugin_id, plugin).await } async fn build_remote_plugin_detail( @@ -527,15 +522,12 @@ async fn build_remote_plugin_detail( pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, - marketplace_name: &str, + _marketplace_name: &str, plugin_id: &str, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; - if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() { - return Err(RemotePluginCatalogError::UnknownMarketplace { - marketplace_name: marketplace_name.to_string(), - }); - } + // Remote plugin IDs uniquely identify remote plugins, so the caller-provided + // marketplace name is not validated before sending the install mutation. let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs new file mode 100644 index 000000000..25f66dabc --- /dev/null +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -0,0 +1,418 @@ +use super::*; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use flate2::Compression; +use flate2::write::GzEncoder; +use reqwest::RequestBuilder; +use reqwest::StatusCode; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Path; + +const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareSaveResult { + pub remote_plugin_id: String, + pub share_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemoteWorkspacePluginUploadUrlRequest<'a> { + filename: &'a str, + mime_type: &'a str, + size_bytes: usize, + #[serde(skip_serializing_if = "Option::is_none")] + plugin_id: Option<&'a str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemoteWorkspacePluginUploadUrlResponse { + file_id: String, + upload_url: String, + etag: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemoteWorkspacePluginCreateRequest { + file_id: String, + etag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemoteWorkspacePluginCreateResponse { + plugin_id: String, + share_url: Option, +} + +pub async fn save_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_path: &Path, + remote_plugin_id: Option<&str>, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let plugin_path = plugin_path.to_path_buf(); + let (filename, archive_bytes) = tokio::task::spawn_blocking(move || { + let filename = archive_filename(&plugin_path)?; + let archive_bytes = archive_plugin_for_upload(&plugin_path)?; + Ok::<_, RemotePluginCatalogError>((filename, archive_bytes)) + }) + .await + .map_err(RemotePluginCatalogError::ArchiveJoin)??; + let upload = create_workspace_plugin_upload( + config, + auth, + &filename, + archive_bytes.len(), + remote_plugin_id, + ) + .await?; + let etag = upload + .etag + .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; + put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; + let response = finalize_workspace_plugin_upload( + config, + auth, + remote_plugin_id, + RemoteWorkspacePluginCreateRequest { + file_id: upload.file_id, + etag, + }, + ) + .await?; + if response.plugin_id.is_empty() { + return Err(RemotePluginCatalogError::UnexpectedResponse( + "workspace plugin create response did not include a plugin id".to_string(), + )); + } + + Ok(RemotePluginShareSaveResult { + remote_plugin_id: response.plugin_id, + share_url: response.share_url, + }) +} + +pub async fn list_remote_plugin_shares( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let created_plugins = fetch_created_workspace_plugins(config, auth).await?; + if created_plugins.is_empty() { + return Ok(Vec::new()); + } + + let installed_by_id = + fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace) + .await? + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect::>(); + + Ok(created_plugins + .into_iter() + .map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id))) + .collect()) +} + +pub async fn delete_remote_plugin_share( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); + let client = build_reqwest_client(); + let request = authenticated_request(client.delete(&url), auth)?; + send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await +} + +async fn fetch_created_workspace_plugins( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_created_workspace_plugins_page(config, auth, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn get_created_workspace_plugins_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/workspace/created"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn create_workspace_plugin_upload( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + filename: &str, + size_bytes: usize, + remote_plugin_id: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/workspace/upload-url"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?.json( + &RemoteWorkspacePluginUploadUrlRequest { + filename, + mime_type: "application/gzip", + size_bytes, + plugin_id: remote_plugin_id, + }, + ); + send_and_decode(request, &url).await +} + +async fn put_workspace_plugin_upload( + upload_url: &str, + archive_bytes: Vec, +) -> Result<(), RemotePluginCatalogError> { + let client = build_reqwest_client(); + let request = client + .put(upload_url) + .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) + .header("x-ms-blob-type", "BlockBlob") + .header("Content-Type", "application/gzip") + .body(archive_bytes); + let response = request + .send() + .await + .map_err(|source| RemotePluginCatalogError::Request { + url: "workspace plugin upload URL".to_string(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if ![StatusCode::OK, StatusCode::CREATED].contains(&status) { + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: "workspace plugin upload URL".to_string(), + status, + body, + }); + } + Ok(()) +} + +async fn finalize_workspace_plugin_upload( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + remote_plugin_id: Option<&str>, + body: RemoteWorkspacePluginCreateRequest, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = if let Some(remote_plugin_id) = remote_plugin_id { + format!("{base_url}/public/plugins/workspace/{remote_plugin_id}") + } else { + format!("{base_url}/public/plugins/workspace") + }; + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?.json(&body); + send_and_decode(request, &url).await +} + +fn archive_filename(plugin_path: &Path) -> Result { + let plugin_name = plugin_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "plugin path must end in a valid UTF-8 directory name".to_string(), + })?; + Ok(format!("{plugin_name}.tar.gz")) +} + +fn archive_plugin_for_upload(plugin_path: &Path) -> Result, RemotePluginCatalogError> { + archive_plugin_for_upload_with_limit(plugin_path, REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES) +} + +fn archive_plugin_for_upload_with_limit( + plugin_path: &Path, + max_bytes: usize, +) -> Result, RemotePluginCatalogError> { + if !plugin_path.is_dir() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "expected a plugin directory".to_string(), + }); + } + if !plugin_path.join(".codex-plugin/plugin.json").is_file() { + return Err(RemotePluginCatalogError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "missing .codex-plugin/plugin.json".to_string(), + }); + } + + let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default()); + let mut archive = tar::Builder::new(encoder); + append_plugin_tree(&mut archive, plugin_path, plugin_path) + .map_err(|source| archive_error(plugin_path, source))?; + let encoder = archive + .into_inner() + .map_err(|source| archive_error(plugin_path, source))?; + encoder + .finish() + .map(SizeLimitedBuffer::into_inner) + .map_err(|source| archive_error(plugin_path, source)) +} + +fn append_plugin_tree( + archive: &mut tar::Builder, + plugin_root: &Path, + current: &Path, +) -> io::Result<()> { + let mut entries = fs::read_dir(current)?.collect::, io::Error>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type()?; + let relative_path = path.strip_prefix(plugin_root).map_err(|err| { + io::Error::other(format!( + "failed to compute plugin archive path for `{}`: {err}", + path.display() + )) + })?; + if file_type.is_dir() { + archive.append_dir(relative_path, &path)?; + append_plugin_tree(archive, plugin_root, &path)?; + } else if file_type.is_file() { + archive.append_path_with_name(&path, relative_path)?; + } else { + return Err(io::Error::other(format!( + "unsupported plugin archive entry type: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn archive_error(plugin_path: &Path, source: io::Error) -> RemotePluginCatalogError { + if let Some(limit) = source + .get_ref() + .and_then(|err| err.downcast_ref::()) + { + return RemotePluginCatalogError::ArchiveTooLarge { + bytes: limit.bytes, + max_bytes: limit.max_bytes, + }; + } + + RemotePluginCatalogError::Archive { + path: plugin_path.to_path_buf(), + source, + } +} + +struct SizeLimitedBuffer { + bytes: Vec, + max_bytes: usize, +} + +impl SizeLimitedBuffer { + fn new(max_bytes: usize) -> Self { + Self { + bytes: Vec::new(), + max_bytes, + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl Write for SizeLimitedBuffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| { + io::Error::other(ArchiveSizeLimitExceeded { + bytes: usize::MAX, + max_bytes: self.max_bytes, + }) + })?; + if next_len > self.max_bytes { + return Err(io::Error::other(ArchiveSizeLimitExceeded { + bytes: next_len, + max_bytes: self.max_bytes, + })); + } + + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct ArchiveSizeLimitExceeded { + bytes: usize, + max_bytes: usize, +} + +impl fmt::Display for ArchiveSizeLimitExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "archive would be {} bytes, exceeding maximum size of {} bytes", + self.bytes, self.max_bytes + ) + } +} + +impl std::error::Error for ArchiveSizeLimitExceeded {} + +async fn send_and_expect_status( + request: RequestBuilder, + url_for_error: &str, + expected_statuses: &[StatusCode], +) -> Result<(), RemotePluginCatalogError> { + let response = request + .send() + .await + .map_err(|source| RemotePluginCatalogError::Request { + url: url_for_error.to_string(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !expected_statuses.contains(&status) { + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: url_for_error.to_string(), + status, + body, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs new file mode 100644 index 000000000..0b3d31630 --- /dev/null +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -0,0 +1,417 @@ +use super::*; +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_login::CodexAuth; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; +use wiremock::matchers::query_param_is_missing; + +fn test_config(server: &MockServer) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + } +} + +fn test_auth() -> CodexAuth { + CodexAuth::create_dummy_chatgpt_auth_for_testing() +} + +fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + fs::write(path, contents).unwrap(); +} + +fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf { + let plugin_path = root.join(plugin_name); + write_file( + &plugin_path.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + ); + write_file( + &plugin_path.join("skills/example/SKILL.md"), + "# Example\n\nA test skill.\n", + ); + plugin_path +} + +fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap> { + let decoder = flate2::read::GzDecoder::new(archive_bytes); + let mut archive = tar::Archive::new(decoder); + archive + .entries() + .unwrap() + .filter_map(|entry| { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + return None; + } + let path = entry.path().unwrap().to_string_lossy().into_owned(); + let mut contents = Vec::new(); + entry.read_to_end(&mut contents).unwrap(); + Some((path, contents)) + }) + .collect() +} + +fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { + json!({ + "id": plugin_id, + "name": "demo-plugin", + "scope": "WORKSPACE", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Demo Plugin", + "description": "Demo plugin description", + "interface": { + "short_description": "A demo plugin", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + }) +} + +fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value { + let mut plugin = remote_plugin_json(plugin_id); + let serde_json::Value::Object(fields) = &mut plugin else { + unreachable!("plugin json should be an object"); + }; + fields.insert("enabled".to_string(), json!(true)); + fields.insert("disabled_skill_names".to_string(), json!([])); + plugin +} + +fn empty_pagination_json() -> serde_json::Value { + json!({ + "next_page_token": null + }) +} + +fn expected_plugin_interface() -> PluginInterface { + PluginInterface { + display_name: Some("Demo Plugin".to_string()), + short_description: Some("A demo plugin".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Read".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + } +} + +#[tokio::test] +async fn save_remote_plugin_share_creates_workspace_plugin() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len(); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "filename": "demo-plugin.tar.gz", + "mime_type": "application/gzip", + "size_bytes": archive_size, + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .and(header("x-ms-blob-type", "BlockBlob")) + .and(header("content-type", "application/gzip")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let result = save_remote_plugin_share( + &config, + Some(&auth), + &plugin_path, + /*remote_plugin_id*/ None, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareSaveResult { + remote_plugin_id: "plugins_123".to_string(), + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + } + ); + + let requests = server.received_requests().await.unwrap_or_default(); + let upload_request = requests + .iter() + .find(|request| request.method == "PUT" && request.url.path() == "/upload/file_123") + .unwrap(); + let archive_files = archive_file_entries(&upload_request.body); + assert_eq!( + archive_files + .get(".codex-plugin/plugin.json") + .map(Vec::as_slice), + Some(br#"{"name":"demo-plugin"}"#.as_slice()) + ); + assert_eq!( + archive_files + .get("skills/example/SKILL.md") + .map(Vec::as_slice), + Some(b"# Example\n\nA test skill.\n".as_slice()) + ); +} + +#[test] +fn archive_plugin_for_upload_rejects_archives_over_limit() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + write_file( + &plugin_path.join("large.txt"), + &"0123456789abcdef".repeat(1024), + ); + + let err = archive_plugin_for_upload_with_limit(&plugin_path, /*max_bytes*/ 16) + .expect_err("oversized plugin archive should fail"); + + assert!(matches!( + err, + RemotePluginCatalogError::ArchiveTooLarge { .. } + )); +} + +#[test] +fn archive_plugin_for_upload_places_manifest_at_archive_root() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + + let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap(); + let archive_files = archive_file_entries(&archive_bytes); + + assert_eq!( + archive_files.keys().cloned().collect::>(), + vec![ + ".codex-plugin/plugin.json".to_string(), + "skills/example/SKILL.md".to_string() + ] + ); + assert_eq!( + archive_files + .get(".codex-plugin/plugin.json") + .map(Vec::as_slice), + Some(br#"{"name":"demo-plugin"}"#.as_slice()) + ); + assert_eq!( + archive_files + .get("skills/example/SKILL.md") + .map(Vec::as_slice), + Some(b"# Example\n\nA test skill.\n".as_slice()) + ); +} + +#[tokio::test] +async fn save_remote_plugin_share_updates_existing_workspace_plugin() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len(); + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(body_json(json!({ + "filename": "demo-plugin.tar.gz", + "mime_type": "application/gzip", + "size_bytes": archive_size, + "plugin_id": "plugins_123", + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_456", + "upload_url": format!("{}/upload/file_456", server.uri()), + "etag": "\"upload_etag_456\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_456")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_456\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(body_json(json!({ + "file_id": "file_456", + "etag": "\"upload_etag_456\"", + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugin_id": "plugins_123", + }))) + .expect(1) + .mount(&server) + .await; + + let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123")) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareSaveResult { + remote_plugin_id: "plugins_123".to_string(), + share_url: None, + } + ); +} + +#[tokio::test] +async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(query_param( + "limit", + REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(), + )) + .and(query_param_is_missing("pageToken")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_123")], + "pagination": { + "next_page_token": "page-2" + }, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/created")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(query_param( + "limit", + REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(), + )) + .and(query_param("pageToken", "page-2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json("plugins_456")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [installed_remote_plugin_json("plugins_456")], + "pagination": empty_pagination_json(), + }))) + .expect(1) + .mount(&server) + .await; + + let result = list_remote_plugin_shares(&config, Some(&auth)) + .await + .unwrap(); + + assert_eq!( + result, + vec![ + RemotePluginSummary { + id: "plugins_123".to_string(), + name: "demo-plugin".to_string(), + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + interface: Some(expected_plugin_interface()), + }, + RemotePluginSummary { + id: "plugins_456".to_string(), + name: "demo-plugin".to_string(), + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + interface: Some(expected_plugin_interface()), + } + ] + ); +} + +#[tokio::test] +async fn delete_remote_plugin_share_deletes_workspace_plugin() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("DELETE")) + .and(path("/backend-api/public/plugins/workspace/plugins_123")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + delete_remote_plugin_share(&config, Some(&auth), "plugins_123") + .await + .unwrap(); +}