From a1ac559ed7e6eedbe10aa3c395b8d933f4b89aea Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Thu, 7 May 2026 17:20:28 -0700 Subject: [PATCH] feat(codex): enable native plugin app support (#78733) * feat(codex): add native plugin config schema * feat(codex): add native plugin inventory activation * feat(codex): configure native plugin apps for threads * feat(codex): enforce plugin elicitation policy * feat(codex): migrate native plugins * docs(codex): document native plugin support * fix(codex): harden plugin migration refresh * fix(codex): satisfy plugin activation lint * fix: stabilize codex plugin app config * fix: address codex plugin review feedback * fix: key codex plugin app cache by websocket credentials * fix: keep codex plugin app fingerprints stable * fix: refresh codex plugin cache test fixtures * fix: refresh plugin app readiness after activation * fix: support remote codex plugin activation * fix: recover plugin app bindings after cache refresh * fix: force codex app refresh after plugin activation * fix: recover partial codex plugin app bindings * fix: sync codex plugin selection config * fix: keep codex plugin activation fail closed * fix: align codex plugin protocol types with main * fix: refresh partial codex plugin app bindings * fix: key codex app cache by env api key * fix: skip failed codex plugin migration config * test: update codex prompt snapshots * fix: fail closed on missing codex app inventory entries * fix(codex): enforce native plugin policy gates * fix(codex): normalize native plugin policy types * fix(codex): fail closed on plugin refresh errors * fix(codex): use native plugin destructive policy * fix(codex): key plugin cache by api-key profiles * fix(codex): drop unshipped plugin fingerprint compat * fix(codex): let native app policy gate plugin tools * fix(codex): allow open-world plugin app tools * fix(codex): revalidate native plugin app bindings * fix(codex): preserve plugin binding on recheck failure * docs(codex): clarify plugin harness scope * fix(codex): return activation report state exhaustively * test(codex): refresh prompt snapshots after rebase * fix(codex): match namespaced plugin ids --- CHANGELOG.md | 2 + docs/cli/migrate.md | 46 +- docs/gateway/configuration-reference.md | 64 + docs/plugins/codex-harness.md | 109 +- extensions/codex/index.ts | 2 +- extensions/codex/openclaw.plugin.json | 56 + .../app-server/app-inventory-cache.test.ts | 137 ++ .../src/app-server/app-inventory-cache.ts | 225 ++++ .../codex/src/app-server/auth-bridge.test.ts | 111 ++ .../codex/src/app-server/auth-bridge.ts | 99 +- .../codex/src/app-server/config.test.ts | 87 ++ extensions/codex/src/app-server/config.ts | 144 +- .../src/app-server/elicitation-bridge.test.ts | 231 ++++ .../src/app-server/elicitation-bridge.ts | 224 ++- .../src/app-server/plugin-activation.test.ts | 319 +++++ .../codex/src/app-server/plugin-activation.ts | 275 ++++ .../src/app-server/plugin-inventory.test.ts | 346 +++++ .../codex/src/app-server/plugin-inventory.ts | 346 +++++ .../app-server/plugin-thread-config.test.ts | 732 ++++++++++ .../src/app-server/plugin-thread-config.ts | 389 ++++++ extensions/codex/src/app-server/protocol.ts | 123 +- .../codex/src/app-server/run-attempt.test.ts | 1198 +++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 124 +- .../src/app-server/session-binding.test.ts | 63 + .../codex/src/app-server/session-binding.ts | 77 +- .../codex/src/app-server/thread-lifecycle.ts | 90 +- extensions/codex/src/conversation-binding.ts | 8 +- extensions/codex/src/migration/apply.ts | 241 +++- extensions/codex/src/migration/plan.ts | 221 ++- .../codex/src/migration/provider.test.ts | 513 ++++++- extensions/codex/src/migration/provider.ts | 14 +- extensions/codex/src/migration/source.ts | 122 +- src/cli/program/register.migrate.ts | 45 +- src/commands/migrate.test.ts | 77 ++ src/commands/migrate.ts | 12 +- src/commands/migrate/apply.ts | 7 +- src/commands/migrate/selection.test.ts | 162 +++ src/commands/migrate/selection.ts | 191 +++ src/commands/migrate/types.ts | 1 + src/config/config.plugin-validation.test.ts | 43 + 40 files changed, 7198 insertions(+), 78 deletions(-) create mode 100644 extensions/codex/src/app-server/app-inventory-cache.test.ts create mode 100644 extensions/codex/src/app-server/app-inventory-cache.ts create mode 100644 extensions/codex/src/app-server/plugin-activation.test.ts create mode 100644 extensions/codex/src/app-server/plugin-activation.ts create mode 100644 extensions/codex/src/app-server/plugin-inventory.test.ts create mode 100644 extensions/codex/src/app-server/plugin-inventory.ts create mode 100644 extensions/codex/src/app-server/plugin-thread-config.test.ts create mode 100644 extensions/codex/src/app-server/plugin-thread-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 569d6067e3e..2588c3210d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash. - Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd. +- Codex/plugins: enable migrated source-installed `openai-curated` Codex plugins in the same Codex harness thread with explicit `codexPlugins` config, cached app readiness, and fail-closed destructive-action policy. Thanks @kevinslin. +- Codex/plugins: enforce native plugin destructive-action policy with Codex app-level `destructive_enabled` config instead of OpenClaw-maintained per-tool deny lists, leave plugin app `open_world_enabled` on by default, and invalidate existing plugin app thread bindings so old generated app config is rebuilt. Thanks @kevinslin. - PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement. - Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc. - ACPX/Codex: preserve trusted Codex project declarations when launching isolated Codex ACP sessions, avoiding interactive trust prompts in headless runs. Thanks @Stedyclaw. diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 85e0af66520..220c861ac79 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -21,9 +21,11 @@ openclaw migrate list openclaw migrate claude --dry-run openclaw migrate codex --dry-run openclaw migrate codex --skill gog-vault77-google-workspace +openclaw migrate codex --plugin google-calendar --dry-run openclaw migrate hermes --dry-run openclaw migrate hermes openclaw migrate apply codex --yes --skill gog-vault77-google-workspace +openclaw migrate apply codex --yes --plugin google-calendar openclaw migrate apply codex --yes openclaw migrate apply claude --yes openclaw migrate apply hermes --yes @@ -54,6 +56,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes Select one skill copy item by skill name or item id. Repeat the flag to migrate multiple skills. When omitted, interactive Codex migrations show a checkbox selector and non-interactive migrations keep all planned skills. + + Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. This only applies to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory. + Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists. @@ -129,20 +134,51 @@ openclaw migrate codex --dry-run --skill gog-vault77-google-workspace openclaw migrate apply codex --yes --skill gog-vault77-google-workspace ``` +Use `--plugin ` to limit native Codex plugin migration to one or more +source-installed curated plugins: + +```bash +openclaw migrate codex --dry-run --plugin google-calendar +openclaw migrate apply codex --yes --plugin google-calendar +``` + ### What Codex imports - Codex CLI skill directories under `$CODEX_HOME/skills`, excluding Codex's `.system` cache. - Personal AgentSkills under `$HOME/.agents/skills`, copied into the current OpenClaw agent workspace when you want per-agent ownership. +- Source-installed `openai-curated` Codex plugins discovered through Codex + app-server `plugin/list`. Apply calls app-server `plugin/install` for each + selected plugin, even if the target app-server already reports that plugin as + installed and enabled. Migrated Codex plugins are usable only in sessions that + select the native Codex harness; they are not exposed to Pi, normal OpenAI + provider runs, ACP conversation bindings, or other harnesses. ### Manual-review Codex state -Codex native plugins, `config.toml`, and native `hooks/hooks.json` are not -activated automatically. Plugins may expose MCP servers, apps, hooks, or other -executable behavior, so the provider reports them for review instead of loading -them into OpenClaw. Config and hook files are copied into the migration report -for manual review. +Codex `config.toml`, native `hooks/hooks.json`, non-curated marketplaces, and +cached plugin bundles that are not source-installed curated plugins are not +activated automatically. They are copied or reported in the migration report for +manual review. + +For migrated source-installed curated plugins, apply writes: + +- `plugins.entries.codex.enabled: true` +- `plugins.entries.codex.config.codexPlugins.enabled: true` +- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions: false` +- one explicit plugin entry with `marketplaceName: "openai-curated"` and + `pluginName` for each selected plugin + +Migration never writes `plugins["*"]` and never stores local marketplace cache +paths. Auth-required installs are reported on the affected plugin item with +`status: "skipped"`, `reason: "auth_required"`, and sanitized app identifiers. +Their explicit config entries are written disabled until you reauthorize and +enable them. Other install failures are item-scoped `error` results. + +If Codex app-server plugin inventory is unavailable during planning, migration +falls back to cached bundle advisory items instead of failing the whole +migration. ## Hermes provider diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 3eada9e6c54..ea05b97fed5 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -200,6 +200,70 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). - Channel plugin account/runtime settings live under `channels.` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry. + +### Codex harness plugin config + +The bundled `codex` plugin owns native Codex app-server harness settings under +`plugins.entries.codex.config`. See [Codex harness](/plugins/codex-harness) for +the full runtime model. + +`codexPlugins` applies only to sessions that select the native Codex harness. +It does not enable Codex plugins for Pi, normal OpenAI provider runs, ACP +conversation bindings, or any non-Codex harness. + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + allow_destructive_actions: false, + }, + }, + }, + }, + }, + }, + }, +} +``` + +- `plugins.entries.codex.config.codexPlugins.enabled`: enables native Codex + plugin/app support for the Codex harness. Default: `false`. +- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions`: + default destructive-action policy for migrated plugin app elicitations. + Default: `false`. +- `plugins.entries.codex.config.codexPlugins.plugins..enabled`: enables a + migrated plugin entry when global `codexPlugins.enabled` is also true. + Default: `true` for explicit entries. +- `plugins.entries.codex.config.codexPlugins.plugins..marketplaceName`: + stable marketplace identity. V1 only supports `"openai-curated"`. +- `plugins.entries.codex.config.codexPlugins.plugins..pluginName`: stable + Codex plugin identity from migration, for example `"google-calendar"`. +- `plugins.entries.codex.config.codexPlugins.plugins..allow_destructive_actions`: + per-plugin destructive-action override. When omitted, the global + `allow_destructive_actions` value is used. + +`codexPlugins.enabled` is the global enablement directive. Explicit plugin +entries written by migration are the durable install and repair eligibility set. +`plugins["*"]` is not supported, there is no `install` switch, and local +`marketplacePath` values are intentionally not config fields because they are +host-specific. + +`app/list` readiness checks are cached for one hour and refreshed +asynchronously when stale. Codex thread app config is computed at Codex harness +session establishment, not on every turn; use `/new`, `/reset`, or a gateway +restart after changing native plugin config. + - `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings. - `apiKey`: Firecrawl API key (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var. - `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`; self-hosted overrides must target private/internal endpoints). diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index c385102a390..49ddfc1df87 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -563,9 +563,11 @@ openclaw migrate apply codex --yes ``` The Codex migration provider copies skills into the current OpenClaw agent -workspace. Codex native plugins, hooks, and config files are reported or archived -for manual review instead of being activated automatically, because they can -execute commands, expose MCP servers, or carry credentials. +workspace. For source-installed `openai-curated` Codex plugins, migration also +calls Codex app-server `plugin/install` and records explicit native plugin +config under `plugins.entries.codex.config.codexPlugins`. Codex config files, +hooks, and cached plugin bundles that are not source-installed curated plugins +remain report-only manual-review items. Auth is selected in this order: @@ -629,6 +631,7 @@ Supported top-level Codex plugin fields: | `codexDynamicToolsProfile` | `"native-first"` | Use `"openclaw-compat"` to expose the full OpenClaw dynamic tool set to Codex app-server. | | `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. | | `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | +| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. | Supported `appServer` fields: @@ -684,6 +687,106 @@ Environment overrides remain available for local testing: preferred for repeatable deployments because it keeps the plugin behavior in the same reviewed file as the rest of the Codex harness setup. +## Native Codex plugins + +Native Codex plugin support uses Codex app-server's own app and plugin +capabilities in the same Codex thread as the OpenClaw harness turn. OpenClaw +does not translate Codex plugins into synthetic `codex_plugin_*` OpenClaw +dynamic tools. That keeps plugin calls in the native Codex transcript and avoids +starting a second ephemeral Codex thread for each plugin invocation. + +Codex plugins only work when the selected OpenClaw agent runtime is the native +Codex harness. The `codexPlugins` config has no effect on Pi runs, normal +OpenAI provider runs, ACP conversation bindings, or other harnesses, because +those paths do not create Codex app-server threads with native `apps` config. + +V1 support is intentionally narrow: + +- Only `openai-curated` plugins that were already installed in the source Codex + app-server inventory are migration-eligible. +- Migration writes explicit plugin identities with `marketplaceName` and + `pluginName`; it does not write local `marketplacePath` cache paths. +- `codexPlugins.enabled` is the global enablement switch. There is no + `plugins["*"]` wildcard and no config key that grants arbitrary install + authority. +- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config files + are preserved in the migration report for manual review. + +Example migrated config: + +```json5 +{ + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }, + }, + }, + }, +} +``` + +Thread app config is computed when OpenClaw establishes a Codex harness session +or replaces a stale Codex thread binding. It is not recomputed on every turn. +After changing `codexPlugins`, use `/new`, `/reset`, or restart the gateway so +future Codex harness sessions start with the updated app set. + +OpenClaw reads Codex app inventory through app-server `app/list`, caches it for +one hour, and refreshes stale or missing entries asynchronously. A plugin app is +exposed only when OpenClaw can map it back to the migrated plugin through stable +ownership: an exact app id from plugin detail, a known MCP server name, or +unique stable metadata. Display-name-only or ambiguous ownership is excluded +until the next inventory refresh proves ownership. + +Plugin-owned app tools use Codex's native app configuration. OpenClaw injects a +restrictive `config.apps` patch for the Codex thread: `_default` is disabled and +only apps owned by enabled migrated plugins are enabled. OpenClaw sets +app-level `destructive_enabled` from the effective global/per-plugin +`allow_destructive_actions` policy and lets Codex enforce destructive tool +metadata from its native app tool annotations. Plugin apps are emitted with +`open_world_enabled: true`; OpenClaw does not expose a separate plugin +open-world policy knob. OpenClaw does not maintain per-plugin destructive +tool-name deny lists. Tool approval mode is prompted by default for plugin +apps, because OpenClaw does not have an interactive app-elicitation UI in this +same-thread path. + +Destructive plugin elicitations fail closed by default: + +- Global `allow_destructive_actions` defaults to `false`. +- Per-plugin `allow_destructive_actions` overrides the global policy for that + plugin. +- When policy is `false`, OpenClaw returns a deterministic decline. +- When policy is `true`, OpenClaw auto-accepts only safe schemas it can map to + an approval response, such as a boolean approve field. +- Missing plugin identity, ambiguous ownership, a missing turn id, a wrong turn + id, or an unsafe elicitation schema declines instead of prompting. + +Common diagnostics: + +- `auth_required`: migration installed the plugin but one of its apps still + needs authentication. The explicit plugin entry is written disabled until you + reauthorize and enable it. +- `marketplace_missing` or `plugin_missing`: the target Codex app-server cannot + see the expected `openai-curated` marketplace or plugin. +- `app_inventory_missing` or `app_inventory_stale`: app readiness came from an + empty or stale cache; OpenClaw schedules an async refresh and excludes plugin + apps until ownership/readiness is known. +- `app_ownership_ambiguous`: app inventory only matched by display name, so the + app is not exposed to the Codex thread. + ## Computer use Computer Use is covered in its own setup guide: diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index f37611cab6b..0467940f0dc 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -29,7 +29,7 @@ export default definePluginEntry({ api.registerMediaUnderstandingProvider( buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }), ); - api.registerMigrationProvider(buildCodexMigrationProvider()); + api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime })); api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig })); api.on("inbound_claim", (event, ctx) => handleCodexConversationInboundClaim(event, ctx, { diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 2495f3aad38..e13aebf3f6f 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -96,6 +96,42 @@ } } }, + "codexPlugins": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "allow_destructive_actions": { + "type": "boolean", + "default": false + }, + "plugins": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "marketplaceName": { + "type": "string", + "enum": ["openai-curated"] + }, + "pluginName": { + "type": "string" + }, + "allow_destructive_actions": { + "type": "boolean" + } + } + } + } + } + }, "appServer": { "type": "object", "additionalProperties": false, @@ -234,6 +270,26 @@ "help": "MCP server name exposed by the Computer Use plugin.", "advanced": true }, + "codexPlugins": { + "label": "Native Codex Plugins", + "help": "Controls native Codex plugin availability for Codex harness turns.", + "advanced": true + }, + "codexPlugins.enabled": { + "label": "Enable Native Plugins", + "help": "Expose explicit migrated Codex plugin entries to Codex harness turns.", + "advanced": true + }, + "codexPlugins.allow_destructive_actions": { + "label": "Allow Destructive Plugin Actions", + "help": "Default policy for plugin app write or destructive action elicitations. Defaults to false.", + "advanced": true + }, + "codexPlugins.plugins": { + "label": "Migrated Plugin Entries", + "help": "Explicit migration-authored plugin entries. The wildcard key * is not supported.", + "advanced": true + }, "appServer": { "label": "App Server", "help": "Runtime controls for connecting to Codex app-server.", diff --git a/extensions/codex/src/app-server/app-inventory-cache.test.ts b/extensions/codex/src/app-server/app-inventory-cache.test.ts new file mode 100644 index 00000000000..0c1b74d768c --- /dev/null +++ b/extensions/codex/src/app-server/app-inventory-cache.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import { CodexAppInventoryCache, buildCodexAppInventoryCacheKey } from "./app-inventory-cache.js"; +import type { v2 } from "./protocol.js"; + +describe("Codex app inventory cache", () => { + it("returns missing while scheduling one coalesced app/list refresh", async () => { + const cache = new CodexAppInventoryCache({ ttlMs: 100 }); + const request = vi.fn(async (_method: "app/list", params: v2.AppsListParams) => { + return { + data: [app(params.cursor ? "app-2" : "app-1")], + nextCursor: params.cursor ? null : "next", + } satisfies v2.AppsListResponse; + }); + + const key = buildCodexAppInventoryCacheKey({ codexHome: "/codex", authProfileId: "work" }); + const read = cache.read({ key, request, nowMs: 0 }); + expect(read.state).toBe("missing"); + expect(read.refreshScheduled).toBe(true); + + const snapshot = await cache.refreshNow({ key, request, nowMs: 0 }); + expect(snapshot.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]); + expect(request).toHaveBeenCalledTimes(2); + + const fresh = cache.read({ key, request, nowMs: 50 }); + expect(fresh.state).toBe("fresh"); + expect(fresh.refreshScheduled).toBe(false); + expect(fresh.snapshot?.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]); + }); + + it("uses stale inventory for the current read while refreshing asynchronously", async () => { + const cache = new CodexAppInventoryCache({ ttlMs: 10 }); + const request = vi.fn(async () => { + return { + data: [app(`app-${request.mock.calls.length}`)], + nextCursor: null, + } satisfies v2.AppsListResponse; + }); + const key = "runtime"; + await cache.refreshNow({ key, request, nowMs: 0 }); + + const stale = cache.read({ key, request, nowMs: 11 }); + expect(stale.state).toBe("stale"); + expect(stale.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]); + expect(stale.refreshScheduled).toBe(true); + + const refreshed = await cache.refreshNow({ key, request, nowMs: 11 }); + expect(refreshed.apps.map((item) => item.id)).toEqual(["app-2"]); + }); + + it("records refresh errors without discarding the last successful snapshot", async () => { + const cache = new CodexAppInventoryCache({ ttlMs: 1 }); + const key = "runtime"; + await cache.refreshNow({ + key, + nowMs: 0, + request: async () => ({ data: [app("app-1")], nextCursor: null }), + }); + + await expect( + cache.refreshNow({ + key, + nowMs: 2, + request: async () => { + throw new Error("app list failed"); + }, + }), + ).rejects.toThrow("app list failed"); + + const read = cache.read({ + key, + nowMs: 2, + request: async () => ({ data: [app("app-2")], nextCursor: null }), + }); + expect(read.snapshot?.apps.map((item) => item.id)).toEqual(["app-1"]); + expect(read.diagnostic?.message).toBe("app list failed"); + }); + + it("forces a post-install refresh past an older in-flight app/list", async () => { + const cache = new CodexAppInventoryCache({ ttlMs: 1_000 }); + const key = "runtime"; + let resolveStale: ((response: v2.AppsListResponse) => void) | undefined; + let resolveFresh: ((response: v2.AppsListResponse) => void) | undefined; + const request = vi.fn( + async (_method: "app/list", params: v2.AppsListParams): Promise => { + expect(params.forceRefetch).toBe(request.mock.calls.length === 2); + return await new Promise((resolve) => { + if (request.mock.calls.length === 1) { + resolveStale = resolve; + } else { + resolveFresh = resolve; + } + }); + }, + ); + + const staleRead = cache.read({ key, request, nowMs: 0 }); + expect(staleRead.state).toBe("missing"); + expect(staleRead.refreshScheduled).toBe(true); + + cache.invalidate(key, "plugin installed", 1); + const forcedRead = cache.read({ key, request, nowMs: 1, forceRefetch: true }); + expect(forcedRead.state).toBe("missing"); + expect(forcedRead.refreshScheduled).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + + const forced = cache.refreshNow({ key, request, nowMs: 1 }); + resolveFresh?.({ data: [app("fresh-app")], nextCursor: null }); + await expect(forced).resolves.toMatchObject({ + apps: [expect.objectContaining({ id: "fresh-app" })], + }); + + resolveStale?.({ data: [app("stale-app")], nextCursor: null }); + await Promise.resolve(); + + const freshRead = cache.read({ key, request, nowMs: 2 }); + expect(freshRead.state).toBe("fresh"); + expect(freshRead.snapshot?.apps.map((item) => item.id)).toEqual(["fresh-app"]); + }); +}); + +function app(id: string): v2.AppInfo { + return { + id, + name: id, + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: true, + pluginDisplayNames: [], + }; +} diff --git a/extensions/codex/src/app-server/app-inventory-cache.ts b/extensions/codex/src/app-server/app-inventory-cache.ts new file mode 100644 index 00000000000..800ad42ca51 --- /dev/null +++ b/extensions/codex/src/app-server/app-inventory-cache.ts @@ -0,0 +1,225 @@ +import type { v2 } from "./protocol.js"; + +export const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000; + +export type CodexAppInventoryRequest = ( + method: "app/list", + params: v2.AppsListParams, +) => Promise; + +export type CodexAppInventoryCacheKeyInput = { + codexHome?: string; + endpoint?: string; + authProfileId?: string; + accountId?: string; + envApiKeyFingerprint?: string; + appServerVersion?: string; +}; + +export type CodexAppInventoryCacheDiagnostic = { + message: string; + atMs: number; +}; + +export type CodexAppInventorySnapshot = { + key: string; + apps: v2.AppInfo[]; + fetchedAtMs: number; + expiresAtMs: number; + revision: number; + lastError?: CodexAppInventoryCacheDiagnostic; +}; + +export type CodexAppInventoryReadState = "fresh" | "stale" | "missing"; + +export type CodexAppInventoryCacheRead = { + state: CodexAppInventoryReadState; + key: string; + revision: number; + snapshot?: CodexAppInventorySnapshot; + refreshScheduled: boolean; + diagnostic?: CodexAppInventoryCacheDiagnostic; +}; + +type CacheEntry = CodexAppInventorySnapshot & { + invalidated: boolean; +}; + +type RefreshParams = { + key: string; + request: CodexAppInventoryRequest; + nowMs?: number; + forceRefetch?: boolean; +}; + +export class CodexAppInventoryCache { + private readonly ttlMs: number; + private readonly entries = new Map(); + private readonly inFlight = new Map>(); + private readonly refreshTokens = new Map(); + private readonly diagnostics = new Map(); + private revision = 0; + + constructor(options: { ttlMs?: number } = {}) { + this.ttlMs = options.ttlMs ?? CODEX_APP_INVENTORY_CACHE_TTL_MS; + } + + read(params: RefreshParams): CodexAppInventoryCacheRead { + const nowMs = params.nowMs ?? Date.now(); + const entry = this.entries.get(params.key); + if (!entry) { + const refreshScheduled = this.scheduleRefresh(params); + return { + state: "missing", + key: params.key, + revision: this.revision, + refreshScheduled, + ...(this.diagnostics.get(params.key) + ? { diagnostic: this.diagnostics.get(params.key) } + : {}), + }; + } + + const state: CodexAppInventoryReadState = + entry.invalidated || entry.expiresAtMs <= nowMs ? "stale" : "fresh"; + const refreshScheduled = + state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params); + return { + state, + key: params.key, + revision: entry.revision, + snapshot: stripEntryState(entry), + refreshScheduled, + ...(entry.lastError ? { diagnostic: entry.lastError } : {}), + }; + } + + refreshNow(params: RefreshParams): Promise { + return this.refresh(params); + } + + invalidate(key: string, reason: string, nowMs = Date.now()): number { + this.revision += 1; + const diagnostic = { message: reason, atMs: nowMs }; + const entry = this.entries.get(key); + if (entry) { + entry.invalidated = true; + entry.lastError = diagnostic; + entry.revision = this.revision; + } else { + this.diagnostics.set(key, diagnostic); + } + return this.revision; + } + + clear(): void { + this.entries.clear(); + this.inFlight.clear(); + this.refreshTokens.clear(); + this.diagnostics.clear(); + this.revision = 0; + } + + getRevision(): number { + return this.revision; + } + + private scheduleRefresh(params: RefreshParams): boolean { + if (this.inFlight.has(params.key) && !params.forceRefetch) { + return true; + } + const promise = this.refresh(params); + this.inFlight.set(params.key, promise); + promise.catch(() => undefined); + return true; + } + + private async refresh(params: RefreshParams): Promise { + const existing = this.inFlight.get(params.key); + if (existing && !params.forceRefetch) { + return existing; + } + + const refreshToken = (this.refreshTokens.get(params.key) ?? 0) + 1; + this.refreshTokens.set(params.key, refreshToken); + const promise = this.refreshUncoalesced(params, refreshToken); + this.inFlight.set(params.key, promise); + try { + return await promise; + } finally { + if (this.inFlight.get(params.key) === promise) { + this.inFlight.delete(params.key); + } + } + } + + private async refreshUncoalesced( + params: RefreshParams, + refreshToken: number, + ): Promise { + const nowMs = params.nowMs ?? Date.now(); + try { + const apps = await listAllApps(params.request, params.forceRefetch ?? false); + this.revision += 1; + const snapshot: CodexAppInventorySnapshot = { + key: params.key, + apps, + fetchedAtMs: nowMs, + expiresAtMs: nowMs + this.ttlMs, + revision: this.revision, + }; + if (this.refreshTokens.get(params.key) === refreshToken) { + this.entries.set(params.key, { ...snapshot, invalidated: false }); + this.diagnostics.delete(params.key); + } + return snapshot; + } catch (error) { + const diagnostic = { + message: error instanceof Error ? error.message : String(error), + atMs: nowMs, + }; + this.diagnostics.set(params.key, diagnostic); + const entry = this.entries.get(params.key); + if (entry) { + entry.lastError = diagnostic; + } + throw error; + } + } +} + +export const defaultCodexAppInventoryCache = new CodexAppInventoryCache(); + +export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyInput): string { + return JSON.stringify({ + codexHome: input.codexHome ?? null, + endpoint: input.endpoint ?? null, + authProfileId: input.authProfileId ?? null, + accountId: input.accountId ?? null, + envApiKeyFingerprint: input.envApiKeyFingerprint ?? null, + appServerVersion: input.appServerVersion ?? null, + }); +} + +async function listAllApps( + request: CodexAppInventoryRequest, + forceRefetch: boolean, +): Promise { + const apps: v2.AppInfo[] = []; + let cursor: string | null | undefined; + do { + const response = await request("app/list", { + cursor, + limit: 100, + forceRefetch, + }); + apps.push(...response.data); + cursor = response.nextCursor; + } while (cursor); + return apps; +} + +function stripEntryState(entry: CacheEntry): CodexAppInventorySnapshot { + const { invalidated: _invalidated, ...snapshot } = entry; + return snapshot; +} diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 4c8adac90ad..d32ccd920df 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -11,6 +11,7 @@ import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions, refreshCodexAppServerAuthTokens, + resolveCodexAppServerAuthAccountCacheKey, resolveCodexAppServerHomeDir, resolveCodexAppServerNativeHomeDir, } from "./auth-bridge.js"; @@ -355,6 +356,116 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("fingerprints resolved API-key auth-profile secrets without exposing them", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "api_key", + provider: "openai-codex", + key: "first-secret-key", + }, + }); + const first = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "api_key", + provider: "openai-codex", + key: "second-secret-key", + }, + }); + const second = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(first).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/); + expect(second).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/); + expect(second).not.toBe(first); + expect(first).not.toContain("first-secret-key"); + expect(second).not.toContain("second-secret-key"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("fingerprints API-key auth-profile secret refs", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "api_key", + provider: "openai-codex", + keyRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TEST_KEY" }, + }, + }); + vi.stubEnv("OPENAI_CODEX_TEST_KEY", "first-ref-secret"); + const first = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + vi.stubEnv("OPENAI_CODEX_TEST_KEY", "second-ref-secret"); + const second = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(first).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/); + expect(second).toMatch(/^openai-codex:work:api_key:sha256:[a-f0-9]{64}$/); + expect(second).not.toBe(first); + expect(first).not.toContain("first-ref-secret"); + expect(second).not.toContain("second-ref-secret"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("fingerprints token auth-profile secret refs", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:work", + credential: { + type: "token", + provider: "openai-codex", + tokenRef: { source: "env", provider: "default", id: "OPENAI_CODEX_TEST_TOKEN" }, + email: "codex@example.test", + }, + }); + vi.stubEnv("OPENAI_CODEX_TEST_TOKEN", "first-ref-token"); + const first = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + vi.stubEnv("OPENAI_CODEX_TEST_TOKEN", "second-ref-token"); + const second = await resolveCodexAppServerAuthAccountCacheKey({ + agentDir, + authProfileId: "openai-codex:work", + }); + + expect(first).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/); + expect(second).toMatch(/^codex@example\.test:token:sha256:[a-f0-9]{64}$/); + expect(second).not.toBe(first); + expect(first).not.toContain("first-ref-token"); + expect(second).not.toContain("second-ref-token"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("applies an OpenAI Codex OAuth profile through app-server login", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 9bef9470dd1..f87671d15d7 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -10,6 +11,7 @@ import { resolvePersistedAuthProfileOwnerAgentDir, saveAuthProfileStore, type AuthProfileCredential, + type AuthProfileStore, type OAuthCredential, } from "openclaw/plugin-sdk/agent-runtime"; import type { CodexAppServerClient } from "./client.js"; @@ -93,6 +95,94 @@ export function resolveCodexAppServerAuthProfileIdForAgent(params: { }); } +export async function resolveCodexAppServerAuthAccountCacheKey(params: { + authProfileId?: string; + authProfileStore?: AuthProfileStore; + agentDir?: string; + config?: AuthProfileOrderConfig; +}): Promise { + const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {}); + const store = + params.authProfileStore ?? ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); + const profileId = resolveCodexAppServerAuthProfileId({ + authProfileId: params.authProfileId, + store, + config: params.config, + }); + if (!profileId) { + return undefined; + } + const credential = store.profiles[profileId]; + if (!credential || !isCodexAppServerAuthProvider(credential.provider, params.config)) { + return undefined; + } + if (credential.type === "api_key") { + const resolved = await resolveApiKeyForProfile({ + store, + profileId, + agentDir, + }); + const apiKey = resolved?.apiKey?.trim(); + return apiKey + ? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintApiKeyAuthProfileCacheKey(apiKey)}` + : resolveChatgptAccountId(profileId, credential); + } + if (credential.type === "token") { + const resolved = await resolveApiKeyForProfile({ + store, + profileId, + agentDir, + }); + const accessToken = resolved?.apiKey?.trim(); + return accessToken + ? `${resolveChatgptAccountId(profileId, credential)}:${fingerprintTokenAuthProfileCacheKey(accessToken)}` + : resolveChatgptAccountId(profileId, credential); + } + return resolveChatgptAccountId(profileId, credential); +} + +export function resolveCodexAppServerEnvApiKeyCacheKey(params: { + startOptions: Pick; + baseEnv?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}): string | undefined { + if (params.startOptions.transport !== "stdio") { + return undefined; + } + const env = resolveCodexAppServerSpawnEnv( + params.startOptions, + params.baseEnv ?? process.env, + params.platform ?? process.platform, + ); + const apiKey = readFirstNonEmptyEnvEntry(env, CODEX_APP_SERVER_API_KEY_ENV_VARS); + if (!apiKey) { + return undefined; + } + const hash = createHash("sha256"); + hash.update("openclaw:codex:app-server-env-api-key:v1"); + hash.update("\0"); + hash.update(apiKey.key); + hash.update("\0"); + hash.update(apiKey.value); + return `${apiKey.key}:sha256:${hash.digest("hex")}`; +} + +function fingerprintApiKeyAuthProfileCacheKey(apiKey: string): string { + const hash = createHash("sha256"); + hash.update("openclaw:codex:app-server-auth-profile-api-key:v1"); + hash.update("\0"); + hash.update(apiKey); + return `api_key:sha256:${hash.digest("hex")}`; +} + +function fingerprintTokenAuthProfileCacheKey(accessToken: string): string { + const hash = createHash("sha256"); + hash.update("openclaw:codex:app-server-auth-profile-token:v1"); + hash.update("\0"); + hash.update(accessToken); + return `token:sha256:${hash.digest("hex")}`; +} + export function resolveCodexAppServerHomeDir(agentDir: string): string { return path.join(path.resolve(agentDir), CODEX_APP_SERVER_HOME_DIRNAME); } @@ -367,10 +457,17 @@ function withClearedEnvironmentVariables( } function readFirstNonEmptyEnv(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined { + return readFirstNonEmptyEnvEntry(env, keys)?.value; +} + +function readFirstNonEmptyEnvEntry( + env: NodeJS.ProcessEnv, + keys: readonly string[], +): { key: string; value: string } | undefined { for (const key of keys) { const value = env[key]?.trim(); if (value) { - return value; + return { key, value }; } } return undefined; diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 7c835559f7e..90585e623aa 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from "vitest"; import { CODEX_APP_SERVER_CONFIG_KEYS, CODEX_COMPUTER_USE_CONFIG_KEYS, + CODEX_PLUGIN_ENTRY_CONFIG_KEYS, + CODEX_PLUGINS_CONFIG_KEYS, codexAppServerStartOptionsKey, readCodexPluginConfig, resolveCodexAppServerRuntimeOptions, resolveCodexComputerUseConfig, + resolveCodexPluginsPolicy, } from "./config.js"; describe("Codex app-server config", () => { @@ -154,6 +157,71 @@ describe("Codex app-server config", () => { }); }); + it("parses native Codex plugin policy without treating wildcard as supported config", () => { + const config = readCodexPluginConfig({ + appServer: { mode: "guardian" }, + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + allow_destructive_actions: true, + }, + slack: { + enabled: false, + marketplaceName: "openai-curated", + pluginName: "slack", + }, + }, + }, + }); + + expect(config.appServer?.mode).toBe("guardian"); + expect(config.codexPlugins?.enabled).toBe(true); + + const policy = resolveCodexPluginsPolicy(config); + expect(policy).toEqual({ + configured: true, + enabled: true, + allowDestructiveActions: false, + pluginPolicies: [ + { + configKey: "google-calendar", + marketplaceName: "openai-curated", + pluginName: "google-calendar", + enabled: true, + allowDestructiveActions: true, + }, + { + configKey: "slack", + marketplaceName: "openai-curated", + pluginName: "slack", + enabled: false, + allowDestructiveActions: false, + }, + ], + }); + }); + + it("rejects non-curated native plugin identities", () => { + const config = readCodexPluginConfig({ + codexPlugins: { + enabled: true, + plugins: { + gmail: { + marketplaceName: "custom-market", + pluginName: "gmail", + }, + }, + }, + }); + + expect(config.codexPlugins).toBeUndefined(); + expect(resolveCodexPluginsPolicy(config).pluginPolicies).toEqual([]); + }); + it("treats configured and environment commands as explicit overrides", () => { expect( resolveCodexAppServerRuntimeOptions({ @@ -392,6 +460,10 @@ describe("Codex app-server config", () => { properties: { appServer: { properties: Record }; computerUse: { properties: Record }; + codexPlugins: { + properties: Record; + additionalProperties: boolean; + }; }; }; uiHints: Record; @@ -411,6 +483,21 @@ describe("Codex app-server config", () => { for (const key of CODEX_COMPUTER_USE_CONFIG_KEYS) { expect(manifest.uiHints[`computerUse.${key}`]).toBeTruthy(); } + const codexPluginsProperties = manifest.configSchema.properties.codexPlugins; + const codexPluginsManifestKeys = Object.keys(codexPluginsProperties.properties).toSorted(); + expect(codexPluginsManifestKeys).toEqual([...CODEX_PLUGINS_CONFIG_KEYS].toSorted()); + expect(codexPluginsProperties.additionalProperties).toBe(false); + for (const key of CODEX_PLUGINS_CONFIG_KEYS) { + expect(manifest.uiHints[`codexPlugins.${key}`]).toBeTruthy(); + } + const pluginEntryProperties = ( + codexPluginsProperties.properties.plugins as { + additionalProperties: { properties: Record }; + } + ).additionalProperties.properties; + expect(Object.keys(pluginEntryProperties).toSorted()).toEqual( + [...CODEX_PLUGIN_ENTRY_CONFIG_KEYS].toSorted(), + ); }); it("does not schema-default mode-derived policy fields", async () => { diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index e6e45573312..2ffac0d81cc 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -7,11 +7,25 @@ const START_OPTIONS_KEY_SECRET = randomBytes(32); type CodexAppServerTransportMode = "stdio" | "websocket"; type CodexAppServerPolicyMode = "yolo" | "guardian"; export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted"; +export type CodexAppServerEffectiveApprovalPolicy = + | CodexAppServerApprovalPolicy + | { + granular: { + mcp_elicitations: boolean; + rules: boolean; + sandbox_approval: boolean; + request_permissions?: boolean; + skill_approval?: boolean; + }; + }; export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access"; type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env"; type CodexDynamicToolsProfile = "native-first" | "openclaw-compat"; export type CodexDynamicToolsLoading = "searchable" | "direct"; +export type CodexPluginDestructivePolicy = boolean; + +export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated"; export type CodexComputerUseConfig = { enabled?: boolean; @@ -35,6 +49,34 @@ export type ResolvedCodexComputerUseConfig = { marketplaceName?: string; }; +export type CodexPluginEntryConfig = { + enabled?: boolean; + marketplaceName?: string; + pluginName?: string; + allow_destructive_actions?: CodexPluginDestructivePolicy; +}; + +export type CodexPluginsConfig = { + enabled?: boolean; + allow_destructive_actions?: CodexPluginDestructivePolicy; + plugins?: Record; +}; + +export type ResolvedCodexPluginPolicy = { + configKey: string; + marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + pluginName: string; + enabled: boolean; + allowDestructiveActions: CodexPluginDestructivePolicy; +}; + +export type ResolvedCodexPluginsPolicy = { + configured: boolean; + enabled: boolean; + allowDestructiveActions: CodexPluginDestructivePolicy; + pluginPolicies: ResolvedCodexPluginPolicy[]; +}; + export type CodexAppServerStartOptions = { transport: CodexAppServerTransportMode; command: string; @@ -51,7 +93,7 @@ export type CodexAppServerRuntimeOptions = { start: CodexAppServerStartOptions; requestTimeoutMs: number; turnCompletionIdleTimeoutMs: number; - approvalPolicy: CodexAppServerApprovalPolicy; + approvalPolicy: CodexAppServerEffectiveApprovalPolicy; sandbox: CodexAppServerSandboxMode; approvalsReviewer: CodexAppServerApprovalsReviewer; serviceTier?: CodexServiceTier; @@ -66,6 +108,7 @@ export type CodexPluginConfig = { timeoutMs?: number; }; computerUse?: CodexComputerUseConfig; + codexPlugins?: CodexPluginsConfig; appServer?: { mode?: CodexAppServerPolicyMode; transport?: CodexAppServerTransportMode; @@ -114,6 +157,19 @@ export const CODEX_COMPUTER_USE_CONFIG_KEYS = [ "mcpServerName", ] as const; +export const CODEX_PLUGINS_CONFIG_KEYS = [ + "enabled", + "allow_destructive_actions", + "plugins", +] as const; + +export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [ + "enabled", + "marketplaceName", + "pluginName", + "allow_destructive_actions", +] as const; + const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use"; const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000; @@ -137,6 +193,23 @@ const codexAppServerServiceTierSchema = z ) .optional(); +const codexPluginEntryConfigSchema = z + .object({ + enabled: z.boolean().optional(), + marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(), + pluginName: z.string().trim().min(1).optional(), + allow_destructive_actions: z.boolean().optional(), + }) + .strict(); + +const codexPluginsConfigSchema = z + .object({ + enabled: z.boolean().optional(), + allow_destructive_actions: z.boolean().optional(), + plugins: z.record(z.string(), codexPluginEntryConfigSchema).optional(), + }) + .strict(); + const codexPluginConfigSchema = z .object({ codexDynamicToolsProfile: codexDynamicToolsProfileSchema.optional(), @@ -162,6 +235,7 @@ const codexPluginConfigSchema = z }) .strict() .optional(), + codexPlugins: z.unknown().optional(), appServer: z .object({ mode: codexAppServerPolicyModeSchema.optional(), @@ -187,7 +261,44 @@ const codexPluginConfigSchema = z export function readCodexPluginConfig(value: unknown): CodexPluginConfig { const parsed = codexPluginConfigSchema.safeParse(value); - return parsed.success ? parsed.data : {}; + if (!parsed.success) { + return {}; + } + const { codexPlugins: rawCodexPlugins, ...config } = parsed.data; + const plugins = codexPluginsConfigSchema.safeParse(rawCodexPlugins); + if (!plugins.success) { + return config; + } + return { ...config, ...(plugins.data ? { codexPlugins: plugins.data } : {}) }; +} + +export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodexPluginsPolicy { + const config = readCodexPluginConfig(pluginConfig).codexPlugins; + const configured = config !== undefined; + const enabled = config?.enabled === true; + const allowDestructiveActions = config?.allow_destructive_actions ?? false; + const pluginPolicies = Object.entries(config?.plugins ?? {}) + .flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => { + if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) { + return []; + } + return [ + { + configKey, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: entry.pluginName, + enabled: enabled && entry.enabled !== false, + allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions, + }, + ]; + }) + .toSorted((left, right) => left.configKey.localeCompare(right.configKey)); + return { + configured, + enabled, + allowDestructiveActions, + pluginPolicies, + }; } export function resolveCodexAppServerRuntimeOptions( @@ -354,6 +465,35 @@ export function codexSandboxPolicyForTurn( }; } +export function withMcpElicitationsApprovalPolicy( + policy: CodexAppServerEffectiveApprovalPolicy, +): CodexAppServerEffectiveApprovalPolicy { + if (typeof policy !== "string") { + return { + granular: { + ...policy.granular, + mcp_elicitations: true, + }, + }; + } + if (policy === "never") { + return { + granular: { + mcp_elicitations: true, + rules: false, + sandbox_approval: false, + }, + }; + } + return { + granular: { + mcp_elicitations: true, + rules: true, + sandbox_approval: true, + }, + }; +} + function resolveTransport(value: unknown): CodexAppServerTransportMode { return value === "websocket" ? "websocket" : "stdio"; } diff --git a/extensions/codex/src/app-server/elicitation-bridge.test.ts b/extensions/codex/src/app-server/elicitation-bridge.test.ts index 1139f2514b1..fde04a7d949 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.test.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.test.ts @@ -73,6 +73,73 @@ function buildCurrentCodexApprovalElicitation() { }; } +function buildPluginApprovalElicitation(overrides: Record = {}) { + return { + threadId: "thread-1", + turnId: "turn-1", + serverName: "google-calendar-mcp", + mode: "form", + message: "Approve app action?", + _meta: { + app_id: "google-calendar-app", + }, + requestedSchema: { + type: "object", + properties: { + approve: { + type: "boolean", + title: "Approve this app action", + }, + }, + required: ["approve"], + }, + ...overrides, + }; +} + +function createPluginAppPolicyContext( + params: { + allowDestructiveActions?: boolean; + apps?: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>; + } = {}, +) { + const apps = params.apps ?? [ + { + appId: "google-calendar-app", + pluginName: "google-calendar", + mcpServerNames: ["google-calendar-mcp"], + }, + ]; + return { + fingerprint: "plugin-policy-1", + apps: Object.fromEntries( + apps.map((app) => [ + app.appId, + { + configKey: app.pluginName, + marketplaceName: "openai-curated" as const, + pluginName: app.pluginName, + allowDestructiveActions: params.allowDestructiveActions ?? false, + mcpServerNames: app.mcpServerNames, + }, + ]), + ), + pluginAppIds: Object.fromEntries( + apps.map((app) => [app.pluginName, appsForPlugin(apps, app.pluginName)]), + ), + }; +} + +function appsForPlugin( + apps: Array<{ appId: string; pluginName: string; mcpServerNames: string[] }>, + pluginName: string, +): string[] { + return apps + .filter((app) => app.pluginName === pluginName) + .map((app) => app.appId) + .toSorted(); +} + describe("Codex app-server elicitation bridge", () => { beforeEach(() => { mockCallGatewayTool.mockReset(); @@ -449,6 +516,170 @@ describe("Codex app-server elicitation bridge", () => { }); }); + it("declines plugin app elicitations when destructive actions are disabled", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation(), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: false }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("accepts safely mapped plugin app elicitations when destructive actions are enabled", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation(), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toEqual({ + action: "accept", + content: { approve: true }, + _meta: null, + }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("declines plugin app elicitations that are missing active turn correlation", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ turnId: null }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("does not answer plugin app elicitations for a different active turn", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ turnId: "turn-2" }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toBeUndefined(); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("declines plugin app elicitations with ambiguous server ownership", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ + serverName: "shared-mcp", + _meta: {}, + }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ + allowDestructiveActions: true, + apps: [ + { + appId: "calendar-app-1", + pluginName: "google-calendar", + mcpServerNames: ["shared-mcp"], + }, + { + appId: "calendar-app-2", + pluginName: "google-calendar", + mcpServerNames: ["shared-mcp"], + }, + ], + }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("declines plugin app elicitations that only match display names", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ + serverName: "unknown-mcp", + _meta: { + connector_name: "Google Calendar", + }, + }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("declines plugin-scoped elicitations when policy context is missing", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation(), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("declines plugin app elicitations with unmappable schemas", async () => { + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildPluginApprovalElicitation({ + requestedSchema: { + type: "object", + properties: { + template: { + type: "string", + enum: ["simple", "detailed"], + }, + }, + required: ["template"], + }, + }), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toEqual({ action: "decline", content: null, _meta: null }); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + }); + + it("keeps unrelated MCP approval elicitations on the existing approval bridge", async () => { + mockCallGatewayTool + .mockResolvedValueOnce({ id: "plugin:approval-unrelated", status: "accepted" }) + .mockResolvedValueOnce({ id: "plugin:approval-unrelated", decision: "allow-once" }); + + const result = await handleCodexAppServerElicitationRequest({ + requestParams: buildCurrentCodexApprovalElicitation(), + paramsForRun: createParams(), + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: createPluginAppPolicyContext({ allowDestructiveActions: true }), + }); + + expect(result).toEqual({ + action: "accept", + content: null, + _meta: null, + }); + expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([ + "plugin.approval.request", + "plugin.approval.waitDecision", + ]); + }); + it("ignores unscoped approval elicitations without the active thread id", async () => { const { turnId, serverName, mode, message, _meta, requestedSchema } = buildCurrentCodexApprovalElicitation(); diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index a91aa8a4305..6e781dac6ea 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -10,6 +10,10 @@ import { type AppServerApprovalOutcome, waitForPluginApprovalDecision, } from "./plugin-approval-roundtrip.js"; +import type { + PluginAppPolicyContext, + PluginAppPolicyContextEntry, +} from "./plugin-thread-config.js"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; type ApprovalPropertyContext = { @@ -25,12 +29,26 @@ type BridgeableApprovalElicitation = { meta: JsonObject; }; +type PluginElicitationResolution = + | { kind: "not_plugin" } + | { kind: "matched"; entry: PluginAppPolicyContextEntry } + | { kind: "decline"; reason: string }; + const MCP_TOOL_APPROVAL_KIND = "mcp_tool_call"; const MCP_TOOL_APPROVAL_KIND_KEY = "codex_approval_kind"; const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY = "connector_name"; const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY = "tool_title"; const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY = "tool_description"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY = "tool_params_display"; +const PLUGIN_APP_ID_META_KEYS = ["app_id", "appId", "codex_app_id", "codexAppId"]; +const PLUGIN_NAME_META_KEYS = ["plugin_name", "pluginName", "codex_plugin_name", "codexPluginName"]; +const PLUGIN_CONFIG_KEY_META_KEYS = ["config_key", "configKey", "codex_config_key"]; +const PLUGIN_MARKETPLACE_NAME_META_KEYS = [ + "marketplace_name", + "marketplaceName", + "codex_marketplace_name", + "codexMarketplaceName", +]; const MAX_DISPLAY_PARAM_ENTRIES = 8; const MAX_DISPLAY_PARAM_VALUE_LENGTH = 120; const MAX_DISPLAY_VALUE_ARRAY_ITEMS = 8; @@ -59,12 +77,35 @@ export async function handleCodexAppServerElicitationRequest(params: { paramsForRun: EmbeddedRunAttemptParams; threadId: string; turnId: string; + pluginAppPolicyContext?: PluginAppPolicyContext; signal?: AbortSignal; }): Promise { const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined; - if (!matchesCurrentTurn(requestParams, params.threadId, params.turnId)) { + if (!requestParams) { return undefined; } + if (!matchesCurrentThread(requestParams, params.threadId)) { + return undefined; + } + if (turnIdMismatches(requestParams, params.turnId)) { + return undefined; + } + const pluginResolution = resolvePluginElicitation({ + requestParams, + pluginAppPolicyContext: params.pluginAppPolicyContext, + }); + if (pluginResolution.kind !== "not_plugin") { + if (pluginResolution.kind === "decline") { + logPluginElicitationDecline(pluginResolution.reason, requestParams); + return declineElicitationResponse(); + } + if (!hasExactTurnId(requestParams, params.turnId)) { + logPluginElicitationDecline("missing_active_turn", requestParams); + return declineElicitationResponse(); + } + return buildPluginPolicyElicitationResponse(pluginResolution.entry, requestParams); + } + const approvalPrompt = readBridgeableApprovalElicitation(requestParams); if (!approvalPrompt) { return undefined; @@ -79,23 +120,174 @@ export async function handleCodexAppServerElicitationRequest(params: { return buildElicitationResponse(approvalPrompt.requestedSchema, approvalPrompt.meta, outcome); } -function matchesCurrentTurn( - requestParams: JsonObject | undefined, - threadId: string, - turnId: string, -): boolean { +function matchesCurrentThread(requestParams: JsonObject | undefined, threadId: string): boolean { if (!requestParams) { return false; } const requestThreadId = readString(requestParams, "threadId"); - if (requestThreadId !== threadId) { + return requestThreadId === threadId; +} + +function turnIdMismatches(requestParams: JsonObject | undefined, turnId: string): boolean { + const rawTurnId = requestParams?.turnId; + return rawTurnId !== null && rawTurnId !== undefined && rawTurnId !== turnId; +} + +function hasExactTurnId(requestParams: JsonObject | undefined, turnId: string): boolean { + return requestParams?.turnId === turnId; +} + +function resolvePluginElicitation(params: { + requestParams: JsonObject | undefined; + pluginAppPolicyContext?: PluginAppPolicyContext; +}): PluginElicitationResolution { + const requestParams = params.requestParams; + if (!requestParams) { + return { kind: "not_plugin" }; + } + const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {}; + const context = params.pluginAppPolicyContext; + const entries = context ? Object.values(context.apps) : []; + + const appId = + readFirstString(meta, PLUGIN_APP_ID_META_KEYS) ?? + readFirstString(requestParams, PLUGIN_APP_ID_META_KEYS); + if (appId) { + if (!context) { + return { kind: "decline", reason: "missing_policy_context" }; + } + const entry = context.apps[appId]; + return uniquePluginMatch(entry ? [entry] : [], "app_id"); + } + + const serverName = readString(requestParams, "serverName"); + if (serverName && context) { + const matches = entries.filter((entry) => entry.mcpServerNames.includes(serverName)); + if (matches.length > 0) { + return uniquePluginMatch(matches, "server_name"); + } + } + + const metadataResolution = resolvePluginStableMetadataMatch({ + meta, + requestParams, + entries, + context, + }); + if (metadataResolution.kind !== "not_plugin") { + return metadataResolution; + } + + if (context && hasDisplayNameOnlyPluginMatch(meta, entries)) { + return { kind: "decline", reason: "display_name_only" }; + } + + return { kind: "not_plugin" }; +} + +function resolvePluginStableMetadataMatch(params: { + meta: JsonObject; + requestParams: JsonObject; + entries: PluginAppPolicyContextEntry[]; + context?: PluginAppPolicyContext; +}): PluginElicitationResolution { + const pluginName = + readFirstString(params.meta, PLUGIN_NAME_META_KEYS) ?? + readFirstString(params.requestParams, PLUGIN_NAME_META_KEYS); + const configKey = + readFirstString(params.meta, PLUGIN_CONFIG_KEY_META_KEYS) ?? + readFirstString(params.requestParams, PLUGIN_CONFIG_KEY_META_KEYS); + const marketplaceName = + readFirstString(params.meta, PLUGIN_MARKETPLACE_NAME_META_KEYS) ?? + readFirstString(params.requestParams, PLUGIN_MARKETPLACE_NAME_META_KEYS); + if (!pluginName && !configKey) { + return { kind: "not_plugin" }; + } + if (!params.context) { + return { kind: "decline", reason: "missing_policy_context" }; + } + const matches = params.entries.filter((entry) => { + if (marketplaceName && entry.marketplaceName !== marketplaceName) { + return false; + } + if (pluginName && entry.pluginName !== pluginName) { + return false; + } + if (configKey && entry.configKey !== configKey) { + return false; + } + return true; + }); + return uniquePluginMatch(matches, "metadata"); +} + +function uniquePluginMatch( + matches: PluginAppPolicyContextEntry[], + source: string, +): PluginElicitationResolution { + if (matches.length === 1 && matches[0]) { + return { kind: "matched", entry: matches[0] }; + } + return { + kind: "decline", + reason: matches.length === 0 ? `${source}_not_enabled` : `${source}_ambiguous`, + }; +} + +function hasDisplayNameOnlyPluginMatch( + meta: JsonObject, + entries: PluginAppPolicyContextEntry[], +): boolean { + const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); + if (!connectorName) { return false; } - const rawTurnId = requestParams.turnId; - if (rawTurnId !== null && rawTurnId !== undefined && rawTurnId !== turnId) { - return false; + const normalized = normalizePluginIdentityText(connectorName); + return entries.some( + (entry) => + normalizePluginIdentityText(entry.pluginName) === normalized || + normalizePluginIdentityText(entry.configKey) === normalized, + ); +} + +function normalizePluginIdentityText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function buildPluginPolicyElicitationResponse( + entry: PluginAppPolicyContextEntry, + requestParams: JsonObject, +): JsonValue { + if (!entry.allowDestructiveActions) { + logPluginElicitationDecline("destructive_actions_disabled", requestParams); + return declineElicitationResponse(); } - return true; + if ( + readString(requestParams, "mode") !== "form" || + !isJsonObject(requestParams.requestedSchema) + ) { + logPluginElicitationDecline("unsupported_schema", requestParams); + return declineElicitationResponse(); + } + const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {}; + const response = buildElicitationResponse(requestParams.requestedSchema, meta, "approved-once"); + if (isJsonObject(response) && response.action === "accept") { + return response; + } + logPluginElicitationDecline("unmappable_schema", requestParams); + return declineElicitationResponse(); +} + +function declineElicitationResponse(): JsonValue { + return { action: "decline", content: null, _meta: null }; +} + +function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void { + embeddedAgentLog.debug("codex plugin elicitation declined", { + reason, + serverName: readString(requestParams, "serverName"), + mode: readString(requestParams, "mode"), + }); } function readBridgeableApprovalElicitation( @@ -555,3 +747,13 @@ function readString(record: JsonObject | undefined, key: string): string | undef const value = record?.[key]; return typeof value === "string" && value.trim() ? value : undefined; } + +function readFirstString(record: JsonObject | undefined, keys: string[]): string | undefined { + for (const key of keys) { + const value = readString(record, key); + if (value) { + return value; + } + } + return undefined; +} diff --git a/extensions/codex/src/app-server/plugin-activation.test.ts b/extensions/codex/src/app-server/plugin-activation.test.ts new file mode 100644 index 00000000000..42083d5e805 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-activation.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from "vitest"; +import { CodexAppInventoryCache } from "./app-inventory-cache.js"; +import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js"; +import { + ensureCodexAppsSubstrateConfig, + ensureCodexPluginActivation, + upsertTomlBoolean, +} from "./plugin-activation.js"; +import type { v2 } from "./protocol.js"; + +describe("Codex plugin activation", () => { + it("skips plugin/install when the migrated plugin is already active", async () => { + const calls: string[] = []; + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + request: async (method) => { + calls.push(method); + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: true, + reason: "already_active", + installAttempted: false, + }); + expect(calls).toEqual(["plugin/list"]); + }); + + it("can reinstall an already active plugin when migration explicitly applies it", async () => { + const calls: string[] = []; + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + installEvenIfActive: true, + request: async (method, params) => { + calls.push(method); + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + expect(params).toEqual({ + marketplacePath: "/marketplaces/openai-curated", + pluginName: "google-calendar", + }); + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: true, + reason: "already_active", + installAttempted: true, + }); + expect(calls).toEqual([ + "plugin/list", + "plugin/install", + "plugin/list", + "skills/list", + "hooks/list", + "config/mcpServer/reload", + ]); + }); + + it("installs a migration-authorized local curated plugin and refreshes runtime state", async () => { + const calls: Array<{ method: string; params: unknown }> = []; + const appCache = new CodexAppInventoryCache(); + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + appCache, + appCacheKey: "runtime", + request: async (method, params) => { + calls.push({ method, params }); + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed: false, enabled: false }), + ]); + } + if (method === "plugin/install") { + expect(params).toEqual({ + marketplacePath: "/marketplaces/openai-curated", + pluginName: "google-calendar", + }); + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + expect(params).toMatchObject({ forceReload: true }); + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + if (method === "app/list") { + expect(params).toMatchObject({ forceRefetch: true }); + return { data: [], nextCursor: null } satisfies v2.AppsListResponse; + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: true, + reason: "installed", + installAttempted: true, + }); + expect(calls.map((call) => call.method)).toEqual([ + "plugin/list", + "plugin/install", + "plugin/list", + "skills/list", + "hooks/list", + "config/mcpServer/reload", + "app/list", + ]); + expect(appCache.getRevision()).toBeGreaterThan(0); + }); + + it("keeps activation fail-closed when post-install app inventory refresh fails", async () => { + const appCache = new CodexAppInventoryCache(); + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + appCache, + appCacheKey: "runtime", + request: async (method) => { + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed: false, enabled: false }), + ]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + if (method === "app/list") { + throw new Error("app/list unavailable"); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: true, + reason: "installed", + installAttempted: true, + }); + expect(result.diagnostics).toContainEqual({ + message: "Codex app inventory refresh skipped: app/list unavailable", + }); + expect(appCache.getRevision()).toBeGreaterThan(0); + }); + + it("reports post-install runtime refresh failures without hiding the install attempt", async () => { + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + request: async (method) => { + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed: false, enabled: false }), + ]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + throw new Error("skills/list unavailable"); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: false, + reason: "refresh_failed", + installAttempted: true, + }); + expect(result.diagnostics).toContainEqual({ + message: "Codex plugin runtime refresh failed after install: skills/list unavailable", + }); + }); + + it("installs from a remote curated marketplace when no local marketplace path is present", async () => { + const calls: Array<{ method: string; params: unknown }> = []; + const result = await ensureCodexPluginActivation({ + identity: identity("google-calendar"), + request: async (method, params) => { + calls.push({ method, params }); + if (method === "plugin/list") { + return { + ...pluginList([pluginSummary("google-calendar", { installed: false, enabled: false })]), + marketplaces: [ + { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + path: null, + interface: null, + plugins: [pluginSummary("google-calendar", { installed: false, enabled: false })], + }, + ], + } satisfies v2.PluginListResponse; + } + if (method === "plugin/install") { + expect(params).toEqual({ + remoteMarketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }); + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(result).toMatchObject({ + ok: true, + reason: "installed", + installAttempted: true, + }); + expect(calls.map((call) => call.method)).toEqual([ + "plugin/list", + "plugin/install", + "plugin/list", + "skills/list", + "hooks/list", + "config/mcpServer/reload", + ]); + }); + + it("upserts native apps substrate config without clobbering other toml", async () => { + const existing = 'model = "gpt-5.5"\n\n[features]\nother = true\n'; + expect(upsertTomlBoolean(existing, "features", "apps", true)).toBe( + 'model = "gpt-5.5"\n\n[features]\nother = true\napps = true\n', + ); + + const writes: Array<{ path: string; content: string }> = []; + const result = await ensureCodexAppsSubstrateConfig({ + codexHome: "/codex-home", + readFile: vi.fn(async () => existing), + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async (filePath, content) => { + writes.push({ path: String(filePath), content: String(content) }); + }), + }); + + expect(result).toEqual({ changed: true, configPath: "/codex-home/config.toml" }); + expect(writes[0]?.content).toContain("[features]\nother = true\napps = true"); + expect(writes[0]?.content).toContain("[apps._default]\nenabled = true"); + }); +}); + +function identity(pluginName: string): ResolvedCodexPluginPolicy { + return { + configKey: pluginName, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName, + enabled: true, + allowDestructiveActions: false, + }; +} + +function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { + return { + marketplaces: [ + { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + path: "/marketplaces/openai-curated", + interface: null, + plugins, + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; +} + +function pluginSummary(id: string, overrides: Partial = {}): v2.PluginSummary { + return { + id, + name: id, + source: { type: "remote" }, + installed: false, + enabled: false, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + ...overrides, + }; +} diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts new file mode 100644 index 00000000000..97ff4f79d52 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -0,0 +1,275 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + type CodexAppInventoryCache, + type CodexAppInventoryRequest, +} from "./app-inventory-cache.js"; +import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js"; +import { + findOpenAiCuratedPluginSummary, + pluginReadParams, + type CodexPluginMarketplaceRef, + type CodexPluginRuntimeRequest, +} from "./plugin-inventory.js"; +import type { v2 } from "./protocol.js"; + +export type CodexPluginActivationReason = + | "already_active" + | "installed" + | "disabled" + | "marketplace_missing" + | "plugin_missing" + | "auth_required" + | "refresh_failed"; + +export type CodexPluginActivationDiagnostic = { + message: string; +}; + +export type CodexPluginActivationResult = { + identity: ResolvedCodexPluginPolicy; + ok: boolean; + reason: CodexPluginActivationReason; + installAttempted: boolean; + marketplace?: CodexPluginMarketplaceRef; + installResponse?: v2.PluginInstallResponse; + diagnostics: CodexPluginActivationDiagnostic[]; +}; + +export type EnsureCodexPluginActivationParams = { + identity: ResolvedCodexPluginPolicy; + request: CodexPluginRuntimeRequest; + appCache?: CodexAppInventoryCache; + appCacheKey?: string; + installEvenIfActive?: boolean; +}; + +export type CodexPluginRuntimeRefreshResult = { + diagnostics: CodexPluginActivationDiagnostic[]; +}; + +export async function ensureCodexPluginActivation( + params: EnsureCodexPluginActivationParams, +): Promise { + if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) { + return activationFailure(params.identity, "marketplace_missing", { + message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.", + }); + } + + const listed = (await params.request("plugin/list", { + cwds: [], + } satisfies v2.PluginListParams)) as v2.PluginListResponse; + const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName); + if (!resolved) { + return activationFailure(params.identity, "plugin_missing", { + message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`, + }); + } + + if (resolved.summary.installed && resolved.summary.enabled && !params.installEvenIfActive) { + return { + identity: params.identity, + ok: true, + reason: "already_active", + installAttempted: false, + marketplace: resolved.marketplace, + diagnostics: [], + }; + } + + const installResponse = (await params.request( + "plugin/install", + pluginReadParams( + resolved.marketplace, + params.identity.pluginName, + ) satisfies v2.PluginInstallParams, + )) as v2.PluginInstallResponse; + const refreshDiagnostics: CodexPluginActivationDiagnostic[] = []; + let refreshFailed = false; + try { + const refreshResult = await refreshCodexPluginRuntimeState({ + request: params.request, + appCache: params.appCache, + appCacheKey: params.appCacheKey, + }); + refreshDiagnostics.push(...refreshResult.diagnostics); + } catch (error) { + refreshFailed = true; + refreshDiagnostics.push({ + message: `Codex plugin runtime refresh failed after install: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + const authRequired = installResponse.appsNeedingAuth.length > 0; + return { + identity: params.identity, + ok: !authRequired && !refreshFailed, + reason: refreshFailed + ? "refresh_failed" + : authRequired + ? "auth_required" + : resolved.summary.installed && resolved.summary.enabled + ? "already_active" + : "installed", + installAttempted: true, + marketplace: resolved.marketplace, + installResponse, + diagnostics: [ + ...refreshDiagnostics, + ...installResponse.appsNeedingAuth.map((app) => ({ + message: `${app.name} requires app authentication before plugin tools are exposed.`, + })), + ], + }; +} + +export async function refreshCodexPluginRuntimeState(params: { + request: CodexPluginRuntimeRequest; + appCache?: CodexAppInventoryCache; + appCacheKey?: string; +}): Promise { + const diagnostics: CodexPluginActivationDiagnostic[] = []; + await params.request("plugin/list", { + cwds: [], + } satisfies v2.PluginListParams); + await params.request("skills/list", { + cwds: [], + forceReload: true, + } satisfies v2.SkillsListParams); + try { + await params.request("hooks/list", { + cwds: [], + } satisfies v2.HooksListParams); + } catch (error) { + diagnostics.push({ + message: `Codex hooks refresh skipped: ${error instanceof Error ? error.message : String(error)}`, + }); + } + await params.request("config/mcpServer/reload", undefined); + + if (params.appCache && params.appCacheKey) { + params.appCache.invalidate(params.appCacheKey, "Codex plugin activation changed app inventory"); + const request: CodexAppInventoryRequest = async (method, requestParams) => + (await params.request(method, requestParams)) as v2.AppsListResponse; + try { + await params.appCache.refreshNow({ + key: params.appCacheKey, + request, + forceRefetch: true, + }); + } catch (error) { + diagnostics.push({ + message: `Codex app inventory refresh skipped: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + + return { diagnostics }; +} + +export async function ensureCodexAppsSubstrateConfig(params: { + codexHome: string; + readFile?: (filePath: string, encoding: "utf8") => Promise; + writeFile?: (filePath: string, content: string, encoding: "utf8") => Promise; + mkdir?: (dirPath: string, options: { recursive: true }) => Promise; +}): Promise<{ changed: boolean; configPath: string }> { + const readFile = params.readFile ?? ((filePath, encoding) => fs.readFile(filePath, encoding)); + const writeFile = + params.writeFile ?? + ((filePath, content, encoding) => fs.writeFile(filePath, content, encoding)); + const mkdir = params.mkdir ?? ((dirPath, options) => fs.mkdir(dirPath, options)); + const configPath = path.join(params.codexHome, "config.toml"); + let current = ""; + try { + current = await readFile(configPath, "utf8"); + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + + const next = upsertTomlBoolean( + upsertTomlBoolean(current, "features", "apps", true), + "apps._default", + "enabled", + true, + ); + if (next === current) { + return { changed: false, configPath }; + } + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, next, "utf8"); + return { changed: true, configPath }; +} + +export function upsertTomlBoolean( + source: string, + section: string, + key: string, + value: boolean, +): string { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + if (lines.length > 0 && lines.at(-1) === "") { + lines.pop(); + } + const sectionHeaderPattern = new RegExp(`^\\s*\\[${escapeRegExp(section)}\\]\\s*(?:#.*)?$`); + const anySectionPattern = /^\s*\[[^\]]+\]\s*(?:#.*)?$/; + const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`); + const desiredLine = `${key} = ${value ? "true" : "false"}`; + const sectionStart = lines.findIndex((line) => sectionHeaderPattern.test(line)); + if (sectionStart === -1) { + const nextLines = [...lines]; + if (nextLines.length > 0 && nextLines.at(-1)?.trim()) { + nextLines.push(""); + } + nextLines.push(`[${section}]`, desiredLine); + return `${nextLines.join("\n")}\n`; + } + + let sectionEnd = lines.length; + for (let index = sectionStart + 1; index < lines.length; index += 1) { + if (anySectionPattern.test(lines[index] ?? "")) { + sectionEnd = index; + break; + } + } + for (let index = sectionStart + 1; index < sectionEnd; index += 1) { + if (keyPattern.test(lines[index] ?? "")) { + if (lines[index] === desiredLine) { + return `${lines.join("\n")}\n`; + } + const nextLines = [...lines]; + nextLines[index] = desiredLine; + return `${nextLines.join("\n")}\n`; + } + } + const nextLines = [...lines]; + nextLines.splice(sectionEnd, 0, desiredLine); + return `${nextLines.join("\n")}\n`; +} + +function activationFailure( + identity: ResolvedCodexPluginPolicy, + reason: CodexPluginActivationReason, + diagnostic: CodexPluginActivationDiagnostic, +): CodexPluginActivationResult { + return { + identity, + ok: false, + reason, + installAttempted: false, + diagnostics: [diagnostic], + }; +} + +function isEnoent(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/extensions/codex/src/app-server/plugin-inventory.test.ts b/extensions/codex/src/app-server/plugin-inventory.test.ts new file mode 100644 index 00000000000..2403bb8b90d --- /dev/null +++ b/extensions/codex/src/app-server/plugin-inventory.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from "vitest"; +import { CodexAppInventoryCache } from "./app-inventory-cache.js"; +import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js"; +import { findOpenAiCuratedPluginSummary, readCodexPluginInventory } from "./plugin-inventory.js"; +import type { v2 } from "./protocol.js"; + +describe("Codex plugin inventory", () => { + it("returns enabled migrated curated plugins with stable owned app ids", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + }), + }); + const calls: string[] = []; + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + slack: { + enabled: false, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "slack", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method, params) => { + calls.push(method); + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed: true, enabled: true }), + pluginSummary("slack", { installed: true, enabled: true }), + ]); + } + if (method === "plugin/read") { + expect(params).toMatchObject({ + marketplacePath: "/marketplaces/openai-curated", + pluginName: "google-calendar", + }); + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(inventory.records).toHaveLength(1); + expect(inventory.records[0]).toMatchObject({ + policy: { pluginName: "google-calendar" }, + summary: { installed: true, enabled: true }, + appOwnership: "proven", + ownedAppIds: ["google-calendar-app"], + apps: [{ id: "google-calendar-app", accessible: true, enabled: true }], + }); + expect(calls).toEqual(["plugin/list", "plugin/read"]); + }); + + it("matches namespaced curated plugin ids by normalized path segment", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("github-app", true)], + nextCursor: null, + }), + }); + + const listed = pluginList([ + pluginSummary("openai-curated/github", { + name: "GitHub", + installed: true, + enabled: true, + }), + ]); + expect(findOpenAiCuratedPluginSummary(listed, "github")?.summary.id).toBe( + "openai-curated/github", + ); + + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + github: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "github", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method, params) => { + if (method === "plugin/list") { + return listed; + } + if (method === "plugin/read") { + expect(params).toMatchObject({ + marketplacePath: "/marketplaces/openai-curated", + pluginName: "github", + }); + return pluginDetail("github", [appSummary("github-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(inventory.records).toHaveLength(1); + expect(inventory.records[0]).toMatchObject({ + policy: { pluginName: "github" }, + summary: { id: "openai-curated/github", installed: true, enabled: true }, + appOwnership: "proven", + ownedAppIds: ["github-app"], + }); + expect(inventory.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "plugin_missing" }), + ); + }); + + it("fails closed when plugin detail apps are absent from app inventory", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [], + nextCursor: null, + }), + }); + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(inventory.records[0]).toMatchObject({ + appOwnership: "proven", + authRequired: true, + ownedAppIds: ["google-calendar-app"], + apps: [ + { + id: "google-calendar-app", + accessible: false, + enabled: false, + needsAuth: true, + }, + ], + }); + }); + + it("marks display-name-only app matches ambiguous instead of exposing app ids", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [ + { + ...appInfo("calendar-app", true), + pluginDisplayNames: ["Google Calendar"], + }, + ], + nextCursor: null, + }), + }); + + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + readPluginDetails: false, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { + name: "Google Calendar", + installed: true, + enabled: true, + }), + ]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(inventory.records[0]?.appOwnership).toBe("ambiguous"); + expect(inventory.records[0]?.ownedAppIds).toEqual([]); + expect(inventory.diagnostics).toContainEqual( + expect.objectContaining({ code: "app_ownership_ambiguous" }), + ); + }); + + it("fails closed when the app inventory cache is missing", async () => { + const appCache = new CodexAppInventoryCache(); + const inventory = await readCodexPluginInventory({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + request: async (method) => { + if (method === "app/list") { + return { data: [], nextCursor: null }; + } + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(inventory.appInventory?.state).toBe("missing"); + expect(inventory.records[0]?.ownedAppIds).toEqual(["google-calendar-app"]); + expect(inventory.records[0]?.apps).toEqual([]); + expect(inventory.diagnostics).toContainEqual( + expect.objectContaining({ code: "app_inventory_missing" }), + ); + }); +}); + +function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { + return { + marketplaces: [ + { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + path: "/marketplaces/openai-curated", + interface: null, + plugins, + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; +} + +function pluginSummary(id: string, overrides: Partial = {}): v2.PluginSummary { + return { + id, + name: id, + source: { type: "remote" }, + installed: false, + enabled: false, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + ...overrides, + }; +} + +function pluginDetail(pluginName: string, apps: v2.AppSummary[]): v2.PluginReadResponse { + return { + plugin: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplacePath: "/marketplaces/openai-curated", + summary: pluginSummary(pluginName, { installed: true, enabled: true }), + description: null, + skills: [], + apps, + mcpServers: [], + }, + }; +} + +function appSummary(id: string): v2.AppSummary { + return { + id, + name: id, + description: null, + installUrl: null, + needsAuth: false, + }; +} + +function appInfo(id: string, accessible: boolean): v2.AppInfo { + return { + id, + name: id, + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: accessible, + isEnabled: true, + pluginDisplayNames: [], + }; +} diff --git a/extensions/codex/src/app-server/plugin-inventory.ts b/extensions/codex/src/app-server/plugin-inventory.ts new file mode 100644 index 00000000000..fc357f65c38 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-inventory.ts @@ -0,0 +1,346 @@ +import { + type CodexAppInventoryCache, + type CodexAppInventoryCacheRead, + type CodexAppInventoryRequest, +} from "./app-inventory-cache.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + resolveCodexPluginsPolicy, + type ResolvedCodexPluginPolicy, + type ResolvedCodexPluginsPolicy, +} from "./config.js"; +import type { v2 } from "./protocol.js"; + +export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise; + +export type CodexPluginMarketplaceRef = { + name: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + path?: string; + remoteMarketplaceName?: string; +}; + +export type CodexPluginInventoryDiagnosticCode = + | "disabled" + | "marketplace_missing" + | "plugin_missing" + | "plugin_disabled" + | "plugin_detail_unavailable" + | "app_inventory_missing" + | "app_inventory_stale" + | "app_ownership_ambiguous"; + +export type CodexPluginInventoryDiagnostic = { + code: CodexPluginInventoryDiagnosticCode; + plugin?: ResolvedCodexPluginPolicy; + message: string; +}; + +export type CodexPluginOwnedApp = { + id: string; + name: string; + accessible: boolean; + enabled: boolean; + needsAuth: boolean; +}; + +export type CodexPluginInventoryRecord = { + policy: ResolvedCodexPluginPolicy; + summary: v2.PluginSummary; + detail?: v2.PluginDetail; + activationRequired: boolean; + authRequired: boolean; + appOwnership: "proven" | "ambiguous" | "none"; + ownedAppIds: string[]; + apps: CodexPluginOwnedApp[]; +}; + +export type CodexPluginInventory = { + policy: ResolvedCodexPluginsPolicy; + marketplace?: CodexPluginMarketplaceRef; + records: CodexPluginInventoryRecord[]; + diagnostics: CodexPluginInventoryDiagnostic[]; + appInventory?: CodexAppInventoryCacheRead; +}; + +export type ReadCodexPluginInventoryParams = { + pluginConfig?: unknown; + policy?: ResolvedCodexPluginsPolicy; + request: CodexPluginRuntimeRequest; + appCache?: CodexAppInventoryCache; + appCacheKey?: string; + nowMs?: number; + readPluginDetails?: boolean; +}; + +export async function readCodexPluginInventory( + params: ReadCodexPluginInventoryParams, +): Promise { + const policy = params.policy ?? resolveCodexPluginsPolicy(params.pluginConfig); + if (!policy.enabled) { + return { + policy, + records: [], + diagnostics: [ + { + code: "disabled", + message: "Native Codex plugin support is disabled.", + }, + ], + }; + } + + const appInventory = readCachedAppInventory(params); + const listed = (await params.request("plugin/list", { + cwds: [], + } satisfies v2.PluginListParams)) as v2.PluginListResponse; + const marketplaceEntry = listed.marketplaces.find( + (marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME, + ); + if (!marketplaceEntry) { + return { + policy, + records: [], + diagnostics: policy.pluginPolicies + .filter((pluginPolicy) => pluginPolicy.enabled) + .map((pluginPolicy) => ({ + code: "marketplace_missing", + plugin: pluginPolicy, + message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`, + })), + ...(appInventory ? { appInventory } : {}), + }; + } + + const marketplace = marketplaceRef(marketplaceEntry); + const diagnostics: CodexPluginInventoryDiagnostic[] = []; + const records: CodexPluginInventoryRecord[] = []; + if (appInventory?.state === "missing") { + diagnostics.push({ + code: "app_inventory_missing", + message: "Cached Codex app inventory is missing; plugin apps are excluded for this setup.", + }); + } else if (appInventory?.state === "stale") { + diagnostics.push({ + code: "app_inventory_stale", + message: "Cached Codex app inventory is stale; using stale app readiness and refreshing.", + }); + } + + for (const pluginPolicy of policy.pluginPolicies) { + if (!pluginPolicy.enabled) { + continue; + } + const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName); + if (!summary) { + diagnostics.push({ + code: "plugin_missing", + plugin: pluginPolicy, + message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`, + }); + continue; + } + + const detail = await readPluginDetail(params, marketplace, pluginPolicy, diagnostics); + const ownedAppIds = + detail?.apps + .map((app) => app.id) + .filter(Boolean) + .toSorted() ?? []; + const appOwnership = resolveAppOwnership({ + detail, + appInventory, + summary, + }); + if (appOwnership === "ambiguous") { + diagnostics.push({ + code: "app_ownership_ambiguous", + plugin: pluginPolicy, + message: `${pluginPolicy.pluginName} has only display-name app matches; apps are not exposed until ownership is stable.`, + }); + } + if (summary.installed && !summary.enabled) { + diagnostics.push({ + code: "plugin_disabled", + plugin: pluginPolicy, + message: `${pluginPolicy.pluginName} is installed in Codex but disabled.`, + }); + } + + const apps = resolveOwnedApps({ + detail, + appInventory, + }); + records.push({ + policy: pluginPolicy, + summary, + ...(detail ? { detail } : {}), + activationRequired: !summary.installed || !summary.enabled, + authRequired: apps.some((app) => app.needsAuth || !app.accessible), + appOwnership, + ownedAppIds, + apps, + }); + } + + return { + policy, + marketplace, + records, + diagnostics, + ...(appInventory ? { appInventory } : {}), + }; +} + +export function findOpenAiCuratedPluginSummary( + listed: v2.PluginListResponse, + pluginName: string, +): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined { + const marketplaceEntry = listed.marketplaces.find( + (marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME, + ); + if (!marketplaceEntry) { + return undefined; + } + const summary = findPluginSummary(marketplaceEntry, pluginName); + return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined; +} + +export function pluginReadParams( + marketplace: CodexPluginMarketplaceRef, + pluginName: string, +): v2.PluginReadParams { + return { + ...(marketplace.path ? { marketplacePath: marketplace.path } : {}), + ...(marketplace.remoteMarketplaceName + ? { remoteMarketplaceName: marketplace.remoteMarketplaceName } + : {}), + pluginName, + }; +} + +function readCachedAppInventory( + params: ReadCodexPluginInventoryParams, +): CodexAppInventoryCacheRead | undefined { + if (!params.appCache || !params.appCacheKey) { + return undefined; + } + const request: CodexAppInventoryRequest = async (method, requestParams) => + (await params.request(method, requestParams)) as v2.AppsListResponse; + return params.appCache.read({ + key: params.appCacheKey, + request, + nowMs: params.nowMs, + }); +} + +async function readPluginDetail( + params: ReadCodexPluginInventoryParams, + marketplace: CodexPluginMarketplaceRef, + pluginPolicy: ResolvedCodexPluginPolicy, + diagnostics: CodexPluginInventoryDiagnostic[], +): Promise { + if (params.readPluginDetails === false) { + return undefined; + } + try { + const response = (await params.request( + "plugin/read", + pluginReadParams(marketplace, pluginPolicy.pluginName), + )) as v2.PluginReadResponse; + return response.plugin; + } catch (error) { + diagnostics.push({ + code: "plugin_detail_unavailable", + plugin: pluginPolicy, + message: `${pluginPolicy.pluginName} detail unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + return undefined; + } +} + +function resolveAppOwnership(params: { + detail?: v2.PluginDetail; + appInventory?: CodexAppInventoryCacheRead; + summary: v2.PluginSummary; +}): "proven" | "ambiguous" | "none" { + if (params.detail && params.detail.apps.length > 0) { + return "proven"; + } + const apps = params.appInventory?.snapshot?.apps ?? []; + const displayMatches = apps.filter((app) => + app.pluginDisplayNames.some((displayName) => displayName === params.summary.name), + ); + return displayMatches.length > 0 ? "ambiguous" : "none"; +} + +function resolveOwnedApps(params: { + detail?: v2.PluginDetail; + appInventory?: CodexAppInventoryCacheRead; +}): CodexPluginOwnedApp[] { + const detailApps = params.detail?.apps ?? []; + if (detailApps.length === 0) { + return []; + } + if (params.appInventory?.state === "missing") { + return []; + } + const appInfoById = new Map( + (params.appInventory?.snapshot?.apps ?? []).map((app) => [app.id, app] as const), + ); + return detailApps + .map((app) => { + const info = appInfoById.get(app.id); + if (!info) { + return { + id: app.id, + name: app.name, + accessible: false, + enabled: false, + needsAuth: true, + }; + } + return { + id: app.id, + name: app.name, + accessible: info.isAccessible, + enabled: info.isEnabled, + needsAuth: app.needsAuth || !info.isAccessible, + }; + }) + .toSorted((left, right) => left.id.localeCompare(right.id)); +} + +function findPluginSummary( + marketplace: v2.PluginMarketplaceEntry, + pluginName: string, +): v2.PluginSummary | undefined { + return marketplace.plugins.find( + (plugin) => + plugin.name === pluginName || + plugin.id === pluginName || + plugin.id === `${pluginName}@${marketplace.name}` || + pluginNameFromPluginId(plugin.id, marketplace.name) === pluginName, + ); +} + +function pluginNameFromPluginId(pluginId: string, marketplaceName: string): string | undefined { + const trimmed = pluginId.trim(); + if (!trimmed) { + return undefined; + } + const marketplaceSuffix = `@${marketplaceName}`; + const withoutMarketplaceSuffix = trimmed.endsWith(marketplaceSuffix) + ? trimmed.slice(0, -marketplaceSuffix.length) + : trimmed; + return withoutMarketplaceSuffix.split("/").at(-1)?.trim() || undefined; +} + +function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef { + return { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + ...(marketplace.path ? { path: marketplace.path } : {}), + ...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}), + }; +} diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts new file mode 100644 index 00000000000..77fbb82c345 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -0,0 +1,732 @@ +import { describe, expect, it, vi } from "vitest"; +import { CodexAppInventoryCache } from "./app-inventory-cache.js"; +import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js"; +import { + buildCodexPluginThreadConfig, + buildCodexPluginThreadConfigInputFingerprint, + isCodexPluginThreadBindingStale, + mergeCodexThreadConfigs, + shouldBuildCodexPluginThreadConfig, +} from "./plugin-thread-config.js"; +import type { v2 } from "./protocol.js"; + +describe("Codex plugin thread config", () => { + it("builds restrictive app config for accessible migrated plugin apps", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + allow_destructive_actions: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail( + "google-calendar", + [appSummary("google-calendar-app")], + ["google-calendar"], + ); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + "google-calendar-app": { + enabled: true, + destructive_enabled: true, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }, + }, + }); + expect(config.policyContext.apps["google-calendar-app"]).toEqual({ + configKey: "google-calendar", + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + allowDestructiveActions: true, + mcpServerNames: ["google-calendar"], + }); + expect(config.diagnostics).toEqual([]); + }); + + it("maps destructive app access from global and per-plugin policy", async () => { + const pluginOverrideDisabled = await buildReadyGoogleCalendarThreadConfig({ + codexPlugins: { + enabled: true, + allow_destructive_actions: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + allow_destructive_actions: false, + }, + }, + }, + }); + + const disabledApps = pluginOverrideDisabled.configPatch?.apps as + | Record + | undefined; + expect(disabledApps?.["google-calendar-app"]).toMatchObject({ + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }); + expect(disabledApps?.["google-calendar-app"]).not.toHaveProperty("default_tools_enabled"); + expect(disabledApps?.["google-calendar-app"]).not.toHaveProperty("tools"); + expect( + pluginOverrideDisabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions, + ).toBe(false); + + const pluginOverrideEnabled = await buildReadyGoogleCalendarThreadConfig({ + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + allow_destructive_actions: true, + }, + }, + }, + }); + + const enabledApps = pluginOverrideEnabled.configPatch?.apps as + | Record + | undefined; + expect(enabledApps?.["google-calendar-app"]).toMatchObject({ + enabled: true, + destructive_enabled: true, + }); + expect( + pluginOverrideEnabled.policyContext.apps["google-calendar-app"]?.allowDestructiveActions, + ).toBe(true); + }); + + it("builds a restrictive app config when native plugin support is disabled", async () => { + expect( + shouldBuildCodexPluginThreadConfig({ + codexPlugins: { enabled: false }, + }), + ).toBe(true); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { codexPlugins: { enabled: false } }, + appCacheKey: "runtime", + request: async (method) => { + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.enabled).toBe(false); + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.diagnostics).toEqual([]); + expect(config.policyContext.apps).toEqual({}); + }); + + it("does not let per-plugin enablement override disabled native plugin support", async () => { + expect( + shouldBuildCodexPluginThreadConfig({ + codexPlugins: { + enabled: false, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }), + ).toBe(true); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: false, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCacheKey: "runtime", + request: async (method) => { + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.enabled).toBe(false); + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toEqual({}); + expect(config.diagnostics).toEqual([]); + }); + + it("waits for the initial app inventory before exposing plugin apps", async () => { + const appCache = new CodexAppInventoryCache(); + const request = vi.fn(async (method: string) => { + if (method === "app/list") { + return { data: [appInfo("google-calendar-app", true)], nextCursor: null }; + } + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }); + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + request, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + "google-calendar-app": { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }, + }, + }); + expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({ + pluginName: "google-calendar", + }); + expect(config.diagnostics).toEqual([]); + expect(request.mock.calls.filter(([method]) => method === "app/list")).toHaveLength(1); + }); + + it("does not expose plugin apps missing from the app inventory snapshot", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toEqual({}); + expect(config.diagnostics).toContainEqual( + expect.objectContaining({ + code: "app_not_ready", + message: "google-calendar-app is not accessible or enabled for google-calendar.", + }), + ); + }); + + it("re-reads app readiness after re-enabling an installed plugin", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true, false)], + nextCursor: null, + }), + }); + let enabled = false; + const appListParams: v2.AppsListParams[] = []; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + if (method === "plugin/install") { + enabled = true; + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + if (method === "app/list") { + appListParams.push(params as v2.AppsListParams); + return { + data: [appInfo("google-calendar-app", true, enabled)], + nextCursor: null, + } satisfies v2.AppsListResponse; + } + throw new Error(`unexpected request ${method}`); + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request, + }); + + expect(config.configPatch?.apps).toMatchObject({ + "google-calendar-app": { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }, + }); + expect(config.policyContext.apps["google-calendar-app"]).toMatchObject({ + pluginName: "google-calendar", + }); + expect(config.diagnostics).toEqual([]); + expect(request.mock.calls.map(([method]) => method)).toContain("plugin/install"); + expect(request.mock.calls.filter(([method]) => method === "app/list").length).toBeGreaterThan( + 0, + ); + expect(appListParams.some((params) => params.forceRefetch)).toBe(true); + }); + + it("surfaces critical post-install refresh failures and keeps plugin apps disabled", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([ + pluginSummary("google-calendar", { installed: false, enabled: false }), + ]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + throw new Error("skills/list unavailable"); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toEqual({}); + expect(config.diagnostics).toContainEqual( + expect.objectContaining({ + code: "plugin_activation_failed", + message: expect.stringContaining("skills/list unavailable"), + }), + ); + }); + + it("fails closed when the initial app inventory refresh fails", async () => { + const appCache = new CodexAppInventoryCache(); + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + request: async (method) => { + if (method === "app/list") { + throw new Error("app/list unavailable"); + } + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expect(config.configPatch).toEqual({ + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }); + expect(config.policyContext.apps).toEqual({}); + expect(config.diagnostics).toContainEqual( + expect.objectContaining({ code: "app_inventory_missing" }), + ); + }); + + it("uses durable policy and app cache key in the cheap input fingerprint", async () => { + const appCache = new CodexAppInventoryCache(); + const first = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: { codexPlugins: { enabled: true } }, + appCacheKey: "runtime-a", + }); + await appCache.refreshNow({ + key: "runtime-a", + request: async () => ({ data: [], nextCursor: null }), + }); + const second = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: { codexPlugins: { enabled: true } }, + appCacheKey: "runtime-a", + }); + const third = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: { codexPlugins: { enabled: true } }, + appCacheKey: "runtime-b", + }); + + expect(second).toBe(first); + expect(third).not.toBe(second); + }); + + it("uses app-level destructive policy for plugins without OpenClaw tool-name knowledge", async () => { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("github-app", true)], + nextCursor: null, + }), + }); + + const config = await buildCodexPluginThreadConfig({ + pluginConfig: { + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + github: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "github", + }, + }, + }, + }, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("github", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("github", [appSummary("github-app")], ["github"]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + const apps = config.configPatch?.apps as Record | undefined; + expect(apps?.["github-app"]).toEqual({ + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }); + expect(apps?.["github-app"]).not.toHaveProperty("tools"); + }); + + it("merges app config with native hook config", () => { + expect( + mergeCodexThreadConfigs( + { "features.codex_hooks": true, hooks: { PreToolUse: [] } }, + { apps: { _default: { enabled: false } } }, + ), + ).toEqual({ + "features.codex_hooks": true, + hooks: { PreToolUse: [] }, + apps: { _default: { enabled: false } }, + }); + }); + + it("marks missing and changed plugin app bindings stale only when relevant", () => { + expect( + isCodexPluginThreadBindingStale({ + codexPluginsEnabled: true, + currentInputFingerprint: "input-2", + }), + ).toBe(true); + expect( + isCodexPluginThreadBindingStale({ + codexPluginsEnabled: true, + bindingFingerprint: "config-1", + bindingInputFingerprint: "input-1", + currentInputFingerprint: "input-2", + hasBindingPolicyContext: true, + }), + ).toBe(true); + expect( + isCodexPluginThreadBindingStale({ + codexPluginsEnabled: true, + bindingFingerprint: "config-1", + bindingInputFingerprint: "input-1", + currentInputFingerprint: "input-1", + hasBindingPolicyContext: true, + }), + ).toBe(false); + expect( + isCodexPluginThreadBindingStale({ + codexPluginsEnabled: false, + bindingFingerprint: "config-1", + bindingInputFingerprint: "input-1", + hasBindingPolicyContext: true, + }), + ).toBe(true); + }); +}); + +function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { + return { + marketplaces: [ + { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + path: "/marketplaces/openai-curated", + interface: null, + plugins, + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; +} + +function pluginSummary(id: string, overrides: Partial = {}): v2.PluginSummary { + return { + id, + name: id, + source: { type: "remote" }, + installed: false, + enabled: false, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + ...overrides, + }; +} + +function pluginDetail( + pluginName: string, + apps: v2.AppSummary[], + mcpServers: string[] = [], +): v2.PluginReadResponse { + return { + plugin: { + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplacePath: "/marketplaces/openai-curated", + summary: pluginSummary(pluginName, { installed: true, enabled: true }), + description: null, + skills: [], + apps, + mcpServers, + }, + }; +} + +function appSummary(id: string): v2.AppSummary { + return { + id, + name: id, + description: null, + installUrl: null, + needsAuth: false, + }; +} + +function appInfo(id: string, accessible: boolean, enabled = true): v2.AppInfo { + return { + id, + name: id, + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: accessible, + isEnabled: enabled, + pluginDisplayNames: [], + }; +} + +async function buildReadyGoogleCalendarThreadConfig( + pluginConfig: unknown, +): Promise>> { + const appCache = new CodexAppInventoryCache(); + await appCache.refreshNow({ + key: "runtime", + nowMs: 0, + request: async () => ({ + data: [appInfo("google-calendar-app", true)], + nextCursor: null, + }), + }); + + return buildCodexPluginThreadConfig({ + pluginConfig, + appCache, + appCacheKey: "runtime", + nowMs: 1, + request: async (method) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/read") { + return pluginDetail("google-calendar", [appSummary("google-calendar-app")]); + } + throw new Error(`unexpected request ${method}`); + }, + }); +} diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts new file mode 100644 index 00000000000..a115e8c205e --- /dev/null +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -0,0 +1,389 @@ +import crypto from "node:crypto"; +import { + defaultCodexAppInventoryCache, + type CodexAppInventoryCache, + type CodexAppInventoryRequest, +} from "./app-inventory-cache.js"; +import { + resolveCodexPluginsPolicy, + type ResolvedCodexPluginPolicy, + type ResolvedCodexPluginsPolicy, +} from "./config.js"; +import { + ensureCodexPluginActivation, + type CodexPluginActivationResult, +} from "./plugin-activation.js"; +import { + readCodexPluginInventory, + type CodexPluginInventory, + type CodexPluginInventoryDiagnostic, + type CodexPluginRuntimeRequest, +} from "./plugin-inventory.js"; +import type { JsonObject, JsonValue } from "./protocol.js"; + +export type PluginAppPolicyContextEntry = { + configKey: string; + marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"]; + pluginName: string; + allowDestructiveActions: boolean; + mcpServerNames: string[]; +}; + +export type PluginAppPolicyContext = { + fingerprint: string; + apps: Record; + pluginAppIds: Record; +}; + +export type CodexPluginThreadConfigDiagnostic = + | CodexPluginInventoryDiagnostic + | { + code: "plugin_activation_failed" | "app_not_ready"; + plugin?: ResolvedCodexPluginPolicy; + message: string; + }; + +export type CodexPluginThreadConfig = { + enabled: boolean; + configPatch?: JsonObject; + fingerprint: string; + inputFingerprint: string; + policyContext: PluginAppPolicyContext; + inventory?: CodexPluginInventory; + diagnostics: CodexPluginThreadConfigDiagnostic[]; +}; + +export type BuildCodexPluginThreadConfigParams = { + pluginConfig?: unknown; + request: CodexPluginRuntimeRequest; + appCache?: CodexAppInventoryCache; + appCacheKey: string; + nowMs?: number; +}; + +const CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION = 1; +const CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION = 1; + +export function shouldBuildCodexPluginThreadConfig(pluginConfig?: unknown): boolean { + return resolveCodexPluginsPolicy(pluginConfig).configured; +} + +export function buildCodexPluginThreadConfigInputFingerprint(params: { + pluginConfig?: unknown; + appCacheKey?: string; +}): string { + const policy = resolveCodexPluginsPolicy(params.pluginConfig); + return fingerprintJson({ + version: CODEX_PLUGIN_THREAD_CONFIG_INPUT_FINGERPRINT_VERSION, + policy: policyFingerprint(policy), + appCacheKey: params.appCacheKey ?? null, + }); +} + +export async function buildCodexPluginThreadConfig( + params: BuildCodexPluginThreadConfigParams, +): Promise { + const appCache = params.appCache ?? defaultCodexAppInventoryCache; + let inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: params.pluginConfig, + appCacheKey: params.appCacheKey, + }); + const policy = resolveCodexPluginsPolicy(params.pluginConfig); + if (!policy.enabled) { + return emptyPluginThreadConfig({ + enabled: false, + inputFingerprint, + configPatch: buildDisabledAppsConfigPatch(), + }); + } + + let inventory = await readCodexPluginInventory({ + pluginConfig: params.pluginConfig, + policy, + request: params.request, + appCache, + appCacheKey: params.appCacheKey, + nowMs: params.nowMs, + }); + if (shouldWaitForInitialAppInventory(params, policy, inventory)) { + await refreshAppInventoryNow(params, appCache); + inventory = await readCodexPluginInventory({ + pluginConfig: params.pluginConfig, + policy, + request: params.request, + appCache, + appCacheKey: params.appCacheKey, + nowMs: params.nowMs, + }); + inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: params.pluginConfig, + appCacheKey: params.appCacheKey, + }); + } + const activationDiagnostics: CodexPluginThreadConfigDiagnostic[] = []; + const activationResults: CodexPluginActivationResult[] = []; + for (const record of inventory.records) { + if (!record.activationRequired) { + continue; + } + const activation = await ensureCodexPluginActivation({ + identity: record.policy, + request: params.request, + appCache, + appCacheKey: params.appCacheKey, + }); + activationResults.push(activation); + if (!activation.ok) { + activationDiagnostics.push({ + code: "plugin_activation_failed", + plugin: record.policy, + message: activation.diagnostics.map((item) => item.message).join(" ") || activation.reason, + }); + } + } + if (activationResults.some((activation) => activation.ok && activation.installAttempted)) { + await refreshAppInventoryNow(params, appCache, { forceRefetch: true }); + inventory = await readCodexPluginInventory({ + pluginConfig: params.pluginConfig, + policy, + request: params.request, + appCache, + appCacheKey: params.appCacheKey, + nowMs: params.nowMs, + }); + inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig: params.pluginConfig, + appCacheKey: params.appCacheKey, + }); + } + + const diagnostics: CodexPluginThreadConfigDiagnostic[] = [ + ...inventory.diagnostics, + ...activationDiagnostics, + ]; + const apps: JsonObject = { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }; + const policyApps: Record = {}; + const pluginAppIds: Record = {}; + for (const record of inventory.records) { + if (record.activationRequired) { + const activation = activationResults.find( + (item) => item.identity.configKey === record.policy.configKey, + ); + if (!activation?.ok) { + continue; + } + } + if (record.appOwnership !== "proven") { + continue; + } + pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted(); + for (const app of record.apps) { + if (!app.accessible || !app.enabled) { + diagnostics.push({ + code: "app_not_ready", + plugin: record.policy, + message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`, + }); + continue; + } + const appConfig: JsonObject = { + enabled: true, + destructive_enabled: record.policy.allowDestructiveActions, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }; + apps[app.id] = appConfig; + policyApps[app.id] = { + configKey: record.policy.configKey, + marketplaceName: record.policy.marketplaceName, + pluginName: record.policy.pluginName, + allowDestructiveActions: record.policy.allowDestructiveActions, + mcpServerNames: [...(record.detail?.mcpServers ?? [])].toSorted(), + }; + } + } + + const configPatch = { apps }; + const policyContext = buildPluginAppPolicyContext(policyApps, pluginAppIds); + return { + enabled: true, + configPatch, + fingerprint: fingerprintJson({ + version: CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION, + inputFingerprint, + configPatch, + policyContext, + }), + inputFingerprint, + policyContext, + inventory, + diagnostics, + }; +} + +export function mergeCodexThreadConfigs( + ...configs: Array +): JsonObject | undefined { + let merged: JsonObject | undefined; + for (const config of configs) { + if (!config) { + continue; + } + merged = mergeJsonObjects(merged ?? {}, config); + } + return merged && Object.keys(merged).length > 0 ? merged : undefined; +} + +export function isCodexPluginThreadBindingStale(params: { + codexPluginsEnabled: boolean; + bindingFingerprint?: string; + bindingInputFingerprint?: string; + currentInputFingerprint?: string; + hasBindingPolicyContext?: boolean; +}): boolean { + if (!params.codexPluginsEnabled) { + return Boolean( + params.bindingFingerprint || params.bindingInputFingerprint || params.hasBindingPolicyContext, + ); + } + if ( + !params.bindingFingerprint || + !params.bindingInputFingerprint || + !params.hasBindingPolicyContext + ) { + return true; + } + return params.bindingInputFingerprint !== params.currentInputFingerprint; +} + +function emptyPluginThreadConfig(params: { + enabled: boolean; + inputFingerprint: string; + configPatch?: JsonObject; +}): CodexPluginThreadConfig { + const policyContext = buildPluginAppPolicyContext({}, {}); + return { + enabled: params.enabled, + fingerprint: fingerprintJson({ + version: CODEX_PLUGIN_THREAD_CONFIG_FINGERPRINT_VERSION, + inputFingerprint: params.inputFingerprint, + configPatch: params.configPatch ?? null, + policyContext, + }), + inputFingerprint: params.inputFingerprint, + ...(params.configPatch ? { configPatch: params.configPatch } : {}), + policyContext, + diagnostics: [], + }; +} + +function buildDisabledAppsConfigPatch(): JsonObject { + return { + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }; +} + +function buildPluginAppPolicyContext( + apps: Record, + pluginAppIds: Record, +): PluginAppPolicyContext { + return { + fingerprint: fingerprintJson({ version: 1, apps, pluginAppIds }), + apps, + pluginAppIds, + }; +} + +function shouldWaitForInitialAppInventory( + params: BuildCodexPluginThreadConfigParams, + policy: ResolvedCodexPluginsPolicy, + inventory: CodexPluginInventory, +): boolean { + return Boolean( + params.appCacheKey && + policy.pluginPolicies.some((plugin) => plugin.enabled) && + inventory.appInventory?.state === "missing", + ); +} + +async function refreshAppInventoryNow( + params: BuildCodexPluginThreadConfigParams, + appCache: CodexAppInventoryCache, + options: { forceRefetch?: boolean } = {}, +): Promise { + const appCacheKey = params.appCacheKey; + if (!appCacheKey) { + return; + } + const request: CodexAppInventoryRequest = async (method, requestParams) => + (await params.request(method, requestParams)) as Awaited>; + try { + await appCache.refreshNow({ + key: appCacheKey, + request, + nowMs: params.nowMs, + forceRefetch: options.forceRefetch, + }); + } catch { + // Keep the thread fail-closed if app/list refresh is unavailable. + } +} + +function policyFingerprint(policy: ResolvedCodexPluginsPolicy): JsonValue { + return { + enabled: policy.enabled, + allowDestructiveActions: policy.allowDestructiveActions, + plugins: policy.pluginPolicies.map((plugin) => ({ + configKey: plugin.configKey, + marketplaceName: plugin.marketplaceName, + pluginName: plugin.pluginName, + enabled: plugin.enabled, + allowDestructiveActions: plugin.allowDestructiveActions, + })), + }; +} + +function mergeJsonObjects(left: JsonObject, right: JsonObject): JsonObject { + const merged: JsonObject = { ...left }; + for (const [key, value] of Object.entries(right)) { + const existing = merged[key]; + merged[key] = + isPlainJsonObject(existing) && isPlainJsonObject(value) + ? mergeJsonObjects(existing, value) + : value; + } + return merged; +} + +function isPlainJsonObject(value: JsonValue | undefined): value is JsonObject { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function fingerprintJson(value: JsonValue): string { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function stableStringify(value: JsonValue | undefined): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index a0bb0b4ffb6..2036a79a890 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -75,7 +75,7 @@ export type CodexThreadStartParams = JsonObject & { cwd?: string; model?: string; modelProvider?: string | null; - approvalPolicy?: string; + approvalPolicy?: string | JsonObject; approvalsReviewer?: string | null; sandbox?: CodexSandboxPolicy; serviceTier?: CodexServiceTier | null; @@ -108,7 +108,7 @@ export type CodexTurnStartParams = JsonObject & { input?: CodexUserInput[]; cwd?: string; model?: string; - approvalPolicy?: string; + approvalPolicy?: string | JsonObject; approvalsReviewer?: string | null; sandboxPolicy?: CodexSandboxPolicy; serviceTier?: CodexServiceTier | null; @@ -258,32 +258,115 @@ export type CodexLoginAccountParams = }; export type CodexPluginSummary = { - id?: string; - name?: string; + id: string; + name: string; + source?: JsonObject; installed: boolean; enabled: boolean; + installPolicy?: string; + authPolicy?: string; + availability?: string; + interface?: JsonValue; +}; + +export type CodexAppSummary = { + id: string; + name: string; + description?: string | null; + installUrl?: string | null; + needsAuth: boolean; }; export type CodexPluginDetail = { - summary: CodexPluginSummary; marketplaceName?: string; marketplacePath?: string | null; + summary: CodexPluginSummary; + description?: string | null; + skills?: JsonValue[]; + apps: CodexAppSummary[]; + mcpServers: string[]; }; export type CodexPluginMarketplaceEntry = { name: string; path?: string | null; + interface?: JsonValue; plugins: CodexPluginSummary[]; }; export type CodexPluginListResponse = { marketplaces: CodexPluginMarketplaceEntry[]; + marketplaceLoadErrors?: JsonValue[]; + featuredPluginIds?: string[]; }; export type CodexPluginReadResponse = { plugin: CodexPluginDetail; }; +export type CodexPluginListParams = { + cwds: string[]; +}; + +export type CodexPluginReadParams = { + marketplacePath?: string; + remoteMarketplaceName?: string; + pluginName: string; +}; + +export type CodexPluginInstallParams = CodexPluginReadParams; + +export type CodexPluginInstallResponse = { + authPolicy: string; + appsNeedingAuth: CodexAppSummary[]; +}; + +export type CodexAppInfo = { + id: string; + name: string; + description?: string | null; + logoUrl?: string | null; + logoUrlDark?: string | null; + distributionChannel?: string | null; + branding?: JsonValue; + appMetadata?: JsonValue; + labels?: JsonValue; + installUrl?: string | null; + isAccessible: boolean; + isEnabled: boolean; + pluginDisplayNames: string[]; +}; + +export type CodexAppsListParams = { + cursor?: string | null; + limit?: number; + forceRefetch?: boolean; +}; + +export type CodexAppsListResponse = { + data: CodexAppInfo[]; + nextCursor?: string | null; +}; + +export type CodexSkillsListParams = { + cwds: string[]; + forceReload?: boolean; +}; + +export type CodexSkillsListResponse = { + data: JsonValue[]; + nextCursor?: string | null; +}; + +export type CodexHooksListParams = { + cwds: string[]; +}; + +export type CodexHooksListResponse = { + data: JsonValue[]; + nextCursor?: string | null; +}; + export type CodexMcpServerStatus = { name: string; tools: JsonObject; @@ -296,6 +379,26 @@ export type CodexListMcpServerStatusResponse = { export type CodexRequestObject = Record; +export declare namespace v2 { + export type AppInfo = CodexAppInfo; + export type AppSummary = CodexAppSummary; + export type AppsListParams = CodexAppsListParams; + export type AppsListResponse = CodexAppsListResponse; + export type HooksListParams = CodexHooksListParams; + export type HooksListResponse = CodexHooksListResponse; + export type PluginDetail = CodexPluginDetail; + export type PluginInstallParams = CodexPluginInstallParams; + export type PluginInstallResponse = CodexPluginInstallResponse; + export type PluginListParams = CodexPluginListParams; + export type PluginListResponse = CodexPluginListResponse; + export type PluginMarketplaceEntry = CodexPluginMarketplaceEntry; + export type PluginReadParams = CodexPluginReadParams; + export type PluginReadResponse = CodexPluginReadResponse; + export type PluginSummary = CodexPluginSummary; + export type SkillsListParams = CodexSkillsListParams; + export type SkillsListResponse = CodexSkillsListResponse; +} + type CodexAppServerRequestParamsOverride = { "thread/start": CodexThreadStartParams; }; @@ -304,11 +407,19 @@ type CodexAppServerRequestResultMap = { initialize: CodexInitializeResponse; "account/rateLimits/read": JsonValue; "account/read": CodexGetAccountResponse; + "app/list": CodexAppsListResponse; + "config/mcpServer/reload": JsonValue; + "experimentalFeature/enablement/set": JsonValue; "feedback/upload": JsonValue; + "hooks/list": CodexHooksListResponse; + "marketplace/add": JsonValue; "mcpServerStatus/list": CodexListMcpServerStatusResponse; "model/list": CodexModelListResponse; + "plugin/install": CodexPluginInstallResponse; + "plugin/list": CodexPluginListResponse; + "plugin/read": CodexPluginReadResponse; "review/start": JsonValue; - "skills/list": JsonValue; + "skills/list": CodexSkillsListResponse; "thread/compact/start": JsonValue; "thread/list": JsonValue; "thread/resume": CodexThreadResumeResponse; diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 5317ff751ce..43682e85b27 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -19,6 +19,15 @@ import { import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; +import { + buildCodexAppInventoryCacheKey, + defaultCodexAppInventoryCache, +} from "./app-inventory-cache.js"; +import { + resolveCodexAppServerEnvApiKeyCacheKey, + resolveCodexAppServerHomeDir, +} from "./auth-bridge.js"; +import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js"; import { CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE } from "./dynamic-tools.js"; import * as elicitationBridge from "./elicitation-bridge.js"; import type { CodexServerNotification } from "./protocol.js"; @@ -375,6 +384,109 @@ function createRuntimeDynamicTool(name: string) { } as never; } +function createPluginAppConfigPatch() { + return { + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + "google-calendar-app": { + enabled: true, + destructive_enabled: true, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }, + }, + }; +} + +function createPluginAppPolicyContext() { + return { + fingerprint: "plugin-policy-1", + apps: { + "google-calendar-app": { + configKey: "google-calendar", + marketplaceName: "openai-curated" as const, + pluginName: "google-calendar", + allowDestructiveActions: false, + mcpServerNames: ["google-calendar"], + }, + }, + pluginAppIds: { + "google-calendar": ["google-calendar-app"], + }, + }; +} + +function createTwoPluginAppConfigPatch() { + return { + apps: { + ...createPluginAppConfigPatch().apps, + "gmail-app": { + enabled: true, + destructive_enabled: true, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }, + }, + }; +} + +function createTwoPluginAppPolicyContext() { + return { + fingerprint: "plugin-policy-2", + apps: { + ...createPluginAppPolicyContext().apps, + "gmail-app": { + configKey: "gmail", + marketplaceName: "openai-curated" as const, + pluginName: "gmail", + allowDestructiveActions: false, + mcpServerNames: ["gmail"], + }, + }, + pluginAppIds: { + ...createPluginAppPolicyContext().pluginAppIds, + gmail: ["gmail-app"], + }, + }; +} + +function createTwoCalendarAppConfigPatch() { + return { + apps: { + ...createPluginAppConfigPatch().apps, + "google-calendar-secondary-app": { + enabled: true, + destructive_enabled: true, + open_world_enabled: true, + default_tools_approval_mode: "prompt", + }, + }, + }; +} + +function createTwoCalendarAppPolicyContext() { + return { + fingerprint: "plugin-policy-calendar-2", + apps: { + ...createPluginAppPolicyContext().apps, + "google-calendar-secondary-app": { + configKey: "google-calendar", + marketplaceName: "openai-curated" as const, + pluginName: "google-calendar", + allowDestructiveActions: false, + mcpServerNames: ["google-calendar"], + }, + }, + pluginAppIds: { + "google-calendar": ["google-calendar-app", "google-calendar-secondary-app"], + }, + }; +} + type AppServerRequestHandler = (request: { id: string | number; method: string; @@ -415,6 +527,8 @@ describe("runCodexAppServerAttempt", () => { beforeEach(async () => { resetAgentEventsForTest(); vi.stubEnv("OPENCLAW_TRAJECTORY", "0"); + vi.stubEnv("CODEX_API_KEY", ""); + vi.stubEnv("OPENAI_API_KEY", ""); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-")); }); @@ -425,6 +539,7 @@ describe("runCodexAppServerAttempt", () => { nativeHookRelayTesting.clearNativeHookRelaysForTests(); resetAgentEventsForTest(); resetGlobalHookRunner(); + defaultCodexAppInventoryCache.clear(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllEnvs(); @@ -2388,6 +2503,526 @@ describe("runCodexAppServerAttempt", () => { await run; }); + it("passes session plugin app policy context to elicitation handling", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + const pluginConfig = { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }; + const appServer = resolveCodexAppServerRuntimeOptions({ + pluginConfig: readCodexPluginConfig(pluginConfig), + }); + defaultCodexAppInventoryCache.clear(); + await defaultCodexAppInventoryCache.refreshNow({ + key: buildCodexAppInventoryCacheKey({ + codexHome: resolveCodexAppServerHomeDir(agentDir), + endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer), + }), + request: async () => ({ + data: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: true, + pluginDisplayNames: [], + }, + ], + nextCursor: null, + }), + }); + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + let handleRequest: + | ((request: { id: string; method: string; params?: unknown }) => Promise) + | undefined; + const bridgeSpy = vi + .spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest") + .mockResolvedValue({ + action: "decline", + content: null, + _meta: null, + }); + const request = vi.fn(async (method: string) => { + if (method === "plugin/list") { + return { + marketplaces: [ + { + name: "openai-curated", + path: "/marketplaces/openai-curated", + interface: null, + plugins: [ + { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + ], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + return { + plugin: { + marketplaceName: "openai-curated", + marketplacePath: "/marketplaces/openai-curated", + summary: { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + description: null, + skills: [], + apps: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + installUrl: null, + needsAuth: false, + }, + ], + mcpServers: ["google-calendar"], + }, + }; + } + if (method === "thread/start") { + return threadStartResult("thread-1"); + } + if (method === "turn/start") { + return turnStartResult("turn-1", "inProgress"); + } + return {}; + }); + __testing.setCodexAppServerClientFactoryForTests( + async () => + ({ + request, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: ( + handler: (request: { + id: string; + method: string; + params?: unknown; + }) => Promise, + ) => { + handleRequest = handler; + return () => undefined; + }, + }) as never, + ); + + const params = createParams(sessionFile, workspaceDir); + params.agentDir = agentDir; + const run = runCodexAppServerAttempt(params, { pluginConfig }); + await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function")); + + const result = await handleRequest?.({ + id: "request-elicitation-1", + method: "mcpServer/elicitation/request", + params: { + threadId: "thread-1", + turnId: "turn-1", + serverName: "google-calendar", + mode: "form", + }, + }); + + expect(result).toEqual({ + action: "decline", + content: null, + _meta: null, + }); + expect(bridgeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + turnId: "turn-1", + pluginAppPolicyContext: expect.objectContaining({ + apps: { + "google-calendar-app": expect.objectContaining({ + pluginName: "google-calendar", + mcpServerNames: ["google-calendar"], + }), + }, + }), + }), + ); + expect(request).toHaveBeenCalledWith( + "thread/start", + expect.objectContaining({ + approvalPolicy: { + granular: expect.objectContaining({ + mcp_elicitations: true, + }), + }, + }), + ); + expect(request).toHaveBeenCalledWith( + "turn/start", + expect.objectContaining({ + approvalPolicy: { + granular: expect.objectContaining({ + mcp_elicitations: true, + }), + }, + }), + expect.anything(), + ); + + await notify({ + method: "turn/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + turn: { id: "turn-1", status: "completed" }, + }, + }); + await run; + }); + + it("keys plugin app inventory by the resolved Codex account", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + const authProfileId = "openai-codex:work"; + const pluginConfig = { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }; + const appServer = resolveCodexAppServerRuntimeOptions({ + pluginConfig: readCodexPluginConfig(pluginConfig), + }); + defaultCodexAppInventoryCache.clear(); + await defaultCodexAppInventoryCache.refreshNow({ + key: buildCodexAppInventoryCacheKey({ + codexHome: resolveCodexAppServerHomeDir(agentDir), + endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer), + authProfileId, + accountId: "account-work", + }), + request: async () => ({ + data: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: true, + pluginDisplayNames: [], + }, + ], + nextCursor: null, + }), + }); + const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => { + if (method === "plugin/list") { + return { + marketplaces: [ + { + name: "openai-curated", + path: "/marketplaces/openai-curated", + interface: null, + plugins: [ + { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + ], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + return { + plugin: { + marketplaceName: "openai-curated", + marketplacePath: "/marketplaces/openai-curated", + summary: { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + description: null, + skills: [], + apps: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + installUrl: null, + needsAuth: false, + }, + ], + mcpServers: ["google-calendar"], + }, + }; + } + if (method === "app/list") { + throw new Error("app/list should use the account-keyed cache entry"); + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.agentDir = agentDir; + params.authProfileId = authProfileId; + params.authProfileStore = { + version: 1, + profiles: { + [authProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "account-work", + email: "work@example.test", + }, + }, + }; + + const run = runCodexAppServerAttempt(params, { pluginConfig }); + await waitForMethod("turn/start"); + await completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + expect(requests).toEqual( + expect.arrayContaining([ + { + method: "thread/start", + params: expect.objectContaining({ + config: expect.objectContaining({ + apps: expect.objectContaining({ + "google-calendar-app": expect.objectContaining({ enabled: true }), + }), + }), + }), + }, + ]), + ); + expect(requests.map((entry) => entry.method)).not.toContain("app/list"); + }); + + it("keys plugin app inventory by inherited API key fallback credentials", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const agentDir = path.join(tempDir, "agent"); + const pluginConfig = { + codexPlugins: { + enabled: true, + plugins: { + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }; + const appServer = resolveCodexAppServerRuntimeOptions({ + pluginConfig: readCodexPluginConfig(pluginConfig), + }); + defaultCodexAppInventoryCache.clear(); + await defaultCodexAppInventoryCache.refreshNow({ + key: buildCodexAppInventoryCacheKey({ + codexHome: resolveCodexAppServerHomeDir(agentDir), + endpoint: __testing.resolveCodexPluginAppCacheEndpoint(appServer), + envApiKeyFingerprint: resolveCodexAppServerEnvApiKeyCacheKey({ + startOptions: appServer.start, + baseEnv: { CODEX_API_KEY: "old-codex-env-key" }, + }), + }), + request: async () => ({ + data: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: true, + pluginDisplayNames: [], + }, + ], + nextCursor: null, + }), + }); + vi.stubEnv("CODEX_API_KEY", "new-codex-env-key"); + vi.stubEnv("OPENAI_API_KEY", ""); + const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => { + if (method === "app/list") { + return { + data: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + logoUrl: null, + logoUrlDark: null, + distributionChannel: null, + branding: null, + appMetadata: null, + labels: null, + installUrl: null, + isAccessible: true, + isEnabled: true, + pluginDisplayNames: [], + }, + ], + nextCursor: null, + }; + } + if (method === "plugin/list") { + return { + marketplaces: [ + { + name: "openai-curated", + path: "/marketplaces/openai-curated", + interface: null, + plugins: [ + { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + ], + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; + } + if (method === "plugin/read") { + return { + plugin: { + marketplaceName: "openai-curated", + marketplacePath: "/marketplaces/openai-curated", + summary: { + id: "google-calendar", + name: "google-calendar", + source: { type: "remote" }, + installed: true, + enabled: true, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + }, + description: null, + skills: [], + apps: [ + { + id: "google-calendar-app", + name: "Google Calendar", + description: null, + installUrl: null, + needsAuth: false, + }, + ], + mcpServers: ["google-calendar"], + }, + }; + } + return undefined; + }); + const params = createParams(sessionFile, workspaceDir); + params.agentDir = agentDir; + + const run = runCodexAppServerAttempt(params, { pluginConfig }); + await waitForMethod("turn/start"); + await completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + expect(requests.map((entry) => entry.method)).toContain("app/list"); + expect(requests).toEqual( + expect.arrayContaining([ + { + method: "thread/start", + params: expect.objectContaining({ + config: expect.objectContaining({ + apps: expect.objectContaining({ + "google-calendar-app": expect.objectContaining({ enabled: true }), + }), + }), + }), + }, + ]), + ); + }); + it("times out app-server startup before thread setup can hang forever", async () => { __testing.setCodexAppServerClientFactoryForTests(() => new Promise(() => undefined)); const params = createParams( @@ -2788,6 +3423,530 @@ describe("runCodexAppServerAttempt", () => { ]); }); + it("merges native hook relay config with plugin app config when starting a thread", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-plugins"); + } + throw new Error(`unexpected method: ${method}`); + }); + const pluginAppPolicyContext = createPluginAppPolicyContext(); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: createPluginAppConfigPatch(), + fingerprint: "plugin-apps-config-1", + inputFingerprint: "plugin-apps-input-1", + policyContext: pluginAppPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + config: { "features.codex_hooks": true, hooks: { PreToolUse: [] } }, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar"], + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: { + "features.codex_hooks": true, + hooks: { PreToolUse: [] }, + ...createPluginAppConfigPatch(), + }, + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-plugins", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext, + }); + }); + + it("revalidates compatible plugin app bindings without resending app config", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start" || method === "thread/resume") { + return threadStartResult("thread-plugins"); + } + throw new Error(`unexpected method: ${method}`); + }); + const pluginAppPolicyContext = createPluginAppPolicyContext(); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: createPluginAppConfigPatch(), + fingerprint: "plugin-apps-config-1", + inputFingerprint: "plugin-apps-input-1", + policyContext: pluginAppPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + config: { "features.codex_hooks": true }, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + build: buildPluginThreadConfig, + }, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + config: { "features.codex_hooks": true }, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar"], + build: buildPluginThreadConfig, + }, + }); + + expect(binding.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(2); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: { + "features.codex_hooks": true, + ...createPluginAppConfigPatch(), + }, + }), + ], + [ + "thread/resume", + expect.objectContaining({ + config: { "features.codex_hooks": true }, + }), + ], + ]); + }); + + it("starts a new plugin app thread when full binding revalidation removes an app", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext: createPluginAppPolicyContext(), + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-revalidated"); + } + throw new Error(`unexpected method: ${method}`); + }); + const emptyPolicyContext = { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} }; + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: { + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }, + fingerprint: "plugin-apps-empty", + inputFingerprint: "plugin-apps-input-1", + policyContext: emptyPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar"], + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: { + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }, + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-revalidated", + pluginAppsFingerprint: "plugin-apps-empty", + pluginAppPolicyContext: emptyPolicyContext, + }); + }); + + it("keeps the existing plugin app binding when revalidation fails", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const pluginAppPolicyContext = createPluginAppPolicyContext(); + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext, + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar"], + build: async () => { + throw new Error("plugin inventory unavailable"); + }, + }, + }); + + expect(request.mock.calls).toEqual([ + ["thread/resume", expect.not.objectContaining({ config: expect.anything() })], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-existing", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext, + }); + }); + + it("rebuilds an empty plugin app binding after app inventory recovers", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-empty", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext: { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} }, + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-recovered"); + } + throw new Error(`unexpected method: ${method}`); + }); + const pluginAppPolicyContext = createPluginAppPolicyContext(); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: createPluginAppConfigPatch(), + fingerprint: "plugin-apps-config-1", + inputFingerprint: "plugin-apps-input-1", + policyContext: pluginAppPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: createPluginAppConfigPatch(), + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-recovered", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppPolicyContext, + }); + }); + + it("keeps an empty plugin app binding when recovery still produces the same config", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const emptyPolicyContext = { fingerprint: "plugin-policy-empty", apps: {}, pluginAppIds: {} }; + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-empty", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext: emptyPolicyContext, + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: { + apps: { + _default: { + enabled: false, + destructive_enabled: false, + open_world_enabled: false, + }, + }, + }, + fingerprint: "plugin-apps-empty", + inputFingerprint: "plugin-apps-input-1", + policyContext: emptyPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + ["thread/resume", expect.not.objectContaining({ config: expect.anything() })], + ]); + }); + + it("rebuilds a partial plugin app binding after another plugin recovers", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-partial", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext: createPluginAppPolicyContext(), + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-recovered"); + } + throw new Error(`unexpected method: ${method}`); + }); + const recoveredPolicyContext = createTwoPluginAppPolicyContext(); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: createTwoPluginAppConfigPatch(), + fingerprint: "plugin-apps-config-2", + inputFingerprint: "plugin-apps-input-1", + policyContext: recoveredPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar", "gmail"], + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: createTwoPluginAppConfigPatch(), + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-recovered", + pluginAppsFingerprint: "plugin-apps-config-2", + pluginAppPolicyContext: recoveredPolicyContext, + }); + }); + + it("rebuilds a partial plugin app binding after another app from the same plugin recovers", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { + dynamicToolsFingerprint: "[]", + pluginAppsFingerprint: "plugin-apps-partial", + pluginAppsInputFingerprint: "plugin-apps-input-1", + pluginAppPolicyContext: { + ...createPluginAppPolicyContext(), + pluginAppIds: { + "google-calendar": ["google-calendar-app", "google-calendar-secondary-app"], + }, + }, + }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-recovered"); + } + throw new Error(`unexpected method: ${method}`); + }); + const recoveredPolicyContext = createTwoCalendarAppPolicyContext(); + const buildPluginThreadConfig = vi.fn(async () => ({ + enabled: true, + configPatch: createTwoCalendarAppConfigPatch(), + fingerprint: "plugin-apps-config-calendar-2", + inputFingerprint: "plugin-apps-input-1", + policyContext: recoveredPolicyContext, + diagnostics: [], + })); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + enabledPluginConfigKeys: ["google-calendar"], + build: buildPluginThreadConfig, + }, + }); + + expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: createTwoCalendarAppConfigPatch(), + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-recovered", + pluginAppsFingerprint: "plugin-apps-config-calendar-2", + pluginAppPolicyContext: recoveredPolicyContext, + }); + }); + + it("starts a new configured thread for legacy bindings missing plugin app metadata", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" }); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-plugins"); + } + throw new Error(`unexpected method: ${method}`); + }); + const pluginAppPolicyContext = createPluginAppPolicyContext(); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + pluginThreadConfig: { + enabled: true, + inputFingerprint: "plugin-apps-input-1", + build: async () => ({ + enabled: true, + configPatch: createPluginAppConfigPatch(), + fingerprint: "plugin-apps-config-1", + inputFingerprint: "plugin-apps-input-1", + policyContext: pluginAppPolicyContext, + diagnostics: [], + }), + }, + }); + + expect(request.mock.calls).toEqual([ + [ + "thread/start", + expect.objectContaining({ + config: createPluginAppConfigPatch(), + }), + ], + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-plugins", + pluginAppsFingerprint: "plugin-apps-config-1", + pluginAppPolicyContext, + }); + }); + it("starts a new Codex thread when dynamic tool schemas change", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -2895,6 +4054,45 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("keys plugin app inventory by websocket credentials without exposing them", () => { + const first = __testing.resolveCodexPluginAppCacheEndpoint({ + start: { + transport: "websocket", + command: "codex", + args: [], + url: "ws://127.0.0.1:39175", + authToken: "token-first", + headers: { Authorization: "Bearer first" }, + }, + requestTimeoutMs: 60_000, + turnCompletionIdleTimeoutMs: 5, + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: "workspace-write", + }); + const second = __testing.resolveCodexPluginAppCacheEndpoint({ + start: { + transport: "websocket", + command: "codex", + args: [], + url: "ws://127.0.0.1:39175", + authToken: "token-second", + headers: { Authorization: "Bearer second" }, + }, + requestTimeoutMs: 60_000, + turnCompletionIdleTimeoutMs: 5, + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: "workspace-write", + }); + + expect(first).not.toEqual(second); + expect(first).not.toContain("token-first"); + expect(first).not.toContain("Bearer first"); + expect(second).not.toContain("token-second"); + expect(second).not.toContain("Bearer second"); + }); + it("builds resume and turn params from the currently selected OpenClaw model", () => { const params = createParams("/tmp/session.jsonl", "/tmp/workspace"); const appServer = { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 536aed3e366..98608207c96 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -41,9 +41,16 @@ import { import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { + buildCodexAppInventoryCacheKey, + defaultCodexAppInventoryCache, +} from "./app-inventory-cache.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { refreshCodexAppServerAuthTokens, + resolveCodexAppServerAuthAccountCacheKey, + resolveCodexAppServerEnvApiKeyCacheKey, + resolveCodexAppServerHomeDir, resolveCodexAppServerAuthProfileId, resolveCodexAppServerAuthProfileIdForAgent, } from "./auth-bridge.js"; @@ -59,7 +66,9 @@ import { import { ensureCodexComputerUse } from "./computer-use.js"; import { readCodexPluginConfig, + resolveCodexPluginsPolicy, resolveCodexAppServerRuntimeOptions, + withMcpElicitationsApprovalPolicy, type CodexAppServerRuntimeOptions, type CodexPluginConfig, } from "./config.js"; @@ -76,6 +85,11 @@ import { buildCodexNativeHookRelayConfig, CODEX_NATIVE_HOOK_RELAY_EVENTS, } from "./native-hook-relay.js"; +import { + buildCodexPluginThreadConfig, + buildCodexPluginThreadConfigInputFingerprint, + shouldBuildCodexPluginThreadConfig, +} from "./plugin-thread-config.js"; import { assertCodexTurnStartResponse, readCodexDynamicToolCallParams, @@ -356,6 +370,50 @@ function toCodexTextInput(text: string): CodexUserInput { return { type: "text", text, text_elements: [] }; } +function resolveCodexPluginAppCacheEndpoint(appServer: CodexAppServerRuntimeOptions): string { + return JSON.stringify({ + transport: appServer.start.transport, + command: appServer.start.command, + args: appServer.start.args, + url: appServer.start.url ?? null, + credentialFingerprint: fingerprintCodexPluginAppCacheCredentials(appServer.start), + }); +} + +function fingerprintCodexPluginAppCacheCredentials( + startOptions: CodexAppServerRuntimeOptions["start"], +): string | null { + const authToken = startOptions.authToken ?? ""; + const headers = Object.entries(startOptions.headers) + .map(([key, value]) => [key.toLowerCase(), value] as const) + .toSorted(([left], [right]) => left.localeCompare(right)); + if (!authToken && headers.length === 0) { + return null; + } + const hash = createHash("sha256"); + hash.update("openclaw:codex:plugin-app-cache-credentials:v1"); + hash.update("\0"); + hash.update(authToken); + for (const [key, value] of headers) { + hash.update("\0"); + hash.update(key); + hash.update("\0"); + hash.update(value); + } + return `sha256:${hash.digest("hex")}`; +} + +function resolveCodexPluginAppCacheCodexHome( + appServer: CodexAppServerRuntimeOptions, + agentDir: string, +): string | undefined { + const configuredCodexHome = appServer.start.env?.CODEX_HOME?.trim(); + if (configuredCodexHome) { + return configuredCodexHome; + } + return appServer.start.transport === "stdio" ? resolveCodexAppServerHomeDir(agentDir) : undefined; +} + export async function runCodexAppServerAttempt( params: EmbeddedRunAttemptParams, options: { @@ -376,6 +434,7 @@ export async function runCodexAppServerAttempt( const attemptClientFactory = resolveCodexAppServerClientFactory(); const pluginConfig = readCodexPluginConfig(options.pluginConfig); const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig }); + let pluginAppServer: CodexAppServerRuntimeOptions = appServer; const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({ configuredEvents: options.nativeHookRelay?.events, appServer, @@ -433,6 +492,17 @@ export async function runCodexAppServerAttempt( sessionKey: sandboxSessionKey, ...(startupAuthProfileId ? { authProfileId: startupAuthProfileId } : {}), }; + const startupAuthAccountCacheKey = await resolveCodexAppServerAuthAccountCacheKey({ + authProfileId: startupAuthProfileId, + authProfileStore: params.authProfileStore, + agentDir, + config: params.config, + }); + const startupEnvApiKeyCacheKey = startupAuthProfileId + ? undefined + : resolveCodexAppServerEnvApiKeyCacheKey({ + startOptions: appServer.start, + }); const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine) ? params.contextEngine : undefined; @@ -604,6 +674,36 @@ export async function runCodexAppServerAttempt( ? buildCodexNativeHookRelayDisabledConfig() : undefined; const threadConfig = nativeHookRelayConfig; + const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig); + const pluginAppCacheKey = buildCodexAppInventoryCacheKey({ + codexHome: resolveCodexPluginAppCacheCodexHome(appServer, agentDir), + endpoint: resolveCodexPluginAppCacheEndpoint(appServer), + authProfileId: startupAuthProfileId, + accountId: startupAuthAccountCacheKey, + envApiKeyFingerprint: startupEnvApiKeyCacheKey, + }); + const pluginThreadConfigInputFingerprint = pluginThreadConfigEnabled + ? buildCodexPluginThreadConfigInputFingerprint({ + pluginConfig, + appCacheKey: pluginAppCacheKey, + }) + : undefined; + const resolvedPluginPolicy = pluginThreadConfigEnabled + ? resolveCodexPluginsPolicy(pluginConfig) + : undefined; + const enabledPluginConfigKeys = resolvedPluginPolicy + ? resolvedPluginPolicy.pluginPolicies + .filter((plugin) => plugin.enabled) + .map((plugin) => plugin.configKey) + .toSorted() + : undefined; + pluginAppServer = + resolvedPluginPolicy?.enabled === true + ? { + ...appServer, + approvalPolicy: withMcpElicitationsApprovalPolicy(appServer.approvalPolicy), + } + : appServer; ({ client, thread } = await withCodexStartupTimeout({ timeoutMs: params.timeoutMs, timeoutFloorMs: options.startupTimeoutFloorMs, @@ -630,9 +730,27 @@ export async function runCodexAppServerAttempt( params: runtimeParams, cwd: effectiveWorkspace, dynamicTools: toolBridge.specs, - appServer, + appServer: pluginAppServer, developerInstructions: promptBuild.developerInstructions, config: threadConfig, + pluginThreadConfig: pluginThreadConfigEnabled + ? { + enabled: true, + inputFingerprint: pluginThreadConfigInputFingerprint, + enabledPluginConfigKeys, + build: () => + buildCodexPluginThreadConfig({ + pluginConfig, + request: (method, requestParams) => + startupClient.request(method, requestParams, { + timeoutMs: appServer.requestTimeoutMs, + signal: runAbortController.signal, + }), + appCache: defaultCodexAppInventoryCache, + appCacheKey: pluginAppCacheKey, + }), + } + : undefined, }); return { client: startupClient, thread: startupThread }; }; @@ -1007,6 +1125,7 @@ export async function runCodexAppServerAttempt( paramsForRun: params, threadId: thread.threadId, turnId, + pluginAppPolicyContext: thread.pluginAppPolicyContext, signal: runAbortController.signal, }); } @@ -1133,7 +1252,7 @@ export async function runCodexAppServerAttempt( buildTurnStartParams(params, { threadId: thread.threadId, cwd: effectiveWorkspace, - appServer, + appServer: pluginAppServer, promptText: promptBuild.prompt, }), { timeoutMs: params.timeoutMs, signal: runAbortController.signal }, @@ -2136,6 +2255,7 @@ export const __testing = { filterCodexDynamicToolsForAllowlist, filterToolsForVisionInputs, handleDynamicToolCallWithTimeout, + resolveCodexPluginAppCacheEndpoint, resolveOpenClawCodingToolsSessionKeys, shouldForceMessageTool, setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void { diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 9a051729ff7..e771c56f19b 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -60,6 +60,69 @@ describe("codex app-server session binding", () => { await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy(); }); + it("round-trips plugin app policy context with app ids as record keys", async () => { + const sessionFile = path.join(tempDir, "session.json"); + const pluginAppPolicyContext = { + fingerprint: "plugin-policy-1", + apps: { + "google-calendar-app": { + configKey: "google-calendar", + marketplaceName: "openai-curated" as const, + pluginName: "google-calendar", + allowDestructiveActions: true, + mcpServerNames: ["google-calendar"], + }, + }, + pluginAppIds: { + "google-calendar": ["google-calendar-app"], + }, + }; + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-123", + cwd: tempDir, + pluginAppPolicyContext, + }); + + const binding = await readCodexAppServerBinding(sessionFile); + + expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); + }); + + it("rejects old plugin app policy entries that duplicate the app id", async () => { + const sessionFile = path.join(tempDir, "session.json"); + await fs.writeFile( + resolveCodexAppServerBindingPath(sessionFile), + `${JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123", + sessionFile, + cwd: tempDir, + pluginAppPolicyContext: { + fingerprint: "plugin-policy-1", + apps: { + "google-calendar-app": { + appId: "google-calendar-app", + configKey: "google-calendar", + marketplaceName: "openai-curated", + pluginName: "google-calendar", + allowDestructiveActions: true, + mcpServerNames: ["google-calendar"], + }, + }, + pluginAppIds: { + "google-calendar": ["google-calendar-app"], + }, + }, + createdAt: "2026-05-03T00:00:00.000Z", + updatedAt: "2026-05-03T00:00:00.000Z", + })}\n`, + ); + + const binding = await readCodexAppServerBinding(sessionFile); + + expect(binding?.pluginAppPolicyContext).toBeUndefined(); + }); + it("does not persist public OpenAI as the provider for Codex-native auth bindings", async () => { const sessionFile = path.join(tempDir, "session.json"); await writeCodexAppServerBinding( diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d6d21d0f32c..4074d8213f6 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -6,7 +6,12 @@ import { resolveProviderIdForAuth, type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; -import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + type CodexAppServerApprovalPolicy, + type CodexAppServerSandboxMode, +} from "./config.js"; +import type { PluginAppPolicyContext } from "./plugin-thread-config.js"; import type { CodexServiceTier } from "./protocol.js"; const CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER = "openai-codex"; @@ -34,6 +39,9 @@ export type CodexAppServerThreadBinding = { sandbox?: CodexAppServerSandboxMode; serviceTier?: CodexServiceTier; dynamicToolsFingerprint?: string; + pluginAppsFingerprint?: string; + pluginAppsInputFingerprint?: string; + pluginAppPolicyContext?: PluginAppPolicyContext; createdAt: string; updatedAt: string; }; @@ -83,6 +91,13 @@ export async function readCodexAppServerBinding( typeof parsed.dynamicToolsFingerprint === "string" ? parsed.dynamicToolsFingerprint : undefined, + pluginAppsFingerprint: + typeof parsed.pluginAppsFingerprint === "string" ? parsed.pluginAppsFingerprint : undefined, + pluginAppsInputFingerprint: + typeof parsed.pluginAppsInputFingerprint === "string" + ? parsed.pluginAppsInputFingerprint + : undefined, + pluginAppPolicyContext: readPluginAppPolicyContext(parsed.pluginAppPolicyContext), createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(), updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(), }; @@ -119,6 +134,9 @@ export async function writeCodexAppServerBinding( sandbox: binding.sandbox, serviceTier: binding.serviceTier, dynamicToolsFingerprint: binding.dynamicToolsFingerprint, + pluginAppsFingerprint: binding.pluginAppsFingerprint, + pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, + pluginAppPolicyContext: binding.pluginAppPolicyContext, createdAt: binding.createdAt ?? now, updatedAt: now, }; @@ -128,6 +146,63 @@ export async function writeCodexAppServerBinding( ); } +function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + if (typeof record.fingerprint !== "string") { + return undefined; + } + const apps = record.apps; + if (!apps || typeof apps !== "object" || Array.isArray(apps)) { + return undefined; + } + const parsedApps: PluginAppPolicyContext["apps"] = {}; + for (const [appId, rawEntry] of Object.entries(apps)) { + if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) { + return undefined; + } + const entry = rawEntry as Record; + if ( + "appId" in entry || + typeof entry.configKey !== "string" || + entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + typeof entry.pluginName !== "string" || + typeof entry.allowDestructiveActions !== "boolean" || + !Array.isArray(entry.mcpServerNames) || + entry.mcpServerNames.some((serverName) => typeof serverName !== "string") + ) { + return undefined; + } + parsedApps[appId] = { + configKey: entry.configKey, + marketplaceName: entry.marketplaceName, + pluginName: entry.pluginName, + allowDestructiveActions: entry.allowDestructiveActions, + mcpServerNames: entry.mcpServerNames, + }; + } + const parsedPluginAppIds: PluginAppPolicyContext["pluginAppIds"] = {}; + const rawPluginAppIds = record.pluginAppIds; + if (rawPluginAppIds && (typeof rawPluginAppIds !== "object" || Array.isArray(rawPluginAppIds))) { + return undefined; + } + if (rawPluginAppIds && typeof rawPluginAppIds === "object") { + for (const [configKey, appIds] of Object.entries(rawPluginAppIds)) { + if (!Array.isArray(appIds) || appIds.some((appId) => typeof appId !== "string")) { + return undefined; + } + parsedPluginAppIds[configKey] = appIds; + } + } + return { + fingerprint: record.fingerprint, + apps: parsedApps, + pluginAppIds: parsedPluginAppIds, + }; +} + export async function clearCodexAppServerBinding(sessionFile: string): Promise { try { await fs.unlink(resolveCodexAppServerBindingPath(sessionFile)); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 0f6977c4c42..43acf9ad0c1 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -9,6 +9,11 @@ import { import { isModernCodexModel } from "../../provider.js"; import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js"; import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js"; +import { + isCodexPluginThreadBindingStale, + mergeCodexThreadConfigs, + type CodexPluginThreadConfig, +} from "./plugin-thread-config.js"; import { assertCodexThreadResumeResponse, assertCodexThreadStartResponse, @@ -32,6 +37,13 @@ import { type CodexAppServerThreadBinding, } from "./session-binding.js"; +export type CodexPluginThreadConfigProvider = { + enabled: boolean; + inputFingerprint?: string; + enabledPluginConfigKeys?: readonly string[]; + build: () => Promise; +}; + export async function startOrResumeThread(params: { client: CodexAppServerClient; params: EmbeddedRunAttemptParams; @@ -40,14 +52,50 @@ export async function startOrResumeThread(params: { appServer: CodexAppServerRuntimeOptions; developerInstructions?: string; config?: JsonObject; + pluginThreadConfig?: CodexPluginThreadConfigProvider; }): Promise { const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools); - const binding = await readCodexAppServerBinding(params.params.sessionFile, { + let binding = await readCodexAppServerBinding(params.params.sessionFile, { authProfileStore: params.params.authProfileStore, agentDir: params.params.agentDir, config: params.params.config, }); let preserveExistingBinding = false; + let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined; + if (binding?.threadId) { + let pluginBindingStale = isCodexPluginThreadBindingStale({ + codexPluginsEnabled: params.pluginThreadConfig?.enabled ?? false, + bindingFingerprint: binding.pluginAppsFingerprint, + bindingInputFingerprint: binding.pluginAppsInputFingerprint, + currentInputFingerprint: params.pluginThreadConfig?.inputFingerprint, + hasBindingPolicyContext: Boolean(binding.pluginAppPolicyContext), + }); + if ( + !pluginBindingStale && + shouldRecheckRecoverablePluginBinding({ + binding, + pluginThreadConfig: params.pluginThreadConfig, + }) + ) { + try { + prebuiltPluginThreadConfig = await params.pluginThreadConfig?.build(); + pluginBindingStale = + prebuiltPluginThreadConfig?.fingerprint !== binding.pluginAppsFingerprint; + } catch (error) { + embeddedAgentLog.warn("codex app-server plugin app config recovery check failed", { + error, + threadId: binding.threadId, + }); + } + } + if (pluginBindingStale) { + embeddedAgentLog.debug("codex app-server plugin app config changed; starting a new thread", { + threadId: binding.threadId, + }); + await clearCodexAppServerBinding(params.params.sessionFile); + binding = undefined; + } + } if (binding?.threadId) { // `/codex resume ` writes a binding before the next turn can know // the dynamic tool catalog, so only invalidate fingerprints we actually have. @@ -110,6 +158,9 @@ export async function startOrResumeThread(params: { model: params.params.modelId, modelProvider: response.modelProvider ?? fallbackModelProvider, dynamicToolsFingerprint, + pluginAppsFingerprint: binding.pluginAppsFingerprint, + pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, + pluginAppPolicyContext: binding.pluginAppPolicyContext, createdAt: binding.createdAt, }, { @@ -126,6 +177,9 @@ export async function startOrResumeThread(params: { model: params.params.modelId, modelProvider: response.modelProvider ?? fallbackModelProvider, dynamicToolsFingerprint, + pluginAppsFingerprint: binding.pluginAppsFingerprint, + pluginAppsInputFingerprint: binding.pluginAppsInputFingerprint, + pluginAppPolicyContext: binding.pluginAppPolicyContext, }; } catch (error) { if (isCodexAppServerConnectionClosedError(error)) { @@ -139,6 +193,10 @@ export async function startOrResumeThread(params: { } } + const pluginThreadConfig = params.pluginThreadConfig?.enabled + ? (prebuiltPluginThreadConfig ?? (await params.pluginThreadConfig.build())) + : undefined; + const config = mergeCodexThreadConfigs(params.config, pluginThreadConfig?.configPatch); const response = assertCodexThreadStartResponse( await params.client.request( "thread/start", @@ -147,7 +205,7 @@ export async function startOrResumeThread(params: { dynamicTools: params.dynamicTools, appServer: params.appServer, developerInstructions: params.developerInstructions, - config: params.config, + config, }), ), ); @@ -169,6 +227,9 @@ export async function startOrResumeThread(params: { model: response.model ?? params.params.modelId, modelProvider: response.modelProvider ?? modelProvider, dynamicToolsFingerprint, + pluginAppsFingerprint: pluginThreadConfig?.fingerprint, + pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, + pluginAppPolicyContext: pluginThreadConfig?.policyContext, createdAt, }, { @@ -187,11 +248,36 @@ export async function startOrResumeThread(params: { model: response.model ?? params.params.modelId, modelProvider: response.modelProvider ?? modelProvider, dynamicToolsFingerprint, + pluginAppsFingerprint: pluginThreadConfig?.fingerprint, + pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, + pluginAppPolicyContext: pluginThreadConfig?.policyContext, createdAt, updatedAt: createdAt, }; } +function shouldRecheckRecoverablePluginBinding(params: { + binding: CodexAppServerThreadBinding; + pluginThreadConfig?: CodexPluginThreadConfigProvider; +}): boolean { + if (!params.pluginThreadConfig?.enabled) { + return false; + } + if ( + !params.binding.pluginAppsFingerprint || + !params.binding.pluginAppsInputFingerprint || + params.binding.pluginAppsInputFingerprint !== params.pluginThreadConfig.inputFingerprint + ) { + return false; + } + const policyContext = params.binding.pluginAppPolicyContext; + if (!policyContext) { + return false; + } + const expectedPluginConfigKeys = params.pluginThreadConfig.enabledPluginConfigKeys ?? []; + return Object.keys(policyContext.apps).length === 0 || expectedPluginConfigKeys.length > 0; +} + export function buildThreadStartParams( params: EmbeddedRunAttemptParams, options: { diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index f1ee5b4802f..b3c72d871b6 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -224,6 +224,8 @@ async function attachExistingThread(params: { { timeoutMs: runtime.requestTimeoutMs }, ); const thread = response.thread; + const runtimeApprovalPolicy = + typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined; await writeCodexAppServerBinding( params.sessionFile, { @@ -236,7 +238,7 @@ async function attachExistingThread(params: { authProfileId: params.authProfileId, modelProvider: response.modelProvider ?? params.modelProvider, }), - approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy, + approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy, sandbox: params.sandbox ?? runtime.sandbox, serviceTier: params.serviceTier ?? runtime.serviceTier, }, @@ -290,6 +292,8 @@ async function createThread(params: { }, { timeoutMs: runtime.requestTimeoutMs }, ); + const runtimeApprovalPolicy = + typeof runtime.approvalPolicy === "string" ? runtime.approvalPolicy : undefined; await writeCodexAppServerBinding( params.sessionFile, { @@ -302,7 +306,7 @@ async function createThread(params: { authProfileId: params.authProfileId, modelProvider: response.modelProvider ?? params.modelProvider, }), - approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy, + approvalPolicy: params.approvalPolicy ?? runtimeApprovalPolicy, sandbox: params.sandbox ?? runtime.sandbox, serviceTier: params.serviceTier ?? runtime.serviceTier, }, diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index df160f14bab..0b426b59a69 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -1,8 +1,17 @@ import path from "node:path"; -import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration"; +import { + applyMigrationManualItem, + markMigrationItemConflict, + markMigrationItemError, + markMigrationItemSkipped, + MIGRATION_REASON_TARGET_EXISTS, + summarizeMigrationItems, + writeMigrationConfigPath, +} from "openclaw/plugin-sdk/migration"; import { archiveMigrationItem, copyMigrationFileItem, + withCachedMigrationConfigRuntime, writeMigrationReport, } from "openclaw/plugin-sdk/migration-runtime"; import type { @@ -11,21 +20,62 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAME, + type ResolvedCodexPluginPolicy, +} from "../app-server/config.js"; +import { + ensureCodexPluginActivation, + type CodexPluginActivationResult, +} from "../app-server/plugin-activation.js"; +import type { v2 } from "../app-server/protocol.js"; +import { requestCodexAppServerJson } from "../app-server/request.js"; import { buildCodexMigrationPlan } from "./plan.js"; +import { + buildCodexPluginsConfigValue, + CODEX_PLUGIN_CONFIG_ITEM_ID, + CODEX_PLUGIN_CONFIG_PATH, + hasCodexPluginConfigConflict, + readCodexPluginMigrationConfigEntry, + type CodexPluginMigrationConfigEntry, +} from "./plan.js"; + +const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required"; +const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration"; + +class CodexPluginConfigConflictError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "CodexPluginConfigConflictError"; + } +} export async function applyCodexMigrationPlan(params: { ctx: MigrationProviderContext; plan?: MigrationPlan; + runtime?: MigrationProviderContext["runtime"]; }): Promise { const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx)); const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex"); const items: MigrationItem[] = []; + const runtime = withCachedMigrationConfigRuntime( + params.ctx.runtime ?? params.runtime, + params.ctx.config, + ); + const applyCtx = { ...params.ctx, runtime }; for (const item of plan.items) { if (item.status !== "planned") { items.push(item); continue; } - if (item.action === "archive") { + if (item.id === CODEX_PLUGIN_CONFIG_ITEM_ID) { + items.push(await applyCodexPluginConfigItem(applyCtx, item, items)); + } else if (item.kind === "plugin" && item.action === "install") { + items.push(await applyCodexPluginInstallItem(applyCtx, item)); + } else if (item.kind === "manual") { + items.push(applyMigrationManualItem(item)); + } else if (item.action === "archive") { items.push(await archiveMigrationItem(item, reportDir)); } else { items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite })); @@ -41,3 +91,190 @@ export async function applyCodexMigrationPlan(params: { await writeMigrationReport(result, { title: "Codex Migration Report" }); return result; } + +async function applyCodexPluginInstallItem( + ctx: MigrationProviderContext, + item: MigrationItem, +): Promise { + const policy = readCodexPluginPolicy(item); + if (!policy) { + return { + ...markMigrationItemError(item, "invalid Codex plugin migration item"), + details: { ...item.details, code: "invalid_plugin_item" }, + }; + } + try { + const result = await ensureCodexPluginActivation({ + identity: policy, + installEvenIfActive: true, + request: async (method, requestParams) => + await requestCodexAppServerJson({ + method, + requestParams, + timeoutMs: 60_000, + config: ctx.config, + }), + }); + defaultCodexAppInventoryCache.clear(); + const baseDetails = { + ...item.details, + code: result.reason, + activationReason: result.reason, + ...codexPluginActivationReportState(result), + installAttempted: result.installAttempted, + diagnostics: result.diagnostics.map((diagnostic) => diagnostic.message), + }; + if (result.ok) { + return { + ...item, + status: "migrated", + ...(result.reason === "already_active" ? { reason: "already active" } : {}), + details: baseDetails, + }; + } + if (result.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON) { + return { + ...item, + status: "skipped", + reason: CODEX_PLUGIN_AUTH_REQUIRED_REASON, + details: { + ...baseDetails, + appsNeedingAuth: sanitizeAppsNeedingAuth(result.installResponse?.appsNeedingAuth ?? []), + }, + }; + } + return { + ...item, + status: "error", + reason: result.reason, + details: baseDetails, + }; + } catch (error) { + return { + ...item, + status: "error", + reason: error instanceof Error ? error.message : String(error), + details: { + ...item.details, + code: "plugin_install_failed", + }, + }; + } +} + +async function applyCodexPluginConfigItem( + ctx: MigrationProviderContext, + item: MigrationItem, + appliedItems: readonly MigrationItem[], +): Promise { + const entries = appliedItems + .map(readAppliedPluginConfigEntry) + .filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined); + if (entries.length === 0) { + return markMigrationItemSkipped(item, "no selected Codex plugins"); + } + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return markMigrationItemError(item, "config runtime unavailable"); + } + const currentConfig = configApi.current() as MigrationProviderContext["config"]; + const value = buildCodexPluginsConfigValue(entries, { config: currentConfig }); + if (!ctx.overwrite && hasCodexPluginConfigConflict(currentConfig, value)) { + return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); + } + try { + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + if (!ctx.overwrite && hasCodexPluginConfigConflict(draft, value)) { + throw new CodexPluginConfigConflictError(MIGRATION_REASON_TARGET_EXISTS); + } + writeMigrationConfigPath(draft as Record, CODEX_PLUGIN_CONFIG_PATH, value); + }, + }); + return { + ...item, + status: "migrated", + details: { + ...item.details, + path: [...CODEX_PLUGIN_CONFIG_PATH], + value, + }, + }; + } catch (error) { + if (error instanceof CodexPluginConfigConflictError) { + return markMigrationItemConflict(item, error.reason); + } + return markMigrationItemError(item, error instanceof Error ? error.message : String(error)); + } +} + +function readAppliedPluginConfigEntry( + item: MigrationItem, +): CodexPluginMigrationConfigEntry | undefined { + if (item.status === "migrated") { + return readCodexPluginMigrationConfigEntry(item, true); + } + if ( + item.status === "skipped" && + item.reason !== CODEX_PLUGIN_NOT_SELECTED_REASON && + item.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON + ) { + return readCodexPluginMigrationConfigEntry(item, false); + } + return undefined; +} + +function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy | undefined { + const configKey = item.details?.configKey; + const marketplaceName = item.details?.marketplaceName; + const pluginName = item.details?.pluginName; + if ( + typeof configKey !== "string" || + marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + typeof pluginName !== "string" + ) { + return undefined; + } + return { + configKey, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName, + enabled: true, + allowDestructiveActions: false, + }; +} + +function codexPluginActivationReportState(result: CodexPluginActivationResult): { + installed?: boolean; + enabled?: boolean; +} { + switch (result.reason) { + case "already_active": + case "installed": + return { installed: true, enabled: true }; + case "auth_required": + return { installed: true, enabled: false }; + case "disabled": + case "marketplace_missing": + case "plugin_missing": + return { installed: false, enabled: false }; + case "refresh_failed": + return { installed: true, enabled: false }; + } + const exhaustiveReason: never = result.reason; + return exhaustiveReason; +} + +function sanitizeAppsNeedingAuth(apps: readonly v2.AppSummary[]): Array<{ + id: string; + name: string; + needsAuth: boolean; +}> { + return apps.map((app) => ({ + id: app.id, + name: app.name, + needsAuth: app.needsAuth, + })); +} diff --git a/extensions/codex/src/migration/plan.ts b/extensions/codex/src/migration/plan.ts index 66eaa1e44aa..81d4ccc1a99 100644 --- a/extensions/codex/src/migration/plan.ts +++ b/extensions/codex/src/migration/plan.ts @@ -2,7 +2,9 @@ import path from "node:path"; import { createMigrationItem, createMigrationManualItem, + hasMigrationConfigPatchConflict, MIGRATION_REASON_TARGET_EXISTS, + readMigrationConfigPath, summarizeMigrationItems, } from "openclaw/plugin-sdk/migration"; import type { @@ -10,10 +12,33 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js"; import { exists, sanitizeName } from "./helpers.js"; -import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js"; +import { + discoverCodexSource, + hasCodexSource, + type CodexPluginSource, + type CodexSkillSource, +} from "./source.js"; import { resolveCodexMigrationTargets } from "./targets.js"; +export const CODEX_PLUGIN_CONFIG_ITEM_ID = "config:codex-plugins"; +export const CODEX_PLUGIN_CONFIG_PATH = ["plugins", "entries", "codex"] as const; +const CODEX_PLUGIN_ENABLED_PATH = ["plugins", "entries", "codex", "enabled"] as const; +const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [ + "plugins", + "entries", + "codex", + "config", + "codexPlugins", +] as const; + +export type CodexPluginMigrationConfigEntry = { + configKey: string; + pluginName: string; + enabled: boolean; +}; + function uniqueSkillName(skill: CodexSkillSource, counts: Map): string { const base = sanitizeName(skill.name) || "codex-skill"; if ((counts.get(base) ?? 0) <= 1) { @@ -67,6 +92,176 @@ async function buildSkillItems(params: { return items; } +function uniquePluginConfigKey( + plugin: CodexPluginSource, + counts: Map, + usedCounts: Map, +): string { + const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin"; + const total = counts.get(base) ?? 0; + if (total <= 1) { + return base; + } + const next = (usedCounts.get(base) ?? 0) + 1; + usedCounts.set(base, next); + return sanitizeName(`${base}-${next}`) || base; +} + +function buildPluginItems(plugins: readonly CodexPluginSource[]): MigrationItem[] { + const baseCounts = new Map(); + for (const plugin of plugins.filter((entry) => entry.migratable)) { + const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin"; + baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1); + } + const usedCounts = new Map(); + let manualIndex = 0; + const items: MigrationItem[] = []; + for (const plugin of plugins) { + if ( + plugin.migratable && + plugin.marketplaceName === CODEX_PLUGINS_MARKETPLACE_NAME && + plugin.pluginName + ) { + const configKey = uniquePluginConfigKey(plugin, baseCounts, usedCounts); + items.push( + createMigrationItem({ + id: `plugin:${configKey}`, + kind: "plugin", + action: "install", + source: plugin.source, + target: `plugins.entries.codex.config.codexPlugins.plugins.${configKey}`, + message: `Install Codex plugin "${plugin.pluginName}" in the OpenClaw-managed Codex app-server runtime.`, + details: { + configKey, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: plugin.pluginName, + sourceInstalled: plugin.installed === true, + sourceEnabled: plugin.enabled === true, + }, + }), + ); + continue; + } + + manualIndex += 1; + items.push( + createMigrationManualItem({ + id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`, + source: plugin.source, + message: + plugin.message ?? + `Codex native plugin "${plugin.name}" was found but not activated automatically.`, + recommendation: + "Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install .", + }), + ); + } + return items; +} + +export function readCodexPluginMigrationConfigEntry( + item: MigrationItem, + enabled: boolean, +): CodexPluginMigrationConfigEntry | undefined { + const configKey = item.details?.configKey; + const marketplaceName = item.details?.marketplaceName; + const pluginName = item.details?.pluginName; + if ( + item.kind !== "plugin" || + item.action !== "install" || + typeof configKey !== "string" || + marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + typeof pluginName !== "string" + ) { + return undefined; + } + return { configKey, pluginName, enabled }; +} + +function readExistingAllowDestructiveActions( + config: MigrationProviderContext["config"], +): boolean | undefined { + const value = readMigrationConfigPath(config as Record, [ + ...CODEX_PLUGIN_NATIVE_CONFIG_PATH, + "allow_destructive_actions", + ]); + return typeof value === "boolean" ? value : undefined; +} + +export function buildCodexPluginsConfigValue( + entries: readonly CodexPluginMigrationConfigEntry[], + params: { config?: MigrationProviderContext["config"] } = {}, +): Record { + const plugins = Object.fromEntries( + entries + .toSorted((a, b) => a.configKey.localeCompare(b.configKey)) + .map((entry) => [ + entry.configKey, + { + enabled: entry.enabled, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: entry.pluginName, + }, + ]), + ); + return { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: + params.config === undefined + ? false + : (readExistingAllowDestructiveActions(params.config) ?? false), + plugins, + }, + }, + }; +} + +export function hasCodexPluginConfigConflict( + config: MigrationProviderContext["config"], + value: Record, +): boolean { + const enabled = readMigrationConfigPath( + config as Record, + CODEX_PLUGIN_ENABLED_PATH, + ); + if (enabled !== undefined && enabled !== true) { + return true; + } + const nativeConfig = (value.config as Record | undefined)?.codexPlugins; + return hasMigrationConfigPatchConflict(config, CODEX_PLUGIN_NATIVE_CONFIG_PATH, nativeConfig); +} + +function buildPluginConfigItem( + ctx: MigrationProviderContext, + pluginItems: readonly MigrationItem[], +): MigrationItem | undefined { + const entries = pluginItems + .map((item) => readCodexPluginMigrationConfigEntry(item, true)) + .filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined); + if (entries.length === 0) { + return undefined; + } + const value = buildCodexPluginsConfigValue(entries, { config: ctx.config }); + const conflict = !ctx.overwrite && hasCodexPluginConfigConflict(ctx.config, value); + return createMigrationItem({ + id: CODEX_PLUGIN_CONFIG_ITEM_ID, + kind: "config", + action: "merge", + target: "plugins.entries.codex.config.codexPlugins", + status: conflict ? "conflict" : "planned", + reason: conflict ? MIGRATION_REASON_TARGET_EXISTS : undefined, + message: + "Enable OpenClaw's Codex plugin integration and record migrated source-installed curated plugins.", + details: { + path: [...CODEX_PLUGIN_CONFIG_PATH], + value, + }, + }); +} + export async function buildCodexMigrationPlan( ctx: MigrationProviderContext, ): Promise { @@ -85,16 +280,11 @@ export async function buildCodexMigrationPlan( overwrite: ctx.overwrite, })), ); - for (const [index, plugin] of source.plugins.entries()) { - items.push( - createMigrationManualItem({ - id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${index + 1}`, - source: plugin.source, - message: `Codex native plugin "${plugin.name}" was found but not activated automatically.`, - recommendation: - "Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install .", - }), - ); + const pluginItems = buildPluginItems(source.plugins); + items.push(...pluginItems); + const pluginConfigItem = buildPluginConfigItem(ctx, pluginItems); + if (pluginConfigItem) { + items.push(pluginConfigItem); } for (const archivePath of source.archivePaths) { items.push( @@ -118,7 +308,12 @@ export async function buildCodexMigrationPlan( : []), ...(source.plugins.length > 0 ? [ - "Codex native plugins are reported for manual review only. OpenClaw does not auto-activate plugin bundles, hooks, MCP servers, or apps from another Codex home.", + "Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.", + ] + : []), + ...(source.pluginDiscoveryError + ? [ + `Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`, ] : []), ...(source.archivePaths.length > 0 @@ -136,7 +331,7 @@ export async function buildCodexMigrationPlan( warnings, nextSteps: [ "Run openclaw doctor after applying the migration.", - "Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.", + "Review skipped or auth-required Codex plugin/config/hook items before exposing them in OpenClaw sessions.", ], metadata: { agentDir: targets.agentDir, diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 1a280923b9b..7ff24228645 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -2,9 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js"; +import type { v2 } from "../app-server/protocol.js"; import { buildCodexMigrationProvider } from "./provider.js"; +const appServerRequest = vi.hoisted(() => vi.fn()); + +vi.mock("../app-server/request.js", () => ({ + requestCodexAppServerJson: appServerRequest, +})); + const tempRoots = new Set(); const logger = { @@ -31,15 +39,20 @@ function makeContext(params: { workspaceDir: string; overwrite?: boolean; reportDir?: string; + config?: MigrationProviderContext["config"]; + runtime?: MigrationProviderContext["runtime"]; }): MigrationProviderContext { return { - config: { - agents: { - defaults: { - workspace: params.workspaceDir, + config: + params.config ?? + ({ + agents: { + defaults: { + workspace: params.workspaceDir, + }, }, - }, - } as MigrationProviderContext["config"], + } as MigrationProviderContext["config"]), + runtime: params.runtime, source: params.source, stateDir: params.stateDir, overwrite: params.overwrite, @@ -84,6 +97,7 @@ async function createCodexFixture(): Promise<{ afterEach(async () => { vi.unstubAllEnvs(); + appServerRequest.mockReset(); for (const root of tempRoots) { await fs.rm(root, { recursive: true, force: true }); } @@ -91,6 +105,10 @@ afterEach(async () => { }); describe("buildCodexMigrationProvider", () => { + beforeEach(() => { + appServerRequest.mockRejectedValue(new Error("codex app-server unavailable")); + }); + it("plans Codex skills while keeping plugins and native config explicit", async () => { const fixture = await createCodexFixture(); const provider = buildCodexMigrationProvider(); @@ -145,8 +163,54 @@ describe("buildCodexMigrationProvider", () => { expect.arrayContaining([expect.objectContaining({ id: "skill:system-skill" })]), ); expect(plan.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("cached plugin bundles")]), + ); + }); + + it("plans source-installed curated plugins without installing during dry-run", async () => { + const fixture = await createCodexFixture(); + appServerRequest.mockResolvedValueOnce( + pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]), + ); + const provider = buildCodexMigrationProvider(); + + const plan = await provider.plan( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + }), + ); + + expect(appServerRequest).toHaveBeenCalledTimes(1); + expect(appServerRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "plugin/list", + requestParams: { cwds: [] }, + }), + ); + expect(appServerRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "plugin/install" }), + ); + expect(plan.items).toEqual( expect.arrayContaining([ - expect.stringContaining("Codex native plugins are reported for manual review only"), + expect.objectContaining({ + id: "plugin:google-calendar", + kind: "plugin", + action: "install", + status: "planned", + details: expect.objectContaining({ + configKey: "google-calendar", + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }), + }), + expect.objectContaining({ + id: "config:codex-plugins", + kind: "config", + action: "merge", + status: "planned", + }), ]), ); }); @@ -184,6 +248,381 @@ describe("buildCodexMigrationProvider", () => { await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined(); }); + it("installs selected curated plugins during apply and writes codexPlugins config", async () => { + const fixture = await createCodexFixture(); + const reportDir = path.join(fixture.root, "report"); + const configState: MigrationProviderContext["config"] = { + plugins: { + entries: { + codex: { + enabled: true, + config: { + appServer: { sandbox: "workspace-write" }, + }, + }, + }, + }, + agents: { defaults: { workspace: fixture.workspaceDir } }, + } as MigrationProviderContext["config"]; + appServerRequest.mockImplementation(async ({ method }: { method: string }) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }); + const provider = buildCodexMigrationProvider({ + runtime: createConfigRuntime(configState), + }); + + const result = await provider.apply( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + reportDir, + config: configState, + }), + ); + + expect(appServerRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "plugin/install", + requestParams: { + marketplacePath: "/marketplaces/openai-curated", + pluginName: "google-calendar", + }, + }), + ); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin:google-calendar", + status: "migrated", + reason: "already active", + details: expect.objectContaining({ + code: "already_active", + installAttempted: true, + }), + }), + expect.objectContaining({ + id: "config:codex-plugins", + status: "migrated", + }), + ]), + ); + expect(configState.plugins?.entries?.codex).toMatchObject({ + enabled: true, + config: { + appServer: { sandbox: "workspace-write" }, + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }, + }, + }); + expect(configState.plugins?.entries?.codex?.config?.codexPlugins).not.toHaveProperty("*"); + }); + + it("does not merge migrated plugin config over existing codexPlugins without overwrite", async () => { + const fixture = await createCodexFixture(); + const configState: MigrationProviderContext["config"] = { + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: true, + plugins: { + slack: { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "slack", + }, + }, + }, + }, + }, + }, + }, + agents: { defaults: { workspace: fixture.workspaceDir } }, + } as MigrationProviderContext["config"]; + appServerRequest.mockImplementation(async ({ method }: { method: string }) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }); + const provider = buildCodexMigrationProvider({ + runtime: createConfigRuntime(configState), + }); + + const result = await provider.apply( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + }), + ); + + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "config:codex-plugins", + status: "conflict", + reason: "target exists", + }), + ]), + ); + expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({ + allow_destructive_actions: true, + plugins: { + slack: { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "slack", + }, + }, + }); + const codexPlugins = configState.plugins?.entries?.codex?.config?.codexPlugins as + | { plugins?: Record } + | undefined; + expect(codexPlugins?.plugins).not.toHaveProperty("google-calendar"); + }); + + it("preserves existing destructive plugin policy when overwrite is explicit", async () => { + const fixture = await createCodexFixture(); + const configState: MigrationProviderContext["config"] = { + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: true, + plugins: {}, + }, + }, + }, + }, + }, + agents: { defaults: { workspace: fixture.workspaceDir } }, + } as MigrationProviderContext["config"]; + appServerRequest.mockImplementation(async ({ method }: { method: string }) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }); + const provider = buildCodexMigrationProvider({ + runtime: createConfigRuntime(configState), + }); + + const result = await provider.apply( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + overwrite: true, + }), + ); + + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "config:codex-plugins", + status: "migrated", + }), + ]), + ); + expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({ + enabled: true, + allow_destructive_actions: true, + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }); + }); + + it("records auth-required plugin installs as disabled explicit config entries", async () => { + const fixture = await createCodexFixture(); + const configState: MigrationProviderContext["config"] = { + agents: { defaults: { workspace: fixture.workspaceDir } }, + } as MigrationProviderContext["config"]; + appServerRequest.mockImplementation(async ({ method }: { method: string }) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + return { + authPolicy: "ON_USE", + appsNeedingAuth: [ + { + id: "google-calendar", + name: "Google Calendar", + description: "Calendar", + installUrl: "https://example.invalid/auth", + needsAuth: true, + }, + ], + } satisfies v2.PluginInstallResponse; + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + if (method === "config/mcpServer/reload") { + return {}; + } + throw new Error(`unexpected request ${method}`); + }); + const provider = buildCodexMigrationProvider({ + runtime: createConfigRuntime(configState), + }); + + const result = await provider.apply( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + }), + ); + + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin:google-calendar", + status: "skipped", + reason: "auth_required", + details: expect.objectContaining({ + code: "auth_required", + appsNeedingAuth: [ + { + id: "google-calendar", + name: "Google Calendar", + needsAuth: true, + }, + ], + }), + }), + ]), + ); + expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toMatchObject({ + enabled: true, + plugins: { + "google-calendar": { + enabled: false, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + pluginName: "google-calendar", + }, + }, + }); + }); + + it("does not write config entries for failed plugin installs", async () => { + const fixture = await createCodexFixture(); + const configState: MigrationProviderContext["config"] = { + agents: { defaults: { workspace: fixture.workspaceDir } }, + } as MigrationProviderContext["config"]; + appServerRequest.mockImplementation(async ({ method }: { method: string }) => { + if (method === "plugin/list") { + return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); + } + if (method === "plugin/install") { + throw new Error("install failed"); + } + if (method === "skills/list") { + return { data: [] } satisfies v2.SkillsListResponse; + } + if (method === "hooks/list") { + return { data: [] } satisfies v2.HooksListResponse; + } + throw new Error(`unexpected request ${method}`); + }); + const provider = buildCodexMigrationProvider({ + runtime: createConfigRuntime(configState), + }); + + const result = await provider.apply( + makeContext({ + source: fixture.codexHome, + stateDir: fixture.stateDir, + workspaceDir: fixture.workspaceDir, + config: configState, + }), + ); + + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin:google-calendar", + status: "error", + reason: "install failed", + }), + expect.objectContaining({ + id: "config:codex-plugins", + status: "skipped", + reason: "no selected Codex plugins", + }), + ]), + ); + expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined(); + }); + it("reports existing skill targets as conflicts unless overwrite is set", async () => { const fixture = await createCodexFixture(); await writeFile(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md")); @@ -217,3 +656,61 @@ describe("buildCodexMigrationProvider", () => { ); }); }); + +function createConfigRuntime( + configState: MigrationProviderContext["config"], +): MigrationProviderContext["runtime"] { + type Runtime = NonNullable; + type MutateConfigFileParams = Parameters[0]; + type MutateConfigFileResult = Awaited>; + return { + config: { + current: () => configState, + mutateConfigFile: async (params: MutateConfigFileParams): Promise => { + const result = await params.mutate(configState, { + snapshot: {} as never, + previousHash: null, + }); + return { + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig: configState, + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + result, + }; + }, + }, + } as unknown as MigrationProviderContext["runtime"]; +} + +function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { + return { + marketplaces: [ + { + name: CODEX_PLUGINS_MARKETPLACE_NAME, + path: "/marketplaces/openai-curated", + interface: null, + plugins, + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; +} + +function pluginSummary(id: string, overrides: Partial = {}): v2.PluginSummary { + return { + id, + name: id, + source: { type: "remote" }, + installed: false, + enabled: false, + installPolicy: "AVAILABLE", + authPolicy: "ON_USE", + availability: "AVAILABLE", + interface: null, + ...overrides, + }; +} diff --git a/extensions/codex/src/migration/provider.ts b/extensions/codex/src/migration/provider.ts index 3831a9f48e6..48a08530243 100644 --- a/extensions/codex/src/migration/provider.ts +++ b/extensions/codex/src/migration/provider.ts @@ -1,9 +1,17 @@ -import type { MigrationPlan, MigrationProviderPlugin } from "openclaw/plugin-sdk/plugin-entry"; +import type { + MigrationPlan, + MigrationProviderContext, + MigrationProviderPlugin, +} from "openclaw/plugin-sdk/plugin-entry"; import { applyCodexMigrationPlan } from "./apply.js"; import { buildCodexMigrationPlan } from "./plan.js"; import { discoverCodexSource, hasCodexSource } from "./source.js"; -export function buildCodexMigrationProvider(): MigrationProviderPlugin { +export function buildCodexMigrationProvider( + params: { + runtime?: MigrationProviderContext["runtime"]; + } = {}, +): MigrationProviderPlugin { return { id: "codex", label: "Codex", @@ -22,7 +30,7 @@ export function buildCodexMigrationProvider(): MigrationProviderPlugin { }, plan: buildCodexMigrationPlan, async apply(ctx, plan?: MigrationPlan) { - return await applyCodexMigrationPlan({ ctx, plan }); + return await applyCodexMigrationPlan({ ctx, plan, runtime: params.runtime }); }, }; } diff --git a/extensions/codex/src/migration/source.ts b/extensions/codex/src/migration/source.ts index cee268cb673..3f6a4db2207 100644 --- a/extensions/codex/src/migration/source.ts +++ b/extensions/codex/src/migration/source.ts @@ -1,6 +1,9 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js"; +import type { v2 } from "../app-server/protocol.js"; +import { requestCodexAppServerJson } from "../app-server/request.js"; import { exists, isDirectory, @@ -19,10 +22,17 @@ export type CodexSkillSource = { sourceLabel: string; }; -type CodexPluginSource = { +export type CodexPluginSource = { name: string; source: string; - manifestPath: string; + sourceKind: "app-server" | "cache"; + migratable: boolean; + manifestPath?: string; + marketplaceName?: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + pluginName?: string; + installed?: boolean; + enabled?: boolean; + message?: string; }; type CodexArchiveSource = { @@ -42,6 +52,7 @@ type CodexSource = { hooksPath?: string; skills: CodexSkillSource[]; plugins: CodexPluginSource[]; + pluginDiscoveryError?: string; archivePaths: CodexArchiveSource[]; }; @@ -104,7 +115,15 @@ async function discoverPluginDirs(codexHome: string): Promise a.source.localeCompare(b.source)); } +async function discoverInstalledCuratedPlugins(codexHome: string): Promise<{ + plugins: CodexPluginSource[]; + error?: string; +}> { + try { + const response = await requestCodexAppServerJson({ + method: "plugin/list", + requestParams: { cwds: [] } satisfies v2.PluginListParams, + timeoutMs: 60_000, + startOptions: { + transport: "stdio", + command: "codex", + commandSource: "config", + args: ["app-server", "--listen", "stdio://"], + headers: {}, + env: { + CODEX_HOME: codexHome, + HOME: path.dirname(codexHome), + }, + }, + }); + const marketplace = response.marketplaces.find( + (entry) => entry.name === CODEX_PLUGINS_MARKETPLACE_NAME, + ); + if (!marketplace) { + return { + plugins: [], + error: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found in source plugin inventory.`, + }; + } + const plugins = marketplace.plugins + .filter((plugin) => plugin.installed) + .map((plugin): CodexPluginSource | undefined => { + const pluginName = pluginNameFromSummary(plugin); + if (!pluginName) { + return undefined; + } + return { + name: plugin.name, + pluginName, + marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + source: `${CODEX_PLUGINS_MARKETPLACE_NAME}/${pluginName}`, + sourceKind: "app-server", + migratable: true, + installed: plugin.installed, + enabled: plugin.enabled, + }; + }) + .filter((plugin): plugin is CodexPluginSource => plugin !== undefined) + .toSorted((a, b) => (a.pluginName ?? a.name).localeCompare(b.pluginName ?? b.name)); + return { plugins }; + } catch (error) { + return { + plugins: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function pluginNameFromSummary(summary: v2.PluginSummary): string | undefined { + const candidates = [summary.id, summary.name]; + for (const candidate of candidates) { + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + const withoutMarketplaceSuffix = trimmed.endsWith(`@${CODEX_PLUGINS_MARKETPLACE_NAME}`) + ? trimmed.slice(0, -`@${CODEX_PLUGINS_MARKETPLACE_NAME}`.length) + : trimmed; + const pathSegment = withoutMarketplaceSuffix.split("/").at(-1)?.trim(); + const normalized = pathSegment?.toLowerCase().replaceAll(/\s+/gu, "-"); + if (normalized) { + return normalized; + } + } + return undefined; +} + export async function discoverCodexSource(input?: string): Promise { const codexHome = resolveHomePath(input?.trim() || defaultCodexHome()); const codexSkillsDir = path.join(codexHome, "skills"); @@ -133,7 +230,19 @@ export async function discoverCodexSource(input?: string): Promise root: agentsSkillsDir, sourceLabel: "personal AgentSkill", }); - const plugins = await discoverPluginDirs(codexHome); + const sourcePluginDiscovery = await discoverInstalledCuratedPlugins(codexHome); + const sourcePluginNames = new Set( + sourcePluginDiscovery.plugins.flatMap((plugin) => + plugin.pluginName ? [plugin.pluginName] : [], + ), + ); + const cachedPlugins = (await discoverPluginDirs(codexHome)).filter((plugin) => { + const normalizedName = sanitizePluginName(plugin.name); + return !sourcePluginNames.has(normalizedName); + }); + const plugins = [...sourcePluginDiscovery.plugins, ...cachedPlugins].toSorted((a, b) => + a.source.localeCompare(b.source), + ); const archivePaths: CodexArchiveSource[] = []; if (await exists(configPath)) { archivePaths.push({ @@ -167,6 +276,7 @@ export async function discoverCodexSource(input?: string): Promise ...((await exists(hooksPath)) ? { hooksPath } : {}), skills, plugins, + ...(sourcePluginDiscovery.error ? { pluginDiscoveryError: sourcePluginDiscovery.error } : {}), archivePaths, }; } @@ -174,3 +284,7 @@ export async function discoverCodexSource(input?: string): Promise export function hasCodexSource(source: CodexSource): boolean { return source.confidence !== "low"; } + +function sanitizePluginName(value: string): string { + return value.trim().toLowerCase().replaceAll(/\s+/gu, "-"); +} diff --git a/src/cli/program/register.migrate.ts b/src/cli/program/register.migrate.ts index 022b222da3b..424e0b9a918 100644 --- a/src/cli/program/register.migrate.ts +++ b/src/cli/program/register.migrate.ts @@ -14,6 +14,10 @@ function collectMigrationSkill(value: string, previous: string[] | undefined): s return [...(previous ?? []), value]; } +function collectMigrationPlugin(value: string, previous: string[] | undefined): string[] { + return [...(previous ?? []), value]; +} + function readMigrationSkills(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -25,6 +29,17 @@ function readMigrationSkills(value: unknown): string[] | undefined { return skills.length > 0 ? skills : undefined; } +function readMigrationPlugins(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const plugins = value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return plugins.length > 0 ? plugins : undefined; +} + function addMigrationSkillOption(command: Command): Command { return command.option( "--skill ", @@ -33,13 +48,23 @@ function addMigrationSkillOption(command: Command): Command { ); } +function addMigrationPluginOption(command: Command): Command { + return command.option( + "--plugin ", + "Select one Codex plugin to migrate by name or item id; repeat for multiple plugins", + collectMigrationPlugin, + ); +} + function addMigrationOptions(command: Command): Command { - return addMigrationSkillOption( - command - .option("--from ", "Source directory to migrate from") - .option("--include-secrets", "Import supported credentials and secrets", false) - .option("--overwrite", "Overwrite conflicting target files after item-level backups", false) - .option("--json", "Output JSON", false), + return addMigrationPluginOption( + addMigrationSkillOption( + command + .option("--from ", "Source directory to migrate from") + .option("--include-secrets", "Import supported credentials and secrets", false) + .option("--overwrite", "Overwrite conflicting target files after item-level backups", false) + .option("--json", "Output JSON", false), + ), ); } @@ -58,6 +83,11 @@ export function registerMigrateCommand(program: Command) { "Select one skill to migrate by name or item id; repeat for multiple skills", collectMigrationSkill, ) + .option( + "--plugin ", + "Select one Codex plugin to migrate by name or item id; repeat for multiple plugins", + collectMigrationPlugin, + ) .option("--backup-output ", "Pre-migration backup archive path or directory") .option("--no-backup", "Skip the pre-migration OpenClaw backup") .option("--force", "Allow dangerous options such as --no-backup", false) @@ -87,6 +117,7 @@ export function registerMigrateCommand(program: Command) { includeSecrets: Boolean(opts.includeSecrets), overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), + plugins: readMigrationPlugins(opts.plugin), dryRun: Boolean(opts.dryRun), yes: Boolean(opts.yes), backupOutput: opts.backupOutput as string | undefined, @@ -119,6 +150,7 @@ export function registerMigrateCommand(program: Command) { includeSecrets: Boolean(opts.includeSecrets), overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), + plugins: readMigrationPlugins(opts.plugin), json: Boolean(opts.json), }); }); @@ -139,6 +171,7 @@ export function registerMigrateCommand(program: Command) { includeSecrets: Boolean(opts.includeSecrets), overwrite: Boolean(opts.overwrite), skills: readMigrationSkills(opts.skill), + plugins: readMigrationPlugins(opts.plugin), yes: Boolean(opts.yes), backupOutput: opts.backupOutput as string | undefined, noBackup: opts.backup === false, diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index 2c14af49500..caf922dafc8 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -125,6 +125,54 @@ function codexSkillPlan(overrides: Partial = {}): MigrationPlan { }; } +function codexPluginPlan(overrides: Partial = {}): MigrationPlan { + const items: MigrationPlan["items"] = [ + { + id: "plugin:google-calendar", + kind: "plugin", + action: "install", + status: "planned", + details: { + configKey: "google-calendar", + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + { + id: "plugin:gmail", + kind: "plugin", + action: "install", + status: "planned", + details: { + configKey: "gmail", + marketplaceName: "openai-curated", + pluginName: "gmail", + }, + }, + { + id: "config:codex-plugins", + kind: "config", + action: "merge", + status: "planned", + }, + ]; + return { + providerId: "codex", + source: "/tmp/codex", + summary: { + total: 3, + planned: 3, + migrated: 0, + skipped: 0, + conflicts: 0, + errors: 0, + sensitive: 0, + }, + items, + ...overrides, + }; +} + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), @@ -576,6 +624,35 @@ describe("migrateApplyCommand", () => { expect(mocks.backupCreateCommand).toHaveBeenCalled(); }); + it("filters explicit Codex plugins before apply", async () => { + const planned = codexPluginPlan(); + mocks.provider.plan.mockResolvedValue(planned); + mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({ + ...selectedPlan, + summary: { ...selectedPlan.summary, planned: 0, migrated: 2 }, + items: selectedPlan.items.map((item) => + item.status === "planned" ? { ...item, status: "migrated" as const } : item, + ), + })); + + await migrateApplyCommand(runtime, { provider: "codex", yes: true, plugins: ["gmail"] }); + + const appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan; + expect(appliedPlan.summary).toMatchObject({ planned: 2, skipped: 1, conflicts: 0 }); + expect(appliedPlan.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin:google-calendar", + status: "skipped", + reason: "not selected for migration", + }), + expect.objectContaining({ id: "plugin:gmail", status: "planned" }), + expect.objectContaining({ id: "config:codex-plugins", status: "planned" }), + ]), + ); + expect(mocks.backupCreateCommand).toHaveBeenCalled(); + }); + it("creates a verified backup before applying a conflict-free migration", async () => { const planned = plan(); const applied: MigrationApplyResult = { diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 3015e25938f..e48a8810e76 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -14,6 +14,7 @@ import { runMigrationApply } from "./migrate/apply.js"; import { formatMigrationPlan } from "./migrate/output.js"; import { createMigrationPlan, resolveMigrationProvider } from "./migrate/providers.js"; import { + applyMigrationPluginSelection, applyMigrationSelectedSkillItemIds, applyMigrationSkillSelection, formatMigrationSkillSelectionHint, @@ -35,8 +36,11 @@ import type { export type { MigrateApplyOptions, MigrateCommonOptions, MigrateDefaultOptions }; -function selectMigrationSkills(plan: MigrationPlan, opts: MigrateCommonOptions): MigrationPlan { - return applyMigrationSkillSelection(plan, opts.skills); +function selectMigrationItems(plan: MigrationPlan, opts: MigrateCommonOptions): MigrationPlan { + return applyMigrationPluginSelection( + applyMigrationSkillSelection(plan, opts.skills), + opts.plugins, + ); } async function promptCodexMigrationSkillSelection( @@ -137,7 +141,7 @@ export async function migratePlanCommand( if (!providerId) { throw new Error("Migration provider is required."); } - const plan = selectMigrationSkills( + const plan = selectMigrationItems( await createMigrationPlan(runtime, { ...opts, provider: providerId }), opts, ); @@ -224,7 +228,7 @@ export async function migrateDefaultCommand( } const plan = opts.json && opts.yes && !opts.dryRun - ? selectMigrationSkills( + ? selectMigrationItems( await createMigrationPlan(runtime, { ...opts, provider: providerId }), opts, ) diff --git a/src/commands/migrate/apply.ts b/src/commands/migrate/apply.ts index 4f7d3e6b99f..a25e3bf80d1 100644 --- a/src/commands/migrate/apply.ts +++ b/src/commands/migrate/apply.ts @@ -5,7 +5,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import { backupCreateCommand } from "../backup.js"; import { buildMigrationContext, buildMigrationReportDir } from "./context.js"; import { assertApplySucceeded, assertConflictFreePlan, writeApplyResult } from "./output.js"; -import { applyMigrationSkillSelection } from "./selection.js"; +import { applyMigrationPluginSelection, applyMigrationSkillSelection } from "./selection.js"; import type { MigrateApplyOptions } from "./types.js"; function shouldTreatMissingBackupAsEmptyState(error: unknown): boolean { @@ -59,7 +59,10 @@ export async function runMigrationApply(params: { json: params.opts.json, }), )); - const selectedPlan = applyMigrationSkillSelection(preflightPlan, params.opts.skills); + const selectedPlan = applyMigrationPluginSelection( + applyMigrationSkillSelection(preflightPlan, params.opts.skills), + params.opts.plugins, + ); assertConflictFreePlan(selectedPlan, params.providerId); const stateDir = resolveStateDir(); const reportDir = buildMigrationReportDir(params.providerId, stateDir); diff --git a/src/commands/migrate/selection.test.ts b/src/commands/migrate/selection.test.ts index d4567057da1..b8d61d63bd6 100644 --- a/src/commands/migrate/selection.test.ts +++ b/src/commands/migrate/selection.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; import { + applyMigrationPluginSelection, applyMigrationSelectedSkillItemIds, applyMigrationSkillSelection, getDefaultMigrationSkillSelectionValues, MIGRATION_SKILL_SELECTION_SKIP, MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, + MIGRATION_PLUGIN_NOT_SELECTED_REASON, MIGRATION_SKILL_NOT_SELECTED_REASON, reconcileInteractiveMigrationShortcutValues, reconcileInteractiveMigrationSkillToggleValues, @@ -34,6 +36,56 @@ function skillItem(params: { }; } +function pluginItem(params: { + id: string; + name: string; + status?: MigrationItem["status"]; +}): MigrationItem { + return { + id: params.id, + kind: "plugin", + action: "install", + status: params.status ?? "planned", + source: `openai-curated/${params.name}`, + target: `plugins.entries.codex.config.codexPlugins.plugins.${params.name}`, + details: { + configKey: params.name, + marketplaceName: "openai-curated", + pluginName: params.name, + }, + }; +} + +function codexPluginConfigItem(pluginNames: string[]): MigrationItem { + return { + id: "config:codex-plugins", + kind: "config", + action: "merge", + status: "planned", + details: { + value: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + allow_destructive_actions: false, + plugins: Object.fromEntries( + pluginNames.map((name) => [ + name, + { + enabled: true, + marketplaceName: "openai-curated", + pluginName: name, + }, + ]), + ), + }, + }, + }, + }, + }; +} + function plan(items: MigrationItem[]): MigrationPlan { return { providerId: "codex", @@ -300,3 +352,113 @@ describe("applyMigrationSkillSelection", () => { ).toThrow('No migratable skill matched "gamma". Available skills: alpha, beta.'); }); }); + +describe("applyMigrationPluginSelection", () => { + it("keeps selected plugins and skips unselected plugin install items", () => { + const selected = applyMigrationPluginSelection( + plan([ + pluginItem({ id: "plugin:google-calendar", name: "google-calendar" }), + pluginItem({ id: "plugin:gmail", name: "gmail" }), + codexPluginConfigItem(["google-calendar", "gmail"]), + ]), + ["google-calendar"], + ); + + expect(selected.summary).toMatchObject({ planned: 2, skipped: 1, conflicts: 0 }); + expect(selected.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "plugin:google-calendar", status: "planned" }), + expect.objectContaining({ + id: "plugin:gmail", + status: "skipped", + reason: MIGRATION_PLUGIN_NOT_SELECTED_REASON, + }), + expect.objectContaining({ id: "config:codex-plugins", status: "planned" }), + ]), + ); + expect( + selected.items.find((item) => item.id === "config:codex-plugins")?.details?.value, + ).toMatchObject({ + config: { + codexPlugins: { + plugins: { + "google-calendar": { + enabled: true, + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + }, + }, + }, + }); + expect( + Object.keys( + ( + ( + ( + selected.items.find((item) => item.id === "config:codex-plugins")?.details + ?.value as Record + ).config as Record + ).codexPlugins as Record + ).plugins as Record, + ), + ).toEqual(["google-calendar"]); + }); + + it("skips the Codex plugin config item when no plugin remains selected", () => { + const selected = applyMigrationPluginSelection( + plan([ + pluginItem({ id: "plugin:google-calendar", name: "google-calendar" }), + pluginItem({ id: "plugin:gmail", name: "gmail" }), + codexPluginConfigItem(["google-calendar", "gmail"]), + ]), + [], + ); + + expect(selected.summary).toMatchObject({ planned: 0, skipped: 3, conflicts: 0 }); + expect(selected.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "plugin:google-calendar", + status: "skipped", + reason: MIGRATION_PLUGIN_NOT_SELECTED_REASON, + }), + expect.objectContaining({ + id: "plugin:gmail", + status: "skipped", + reason: MIGRATION_PLUGIN_NOT_SELECTED_REASON, + }), + expect.objectContaining({ + id: "config:codex-plugins", + status: "skipped", + reason: MIGRATION_PLUGIN_NOT_SELECTED_REASON, + }), + ]), + ); + }); + + it("accepts item ids as non-interactive plugin selectors", () => { + const selected = applyMigrationPluginSelection( + plan([pluginItem({ id: "plugin:google-calendar", name: "google-calendar" })]), + ["plugin:google-calendar"], + ); + + expect(selected.items).toEqual([ + expect.objectContaining({ id: "plugin:google-calendar", status: "planned" }), + ]); + }); + + it("rejects unknown explicit plugin selectors with available choices", () => { + expect(() => + applyMigrationPluginSelection( + plan([ + pluginItem({ id: "plugin:google-calendar", name: "google-calendar" }), + pluginItem({ id: "plugin:gmail", name: "gmail" }), + ]), + ["calendar"], + ), + ).toThrow( + 'No migratable plugin matched "calendar". Available plugins: gmail, google-calendar.', + ); + }); +}); diff --git a/src/commands/migrate/selection.ts b/src/commands/migrate/selection.ts index b8f293a18e3..0d8d701d5ac 100644 --- a/src/commands/migrate/selection.ts +++ b/src/commands/migrate/selection.ts @@ -3,6 +3,7 @@ import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin- import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; export const MIGRATION_SKILL_NOT_SELECTED_REASON = "not selected for migration"; +export const MIGRATION_PLUGIN_NOT_SELECTED_REASON = "not selected for migration"; export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON = "__openclaw_migrate_toggle_all_on__"; export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF = "__openclaw_migrate_toggle_all_off__"; export const MIGRATION_SKILL_SELECTION_SKIP = "__openclaw_migrate_skip_for_now__"; @@ -25,6 +26,20 @@ function readMigrationSkillSourceLabel(item: MigrationItem): string | undefined return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +function readMigrationPluginName(item: MigrationItem): string | undefined { + const value = item.details?.pluginName; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readMigrationPluginConfigKey(item: MigrationItem): string | undefined { + const value = item.details?.configKey; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function migrationSkillRefs(item: MigrationItem): string[] { const skillName = readMigrationSkillName(item); const idSuffix = item.id.startsWith("skill:") ? item.id.slice("skill:".length) : undefined; @@ -35,6 +50,17 @@ function migrationSkillRefs(item: MigrationItem): string[] { ); } +function migrationPluginRefs(item: MigrationItem): string[] { + const pluginName = readMigrationPluginName(item); + const configKey = readMigrationPluginConfigKey(item); + const idSuffix = item.id.startsWith("plugin:") ? item.id.slice("plugin:".length) : undefined; + const sourceBase = item.source ? path.basename(item.source) : undefined; + const targetBase = item.target ? path.basename(item.target) : undefined; + return [item.id, idSuffix, pluginName, configKey, sourceBase, targetBase].filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); +} + function formatSelectionRefList(values: readonly string[]): string { if (values.length === 0) { return "none"; @@ -60,6 +86,24 @@ function buildSkillSelectionIndex( return index; } +function buildPluginSelectionIndex( + items: readonly MigrationItem[], +): Map> { + const index = new Map>(); + for (const item of items) { + for (const ref of migrationPluginRefs(item)) { + const normalized = normalizeSelectionRef(ref); + if (!normalized) { + continue; + } + const existing = index.get(normalized) ?? new Set(); + existing.add(item.id); + index.set(normalized, existing); + } + } + return index; +} + function resolveSelectedSkillItemIds( items: readonly MigrationItem[], selectedRefs: readonly string[], @@ -106,6 +150,52 @@ function resolveSelectedSkillItemIds( return selectedIds; } +function resolveSelectedPluginItemIds( + items: readonly MigrationItem[], + selectedRefs: readonly string[], +): Set { + const index = buildPluginSelectionIndex(items); + const selectedIds = new Set(); + const unknownRefs: string[] = []; + const ambiguousRefs: string[] = []; + for (const ref of selectedRefs) { + const normalized = normalizeSelectionRef(ref); + if (!normalized) { + continue; + } + const matches = index.get(normalized); + if (!matches) { + unknownRefs.push(ref); + continue; + } + if (matches.size > 1) { + ambiguousRefs.push(ref); + continue; + } + const [id] = matches; + if (id) { + selectedIds.add(id); + } + } + + if (unknownRefs.length > 0 || ambiguousRefs.length > 0) { + const available = items + .map(formatMigrationPluginSelectionLabel) + .toSorted((a, b) => a.localeCompare(b)); + const parts: string[] = []; + if (unknownRefs.length > 0) { + parts.push(`No migratable plugin matched ${formatSelectionRefList(unknownRefs)}.`); + } + if (ambiguousRefs.length > 0) { + parts.push(`Plugin selection ${formatSelectionRefList(ambiguousRefs)} was ambiguous.`); + } + parts.push(`Available plugins: ${available.length > 0 ? available.join(", ") : "none"}.`); + throw new Error(parts.join(" ")); + } + + return selectedIds; +} + export function getSelectableMigrationSkillItems(plan: MigrationPlan): MigrationItem[] { return plan.items.filter( (item) => @@ -115,10 +205,20 @@ export function getSelectableMigrationSkillItems(plan: MigrationPlan): Migration ); } +export function getSelectableMigrationPluginItems(plan: MigrationPlan): MigrationItem[] { + return plan.items.filter( + (item) => item.kind === "plugin" && item.action === "install" && item.status === "planned", + ); +} + export function getMigrationSkillSelectionValue(item: MigrationItem): string { return item.id; } +export function formatMigrationPluginSelectionLabel(item: MigrationItem): string { + return readMigrationPluginName(item) ?? item.id.replace(/^plugin:/u, ""); +} + export function getDefaultMigrationSkillSelectionValues(items: readonly MigrationItem[]): string[] { return items.filter((item) => item.status === "planned").map(getMigrationSkillSelectionValue); } @@ -169,6 +269,97 @@ export function applyMigrationSkillSelection( return applyMigrationSelectedSkillItemIds(plan, selectedIds); } +export function applyMigrationPluginSelection( + plan: MigrationPlan, + selectedPluginRefs: readonly string[] | undefined, +): MigrationPlan { + if (selectedPluginRefs === undefined) { + return plan; + } + const selectable = getSelectableMigrationPluginItems(plan); + const selectedIds = resolveSelectedPluginItemIds(selectable, selectedPluginRefs); + const selectableIds = new Set(selectable.map((item) => item.id)); + const selectedConfigKeys = new Set( + selectable + .filter((item) => selectedIds.has(item.id)) + .map(readMigrationPluginConfigKey) + .filter((value): value is string => value !== undefined), + ); + const items = plan.items.map((item) => { + if (isCodexPluginConfigItem(item)) { + return applyCodexPluginConfigSelection(item, selectedConfigKeys); + } + if (!selectableIds.has(item.id) || selectedIds.has(item.id)) { + return item; + } + return markMigrationItemSkipped(item, MIGRATION_PLUGIN_NOT_SELECTED_REASON); + }); + return { + ...plan, + items, + summary: summarizeMigrationItems(items), + }; +} + +function isCodexPluginConfigItem(item: MigrationItem): boolean { + if (item.kind !== "config" || item.action !== "merge") { + return false; + } + const value = item.details?.value; + if (!isRecord(value)) { + return false; + } + const config = value.config; + if (!isRecord(config)) { + return false; + } + const codexPlugins = config.codexPlugins; + if (!isRecord(codexPlugins)) { + return false; + } + return isRecord(codexPlugins.plugins); +} + +function applyCodexPluginConfigSelection( + item: MigrationItem, + selectedConfigKeys: ReadonlySet, +): MigrationItem { + const value = item.details?.value; + if (!isRecord(value)) { + return item; + } + const config = value.config; + if (!isRecord(config)) { + return item; + } + const codexPlugins = config.codexPlugins; + if (!isRecord(codexPlugins) || !isRecord(codexPlugins.plugins)) { + return item; + } + const plugins = Object.fromEntries( + Object.entries(codexPlugins.plugins).filter(([configKey]) => selectedConfigKeys.has(configKey)), + ); + if (Object.keys(plugins).length === 0) { + return markMigrationItemSkipped(item, MIGRATION_PLUGIN_NOT_SELECTED_REASON); + } + return { + ...item, + details: { + ...item.details, + value: { + ...value, + config: { + ...config, + codexPlugins: { + ...codexPlugins, + plugins, + }, + }, + }, + }, + }; +} + export function resolveInteractiveMigrationSkillSelection( items: readonly MigrationItem[], selectedValues: readonly string[], diff --git a/src/commands/migrate/types.ts b/src/commands/migrate/types.ts index 77ddd877da2..90e6fabcc0f 100644 --- a/src/commands/migrate/types.ts +++ b/src/commands/migrate/types.ts @@ -6,6 +6,7 @@ export type MigrateCommonOptions = { includeSecrets?: boolean; overwrite?: boolean; skills?: string[]; + plugins?: string[]; json?: boolean; }; diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 8c4deaed90e..c9724297337 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -684,6 +684,49 @@ describe("config plugin validation", () => { } }); + it("surfaces invalid Codex native plugin marketplaces as config diagnostics", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { + codex: { + enabled: true, + config: { + codexPlugins: { + enabled: true, + plugins: { + github: { + enabled: true, + marketplaceName: "not-openai-curated", + pluginName: "github", + }, + }, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues).toContainEqual( + expect.objectContaining({ + path: "plugins.entries.codex.config.codexPlugins.plugins.github.marketplaceName", + message: expect.stringContaining("invalid config"), + }), + ); + expect( + res.issues.some( + (issue) => + issue.path === + "plugins.entries.codex.config.codexPlugins.plugins.github.marketplaceName" && + issue.allowedValues?.includes("openai-curated"), + ), + ).toBe(true); + } + }); + it("does not require native config schemas for enabled bundle plugins", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] },