From 4f3d8a57ddd3cb72e23052e170aac8f979e4a4d2 Mon Sep 17 00:00:00 2001 From: Soham Patankar <102520430+yaanfpv@users.noreply.github.com> Date: Sun, 31 May 2026 15:38:42 +0530 Subject: [PATCH] fix(codex): accept first-party OpenAI plugin marketplaces Allow Codex native plugin config to target first-party OpenAI marketplaces, including openai-curated, openai-bundled, and openai-primary-runtime. Fixes #82216. Thanks @yaanfpv for the contribution. Verification: - node scripts/run-vitest.mjs test/scripts/lint-suppressions.test.ts - pnpm build:ci-artifacts - OPENCLAW_VITEST_MAX_WORKERS=2 node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts test/scripts/lint-suppressions.test.ts - node scripts/run-vitest.mjs extensions/codex/src/app-server/config.test.ts extensions/codex/src/app-server/plugin-activation.test.ts extensions/codex/src/app-server/session-binding.test.ts extensions/codex/src/migration/provider.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/inbound.test.ts - git diff --check - ./.agents/skills/autoreview/scripts/autoreview --mode local - GitHub PR CI on head 896640060bbdbecf03461fbb4bc37e9067bded24, including build-artifacts run 26709647050 --- docs/cli/migrate.md | 4 + docs/gateway/configuration-reference.md | 3 +- docs/plugins/codex-harness-reference.md | 16 ++-- docs/plugins/codex-harness.md | 2 +- docs/plugins/codex-native-plugins.md | 28 ++++--- extensions/codex/openclaw.plugin.json | 2 +- .../codex/src/app-server/config.test.ts | 53 ++++++++++++ extensions/codex/src/app-server/config.ts | 33 ++++++-- .../src/app-server/plugin-activation.test.ts | 78 +++++++++++++++++- .../codex/src/app-server/plugin-activation.ts | 27 ++++--- .../codex/src/app-server/plugin-inventory.ts | 81 ++++++++++++------- .../src/app-server/session-binding.test.ts | 30 +++++++ .../codex/src/app-server/session-binding.ts | 5 +- extensions/codex/src/migration/apply.ts | 19 +++-- .../codex/src/migration/provider.test.ts | 11 ++- extensions/sms/src/channel.test.ts | 35 +++++++- extensions/sms/src/channel.ts | 57 ++++++++++--- extensions/sms/src/inbound.test.ts | 15 ++-- extensions/sms/src/twilio.test.ts | 11 ++- extensions/sms/src/twilio.ts | 8 +- src/agents/tools/skill-workshop-tool.ts | 3 + src/config/sessions.cache.test.ts | 4 +- ...erver.agent.gateway-server-agent-b.test.ts | 3 +- test/scripts/lint-suppressions.test.ts | 11 ++- 24 files changed, 428 insertions(+), 111 deletions(-) diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index d80539d972f..230ec1b3fb6 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -218,6 +218,10 @@ Target-side auth-required installs are reported on the affected plugin item with Their explicit config entries are written disabled until you reauthorize and enable them. Other install failures are item-scoped `error` results. +The native Codex plugin config also accepts first-party `openai-bundled` and +`openai-primary-runtime` marketplace identities, but migration does not +auto-discover or install them from source state. + If Codex app-server plugin inventory is unavailable during planning, migration falls back to cached bundle advisory items instead of failing the whole migration. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 30805f96ad1..b9c3fac6080 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -316,7 +316,8 @@ conversation bindings, or any non-Codex harness. 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"`. + stable marketplace identity. V1 supports `"openai-curated"`, + `"openai-bundled"`, and `"openai-primary-runtime"`. - `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`: diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 2ac26f42c7a..90c006ddc2a 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -38,14 +38,14 @@ All Codex harness settings live under `plugins.entries.codex.config`. Supported top-level fields: -| Field | Default | Meaning | -| -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. | -| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. | -| `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. See [Native Codex plugins](/plugins/codex-native-plugins). | -| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | +| Field | Default | Meaning | +| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. | +| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. | +| `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 configured first-party Codex plugins. See [Native Codex plugins](/plugins/codex-native-plugins). | +| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). | ## App-server transport diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 4412e1db417..aaead9e069f 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -526,7 +526,7 @@ Supported top-level Codex plugin fields: | -------------------------- | -------------- | ---------------------------------------------------------------------------------------- | | `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. | +| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. | Supported `appServer` fields: diff --git a/docs/plugins/codex-native-plugins.md b/docs/plugins/codex-native-plugins.md index 0e7756de53e..eedf2779d2d 100644 --- a/docs/plugins/codex-native-plugins.md +++ b/docs/plugins/codex-native-plugins.md @@ -3,7 +3,7 @@ summary: "Configure migrated native Codex plugins for Codex-mode OpenClaw agents title: "Native Codex plugins" read_when: - You want Codex-mode OpenClaw agents to use native Codex plugins - - You are migrating source-installed openai-curated Codex plugins + - You are configuring first-party Codex plugin marketplaces - You are troubleshooting codexPlugins, app inventory, destructive actions, or plugin app diagnostics --- @@ -22,7 +22,9 @@ Use this page after the base [Codex harness](/plugins/codex-harness) is working. - The selected OpenClaw agent runtime must be the native Codex harness. - `plugins.entries.codex.enabled` must be true. - `plugins.entries.codex.config.codexPlugins.enabled` must be true. -- V1 supports only `openai-curated` plugins that migration observed as +- V1 supports first-party Codex plugin marketplaces: `openai-curated`, + `openai-bundled`, and `openai-primary-runtime`. +- Migration only auto-discovers `openai-curated` plugins that it observed as source-installed in the source Codex home. - The target Codex app-server must be able to see the expected marketplace, plugin, and app inventory. @@ -52,9 +54,11 @@ Apply the migration when the plan looks right: openclaw migrate apply codex --yes ``` -Migration writes explicit `codexPlugins` entries for eligible plugins and calls -Codex app-server `plugin/install` for selected plugins. A typical migrated -config looks like this: +Migration writes explicit `codexPlugins` entries for eligible curated plugins +and calls Codex app-server `plugin/install` for selected plugins. Explicit +config may also reference Codex's bundled and primary-runtime first-party +marketplaces when the target app-server inventory exposes those plugin apps. A +typical migrated config looks like this: ```json5 { @@ -146,8 +150,10 @@ up the updated app set. V1 is intentionally narrow: +- Runtime config accepts `openai-curated`, `openai-bundled`, and + `openai-primary-runtime` plugin identities. - Only `openai-curated` plugins that were already installed in the source Codex - app-server inventory are migration-eligible. + app-server inventory are migration-eligible for automatic migration. - App-backed source plugins must pass the migration-time subscription gate. `--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated accounts plus, in verification mode, inaccessible, disabled, missing source @@ -160,7 +166,9 @@ V1 is intentionally narrow: - 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. + are preserved in the migration report for manual review. Bundled and + primary-runtime first-party plugins can still be added manually through + explicit `codexPlugins` config. ## App inventory and ownership @@ -248,8 +256,10 @@ app-server auth or rerun with `--verify-plugin-apps` if you want source app inventory to decide eligibility when account lookup fails. **`marketplace_missing` or `plugin_missing`:** the target Codex app-server -cannot see the expected `openai-curated` marketplace or plugin. Rerun migration -against the target runtime or inspect Codex app-server plugin status. +cannot see the expected first-party marketplace or plugin. Rerun migration +against the target runtime, inspect Codex app-server plugin status, or confirm +the explicit `marketplaceName` is one of `openai-curated`, `openai-bundled`, or +`openai-primary-runtime`. **`app_inventory_missing` or `app_inventory_stale`:** app readiness came from an empty or stale cache. OpenClaw schedules an async refresh and excludes plugin diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index dc65964ee85..ce595113bf9 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -114,7 +114,7 @@ }, "marketplaceName": { "type": "string", - "enum": ["openai-curated"] + "enum": ["openai-curated", "openai-bundled", "openai-primary-runtime"] }, "pluginName": { "type": "string" diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index ff6fceee321..29eda8f92ee 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -653,6 +653,59 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([]); }); + it("accepts native plugin identities from every first-party OpenAI marketplace", () => { + // OpenAI ships first-party Codex plugins across three marketplaces: the local + // openai-bundled marketplace shipped with Codex.app (chrome, browser, computer-use, + // latex-tectonic), the remote openai-curated marketplace, and the + // openai-primary-runtime marketplace owned by the Codex primary runtime + // (documents, spreadsheets, presentations). All three should resolve. + const config = readCodexPluginConfig({ + codexPlugins: { + enabled: true, + plugins: { + chrome: { + marketplaceName: "openai-bundled", + pluginName: "chrome", + }, + "google-calendar": { + marketplaceName: "openai-curated", + pluginName: "google-calendar", + }, + documents: { + marketplaceName: "openai-primary-runtime", + pluginName: "documents", + }, + }, + }, + }); + + expect(config.codexPlugins?.enabled).toBe(true); + const policy = resolveCodexPluginsPolicy(config); + expect(policy.pluginPolicies).toEqual([ + { + configKey: "chrome", + marketplaceName: "openai-bundled", + pluginName: "chrome", + enabled: true, + allowDestructiveActions: true, + }, + { + configKey: "documents", + marketplaceName: "openai-primary-runtime", + pluginName: "documents", + enabled: true, + allowDestructiveActions: true, + }, + { + configKey: "google-calendar", + marketplaceName: "openai-curated", + pluginName: "google-calendar", + enabled: true, + allowDestructiveActions: true, + }, + ]); + }); + it("treats configured and environment commands as explicit overrides", () => { expectFields( resolveRuntimeForTest({ diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 06ea943c33c..69ff1a94959 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -60,7 +60,30 @@ type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | " export type CodexDynamicToolsLoading = "searchable" | "direct"; export type CodexPluginDestructivePolicy = boolean; -export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated"; +// OpenAI ships first-party Codex plugins across three marketplaces: +// - openai-curated: remote curated marketplace, fetched via `codex plugin marketplace add` +// - openai-bundled: local marketplace that ships with Codex.app and the Codex CLI +// (browser, chrome, computer-use, latex-tectonic) +// - openai-primary-runtime: marketplace owned by the Codex primary runtime +// (documents, spreadsheets, presentations) +// All three are owned by OpenAI. Allow activating plugins from any of them. +export const CODEX_PLUGINS_MARKETPLACE_NAMES = [ + "openai-curated", + "openai-bundled", + "openai-primary-runtime", +] as const; +export type CodexPluginsMarketplaceName = (typeof CODEX_PLUGINS_MARKETPLACE_NAMES)[number]; + +// Back-compat constant for callers that still reference the curated marketplace by name. +export const CODEX_PLUGINS_MARKETPLACE_NAME: CodexPluginsMarketplaceName = "openai-curated"; + +export function isCodexPluginsMarketplaceName( + name: string | undefined, +): name is CodexPluginsMarketplaceName { + return ( + name !== undefined && (CODEX_PLUGINS_MARKETPLACE_NAMES as readonly string[]).includes(name) + ); +} export type CodexComputerUseConfig = { enabled?: boolean; @@ -103,7 +126,7 @@ export type CodexAppServerExperimentalConfig = { export type ResolvedCodexPluginPolicy = { configKey: string; - marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + marketplaceName: CodexPluginsMarketplaceName; pluginName: string; enabled: boolean; allowDestructiveActions: CodexPluginDestructivePolicy; @@ -255,7 +278,7 @@ const codexAppServerExperimentalSchema = z const codexPluginEntryConfigSchema = z .object({ enabled: z.boolean().optional(), - marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(), + marketplaceName: z.enum(CODEX_PLUGINS_MARKETPLACE_NAMES).optional(), pluginName: z.string().trim().min(1).optional(), allow_destructive_actions: z.boolean().optional(), }) @@ -365,13 +388,13 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex const allowDestructiveActions = config?.allow_destructive_actions ?? true; const pluginPolicies = Object.entries(config?.plugins ?? {}) .flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => { - if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) { + if (!isCodexPluginsMarketplaceName(entry.marketplaceName) || !entry.pluginName) { return []; } return [ { configKey, - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplaceName: entry.marketplaceName, pluginName: entry.pluginName, enabled: enabled && entry.enabled !== false, allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions, diff --git a/extensions/codex/src/app-server/plugin-activation.test.ts b/extensions/codex/src/app-server/plugin-activation.test.ts index 14a2ed8d1c6..f05d49277f0 100644 --- a/extensions/codex/src/app-server/plugin-activation.test.ts +++ b/extensions/codex/src/app-server/plugin-activation.test.ts @@ -22,6 +22,59 @@ describe("Codex plugin activation", () => { expect((params as Record | undefined)?.[key]).toBe(expected); } + it("activates plugins from every first-party OpenAI marketplace", async () => { + // chrome ships in openai-bundled (with Codex.app), documents ships in + // openai-primary-runtime (Codex primary runtime). Both should activate the + // same way openai-curated plugins do. + for (const { plugin, marketplace } of [ + { plugin: "chrome", marketplace: "openai-bundled" as const }, + { plugin: "documents", marketplace: "openai-primary-runtime" as const }, + ]) { + const calls: string[] = []; + const result = await ensureCodexPluginActivation({ + identity: identity(plugin, marketplace), + request: async (method) => { + calls.push(method); + if (method === "plugin/list") { + return pluginListFor(marketplace, [ + pluginSummary(plugin, { installed: true, enabled: true }), + ]); + } + throw new Error(`unexpected request ${method}`); + }, + }); + + expectActivationResult(result, { + ok: true, + reason: "already_active", + installAttempted: false, + }); + expect(result.marketplace?.name).toBe(marketplace); + expect(calls).toEqual(["plugin/list"]); + } + }); + + it("rejects activation requests for marketplaces outside the openai allowlist", async () => { + const result = await ensureCodexPluginActivation({ + identity: { + configKey: "rogue", + marketplaceName: "third-party" as never, + pluginName: "rogue", + enabled: true, + allowDestructiveActions: false, + }, + request: async () => { + throw new Error("plugin/list should not be reached when marketplace is rejected"); + }, + }); + + expectActivationResult(result, { + ok: false, + reason: "marketplace_missing", + installAttempted: false, + }); + }); + it("skips plugin/install when the migrated plugin is already active", async () => { const calls: string[] = []; const result = await ensureCodexPluginActivation({ @@ -295,10 +348,13 @@ describe("Codex plugin activation", () => { }); }); -function identity(pluginName: string): ResolvedCodexPluginPolicy { +function identity( + pluginName: string, + marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"] = CODEX_PLUGINS_MARKETPLACE_NAME, +): ResolvedCodexPluginPolicy { return { configKey: pluginName, - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplaceName, pluginName, enabled: true, allowDestructiveActions: false, @@ -320,6 +376,24 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { }; } +function pluginListFor( + marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"], + plugins: v2.PluginSummary[], +): v2.PluginListResponse { + return { + marketplaces: [ + { + name: marketplaceName, + path: `/marketplaces/${marketplaceName}`, + interface: null, + plugins, + }, + ], + marketplaceLoadErrors: [], + featuredPluginIds: [], + }; +} + function pluginSummary(id: string, overrides: Partial = {}): v2.PluginSummary { return { id, diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts index 4fb9911547e..a3c5bbb0e8e 100644 --- a/extensions/codex/src/app-server/plugin-activation.ts +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -1,7 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js"; -import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js"; +import { + CODEX_PLUGINS_MARKETPLACE_NAMES, + isCodexPluginsMarketplaceName, + type ResolvedCodexPluginPolicy, +} from "./config.js"; import { findOpenAiCuratedPluginSummary, pluginReadParams, @@ -48,27 +52,32 @@ export type CodexPluginRuntimeRefreshResult = { export async function ensureCodexPluginActivation( params: EnsureCodexPluginActivationParams, ): Promise { - if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) { + if (!isCodexPluginsMarketplaceName(params.identity.marketplaceName)) { return activationFailure(params.identity, "marketplace_missing", { - message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.", + message: + "Only " + CODEX_PLUGINS_MARKETPLACE_NAMES.join(" or ") + " 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); + const resolved = findOpenAiCuratedPluginSummary( + listed, + params.identity.pluginName, + params.identity.marketplaceName, + ); if (!resolved) { - const hasCuratedMarketplace = listed.marketplaces.some( - (marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME, + const hasMarketplace = listed.marketplaces.some( + (marketplace) => marketplace.name === params.identity.marketplaceName, ); - if (!hasCuratedMarketplace) { + if (!hasMarketplace) { return activationFailure(params.identity, "marketplace_missing", { - message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`, + message: `Codex marketplace ${params.identity.marketplaceName} was not found.`, }); } return activationFailure(params.identity, "plugin_missing", { - message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`, + message: `${params.identity.pluginName} was not found in ${params.identity.marketplaceName}.`, }); } diff --git a/extensions/codex/src/app-server/plugin-inventory.ts b/extensions/codex/src/app-server/plugin-inventory.ts index aaa7a00d35c..5591dabdb13 100644 --- a/extensions/codex/src/app-server/plugin-inventory.ts +++ b/extensions/codex/src/app-server/plugin-inventory.ts @@ -6,7 +6,10 @@ import type { } from "./app-inventory-cache.js"; import { CODEX_PLUGINS_MARKETPLACE_NAME, + CODEX_PLUGINS_MARKETPLACE_NAMES, + isCodexPluginsMarketplaceName, resolveCodexPluginsPolicy, + type CodexPluginsMarketplaceName, type ResolvedCodexPluginPolicy, type ResolvedCodexPluginsPolicy, } from "./config.js"; @@ -15,7 +18,7 @@ import type { v2 } from "./protocol.js"; export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise; export type CodexPluginMarketplaceRef = { - name: typeof CODEX_PLUGINS_MARKETPLACE_NAME; + name: CodexPluginsMarketplaceName; path?: string; remoteMarketplaceName?: string; }; @@ -57,7 +60,6 @@ export type CodexPluginInventoryRecord = { export type CodexPluginInventory = { policy: ResolvedCodexPluginsPolicy; - marketplace?: CodexPluginMarketplaceRef; records: CodexPluginInventoryRecord[]; diagnostics: CodexPluginInventoryDiagnostic[]; appInventory?: CodexAppInventoryCacheRead; @@ -95,25 +97,14 @@ export async function readCodexPluginInventory( 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 } : {}), - }; + // Index the supported marketplaces (curated + bundled) by name so each plugin + // policy is matched to the marketplace its config actually points at. + const marketplaceByName = new Map(); + for (const marketplace of listed.marketplaces) { + if (isCodexPluginsMarketplaceName(marketplace.name)) { + marketplaceByName.set(marketplace.name, marketplace); + } } - - const marketplace = marketplaceRef(marketplaceEntry); const diagnostics: CodexPluginInventoryDiagnostic[] = []; const records: CodexPluginInventoryRecord[] = []; if (appInventory?.state === "missing") { @@ -132,12 +123,22 @@ export async function readCodexPluginInventory( if (!pluginPolicy.enabled) { continue; } + const marketplaceEntry = marketplaceByName.get(pluginPolicy.marketplaceName); + if (!marketplaceEntry) { + diagnostics.push({ + code: "marketplace_missing", + plugin: pluginPolicy, + message: `Codex marketplace ${pluginPolicy.marketplaceName} was not found.`, + }); + continue; + } + const marketplace = marketplaceRef(marketplaceEntry); 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}.`, + message: `${pluginPolicy.pluginName} was not found in ${pluginPolicy.marketplaceName}.`, }); continue; } @@ -187,7 +188,6 @@ export async function readCodexPluginInventory( const inventory = { policy, - marketplace, records, diagnostics, ...(appInventory ? { appInventory } : {}), @@ -198,15 +198,32 @@ export async function readCodexPluginInventory( export function findOpenAiCuratedPluginSummary( listed: v2.PluginListResponse, pluginName: string, + marketplaceName?: CodexPluginsMarketplaceName, ): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined { - const marketplaceEntry = listed.marketplaces.find( - (marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME, - ); - if (!marketplaceEntry) { - return undefined; + if (marketplaceName) { + const marketplaceEntry = listed.marketplaces.find( + (marketplace) => marketplace.name === marketplaceName, + ); + if (!marketplaceEntry) { + return undefined; + } + const summary = findPluginSummary(marketplaceEntry, pluginName); + return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined; } - const summary = findPluginSummary(marketplaceEntry, pluginName); - return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined; + // No marketplace hint: search every supported marketplace and return the first hit. + for (const allowedName of CODEX_PLUGINS_MARKETPLACE_NAMES) { + const marketplaceEntry = listed.marketplaces.find( + (marketplace) => marketplace.name === allowedName, + ); + if (!marketplaceEntry) { + continue; + } + const summary = findPluginSummary(marketplaceEntry, pluginName); + if (summary) { + return { marketplace: marketplaceRef(marketplaceEntry), summary }; + } + } + return undefined; } export function pluginReadParams( @@ -349,8 +366,12 @@ function pluginNameFromPluginId(pluginId: string, marketplaceName: string): stri } function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef { + // marketplace.name is validated at every call site via isCodexPluginsMarketplaceName. + const name = isCodexPluginsMarketplaceName(marketplace.name) + ? marketplace.name + : CODEX_PLUGINS_MARKETPLACE_NAME; return { - name: CODEX_PLUGINS_MARKETPLACE_NAME, + name, ...(marketplace.path ? { path: marketplace.path } : {}), ...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}), }; diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 0e427972c55..ecabaab03a0 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -107,6 +107,36 @@ describe("codex app-server session binding", () => { expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); }); + it("round-trips plugin app policy context for openai-bundled marketplace plugins", async () => { + // The chrome plugin lives in openai-bundled (ships with Codex.app), so + // its policy must persist across reads/writes the same way curated entries do. + const sessionFile = path.join(tempDir, "session-bundled.json"); + const pluginAppPolicyContext = { + fingerprint: "plugin-policy-bundled-1", + apps: { + "chrome-app": { + configKey: "chrome", + marketplaceName: "openai-bundled" as const, + pluginName: "chrome", + allowDestructiveActions: true, + mcpServerNames: ["chrome"], + }, + }, + pluginAppIds: { + chrome: ["chrome-app"], + }, + }; + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-bundled", + cwd: tempDir, + pluginAppPolicyContext, + }); + + const binding = await readCodexAppServerBinding(sessionFile); + + expect(binding?.pluginAppPolicyContext).toEqual(pluginAppPolicyContext); + }); + it("round-trips context-engine binding metadata", async () => { const sessionFile = path.join(tempDir, "session.json"); await writeCodexAppServerBinding(sessionFile, { diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 71a4d7a75fc..c67b2c8342f 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -7,7 +7,7 @@ import { type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import { - CODEX_PLUGINS_MARKETPLACE_NAME, + isCodexPluginsMarketplaceName, normalizeCodexServiceTier, type CodexAppServerApprovalPolicy, type CodexAppServerSandboxMode, @@ -251,7 +251,8 @@ function readPluginAppPolicyContext(value: unknown): PluginAppPolicyContext | un if ( "appId" in entry || typeof entry.configKey !== "string" || - entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + typeof entry.marketplaceName !== "string" || + !isCodexPluginsMarketplaceName(entry.marketplaceName) || typeof entry.pluginName !== "string" || typeof entry.allowDestructiveActions !== "boolean" || !Array.isArray(entry.mcpServerNames) || diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index e2ad00304ad..1fb473a46ee 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -30,6 +30,7 @@ import { } from "../app-server/auth-bridge.js"; import { CODEX_PLUGINS_MARKETPLACE_NAME, + isCodexPluginsMarketplaceName, readCodexPluginConfig, resolveCodexAppServerRuntimeOptions, type ResolvedCodexPluginPolicy, @@ -354,12 +355,13 @@ function hasOpenAiCuratedMarketplace(response: unknown): boolean { const marketplaces = (response as { marketplaces?: unknown }).marketplaces; return ( Array.isArray(marketplaces) && - marketplaces.some( - (marketplace) => - marketplace && - typeof marketplace === "object" && - (marketplace as { name?: unknown }).name === CODEX_PLUGINS_MARKETPLACE_NAME, - ) + marketplaces.some((marketplace) => { + if (!marketplace || typeof marketplace !== "object") { + return false; + } + const name = (marketplace as { name?: unknown }).name; + return name === CODEX_PLUGINS_MARKETPLACE_NAME; + }) ); } @@ -494,14 +496,15 @@ function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy | const pluginName = item.details?.pluginName; if ( typeof configKey !== "string" || - marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || + typeof marketplaceName !== "string" || + !isCodexPluginsMarketplaceName(marketplaceName) || typeof pluginName !== "string" ) { return undefined; } return { configKey, - marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME, + marketplaceName, pluginName, enabled: true, allowDestructiveActions: true, diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index bfab1128fee..60ccf6771d3 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -1462,7 +1462,7 @@ describe("buildCodexMigrationProvider", () => { if (method === "plugin/list" && isTarget) { targetPluginListCalls += 1; if (targetPluginListCalls === 1) { - return { marketplaces: [], marketplaceLoadErrors: [], featuredPluginIds: [] }; + return pluginList([], "openai-bundled"); } return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]); } @@ -2225,12 +2225,15 @@ function createConfigRuntime( } as unknown as MigrationProviderContext["runtime"]; } -function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse { +function pluginList( + plugins: v2.PluginSummary[], + marketplaceName = CODEX_PLUGINS_MARKETPLACE_NAME, +): v2.PluginListResponse { return { marketplaces: [ { - name: CODEX_PLUGINS_MARKETPLACE_NAME, - path: "/marketplaces/openai-curated", + name: marketplaceName, + path: `/marketplaces/${marketplaceName}`, interface: null, plugins, }, diff --git a/extensions/sms/src/channel.test.ts b/extensions/sms/src/channel.test.ts index fa3243549a2..69a6f935cb2 100644 --- a/extensions/sms/src/channel.test.ts +++ b/extensions/sms/src/channel.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedSmsAccount } from "./types.js"; type ChannelModule = typeof import("./channel.js"); @@ -48,13 +49,16 @@ describe("smsPlugin status", () => { }, }); - expect(snapshot).toEqual({ + expect(snapshot).toMatchObject({ accountId: "support", name: "+15557654321", enabled: true, configured: true, statusState: "configured", + running: false, + webhookPath: "/webhooks/sms", }); + expect(snapshot).not.toHaveProperty("connected"); }); }); @@ -146,4 +150,33 @@ describe("smsPlugin outbound", () => { }), ).toEqual({ ok: true, to: "+15551234567" }); }); + + it("preserves inspected account status fields", async () => { + const cfg = { + channels: { + sms: { + accountSid: "AC123", + authToken: "secret", + fromNumber: "+15557654321", + webhookPath: "/twilio/sms", + }, + }, + }; + const account = smsPlugin.config.inspectAccount?.(cfg); + expect(account).toBeDefined(); + + const snapshot = await smsPlugin.status?.buildAccountSnapshot?.({ + account: account as ResolvedSmsAccount, + cfg, + }); + + expect(snapshot).toMatchObject({ + configured: true, + enabled: true, + statusState: "configured", + tokenStatus: "available", + webhookPath: "/twilio/sms", + }); + expect(snapshot).not.toHaveProperty("connected"); + }); }); diff --git a/extensions/sms/src/channel.ts b/extensions/sms/src/channel.ts index 3906440d67f..d9599284af4 100644 --- a/extensions/sms/src/channel.ts +++ b/extensions/sms/src/channel.ts @@ -33,6 +33,46 @@ import type { ResolvedSmsAccount } from "./types.js"; const CHANNEL_ID = "sms"; +type SmsStatusSnapshotAccount = Partial & { + configured?: boolean; + tokenStatus?: string; + webhookPath?: string; +}; + +function buildSmsAccountSnapshot(params: { + account: ResolvedSmsAccount; + runtime?: { + running?: boolean; + connected?: boolean; + lastConnectedAt?: number | null; + lastError?: string | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; + }; +}) { + const account = params.account as SmsStatusSnapshotAccount; + const configured = + typeof account.configured === "boolean" + ? account.configured + : isSmsAccountConfigured(params.account); + return { + accountId: account.accountId ?? "", + name: account.fromNumber || account.messagingServiceSid || "SMS", + enabled: account.enabled, + configured, + statusState: + account.enabled === false ? "disabled" : configured ? "configured" : "unconfigured", + ...(account.tokenStatus ? { tokenStatus: account.tokenStatus } : {}), + ...(account.webhookPath ? { webhookPath: account.webhookPath } : {}), + running: params.runtime?.running ?? false, + ...(params.runtime?.connected !== undefined ? { connected: params.runtime.connected } : {}), + lastConnectedAt: params.runtime?.lastConnectedAt ?? null, + lastError: params.runtime?.lastError ?? null, + lastInboundAt: params.runtime?.lastInboundAt ?? null, + lastOutboundAt: params.runtime?.lastOutboundAt ?? null, + }; +} + const smsConfigAdapter = createHybridChannelConfigAdapter({ sectionKey: CHANNEL_ID, listAccountIds: listSmsAccountIds, @@ -254,16 +294,15 @@ export const smsPlugin: ChannelPlugin = createChatChannelPlu }, }, status: { - buildAccountSnapshot: ({ account }) => { - const configured = isSmsAccountConfigured(account); - return { - accountId: account.accountId, - name: account.fromNumber || account.messagingServiceSid || "SMS", - enabled: account.enabled, - configured, - statusState: !account.enabled ? "disabled" : configured ? "configured" : "unconfigured", - }; + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastConnectedAt: null, + lastError: null, + lastInboundAt: null, + lastOutboundAt: null, }, + buildAccountSnapshot: buildSmsAccountSnapshot, buildCapabilitiesDiagnostics: async ({ account }) => ({ lines: collectSmsStartupWarnings(account).map((text) => ({ text, tone: "warn" })), }), diff --git a/extensions/sms/src/inbound.test.ts b/extensions/sms/src/inbound.test.ts index 207e0823a24..65738d7a739 100644 --- a/extensions/sms/src/inbound.test.ts +++ b/extensions/sms/src/inbound.test.ts @@ -109,12 +109,15 @@ describe("dispatchSmsInboundEvent", () => { meta: undefined, }); expect(sendSmsViaTwilio).toHaveBeenCalledOnce(); - expect(sendSmsViaTwilio).toHaveBeenCalledWith( - expect.objectContaining({ - to: "+15551234567", - text: expect.stringContaining("PAIR123"), - }), - ); + const firstSendCall = sendSmsViaTwilio.mock.calls[0]; + expect(firstSendCall).toBeDefined(); + if (!firstSendCall) { + throw new Error("Expected SMS send call"); + } + expect(firstSendCall[0]).toMatchObject({ + to: "+15551234567", + }); + expect(firstSendCall[0].text).toContain("PAIR123"); }); it("uses the canonical routed session key for authorized SMS turns", async () => { diff --git a/extensions/sms/src/twilio.test.ts b/extensions/sms/src/twilio.test.ts index a3595a29cac..1b1b93af972 100644 --- a/extensions/sms/src/twilio.test.ts +++ b/extensions/sms/src/twilio.test.ts @@ -159,8 +159,17 @@ describe("Twilio SMS helpers", () => { status: "queued", }); - const [url, init] = fetchImpl.mock.calls[0] ?? []; + const firstFetchCall = fetchImpl.mock.calls[0]; + expect(firstFetchCall).toBeDefined(); + if (!firstFetchCall) { + throw new Error("Expected Twilio fetch call"); + } + const [url, init] = firstFetchCall; expect(url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json"); + expect(init).toBeDefined(); + if (!init) { + throw new Error("Expected Twilio request init"); + } expect(init?.method).toBe("POST"); expect(init?.headers).toMatchObject({ authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`, diff --git a/extensions/sms/src/twilio.ts b/extensions/sms/src/twilio.ts index 67efcdd9cde..fcad632d378 100644 --- a/extensions/sms/src/twilio.ts +++ b/extensions/sms/src/twilio.ts @@ -72,11 +72,11 @@ function parseTwilioSuccessPayload(text: string): TwilioMessagePayload { from: typeof record.from === "string" ? record.from : undefined, status: typeof record.status === "string" ? record.status : undefined, }; - } catch (err) { - if (err instanceof Error && err.message === "Twilio SMS send returned malformed JSON.") { - throw err; + } catch (error) { + if (error instanceof Error && error.message === "Twilio SMS send returned malformed JSON.") { + throw error; } - throw new Error("Twilio SMS send returned malformed JSON.", { cause: err }); + throw new Error("Twilio SMS send returned malformed JSON.", { cause: error }); } } diff --git a/src/agents/tools/skill-workshop-tool.ts b/src/agents/tools/skill-workshop-tool.ts index a7f67e157be..da59b4c1c41 100644 --- a/src/agents/tools/skill-workshop-tool.ts +++ b/src/agents/tools/skill-workshop-tool.ts @@ -382,6 +382,9 @@ function listProposalEntries(params: { proposal.skillName, proposal.skillKey, ].some((value) => { + if (typeof value !== "string") { + return false; + } const lower = value.toLowerCase(); return ( lower.includes(query) || diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index a9bb3756800..ec621974118 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -314,8 +314,8 @@ describe("Session Store Cache", () => { if (!entry) { throw new Error("Expected cached entry"); } - expect(entry?.polluted).toBeUndefined(); - expect(Object.hasOwn(entry as object, "__proto__")).toBe(true); + expect(entry.polluted).toBeUndefined(); + expect(Object.hasOwn(entry, "__proto__")).toBe(true); expect(Object.prototype).not.toHaveProperty("polluted"); }); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 20b8b8adb48..08a6951a974 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -365,11 +365,10 @@ describe("gateway server agent", () => { }); test("agent errors when deliver=true and last channel is webchat", async () => { - testState.allowFrom = ["+1555"]; await writeMainSessionEntry({ sessionId: "sess-main-webchat", lastChannel: "webchat", - lastTo: "+1555", + lastTo: "webchat-room", }); const res = await rpcReq(ws, "agent", { message: "hi", diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index b3d06deb48c..a6a6bfd3820 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -186,14 +186,9 @@ describe("production lint suppressions", () => { "extensions/slack/src/monitor/provider-support.ts|typescript/no-unnecessary-type-parameters|1", "extensions/telegram/src/telegram-ingress-worker.runtime.ts|unicorn/require-post-message-target-origin|1", "extensions/telegram/src/telegram-ingress-worker.ts|unicorn/require-post-message-target-origin|1", - "extensions/whatsapp/src/document-filename.ts|no-control-regex|1", - "scripts/e2e/mcp-channels-harness.ts|unicorn/prefer-add-event-listener|1", "scripts/lib/extension-package-boundary.ts|typescript/no-unnecessary-type-parameters|1", "scripts/lib/plugin-npm-release.ts|typescript/no-unnecessary-type-parameters|1", - "src/agents/agent-scope.ts|no-control-regex|1", "src/agents/code-mode.worker.ts|unicorn/require-post-message-target-origin|1", - "src/agents/embedded-agent-runner/run/images.ts|no-control-regex|1", - "src/agents/subagent-spawn.ts|no-control-regex|1", "src/channels/plugins/channel-runtime-surface.types.ts|typescript/no-unnecessary-type-parameters|1", "src/channels/plugins/contracts/test-helpers.ts|typescript/no-unnecessary-type-parameters|1", "src/channels/plugins/types.plugin.ts|typescript/no-explicit-any|1", @@ -201,7 +196,7 @@ describe("production lint suppressions", () => { "src/cli/command-options.ts|typescript/no-unnecessary-type-parameters|1", "src/cli/plugins-cli-test-helpers.ts|typescript/no-unnecessary-type-parameters|1", "src/cli/test-runtime-capture.ts|typescript/no-unnecessary-type-parameters|1", - "src/config/types.channels.ts|@typescript-eslint/no-explicit-any|1", + "src/config/types.channels.ts|typescript/no-explicit-any|1", "src/gateway/test-helpers.server.ts|typescript/no-unnecessary-type-parameters|1", "src/hooks/module-loader.ts|typescript/no-unnecessary-type-parameters|1", "src/infra/channel-runtime-context.ts|typescript/no-unnecessary-type-parameters|1", @@ -246,6 +241,10 @@ describe("production lint suppressions", () => { file: "src/channels/plugins/types.plugin.ts", rule: "typescript/no-explicit-any", }, + { + file: "src/config/types.channels.ts", + rule: "typescript/no-explicit-any", + }, { file: "src/test-utils/vitest-mock-fn.ts", rule: "typescript/no-explicit-any",