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" }] },