feat(codex): migrate native plugins

This commit is contained in:
kevinlin-openai
2026-05-06 04:24:37 -07:00
parent e045e45210
commit 6beeff01c9
8 changed files with 1385 additions and 32 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

@@ -77,9 +77,33 @@ Verification:
Goal: preserve useful Codex plugin migration by activating selected plugins in
Codex app-server, not in OpenClaw's tool registry.
This milestone is intentionally deferred to the stacked migration PR. The first
PR only lands the native invocation and configuration substrate that migration
uses.
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
@@ -111,12 +135,15 @@ that OpenClaw forwards to `turn/start`.
### 2.5 Migrate selected plugins through app-server
Deferred to the stacked migration PR. That PR adds Codex source discovery,
planning, apply-time install/reload behavior, and migration CLI/docs updates.
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 1 docs, tests, and proof
### 2.6 PR stack docs, tests, and proof
This PR owns:
PR 1 owns:
- Harness docs for native mention usage and the removal of bridge tool
semantics.
@@ -126,3 +153,11 @@ This PR owns:
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

@@ -277,10 +277,19 @@ Use native Codex mention syntax in a Codex-mode turn:
[@Google Calendar](plugin://google-calendar) Find a free slot tomorrow afternoon.
```
Codex plugin migration is covered by the stacked migration PR. The runtime
contract here is the same before and after migration: the Codex app-server owns
plugin activation, app authorization, permission prompts, and transcript
semantics.
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

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