mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 19:50:45 +00:00
* 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
344 lines
11 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|