mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Add remote plugin fields to plugin API (#17277)
## Summary
Update the plugin API for the new remote plugin model.
The mental model is no longer “keep local plugin state in sync with
remote.” Instead, local and remote plugins are becoming separate
sources. Remote catalog entries can be shown directly from the remote
API before installation; after installation they are still downloaded
into the local cache for execution, but remote installed state will come
from the API and be held in memory rather than being read from config.
• ## API changes
- Remove `forceRemoteSync` from `plugin/list`, `plugin/install`, and
`plugin/uninstall`.
- Remove `remoteSyncError` from `plugin/list`.
- Add remote-capable metadata to `plugin/list` / `plugin/read`:
- nullable `marketplaces[].path`
- `source: { type: "remote", downloadUrl }`
- URL asset fields alongside local path fields:
`composerIconUrl`, `logoUrl`, `screenshotUrls`
- Make `plugin/read` and `plugin/install` source-compatible:
- `marketplacePath?: AbsolutePathBuf | null`
- `remoteMarketplaceName?: string | null`
- exactly one source is required at runtime
This commit is contained in:
committed by
GitHub
Unverified
parent
120bbf46c1
commit
26d9894a27
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+79
-24
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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<string> | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
defaultPrompt: Array<string> | 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<AbsolutePathBuf>,
|
||||
/**
|
||||
* Remote screenshot URLs from the plugin catalog.
|
||||
*/
|
||||
screenshotUrls: Array<string>, };
|
||||
|
||||
@@ -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<AbsolutePathBuf> | null,
|
||||
/**
|
||||
* When true, reconcile the official curated marketplace against the remote plugin state
|
||||
* before listing marketplaces.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
cwds?: Array<AbsolutePathBuf> | null, };
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, featuredPluginIds: Array<string>, };
|
||||
|
||||
@@ -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<PluginSummary>, };
|
||||
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<PluginSummary>, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -3459,10 +3459,6 @@ pub struct PluginListParams {
|
||||
/// only home-scoped marketplaces and the official curated marketplace are considered.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwds: Option<Vec<AbsolutePathBuf>>,
|
||||
/// 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<PluginMarketplaceEntry>,
|
||||
#[serde(default)]
|
||||
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
|
||||
pub remote_sync_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub featured_plugin_ids: Vec<String>,
|
||||
}
|
||||
@@ -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<AbsolutePathBuf>,
|
||||
#[ts(optional = nullable)]
|
||||
pub remote_marketplace_name: Option<String>,
|
||||
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<AbsolutePathBuf>,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<PluginSummary>,
|
||||
}
|
||||
@@ -3694,9 +3694,18 @@ pub struct PluginInterface {
|
||||
/// 128 characters per entry.
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
/// Local composer icon path, resolved from the installed plugin package.
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
/// Remote composer icon URL from the plugin catalog.
|
||||
pub composer_icon_url: Option<String>,
|
||||
/// Local logo path, resolved from the installed plugin package.
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
/// Remote logo URL from the plugin catalog.
|
||||
pub logo_url: Option<String>,
|
||||
/// Local screenshot paths, resolved from the installed plugin package.
|
||||
pub screenshots: Vec<AbsolutePathBuf>,
|
||||
/// Remote screenshot URLs from the plugin catalog.
|
||||
pub screenshot_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3715,6 +3724,9 @@ pub enum PluginSource {
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
/// 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<AbsolutePathBuf>,
|
||||
#[ts(optional = nullable)]
|
||||
pub remote_marketplace_name: Option<String>,
|
||||
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::<PluginListParams>(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::<PluginReadParams>(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::<PluginReadParams>(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::<PluginInstallParams>(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::<PluginInstallParams>(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::<PluginUninstallParams>(json!({
|
||||
"pluginId": "gmail@openai-curated",
|
||||
"forceRemoteSync": true,
|
||||
}),
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<AppSummary>::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,
|
||||
|
||||
@@ -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<_>>(),
|
||||
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<_>>(),
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-12
@@ -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(),
|
||||
}]
|
||||
|
||||
@@ -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<SelectionAction> = 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<dyn Renderable> {
|
||||
fn plugins_header(subtitle: String, count_line: String) -> Box<dyn Renderable> {
|
||||
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<String>) -> Vec<String> {
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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<PluginSummary>) -> 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user