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 896640060b, including build-artifacts run 26709647050
This commit is contained in:
Soham Patankar
2026-05-31 15:38:42 +05:30
committed by GitHub
parent f454d6202f
commit 4f3d8a57dd
24 changed files with 428 additions and 111 deletions

View File

@@ -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.

View File

@@ -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.<key>.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.<key>.pluginName`: stable
Codex plugin identity from migration, for example `"google-calendar"`.
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -114,7 +114,7 @@
},
"marketplaceName": {
"type": "string",
"enum": ["openai-curated"]
"enum": ["openai-curated", "openai-bundled", "openai-primary-runtime"]
},
"pluginName": {
"type": "string"

View File

@@ -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({

View File

@@ -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,

View File

@@ -22,6 +22,59 @@ describe("Codex plugin activation", () => {
expect((params as Record<string, unknown> | 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> = {}): v2.PluginSummary {
return {
id,

View File

@@ -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<CodexPluginActivationResult> {
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}.`,
});
}

View File

@@ -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<unknown>;
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<CodexPluginsMarketplaceName, v2.PluginMarketplaceEntry>();
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 } : {}),
};

View File

@@ -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, {

View File

@@ -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) ||

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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");
});
});

View File

@@ -33,6 +33,46 @@ import type { ResolvedSmsAccount } from "./types.js";
const CHANNEL_ID = "sms";
type SmsStatusSnapshotAccount = Partial<ResolvedSmsAccount> & {
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<ResolvedSmsAccount>({
sectionKey: CHANNEL_ID,
listAccountIds: listSmsAccountIds,
@@ -254,16 +294,15 @@ export const smsPlugin: ChannelPlugin<ResolvedSmsAccount> = 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" })),
}),

View File

@@ -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 () => {

View File

@@ -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")}`,

View File

@@ -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 });
}
}

View File

@@ -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) ||

View File

@@ -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");
});

View File

@@ -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",

View File

@@ -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",