Compare commits

...

5 Commits

Author SHA1 Message Date
kevinlin-openai
64afe05084 docs(codex): keep plugin flow in mem 2026-05-06 09:44:02 -07:00
kevinlin-openai
ea461328e2 docs(codex): add native plugin flow 2026-05-06 09:44:02 -07:00
kevinlin-openai
2c99d1f209 feat(codex): migrate native plugins 2026-05-06 09:44:02 -07:00
kevinlin-openai
fc8e2196e9 fix(codex): enable native apps in app-server home 2026-05-06 09:40:05 -07:00
kevinlin-openai
e045e45210 feat(codex): use native plugin thread 2026-05-06 04:22:15 -07:00
17 changed files with 1893 additions and 27 deletions

View File

@@ -10,10 +10,10 @@ Docs: https://docs.openclaw.ai
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
- 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.
- Codex: migrate selected source-installed `openai-curated` Codex plugins into Codex app-server native activation so plugin mentions run in the main Codex session thread instead of through OpenClaw dynamic tools. 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.
- Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface.
- Diagnostics/Talk: export bounded Talk lifecycle/audio metrics and session recovery metrics through OpenTelemetry and Prometheus without exposing transcripts, audio payloads, room ids, turn ids, or session ids.
- Google Meet/Voice Call: make Twilio dial-in joins speak through the realtime Gemini voice bridge with paced audio streaming, backpressure-aware buffering, barge-in queue clearing, same-session agent consult routing, duplicate-consult coalescing, and no TwiML fallback during realtime speech, giving Meet participants a much snappier OpenClaw voice agent. (#77064) Thanks @scoootscooob.
- Voice Call/realtime: add opt-in OpenClaw agent voice context capsules and consult-cadence guidance so Gemini/OpenAI realtime calls can sound like the configured agent without consulting the full agent on every ordinary turn. Thanks @scoootscooob.
- Docker/Gateway: harden the gateway container by dropping `NET_RAW` and `NET_ADMIN` capabilities and enabling `no-new-privileges` in the bundled `docker-compose.yml`. Thanks @VintageAyu.

View File

@@ -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
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
<ParamField path="--skill <name>" type="string">
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.
</ParamField>
<ParamField path="--plugin <name>" type="string">
Select one source-installed `openai-curated` Codex plugin to activate through Codex app-server. Repeat the flag to migrate multiple plugins.
</ParamField>
<ParamField path="--no-backup" type="boolean">
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
</ParamField>
@@ -135,13 +140,19 @@ openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
`.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 selected with
`--plugin <name>`, activated through the target Codex app-server with
`plugin/install`. OpenClaw does not turn these into OpenClaw tools; invoke
them from Codex-mode turns with native mentions such as
`[@Google Calendar](plugin://google-calendar)`.
### 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
Cached Codex plugin bundles, non-`openai-curated` marketplaces, `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 unsafe or undiscoverable bundles for review instead of copying plugin
bytes into OpenClaw. Config and hook files are copied into the migration report
for manual review.
## Hermes provider

View File

@@ -0,0 +1,163 @@
---
title: Codex Native Plugin Apps
description: Milestone specs for removing OpenClaw Codex plugin dynamic tools and relying on Codex app-server native plugin support.
---
Draft implementation specification.
## 1. Milestone feature specs
### 1.1 Remove OpenClaw Codex plugin dynamic tools
Goal: remove the synthetic OpenClaw tool layer that converted configured Codex
plugins into OpenClaw dynamic tools.
User-visible behavior:
- Gateway tool discovery no longer exposes plugin tools for Codex-native apps.
- Codex-mode conversations no longer spawn a second ephemeral Codex thread just
to invoke a Codex plugin.
- Existing Codex app-server turns continue to receive ordinary OpenClaw dynamic
tools that are not native Codex plugin replacements.
Implementation scope:
- Delete the Codex plugin tool registration, inventory, activation, and invoker
modules.
- Remove the Codex plugin wildcard tool contract from the bundled plugin
manifest.
- Remove bridge-specific config schema, UI hints, docs, and tests.
Acceptance criteria:
- No bundled Codex manifest contract declares plugin-derived OpenClaw tools.
- No Codex plugin config key enables a synthetic OpenClaw tool bridge.
- Tool-contract tests keep generic wildcard coverage without referencing Codex
plugins.
Verification:
- Targeted config, manifest, and plugin-tool tests.
- A live dev-gateway proof shows no Codex plugin dynamic tool appears while
native plugin invocation still works.
### 1.2 Invoke plugins in the main Codex session thread
Goal: rely on Codex app-server's native mention handling in the session thread
that OpenClaw already uses for Codex-mode turns.
User-visible behavior:
- Users invoke native Codex plugins with mention syntax such as
`[@Google Calendar](plugin://google-calendar)` inside a Codex-mode message.
- Plugin calls share the same Codex transcript, approval semantics, and app
authorization flow as ordinary Codex app-server plugin use.
- OpenClaw no longer duplicates plugin auth or transcript behavior.
Implementation scope:
- Keep OpenClaw forwarding user text to `turn/start` on the bound Codex thread.
- Document native mention usage in the Codex harness and migration docs.
- Do not add compatibility parsing or translation for the removed OpenClaw tool
names.
Acceptance criteria:
- A TUI-submitted Codex-mode message containing a native plugin mention reaches
the Codex app-server turn on the bound thread.
- The live behavior uses native plugin/app events, not an OpenClaw tool call.
Verification:
- Showboat demo against the dev gateway and TUI with logs or transcript
evidence for the native plugin mention and resulting plugin behavior.
### 1.3 Migration activation
Goal: preserve useful Codex plugin migration by activating selected plugins in
Codex app-server, not in OpenClaw's tool registry.
User-visible behavior:
- `openclaw migrate codex --plugin <name>` can install or enable selected
source-installed `openai-curated` Codex plugins.
- Migration enables the bundled `codex` plugin and updates `plugins.allow` only
when needed for the Codex harness itself.
- Migration does not write tool allowlist entries or bridge config for Codex
plugins.
Implementation scope:
- Discover source-installed plugins through app-server `plugin/list` and
related app state through `app/list`.
- Keep apply-time `plugin/install` plus app, MCP server, and skill reloads.
- Report inaccessible or unauthorized apps on plugin items.
- Remove apply-time fallback to bridge config.
Acceptance criteria:
- Selected plugins are installed through app-server APIs.
- Failed app authorization does not create fallback tool config.
- Restrictive plugin allowlists are updated only for the bundled `codex` plugin.
Verification:
- Migration provider tests cover planning, selected plugin install, restrictive
allowlists, and app authorization failures.
## 2. Implementation plan
### 2.1 Remove the OpenClaw tool bridge as an invocation path
Codex-native apps should not be exposed as OpenClaw `codex_plugin_*` dynamic
tools. The native thread path keeps transcript, approval, and app authorization
inside the Codex app-server session.
### 2.2 Keep app-server plugin/app methods typed
OpenClaw still needs typed JSON-RPC coverage for native Codex plugin/app
configuration surfaces such as `plugin/list`, `plugin/install`, `app/list`, and
`hooks/list`. These methods are app-server control-plane calls, not OpenClaw
tool registrations.
### 2.3 Tolerate app-server permission-profile drift
Live app-server `thread/start` and `thread/resume` responses may contain newer
special filesystem path kinds before OpenClaw updates its generated schema.
Normalize unknown special path kinds to the stable `unknown` shape so native
plugin invocation is not blocked at thread startup.
### 2.4 Invoke native plugins from the bound Codex thread
Users invoke plugins with native Codex mention syntax, for example
`[@Google Calendar](plugin://google-calendar)`, in the same Codex-mode message
that OpenClaw forwards to `turn/start`.
### 2.5 Migrate selected plugins through app-server
This stacked PR adds Codex source discovery, planning, apply-time install/reload
behavior, and migration CLI/docs updates. The migration provider records native
plugin items as `plugin` install actions with `nativeThreadPlugin: true`, then
applies them with Codex app-server control-plane calls instead of OpenClaw tool
registration.
### 2.6 PR stack docs, tests, and proof
PR 1 owns:
- Harness docs for native mention usage and the removal of bridge tool
semantics.
- App-server schema normalization tests for current live `permissionProfile`
responses.
- Generic wildcard plugin-tool contract tests that keep OpenClaw's plugin tool
registry behavior independent of Codex-native plugin ids.
- Showboat/dev-gateway/TUI proof that a native plugin mention reaches the main
Codex app-server thread without a `codex_plugin_*` OpenClaw tool call.
This PR owns:
- CLI migration docs for `openclaw migrate codex --plugin <name>` and native
mention usage after migration.
- Harness docs that explain the migration-specific native setup behavior.
- Migration provider tests for selected plugin discovery, install, reload,
restrictive allowlist handling, and related-app authorization failures.

View File

@@ -264,6 +264,33 @@ For live and Docker smoke tests, auth usually comes from the Codex CLI account
or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can
also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present.
## Native Codex plugins and apps
OpenClaw relies on Codex app-server's native plugin and app support for Codex
mode. It does not register synthetic OpenClaw tools for Codex plugins, and it
does not start a separate ephemeral Codex thread to invoke a plugin. Plugin
mentions stay in the main Codex app-server thread for the session.
Use native Codex mention syntax in a Codex-mode turn:
```md
[@Google Calendar](plugin://google-calendar) Find a free slot tomorrow afternoon.
```
When `openclaw migrate codex --plugin ...` sees source-installed
`openai-curated` Codex plugins through app-server discovery, migration applies
only the native setup:
- enables the bundled `codex` plugin when needed
- adds `codex` to `plugins.allow` when that allowlist is already restrictive
- installs or enables the selected `openai-curated` plugin with `plugin/install`
- reloads Codex skills, MCP servers, and apps through app-server APIs
After migration, the Codex app-server owns plugin activation, app authorization,
permission prompts, and transcript semantics. If a related app is inaccessible
or needs authorization, migration reports that on the plugin item instead of
creating an OpenClaw fallback tool.
## Workspace bootstrap files
Codex handles `AGENTS.md` itself through native project-doc discovery. OpenClaw

View File

@@ -138,12 +138,61 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
await expect(fs.access(codexHome)).resolves.toBeUndefined();
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
"[features]\napps = true\n\n[apps._default]\nenabled = true\n",
);
expect(startOptions.env).toBeUndefined();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("upserts native Codex app defaults into an existing app-server config", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions();
try {
const codexHome = resolveCodexAppServerHomeDir(agentDir);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(
path.join(codexHome, "config.toml"),
[
'model = "gpt-5.5"',
"",
"[features]",
"apps = false",
"codex_hooks = true",
"",
"[apps._default]",
"enabled = false",
"open_world_enabled = false",
"",
].join("\n"),
);
await bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
});
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
[
'model = "gpt-5.5"',
"",
"[features]",
"apps = true",
"codex_hooks = true",
"",
"[apps._default]",
"enabled = true",
"open_world_enabled = false",
"",
].join("\n"),
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const codexHome = path.join(agentDir, "custom-codex-home");
@@ -169,6 +218,9 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
await expect(fs.access(codexHome)).resolves.toBeUndefined();
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toContain(
"[features]\napps = true",
);
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });

View File

@@ -25,6 +25,7 @@ const CODEX_HOME_ENV_VAR = "CODEX_HOME";
const HOME_ENV_VAR = "HOME";
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
const CODEX_APP_SERVER_CONFIG_FILENAME = "config.toml";
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
@@ -111,6 +112,7 @@ async function withAgentCodexHomeEnvironment(
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
await fs.mkdir(codexHome, { recursive: true });
await fs.mkdir(nativeHome, { recursive: true });
await ensureCodexAppServerAppsConfig(codexHome);
const nextStartOptions: CodexAppServerStartOptions = {
...startOptions,
env: {
@@ -128,6 +130,84 @@ async function withAgentCodexHomeEnvironment(
return nextStartOptions;
}
async function ensureCodexAppServerAppsConfig(codexHome: string): Promise<void> {
const configPath = path.join(codexHome, CODEX_APP_SERVER_CONFIG_FILENAME);
let current = "";
try {
current = await fs.readFile(configPath, "utf8");
} catch (error) {
if (!isNodeErrorCode(error, "ENOENT")) {
throw error;
}
}
const next = upsertTomlBoolean(
upsertTomlBoolean(current, "features", "apps"),
"apps._default",
"enabled",
);
if (next !== current) {
await fs.writeFile(configPath, next, "utf8");
}
}
function upsertTomlBoolean(content: string, section: string, key: string): string {
const lines = content.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const sectionHeader = `[${section}]`;
const sectionStart = lines.findIndex((line) => line.trim() === sectionHeader);
if (sectionStart === -1) {
const nextLines = [...lines];
if (nextLines.length > 0) {
nextLines.push("");
}
nextLines.push(sectionHeader, `${key} = true`);
return `${nextLines.join("\n")}\n`;
}
const sectionEnd = findTomlSectionEnd(lines, sectionStart + 1);
const keyIndex = findTomlKey(lines, key, sectionStart + 1, sectionEnd);
const nextLines = [...lines];
if (keyIndex === -1) {
nextLines.splice(sectionEnd, 0, `${key} = true`);
} else if (nextLines[keyIndex] !== `${key} = true`) {
nextLines[keyIndex] = `${key} = true`;
}
return `${nextLines.join("\n")}\n`;
}
function findTomlSectionEnd(lines: string[], start: number): number {
for (let index = start; index < lines.length; index += 1) {
if (/^\s*\[[^\]]+\]\s*$/.test(lines[index])) {
return index;
}
}
return lines.length;
}
function findTomlKey(lines: string[], key: string, start: number, end: number): number {
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
for (let index = start; index < end; index += 1) {
const line = lines[index];
if (line.trimStart().startsWith("#")) {
continue;
}
if (keyPattern.test(line)) {
return index;
}
}
return -1;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isNodeErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
if (!clearEnv) {
return undefined;

View File

@@ -41,11 +41,19 @@ const validateTurnCompletedNotification = ajv.compile<v2.TurnCompletedNotificati
const validateTurnStartResponse = ajv.compile<CodexTurnStartResponse>(turnStartResponseSchema);
export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse {
return assertCodexShape(validateThreadStartResponse, value, "thread/start response");
return assertCodexShape(
validateThreadStartResponse,
normalizeThreadPermissionProfile(value),
"thread/start response",
);
}
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
return assertCodexShape(validateThreadResumeResponse, value, "thread/resume response");
return assertCodexShape(
validateThreadResumeResponse,
normalizeThreadPermissionProfile(value),
"thread/resume response",
);
}
export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse {
@@ -95,6 +103,95 @@ function readCodexShape<T>(validate: ValidateFunction<T>, value: unknown): T | u
return validate(value) ? value : undefined;
}
function normalizeThreadPermissionProfile(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const response = value as { permissionProfile?: unknown };
const permissionProfile = normalizePermissionProfile(response.permissionProfile);
if (permissionProfile === response.permissionProfile) {
return value;
}
return { ...value, permissionProfile };
}
function normalizePermissionProfile(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const profile = value as { type?: unknown; fileSystem?: unknown };
if (profile.type !== "managed") {
return value;
}
const fileSystem = normalizePermissionProfileFileSystem(profile.fileSystem);
if (fileSystem === profile.fileSystem) {
return value;
}
return { ...value, fileSystem };
}
function normalizePermissionProfileFileSystem(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const fileSystem = value as { type?: unknown; entries?: unknown };
if (fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)) {
return value;
}
let changed = false;
const entries = fileSystem.entries.map((entry) => {
const normalized = normalizePermissionProfileFileSystemEntry(entry);
if (normalized !== entry) {
changed = true;
}
return normalized;
});
return changed ? { ...value, entries } : value;
}
function normalizePermissionProfileFileSystemEntry(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const entry = value as { path?: unknown };
const normalizedPath = normalizePermissionProfileFileSystemPath(entry.path);
return normalizedPath === entry.path ? value : { ...value, path: normalizedPath };
}
function normalizePermissionProfileFileSystemPath(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const path = value as { type?: unknown; value?: unknown };
if (path.type !== "special" || !path.value || typeof path.value !== "object") {
return value;
}
const special = path.value as { kind?: unknown; subpath?: unknown };
if (typeof special.kind !== "string" || isKnownPermissionProfileSpecialPathKind(special.kind)) {
return value;
}
return {
...value,
value: {
kind: "unknown",
path: special.kind,
subpath:
typeof special.subpath === "string" || special.subpath === null ? special.subpath : null,
},
};
}
function isKnownPermissionProfileSpecialPathKind(kind: string): boolean {
return (
kind === "root" ||
kind === "minimal" ||
kind === "project_roots" ||
kind === "tmpdir" ||
kind === "slash_tmp" ||
kind === "unknown"
);
}
function normalizeTurn(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;

View File

@@ -89,6 +89,12 @@ type CodexAppServerRequestResultMap = {
"account/read": v2.GetAccountResponse;
"feedback/upload": v2.FeedbackUploadResponse;
"mcpServerStatus/list": v2.ListMcpServerStatusResponse;
"plugin/list": v2.PluginListResponse;
"plugin/install": v2.PluginInstallResponse;
"app/list": v2.AppsListResponse;
"hooks/list": v2.HooksListResponse;
"config/mcpServer/reload": undefined;
"config/batchWrite": undefined;
"model/list": v2.ModelListResponse;
"review/start": v2.ReviewStartResponse;
"skills/list": v2.SkillsListResponse;

View File

@@ -166,4 +166,43 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("accepts newer permission-profile special filesystem path kinds", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return {
...threadStartResult(),
permissionProfile: {
type: "managed",
network: { enabled: true },
fileSystem: {
type: "restricted",
entries: [
{
path: {
type: "special",
value: { kind: "future_project_roots", subpath: null },
},
access: "write",
},
],
},
},
};
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object));
});
});

View File

@@ -1,5 +1,11 @@
import path from "node:path";
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import {
applyMigrationConfigPatchItem,
markMigrationItemConflict,
markMigrationItemError,
readMigrationConfigPatchDetails,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
@@ -11,8 +17,276 @@ import type {
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolveCodexAppServerRuntimeOptions } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol-generated/typescript/index.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import { buildCodexMigrationPlan } from "./plan.js";
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
const CODEX_PLUGIN_APPLY_TIMEOUT_MS = 60_000;
const CODEX_CONFIG_ALLOWLIST_ITEM_IDS = new Set(["config:codex-plugin-allowlist"]);
type CodexMigrationAppServerRequest = (method: string, params?: unknown) => Promise<unknown>;
let appServerRequestForTests: CodexMigrationAppServerRequest | undefined;
function readCodexPluginConfigFromOpenClawConfig(config: unknown): unknown {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return undefined;
}
const plugins = (config as { plugins?: unknown }).plugins;
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
return undefined;
}
const entries = (plugins as { entries?: unknown }).entries;
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return undefined;
}
const codex = (entries as Record<string, unknown>).codex;
if (!codex || typeof codex !== "object" || Array.isArray(codex)) {
return undefined;
}
return (codex as { config?: unknown }).config;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readConfigPath(config: unknown, path: readonly string[]): unknown {
let current: unknown = config;
for (const segment of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
function writeConfigPath(root: Record<string, unknown>, path: readonly string[], value: unknown) {
let current = root;
for (const segment of path.slice(0, -1)) {
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
const leaf = path.at(-1);
if (leaf) {
current[leaf] = value;
}
}
function mergeStringAllowlist(existing: unknown, values: readonly string[]): string[] | undefined {
if (existing !== undefined && !Array.isArray(existing)) {
return undefined;
}
if (Array.isArray(existing) && !existing.every((value) => typeof value === "string")) {
return undefined;
}
const next = new Set<string>(Array.isArray(existing) ? existing : []);
for (const value of values) {
next.add(value);
}
return [...next];
}
async function defaultAppServerRequest(
ctx: MigrationProviderContext,
): Promise<CodexMigrationAppServerRequest> {
const runtimeOptions = resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfigFromOpenClawConfig(ctx.config),
});
const startOptions =
typeof ctx.source === "string" && ctx.source.trim()
? {
...runtimeOptions.start,
env: {
...runtimeOptions.start.env,
CODEX_HOME: ctx.source,
},
}
: runtimeOptions.start;
return async (method: string, requestParams?: unknown): Promise<unknown> =>
await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: CODEX_PLUGIN_APPLY_TIMEOUT_MS,
startOptions,
config: ctx.config,
});
}
function readPluginDetail(item: MigrationItem):
| {
pluginName: string;
marketplaceName: string;
accessible?: boolean;
}
| undefined {
const pluginName = item.details?.pluginName;
const marketplaceName = item.details?.marketplaceName;
const accessible = item.details?.accessible;
if (typeof pluginName !== "string" || typeof marketplaceName !== "string") {
return undefined;
}
return {
pluginName,
marketplaceName,
...(typeof accessible === "boolean" ? { accessible } : {}),
};
}
async function refreshCodexPluginRuntime(request: CodexMigrationAppServerRequest): Promise<void> {
await request("plugin/list", { cwds: [] } satisfies v2.PluginListParams);
await request("skills/list", {
cwds: [],
forceReload: true,
} satisfies v2.SkillsListParams);
await request("config/mcpServer/reload", undefined);
await request("app/list", {
limit: 100,
forceRefetch: true,
} satisfies v2.AppsListParams);
}
async function applyCodexPluginActivationItems(params: {
ctx: MigrationProviderContext;
items: MigrationItem[];
}): Promise<MigrationItem[]> {
if (params.items.length === 0) {
return [];
}
const request = appServerRequestForTests ?? (await defaultAppServerRequest(params.ctx));
const listed = (await request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const marketplace = listed.marketplaces.find(
(entry) => entry.name === OPENAI_CURATED_MARKETPLACE,
);
const applied: MigrationItem[] = [];
let changed = false;
for (const item of params.items) {
const detail = readPluginDetail(item);
if (!detail) {
applied.push({ ...item, status: "error", reason: "missing plugin migration metadata" });
continue;
}
if (detail.marketplaceName !== OPENAI_CURATED_MARKETPLACE) {
applied.push({
...item,
status: "error",
reason: "only openai-curated Codex plugins can be activated by migration",
});
continue;
}
const plugin = marketplace?.plugins.find(
(candidate) =>
candidate.name === detail.pluginName ||
candidate.id === detail.pluginName ||
candidate.id === `${detail.pluginName}@${OPENAI_CURATED_MARKETPLACE}`,
);
if (!marketplace || !plugin) {
applied.push({
...item,
status: "error",
reason: `openai-curated Codex plugin "${detail.pluginName}" was not found in target app-server inventory`,
});
continue;
}
if (plugin.installed && plugin.enabled && detail.accessible === false) {
applied.push({
...item,
status: "error",
reason: `plugin "${detail.pluginName}" is installed and enabled but its app is not accessible; reauthorize the app before migration can enable it`,
});
continue;
}
if (plugin.installed && plugin.enabled) {
applied.push({
...item,
status: "migrated",
reason: "already installed and enabled",
});
continue;
}
if (!marketplace.path) {
applied.push({
...item,
status: "error",
reason: "openai-curated marketplace path is unavailable",
});
continue;
}
const installResponse = (await request("plugin/install", {
marketplacePath: marketplace.path,
pluginName: detail.pluginName,
} satisfies v2.PluginInstallParams)) as v2.PluginInstallResponse;
changed = true;
const appsNeedingAuth = installResponse.appsNeedingAuth ?? [];
if (appsNeedingAuth.length > 0) {
applied.push({
...item,
status: "error",
reason: `plugin installed but requires app authorization before migration can enable it: ${appsNeedingAuth
.map((app) => app.name || app.id)
.join(", ")}`,
});
continue;
}
applied.push({ ...item, status: "migrated" });
}
if (changed) {
await refreshCodexPluginRuntime(request);
}
return applied;
}
async function applyCodexAllowlistConfigPatchItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
if (item.status !== "planned") {
return item;
}
const details = readMigrationConfigPatchDetails(item);
const values = details?.value;
if (!details || !Array.isArray(values) || !values.every((value) => typeof value === "string")) {
return markMigrationItemError(item, "missing allowlist config patch");
}
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return markMigrationItemError(item, "config runtime unavailable");
}
const current = configApi.current() as MigrationProviderContext["config"];
const merged = mergeStringAllowlist(readConfigPath(current, details.path), values);
if (!merged && !ctx.overwrite) {
return markMigrationItemConflict(item, "target exists");
}
try {
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
const existing = readConfigPath(draft, details.path);
const next = mergeStringAllowlist(existing, values);
if (!next && !ctx.overwrite) {
throw new Error("target exists");
}
writeConfigPath(draft as Record<string, unknown>, details.path, next ?? values);
},
});
return { ...item, status: "migrated" };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return reason === "target exists"
? markMigrationItemConflict(item, reason)
: markMigrationItemError(item, reason);
}
}
export async function applyCodexMigrationPlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
@@ -20,13 +294,37 @@ export async function applyCodexMigrationPlan(params: {
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
const items: MigrationItem[] = [];
const pluginActivationItems = plan.items.filter(
(item) => item.kind === "plugin" && item.action === "install" && item.status === "planned",
);
const appliedPluginItemsById = new Map(
(
await applyCodexPluginActivationItems({
ctx: params.ctx,
items: pluginActivationItems,
})
).map((item) => [item.id, item]),
);
for (const item of plan.items) {
const appliedPluginItem = appliedPluginItemsById.get(item.id);
if (appliedPluginItem) {
items.push(appliedPluginItem);
continue;
}
if (item.status !== "planned") {
items.push(item);
continue;
}
if (item.action === "archive") {
items.push(await archiveMigrationItem(item, reportDir));
} else if (
item.kind === "config" &&
item.action === "merge" &&
CODEX_CONFIG_ALLOWLIST_ITEM_IDS.has(item.id)
) {
items.push(await applyCodexAllowlistConfigPatchItem(params.ctx, item));
} else if (item.kind === "config" && item.action === "merge") {
items.push(await applyMigrationConfigPatchItem(params.ctx, item));
} else {
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
}
@@ -41,3 +339,9 @@ export async function applyCodexMigrationPlan(params: {
await writeMigrationReport(result, { title: "Codex Migration Report" });
return result;
}
export const __testing = {
setAppServerRequestForTests(request: CodexMigrationAppServerRequest | undefined): void {
appServerRequestForTests = request;
},
};

View File

@@ -1,7 +1,9 @@
import path from "node:path";
import {
createMigrationItem,
createMigrationConfigPatchItem,
createMigrationManualItem,
hasMigrationConfigPatchConflict,
MIGRATION_REASON_TARGET_EXISTS,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
@@ -11,9 +13,43 @@ import type {
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { exists, sanitizeName } from "./helpers.js";
import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js";
import {
discoverCodexSource,
hasCodexSource,
type CodexInstalledPluginSource,
type CodexSkillSource,
} from "./source.js";
import { resolveCodexMigrationTargets } from "./targets.js";
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
type CodexMigrationContext = MigrationProviderContext & {
plugins?: string[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readConfigPath(config: unknown, path: readonly string[]): unknown {
let current: unknown = config;
for (const segment of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
function appendUnique(values: readonly string[], value: string): string[] {
return values.includes(value) ? [...values] : [...values, value];
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry): entry is string => typeof entry === "string");
}
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
const base = sanitizeName(skill.name) || "codex-skill";
if ((counts.get(base) ?? 0) <= 1) {
@@ -67,10 +103,143 @@ async function buildSkillItems(params: {
return items;
}
function normalizePluginSelectionRef(value: string): string {
return sanitizeName(value).replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), "");
}
function readSelectedPlugins(ctx: MigrationProviderContext): Set<string> | undefined {
const selected = (ctx as CodexMigrationContext).plugins;
if (!selected || selected.length === 0) {
return undefined;
}
return new Set(
selected
.map((plugin) => normalizePluginSelectionRef(plugin))
.filter((plugin) => plugin.length > 0),
);
}
function codexPluginKey(plugin: CodexInstalledPluginSource): string {
return sanitizeName(plugin.name) || sanitizeName(plugin.id) || "codex-plugin";
}
function selectCodexPlugins(params: {
plugins: CodexInstalledPluginSource[];
selected?: Set<string>;
}): CodexInstalledPluginSource[] {
if (!params.selected) {
return params.plugins;
}
const availableRefs = new Map<string, CodexInstalledPluginSource>();
for (const plugin of params.plugins) {
const refs = [
plugin.name,
plugin.id,
codexPluginKey(plugin),
plugin.id.replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), ""),
];
for (const ref of refs) {
availableRefs.set(normalizePluginSelectionRef(ref), plugin);
}
}
const selectedPlugins: CodexInstalledPluginSource[] = [];
const unknown: string[] = [];
for (const ref of params.selected) {
const plugin = availableRefs.get(ref);
if (!plugin) {
unknown.push(ref);
continue;
}
if (!selectedPlugins.some((existing) => existing.id === plugin.id)) {
selectedPlugins.push(plugin);
}
}
if (unknown.length > 0) {
const available = params.plugins.map((plugin) => plugin.name).toSorted();
throw new Error(
`No migratable Codex plugin matched ${unknown.map((item) => `"${item}"`).join(", ")}. Available plugins: ${
available.length > 0 ? available.join(", ") : "none"
}.`,
);
}
return selectedPlugins.toSorted((a, b) => a.name.localeCompare(b.name));
}
function buildCodexPluginConfigItems(params: { ctx: MigrationProviderContext }): MigrationItem[] {
const items: MigrationItem[] = [];
items.push(
createMigrationConfigPatchItem({
id: "config:codex-enabled",
target: "plugins.entries.codex.enabled",
path: ["plugins", "entries", "codex", "enabled"],
value: true,
message:
"Enable the bundled Codex plugin so migrated Codex plugins run through the native app-server thread.",
conflict:
!params.ctx.overwrite &&
hasMigrationConfigPatchConflict(
params.ctx.config,
["plugins", "entries", "codex", "enabled"],
true,
),
}),
);
const pluginAllow = readConfigPath(params.ctx.config, ["plugins", "allow"]);
if (isStringArray(pluginAllow)) {
const value = appendUnique(pluginAllow, "codex");
items.push(
createMigrationConfigPatchItem({
id: "config:codex-plugin-allowlist",
target: "plugins.allow",
path: ["plugins", "allow"],
value,
message: "Include Codex in the plugin allowlist so the enabled plugin can load.",
}),
);
}
return items;
}
function buildCodexPluginItems(params: {
ctx: MigrationProviderContext;
plugins: CodexInstalledPluginSource[];
}): MigrationItem[] {
if (params.plugins.length === 0) {
return [];
}
const items: MigrationItem[] = [];
for (const plugin of params.plugins) {
items.push(
createMigrationItem({
id: `plugin:${codexPluginKey(plugin)}`,
kind: "plugin",
action: "install",
source: `${OPENAI_CURATED_MARKETPLACE}:${plugin.name}`,
status: "planned",
message: `Activate Codex plugin "${plugin.displayName}" through Codex app-server.`,
details: {
pluginId: plugin.id,
pluginName: plugin.name,
displayName: plugin.displayName,
marketplaceName: OPENAI_CURATED_MARKETPLACE,
...(plugin.marketplacePath ? { marketplacePath: plugin.marketplacePath } : {}),
sourceInstalled: plugin.installed,
sourceEnabled: plugin.enabled,
nativeThreadPlugin: true,
...(plugin.accessible !== undefined ? { accessible: plugin.accessible } : {}),
},
}),
);
}
items.push(...buildCodexPluginConfigItems({ ctx: params.ctx }));
return items;
}
export async function buildCodexMigrationPlan(
ctx: MigrationProviderContext,
): Promise<MigrationPlan> {
const source = await discoverCodexSource(ctx.source);
const source = await discoverCodexSource(ctx.source, { config: ctx.config });
if (!hasCodexSource(source)) {
throw new Error(
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
@@ -85,14 +254,19 @@ export async function buildCodexMigrationPlan(
overwrite: ctx.overwrite,
})),
);
const selectedPlugins = selectCodexPlugins({
plugins: source.nativePlugins,
selected: readSelectedPlugins(ctx),
});
items.push(...buildCodexPluginItems({ ctx, plugins: selectedPlugins }));
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.`,
message: `Codex native plugin "${plugin.name}" was found in the cache scan but not activated automatically.`,
recommendation:
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
"Codex plugin migration can activate source-installed openai-curated plugins only when app-server discovery is available; review other cached plugin bundles manually.",
}),
);
}
@@ -116,9 +290,19 @@ export async function buildCodexMigrationPlan(
"Conflicts were found. Re-run with --overwrite to replace conflicting skill targets after item-level backups.",
]
: []),
...(source.pluginDiscoveryError
? [
`Codex app-server plugin discovery was unavailable (${source.pluginDiscoveryError}). Cached plugin bundles are reported for manual review only.`,
]
: []),
...(selectedPlugins.length > 0
? [
"Source-installed openai-curated Codex plugins will be activated through Codex app-server during apply and invoked by native plugin mentions on the main Codex thread. Plugin bytes are not copied manually.",
]
: []),
...(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.",
"Cached Codex plugin bundles are manual-review fallback items. OpenClaw does not copy plugin bytes or activate non-openai-curated marketplaces.",
]
: []),
...(source.archivePaths.length > 0
@@ -136,6 +320,7 @@ export async function buildCodexMigrationPlan(
warnings,
nextSteps: [
"Run openclaw doctor after applying the migration.",
"Invoke migrated Codex plugins from Codex-mode turns with native mentions such as [@Google Calendar](plugin://google-calendar).",
"Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.",
],
metadata: {

View File

@@ -3,7 +3,9 @@ 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 { __testing as applyTesting } from "./apply.js";
import { buildCodexMigrationProvider } from "./provider.js";
import { __testing as sourceTesting } from "./source.js";
const tempRoots = new Set<string>();
@@ -31,20 +33,154 @@ function makeContext(params: {
workspaceDir: string;
overwrite?: boolean;
reportDir?: string;
plugins?: 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"]),
source: params.source,
stateDir: params.stateDir,
overwrite: params.overwrite,
reportDir: params.reportDir,
...(params.plugins ? { plugins: params.plugins } : {}),
...(params.runtime ? { runtime: params.runtime } : {}),
logger,
} as MigrationProviderContext;
}
function createConfigRuntime(initialConfig: MigrationProviderContext["config"]): {
runtime: NonNullable<MigrationProviderContext["runtime"]>;
getConfig: () => MigrationProviderContext["config"];
} {
let currentConfig = structuredClone(initialConfig);
const runtime = {
config: {
current: () => currentConfig,
mutateConfigFile: async (params: {
mutate: (draft: MigrationProviderContext["config"]) => void | Promise<void>;
}) => {
const draft = structuredClone(currentConfig);
await params.mutate(draft);
currentConfig = draft;
return { nextConfig: currentConfig };
},
},
} as unknown as NonNullable<MigrationProviderContext["runtime"]>;
return { runtime, getConfig: () => currentConfig };
}
function pluginListResponse(params: {
gmail?: { installed?: boolean; enabled?: boolean };
slack?: { installed?: boolean; enabled?: boolean };
includeOtherMarketplace?: boolean;
}) {
return {
marketplaces: [
{
name: "openai-curated",
path: "/tmp/openai-curated",
interface: null,
plugins: [
{
id: "gmail@openai-curated",
name: "gmail",
source: { type: "local", path: "/tmp/openai-curated/gmail" },
installed: params.gmail?.installed ?? true,
enabled: params.gmail?.enabled ?? true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: { displayName: "Gmail" },
},
{
id: "slack@openai-curated",
name: "slack",
source: { type: "local", path: "/tmp/openai-curated/slack" },
installed: params.slack?.installed ?? true,
enabled: params.slack?.enabled ?? false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: { displayName: "Slack" },
},
],
},
...(params.includeOtherMarketplace
? [
{
name: "openai-primary-runtime",
path: "/tmp/openai-primary-runtime",
interface: null,
plugins: [
{
id: "documents@openai-primary-runtime",
name: "documents",
source: { type: "local", path: "/tmp/openai-primary-runtime/documents" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: { displayName: "Documents" },
},
],
},
]
: []),
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
function appsListResponse(params: { accessible?: boolean; extraAppAccessible?: boolean } = {}) {
return {
data: [
{
id: "gmail",
name: "Gmail",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: params.accessible ?? true,
isEnabled: true,
pluginDisplayNames: ["Gmail"],
},
...(params.extraAppAccessible === undefined
? []
: [
{
id: "gmail-extra",
name: "Gmail extra",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: params.extraAppAccessible,
isEnabled: true,
pluginDisplayNames: ["Gmail"],
},
]),
],
nextCursor: null,
};
}
@@ -61,6 +197,9 @@ async function createCodexFixture(): Promise<{
const stateDir = path.join(root, "state");
const workspaceDir = path.join(root, "workspace");
vi.stubEnv("HOME", homeDir);
sourceTesting.setAppServerRequestForTests(async () => {
throw new Error("app-server unavailable");
});
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"), "# Tweet helper\n");
await writeFile(path.join(codexHome, "skills", ".system", "system-skill", "SKILL.md"));
await writeFile(path.join(homeDir, ".agents", "skills", "personal-style", "SKILL.md"));
@@ -84,6 +223,8 @@ async function createCodexFixture(): Promise<{
afterEach(async () => {
vi.unstubAllEnvs();
sourceTesting.setAppServerRequestForTests(undefined);
applyTesting.setAppServerRequestForTests(undefined);
for (const root of tempRoots) {
await fs.rm(root, { recursive: true, force: true });
}
@@ -146,11 +287,107 @@ describe("buildCodexMigrationProvider", () => {
);
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("Codex native plugins are reported for manual review only"),
expect.stringContaining("Codex app-server plugin discovery was unavailable"),
expect.stringContaining("Cached Codex plugin bundles are manual-review fallback items"),
]),
);
});
it("plans installed openai-curated Codex plugins for native app-server activation", async () => {
const fixture = await createCodexFixture();
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({ includeOtherMarketplace: true });
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected ${method}`);
});
sourceTesting.setAppServerRequestForTests(request);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(request).toHaveBeenCalledWith("plugin/list", { cwds: [] });
expect(request).toHaveBeenCalledWith("app/list", {
limit: 100,
forceRefetch: true,
});
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:gmail",
kind: "plugin",
action: "install",
status: "planned",
details: expect.objectContaining({
marketplaceName: "openai-curated",
pluginName: "gmail",
}),
}),
expect.objectContaining({
id: "plugin:slack",
kind: "plugin",
action: "install",
status: "planned",
details: expect.objectContaining({
marketplaceName: "openai-curated",
pluginName: "slack",
}),
}),
expect.objectContaining({
id: "config:codex-enabled",
kind: "config",
action: "merge",
details: expect.objectContaining({
path: ["plugins", "entries", "codex", "enabled"],
value: true,
}),
}),
]),
);
expect(plan.items).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "plugin:documents:1" })]),
);
});
it("filters Codex plugin migration with repeated plugin selections", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected ${method}`);
});
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
}),
);
expect(plan.items).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "plugin:gmail" })]),
);
expect(plan.items).not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: "plugin:slack" })]),
);
});
it("copies planned skills and archives native config during apply", async () => {
const fixture = await createCodexFixture();
const reportDir = path.join(fixture.root, "report");
@@ -184,6 +421,388 @@ describe("buildCodexMigrationProvider", () => {
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined();
});
it("activates selected source-installed curated plugins natively during apply", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected plan ${method}`);
});
const applyRequest = vi.fn(async (method: string, params: unknown) => {
if (method === "plugin/list") {
return pluginListResponse({
gmail: { installed: false, enabled: false },
slack: { installed: true, enabled: false },
});
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] };
}
if (method === "skills/list") {
return { data: [] };
}
if (method === "config/mcpServer/reload") {
return undefined;
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected apply ${method} ${JSON.stringify(params)}`);
});
applyTesting.setAppServerRequestForTests(applyRequest);
const config = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
const { runtime, getConfig } = createConfigRuntime(config);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
config,
}),
);
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir: path.join(fixture.root, "report"),
config,
runtime,
}),
plan,
);
expect(applyRequest).toHaveBeenCalledWith("plugin/install", {
marketplacePath: "/tmp/openai-curated",
pluginName: "gmail",
});
expect(applyRequest).not.toHaveBeenCalledWith(
"plugin/install",
expect.objectContaining({ pluginName: "slack" }),
);
expect(applyRequest).toHaveBeenCalledWith("skills/list", {
cwds: [],
forceReload: true,
});
expect(applyRequest).toHaveBeenCalledWith("config/mcpServer/reload", undefined);
expect(applyRequest).toHaveBeenCalledWith("app/list", {
limit: 100,
forceRefetch: true,
});
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "plugin:gmail", status: "migrated" }),
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
]),
);
expect(getConfig()).toMatchObject({
plugins: {
entries: {
codex: {
enabled: true,
},
},
},
});
expect(getConfig()).not.toMatchObject({
plugins: { entries: { codex: { config: expect.anything() } } },
});
});
it("merges Codex into existing plugin allowlists during apply", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected plan ${method}`);
});
applyTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
throw new Error(`unexpected apply ${method}`);
});
const config = {
agents: { defaults: { workspace: fixture.workspaceDir } },
plugins: { allow: ["browser"] },
tools: { alsoAllow: ["browser"] },
} as MigrationProviderContext["config"];
const { runtime, getConfig } = createConfigRuntime(config);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
config,
}),
);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "config:codex-plugin-allowlist", status: "planned" }),
]),
);
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir: path.join(fixture.root, "report"),
config,
runtime,
}),
plan,
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "config:codex-plugin-allowlist", status: "migrated" }),
]),
);
expect(getConfig()).toMatchObject({
plugins: { allow: ["browser", "codex"] },
tools: { alsoAllow: ["browser"] },
});
});
it("reports native plugin activation errors when app auth is still required", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected plan ${method}`);
});
const applyRequest = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({ gmail: { installed: false, enabled: false } });
}
if (method === "plugin/install") {
return {
authPolicy: "ON_USE",
appsNeedingAuth: [
{
id: "gmail",
name: "Gmail",
description: null,
installUrl: "https://example.invalid/auth",
needsAuth: true,
},
],
};
}
if (method === "skills/list") {
return { data: [] };
}
if (method === "config/mcpServer/reload") {
return undefined;
}
if (method === "app/list") {
return appsListResponse();
}
throw new Error(`unexpected apply ${method}`);
});
applyTesting.setAppServerRequestForTests(applyRequest);
const config = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
const { runtime } = createConfigRuntime(config);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
config,
}),
);
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir: path.join(fixture.root, "report"),
config,
runtime,
}),
plan,
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:gmail",
status: "error",
reason: expect.stringContaining("requires app authorization"),
}),
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
]),
);
});
it("reports native plugin activation errors when an already-installed app is inaccessible", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse({ accessible: false });
}
throw new Error(`unexpected plan ${method}`);
});
const applyRequest = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
throw new Error(`unexpected apply ${method}`);
});
applyTesting.setAppServerRequestForTests(applyRequest);
const config = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
const { runtime } = createConfigRuntime(config);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
config,
}),
);
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir: path.join(fixture.root, "report"),
config,
runtime,
}),
plan,
);
expect(applyRequest).not.toHaveBeenCalledWith("plugin/install", expect.anything());
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:gmail",
status: "error",
reason: expect.stringContaining("app is not accessible"),
}),
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
]),
);
});
it("reports native plugin activation errors when any related app is inaccessible", async () => {
const fixture = await createCodexFixture();
sourceTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
if (method === "app/list") {
return appsListResponse({ accessible: true, extraAppAccessible: false });
}
throw new Error(`unexpected plan ${method}`);
});
applyTesting.setAppServerRequestForTests(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({});
}
throw new Error(`unexpected apply ${method}`);
});
const config = {
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
const { runtime } = createConfigRuntime(config);
const provider = buildCodexMigrationProvider();
const plan = await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
plugins: ["gmail"],
config,
}),
);
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
reportDir: path.join(fixture.root, "report"),
config,
runtime,
}),
plan,
);
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "plugin:gmail",
status: "error",
reason: expect.stringContaining("app is not accessible"),
}),
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
]),
);
});
it("does not call plugin install during dry-run planning", async () => {
const fixture = await createCodexFixture();
const request = vi.fn(async (method: string) => {
if (method === "plugin/list") {
return pluginListResponse({ gmail: { installed: false, enabled: false } });
}
if (method === "app/list") {
return appsListResponse();
}
if (method === "plugin/install") {
throw new Error("dry-run must not install");
}
throw new Error(`unexpected ${method}`);
});
sourceTesting.setAppServerRequestForTests(request);
const provider = buildCodexMigrationProvider();
await provider.plan(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
}),
);
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
});
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"));

View File

@@ -1,6 +1,10 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { resolveCodexAppServerRuntimeOptions } from "../app-server/config.js";
import type { v2 } from "../app-server/protocol-generated/typescript/index.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import {
exists,
isDirectory,
@@ -12,6 +16,8 @@ import {
const SKILL_FILENAME = "SKILL.md";
const MAX_SCAN_DEPTH = 6;
const MAX_DISCOVERED_DIRS = 2000;
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
const CODEX_PLUGIN_DISCOVERY_TIMEOUT_MS = 5_000;
export type CodexSkillSource = {
name: string;
@@ -19,12 +25,23 @@ export type CodexSkillSource = {
sourceLabel: string;
};
type CodexPluginSource = {
export type CodexPluginSource = {
name: string;
source: string;
manifestPath: string;
};
export type CodexInstalledPluginSource = {
id: string;
name: string;
displayName: string;
marketplaceName: typeof OPENAI_CURATED_MARKETPLACE;
marketplacePath?: string;
installed: boolean;
enabled: boolean;
accessible?: boolean;
};
type CodexArchiveSource = {
id: string;
path: string;
@@ -41,10 +58,16 @@ type CodexSource = {
configPath?: string;
hooksPath?: string;
skills: CodexSkillSource[];
nativePlugins: CodexInstalledPluginSource[];
pluginDiscoveryError?: string;
plugins: CodexPluginSource[];
archivePaths: CodexArchiveSource[];
};
type CodexMigrationAppServerRequest = (method: string, params?: unknown) => Promise<unknown>;
let appServerRequestForTests: CodexMigrationAppServerRequest | undefined;
function defaultCodexHome(): string {
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
}
@@ -118,7 +141,158 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
}
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
function displayNameForPlugin(plugin: v2.PluginSummary): string {
const displayName = plugin.interface?.displayName?.trim();
return displayName || plugin.name || plugin.id;
}
function pluginNameFromSummary(plugin: v2.PluginSummary): string {
const name = plugin.name.trim();
if (name) {
return name;
}
return plugin.id.replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), "");
}
function pluginAccessible(
plugin: v2.PluginSummary,
apps: readonly v2.AppInfo[],
): boolean | undefined {
const displayName = displayNameForPlugin(plugin).toLowerCase();
const pluginName = pluginNameFromSummary(plugin).toLowerCase();
const matchingApps = apps.filter((app) => {
const pluginNames = new Set(
app.pluginDisplayNames
.map((name) => name.trim().toLowerCase())
.filter((name) => name.length > 0),
);
return pluginNames.has(displayName) || pluginNames.has(pluginName);
});
if (matchingApps.length === 0) {
return undefined;
}
return matchingApps.every((app) => app.isAccessible && app.isEnabled);
}
function readCodexPluginConfigFromOpenClawConfig(config: unknown): unknown {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return undefined;
}
const plugins = (config as { plugins?: unknown }).plugins;
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
return undefined;
}
const entries = (plugins as { entries?: unknown }).entries;
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return undefined;
}
const codex = (entries as Record<string, unknown>).codex;
if (!codex || typeof codex !== "object" || Array.isArray(codex)) {
return undefined;
}
return (codex as { config?: unknown }).config;
}
async function defaultAppServerRequest(params: {
codexHome: string;
pluginConfig?: unknown;
config?: OpenClawConfig;
}): Promise<CodexMigrationAppServerRequest> {
const runtimeOptions = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const startOptions = {
...runtimeOptions.start,
env: {
...runtimeOptions.start.env,
CODEX_HOME: params.codexHome,
},
};
return async (method: string, requestParams?: unknown): Promise<unknown> =>
await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: CODEX_PLUGIN_DISCOVERY_TIMEOUT_MS,
startOptions,
config: params.config,
});
}
async function listAllApps(request: CodexMigrationAppServerRequest): Promise<v2.AppInfo[]> {
const apps: v2.AppInfo[] = [];
let cursor: string | null | undefined;
do {
const params = {
...(cursor !== undefined ? { cursor } : {}),
limit: 100,
forceRefetch: true,
} satisfies v2.AppsListParams;
const response = (await request("app/list", params)) as v2.AppsListResponse;
apps.push(...response.data);
cursor = response.nextCursor;
} while (cursor);
return apps;
}
async function discoverInstalledOpenAiCuratedPlugins(params: {
codexHome: string;
pluginConfig?: unknown;
config?: OpenClawConfig;
appServerRequest?: CodexMigrationAppServerRequest;
}): Promise<{ plugins: CodexInstalledPluginSource[]; error?: string }> {
try {
const request =
params.appServerRequest ??
appServerRequestForTests ??
(await defaultAppServerRequest({
codexHome: params.codexHome,
pluginConfig: params.pluginConfig,
config: params.config,
}));
const [listed, apps] = await Promise.all([
request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams) as Promise<v2.PluginListResponse>,
listAllApps(request),
]);
const marketplace = listed.marketplaces.find(
(entry) => entry.name === OPENAI_CURATED_MARKETPLACE,
);
if (!marketplace) {
return { plugins: [] };
}
const plugins = marketplace.plugins
.filter((plugin) => plugin.installed)
.map((plugin): CodexInstalledPluginSource => {
const accessible = pluginAccessible(plugin, apps);
const source: CodexInstalledPluginSource = {
id: plugin.id,
name: pluginNameFromSummary(plugin),
displayName: displayNameForPlugin(plugin),
marketplaceName: OPENAI_CURATED_MARKETPLACE,
installed: plugin.installed,
enabled: plugin.enabled,
};
if (marketplace.path) {
source.marketplacePath = marketplace.path;
}
if (accessible !== undefined) {
source.accessible = accessible;
}
return source;
})
.toSorted((a, b) => a.name.localeCompare(b.name));
return { plugins };
} catch (err) {
return { plugins: [], error: err instanceof Error ? err.message : String(err) };
}
}
export async function discoverCodexSource(
input?: string,
options: {
config?: OpenClawConfig;
appServerRequest?: CodexMigrationAppServerRequest;
} = {},
): Promise<CodexSource> {
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
const codexSkillsDir = path.join(codexHome, "skills");
const agentsSkillsDir = personalAgentsSkillsDir();
@@ -133,6 +307,12 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
root: agentsSkillsDir,
sourceLabel: "personal AgentSkill",
});
const appServerPlugins = await discoverInstalledOpenAiCuratedPlugins({
codexHome,
pluginConfig: readCodexPluginConfigFromOpenClawConfig(options.config),
config: options.config,
appServerRequest: options.appServerRequest,
});
const plugins = await discoverPluginDirs(codexHome);
const archivePaths: CodexArchiveSource[] = [];
if (await exists(configPath)) {
@@ -155,7 +335,9 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
const skills = [...codexSkills, ...personalAgentSkills].toSorted((a, b) =>
a.source.localeCompare(b.source),
);
const high = Boolean(codexSkills.length || plugins.length || archivePaths.length);
const high = Boolean(
codexSkills.length || appServerPlugins.plugins.length || plugins.length || archivePaths.length,
);
const medium = personalAgentSkills.length > 0;
return {
root: codexHome,
@@ -166,6 +348,8 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
...((await exists(configPath)) ? { configPath } : {}),
...((await exists(hooksPath)) ? { hooksPath } : {}),
skills,
nativePlugins: appServerPlugins.plugins,
...(appServerPlugins.error ? { pluginDiscoveryError: appServerPlugins.error } : {}),
plugins,
archivePaths,
};
@@ -174,3 +358,9 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
export function hasCodexSource(source: CodexSource): boolean {
return source.confidence !== "low";
}
export const __testing = {
setAppServerRequestForTests(request: CodexMigrationAppServerRequest | undefined): void {
appServerRequestForTests = request;
},
};

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
describe("plugin tool contracts", () => {
it("allows explicit prefix wildcard contracts for config-derived tool names", () => {
expect(
findUndeclaredPluginToolNames({
declaredNames: ["derived_tool_*"],
toolNames: ["derived_tool_calendar", "derived_tool_slack"],
}),
).toEqual([]);
expect(
findUndeclaredPluginToolNames({
declaredNames: ["derived_tool_*"],
toolNames: ["memory_search"],
}),
).toEqual(["memory_search"]);
});
});

View File

@@ -22,5 +22,11 @@ export function findUndeclaredPluginToolNames(params: {
toolNames: readonly string[];
}): string[] {
const declared = new Set(normalizePluginToolNames(params.declaredNames));
return normalizePluginToolNames(params.toolNames).filter((name) => !declared.has(name));
const wildcardPrefixes = [...declared]
.filter((name) => name.endsWith("*"))
.map((name) => name.slice(0, -1))
.filter(Boolean);
return normalizePluginToolNames(params.toolNames).filter(
(name) => !declared.has(name) && !wildcardPrefixes.some((prefix) => name.startsWith(prefix)),
);
}

View File

@@ -522,6 +522,53 @@ describe("resolvePluginTools optional tools", () => {
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
});
it("loads a concrete plugin tool selected through a wildcard manifest contract", () => {
const baseContext = createContext();
const config = {
...baseContext.config,
plugins: {
...baseContext.config.plugins,
allow: [...baseContext.config.plugins.allow, "wildcard-demo"],
entries: {
"wildcard-demo": { enabled: true },
},
},
};
const registry = createToolRegistry([
{
pluginId: "wildcard-demo",
optional: true,
source: "/tmp/wildcard-demo.js",
names: ["derived_tool_calendar"],
declaredNames: ["derived_tool_*"],
factory: () => makeTool("derived_tool_calendar"),
},
]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "wildcard-demo",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["derived_tool_*"],
},
},
});
const tools = resolvePluginTools({
context: { ...baseContext, config } as never,
toolAllowlist: ["derived_tool_calendar"],
allowGatewaySubagentBinding: true,
});
expectResolvedToolNames(tools, ["derived_tool_calendar"]);
expectLoaderSelectedOnlyPluginIds(["wildcard-demo"]);
});
it("auto-loads cold registry for path-based config-origin plugins without pre-warming (#76598)", () => {
const context = {
...createContext(),

View File

@@ -179,7 +179,9 @@ function isOptionalToolEntryPotentiallyAllowed(params: {
if (params.names.length === 0) {
return true;
}
return params.names.some((name) => params.allowlist.has(normalizeToolName(name)));
return params.names.some((name) =>
allowlistMatchesToolNameOrContract(params.allowlist, normalizeToolName(name)),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -348,6 +350,25 @@ function pluginToolNamesMatchAllowlist(params: {
return isOptionalToolEntryPotentiallyAllowed(params);
}
function toolContractPrefix(name: string): string | undefined {
if (!name.endsWith("*")) {
return undefined;
}
const prefix = normalizeToolName(name.slice(0, -1));
return prefix || undefined;
}
function allowlistMatchesToolNameOrContract(allowlist: Set<string>, name: string): boolean {
const normalized = normalizeToolName(name);
if (allowlist.has(normalized)) {
return true;
}
const wildcardPrefix = toolContractPrefix(normalized);
return wildcardPrefix
? [...allowlist].some((allowed) => normalizeToolName(allowed).startsWith(wildcardPrefix))
: false;
}
function listManifestToolNamesForAllowlist(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
@@ -365,13 +386,13 @@ function listManifestToolNamesForAllowlist(params: {
return [...params.toolNames];
}
const matchedToolNames = params.toolNames.filter((name) =>
params.allowlist.has(normalizeToolName(name)),
allowlistMatchesToolNameOrContract(params.allowlist, name),
);
if (!allowlistIncludesDefaultPluginTools(params.allowlist)) {
return matchedToolNames;
}
const defaultToolNames = params.toolNames.filter(
(name) => !isManifestToolOptional(params.plugin, name),
(name) => !toolContractPrefix(name) && !isManifestToolOptional(params.plugin, name),
);
return [...new Set([...defaultToolNames, ...matchedToolNames])];
}