mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 23:30: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
281 lines
8.6 KiB
TypeScript
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,
|
|
}));
|
|
}
|