Files
openclaw/extensions/codex/src/migration/apply.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

281 lines
8.6 KiB
TypeScript

import path from "node:path";
import {
applyMigrationManualItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
MIGRATION_REASON_TARGET_EXISTS,
summarizeMigrationItems,
writeMigrationConfigPath,
} from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
withCachedMigrationConfigRuntime,
writeMigrationReport,
} from "openclaw/plugin-sdk/migration-runtime";
import type {
MigrationApplyResult,
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
type ResolvedCodexPluginPolicy,
} from "../app-server/config.js";
import {
ensureCodexPluginActivation,
type CodexPluginActivationResult,
} from "../app-server/plugin-activation.js";
import type { v2 } from "../app-server/protocol.js";
import { requestCodexAppServerJson } from "../app-server/request.js";
import { buildCodexMigrationPlan } from "./plan.js";
import {
buildCodexPluginsConfigValue,
CODEX_PLUGIN_CONFIG_ITEM_ID,
CODEX_PLUGIN_CONFIG_PATH,
hasCodexPluginConfigConflict,
readCodexPluginMigrationConfigEntry,
type CodexPluginMigrationConfigEntry,
} from "./plan.js";
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
class CodexPluginConfigConflictError extends Error {
constructor(readonly reason: string) {
super(reason);
this.name = "CodexPluginConfigConflictError";
}
}
export async function applyCodexMigrationPlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
runtime?: MigrationProviderContext["runtime"];
}): Promise<MigrationApplyResult> {
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
const items: MigrationItem[] = [];
const runtime = withCachedMigrationConfigRuntime(
params.ctx.runtime ?? params.runtime,
params.ctx.config,
);
const applyCtx = { ...params.ctx, runtime };
for (const item of plan.items) {
if (item.status !== "planned") {
items.push(item);
continue;
}
if (item.id === CODEX_PLUGIN_CONFIG_ITEM_ID) {
items.push(await applyCodexPluginConfigItem(applyCtx, item, items));
} else if (item.kind === "plugin" && item.action === "install") {
items.push(await applyCodexPluginInstallItem(applyCtx, item));
} else if (item.kind === "manual") {
items.push(applyMigrationManualItem(item));
} else if (item.action === "archive") {
items.push(await archiveMigrationItem(item, reportDir));
} else {
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
}
}
const result: MigrationApplyResult = {
...plan,
items,
summary: summarizeMigrationItems(items),
backupPath: params.ctx.backupPath,
reportDir,
};
await writeMigrationReport(result, { title: "Codex Migration Report" });
return result;
}
async function applyCodexPluginInstallItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
const policy = readCodexPluginPolicy(item);
if (!policy) {
return {
...markMigrationItemError(item, "invalid Codex plugin migration item"),
details: { ...item.details, code: "invalid_plugin_item" },
};
}
try {
const result = await ensureCodexPluginActivation({
identity: policy,
installEvenIfActive: true,
request: async (method, requestParams) =>
await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: 60_000,
config: ctx.config,
}),
});
defaultCodexAppInventoryCache.clear();
const baseDetails = {
...item.details,
code: result.reason,
activationReason: result.reason,
...codexPluginActivationReportState(result),
installAttempted: result.installAttempted,
diagnostics: result.diagnostics.map((diagnostic) => diagnostic.message),
};
if (result.ok) {
return {
...item,
status: "migrated",
...(result.reason === "already_active" ? { reason: "already active" } : {}),
details: baseDetails,
};
}
if (result.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON) {
return {
...item,
status: "skipped",
reason: CODEX_PLUGIN_AUTH_REQUIRED_REASON,
details: {
...baseDetails,
appsNeedingAuth: sanitizeAppsNeedingAuth(result.installResponse?.appsNeedingAuth ?? []),
},
};
}
return {
...item,
status: "error",
reason: result.reason,
details: baseDetails,
};
} catch (error) {
return {
...item,
status: "error",
reason: error instanceof Error ? error.message : String(error),
details: {
...item.details,
code: "plugin_install_failed",
},
};
}
}
async function applyCodexPluginConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,
appliedItems: readonly MigrationItem[],
): Promise<MigrationItem> {
const entries = appliedItems
.map(readAppliedPluginConfigEntry)
.filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined);
if (entries.length === 0) {
return markMigrationItemSkipped(item, "no selected Codex plugins");
}
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
return markMigrationItemError(item, "config runtime unavailable");
}
const currentConfig = configApi.current() as MigrationProviderContext["config"];
const value = buildCodexPluginsConfigValue(entries, { config: currentConfig });
if (!ctx.overwrite && hasCodexPluginConfigConflict(currentConfig, value)) {
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
}
try {
await configApi.mutateConfigFile({
base: "runtime",
afterWrite: { mode: "auto" },
mutate(draft) {
if (!ctx.overwrite && hasCodexPluginConfigConflict(draft, value)) {
throw new CodexPluginConfigConflictError(MIGRATION_REASON_TARGET_EXISTS);
}
writeMigrationConfigPath(draft as Record<string, unknown>, CODEX_PLUGIN_CONFIG_PATH, value);
},
});
return {
...item,
status: "migrated",
details: {
...item.details,
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
};
} catch (error) {
if (error instanceof CodexPluginConfigConflictError) {
return markMigrationItemConflict(item, error.reason);
}
return markMigrationItemError(item, error instanceof Error ? error.message : String(error));
}
}
function readAppliedPluginConfigEntry(
item: MigrationItem,
): CodexPluginMigrationConfigEntry | undefined {
if (item.status === "migrated") {
return readCodexPluginMigrationConfigEntry(item, true);
}
if (
item.status === "skipped" &&
item.reason !== CODEX_PLUGIN_NOT_SELECTED_REASON &&
item.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON
) {
return readCodexPluginMigrationConfigEntry(item, false);
}
return undefined;
}
function readCodexPluginPolicy(item: MigrationItem): ResolvedCodexPluginPolicy | undefined {
const configKey = item.details?.configKey;
const marketplaceName = item.details?.marketplaceName;
const pluginName = item.details?.pluginName;
if (
typeof configKey !== "string" ||
marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME ||
typeof pluginName !== "string"
) {
return undefined;
}
return {
configKey,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName,
enabled: true,
allowDestructiveActions: false,
};
}
function codexPluginActivationReportState(result: CodexPluginActivationResult): {
installed?: boolean;
enabled?: boolean;
} {
switch (result.reason) {
case "already_active":
case "installed":
return { installed: true, enabled: true };
case "auth_required":
return { installed: true, enabled: false };
case "disabled":
case "marketplace_missing":
case "plugin_missing":
return { installed: false, enabled: false };
case "refresh_failed":
return { installed: true, enabled: false };
}
const exhaustiveReason: never = result.reason;
return exhaustiveReason;
}
function sanitizeAppsNeedingAuth(apps: readonly v2.AppSummary[]): Array<{
id: string;
name: string;
needsAuth: boolean;
}> {
return apps.map((app) => ({
id: app.id,
name: app.name,
needsAuth: app.needsAuth,
}));
}