mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Add plugin reconciliation app-server API (#41949)
## What changed - Add the `plugin/reconcile` JSON-RPC method to synchronize installed remote plugin bundles and wait for required hook updates. - Return affected plugin IDs with MCP, Apps, hooks, and skills refresh hints, plus remote update and materialization failures. - Refresh loaded hook runtimes after relevant updates and return an empty result when plugins or backend authentication are unavailable. ## Testing - Cover workspace and global bundle installs, unchanged passes, enablement changes, updates, removals, materialization failures, hook refreshes, and disabled plugins. GitOrigin-RevId: 45c02518d71990d13eb09d56ed51505c91c66956
This commit is contained in:
@@ -2397,6 +2397,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileParams": {
|
||||
"properties": {
|
||||
"reason": {
|
||||
"description": "Optional client-provided reason recorded with the reconciliation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSearchScope": {
|
||||
"enum": [
|
||||
"global",
|
||||
@@ -6587,6 +6599,30 @@
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/reconcile"
|
||||
],
|
||||
"title": "Plugin/reconcileRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginReconcileParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/reconcileRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+102
@@ -1093,6 +1093,30 @@
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/reconcile"
|
||||
],
|
||||
"title": "Plugin/reconcileRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginReconcileParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/reconcileRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -16181,6 +16205,84 @@
|
||||
"title": "PluginReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileChangedPlugin": {
|
||||
"description": "Runtime categories affected by this change, not just capabilities currently present. Flags describe declarations before runtime policy filtering. Updates OR the old and new bundle flags; enablement changes and cached reinstalls use the cached bundle; removals retain the old bundle's flags.",
|
||||
"properties": {
|
||||
"hasApps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasHooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasMcps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSkills": {
|
||||
"description": "Whether either bundle declares skill roots; not a validated inventory of enabled skills.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"description": "Local plugin ID (`name@marketplace`), matching `PluginSummary.id`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasApps",
|
||||
"hasHooks",
|
||||
"hasMcps",
|
||||
"hasSkills",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"description": "Optional client-provided reason recorded with the reconciliation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginReconcileParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Bundle and installed-state changes observed by this pass, not a runtime-readiness acknowledgement or a cumulative diff since the client's last request. Other metadata-only changes are not listed.",
|
||||
"properties": {
|
||||
"changedPlugins": {
|
||||
"description": "Plugins affected by bundle changes, enablement changes, or removals. Installed-state changes compare against the previous cached snapshot, including cached reinstalls. Removal hints survive cache cleanup failures; unchanged plugins are omitted.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginReconcileChangedPlugin"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedMaterializationRemotePluginIds": {
|
||||
"description": "Subset of failures for which the requested bundle could not be materialized. A previously cached version may still be available.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedRemotePluginIds": {
|
||||
"description": "Backend remote plugin IDs whose bundle or identity update failed.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPlugins",
|
||||
"failedMaterializationRemotePluginIds",
|
||||
"failedRemotePluginIds"
|
||||
],
|
||||
"title": "PluginReconcileResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSearchResult": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
|
||||
+102
@@ -2521,6 +2521,30 @@
|
||||
"title": "Plugin/installedRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/reconcile"
|
||||
],
|
||||
"title": "Plugin/reconcileRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginReconcileParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/reconcileRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -12253,6 +12277,84 @@
|
||||
"title": "PluginReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileChangedPlugin": {
|
||||
"description": "Runtime categories affected by this change, not just capabilities currently present. Flags describe declarations before runtime policy filtering. Updates OR the old and new bundle flags; enablement changes and cached reinstalls use the cached bundle; removals retain the old bundle's flags.",
|
||||
"properties": {
|
||||
"hasApps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasHooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasMcps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSkills": {
|
||||
"description": "Whether either bundle declares skill roots; not a validated inventory of enabled skills.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"description": "Local plugin ID (`name@marketplace`), matching `PluginSummary.id`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasApps",
|
||||
"hasHooks",
|
||||
"hasMcps",
|
||||
"hasSkills",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"description": "Optional client-provided reason recorded with the reconciliation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginReconcileParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginReconcileResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Bundle and installed-state changes observed by this pass, not a runtime-readiness acknowledgement or a cumulative diff since the client's last request. Other metadata-only changes are not listed.",
|
||||
"properties": {
|
||||
"changedPlugins": {
|
||||
"description": "Plugins affected by bundle changes, enablement changes, or removals. Installed-state changes compare against the previous cached snapshot, including cached reinstalls. Removal hints survive cache cleanup failures; unchanged plugins are omitted.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginReconcileChangedPlugin"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedMaterializationRemotePluginIds": {
|
||||
"description": "Subset of failures for which the requested bundle could not be materialized. A previously cached version may still be available.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedRemotePluginIds": {
|
||||
"description": "Backend remote plugin IDs whose bundle or identity update failed.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPlugins",
|
||||
"failedMaterializationRemotePluginIds",
|
||||
"failedRemotePluginIds"
|
||||
],
|
||||
"title": "PluginReconcileResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginSearchResult": {
|
||||
"properties": {
|
||||
"marketplaceName": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"description": "Optional client-provided reason recorded with the reconciliation attempt.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "PluginReconcileParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"PluginReconcileChangedPlugin": {
|
||||
"description": "Runtime categories affected by this change, not just capabilities currently present. Flags describe declarations before runtime policy filtering. Updates OR the old and new bundle flags; enablement changes and cached reinstalls use the cached bundle; removals retain the old bundle's flags.",
|
||||
"properties": {
|
||||
"hasApps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasHooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasMcps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSkills": {
|
||||
"description": "Whether either bundle declares skill roots; not a validated inventory of enabled skills.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"description": "Local plugin ID (`name@marketplace`), matching `PluginSummary.id`.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasApps",
|
||||
"hasHooks",
|
||||
"hasMcps",
|
||||
"hasSkills",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Bundle and installed-state changes observed by this pass, not a runtime-readiness acknowledgement or a cumulative diff since the client's last request. Other metadata-only changes are not listed.",
|
||||
"properties": {
|
||||
"changedPlugins": {
|
||||
"description": "Plugins affected by bundle changes, enablement changes, or removals. Installed-state changes compare against the previous cached snapshot, including cached reinstalls. Removal hints survive cache cleanup failures; unchanged plugins are omitted.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginReconcileChangedPlugin"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedMaterializationRemotePluginIds": {
|
||||
"description": "Subset of failures for which the requested bundle could not be materialized. A previously cached version may still be available.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"failedRemotePluginIds": {
|
||||
"description": "Backend remote plugin IDs whose bundle or identity update failed.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"changedPlugins",
|
||||
"failedMaterializationRemotePluginIds",
|
||||
"failedRemotePluginIds"
|
||||
],
|
||||
"title": "PluginReconcileResponse",
|
||||
"type": "object"
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
+19
@@ -0,0 +1,19 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Runtime categories affected by this change, not just capabilities currently present.
|
||||
* Flags describe declarations before runtime policy filtering. Updates OR the old and new
|
||||
* bundle flags; enablement changes and cached reinstalls use the cached bundle; removals retain
|
||||
* the old bundle's flags.
|
||||
*/
|
||||
export type PluginReconcileChangedPlugin = {
|
||||
/**
|
||||
* Local plugin ID (`name@marketplace`), matching `PluginSummary.id`.
|
||||
*/
|
||||
id: string, hasMcps: boolean, hasApps: boolean, hasHooks: boolean,
|
||||
/**
|
||||
* Whether either bundle declares skill roots; not a validated inventory of enabled skills.
|
||||
*/
|
||||
hasSkills: boolean, };
|
||||
@@ -0,0 +1,9 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginReconcileParams = {
|
||||
/**
|
||||
* Optional client-provided reason recorded with the reconciliation attempt.
|
||||
*/
|
||||
reason?: string | null, };
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// 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 { PluginReconcileChangedPlugin } from "./PluginReconcileChangedPlugin";
|
||||
|
||||
/**
|
||||
* Bundle and installed-state changes observed by this pass, not a runtime-readiness
|
||||
* acknowledgement or a cumulative diff since the client's last request. Other metadata-only
|
||||
* changes are not listed.
|
||||
*/
|
||||
export type PluginReconcileResponse = {
|
||||
/**
|
||||
* Plugins affected by bundle changes, enablement changes, or removals.
|
||||
* Installed-state changes compare against the previous cached snapshot, including
|
||||
* cached reinstalls. Removal hints survive cache cleanup failures; unchanged plugins are omitted.
|
||||
*/
|
||||
changedPlugins: Array<PluginReconcileChangedPlugin>,
|
||||
/**
|
||||
* Backend remote plugin IDs whose bundle or identity update failed.
|
||||
*/
|
||||
failedRemotePluginIds: Array<string>,
|
||||
/**
|
||||
* Subset of failures for which the requested bundle could not be materialized.
|
||||
* A previously cached version may still be available.
|
||||
*/
|
||||
failedMaterializationRemotePluginIds: Array<string>, };
|
||||
@@ -353,6 +353,9 @@ export type { PluginListResponse } from "./PluginListResponse";
|
||||
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
export type { PluginReadParams } from "./PluginReadParams";
|
||||
export type { PluginReadResponse } from "./PluginReadResponse";
|
||||
export type { PluginReconcileChangedPlugin } from "./PluginReconcileChangedPlugin";
|
||||
export type { PluginReconcileParams } from "./PluginReconcileParams";
|
||||
export type { PluginReconcileResponse } from "./PluginReconcileResponse";
|
||||
export type { PluginSearchResult } from "./PluginSearchResult";
|
||||
export type { PluginSearchScope } from "./PluginSearchScope";
|
||||
export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams";
|
||||
|
||||
@@ -852,6 +852,11 @@ client_request_definitions! {
|
||||
serialization: None,
|
||||
response: v2::PluginInstalledResponse,
|
||||
},
|
||||
PluginReconcile => "plugin/reconcile" {
|
||||
params: v2::PluginReconcileParams,
|
||||
serialization: None,
|
||||
response: v2::PluginReconcileResponse,
|
||||
},
|
||||
PluginRead => "plugin/read" {
|
||||
params: v2::PluginReadParams,
|
||||
serialization: None,
|
||||
|
||||
@@ -192,6 +192,50 @@ pub struct PluginInstalledResponse {
|
||||
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginReconcileParams {
|
||||
/// Optional client-provided reason recorded with the reconciliation attempt.
|
||||
#[ts(optional = nullable)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Bundle and installed-state changes observed by this pass, not a runtime-readiness
|
||||
/// acknowledgement or a cumulative diff since the client's last request. Other metadata-only
|
||||
/// changes are not listed.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginReconcileResponse {
|
||||
/// Plugins affected by bundle changes, enablement changes, or removals.
|
||||
/// Installed-state changes compare against the previous cached snapshot, including
|
||||
/// cached reinstalls. Removal hints survive cache cleanup failures; unchanged plugins are omitted.
|
||||
pub changed_plugins: Vec<PluginReconcileChangedPlugin>,
|
||||
/// Backend remote plugin IDs whose bundle or identity update failed.
|
||||
pub failed_remote_plugin_ids: Vec<String>,
|
||||
/// Subset of failures for which the requested bundle could not be materialized.
|
||||
/// A previously cached version may still be available.
|
||||
pub failed_materialization_remote_plugin_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Runtime categories affected by this change, not just capabilities currently present.
|
||||
/// Flags describe declarations before runtime policy filtering. Updates OR the old and new
|
||||
/// bundle flags; enablement changes and cached reinstalls use the cached bundle; removals retain
|
||||
/// the old bundle's flags.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginReconcileChangedPlugin {
|
||||
/// Local plugin ID (`name@marketplace`), matching `PluginSummary.id`.
|
||||
pub id: String,
|
||||
pub has_mcps: bool,
|
||||
pub has_apps: bool,
|
||||
pub has_hooks: bool,
|
||||
/// Whether either bundle declares skill roots; not a validated inventory of enabled skills.
|
||||
pub has_skills: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -271,6 +271,7 @@ Example with notification opt-out:
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, 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. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes nullable `disabledReason` and `eligiblePlanTypes`, preserving plugin-service availability metadata and raw plan identifiers for remote plugins while returning `null` for local plugins or older remote responses. The same summaries include `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `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/search` — search the remote plugin service directly and combine matching local marketplace plugins into the first result page. Accepts a `searchTerm`, optional `global`, `workspace`, or `personal` scope, optional `cwds` for discovering repo marketplaces, and optional `cursor` and `limit`; `personal` searches user-owned plugins. Local matching uses plugin names, display names, and keywords, with case- and punctuation-insensitive relevance ordering. Global searches include applicable built-in local plugins, personal searches include other local plugins, workspace searches remain remote-only, and an omitted scope includes all local plugins. When the remote global catalog is active, it is authoritative and replaces the local curated marketplace. Local results remain available with API-key authentication and when `remote_plugin` is disabled; in the latter case, omitted-scope and explicit workspace searches can still query the remote workspace catalog, while explicit global and personal searches do not query plugin-service. The first page includes at most 100 local matches and can exceed `limit`; subsequent pages contain remote results only, and the upstream pagination token is passed through unchanged as `nextCursor`. Local and remote copies are deduplicated by shared remote identity, with the remote summary retaining local installed state. Every result always explicitly returns `plugin.enabled: false`, including enabled local plugins, deduplicated plugins, and later remote-only pages; search reports discovery metadata rather than effective activation. Use `plugin/list` or `plugin/read` to determine whether a plugin is actually enabled. When `plugin_sharing` is disabled, shared/private workspace results are omitted after the remote page is fetched (**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. Remote rows include nullable `installPolicySource` and `installedAt`, the backend installation timestamp in Unix seconds. `installedAt` is also returned by `plugin/list`, `plugin/read`, and `plugin/share/list`; it is `null` for local plugins, uninstalled plugins, plugins installed by default, and older backend responses that do not include an installation timestamp. 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/reconcile` — sync installed remote plugin bundles to match the latest plugin-service state. Blocks until synchronization and required hook updates finish, then returns `changedPlugins` with `hasMcps`, `hasApps`, `hasHooks`, and `hasSkills` refresh hints, including removals. Callers refresh MCP and Apps runtimes; plugin skills are picked up automatically on subsequent turns.
|
||||
- `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. Remote plugin details can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. For owned workspace plugins, `summary.shareContext.canPublishToWorkspace` reports whether the current user may add the plugin to the workspace directory; `plugin/share/save` returns the same capability after creating or updating a share, and clients should fail closed when either value is `null`. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. 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`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current 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.
|
||||
|
||||
@@ -94,7 +94,7 @@ fn hook_trusted_hash_edit(hook_key: &str, current_hash: &str) -> ConfigEdit {
|
||||
}
|
||||
}
|
||||
|
||||
async fn trust_materialized_plugin_hooks(
|
||||
pub(crate) async fn trust_materialized_plugin_hooks(
|
||||
materializations: Vec<RemotePluginMaterialization>,
|
||||
auth_manager: &AuthManager,
|
||||
thread_manager: &ThreadManager,
|
||||
|
||||
@@ -1377,6 +1377,15 @@ impl MessageProcessor {
|
||||
ClientRequest::PluginInstalled { params, .. } => {
|
||||
self.plugin_processor.plugin_installed(params).await
|
||||
}
|
||||
ClientRequest::PluginReconcile { params, .. } => {
|
||||
self.plugin_processor
|
||||
.plugin_reconcile(
|
||||
params,
|
||||
self.config_processor.clone(),
|
||||
&self.request_serialization_queues,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ClientRequest::PluginRead { params, .. } => {
|
||||
self.plugin_processor.plugin_read(params).await
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ use codex_rmcp_client::StreamableHttpRedirectMode;
|
||||
use codex_rmcp_client::perform_oauth_login_silent;
|
||||
|
||||
mod local;
|
||||
mod reconcile;
|
||||
mod search;
|
||||
|
||||
fn plugin_redirect_mode(plugin_root: &Path) -> StreamableHttpRedirectMode {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Exposes shared installed-plugin reconciliation with hook lifecycle updates.
|
||||
|
||||
use super::*;
|
||||
use crate::effective_plugin_change::trust_materialized_plugin_hooks;
|
||||
use crate::request_serialization::RequestSerializationAccess;
|
||||
use crate::request_serialization::RequestSerializationQueueKey;
|
||||
use crate::request_serialization::RequestSerializationQueues;
|
||||
use codex_app_server_protocol::PluginReconcileChangedPlugin;
|
||||
use codex_app_server_protocol::PluginReconcileParams;
|
||||
use codex_app_server_protocol::PluginReconcileResponse;
|
||||
use codex_core_plugins::remote::RemotePluginShareDiscoverability;
|
||||
|
||||
impl PluginRequestProcessor {
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(reason = ?params.reason))]
|
||||
pub(crate) async fn plugin_reconcile(
|
||||
&self,
|
||||
params: PluginReconcileParams,
|
||||
config_processor: ConfigRequestProcessor,
|
||||
request_serialization_queues: &RequestSerializationQueues,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !plugins_input.plugins_enabled
|
||||
|| !auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)
|
||||
{
|
||||
return Ok(Some(PluginReconcileResponse::default().into()));
|
||||
}
|
||||
|
||||
// Match background bundle sync: remote_plugin controls catalog visibility, not sync.
|
||||
// The shared reconciler owns synchronization, auth checks, and cache publication.
|
||||
let outcome = self
|
||||
.thread_manager
|
||||
.plugins_manager()
|
||||
.reconcile_remote_installed_plugins(&plugins_input, auth.as_ref())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to reconcile remote installed plugins: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let hooks_changed = outcome
|
||||
.changed_plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.capabilities.has_hooks);
|
||||
// Preserve materialization-owned trust: unchanged background passes cannot recover it.
|
||||
// Serialize hook updates with config writes, after releasing the bundle writer gate.
|
||||
// Changes without hooks or eligible materializations still skip this queue.
|
||||
if hooks_changed
|
||||
|| outcome.materialized_remote_plugins.iter().any(|plugin| {
|
||||
plugin.scope == RemotePluginScope::Workspace
|
||||
&& plugin.discoverability == Some(RemotePluginShareDiscoverability::Listed)
|
||||
})
|
||||
{
|
||||
let materializations = outcome.materialized_remote_plugins.clone();
|
||||
let processor = self.clone();
|
||||
let (complete, completion) = tokio::sync::oneshot::channel();
|
||||
request_serialization_queues
|
||||
.enqueue_background(
|
||||
RequestSerializationQueueKey::Global("config"),
|
||||
RequestSerializationAccess::Exclusive,
|
||||
async move {
|
||||
let result = trust_materialized_plugin_hooks(
|
||||
materializations,
|
||||
&processor.auth_manager,
|
||||
&processor.thread_manager,
|
||||
&processor.config_manager,
|
||||
&config_processor,
|
||||
)
|
||||
.await;
|
||||
// Removals and disablements have no trust write to rebuild loaded hooks.
|
||||
// Rebuild after the trust attempt, even if it failed, to drop stale hooks.
|
||||
if hooks_changed {
|
||||
processor.thread_manager.refresh_hook_runtimes().await;
|
||||
}
|
||||
let _ = complete.send(result);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
completion
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!("plugin hook trust update was cancelled: {err}"))
|
||||
})?
|
||||
.map_err(|err| {
|
||||
internal_error(format!("failed to trust materialized plugin hooks: {err}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Some(
|
||||
PluginReconcileResponse {
|
||||
changed_plugins: outcome
|
||||
.changed_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| PluginReconcileChangedPlugin {
|
||||
id: plugin.plugin_id,
|
||||
has_mcps: plugin.capabilities.has_mcps,
|
||||
has_apps: plugin.capabilities.has_apps,
|
||||
has_hooks: plugin.capabilities.has_hooks,
|
||||
has_skills: plugin.capabilities.has_skills,
|
||||
})
|
||||
.collect(),
|
||||
failed_remote_plugin_ids: outcome.failed_remote_plugin_ids,
|
||||
failed_materialization_remote_plugin_ids: outcome
|
||||
.failed_materialization_remote_plugin_ids,
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ mod plan_item;
|
||||
mod plugin_install;
|
||||
mod plugin_list;
|
||||
mod plugin_read;
|
||||
mod plugin_reconcile;
|
||||
mod plugin_search;
|
||||
mod plugin_share;
|
||||
mod plugin_uninstall;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Exercises blocking plugin reconciliation through the public JSON-RPC API.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::MockResponsesConfig;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::PluginReconcileChangedPlugin;
|
||||
use codex_app_server_protocol::PluginReconcileResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_features::Feature;
|
||||
use core_test_support::responses;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
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;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[test_case("WORKSPACE", "workspace-directory"; "workspace")]
|
||||
#[test_case("GLOBAL", "openai-curated-remote"; "global")]
|
||||
#[tokio::test]
|
||||
async fn plugin_reconcile_syncs_bundles_and_reports_changes(
|
||||
scope: &str,
|
||||
marketplace: &str,
|
||||
) -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
// Both scopes must retain background-sync behavior with remote_plugin disabled.
|
||||
let (mut app_server, codex_home) = start_app_server(&server).await?;
|
||||
let thread = app_server.start_thread(Default::default()).await?.thread;
|
||||
let bundle_url = format!("{}/bundle", server.uri());
|
||||
let plugin_id = format!("linear@{marketplace}");
|
||||
let first = PluginReconcileChangedPlugin {
|
||||
id: plugin_id.clone(),
|
||||
has_mcps: true,
|
||||
has_apps: false,
|
||||
has_hooks: true,
|
||||
has_skills: true,
|
||||
};
|
||||
let second = PluginReconcileChangedPlugin {
|
||||
id: plugin_id.clone(),
|
||||
has_mcps: false,
|
||||
has_apps: true,
|
||||
has_hooks: false,
|
||||
has_skills: false,
|
||||
};
|
||||
let updated = PluginReconcileChangedPlugin {
|
||||
id: plugin_id,
|
||||
has_mcps: true,
|
||||
has_apps: true,
|
||||
has_hooks: true,
|
||||
has_skills: true,
|
||||
};
|
||||
let failed_id = "plugins~Plugin_11111111111111111111111111111111";
|
||||
let mut missing_bundle = installed_plugin("1.0.0", &bundle_url, "WORKSPACE");
|
||||
missing_bundle["id"] = json!(failed_id);
|
||||
missing_bundle["name"] = json!("missing-bundle");
|
||||
missing_bundle["release"]["bundle_download_url"] = Value::Null;
|
||||
let mut first_pass_failure = Some(missing_bundle);
|
||||
|
||||
// v1.1 drops hooks; v2 restores them and drops Apps. Both updates must report
|
||||
// both sides, while uninstall below reports only v2's remaining capabilities.
|
||||
// Enablement changes report cached capabilities without downloading the bundle again.
|
||||
for (version, enabled, capabilities, expected_plugins, expected_downloads) in [
|
||||
("1.0.0", true, &first, vec![first.clone()], 1),
|
||||
("1.0.0", true, &first, Vec::new(), 0),
|
||||
("1.0.0", false, &first, vec![first.clone()], 0),
|
||||
("1.0.0", true, &first, vec![first.clone()], 0),
|
||||
("1.1.0", true, &second, vec![updated.clone()], 1),
|
||||
("2.0.0", true, &first, vec![updated], 1),
|
||||
] {
|
||||
let mut files = vec![(
|
||||
".codex-plugin/plugin.json",
|
||||
json!({"name": "linear"}).to_string(),
|
||||
)];
|
||||
if capabilities.has_mcps {
|
||||
files.push((
|
||||
".mcp.json",
|
||||
json!({"mcpServers": {"example": {"command": "unused", "enabled": false}}})
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if capabilities.has_apps {
|
||||
files.push((
|
||||
".app.json",
|
||||
json!({"apps": {"example": {"id": "connector_example"}}}).to_string(),
|
||||
));
|
||||
}
|
||||
if capabilities.has_hooks {
|
||||
files.push((
|
||||
"hooks/hooks.json",
|
||||
json!({"hooks": {"UserPromptSubmit": [{"hooks": [{
|
||||
"type": "command", "command": format!("echo hook {version}")
|
||||
}]}]}})
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if capabilities.has_skills {
|
||||
files.push((
|
||||
"skills/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nExample skill".to_string(),
|
||||
));
|
||||
}
|
||||
let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
|
||||
for (path, contents) in files {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(/*mode*/ 0o644);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, path, contents.as_bytes())?;
|
||||
}
|
||||
let bundle = archive.into_inner()?.finish()?;
|
||||
let mut plugin = installed_plugin(version, &bundle_url, scope);
|
||||
plugin["enabled"] = json!(enabled);
|
||||
let mut plugins = vec![plugin];
|
||||
let mut failures = Vec::new();
|
||||
if let Some(failure) = first_pass_failure.take() {
|
||||
plugins.push(failure);
|
||||
failures.push(failed_id.to_string());
|
||||
}
|
||||
let expected = PluginReconcileResponse {
|
||||
changed_plugins: expected_plugins,
|
||||
failed_remote_plugin_ids: failures.clone(),
|
||||
failed_materialization_remote_plugin_ids: failures,
|
||||
};
|
||||
mount_installed_snapshot(&server, plugins).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/bundle"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(bundle))
|
||||
.expect(expected_downloads)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert_eq!(reconcile(&mut app_server).await?, expected);
|
||||
// A turn exercises the loaded registry, not fresh hooks/list discovery. Hooks run
|
||||
// on the app-server host even when the thread uses a remote executor.
|
||||
assert_eq!(
|
||||
turn_hook_runs(&mut app_server, &server, thread.id.clone()).await?,
|
||||
usize::from(scope == "WORKSPACE" && enabled && capabilities.has_hooks),
|
||||
);
|
||||
server.verify().await;
|
||||
server.reset().await;
|
||||
}
|
||||
|
||||
// Uninstall must report removed capabilities and stop already-loaded hooks.
|
||||
mount_installed_snapshot(&server, Vec::new()).await;
|
||||
assert_eq!(
|
||||
reconcile(&mut app_server).await?,
|
||||
PluginReconcileResponse {
|
||||
changed_plugins: vec![first],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
turn_hook_runs(&mut app_server, &server, thread.id.clone()).await?,
|
||||
0
|
||||
);
|
||||
server.verify().await;
|
||||
server.reset().await;
|
||||
|
||||
// The next RPC must honor the latest config without contacting the plugin service.
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
config_path,
|
||||
config.replace("plugins = true", "plugins = false"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
reconcile(&mut app_server).await?,
|
||||
PluginReconcileResponse::default()
|
||||
);
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(requests.iter().all(|request| {
|
||||
!request.url.path().starts_with("/backend-api/ps/plugins/")
|
||||
&& request.url.path() != "/bundle"
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn turn_hook_runs(
|
||||
app_server: &mut TestAppServer,
|
||||
server: &MockServer,
|
||||
thread_id: String,
|
||||
) -> Result<usize> {
|
||||
let _response = responses::mount_sse_once(
|
||||
server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("response"),
|
||||
responses::ev_completed("response"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
app_server.clear_message_buffer();
|
||||
let completed = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
app_server.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id,
|
||||
input: vec![UserInput::Text {
|
||||
text: "run hooks".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
Ok(app_server
|
||||
.pending_notification_methods()
|
||||
.iter()
|
||||
.filter(|method| method.as_str() == "hook/started")
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn reconcile(app_server: &mut TestAppServer) -> Result<PluginReconcileResponse> {
|
||||
let request = app_server
|
||||
.send_raw_request(
|
||||
"plugin/reconcile",
|
||||
Some(json!({"reason": "tooling_changed"})),
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, app_server.read_response(request)).await?
|
||||
}
|
||||
|
||||
async fn start_app_server(server: &MockServer) -> Result<(TestAppServer, TempDir)> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let base_url = format!("{}/backend-api/", server.uri());
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.with_root_config(&format!("chatgpt_base_url = \"{base_url}\""))
|
||||
.disable_feature(Feature::Apps)
|
||||
.enable_feature(Feature::Plugins)
|
||||
.enable_feature(Feature::CodexHooks)
|
||||
.disable_feature(Feature::RemotePlugin)
|
||||
.disable_feature(Feature::PluginSharing)
|
||||
.write(codex_home.path())?;
|
||||
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 app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_managed_config()
|
||||
.with_env_overrides(&[(
|
||||
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS",
|
||||
Some("1"),
|
||||
)])
|
||||
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
Ok((app_server, codex_home))
|
||||
}
|
||||
|
||||
async fn mount_installed_snapshot(server: &MockServer, plugins: Vec<Value>) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("includeDownloadUrls", "true"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": plugins,
|
||||
"pagination": { "limit": 200, "next_page_token": null },
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn installed_plugin(version: &str, bundle_url: &str, scope: &str) -> Value {
|
||||
json!({
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": scope,
|
||||
"discoverability": (scope == "WORKSPACE").then_some("LISTED"),
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"version": version,
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"bundle_download_url": bundle_url,
|
||||
"interface": {},
|
||||
},
|
||||
"enabled": true,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user