mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add installed-plugin mention API (#22448)
## Summary - add app-server `plugin/installed` for mention-oriented plugin loading - return installed plugins plus explicitly requested install-suggestion rows - keep remote handling on installed-state data instead of the broad catalog listing path ## Why The `@` mention surface only needs plugins that are usable now, plus a small product-approved set of install suggestions. It does not need the full catalog-shaped `plugin/list` payload that the Plugins page uses. ## Validation - `just write-app-server-schema` - `just fmt` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core-plugins` - `cargo test -p codex-app-server --test all plugin_installed_` ## Notes - The package-wide `cargo test -p codex-app-server` run still hits an existing unrelated stack overflow in `in_process::tests::in_process_start_clamps_zero_channel_capacity`. - Companion webview PR: https://github.com/openai/openai/pull/915672
This commit is contained in:
committed by
GitHub
Unverified
parent
22dd9ad392
commit
da14dd2add
@@ -1589,6 +1589,31 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstalledParams": {
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"installSuggestionPluginNames": {
|
||||
"description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PluginListMarketplaceKind": {
|
||||
"enum": [
|
||||
"local",
|
||||
@@ -4638,6 +4663,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/installed"
|
||||
],
|
||||
"title": "Plugin/installedRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginInstalledParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+74
@@ -757,6 +757,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/installed"
|
||||
],
|
||||
"title": "Plugin/installedRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginInstalledParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -11825,6 +11849,56 @@
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstalledParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"installSuggestionPluginNames": {
|
||||
"description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginInstalledParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstalledResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginInstalledResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInterface": {
|
||||
"properties": {
|
||||
"brandColor": {
|
||||
|
||||
+74
@@ -1464,6 +1464,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/installed"
|
||||
],
|
||||
"title": "Plugin/installedRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginInstalledParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8374,6 +8398,56 @@
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstalledParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"installSuggestionPluginNames": {
|
||||
"description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginInstalledParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstalledResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginInstalledResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInterface": {
|
||||
"properties": {
|
||||
"brandColor": {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwds": {
|
||||
"description": "Optional working directories used to discover repo marketplaces.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"installSuggestionPluginNames": {
|
||||
"description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginInstalledParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"MarketplaceInterface": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceLoadErrorInfo": {
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginAuthPolicy": {
|
||||
"enum": [
|
||||
"ON_INSTALL",
|
||||
"ON_USE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginAvailability": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"DISABLED_BY_ADMIN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
|
||||
"enum": [
|
||||
"AVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginInstallPolicy": {
|
||||
"enum": [
|
||||
"NOT_AVAILABLE",
|
||||
"AVAILABLE",
|
||||
"INSTALLED_BY_DEFAULT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginInterface": {
|
||||
"properties": {
|
||||
"brandColor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"composerIcon": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local composer icon path, resolved from the installed plugin package."
|
||||
},
|
||||
"composerIconUrl": {
|
||||
"description": "Remote composer icon URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"developerName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"displayName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logo": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local logo path, resolved from the installed plugin package."
|
||||
},
|
||||
"logoUrl": {
|
||||
"description": "Remote logo URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"longDescription": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"privacyPolicyUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"screenshotUrls": {
|
||||
"description": "Remote screenshot URLs from the plugin catalog.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"screenshots": {
|
||||
"description": "Local screenshot paths, resolved from the installed plugin package.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"shortDescription": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"termsOfServiceUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"websiteUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"capabilities",
|
||||
"screenshotUrls",
|
||||
"screenshots"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginMarketplaceEntry": {
|
||||
"properties": {
|
||||
"interface": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MarketplaceInterface"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"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": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareContext": {
|
||||
"properties": {
|
||||
"creatorAccountUserId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"creatorName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"discoverability": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginShareDiscoverability"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"remotePluginId": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteVersion": {
|
||||
"default": null,
|
||||
"description": "Version of the remote shared plugin release when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sharePrincipals": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSharePrincipal"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"shareUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remotePluginId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginShareDiscoverability": {
|
||||
"enum": [
|
||||
"LISTED",
|
||||
"UNLISTED",
|
||||
"PRIVATE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginSharePrincipal": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"principalId": {
|
||||
"type": "string"
|
||||
},
|
||||
"principalType": {
|
||||
"$ref": "#/definitions/PluginSharePrincipalType"
|
||||
},
|
||||
"role": {
|
||||
"$ref": "#/definitions/PluginSharePrincipalRole"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"principalId",
|
||||
"principalType",
|
||||
"role"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSharePrincipalRole": {
|
||||
"enum": [
|
||||
"reader",
|
||||
"editor",
|
||||
"owner"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginSharePrincipalType": {
|
||||
"enum": [
|
||||
"user",
|
||||
"group",
|
||||
"workspace"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"local"
|
||||
],
|
||||
"title": "LocalPluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"type"
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sha": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"git"
|
||||
],
|
||||
"title": "GitPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"remote"
|
||||
],
|
||||
"title": "RemotePluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "RemotePluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"authPolicy": {
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
"availability": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAvailability"
|
||||
}
|
||||
],
|
||||
"default": "AVAILABLE",
|
||||
"description": "Availability state for installing and using the plugin."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"installPolicy": {
|
||||
"$ref": "#/definitions/PluginInstallPolicy"
|
||||
},
|
||||
"installed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"interface": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginInterface"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keywords": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"localVersion": {
|
||||
"default": null,
|
||||
"description": "Version of the locally materialized plugin package when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"description": "Backend remote plugin identifier when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"shareContext": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginShareContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Remote sharing context associated with this plugin when available."
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authPolicy",
|
||||
"enabled",
|
||||
"id",
|
||||
"installPolicy",
|
||||
"installed",
|
||||
"name",
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"marketplaceLoadErrors": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MarketplaceLoadErrorInfo"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"marketplaces": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaces"
|
||||
],
|
||||
"title": "PluginInstalledResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginInstalledParams = {
|
||||
/**
|
||||
* Optional working directories used to discover repo marketplaces.
|
||||
*/
|
||||
cwds?: Array<AbsolutePathBuf> | null,
|
||||
/**
|
||||
* Additional uninstalled plugin names that should be returned when present locally.
|
||||
* This is used by mention surfaces that intentionally expose install entrypoints.
|
||||
*/
|
||||
installSuggestionPluginNames?: Array<string> | null, };
|
||||
@@ -0,0 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
|
||||
export type PluginInstalledResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, };
|
||||
@@ -265,6 +265,8 @@ export type { PluginHookSummary } from "./PluginHookSummary";
|
||||
export type { PluginInstallParams } from "./PluginInstallParams";
|
||||
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
|
||||
export type { PluginInstallResponse } from "./PluginInstallResponse";
|
||||
export type { PluginInstalledParams } from "./PluginInstalledParams";
|
||||
export type { PluginInstalledResponse } from "./PluginInstalledResponse";
|
||||
export type { PluginInterface } from "./PluginInterface";
|
||||
export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind";
|
||||
export type { PluginListParams } from "./PluginListParams";
|
||||
|
||||
@@ -625,6 +625,11 @@ client_request_definitions! {
|
||||
serialization: None,
|
||||
response: v2::PluginListResponse,
|
||||
},
|
||||
PluginInstalled => "plugin/installed" {
|
||||
params: v2::PluginInstalledParams,
|
||||
serialization: None,
|
||||
response: v2::PluginInstalledResponse,
|
||||
},
|
||||
PluginRead => "plugin/read" {
|
||||
params: v2::PluginReadParams,
|
||||
serialization: None,
|
||||
@@ -1719,6 +1724,15 @@ mod tests {
|
||||
};
|
||||
assert_eq!(plugin_read.serialization_scope(), None);
|
||||
|
||||
let plugin_installed = ClientRequest::PluginInstalled {
|
||||
request_id: request_id(),
|
||||
params: v2::PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(plugin_installed.serialization_scope(), None);
|
||||
|
||||
let plugin_uninstall = ClientRequest::PluginUninstall {
|
||||
request_id: request_id(),
|
||||
params: v2::PluginUninstallParams {
|
||||
|
||||
@@ -125,6 +125,19 @@ pub struct PluginListParams {
|
||||
pub marketplace_kinds: Option<Vec<PluginListMarketplaceKind>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstalledParams {
|
||||
/// Optional working directories used to discover repo marketplaces.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwds: Option<Vec<AbsolutePathBuf>>,
|
||||
/// Additional uninstalled plugin names that should be returned when present locally.
|
||||
/// This is used by mention surfaces that intentionally expose install entrypoints.
|
||||
#[ts(optional = nullable)]
|
||||
pub install_suggestion_plugin_names: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum PluginListMarketplaceKind {
|
||||
@@ -150,6 +163,15 @@ pub struct PluginListResponse {
|
||||
pub featured_plugin_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstalledResponse {
|
||||
pub marketplaces: Vec<PluginMarketplaceEntry>,
|
||||
#[serde(default)]
|
||||
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -2790,6 +2790,27 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_installed_params_serializes_install_suggestion_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: Some(vec![
|
||||
"computer-use".to_string(),
|
||||
"chrome".to_string(),
|
||||
]),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"cwds": null,
|
||||
"installSuggestionPluginNames": [
|
||||
"computer-use",
|
||||
"chrome",
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_read_params_serialization_uses_install_source_fields() {
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
|
||||
@@ -198,6 +198,7 @@ Example with notification opt-out:
|
||||
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), 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/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**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/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
@@ -718,7 +719,7 @@ Invoke an app by including `$<app-slug>` in the text input and adding a `mention
|
||||
|
||||
### Example: Start a turn (invoke a plugin)
|
||||
|
||||
Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://<plugin-name>@<marketplace-name>` path returned by `plugin/list`.
|
||||
Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://<plugin-name>@<marketplace-name>` path returned by `plugin/installed` or `plugin/list`.
|
||||
|
||||
```json
|
||||
{ "method": "turn/start", "id": 35, "params": {
|
||||
@@ -1676,7 +1677,7 @@ The server also emits `app/list/updated` notifications whenever either source (a
|
||||
}
|
||||
```
|
||||
|
||||
Invoke an app by inserting `$<app-slug>` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://<connector-id>` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://<plugin-name>@<marketplace-name>` paths from `plugin/list`.
|
||||
Invoke an app by inserting `$<app-slug>` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://<connector-id>` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://<plugin-name>@<marketplace-name>` paths from `plugin/installed` or `plugin/list`.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1102,6 +1102,9 @@ impl MessageProcessor {
|
||||
ClientRequest::PluginList { params, .. } => {
|
||||
self.plugin_processor.plugin_list(params).await
|
||||
}
|
||||
ClientRequest::PluginInstalled { params, .. } => {
|
||||
self.plugin_processor.plugin_installed(params).await
|
||||
}
|
||||
ClientRequest::PluginRead { params, .. } => {
|
||||
self.plugin_processor.plugin_read(params).await
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ use codex_app_server_protocol::PermissionProfileSelectionParams;
|
||||
use codex_app_server_protocol::PluginDetail;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginInstalledParams;
|
||||
use codex_app_server_protocol::PluginInstalledResponse;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginListMarketplaceKind;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginSharePrincipalRole;
|
||||
use codex_app_server_protocol::PluginShareTargetRole;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_core_plugins::remote::RemotePluginScope;
|
||||
use codex_core_plugins::remote::is_valid_remote_plugin_id;
|
||||
use codex_core_plugins::remote::validate_remote_plugin_id;
|
||||
use codex_mcp::McpOAuthLoginSupport;
|
||||
@@ -122,6 +123,39 @@ fn share_context_for_source(
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_configured_marketplace_plugin_to_plugin_summary(
|
||||
plugin: codex_core_plugins::ConfiguredMarketplacePlugin,
|
||||
shared_plugin_ids_by_local_path: &std::collections::BTreeMap<AbsolutePathBuf, String>,
|
||||
) -> PluginSummary {
|
||||
let share_context = share_context_for_source(&plugin.source, shared_plugin_ids_by_local_path);
|
||||
PluginSummary {
|
||||
id: plugin.id,
|
||||
remote_plugin_id: None,
|
||||
local_version: plugin.local_version,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
name: plugin.name,
|
||||
share_context,
|
||||
source: marketplace_plugin_source_to_info(plugin.source),
|
||||
install_policy: plugin.policy.installation.into(),
|
||||
auth_policy: plugin.policy.authentication.into(),
|
||||
availability: PluginAvailability::Available,
|
||||
interface: plugin.interface.map(local_plugin_interface_to_info),
|
||||
keywords: plugin.keywords,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_visible_scopes(config: &Config) -> Vec<RemotePluginScope> {
|
||||
let mut scopes = Vec::new();
|
||||
if config.features.enabled(Feature::RemotePlugin) {
|
||||
scopes.push(RemotePluginScope::Global);
|
||||
}
|
||||
if config.features.enabled(Feature::PluginSharing) {
|
||||
scopes.push(RemotePluginScope::Workspace);
|
||||
}
|
||||
scopes
|
||||
}
|
||||
|
||||
fn remote_plugin_share_discoverability(
|
||||
discoverability: PluginShareDiscoverability,
|
||||
) -> codex_core_plugins::remote::RemotePluginShareDiscoverability {
|
||||
@@ -268,6 +302,15 @@ impl PluginRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn plugin_installed(
|
||||
&self,
|
||||
params: PluginInstalledParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.plugin_installed_response(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn plugin_read(
|
||||
&self,
|
||||
params: PluginReadParams,
|
||||
@@ -487,27 +530,10 @@ impl PluginRequestProcessor {
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| {
|
||||
let share_context = share_context_for_source(
|
||||
&plugin.source,
|
||||
convert_configured_marketplace_plugin_to_plugin_summary(
|
||||
plugin,
|
||||
&shared_plugin_ids_by_local_path,
|
||||
);
|
||||
PluginSummary {
|
||||
id: plugin.id,
|
||||
remote_plugin_id: None,
|
||||
local_version: plugin.local_version,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
name: plugin.name,
|
||||
share_context,
|
||||
source: marketplace_plugin_source_to_info(plugin.source),
|
||||
install_policy: plugin.policy.installation.into(),
|
||||
auth_policy: plugin.policy.authentication.into(),
|
||||
availability: PluginAvailability::Available,
|
||||
interface: plugin
|
||||
.interface
|
||||
.map(local_plugin_interface_to_info),
|
||||
keywords: plugin.keywords,
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -632,6 +658,193 @@ impl PluginRequestProcessor {
|
||||
})
|
||||
}
|
||||
|
||||
async fn plugin_installed_response(
|
||||
&self,
|
||||
params: PluginInstalledParams,
|
||||
) -> Result<PluginInstalledResponse, JSONRPCErrorError> {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let PluginInstalledParams {
|
||||
cwds,
|
||||
install_suggestion_plugin_names,
|
||||
} = params;
|
||||
let roots = cwds.unwrap_or_default();
|
||||
let install_suggestion_plugin_names = install_suggestion_plugin_names
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let empty_response = || PluginInstalledResponse {
|
||||
marketplaces: Vec::new(),
|
||||
marketplace_load_errors: Vec::new(),
|
||||
};
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let remote_installed_plugin_visible_scopes =
|
||||
remote_installed_plugin_visible_scopes(&config);
|
||||
plugins_manager.maybe_start_remote_installed_plugin_bundle_sync(
|
||||
&plugins_input,
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
);
|
||||
|
||||
let (mut data, marketplace_load_errors) = self
|
||||
.load_local_installed_and_suggested_plugins(
|
||||
plugins_manager.clone(),
|
||||
&config,
|
||||
&plugins_input,
|
||||
roots,
|
||||
install_suggestion_plugin_names,
|
||||
)
|
||||
.await?;
|
||||
|
||||
data.extend(
|
||||
self.load_remote_installed_plugins(
|
||||
plugins_manager,
|
||||
&plugins_input,
|
||||
&remote_installed_plugin_visible_scopes,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
Ok(PluginInstalledResponse {
|
||||
marketplaces: data,
|
||||
marketplace_load_errors,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_local_installed_and_suggested_plugins(
|
||||
&self,
|
||||
plugins_manager: Arc<codex_core_plugins::PluginsManager>,
|
||||
config: &Config,
|
||||
plugins_input: &codex_core_plugins::PluginsConfigInput,
|
||||
roots: Vec<AbsolutePathBuf>,
|
||||
install_suggestion_plugin_names: HashSet<String>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<PluginMarketplaceEntry>,
|
||||
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
|
||||
),
|
||||
JSONRPCErrorError,
|
||||
> {
|
||||
let config_for_marketplace_listing = plugins_input.clone();
|
||||
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?;
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let outcome = plugins_manager
|
||||
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
|
||||
Ok::<
|
||||
(
|
||||
Vec<PluginMarketplaceEntry>,
|
||||
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
|
||||
),
|
||||
MarketplaceError,
|
||||
>((
|
||||
outcome
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.filter_map(|marketplace| {
|
||||
let plugins = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| {
|
||||
plugin.installed
|
||||
|| install_suggestion_plugin_names.contains(&plugin.name)
|
||||
})
|
||||
.map(|plugin| {
|
||||
convert_configured_marketplace_plugin_to_plugin_summary(
|
||||
plugin,
|
||||
&shared_plugin_ids_by_local_path,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(!plugins.is_empty()).then_some(PluginMarketplaceEntry {
|
||||
name: marketplace.name,
|
||||
path: Some(marketplace.path),
|
||||
interface: marketplace.interface.map(|interface| {
|
||||
MarketplaceInterface {
|
||||
display_name: interface.display_name,
|
||||
}
|
||||
}),
|
||||
plugins,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
outcome
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
|
||||
marketplace_path: err.path,
|
||||
message: err.message,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(outcome)) => Ok(outcome),
|
||||
Ok(Err(err)) => Err(Self::marketplace_error(
|
||||
err,
|
||||
"list installed and suggested marketplace plugins",
|
||||
)),
|
||||
Err(err) => Err(internal_error(format!(
|
||||
"failed to list installed and suggested plugins: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_remote_installed_plugins(
|
||||
&self,
|
||||
plugins_manager: Arc<codex_core_plugins::PluginsManager>,
|
||||
plugins_input: &codex_core_plugins::PluginsConfigInput,
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Vec<PluginMarketplaceEntry> {
|
||||
let remote_marketplaces = if let Some(remote_marketplaces) =
|
||||
plugins_manager.build_remote_installed_plugin_marketplaces_from_cache(visible_scopes)
|
||||
{
|
||||
Ok(remote_marketplaces)
|
||||
} else {
|
||||
plugins_manager
|
||||
.build_and_cache_remote_installed_plugin_marketplaces(
|
||||
plugins_input,
|
||||
auth,
|
||||
visible_scopes,
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match remote_marketplaces {
|
||||
Ok(remote_marketplaces) => remote_marketplaces
|
||||
.into_iter()
|
||||
.map(remote_marketplace_to_info)
|
||||
.collect(),
|
||||
Err(
|
||||
RemotePluginCatalogError::AuthRequired
|
||||
| RemotePluginCatalogError::UnsupportedAuthMode,
|
||||
) => Vec::new(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"plugin/installed remote installed plugin fetch failed; returning local marketplaces only"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_read_response(
|
||||
&self,
|
||||
params: PluginReadParams,
|
||||
|
||||
@@ -57,6 +57,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstalledParams;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
@@ -684,6 +685,15 @@ impl McpProcess {
|
||||
self.send_request("plugin/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/installed` JSON-RPC request.
|
||||
pub async fn send_plugin_installed_request(
|
||||
&mut self,
|
||||
params: PluginInstalledParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/installed", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/read` JSON-RPC request.
|
||||
pub async fn send_plugin_read_request(
|
||||
&mut self,
|
||||
|
||||
@@ -9,6 +9,8 @@ use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstalledParams;
|
||||
use codex_app_server_protocol::PluginInstalledResponse;
|
||||
use codex_app_server_protocol::PluginListMarketplaceKind;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
@@ -125,6 +127,95 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
write_openai_curated_marketplace(
|
||||
codex_home.path(),
|
||||
&["linear", "computer-use", "not-mentioned"],
|
||||
)?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: Some(vec!["computer-use".to_string()]),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstalledResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.marketplaces.len(), 1);
|
||||
assert_eq!(response.marketplaces[0].name, "openai-curated");
|
||||
assert_eq!(
|
||||
response.marketplaces[0]
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("linear@openai-curated".to_string(), true, true),
|
||||
("computer-use@openai-curated".to_string(), false, false),
|
||||
]
|
||||
);
|
||||
assert_eq!(response.marketplace_load_errors, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstalledResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.marketplaces, Vec::new());
|
||||
assert_eq!(response.marketplace_load_errors, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_rejects_relative_cwds() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1360,7 +1451,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugins_enabled_config_with_base_url(
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
@@ -1710,6 +1801,185 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_includes_remote_shared_with_me_plugins() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
remote_plugin = false
|
||||
plugin_sharing = 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,
|
||||
)?;
|
||||
let mut workspace_installed_body: serde_json::Value =
|
||||
serde_json::from_str(&workspace_remote_plugin_page_body(
|
||||
"plugins~Plugin_22222222222222222222222222222222",
|
||||
"shared-linear",
|
||||
"Shared Linear",
|
||||
"PRIVATE",
|
||||
/*enabled*/ Some(true),
|
||||
))?;
|
||||
let unlisted_installed_body: serde_json::Value =
|
||||
serde_json::from_str(&workspace_remote_plugin_page_body(
|
||||
"plugins~Plugin_33333333333333333333333333333333",
|
||||
"unlisted-linear",
|
||||
"Unlisted Linear",
|
||||
"UNLISTED",
|
||||
/*enabled*/ Some(false),
|
||||
))?;
|
||||
workspace_installed_body["plugins"]
|
||||
.as_array_mut()
|
||||
.expect("installed plugins should be an array")
|
||||
.push(unlisted_installed_body["plugins"][0].clone());
|
||||
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
||||
let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true);
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstalledResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.marketplaces.len(), 1);
|
||||
let marketplace = &response.marketplaces[0];
|
||||
assert_eq!(marketplace.name, "workspace-shared-with-me");
|
||||
assert_eq!(
|
||||
marketplace
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("Shared with me")
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(
|
||||
"shared-linear@workspace-shared-with-me".to_string(),
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"unlisted-linear@workspace-shared-with-me".to_string(),
|
||||
true,
|
||||
false
|
||||
)
|
||||
]
|
||||
);
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
plugin_sharing = false
|
||||
"#,
|
||||
server.uri()
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
"linear",
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
let global_installed_body =
|
||||
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let plugin_installed_request_id = mcp
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
let response: PluginInstalledResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(plugin_installed_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
|
||||
assert_eq!(response.marketplaces.len(), 1);
|
||||
assert_eq!(response.marketplaces[0].name, "chatgpt-global");
|
||||
assert_eq!(
|
||||
response.marketplaces[0]
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("linear@chatgpt-global".to_string(), true, true)]
|
||||
);
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json");
|
||||
wait_for_path_exists(&installed_path).await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1987,12 +2257,8 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
|
||||
share_context.discoverability,
|
||||
Some(PluginShareDiscoverability::Unlisted)
|
||||
);
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"/ps/plugins/installed",
|
||||
/*expected_count*/ 5,
|
||||
)
|
||||
.await?;
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2490,6 +2756,29 @@ async fn wait_for_remote_plugin_request_count(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = server.received_requests().await else {
|
||||
bail!("wiremock did not record requests");
|
||||
};
|
||||
if requests.iter().any(|request| {
|
||||
request.method == "GET"
|
||||
&& request.url.path().ends_with("/ps/plugins/installed")
|
||||
&& request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "scope" && value == scope)
|
||||
}) {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
|
||||
@@ -37,6 +37,7 @@ use crate::marketplace_upgrade::configured_git_marketplace_names;
|
||||
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::remote::RemotePluginCatalogError;
|
||||
use crate::remote::RemotePluginScope;
|
||||
use crate::remote::RemotePluginServiceConfig;
|
||||
use crate::remote_legacy::RemotePluginFetchError;
|
||||
use crate::remote_legacy::RemotePluginMutationError;
|
||||
@@ -597,6 +598,39 @@ impl PluginsManager {
|
||||
remote_installed_plugins_to_config(plugins, &self.store)
|
||||
}
|
||||
|
||||
pub fn build_remote_installed_plugin_marketplaces_from_cache(
|
||||
&self,
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
) -> Option<Vec<crate::remote::RemoteMarketplace>> {
|
||||
let cache = match self.remote_installed_plugins_cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
let plugins = cache.as_ref()?;
|
||||
Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes))
|
||||
}
|
||||
|
||||
pub async fn build_and_cache_remote_installed_plugin_marketplaces(
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) -> Result<Vec<crate::remote::RemoteMarketplace>, RemotePluginCatalogError> {
|
||||
let plugins = crate::remote::fetch_remote_installed_plugins(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
let marketplaces =
|
||||
crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes);
|
||||
let changed = self.write_remote_installed_plugins_cache(plugins);
|
||||
if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed {
|
||||
on_effective_plugins_changed();
|
||||
}
|
||||
Ok(marketplaces)
|
||||
}
|
||||
|
||||
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
|
||||
let mut cache = match self.remote_installed_plugins_cache.write() {
|
||||
Ok(cache) => cache,
|
||||
@@ -674,7 +708,7 @@ impl PluginsManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
pub fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
self: &Arc<Self>,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::loader::refresh_non_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use crate::marketplace::MarketplacePluginInstallPolicy;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::remote::RemotePluginScope;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
@@ -147,6 +148,20 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
||||
load_plugins_config_input(codex_home, cwd).await
|
||||
}
|
||||
|
||||
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
|
||||
RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
|
||||
auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: None,
|
||||
keywords: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -334,38 +349,6 @@ approval_mode = "approve"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_base = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear");
|
||||
write_plugin(&plugin_base, "local", "linear");
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
}]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(
|
||||
outcome.effective_skill_roots(),
|
||||
vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()]
|
||||
);
|
||||
assert_eq!(outcome.plugins().len(), 1);
|
||||
assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_installed_cache_ignores_plugins_missing_local_cache() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -379,17 +362,85 @@ remote_plugin = true
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
}]);
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(outcome, PluginLoadOutcome::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mut plugin = remote_installed_linear_plugin();
|
||||
plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault;
|
||||
plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall;
|
||||
plugin.interface = Some(codex_app_server_protocol::PluginInterface {
|
||||
display_name: Some("Linear".to_string()),
|
||||
short_description: Some("Track remote work".to_string()),
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: Some("#111111".to_string()),
|
||||
composer_icon: None,
|
||||
composer_icon_url: None,
|
||||
logo: None,
|
||||
logo_url: None,
|
||||
screenshots: Vec::new(),
|
||||
screenshot_urls: Vec::new(),
|
||||
});
|
||||
plugin.keywords = vec!["issues".to_string()];
|
||||
manager.write_remote_installed_plugins_cache(vec![plugin]);
|
||||
|
||||
let marketplaces = manager
|
||||
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global])
|
||||
.expect("remote installed cache should be present");
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "chatgpt-global");
|
||||
assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins");
|
||||
assert_eq!(marketplaces[0].plugins.len(), 1);
|
||||
let plugin = &marketplaces[0].plugins[0];
|
||||
assert_eq!(plugin.id, "linear@chatgpt-global");
|
||||
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin.name, "linear");
|
||||
assert_eq!(plugin.installed, true);
|
||||
assert_eq!(plugin.enabled, true);
|
||||
assert_eq!(
|
||||
plugin.install_policy,
|
||||
codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault
|
||||
);
|
||||
assert_eq!(
|
||||
plugin.auth_policy,
|
||||
codex_app_server_protocol::PluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(plugin.keywords, vec!["issues".to_string()]);
|
||||
assert_eq!(
|
||||
plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("Linear")
|
||||
);
|
||||
assert_eq!(
|
||||
plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.short_description.as_deref()),
|
||||
Some("Track remote work")
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace])
|
||||
.expect("remote installed cache should be present"),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
|
||||
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;
|
||||
pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
|
||||
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
|
||||
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
|
||||
pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
|
||||
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
|
||||
pub use share::RemotePluginShareAccessPolicy;
|
||||
pub use share::RemotePluginShareDiscoverability;
|
||||
@@ -63,6 +63,28 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
|
||||
(
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME,
|
||||
REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME,
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME,
|
||||
),
|
||||
(
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
|
||||
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME,
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginServiceConfig {
|
||||
@@ -89,6 +111,11 @@ pub struct RemoteInstalledPlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub install_policy: PluginInstallPolicy,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub availability: PluginAvailability,
|
||||
pub interface: Option<PluginInterface>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -264,7 +291,7 @@ pub enum RemotePluginCatalogError {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
||||
enum RemotePluginScope {
|
||||
pub enum RemotePluginScope {
|
||||
#[serde(rename = "GLOBAL")]
|
||||
Global,
|
||||
#[serde(rename = "WORKSPACE")]
|
||||
@@ -608,13 +635,7 @@ fn build_remote_marketplace(
|
||||
})
|
||||
.map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
plugins.sort_by(|left, right| {
|
||||
remote_plugin_display_name(left)
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
|
||||
.then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
sort_remote_plugin_summaries_by_display_name(&mut plugins);
|
||||
Ok(Some(RemoteMarketplace {
|
||||
name: name.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
@@ -622,7 +643,7 @@ fn build_remote_marketplace(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_installed_plugins(
|
||||
pub(crate) async fn fetch_remote_installed_plugins(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> {
|
||||
@@ -642,7 +663,7 @@ pub async fn fetch_remote_installed_plugins(
|
||||
let mut installed_plugins = [global, workspace]
|
||||
.into_iter()
|
||||
.flat_map(|(_scope, plugins)| plugins)
|
||||
.map(|plugin| remote_installed_plugin_to_info(&plugin))
|
||||
.map(|plugin| remote_installed_plugin_to_cache_entry(&plugin))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
installed_plugins.sort_by(|left, right| {
|
||||
left.marketplace_name
|
||||
@@ -652,6 +673,55 @@ pub async fn fetch_remote_installed_plugins(
|
||||
Ok(installed_plugins)
|
||||
}
|
||||
|
||||
pub fn group_remote_installed_plugins_by_marketplaces(
|
||||
plugins: &[RemoteInstalledPlugin],
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
) -> Vec<RemoteMarketplace> {
|
||||
let mut plugins_by_marketplace = BTreeMap::<String, Vec<RemotePluginSummary>>::new();
|
||||
|
||||
for plugin in plugins {
|
||||
if !RemotePluginScope::from_marketplace_name(&plugin.marketplace_name)
|
||||
.is_some_and(|scope| visible_scopes.contains(&scope))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let plugin_summary = RemotePluginSummary {
|
||||
id: plugin_id.as_key(),
|
||||
remote_plugin_id: plugin.id.clone(),
|
||||
name: plugin.name.clone(),
|
||||
share_context: None,
|
||||
installed: true,
|
||||
enabled: plugin.enabled,
|
||||
install_policy: plugin.install_policy,
|
||||
auth_policy: plugin.auth_policy,
|
||||
availability: plugin.availability,
|
||||
interface: plugin.interface.clone(),
|
||||
keywords: plugin.keywords.clone(),
|
||||
};
|
||||
plugins_by_marketplace
|
||||
.entry(plugin.marketplace_name.clone())
|
||||
.or_default()
|
||||
.push(plugin_summary);
|
||||
}
|
||||
|
||||
REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER
|
||||
.into_iter()
|
||||
.filter_map(|(marketplace_name, display_name)| {
|
||||
let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?;
|
||||
sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins);
|
||||
Some(RemoteMarketplace {
|
||||
name: marketplace_name.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
plugins: marketplace_plugins,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_plugin_detail(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -982,7 +1052,7 @@ fn remote_plugin_share_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_to_info(
|
||||
fn remote_installed_plugin_to_cache_entry(
|
||||
installed_plugin: &RemotePluginInstalledItem,
|
||||
) -> Result<RemoteInstalledPlugin, RemotePluginCatalogError> {
|
||||
let plugin = &installed_plugin.plugin;
|
||||
@@ -994,6 +1064,11 @@ fn remote_installed_plugin_to_info(
|
||||
id: plugin.id.clone(),
|
||||
name: plugin.name.clone(),
|
||||
enabled: installed_plugin.enabled,
|
||||
install_policy: plugin.installation_policy,
|
||||
auth_policy: plugin.authentication_policy,
|
||||
availability: plugin.availability,
|
||||
interface: remote_plugin_interface_to_info(plugin),
|
||||
keywords: plugin.release.keywords.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1068,6 +1143,18 @@ fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str {
|
||||
.unwrap_or(&plugin.name)
|
||||
}
|
||||
|
||||
fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) {
|
||||
plugins.sort_by(|left, right| {
|
||||
let left_display_name = remote_plugin_display_name(left);
|
||||
let right_display_name = remote_plugin_display_name(right);
|
||||
left_display_name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&right_display_name.to_ascii_lowercase())
|
||||
.then_with(|| left_display_name.cmp(right_display_name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn non_empty_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim();
|
||||
|
||||
@@ -78,7 +78,7 @@ pub struct RemotePluginCacheMutationGuard {
|
||||
key: RemotePluginCacheMutationKey,
|
||||
}
|
||||
|
||||
pub fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
pub(crate) fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
codex_home: PathBuf,
|
||||
config: RemotePluginServiceConfig,
|
||||
auth: Option<CodexAuth>,
|
||||
|
||||
Reference in New Issue
Block a user