Files
openclaw/extensions/codex/src/migration/plan.ts
Kevin Lin a1ac559ed7 feat(codex): enable native plugin app support (#78733)
* feat(codex): add native plugin config schema

* feat(codex): add native plugin inventory activation

* feat(codex): configure native plugin apps for threads

* feat(codex): enforce plugin elicitation policy

* feat(codex): migrate native plugins

* docs(codex): document native plugin support

* fix(codex): harden plugin migration refresh

* fix(codex): satisfy plugin activation lint

* fix: stabilize codex plugin app config

* fix: address codex plugin review feedback

* fix: key codex plugin app cache by websocket credentials

* fix: keep codex plugin app fingerprints stable

* fix: refresh codex plugin cache test fixtures

* fix: refresh plugin app readiness after activation

* fix: support remote codex plugin activation

* fix: recover plugin app bindings after cache refresh

* fix: force codex app refresh after plugin activation

* fix: recover partial codex plugin app bindings

* fix: sync codex plugin selection config

* fix: keep codex plugin activation fail closed

* fix: align codex plugin protocol types with main

* fix: refresh partial codex plugin app bindings

* fix: key codex app cache by env api key

* fix: skip failed codex plugin migration config

* test: update codex prompt snapshots

* fix: fail closed on missing codex app inventory entries

* fix(codex): enforce native plugin policy gates

* fix(codex): normalize native plugin policy types

* fix(codex): fail closed on plugin refresh errors

* fix(codex): use native plugin destructive policy

* fix(codex): key plugin cache by api-key profiles

* fix(codex): drop unshipped plugin fingerprint compat

* fix(codex): let native app policy gate plugin tools

* fix(codex): allow open-world plugin app tools

* fix(codex): revalidate native plugin app bindings

* fix(codex): preserve plugin binding on recheck failure

* docs(codex): clarify plugin harness scope

* fix(codex): return activation report state exhaustively

* test(codex): refresh prompt snapshots after rebase

* fix(codex): match namespaced plugin ids
2026-05-07 17:20:28 -07:00

344 lines
11 KiB
TypeScript

import path from "node:path";
import {
createMigrationItem,
createMigrationManualItem,
hasMigrationConfigPatchConflict,
MIGRATION_REASON_TARGET_EXISTS,
readMigrationConfigPath,
summarizeMigrationItems,
} from "openclaw/plugin-sdk/migration";
import type {
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js";
import { exists, sanitizeName } from "./helpers.js";
import {
discoverCodexSource,
hasCodexSource,
type CodexPluginSource,
type CodexSkillSource,
} from "./source.js";
import { resolveCodexMigrationTargets } from "./targets.js";
export const CODEX_PLUGIN_CONFIG_ITEM_ID = "config:codex-plugins";
export const CODEX_PLUGIN_CONFIG_PATH = ["plugins", "entries", "codex"] as const;
const CODEX_PLUGIN_ENABLED_PATH = ["plugins", "entries", "codex", "enabled"] as const;
const CODEX_PLUGIN_NATIVE_CONFIG_PATH = [
"plugins",
"entries",
"codex",
"config",
"codexPlugins",
] as const;
export type CodexPluginMigrationConfigEntry = {
configKey: string;
pluginName: string;
enabled: boolean;
};
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
const base = sanitizeName(skill.name) || "codex-skill";
if ((counts.get(base) ?? 0) <= 1) {
return base;
}
const parent = sanitizeName(path.basename(path.dirname(skill.source)));
return sanitizeName(["codex", parent, base].filter(Boolean).join("-")) || base;
}
async function buildSkillItems(params: {
skills: CodexSkillSource[];
workspaceDir: string;
overwrite?: boolean;
}): Promise<MigrationItem[]> {
const baseCounts = new Map<string, number>();
for (const skill of params.skills) {
const base = sanitizeName(skill.name) || "codex-skill";
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
}
const resolvedCounts = new Map<string, number>();
const planned = params.skills.map((skill) => {
const name = uniqueSkillName(skill, baseCounts);
resolvedCounts.set(name, (resolvedCounts.get(name) ?? 0) + 1);
return { skill, name, target: path.join(params.workspaceDir, "skills", name) };
});
const items: MigrationItem[] = [];
for (const item of planned) {
const collides = (resolvedCounts.get(item.name) ?? 0) > 1;
const targetExists = await exists(item.target);
items.push(
createMigrationItem({
id: `skill:${item.name}`,
kind: "skill",
action: "copy",
source: item.skill.source,
target: item.target,
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
reason: collides
? `multiple Codex skills normalize to "${item.name}"`
: targetExists && !params.overwrite
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
message: `Copy ${item.skill.sourceLabel} into this OpenClaw agent workspace.`,
details: {
skillName: item.name,
sourceLabel: item.skill.sourceLabel,
},
}),
);
}
return items;
}
function uniquePluginConfigKey(
plugin: CodexPluginSource,
counts: Map<string, number>,
usedCounts: Map<string, number>,
): string {
const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin";
const total = counts.get(base) ?? 0;
if (total <= 1) {
return base;
}
const next = (usedCounts.get(base) ?? 0) + 1;
usedCounts.set(base, next);
return sanitizeName(`${base}-${next}`) || base;
}
function buildPluginItems(plugins: readonly CodexPluginSource[]): MigrationItem[] {
const baseCounts = new Map<string, number>();
for (const plugin of plugins.filter((entry) => entry.migratable)) {
const base = sanitizeName(plugin.pluginName ?? plugin.name) || "codex-plugin";
baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
}
const usedCounts = new Map<string, number>();
let manualIndex = 0;
const items: MigrationItem[] = [];
for (const plugin of plugins) {
if (
plugin.migratable &&
plugin.marketplaceName === CODEX_PLUGINS_MARKETPLACE_NAME &&
plugin.pluginName
) {
const configKey = uniquePluginConfigKey(plugin, baseCounts, usedCounts);
items.push(
createMigrationItem({
id: `plugin:${configKey}`,
kind: "plugin",
action: "install",
source: plugin.source,
target: `plugins.entries.codex.config.codexPlugins.plugins.${configKey}`,
message: `Install Codex plugin "${plugin.pluginName}" in the OpenClaw-managed Codex app-server runtime.`,
details: {
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: plugin.pluginName,
sourceInstalled: plugin.installed === true,
sourceEnabled: plugin.enabled === true,
},
}),
);
continue;
}
manualIndex += 1;
items.push(
createMigrationManualItem({
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${manualIndex}`,
source: plugin.source,
message:
plugin.message ??
`Codex native plugin "${plugin.name}" was found but not activated automatically.`,
recommendation:
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
}),
);
}
return items;
}
export function readCodexPluginMigrationConfigEntry(
item: MigrationItem,
enabled: boolean,
): CodexPluginMigrationConfigEntry | undefined {
const configKey = item.details?.configKey;
const marketplaceName = item.details?.marketplaceName;
const pluginName = item.details?.pluginName;
if (
item.kind !== "plugin" ||
item.action !== "install" ||
typeof configKey !== "string" ||
marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof pluginName !== "string"
) {
return undefined;
}
return { configKey, pluginName, enabled };
}
function readExistingAllowDestructiveActions(
config: MigrationProviderContext["config"],
): boolean | undefined {
const value = readMigrationConfigPath(config as Record<string, unknown>, [
...CODEX_PLUGIN_NATIVE_CONFIG_PATH,
"allow_destructive_actions",
]);
return typeof value === "boolean" ? value : undefined;
}
export function buildCodexPluginsConfigValue(
entries: readonly CodexPluginMigrationConfigEntry[],
params: { config?: MigrationProviderContext["config"] } = {},
): Record<string, unknown> {
const plugins = Object.fromEntries(
entries
.toSorted((a, b) => a.configKey.localeCompare(b.configKey))
.map((entry) => [
entry.configKey,
{
enabled: entry.enabled,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: entry.pluginName,
},
]),
);
return {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions:
params.config === undefined
? false
: (readExistingAllowDestructiveActions(params.config) ?? false),
plugins,
},
},
};
}
export function hasCodexPluginConfigConflict(
config: MigrationProviderContext["config"],
value: Record<string, unknown>,
): boolean {
const enabled = readMigrationConfigPath(
config as Record<string, unknown>,
CODEX_PLUGIN_ENABLED_PATH,
);
if (enabled !== undefined && enabled !== true) {
return true;
}
const nativeConfig = (value.config as Record<string, unknown> | undefined)?.codexPlugins;
return hasMigrationConfigPatchConflict(config, CODEX_PLUGIN_NATIVE_CONFIG_PATH, nativeConfig);
}
function buildPluginConfigItem(
ctx: MigrationProviderContext,
pluginItems: readonly MigrationItem[],
): MigrationItem | undefined {
const entries = pluginItems
.map((item) => readCodexPluginMigrationConfigEntry(item, true))
.filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined);
if (entries.length === 0) {
return undefined;
}
const value = buildCodexPluginsConfigValue(entries, { config: ctx.config });
const conflict = !ctx.overwrite && hasCodexPluginConfigConflict(ctx.config, value);
return createMigrationItem({
id: CODEX_PLUGIN_CONFIG_ITEM_ID,
kind: "config",
action: "merge",
target: "plugins.entries.codex.config.codexPlugins",
status: conflict ? "conflict" : "planned",
reason: conflict ? MIGRATION_REASON_TARGET_EXISTS : undefined,
message:
"Enable OpenClaw's Codex plugin integration and record migrated source-installed curated plugins.",
details: {
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
});
}
export async function buildCodexMigrationPlan(
ctx: MigrationProviderContext,
): Promise<MigrationPlan> {
const source = await discoverCodexSource(ctx.source);
if (!hasCodexSource(source)) {
throw new Error(
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
);
}
const targets = resolveCodexMigrationTargets(ctx);
const items: MigrationItem[] = [];
items.push(
...(await buildSkillItems({
skills: source.skills,
workspaceDir: targets.workspaceDir,
overwrite: ctx.overwrite,
})),
);
const pluginItems = buildPluginItems(source.plugins);
items.push(...pluginItems);
const pluginConfigItem = buildPluginConfigItem(ctx, pluginItems);
if (pluginConfigItem) {
items.push(pluginConfigItem);
}
for (const archivePath of source.archivePaths) {
items.push(
createMigrationItem({
id: archivePath.id,
kind: "archive",
action: "archive",
source: archivePath.path,
message:
archivePath.message ??
"Archived in the migration report for manual review; not imported into live config.",
details: { archiveRelativePath: archivePath.relativePath },
}),
);
}
const warnings = [
...(items.some((item) => item.status === "conflict")
? [
"Conflicts were found. Re-run with --overwrite to replace conflicting skill targets after item-level backups.",
]
: []),
...(source.plugins.length > 0
? [
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
]
: []),
...(source.pluginDiscoveryError
? [
`Codex app-server plugin inventory discovery failed: ${source.pluginDiscoveryError}. Cached plugin bundles, if any, are advisory only.`,
]
: []),
...(source.archivePaths.length > 0
? [
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
]
: []),
];
return {
providerId: "codex",
source: source.root,
target: targets.workspaceDir,
summary: summarizeMigrationItems(items),
items,
warnings,
nextSteps: [
"Run openclaw doctor after applying the migration.",
"Review skipped or auth-required Codex plugin/config/hook items before exposing them in OpenClaw sessions.",
],
metadata: {
agentDir: targets.agentDir,
codexHome: source.codexHome,
codexSkillsDir: source.codexSkillsDir,
personalAgentsSkillsDir: source.personalAgentsSkillsDir,
},
};
}