fix: carry codex migration config through onboarding

This commit is contained in:
Kevin Lin
2026-05-13 20:50:07 -07:00
committed by GitHub
parent 3da9027770
commit 78644bc6de
14 changed files with 485 additions and 35 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
- ACP: preserve redacted numeric JSON-RPC `RequestError` details in runtime failure text, so backend diagnostics are visible instead of only `Internal error`. Fixes #81126. (#81188) Thanks @vyctorbrzezowski.
- Agents: cache unchanged PI model discovery stores and model lookups, reducing repeated model-resolution startup latency under large model configs. Fixes #78851.
- Onboarding: carry returned Codex plugin migration config through the OpenAI model wizard so accepted plugin migrations are saved with the final config write.
- Security/Windows ACL audit: classify Anonymous Logon, Guests, Interactive, Local, and Network SIDs as world-equivalent principals so broadly writable paths stay critical instead of being downgraded to group-writable. Fixes #74350. (#74383) Thanks @dwc1997.
- Media-understanding: retry transient remote attachment fetch failures before audio or vision processing, so Discord voice notes are not lost after one network/CDN blip. Fixes #74316. Thanks @vyctorbrzezowski and @gabrielexito-stack.
- Control UI: order timestamped live stream and tool items before untimestamped history fallbacks, keeping chat history in visible time order. Fixes #80759. (#81016) Thanks @akrimm702.

View File

@@ -41,6 +41,13 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
`plugin-test-runtime` is still an active focused test helper subpath.
### Reserved bundled plugin helper subpaths
These subpaths are plugin-owned compatibility surfaces reserved for their owning
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
by package contract guardrails.
### Deprecated unused public subpaths
These public subpaths existed for at least one month and currently have no

View File

@@ -52,6 +52,7 @@ import { resolveCodexMigrationTargets } from "./targets.js";
const CODEX_PLUGIN_AUTH_REQUIRED_REASON = "auth_required";
const CODEX_PLUGIN_NOT_SELECTED_REASON = "not selected for migration";
const CODEX_CONFIG_PATCH_MODE_RETURN = "return";
class CodexPluginConfigConflictError extends Error {
constructor(readonly reason: string) {
@@ -60,6 +61,10 @@ class CodexPluginConfigConflictError extends Error {
}
}
function shouldReturnCodexPluginConfigPatch(ctx: MigrationProviderContext): boolean {
return ctx.providerOptions?.configPatchMode === CODEX_CONFIG_PATCH_MODE_RETURN;
}
export async function applyCodexMigrationPlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
@@ -220,15 +225,33 @@ async function applyCodexPluginConfigItem(
if (entries.length === 0) {
return markMigrationItemSkipped(item, "no selected Codex plugins");
}
const returnPatch = shouldReturnCodexPluginConfigPatch(ctx);
const configApi = ctx.runtime?.config;
if (!configApi?.current || !configApi.mutateConfigFile) {
const currentConfig = returnPatch
? ctx.config
: (configApi?.current?.() as MigrationProviderContext["config"] | undefined);
if (!currentConfig) {
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);
}
const migratedItem: MigrationItem = {
...item,
status: "migrated",
details: {
...item.details,
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
};
if (returnPatch) {
return migratedItem;
}
if (!configApi?.mutateConfigFile) {
return markMigrationItemError(item, "config runtime unavailable");
}
try {
await configApi.mutateConfigFile({
base: "runtime",
@@ -240,15 +263,7 @@ async function applyCodexPluginConfigItem(
writeMigrationConfigPath(draft as Record<string, unknown>, CODEX_PLUGIN_CONFIG_PATH, value);
},
});
return {
...item,
status: "migrated",
details: {
...item.details,
path: [...CODEX_PLUGIN_CONFIG_PATH],
value,
},
};
return migratedItem;
} catch (error) {
if (error instanceof CodexPluginConfigConflictError) {
return markMigrationItemConflict(item, error.reason);

View File

@@ -41,6 +41,7 @@ function makeContext(params: {
workspaceDir: string;
overwrite?: boolean;
verifyPluginApps?: boolean;
providerOptions?: MigrationProviderContext["providerOptions"];
reportDir?: string;
config?: MigrationProviderContext["config"];
runtime?: MigrationProviderContext["runtime"];
@@ -59,7 +60,8 @@ function makeContext(params: {
source: params.source,
stateDir: params.stateDir,
overwrite: params.overwrite,
providerOptions: params.verifyPluginApps ? { verifyPluginApps: true } : undefined,
providerOptions:
params.providerOptions ?? (params.verifyPluginApps ? { verifyPluginApps: true } : undefined),
reportDir: params.reportDir,
logger,
};
@@ -210,9 +212,6 @@ describe("buildCodexMigrationProvider", () => {
status: "planned",
});
expect(plan.items.some((item) => item.id === "skill:system-skill")).toBe(false);
expect((plan.warnings ?? []).some((warning) => warning.includes("cached plugin bundles"))).toBe(
true,
);
});
it("plans source-installed curated plugins without installing during dry-run", async () => {
@@ -377,7 +376,6 @@ describe("buildCodexMigrationProvider", () => {
expect(plan.warnings).toEqual([
"Codex source-installed openai-curated plugins are planned for native activation; cached plugin bundles remain manual-review only.",
"Codex app-backed plugins were planned without source app accessibility verification. Re-run with --verify-plugin-apps to force a fresh source app/list check before planning native plugin activation.",
"Codex cached plugin bundles remain manual-review only.",
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
]);
expect(appServerRequest.mock.calls.filter(([arg]) => arg.method === "app/list")).toHaveLength(
@@ -434,7 +432,6 @@ describe("buildCodexMigrationProvider", () => {
},
]);
expect(plan.warnings).toEqual([
"Codex cached plugin bundles remain manual-review only.",
"Codex app-backed plugin migration requires the Codex app-server source account to be logged in with a ChatGPT subscription account. Log in to the Codex app with subscription auth; OpenClaw auth or API-key auth does not satisfy Codex app connector access.",
"Codex config and hook files are archive-only. They are preserved in the migration report, not loaded into OpenClaw automatically.",
]);
@@ -1079,6 +1076,93 @@ describe("buildCodexMigrationProvider", () => {
});
});
it("returns Codex plugin config patches without mutating config in return mode", async () => {
const fixture = await createCodexFixture();
const configState: MigrationProviderContext["config"] = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: { sandbox: "workspace-write" },
},
},
},
},
agents: { defaults: { workspace: fixture.workspaceDir } },
} as MigrationProviderContext["config"];
appServerRequest.mockImplementation(async ({ method }: { method: string }) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginRead("google-calendar");
}
if (method === "plugin/install") {
return { authPolicy: "ON_USE", appsNeedingAuth: [] } satisfies v2.PluginInstallResponse;
}
if (method === "skills/list") {
return { data: [] } satisfies v2.SkillsListResponse;
}
if (method === "hooks/list") {
return { data: [] } satisfies v2.HooksListResponse;
}
if (method === "config/mcpServer/reload") {
return {};
}
if (method === "app/list") {
return appsList([]);
}
throw new Error(`unexpected request ${method}`);
});
const mutateConfigFile = vi.fn(async () => {
throw new Error("mutateConfigFile should not be called in return mode");
});
const provider = buildCodexMigrationProvider({
runtime: {
config: {
current: () => configState,
mutateConfigFile,
},
} as unknown as MigrationProviderContext["runtime"],
});
const result = await provider.apply(
makeContext({
source: fixture.codexHome,
stateDir: fixture.stateDir,
workspaceDir: fixture.workspaceDir,
config: configState,
providerOptions: { configPatchMode: "return" },
}),
);
expect(mutateConfigFile).not.toHaveBeenCalled();
expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined();
const configItem = findItem(result.items, "config:codex-plugins");
expectRecordFields(configItem, { status: "migrated" });
const configDetails = configItem.details as Record<string, unknown>;
expectRecordFields(configDetails, {
path: ["plugins", "entries", "codex"],
});
expect(configDetails.value).toEqual({
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
"google-calendar": {
enabled: true,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
});
});
it("merges migrated plugin config with existing Codex plugins when entries do not conflict", async () => {
const fixture = await createCodexFixture();
const sourceKey = sourceAppCacheKey(fixture);

View File

@@ -397,6 +397,42 @@ describe("migrateApplyCommand", () => {
expect(mocks.provider.apply).toHaveBeenCalledTimes(1);
});
it("uses embedded config override and return patch mode for Codex planning and apply", async () => {
const configOverride = {
plugins: {
entries: {
codex: { enabled: true },
},
},
};
const planned = codexPluginPlan();
const applied: MigrationApplyResult = {
...planned,
summary: { ...planned.summary, planned: 0, migrated: planned.summary.planned },
items: planned.items.map((item) => ({ ...item, status: "migrated" as const })),
};
mocks.provider.plan.mockImplementation(async (ctx) => {
expect(ctx.config).toBe(configOverride);
expect(ctx.providerOptions).toEqual({ configPatchMode: "return" });
return planned;
});
mocks.provider.apply.mockImplementation(async (ctx) => {
expect(ctx.config).toBe(configOverride);
expect(ctx.providerOptions).toEqual({ configPatchMode: "return" });
return applied;
});
await migrateApplyCommand(runtime, {
provider: "codex",
yes: true,
configOverride,
configPatchMode: "return",
});
expect(mocks.provider.plan).toHaveBeenCalledTimes(1);
expect(mocks.provider.apply).toHaveBeenCalledTimes(1);
});
it("previews and prompts before interactive apply without --yes", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,

View File

@@ -363,7 +363,7 @@ export async function migrateApplyCommand(
`openclaw migrate apply requires --yes in non-interactive mode. Preview first with ${formatCliCommand("openclaw migrate plan --provider <provider>")}.`,
);
}
const provider = resolveMigrationProvider(providerId);
const provider = resolveMigrationProvider(providerId, opts.configOverride);
if (!opts.yes) {
const plan = await migratePlanCommand(runtime, {
...opts,

View File

@@ -68,6 +68,7 @@ export async function runMigrationApply(params: {
source: params.opts.source,
includeSecrets: params.opts.includeSecrets,
overwrite: params.opts.overwrite,
configOverride: params.opts.configOverride,
providerOptions: buildMigrationProviderOptions(params.opts),
runtime: params.runtime,
json: params.opts.json,
@@ -97,6 +98,7 @@ export async function runMigrationApply(params: {
source: params.opts.source,
includeSecrets: params.opts.includeSecrets,
overwrite: params.opts.overwrite,
configOverride: params.opts.configOverride,
providerOptions: buildMigrationProviderOptions(params.opts),
runtime: params.runtime,
backupPath,

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { getRuntimeConfig } from "../../config/config.js";
import { resolveStateDir } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { MigrationProviderContext } from "../../plugins/types.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -33,11 +34,12 @@ export function buildMigrationContext(params: {
overwrite?: boolean;
providerOptions?: Record<string, unknown>;
backupPath?: string;
configOverride?: OpenClawConfig;
runtime: RuntimeEnv;
reportDir?: string;
json?: boolean;
}): MigrationProviderContext {
const config = getRuntimeConfig();
const config = params.configOverride ?? getRuntimeConfig();
const stateDir = resolveStateDir();
return {
config,

View File

@@ -9,8 +9,10 @@ import type { RuntimeEnv } from "../../runtime.js";
import { buildMigrationContext } from "./context.js";
import type { MigrateCommonOptions } from "./types.js";
export function resolveMigrationProvider(providerId: string): MigrationProviderPlugin {
const config = getRuntimeConfig();
export function resolveMigrationProvider(
providerId: string,
config = getRuntimeConfig(),
): MigrationProviderPlugin {
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: config });
const provider = resolvePluginMigrationProvider({ providerId, cfg: config });
if (!provider) {
@@ -27,10 +29,14 @@ export function resolveMigrationProvider(providerId: string): MigrationProviderP
export function buildMigrationProviderOptions(
opts: MigrateCommonOptions,
): Record<string, unknown> | undefined {
const options: Record<string, unknown> = {};
if (opts.provider === "codex" && opts.verifyPluginApps === true) {
return { verifyPluginApps: true };
options.verifyPluginApps = true;
}
return undefined;
if (opts.provider === "codex" && opts.configPatchMode) {
options.configPatchMode = opts.configPatchMode;
}
return Object.keys(options).length > 0 ? options : undefined;
}
export async function createMigrationPlan(
@@ -40,11 +46,12 @@ export async function createMigrationPlan(
if (opts.verifyPluginApps && opts.provider !== "codex") {
throw new Error("--verify-plugin-apps is only supported for Codex migrations.");
}
const provider = resolveMigrationProvider(opts.provider);
const provider = resolveMigrationProvider(opts.provider, opts.configOverride);
const ctx = buildMigrationContext({
source: opts.source,
includeSecrets: opts.includeSecrets,
overwrite: opts.overwrite,
configOverride: opts.configOverride,
providerOptions: buildMigrationProviderOptions(opts),
runtime,
json: opts.json,

View File

@@ -1,5 +1,8 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { MigrationPlan } from "../../plugins/types.js";
export type MigrationConfigPatchMode = "return";
export type MigrateCommonOptions = {
provider?: string;
source?: string;
@@ -14,6 +17,12 @@ export type MigrateCommonOptions = {
// already secured user consent and do not want to re-render the plan.
// The interactive selection picker and apply confirmation still run.
suppressPlanLog?: boolean;
// Internal embedded migration source of truth. Standalone CLI callers should
// omit this so migration uses the current runtime config from disk.
configOverride?: OpenClawConfig;
// Internal embedded mode for config patch items. Default CLI behavior persists
// patches when this is omitted; onboarding can request returned patch details.
configPatchMode?: MigrationConfigPatchMode;
};
export type MigrateApplyOptions = MigrateCommonOptions & {

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createNonExitingRuntime } from "../runtime.js";
import type { ProviderPlugin } from "./types.js";
const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => vi.fn());
vi.mock("../commands/codex-runtime-plugin-install.js", () => ({
CODEX_RUNTIME_PLUGIN_ID: "codex",
ensureCodexRuntimePluginForModelSelection,
}));
const offerPostInstallMigrations = vi.hoisted(() => vi.fn());
vi.mock("../wizard/setup.post-install-migration.js", () => ({
offerPostInstallMigrations,
}));
const { __testing, applyAuthChoicePluginProvider } = await import("./provider-auth-choice.js");
function buildProvider(): ProviderPlugin {
return {
id: "openai",
label: "OpenAI",
auth: [
{
id: "api-key",
label: "API key",
kind: "api_key",
run: vi.fn(async () => ({
profiles: [],
notes: [],
defaultModel: "gpt-5.5",
})),
},
],
};
}
describe("applyAuthChoicePluginProvider", () => {
beforeEach(() => {
__testing.resetDepsForTest();
ensureCodexRuntimePluginForModelSelection.mockReset();
offerPostInstallMigrations.mockReset();
});
it("returns post-install Codex migration config when setting an OpenAI default model", async () => {
const provider = buildProvider();
const runProviderModelSelectedHook = vi.fn(async () => undefined);
__testing.setDepsForTest({
loadPluginProviderRuntime: async () =>
({
resolvePluginProviders: () => [provider],
runProviderModelSelectedHook,
}) as never,
});
ensureCodexRuntimePluginForModelSelection.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
installed: true,
cfg: {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
codex: { enabled: true },
},
},
},
}),
);
offerPostInstallMigrations.mockImplementation(
async ({ config }: { config: OpenClawConfig }) => ({
config: {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
codex: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
gmail: {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "gmail",
},
},
},
},
},
},
},
},
}),
);
const result = await applyAuthChoicePluginProvider(
{
authChoice: "openai-api-key",
config: {},
runtime: createNonExitingRuntime(),
prompter: createWizardPrompter(),
setDefaultModel: true,
},
{
authChoice: "openai-api-key",
pluginId: "openai",
providerId: "openai",
methodId: "api-key",
label: "OpenAI",
},
);
expect(runProviderModelSelectedHook).toHaveBeenCalledOnce();
expect(offerPostInstallMigrations).toHaveBeenCalledWith(
expect.objectContaining({
installedPluginIds: ["codex"],
}),
);
const resultConfig = result?.config;
expect(resultConfig?.agents?.defaults?.model).toEqual({ primary: "gpt-5.5" });
const codexConfig = resultConfig?.plugins?.entries?.codex?.config as
| { codexPlugins?: { plugins?: unknown } }
| undefined;
expect(codexConfig?.codexPlugins?.plugins).toEqual({
gmail: {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "gmail",
},
});
});
});

View File

@@ -172,12 +172,13 @@ async function applyDefaultModelFromAuthChoice(params: {
// migratable state to find.
const { offerPostInstallMigrations } =
await import("../wizard/setup.post-install-migration.js");
await offerPostInstallMigrations({
const migrationResult = await offerPostInstallMigrations({
config: nextConfig,
runtime: params.runtime,
prompter: params.prompter,
installedPluginIds: [CODEX_RUNTIME_PLUGIN_ID],
});
nextConfig = migrationResult.config;
}
}
await noteDefaultModelResult({

View File

@@ -74,12 +74,13 @@ function setTTY(isTTY: boolean): void {
}
function buildBaseArgs(overrides: {
config?: OpenClawConfig;
prompter?: WizardPrompter;
installedPluginIds?: readonly string[];
nonInteractive?: boolean;
}) {
return {
config: {} as OpenClawConfig,
config: overrides.config ?? ({} as OpenClawConfig),
runtime: createNonExitingRuntime(),
prompter: overrides.prompter ?? createWizardPrompter(),
installedPluginIds: overrides.installedPluginIds ?? ["codex"],
@@ -109,9 +110,13 @@ describe("offerPostInstallMigrations", () => {
});
it("returns early when no plugins were installed in this onboarding step", async () => {
await offerPostInstallMigrations(buildBaseArgs({ installedPluginIds: [] }));
const config = { plugins: { entries: { codex: { enabled: true } } } } as OpenClawConfig;
const result = await offerPostInstallMigrations(
buildBaseArgs({ config, installedPluginIds: [] }),
);
expect(resolvePluginMigrationProviders).not.toHaveBeenCalled();
expect(migrateDefaultCommand).not.toHaveBeenCalled();
expect(result.config).toBe(config);
});
it("skips providers not owned by any plugin in installedPluginIds", async () => {
@@ -165,14 +170,110 @@ describe("offerPostInstallMigrations", () => {
confirm: confirm as WizardPrompter["confirm"],
});
await offerPostInstallMigrations(buildBaseArgs({ prompter }));
const result = await offerPostInstallMigrations(buildBaseArgs({ prompter }));
expect(confirm).toHaveBeenCalledOnce();
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false }));
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
expect(migrateDefaultCommand).toHaveBeenCalledWith(expect.anything(), {
provider: "codex",
suppressPlanLog: true,
expect(migrateDefaultCommand).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
provider: "codex",
configPatchMode: "return",
suppressPlanLog: true,
}),
);
expect(result.config).toEqual({});
});
it("returns config patched from migrated config items without mutating the input config", async () => {
const provider = buildProvider();
setProviders([provider]);
setOwnership("codex", ["codex"]);
const inputConfig = {
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: { sandbox: "workspace-write" },
},
},
},
},
} as OpenClawConfig;
migrateDefaultCommand.mockResolvedValueOnce({
providerId: "codex",
source: "/home/user/.codex",
summary: {
total: 1,
planned: 0,
migrated: 1,
skipped: 0,
conflicts: 0,
errors: 0,
sensitive: 0,
},
items: [
{
id: "config:codex-plugins",
kind: "config",
action: "merge",
status: "migrated",
details: {
path: ["plugins", "entries", "codex"],
value: {
enabled: true,
config: {
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
gmail: {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "gmail",
},
},
},
},
},
},
},
],
} as never);
const prompter = createWizardPrompter({
confirm: vi.fn(async () => true) as WizardPrompter["confirm"],
});
const result = await offerPostInstallMigrations(
buildBaseArgs({ config: inputConfig, prompter }),
);
expect(migrateDefaultCommand).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
configOverride: inputConfig,
configPatchMode: "return",
}),
);
expect(result.config).not.toBe(inputConfig);
expect(result.config.plugins?.entries?.codex?.config).toEqual({
appServer: { sandbox: "workspace-write" },
codexPlugins: {
enabled: true,
allow_destructive_actions: true,
plugins: {
gmail: {
enabled: true,
marketplaceName: "openai-curated",
pluginName: "gmail",
},
},
},
});
expect(inputConfig.plugins?.entries?.codex?.config).toEqual({
appServer: { sandbox: "workspace-write" },
});
});
@@ -223,7 +324,9 @@ describe("offerPostInstallMigrations", () => {
confirm: vi.fn(async () => true) as WizardPrompter["confirm"],
});
await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toBeUndefined();
await expect(offerPostInstallMigrations(buildBaseArgs({ prompter }))).resolves.toEqual({
config: {},
});
expect(migrateDefaultCommand).toHaveBeenCalledOnce();
});

View File

@@ -1,6 +1,10 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
readMigrationConfigPatchDetails,
writeMigrationConfigPath,
} from "../plugin-sdk/migration.js";
import type { MigrationProviderPlugin } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
@@ -20,6 +24,10 @@ export type PostInstallMigrationOptions = {
nonInteractive?: boolean;
};
export type PostInstallMigrationResult = {
config: OpenClawConfig;
};
type ResolvedProviderCandidate = {
provider: MigrationProviderPlugin;
source?: string;
@@ -99,6 +107,39 @@ function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandid
runtime.log(`Detected ${describeCandidate(candidate)}. Preview migration with ${command}.`);
}
function applyMigrationConfigPatches(
config: OpenClawConfig,
result: { items?: readonly unknown[] } | undefined,
): OpenClawConfig {
const items = result?.items ?? [];
const patches = items
.filter((item): item is Parameters<typeof readMigrationConfigPatchDetails>[0] =>
Boolean(
item &&
typeof item === "object" &&
"kind" in item &&
item.kind === "config" &&
"action" in item &&
item.action === "merge" &&
"status" in item &&
item.status === "migrated",
),
)
.map(readMigrationConfigPatchDetails)
.filter(
(patch): patch is NonNullable<ReturnType<typeof readMigrationConfigPatchDetails>> =>
patch !== undefined,
);
if (patches.length === 0) {
return config;
}
const nextConfig = structuredClone(config);
for (const patch of patches) {
writeMigrationConfigPath(nextConfig as Record<string, unknown>, patch.path, patch.value);
}
return nextConfig;
}
/**
* Offer interactive migration for any migration provider owned by a plugin
* that was just installed during onboarding. In non-interactive mode this is
@@ -109,15 +150,16 @@ function logMigrationHint(runtime: RuntimeEnv, candidate: ResolvedProviderCandid
*/
export async function offerPostInstallMigrations(
params: PostInstallMigrationOptions,
): Promise<void> {
): Promise<PostInstallMigrationResult> {
const candidates = await resolveCandidates({
config: params.config,
runtime: params.runtime,
installedPluginIds: params.installedPluginIds,
});
if (candidates.length === 0) {
return;
return { config: params.config };
}
let nextConfig = params.config;
const prompter = params.prompter;
const interactive =
params.nonInteractive !== true && process.stdin.isTTY && prompter !== undefined;
@@ -148,10 +190,13 @@ export async function offerPostInstallMigrations(
}
try {
const { migrateDefaultCommand } = await import("../commands/migrate.js");
await migrateDefaultCommand(params.runtime, {
const result = await migrateDefaultCommand(params.runtime, {
provider: candidate.provider.id,
configOverride: nextConfig,
configPatchMode: "return",
suppressPlanLog: true,
});
nextConfig = applyMigrationConfigPatches(nextConfig, result);
} catch (error) {
params.runtime.log(
`${candidate.provider.label} migration failed: ${formatErrorMessage(error)}. ` +
@@ -159,4 +204,5 @@ export async function offerPostInstallMigrations(
);
}
}
return { config: nextConfig };
}