From 78644bc6de910acc80c0f2c7becd9d6517cc2345 Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Wed, 13 May 2026 20:50:07 -0700 Subject: [PATCH] fix: carry codex migration config through onboarding --- CHANGELOG.md | 1 + docs/plugins/sdk-subpaths.md | 7 + extensions/codex/src/migration/apply.ts | 37 +++-- .../codex/src/migration/provider.test.ts | 96 +++++++++++- src/commands/migrate.test.ts | 36 +++++ src/commands/migrate.ts | 2 +- src/commands/migrate/apply.ts | 2 + src/commands/migrate/context.ts | 4 +- src/commands/migrate/providers.ts | 17 ++- src/commands/migrate/types.ts | 9 ++ src/plugins/provider-auth-choice.test.ts | 137 ++++++++++++++++++ src/plugins/provider-auth-choice.ts | 3 +- .../setup.post-install-migration.test.ts | 117 ++++++++++++++- src/wizard/setup.post-install-migration.ts | 52 ++++++- 14 files changed, 485 insertions(+), 35 deletions(-) create mode 100644 src/plugins/provider-auth-choice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ae384c6e0..65a4ae38785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 2941aae45b4..cc37350353b 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index 8f0e7abf60a..9edb81beda7 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -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, 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); diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 7342ccc1739..11c05adddd6 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -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; + 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); diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index b8c38d48d0b..fbf4cfea5ee 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -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, diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 91b27025f9d..21aef8523bc 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -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 ")}.`, ); } - const provider = resolveMigrationProvider(providerId); + const provider = resolveMigrationProvider(providerId, opts.configOverride); if (!opts.yes) { const plan = await migratePlanCommand(runtime, { ...opts, diff --git a/src/commands/migrate/apply.ts b/src/commands/migrate/apply.ts index af132dbcda1..8de8f854ccb 100644 --- a/src/commands/migrate/apply.ts +++ b/src/commands/migrate/apply.ts @@ -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, diff --git a/src/commands/migrate/context.ts b/src/commands/migrate/context.ts index 87d85f3fa1b..93f47b35be5 100644 --- a/src/commands/migrate/context.ts +++ b/src/commands/migrate/context.ts @@ -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; backupPath?: string; + configOverride?: OpenClawConfig; runtime: RuntimeEnv; reportDir?: string; json?: boolean; }): MigrationProviderContext { - const config = getRuntimeConfig(); + const config = params.configOverride ?? getRuntimeConfig(); const stateDir = resolveStateDir(); return { config, diff --git a/src/commands/migrate/providers.ts b/src/commands/migrate/providers.ts index fb5e2f4f7a3..3f9e937b7af 100644 --- a/src/commands/migrate/providers.ts +++ b/src/commands/migrate/providers.ts @@ -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 | undefined { + const options: Record = {}; 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, diff --git a/src/commands/migrate/types.ts b/src/commands/migrate/types.ts index 96161eba2db..df9f3c0b1f6 100644 --- a/src/commands/migrate/types.ts +++ b/src/commands/migrate/types.ts @@ -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 & { diff --git a/src/plugins/provider-auth-choice.test.ts b/src/plugins/provider-auth-choice.test.ts new file mode 100644 index 00000000000..6e7f213363c --- /dev/null +++ b/src/plugins/provider-auth-choice.test.ts @@ -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", + }, + }); + }); +}); diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 2412b1b8963..a4adfd87b1b 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -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({ diff --git a/src/wizard/setup.post-install-migration.test.ts b/src/wizard/setup.post-install-migration.test.ts index d5a6de8aa49..cc9b8b1b2dd 100644 --- a/src/wizard/setup.post-install-migration.test.ts +++ b/src/wizard/setup.post-install-migration.test.ts @@ -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(); }); diff --git a/src/wizard/setup.post-install-migration.ts b/src/wizard/setup.post-install-migration.ts index fea3eac95c7..9f2b8405ffd 100644 --- a/src/wizard/setup.post-install-migration.ts +++ b/src/wizard/setup.post-install-migration.ts @@ -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[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> => + patch !== undefined, + ); + if (patches.length === 0) { + return config; + } + const nextConfig = structuredClone(config); + for (const patch of patches) { + writeMigrationConfigPath(nextConfig as Record, 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 { +): Promise { 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 }; }