diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ac9f480a8..d195d57b6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1437,19 +1437,27 @@ }, "PluginInstallParams": { "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "type": "object" @@ -1465,10 +1473,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "type": "object" @@ -1476,24 +1480,32 @@ "PluginReadParams": { "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "type": "object" }, "PluginUninstallParams": { "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } 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 fb393c114..9fb9c3e3e 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 @@ -10208,19 +10208,27 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -10282,6 +10290,14 @@ { "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": { @@ -10314,6 +10330,14 @@ { "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": { @@ -10328,7 +10352,15 @@ "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/v2/AbsolutePathBuf" }, @@ -10355,6 +10387,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -10371,10 +10404,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", @@ -10402,12 +10431,6 @@ "$ref": "#/definitions/v2/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ @@ -10432,7 +10455,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -10443,7 +10474,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -10452,14 +10482,26 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", @@ -10537,6 +10579,23 @@ ], "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" } ] }, @@ -10588,10 +10647,6 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } 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 458367e20..da0381d8b 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 @@ -6960,19 +6960,27 @@ "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", @@ -7034,6 +7042,14 @@ { "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": { @@ -7066,6 +7082,14 @@ { "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": { @@ -7080,7 +7104,15 @@ "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" }, @@ -7107,6 +7139,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -7123,10 +7156,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", @@ -7154,12 +7183,6 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ @@ -7184,7 +7207,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -7195,7 +7226,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -7204,14 +7234,26 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", @@ -7289,6 +7331,23 @@ ], "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" } ] }, @@ -7340,10 +7399,6 @@ "PluginUninstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json index 689070531..ad3c0c107 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallParams.json @@ -7,19 +7,27 @@ } }, "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local install flow.", - "type": "boolean" - }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginInstallParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 669ff92b9..27ea8c4df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -16,10 +16,6 @@ "array", "null" ] - }, - "forceRemoteSync": { - "description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.", - "type": "boolean" } }, "title": "PluginListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index ee039060e..72c941c45 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -74,6 +74,14 @@ { "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": { @@ -106,6 +114,14 @@ { "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": { @@ -120,7 +136,15 @@ "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" }, @@ -147,6 +171,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -167,7 +192,15 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." }, "plugins": { "items": { @@ -178,7 +211,6 @@ }, "required": [ "name", - "path", "plugins" ], "type": "object" @@ -242,6 +274,23 @@ ], "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" } ] }, @@ -311,12 +360,6 @@ "$ref": "#/definitions/PluginMarketplaceEntry" }, "type": "array" - }, - "remoteSyncError": { - "type": [ - "string", - "null" - ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json index a720ae3b5..5cc3e5cab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadParams.json @@ -8,14 +8,26 @@ }, "properties": { "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "pluginName": { "type": "string" + }, + "remoteMarketplaceName": { + "type": [ + "string", + "null" + ] } }, "required": [ - "marketplacePath", "pluginName" ], "title": "PluginReadParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 43628e28f..5ec07f00f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -126,6 +126,14 @@ { "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": { @@ -158,6 +166,14 @@ { "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": { @@ -172,7 +188,15 @@ "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" }, @@ -199,6 +223,7 @@ }, "required": [ "capabilities", + "screenshotUrls", "screenshots" ], "type": "object" @@ -262,6 +287,23 @@ ], "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" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json index a6d7ec78b..5b7e0a592 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginUninstallParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "forceRemoteSync": { - "description": "When true, apply the remote plugin change before the local uninstall flow.", - "type": "boolean" - }, "pluginId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts index 9ac1c50c1..257dc47a1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallParams.ts @@ -3,8 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, -/** - * When true, apply the remote plugin change before the local install flow. - */ -forceRemoteSync?: boolean, }; +export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index 7e0a48aae..4e97ee66f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -8,4 +8,28 @@ export type PluginInterface = { displayName: string | null, shortDescription: st * Starter prompts for the plugin. Capped at 3 entries with a maximum of * 128 characters per entry. */ -defaultPrompt: Array | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array, }; +defaultPrompt: Array | null, brandColor: string | null, +/** + * Local composer icon path, resolved from the installed plugin package. + */ +composerIcon: AbsolutePathBuf | null, +/** + * Remote composer icon URL from the plugin catalog. + */ +composerIconUrl: string | null, +/** + * Local logo path, resolved from the installed plugin package. + */ +logo: AbsolutePathBuf | null, +/** + * Remote logo URL from the plugin catalog. + */ +logoUrl: string | null, +/** + * Local screenshot paths, resolved from the installed plugin package. + */ +screenshots: Array, +/** + * Remote screenshot URLs from the plugin catalog. + */ +screenshotUrls: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index cd2a0cde1..dcf23796d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -8,9 +8,4 @@ export type PluginListParams = { * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, -/** - * When true, reconcile the official curated marketplace against the remote plugin state - * before listing marketplaces. - */ -forceRemoteSync?: boolean, }; +cwds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts index 7ae5f8e50..d50200c90 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -4,4 +4,4 @@ import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; -export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; +export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, featuredPluginIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts index c0ab75b8f..f9dcee27d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts @@ -5,4 +5,9 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { MarketplaceInterface } from "./MarketplaceInterface"; import type { PluginSummary } from "./PluginSummary"; -export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, interface: MarketplaceInterface | null, plugins: Array, }; +export type PluginMarketplaceEntry = { name: string, +/** + * Local marketplace file path when the marketplace is backed by a local file. + * Remote-only catalog marketplaces do not have a local path. + */ +path: AbsolutePathBuf | null, interface: MarketplaceInterface | null, plugins: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts index cd6696873..8c4394f0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginReadParams.ts @@ -3,4 +3,4 @@ // 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 PluginReadParams = { marketplacePath: AbsolutePathBuf, pluginName: string, }; +export type PluginReadParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts index 5c8771aa0..f6e867195 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts @@ -3,4 +3,4 @@ // 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 PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, }; +export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts index aa1d1bfef..e7f52c0eb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginUninstallParams.ts @@ -2,8 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginUninstallParams = { pluginId: string, -/** - * When true, apply the remote plugin change before the local uninstall flow. - */ -forceRemoteSync?: boolean, }; +export type PluginUninstallParams = { pluginId: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1edd7d2dd..340a80a61 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3459,10 +3459,6 @@ pub struct PluginListParams { /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] pub cwds: Option>, - /// When true, reconcile the official curated marketplace against the remote plugin state - /// before listing marketplaces. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3472,7 +3468,6 @@ pub struct PluginListResponse { pub marketplaces: Vec, #[serde(default)] pub marketplace_load_errors: Vec, - pub remote_sync_error: Option, #[serde(default)] pub featured_plugin_ids: Vec, } @@ -3489,7 +3484,10 @@ pub struct MarketplaceLoadErrorInfo { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginReadParams { - pub marketplace_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, pub plugin_name: String, } @@ -3601,7 +3599,9 @@ pub struct SkillsListEntry { #[ts(export_to = "v2/")] pub struct PluginMarketplaceEntry { pub name: String, - pub path: AbsolutePathBuf, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, pub interface: Option, pub plugins: Vec, } @@ -3694,9 +3694,18 @@ pub struct PluginInterface { /// 128 characters per entry. pub default_prompt: Option>, pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3715,6 +3724,9 @@ pub enum PluginSource { ref_name: Option, sha: Option, }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3741,11 +3753,11 @@ pub struct SkillsConfigWriteResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginInstallParams { - pub marketplace_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, pub plugin_name: String, - /// When true, apply the remote plugin change before the local install flow. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3761,9 +3773,6 @@ pub struct PluginInstallResponse { #[ts(export_to = "v2/")] pub struct PluginUninstallParams { pub plugin_id: String, - /// When true, apply the remote plugin change before the local uninstall flow. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_remote_sync: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -8556,27 +8565,44 @@ mod tests { } #[test] - fn plugin_list_params_serialization_uses_force_remote_sync() { + fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + assert_eq!( - serde_json::to_value(PluginListParams { - cwds: None, - force_remote_sync: false, - }) - .unwrap(), + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), json!({ - "cwds": null, + "type": "local", + "path": local_path_json, }), ); assert_eq!( - serde_json::to_value(PluginListParams { - cwds: None, - force_remote_sync: true, + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), }) .unwrap(), json!({ - "cwds": null, - "forceRemoteSync": true, + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", }), ); } @@ -8613,7 +8639,143 @@ mod tests { } #[test] - fn plugin_install_params_serialization_uses_force_remote_sync() { + fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); + } + + #[test] + fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); + } + + #[test] + fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { cwds: None }, + ); + } + + #[test] + fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); + } + + #[test] + fn plugin_install_params_serialization_omits_force_remote_sync() { let marketplace_path = if cfg!(windows) { r"C:\plugins\marketplace.json" } else { @@ -8623,38 +8785,52 @@ mod tests { let marketplace_path_json = marketplace_path.as_path().display().to_string(); assert_eq!( serde_json::to_value(PluginInstallParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: "gmail".to_string(), - force_remote_sync: false, }) .unwrap(), json!({ "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, "pluginName": "gmail", }), ); assert_eq!( - serde_json::to_value(PluginInstallParams { - marketplace_path, - plugin_name: "gmail".to_string(), - force_remote_sync: true, - }) - .unwrap(), - json!({ + serde_json::from_value::(json!({ "marketplacePath": marketplace_path_json, "pluginName": "gmail", "forceRemoteSync": true, - }), + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, ); } #[test] - fn plugin_uninstall_params_serialization_uses_force_remote_sync() { + fn plugin_uninstall_params_serialization_omits_force_remote_sync() { assert_eq!( serde_json::to_value(PluginUninstallParams { plugin_id: "gmail@openai-curated".to_string(), - force_remote_sync: false, }) .unwrap(), json!({ @@ -8663,15 +8839,14 @@ mod tests { ); assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - force_remote_sync: true, - }) - .unwrap(), - json!({ + serde_json::from_value::(json!({ "pluginId": "gmail@openai-curated", "forceRemoteSync": true, - }), + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88156f553..fcc19ce42 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -185,7 +185,7 @@ Example with notification opt-out: - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e3f6322d4..aa058c583 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6428,55 +6428,19 @@ impl CodexMessageProcessor { async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { - cwds, - force_remote_sync, - } = params; + let PluginListParams { cwds } = params; let roots = cwds.unwrap_or_default(); plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); - let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => config, Err(err) => { self.outgoing.send_error(request_id, err).await; return; } }; - let mut remote_sync_error = None; let auth = self.auth_manager.auth().await; - if force_remote_sync { - match plugins_manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ false) - .await - { - Ok(sync_result) => { - info!( - installed_plugin_ids = ?sync_result.installed_plugin_ids, - enabled_plugin_ids = ?sync_result.enabled_plugin_ids, - disabled_plugin_ids = ?sync_result.disabled_plugin_ids, - uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, - "completed plugin/list remote sync" - ); - } - Err(err) => { - warn!( - error = %err, - "plugin/list remote sync failed; returning local marketplace state" - ); - remote_sync_error = Some(err.to_string()); - } - } - - config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - } - let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { @@ -6494,7 +6458,7 @@ impl CodexMessageProcessor { .into_iter() .map(|marketplace| PluginMarketplaceEntry { name: marketplace.name, - path: marketplace.path, + path: Some(marketplace.path), interface: marketplace.interface.map(|interface| MarketplaceInterface { display_name: interface.display_name, }), @@ -6509,7 +6473,7 @@ impl CodexMessageProcessor { source: marketplace_plugin_source_to_info(plugin.source), install_policy: plugin.policy.installation.into(), auth_policy: plugin.policy.authentication.into(), - interface: plugin.interface.map(plugin_interface_to_info), + interface: plugin.interface.map(local_plugin_interface_to_info), }) .collect(), }) @@ -6569,7 +6533,6 @@ impl CodexMessageProcessor { PluginListResponse { marketplaces: data, marketplace_load_errors, - remote_sync_error, featured_plugin_ids, }, ) @@ -6613,8 +6576,40 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); let PluginReadParams { marketplace_path, + remote_marketplace_name, plugin_name, } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin read is not supported yet for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let config = match self.load_latest_config(config_cwd).await { @@ -6664,7 +6659,7 @@ impl CodexMessageProcessor { enabled: outcome.plugin.enabled, install_policy: outcome.plugin.policy.installation.into(), auth_policy: outcome.plugin.policy.authentication.into(), - interface: outcome.plugin.interface.map(plugin_interface_to_info), + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), }, description: outcome.plugin.description, skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), @@ -6738,9 +6733,40 @@ impl CodexMessageProcessor { async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { let PluginInstallParams { marketplace_path, + remote_marketplace_name, plugin_name, - force_remote_sync, } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "remote plugin install is not supported yet for marketplace {remote_marketplace_name}" + ), + data: None, + }, + ) + .await; + return; + } + (Some(_), Some(_)) | (None, None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), + data: None, + }, + ) + .await; + return; + } + }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let plugins_manager = self.thread_manager.plugins_manager(); @@ -6749,21 +6775,7 @@ impl CodexMessageProcessor { marketplace_path, }; - let install_result = if force_remote_sync { - let config = match self.load_latest_config(config_cwd.clone()).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - plugins_manager - .install_plugin_with_remote_sync(&config, auth.as_ref(), request) - .await - } else { - plugins_manager.install_plugin(request).await - }; + let install_result = plugins_manager.install_plugin(request).await; match install_result { Ok(result) => { @@ -6915,27 +6927,10 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: PluginUninstallParams, ) { - let PluginUninstallParams { - plugin_id, - force_remote_sync, - } = params; + let PluginUninstallParams { plugin_id } = params; let plugins_manager = self.thread_manager.plugins_manager(); - let uninstall_result = if force_remote_sync { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - plugins_manager - .uninstall_plugin_with_remote_sync(&config, auth.as_ref(), plugin_id) - .await - } else { - plugins_manager.uninstall_plugin(plugin_id).await - }; + let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; match uninstall_result { Ok(()) => { @@ -9105,7 +9100,7 @@ fn plugin_skills_to_info( .collect() } -fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { +fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { PluginInterface { display_name: interface.display_name, short_description: interface.short_description, @@ -9119,8 +9114,11 @@ fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterfa default_prompt: interface.default_prompt, brand_color: interface.brand_color, composer_icon: interface.composer_icon, + composer_icon_url: None, logo: interface.logo, + logo_url: None, screenshots: interface.screenshots, + screenshot_urls: Vec::new(), } } diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 21acf2490..049256b60 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -98,10 +98,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, 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 e51fac725..3555dd745 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -44,12 +44,6 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; // Plugin install tests wait on connector discovery after the install response path // starts, which is noticeably slower on Windows CI. @@ -82,6 +76,97 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_missing_install_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_supported() -> Result<()> +{ + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin install is not supported yet") + ); + assert!(err.error.message.contains("openai-curated")); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; @@ -90,11 +175,11 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() - let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("missing-marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "missing-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -131,9 +216,9 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -181,9 +266,9 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -198,76 +283,6 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() Ok(()) } -#[tokio::test] -async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()> -{ - let server = MockServer::start().await; - let codex_home = TempDir::new()?; - write_plugin_remote_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let repo_root = TempDir::new()?; - write_plugin_marketplace( - repo_root.path(), - "debug", - "sample-plugin", - "./sample-plugin", - /*install_policy*/ None, - /*auth_policy*/ None, - )?; - write_plugin_source(repo_root.path(), "sample-plugin", &[])?; - let marketplace_path = - AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - - Mock::given(method("POST")) - .and(path("/backend-api/plugins/sample-plugin@debug/enable")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#), - ) - .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_plugin_install_request(PluginInstallParams { - marketplace_path, - plugin_name: "sample-plugin".to_string(), - force_remote_sync: true, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginInstallResponse = to_response(response)?; - assert_eq!(response.apps_needing_auth, Vec::::new()); - - assert!( - codex_home - .path() - .join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json") - .is_file() - ); - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#)); - assert!(config.contains("enabled = true")); - Ok(()) -} - #[tokio::test] async fn plugin_install_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -300,9 +315,9 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( @@ -415,9 +430,9 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -499,9 +514,9 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; @@ -566,9 +581,9 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( @@ -758,23 +773,6 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std:: ) } -fn write_plugin_remote_sync_config( - codex_home: &std::path::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 -"# - ), - ) -} - fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 28056e9ec..bf69df3c4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -71,7 +71,6 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -86,7 +85,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul response .marketplaces .iter() - .all(|marketplace| { marketplace.path != marketplace_path }), + .all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }), "invalid marketplace should be skipped" ); assert_eq!(response.marketplace_load_errors.len(), 1); @@ -200,7 +199,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ AbsolutePathBuf::try_from(valid_repo_root.path())?, AbsolutePathBuf::try_from(invalid_repo_root.path())?, ]), - force_remote_sync: false, }) .await?; @@ -215,7 +213,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ response.marketplaces, vec![PluginMarketplaceEntry { name: "valid-marketplace".to_string(), - path: valid_marketplace_path, + path: Some(valid_marketplace_path), interface: None, plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), @@ -243,7 +241,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ "unexpected error: {:?}", response.marketplace_load_errors ); - assert_eq!(response.remote_sync_error, None); assert!(response.featured_plugin_ids.is_empty()); Ok(()) } @@ -314,7 +311,6 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -329,7 +325,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab response.marketplaces, vec![PluginMarketplaceEntry { name: "alternate-marketplace".to_string(), - path: marketplace_path, + path: Some(marketplace_path), interface: None, plugins: vec![ PluginSummary { @@ -355,8 +351,11 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab 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(), }), }, PluginSummary { @@ -412,10 +411,7 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -486,7 +482,6 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -501,11 +496,13 @@ enabled = false .marketplaces .into_iter() .find(|marketplace| { - marketplace.path - == AbsolutePathBuf::try_from( - repo_root.path().join(".agents/plugins/marketplace.json"), + marketplace.path.as_ref() + == Some( + &AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + ) + .expect("absolute marketplace path"), ) - .expect("absolute marketplace path") }) .expect("expected repo marketplace entry"); @@ -641,7 +638,6 @@ enabled = false AbsolutePathBuf::try_from(workspace_enabled.path())?, AbsolutePathBuf::try_from(workspace_default.path())?, ]), - force_remote_sync: false, }) .await?; @@ -725,7 +721,6 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -838,7 +833,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), - force_remote_sync: false, }) .await?; @@ -865,163 +859,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } -#[tokio::test] -async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> { - let codex_home = TempDir::new()?; - write_plugin_sync_config(codex_home.path(), "https://chatgpt.com/backend-api/")?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - write_installed_plugin(&codex_home, "openai-curated", "linear")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: true, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - - assert!( - response - .remote_sync_error - .as_deref() - .is_some_and(|message| message.contains("chatgpt authentication required")) - ); - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![("linear@openai-curated".to_string(), true, false)] - ); - Ok(()) -} - -#[tokio::test] -async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_plugin_sync_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, - )?; - write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail", "calendar"])?; - write_installed_plugin(&codex_home, "openai-curated", "linear")?; - write_installed_plugin(&codex_home, "openai-curated", "gmail")?; - write_installed_plugin(&codex_home, "openai-curated", "calendar")?; - - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/featured")) - .and(query_param("platform", "codex")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"["linear@openai-curated","calendar@openai-curated"]"#), - ) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: true, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - assert_eq!(response.remote_sync_error, None); - assert_eq!( - response.featured_plugin_ids, - vec![ - "linear@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ] - ); - - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - assert!( - codex_home - .path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !codex_home - .path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !codex_home - .path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - Ok(()) -} - #[tokio::test] async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { let codex_home = TempDir::new()?; @@ -1069,10 +906,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) .await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1128,10 +962,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -1145,7 +976,6 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul response.featured_plugin_ids, vec!["linear@openai-curated".to_string()] ); - assert_eq!(response.remote_sync_error, None); Ok(()) } @@ -1169,10 +999,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - force_remote_sync: false, - }) + .send_plugin_list_request(PluginListParams { cwds: None }) .await?; let response: JSONRPCResponse = timeout( @@ -1186,7 +1013,6 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> response.featured_plugin_ids, vec!["linear@openai-curated".to_string()] ); - assert_eq!(response.remote_sync_error, None); Ok(()) } 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 20114e79b..f5681e9d4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -45,6 +45,96 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +#[tokio::test] +async fn plugin_read_rejects_missing_read_source() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + codex_home.path().join("marketplace.json"), + )?), + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("requires exactly one of marketplacePath or remoteMarketplaceName") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin read is not supported yet") + ); + assert!(err.error.message.contains("openai-curated")); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> { let codex_home = TempDir::new()?; @@ -179,7 +269,8 @@ enabled = true AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; @@ -326,7 +417,8 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), }) .await?; @@ -392,9 +484,10 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; @@ -446,9 +539,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "missing-plugin".to_string(), }) .await?; @@ -498,9 +592,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - let request_id = mcp .send_plugin_read_request(PluginReadParams { - marketplace_path: AbsolutePathBuf::try_from( + marketplace_path: Some(AbsolutePathBuf::try_from( repo_root.path().join(".agents/plugins/marketplace.json"), - )?, + )?), + remote_marketplace_name: None, plugin_name: "demo-plugin".to_string(), }) .await?; 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 00fabe483..512cce399 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -16,12 +16,6 @@ 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::header; -use wiremock::matchers::method; -use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -44,7 +38,6 @@ enabled = true let params = PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: false, }; let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; @@ -77,74 +70,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> { - let server = MockServer::start().await; - let codex_home = TempDir::new()?; - write_installed_plugin(&codex_home, "debug", "sample-plugin")?; - std::fs::write( - codex_home.path().join("config.toml"), - format!( - r#"chatgpt_base_url = "{}/backend-api/" - -[features] -plugins = true - -[plugins."sample-plugin@debug"] -enabled = true -"#, - 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/plugins/sample-plugin@debug/uninstall")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#), - ) - .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_plugin_uninstall_request(PluginUninstallParams { - plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: true, - }) - .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 {}); - - assert!( - !codex_home - .path() - .join("plugins/cache/debug/sample-plugin") - .exists() - ); - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); - Ok(()) -} - #[tokio::test] async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; @@ -172,7 +97,6 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), - force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d3afbf897..2be36210e 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -565,9 +565,7 @@ impl PluginsManager { self.restriction_product, )?; let plugin_id = resolved.plugin_id.as_key(); - // This only forwards the backend mutation before the local install flow. We rely on - // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra - // reconcile pass here. + // This only forwards the backend mutation before the local install flow. codex_core_plugins::remote::enable_remote_plugin( &remote_plugin_service_config(config), auth, @@ -655,11 +653,11 @@ impl PluginsManager { auth: Option<&CodexAuth>, plugin_id: String, ) -> Result<(), PluginUninstallError> { + // TODO: Remove this legacy remote-sync path once remote plugins have + // their own manager and installed-state API. let plugin_id = PluginId::parse(&plugin_id)?; let plugin_key = plugin_id.as_key(); - // This only forwards the backend mutation before the local uninstall flow. We rely on - // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra - // reconcile pass here. + // This only forwards the backend mutation before the local uninstall flow. codex_core_plugins::remote::uninstall_remote_plugin( &remote_plugin_service_config(config), auth, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 725427213..6a2fff439 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4772,7 +4772,8 @@ impl App { app_server, cwd, PluginReadParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name, }, ); @@ -6502,7 +6503,6 @@ async fn fetch_plugins_list( request_id, params: PluginListParams { cwds: Some(vec![cwd]), - force_remote_sync: false, }, }) .await @@ -6540,9 +6540,9 @@ async fn fetch_plugin_install( .request_typed(ClientRequest::PluginInstall { request_id, params: PluginInstallParams { - marketplace_path, + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name, - force_remote_sync: false, }, }) .await @@ -6557,10 +6557,7 @@ async fn fetch_plugin_uninstall( request_handle .request_typed(ClientRequest::PluginUninstall { request_id, - params: PluginUninstallParams { - plugin_id, - force_remote_sync: false, - }, + params: PluginUninstallParams { plugin_id }, }) .await .wrap_err("plugin/uninstall failed in TUI") @@ -6759,19 +6756,18 @@ mod tests { marketplaces: vec![ PluginMarketplaceEntry { name: "openai-bundled".to_string(), - path: test_absolute_path("/marketplaces/openai-bundled"), + path: Some(test_absolute_path("/marketplaces/openai-bundled")), interface: None, plugins: Vec::new(), }, PluginMarketplaceEntry { name: "openai-curated".to_string(), - path: test_absolute_path("/marketplaces/openai-curated"), + path: Some(test_absolute_path("/marketplaces/openai-curated")), interface: None, plugins: Vec::new(), }, ], marketplace_load_errors: Vec::new(), - remote_sync_error: None, featured_plugin_ids: Vec::new(), }; @@ -6781,7 +6777,7 @@ mod tests { response.marketplaces, vec![PluginMarketplaceEntry { name: "openai-curated".to_string(), - path: test_absolute_path("/marketplaces/openai-curated"), + path: Some(test_absolute_path("/marketplaces/openai-curated")), interface: None, plugins: Vec::new(), }] diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 964a98631..bf5a930e2 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -5,6 +5,7 @@ use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionTab; @@ -760,7 +761,6 @@ impl ChatWidget { header: plugins_header( "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( all_entries, @@ -776,7 +776,6 @@ impl ChatWidget { header: plugins_header( "Installed plugins.".to_string(), format!("Showing {installed} installed plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( installed_entries, @@ -804,7 +803,6 @@ impl ChatWidget { header: plugins_header( "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( curated_entries, @@ -848,7 +846,6 @@ impl ChatWidget { format!( "Installed {marketplace_installed} of {marketplace_total} {label} plugins." ), - response.remote_sync_error.as_deref(), ), items: self.plugin_selection_items( entries, @@ -1047,24 +1044,35 @@ impl ChatWidget { let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); - - items.push(SelectionItem { - name: display_name, - description: Some(description), - selected_description: Some(selected_description), - search_value: Some(search_value), - actions: vec![Box::new(move |tx| { + let is_disabled = marketplace_path.is_none(); + let actions: Vec = if let Some(marketplace_path) = marketplace_path { + vec![Box::new(move |tx| { tx.send(AppEvent::OpenPluginDetailLoading { plugin_display_name: plugin_display_name.clone(), }); tx.send(AppEvent::FetchPluginDetail { cwd: cwd.clone(), params: codex_app_server_protocol::PluginReadParams { - marketplace_path: marketplace_path.clone(), + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, plugin_name: plugin_name.clone(), }, }); - })], + })] + } else { + Vec::new() + }; + let disabled_reason = + is_disabled.then(|| "remote plugin details are not available yet".to_string()); + + items.push(SelectionItem { + name: display_name, + description: Some(description), + selected_description: Some(selected_description), + search_value: Some(search_value), + actions, + is_disabled, + disabled_reason, ..Default::default() }); } @@ -1089,20 +1097,11 @@ fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header( - subtitle: String, - count_line: String, - remote_sync_error: Option<&str>, -) -> Box { +fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); header.push(Line::from(count_line.dim())); - if let Some(remote_sync_error) = remote_sync_error { - header.push(Line::from( - format!("Using cached marketplace data: {remote_sync_error}").dim(), - )); - } Box::new(header) } @@ -1138,7 +1137,10 @@ fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, } fn marketplace_tab_id(marketplace: &PluginMarketplaceEntry) -> String { - format!("marketplace:{}", marketplace.path.display()) + match marketplace.path.as_ref() { + Some(path) => format!("marketplace:{}", path.display()), + None => format!("marketplace:{}", marketplace.name), + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index e43251eb9..ee8c81db3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -1,11 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Plugins Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - Using cached marketplace data: remote sync timed out [All Plugins] Installed (1) OpenAI Curated Repo Marketplace diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 89de317f7..66df972eb 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -859,8 +859,11 @@ pub(super) fn plugins_test_interface( 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(), } } @@ -896,7 +899,7 @@ pub(super) fn plugins_test_curated_marketplace( ) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), - path: plugins_test_absolute_path("marketplaces/chatgpt"), + path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), interface: Some(MarketplaceInterface { display_name: Some("ChatGPT Marketplace".to_string()), }), @@ -907,7 +910,7 @@ pub(super) fn plugins_test_curated_marketplace( pub(super) fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: "repo".to_string(), - path: plugins_test_absolute_path("marketplaces/repo"), + path: Some(plugins_test_absolute_path("marketplaces/repo")), interface: Some(MarketplaceInterface { display_name: Some("Repo Marketplace".to_string()), }), @@ -921,7 +924,6 @@ pub(super) fn plugins_test_response( PluginListResponse { marketplaces, marketplace_load_errors: Vec::new(), - remote_sync_error: None, featured_plugin_ids: Vec::new(), } } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 859be3b94..e55881d3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -105,7 +105,7 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); - let mut response = plugins_test_response(vec![ + let response = plugins_test_response(vec![ plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-bravo", @@ -145,8 +145,6 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ PluginInstallPolicy::Available, )]), ]); - response.remote_sync_error = Some("remote sync timed out".to_string()); - let popup = render_loaded_plugins_popup(&mut chat, response); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( @@ -544,7 +542,9 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { let response = plugins_test_response(vec![ PluginMarketplaceEntry { name: "duplicate".to_string(), - path: plugins_test_absolute_path("marketplaces/home/marketplace.json"), + path: Some(plugins_test_absolute_path( + "marketplaces/home/marketplace.json", + )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }), @@ -560,7 +560,9 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { }, PluginMarketplaceEntry { name: "duplicate".to_string(), - path: plugins_test_absolute_path("marketplaces/repo/marketplace.json"), + path: Some(plugins_test_absolute_path( + "marketplaces/repo/marketplace.json", + )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }),