From 852548ce059fa65526e0744714e4e54f84e6fa36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 07:06:01 -0400 Subject: [PATCH] feat(onboard): stage migration imports before promotion (#112798) * feat(onboard): stage migration imports before promotion * fix(onboard): harden migration recovery * fix(onboard): commit promoted config with CAS * fix(onboard): resume setup after migration recovery * test(onboard): split noninteractive migration coverage * refactor(onboard): split migration promotion helpers * fix(onboard): normalize migration preparation cleanup * fix(onboard): preserve recovered inference ownership * fix(codex): complete empty deferred plugin config * fix(codex): reconcile deferred plugin config retries * fix(onboard): resolve verification target after rebase * refactor(onboard): trim retired migration exports * test(onboard): assert read-only auth prompt store --- .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- docs/cli/migrate.md | 4 +- docs/cli/onboard.md | 2 +- docs/install/migrating-claude.md | 2 +- docs/install/migrating-hermes.md | 2 +- docs/install/migrating.md | 2 +- extensions/codex/src/migration/apply.ts | 34 +- extensions/codex/src/migration/auth.ts | 7 +- extensions/codex/src/migration/plan.ts | 2 + .../codex/src/migration/provider.test.ts | 4 +- extensions/codex/src/migration/provider.ts | 1 + extensions/migrate-hermes/auth-config.ts | 6 +- extensions/migrate-hermes/auth.ts | 1 + extensions/migrate-hermes/model.ts | 3 +- extensions/migrate-hermes/secrets.ts | 1 + src/agents/auth-profiles/sqlite.ts | 7 +- src/agents/auth-profiles/store.ts | 37 +- src/agents/auth-profiles/upsert-with-lock.ts | 2 + src/commands/migrate/apply.ts | 4 +- .../onboard-non-interactive.gateway.test.ts | 115 --- .../onboard-non-interactive.migration.test.ts | 186 +++++ src/commands/onboard-non-interactive.ts | 28 +- src/plugin-sdk/migration.ts | 7 +- src/plugins/migration-provider.types.ts | 20 + src/plugins/provider-auth-choice.ts | 1 + src/plugins/types.ts | 1 + src/state/openclaw-state-db.ts | 17 + src/system-agent/setup-inference.ts | 6 +- .../setup.inference-verification.test.ts | 58 ++ src/wizard/setup.inference-verification.ts | 124 +++ src/wizard/setup.migration-finalize.ts | 264 +++++++ src/wizard/setup.migration-import.ts | 241 +++--- src/wizard/setup.migration-promotion.ts | 546 +++++++++++++ src/wizard/setup.migration-recovery.test.ts | 543 ------------- src/wizard/setup.migration-recovery.ts | 397 ---------- src/wizard/setup.migration-snapshot.ts | 12 +- src/wizard/setup.migration-stage.test.ts | 736 ++++++++++++++++++ src/wizard/setup.migration-stage.ts | 512 ++++++++++++ .../setup.migration-transaction.test.ts | 551 +++++++++++++ src/wizard/setup.model-auth.test.ts | 1 + src/wizard/setup.model-auth.ts | 9 +- src/wizard/setup.test.ts | 139 ++-- src/wizard/setup.ts | 129 +-- 43 files changed, 3409 insertions(+), 1357 deletions(-) create mode 100644 src/commands/onboard-non-interactive.migration.test.ts create mode 100644 src/wizard/setup.inference-verification.test.ts create mode 100644 src/wizard/setup.inference-verification.ts create mode 100644 src/wizard/setup.migration-finalize.ts create mode 100644 src/wizard/setup.migration-promotion.ts delete mode 100644 src/wizard/setup.migration-recovery.test.ts delete mode 100644 src/wizard/setup.migration-recovery.ts create mode 100644 src/wizard/setup.migration-stage.test.ts create mode 100644 src/wizard/setup.migration-stage.ts create mode 100644 src/wizard/setup.migration-transaction.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 72372329ca8d..b8212531685c 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -95,7 +95,7 @@ d09ddb38c9d20a41fcf4cedaf6a2c28eb5333a9fba2287e34b458fa5caba751c module/memory- 987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime a5e4304b8b5878d5b7b7732939e1c91d009b4d3e9ee1c41d2f6ccde81c10eb99 module/plugin-entry 8097910898d08166f3e2e3676de5dac43f3442cfb1faa1b894ded353d973d09d module/plugin-runtime -83df49f1fcb2fc4ac1bd2406a87a10be71bbcdb0967f61e9a6ccaa23c534149f module/provider-auth +9c9670596f6dee29ec7cdcb0616cefbcd50facbf71413bec4f7c63de8f4d5469 module/provider-auth 71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime 56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/question-gateway-runtime diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 105263dcd335..cccb6a64bd71 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -223,13 +223,13 @@ Migration sources are plugins. A plugin declares its provider ids in `openclaw.p } ``` -At runtime the plugin calls `api.registerMigrationProvider(...)`. The provider implements `detect`, `plan`, and `apply`. Core owns CLI orchestration, backup policy, prompts, JSON output, and conflict preflight. Core passes the reviewed plan into `apply(ctx, plan)`, and providers may rebuild the plan only when that argument is absent for compatibility. +At runtime the plugin calls `api.registerMigrationProvider(...)`. The provider implements `detect`, `plan`, and `apply`. Core owns CLI orchestration, backup policy, prompts, JSON output, and conflict preflight. Core passes the reviewed plan into `apply(ctx, plan)`, and providers may rebuild the plan only when that argument is absent for compatibility. Migration items may set `applyPhase: "after-promotion"` for external activation effects that onboarding must defer until staged local data is durably published. Those providers must declare `deferredApply: { retrySafe: true }` and make each deferred effect safe to replay after an interrupted process; onboarding rejects undeclared deferred effects. An idempotent no-op should return a non-mutating item with `deferredCompletion: true` so recovery can record it as complete. Standalone `openclaw migrate` still applies the complete plan through its normal backup-backed flow. Provider plugins can use `openclaw/plugin-sdk/migration` for item construction and summary counts, plus `openclaw/plugin-sdk/migration-runtime` for conflict-aware file copies, archive-only report copies, cached config-runtime wrappers, and migration reports. ## Onboarding integration -Onboarding can offer migration when a provider detects a known source. Both `openclaw onboard --flow import` and `openclaw setup --wizard --import-from hermes` use the same plugin migration provider and still show a preview before applying. +Onboarding can offer migration when a provider detects a known source. Both `openclaw onboard --flow import` and `openclaw setup --wizard --import-from hermes` use the same plugin migration provider and still show a preview before applying. Unlike standalone migration, the fresh-target onboarding path stages local artifacts and imported credentials, verifies or repairs imported inference inside staging, then promotes workspace and agent state before committing configuration. A mode-`0600` promotion journal lets the next run finish or roll back an interrupted publish, including any deferred external activation, without replaying imported local data. Onboarding imports require a fresh OpenClaw setup. Reset config, credentials, sessions, and the workspace first if you already have local state. Backup-plus-overwrite or merge imports are feature-gated for existing setups. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 83d12ee66684..7aced66ad33f 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -85,7 +85,7 @@ not overwrite the existing skill. options keep their current values. - `--flow manual` (alias `advanced`): opens the classic wizard with full prompts for port, bind, and auth. -- `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`), previews the plan, then applies after confirmation. When an interactive import supplies a default model, onboarding requires that route to pass a live completion before it skips provider setup; a failed imported route returns to provider configuration. Import only runs against a fresh OpenClaw setup - reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, reports, and exact mappings. +- `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`) against a fresh setup. After confirmation, onboarding stages config, credentials, workspace files, memory, and skills under private temporary targets; imported inference must pass a live completion before workspace and agent state are promoted and configuration is committed. Failure or cancellation before promotion leaves the live target untouched. External activation steps that cannot be rolled back, such as Codex plugin installation, run afterward and remain retryable from the migration report. Reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, verified backups, reports, and exact mappings. - `--remote-url` and `--remote-token`: prefill the classic remote Gateway step and override stored remote values for this run. Changing the URL does not reuse stored credentials unless you also pass a token. The token stays masked in prompts and follows the wizard's existing plaintext or SecretRef storage choice. - `--tailscale-reset-on-exit` and `--no-tailscale-reset-on-exit`: explicitly control whether Tailscale Serve or Funnel configuration is reset when the Gateway exits. Omitting both preserves the current setting during non-interactive reruns. - `--modern` is a compatibility alias for the OpenClaw conversational setup diff --git a/docs/install/migrating-claude.md b/docs/install/migrating-claude.md index f9e960c40988..10b9cd2094a5 100644 --- a/docs/install/migrating-claude.md +++ b/docs/install/migrating-claude.md @@ -6,7 +6,7 @@ read_when: title: "Migrating from Claude" --- -OpenClaw imports local Claude state through the bundled Claude migration provider. The provider previews every item before changing state, redacts secrets in plans and reports, and creates a verified backup before apply. +OpenClaw imports local Claude state through the bundled Claude migration provider. The provider previews every item before changing state and redacts secrets in plans and reports. Standalone `openclaw migrate` creates a verified backup; the fresh onboarding path stages the import and publishes it only after verification succeeds. Onboarding imports require a fresh OpenClaw setup. If you already have local OpenClaw state, reset config, credentials, sessions, and the workspace first, or use `openclaw migrate` directly with `--overwrite` after reviewing the plan. diff --git a/docs/install/migrating-hermes.md b/docs/install/migrating-hermes.md index f2172de84399..a275783d873c 100644 --- a/docs/install/migrating-hermes.md +++ b/docs/install/migrating-hermes.md @@ -7,7 +7,7 @@ read_when: title: "Migrating from Hermes" --- -The bundled Hermes migration provider follows `HERMES_HOME` and the active Hermes profile, falling back to `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows. It previews every change before applying, redacts secrets in plans and reports, and writes a verified OpenClaw backup before it touches anything. An explicit `--from` path always wins. +The bundled Hermes migration provider follows `HERMES_HOME` and the active Hermes profile, falling back to `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows. It previews every change before applying and redacts secrets in plans and reports. Standalone `openclaw migrate` writes a verified backup; the fresh onboarding path stages config, credentials, and files and publishes them only after imported inference verifies. An explicit `--from` path always wins. Imports require a fresh OpenClaw setup. If you already have local OpenClaw state, reset config, credentials, sessions, and the workspace first, or use `openclaw migrate apply hermes` directly with `--overwrite` after reviewing the plan. diff --git a/docs/install/migrating.md b/docs/install/migrating.md index 56d1380b825d..db026d73d876 100644 --- a/docs/install/migrating.md +++ b/docs/install/migrating.md @@ -11,7 +11,7 @@ OpenClaw supports three migration paths: importing from another agent system, mo ## Import from another agent system -Bundled migration providers bring instructions, MCP servers, skills, model config, and (opt-in) API keys into OpenClaw. Plans are previewed before any change, secrets are redacted in reports, and apply is backed by a verified backup. +Bundled migration providers bring instructions, MCP servers, skills, model config, and (opt-in) API keys into OpenClaw. Plans are previewed before any change and secrets are redacted in reports. Standalone `openclaw migrate` is backed by a verified backup; fresh onboarding imports instead stage and verify local artifacts before publishing them with configuration committed before any irreversible external activation. diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index 2ea9a86943a5..10ae156b8495 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -6,6 +6,7 @@ import { markMigrationItemError, markMigrationItemSkipped, MIGRATION_REASON_TARGET_EXISTS, + resolveMigrationConfigRuntime, summarizeMigrationItems, writeMigrationConfigPath, } from "openclaw/plugin-sdk/migration"; @@ -421,14 +422,31 @@ async function applyCodexPluginConfigItem( item: MigrationItem, appliedItems: readonly MigrationItem[], ): Promise { + const incompletePluginItems = appliedItems.filter( + (candidate) => + candidate.kind === "plugin" && + candidate.action === "install" && + readCodexPluginPolicy(candidate) !== undefined && + !isCodexPluginConfigTerminal(candidate), + ); + if (incompletePluginItems.length > 0) { + return { + ...item, + status: "warning", + reason: "selected Codex plugin activation is incomplete", + }; + } const entries = appliedItems .map(readAppliedPluginConfigEntry) .filter((entry): entry is CodexPluginMigrationConfigEntry => entry !== undefined); if (entries.length === 0) { - return markMigrationItemSkipped(item, "no selected Codex plugins"); + return { + ...markMigrationItemSkipped(item, "no selected Codex plugins"), + deferredCompletion: true, + }; } const returnPatch = shouldReturnCodexPluginConfigPatch(ctx); - const configApi = ctx.runtime?.config; + const configApi = resolveMigrationConfigRuntime(ctx); const currentConfig = returnPatch ? ctx.config : (configApi?.current?.() as MigrationProviderContext["config"] | undefined); @@ -474,10 +492,20 @@ async function applyCodexPluginConfigItem( } } +function isCodexPluginConfigTerminal(item: MigrationItem): boolean { + return ( + item.status === "migrated" || + (item.status === "skipped" && + (item.deferredCompletion === true || + item.reason === CODEX_PLUGIN_NOT_SELECTED_REASON || + item.reason === CODEX_PLUGIN_AUTH_REQUIRED_REASON)) + ); +} + function readAppliedPluginConfigEntry( item: MigrationItem, ): CodexPluginMigrationConfigEntry | undefined { - if (item.status === "migrated") { + if (item.status === "migrated" || item.deferredCompletion === true) { return readCodexPluginMigrationConfigEntry(item, true); } if ( diff --git a/extensions/codex/src/migration/auth.ts b/extensions/codex/src/migration/auth.ts index 1cd593011848..41996a495693 100644 --- a/extensions/codex/src/migration/auth.ts +++ b/extensions/codex/src/migration/auth.ts @@ -5,6 +5,7 @@ import { markMigrationItemConflict, markMigrationItemError, markMigrationItemSkipped, + resolveMigrationConfigRuntime, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { @@ -258,7 +259,8 @@ function hasCurrentAuthProfileConfigConflict( ): boolean { let config = ctx.config; try { - config = (ctx.runtime?.config?.current?.() as OpenClawConfig | undefined) ?? config; + config = + (resolveMigrationConfigRuntime(ctx)?.current?.() as OpenClawConfig | undefined) ?? config; } catch { // Fall back to the planning snapshot; direct config writes recheck inside mutate. } @@ -415,7 +417,7 @@ async function applyCodexAuthProfileConfig( profile: CodexAuthProfileConfig, applyConfig: (config: OpenClawConfig) => OpenClawConfig, ): Promise { - const configApi = ctx.runtime?.config; + const configApi = resolveMigrationConfigRuntime(ctx); if (!configApi?.current || !configApi.mutateConfigFile) { return "unavailable"; } @@ -559,6 +561,7 @@ export async function applyCodexAuthItem(params: { let wrote = false; const store = await updateAuthProfileStoreWithLock({ agentDir: targets.agentDir, + stateDir: ctx.stateDir, updater: (freshStore) => { const existing = freshStore.profiles[profileId]; if (!ctx.overwrite && existing) { diff --git a/extensions/codex/src/migration/plan.ts b/extensions/codex/src/migration/plan.ts index a4636b4bd60f..efb4df83588e 100644 --- a/extensions/codex/src/migration/plan.ts +++ b/extensions/codex/src/migration/plan.ts @@ -179,6 +179,7 @@ function buildPluginItems( action: "install", status: conflict ? "conflict" : "planned", reason: conflict ? MIGRATION_REASON_PLUGIN_EXISTS : undefined, + applyPhase: "after-promotion", 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.`, @@ -425,6 +426,7 @@ function buildPluginConfigItem( target: "plugins.entries.codex.config.codexPlugins", status: conflict ? "conflict" : "planned", reason: conflict ? MIGRATION_REASON_TARGET_EXISTS : undefined, + applyPhase: "after-promotion", message: "Enable OpenClaw's Codex plugin integration and record migrated source-installed curated plugins.", details: { diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 5e902c68c80c..19c0201e7fd5 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -2463,8 +2463,8 @@ describe("buildCodexMigrationProvider", () => { reason: "install failed", }); expectRecordFields(findItem(result.items, "config:codex-plugins"), { - status: "skipped", - reason: "no selected Codex plugins", + status: "warning", + reason: "selected Codex plugin activation is incomplete", }); expect(configState.plugins?.entries?.codex?.config?.codexPlugins).toBeUndefined(); }); diff --git a/extensions/codex/src/migration/provider.ts b/extensions/codex/src/migration/provider.ts index 4bf5716f6fdf..a95b974d8477 100644 --- a/extensions/codex/src/migration/provider.ts +++ b/extensions/codex/src/migration/provider.ts @@ -41,6 +41,7 @@ export function buildCodexMigrationProvider( }; }, plan: buildCodexMigrationPlan, + deferredApply: { retrySafe: true }, prepareApply(ctx) { if (isMemoryOnlyMigration(ctx)) { return undefined; diff --git a/extensions/migrate-hermes/auth-config.ts b/extensions/migrate-hermes/auth-config.ts index 6b2ce2bce4d4..74feccc75c77 100644 --- a/extensions/migrate-hermes/auth-config.ts +++ b/extensions/migrate-hermes/auth-config.ts @@ -1,4 +1,5 @@ // Migrate Hermes helper module supports auth config behavior. +import { resolveMigrationConfigRuntime } from "openclaw/plugin-sdk/migration"; import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { applyAuthProfileConfig, type OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; @@ -52,7 +53,8 @@ export function hasCurrentAuthProfileConfigConflict( ): boolean { let config = ctx.config; try { - config = (ctx.runtime?.config?.current?.() as OpenClawConfig | undefined) ?? config; + config = + (resolveMigrationConfigRuntime(ctx)?.current?.() as OpenClawConfig | undefined) ?? config; } catch { // Fall back to the planning snapshot; apply still rechecks inside mutate. } @@ -64,7 +66,7 @@ export async function applyAuthProfileConfigWithConflictCheck(params: { profile: HermesAuthProfileConfig; applyConfigPatch?: (config: OpenClawConfig) => OpenClawConfig; }): Promise { - const configApi = params.ctx.runtime?.config; + const configApi = resolveMigrationConfigRuntime(params.ctx); if (!configApi?.current || !configApi.mutateConfigFile) { return "unavailable"; } diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts index 163cbdb32d43..39a490c27c42 100644 --- a/extensions/migrate-hermes/auth.ts +++ b/extensions/migrate-hermes/auth.ts @@ -444,6 +444,7 @@ export async function applyAuthItem( } const store = await updateAuthProfileStoreWithLock({ agentDir: targets.agentDir, + stateDir: ctx.stateDir, updater: (freshStore) => { const existing = freshStore.profiles[profileId]; if (!ctx.overwrite && existing) { diff --git a/extensions/migrate-hermes/model.ts b/extensions/migrate-hermes/model.ts index cf5364136b8d..2fc92a60a052 100644 --- a/extensions/migrate-hermes/model.ts +++ b/extensions/migrate-hermes/model.ts @@ -4,6 +4,7 @@ import { resolveDefaultAgentId, setAgentEffectiveModelPrimary, } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveMigrationConfigRuntime } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { readString } from "./helpers.js"; import { @@ -368,7 +369,7 @@ export async function applyModelItem( return item; } try { - const configApi = ctx.runtime?.config; + const configApi = resolveMigrationConfigRuntime(ctx); if (!configApi?.current || !configApi.mutateConfigFile) { return hermesItemError(item, HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE); } diff --git a/extensions/migrate-hermes/secrets.ts b/extensions/migrate-hermes/secrets.ts index 33efa689c11f..3c1a68b4f758 100644 --- a/extensions/migrate-hermes/secrets.ts +++ b/extensions/migrate-hermes/secrets.ts @@ -368,6 +368,7 @@ export async function applySecretItem( let wrote = false; const store = await updateAuthProfileStoreWithLock({ agentDir: targets.agentDir, + stateDir: ctx.stateDir, updater: (freshStore) => { if (!ctx.overwrite && freshStore.profiles[details.profileId]) { conflicted = true; diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index b7c90e893b56..c88447ce17f5 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -335,6 +335,11 @@ export function writePersistedAuthProfileStateRaw( export function runAuthProfileWriteTransaction( agentDir: string | undefined, operation: (database: OpenClawAgentDatabase) => T, + options: { stateDir?: string } = {}, ): T { - return runOpenClawAgentWriteTransaction(operation, resolveAuthProfileDatabaseOptions(agentDir)); + const databaseOptions = resolveAuthProfileDatabaseOptions(agentDir); + return runOpenClawAgentWriteTransaction(operation, { + ...databaseOptions, + ...(options.stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: options.stateDir } } : {}), + }); } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index d05e15b0fcee..5932eac41bd8 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -866,29 +866,34 @@ function mergeRuntimeExternalProfileState(params: { /** Apply an auth store update inside the SQLite write lock. */ export async function updateAuthProfileStoreWithLock(params: { agentDir?: string; + stateDir?: string; saveOptions?: SaveAuthProfileStoreOptions; updater: (store: AuthProfileStore) => boolean; }): Promise { let publishRuntimeSnapshots: (() => void) | undefined; let store: AuthProfileStore; try { - store = runAuthProfileWriteTransaction(params.agentDir, (database) => { - const loadedStore = loadAuthProfileStoreForAgent(params.agentDir, { - database, - readOnly: true, - syncExternalCli: false, - }); - const shouldSave = params.updater(loadedStore); - if (shouldSave) { - publishRuntimeSnapshots = saveAuthProfileStoreInTransaction( - loadedStore, - params.agentDir, - params.saveOptions, + store = runAuthProfileWriteTransaction( + params.agentDir, + (database) => { + const loadedStore = loadAuthProfileStoreForAgent(params.agentDir, { database, - ); - } - return loadedStore; - }); + readOnly: true, + syncExternalCli: false, + }); + const shouldSave = params.updater(loadedStore); + if (shouldSave) { + publishRuntimeSnapshots = saveAuthProfileStoreInTransaction( + loadedStore, + params.agentDir, + params.saveOptions, + database, + ); + } + return loadedStore; + }, + { stateDir: params.stateDir }, + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); log.warn(`auth profile store update failed: ${message}`, { diff --git a/src/agents/auth-profiles/upsert-with-lock.ts b/src/agents/auth-profiles/upsert-with-lock.ts index 77554b16377f..dec4cc28562d 100644 --- a/src/agents/auth-profiles/upsert-with-lock.ts +++ b/src/agents/auth-profiles/upsert-with-lock.ts @@ -12,10 +12,12 @@ export async function upsertAuthProfileWithLock(params: { profileId: string; credential: AuthProfileCredential; agentDir?: string; + stateDir?: string; }): Promise { const credential = normalizeAuthProfileCredential(params.credential); return await updateAuthProfileStoreWithLock({ agentDir: params.agentDir, + stateDir: params.stateDir, saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false, diff --git a/src/commands/migrate/apply.ts b/src/commands/migrate/apply.ts index ef4794278135..19ebfd10ab1a 100644 --- a/src/commands/migrate/apply.ts +++ b/src/commands/migrate/apply.ts @@ -22,9 +22,7 @@ function shouldTreatMissingBackupAsEmptyState(error: unknown): boolean { } /** Creates a verified pre-migration backup, treating absent local state as empty. */ -export async function createPreMigrationBackup(opts: { - output?: string; -}): Promise { +async function createPreMigrationBackup(opts: { output?: string }): Promise { try { const result = await backupCreateCommand( { diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 5375fca90593..b7296a648315 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; @@ -16,14 +15,6 @@ type InstallGatewayDaemonResult = Awaited vi.fn(async (): Promise => ({ installed: true })), ); -const createPreMigrationBackupMock = vi.hoisted(() => vi.fn(async () => undefined)); -const migrationProviderMock = vi.hoisted(() => ({ - id: "hermes", - label: "Hermes", - description: "Hermes migration provider", - plan: vi.fn(), - apply: vi.fn(), -})); const healthCommandMock = vi.hoisted(() => vi.fn(async () => {})); const gatewayServiceMock = vi.hoisted(() => ({ label: "LaunchAgent", @@ -157,21 +148,6 @@ vi.mock("./health.js", () => ({ healthCommand: healthCommandMock, })); -vi.mock("../plugins/migration-provider-runtime.js", () => ({ - ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(), - resolvePluginMigrationProviders: () => [migrationProviderMock], - resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) => - providerId === migrationProviderMock.id ? migrationProviderMock : undefined, -})); - -vi.mock("./migrate/apply.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - createPreMigrationBackup: createPreMigrationBackupMock, - }; -}); - vi.mock("../daemon/service.js", () => ({ resolveGatewayService: () => gatewayServiceMock, })); @@ -248,18 +224,6 @@ type EnsureWorkspaceOptions = { skipBootstrap?: boolean; }; -type MigrationPlanCall = { - config?: OpenClawConfig; - includeSecrets?: boolean; - overwrite?: boolean; - source?: string; -}; - -type MigrationApplyCall = { - reportDir?: string; - source?: string; -}; - type GatewayHealthCall = { password?: string; token?: string; @@ -395,9 +359,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { capturedReplaceConfigFileCalls.length = 0; ensureWorkspaceAndSessionsMock.mockClear(); installGatewayDaemonNonInteractiveMock.mockClear(); - createPreMigrationBackupMock.mockClear(); - migrationProviderMock.plan.mockReset(); - migrationProviderMock.apply.mockReset(); healthCommandMock.mockClear(); gatewayServiceMock.isLoaded.mockClear(); gatewayServiceMock.readRuntime.mockClear(); @@ -627,82 +588,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); - it("applies non-interactive migration imports instead of ignoring import flags", async () => { - await withStateDir("state-noninteractive-import-", async (stateDir) => { - const source = path.join(stateDir, "hermes-home"); - const workspace = path.join(stateDir, "openclaw"); - const planned: MigrationPlan = { - providerId: "hermes", - source, - target: workspace, - summary: { - total: 1, - planned: 1, - migrated: 0, - skipped: 0, - conflicts: 0, - errors: 0, - sensitive: 0, - }, - items: [ - { - id: "workspace:AGENTS.md", - kind: "workspace", - action: "copy", - status: "planned", - source: path.join(source, "AGENTS.md"), - target: path.join(workspace, "AGENTS.md"), - }, - ], - }; - const applied: MigrationApplyResult = { - ...planned, - summary: { - ...planned.summary, - planned: 0, - migrated: 1, - }, - items: planned.items.map((item) => ({ ...item, status: "migrated" as const })), - }; - migrationProviderMock.plan.mockResolvedValueOnce(planned); - migrationProviderMock.apply.mockResolvedValueOnce(applied); - - await runNonInteractiveSetup( - { - nonInteractive: true, - mode: "local", - workspace, - authChoice: "skip", - skipHealth: true, - importFrom: "hermes", - importSource: source, - }, - runtime, - ); - - expect(migrationProviderMock.plan).toHaveBeenCalledOnce(); - const [planCall] = readFirstMockCall( - migrationProviderMock.plan, - "migrationProvider.plan", - ) as [MigrationPlanCall]; - expect(planCall.source).toBe(source); - expect(planCall.includeSecrets).toBe(false); - expect(planCall.overwrite).toBe(false); - expect(planCall.config?.agents?.defaults?.workspace).toBe(workspace); - expect(migrationProviderMock.apply).toHaveBeenCalledOnce(); - const [applyCall, appliedPlan] = readFirstMockCall( - migrationProviderMock.apply, - "migrationProvider.apply", - ) as [MigrationApplyCall, MigrationPlan]; - expect(applyCall.source).toBe(source); - expect(applyCall.reportDir).toContain(path.join(stateDir, "migration", "hermes")); - expect(appliedPlan).toBe(planned); - expect(readTestConfig().agents?.defaults?.workspace).toBe(workspace); - expect(ensureWorkspaceAndSessionsMock).not.toHaveBeenCalled(); - expect(healthCommandMock).not.toHaveBeenCalled(); - }); - }, 60_000); - it("writes gateway.remote url/token", async () => { await withStateDir("state-remote-", async (_stateDir) => { const port = getPseudoPort(30_000); diff --git a/src/commands/onboard-non-interactive.migration.test.ts b/src/commands/onboard-non-interactive.migration.test.ts new file mode 100644 index 000000000000..bb655b97d2a7 --- /dev/null +++ b/src/commands/onboard-non-interactive.migration.test.ts @@ -0,0 +1,186 @@ +// Non-interactive migration tests exercise the real staged import and terminal acknowledgement. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; +import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; +import type { RuntimeEnv } from "../runtime.js"; + +const tempRoots = useAutoCleanupTempDirTracker(afterEach); +const configStore = new Map(); +const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {})); +const provider = vi.hoisted(() => ({ + id: "hermes", + label: "Hermes", + description: "Hermes migration provider", + plan: vi.fn(), + apply: vi.fn(), +})); +let previousStateDir: string | undefined; + +function configPath(): string { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is required"); + } + return path.join(stateDir, "openclaw.json"); +} + +vi.mock("../config/io.js", () => ({ + readConfigFileSnapshot: async () => { + const config = configStore.get(configPath()); + return config + ? { + exists: true, + valid: true, + config, + sourceConfig: config, + raw: `${JSON.stringify(config)}\n`, + hash: "test-config-hash", + } + : { + exists: false, + valid: true, + config: {}, + sourceConfig: {}, + raw: null, + hash: undefined, + }; + }, +})); + +vi.mock("../config/config.js", () => ({ + ConfigMutationConflictError: class ConfigMutationConflictError extends Error {}, + replaceConfigFile: async ({ nextConfig }: { nextConfig: OpenClawConfig }) => { + configStore.set(configPath(), structuredClone(nextConfig)); + return { nextConfig }; + }, + resolveGatewayPort: (config: OpenClawConfig) => config.gateway?.port ?? 18789, +})); + +vi.mock("./onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", + applyWizardMetadata: (config: OpenClawConfig) => config, + ensureWorkspaceAndSessions, +})); + +vi.mock("../plugins/migration-provider-runtime.js", () => ({ + ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(), + resolvePluginMigrationProviders: () => [provider], + resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) => + providerId === provider.id ? provider : undefined, +})); + +import { runNonInteractiveSetup } from "./onboard-non-interactive.js"; + +function runtime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} + +describe("non-interactive migration onboarding", () => { + beforeEach(() => { + previousStateDir = process.env.OPENCLAW_STATE_DIR; + configStore.clear(); + ensureWorkspaceAndSessions.mockClear(); + provider.plan.mockReset(); + provider.apply.mockReset(); + }); + + afterEach(() => { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + }); + + it("stages, promotes, and acknowledges an explicit import", async () => { + const stateDir = tempRoots.make("openclaw-noninteractive-migration-"); + const source = path.join(stateDir, "hermes-home"); + const workspace = path.join(stateDir, "workspace"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "AGENTS.md"), "Imported agents.\n", "utf8"); + process.env.OPENCLAW_STATE_DIR = stateDir; + + provider.plan.mockImplementation(async (ctx): Promise => { + const configuredWorkspace = ctx.config.agents?.defaults?.workspace; + expect(configuredWorkspace).toBe(workspace); + if (!configuredWorkspace) { + throw new Error("missing configured workspace"); + } + const target = path.join(configuredWorkspace, "AGENTS.md"); + const items: MigrationPlan["items"] = [ + { + id: "workspace:AGENTS.md", + kind: "workspace", + action: "copy", + status: "planned", + source: path.join(source, "AGENTS.md"), + target, + }, + ]; + return { + providerId: "hermes", + source, + target: path.dirname(target), + items, + summary: summarizeMigrationItems(items), + }; + }); + provider.apply.mockImplementation(async (ctx, plan): Promise => { + const item = plan?.items[0]; + if (!plan || !item?.source || !item.target) { + throw new Error("missing migration plan item"); + } + await fs.mkdir(path.dirname(item.target), { recursive: true }); + await fs.copyFile(item.source, item.target); + const items = [{ ...item, status: "migrated" as const }]; + return { + ...plan, + items, + summary: summarizeMigrationItems(items), + reportDir: ctx.reportDir, + }; + }); + + await runNonInteractiveSetup( + { + nonInteractive: true, + mode: "local", + workspace, + authChoice: "skip", + skipHealth: true, + importFrom: "hermes", + importSource: source, + }, + runtime(), + ); + + expect(provider.plan).toHaveBeenCalledOnce(); + expect(provider.plan).toHaveBeenCalledWith( + expect.objectContaining({ source, includeSecrets: false, overwrite: false }), + ); + expect(provider.apply).toHaveBeenCalledOnce(); + const [applyContext, stagedPlan] = provider.apply.mock.calls[0] ?? []; + expect(applyContext?.source).toBe(source); + expect(applyContext?.reportDir).toContain(".openclaw-migration-state-"); + expect(stagedPlan?.items[0]?.target).toContain(".openclaw-migration-workspace-"); + expect(await fs.readFile(path.join(workspace, "AGENTS.md"), "utf8")).toBe("Imported agents.\n"); + expect(configStore.get(configPath())?.agents?.defaults?.workspace).toBe(workspace); + const [reportDir] = await fs.readdir(path.join(stateDir, "migration", "hermes")); + await expect( + fs.access( + path.join(stateDir, "migration", "hermes", reportDir!, "onboarding-promotion.json"), + ), + ).rejects.toThrow(); + expect(ensureWorkspaceAndSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 36fefeea6d48..8addc93ec6b3 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -4,8 +4,9 @@ * This module validates the existing config snapshot, routes local/remote * setup, and handles explicit migration imports without interactive prompts. */ +import { isDeepStrictEqual } from "node:util"; import { formatCliCommand } from "../cli/command-format.js"; -import { replaceConfigFile } from "../config/config.js"; +import { ConfigMutationConflictError, replaceConfigFile } from "../config/config.js"; import { readConfigFileSnapshot } from "../config/io.js"; import { logConfigUpdated } from "../config/logging.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -21,7 +22,6 @@ async function runNonInteractiveMigrationImport(params: { opts: OnboardOptions; runtime: RuntimeEnv; baseConfig: OpenClawConfig; - baseHash?: string; }) { const providerId = params.opts.importFrom?.trim(); if (!providerId) { @@ -39,7 +39,7 @@ async function runNonInteractiveMigrationImport(params: { config: params.baseConfig, runtime: params.runtime, }); - await runSetupMigrationImport({ + const outcome = await runSetupMigrationImport({ opts: { ...params.opts, importFrom: providerId, nonInteractive: true }, baseConfig: params.baseConfig, detections, @@ -56,16 +56,28 @@ async function runNonInteractiveMigrationImport(params: { } return snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {}; }, - async commitConfigFile(config) { - await replaceConfigFile({ + async commitConfigFile(config, expectedConfig) { + const latest = await readConfigFileSnapshot(); + if (!latest.valid) { + throw new Error("Migration target config became invalid. Run `openclaw doctor`."); + } + const latestConfig = latest.exists ? (latest.sourceConfig ?? latest.config) : {}; + if (!isDeepStrictEqual(latestConfig, expectedConfig)) { + throw new ConfigMutationConflictError("config changed during migration promotion", { + currentHash: latest.hash ?? null, + }); + } + const committed = await replaceConfigFile({ nextConfig: config, - ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + snapshot: latest, + ...(latest.hash !== undefined ? { baseHash: latest.hash } : {}), writeOptions: { allowConfigSizeDrop: true }, }); logConfigUpdated(params.runtime); - return config; + return committed.nextConfig; }, }); + await outcome.acknowledgePromotion?.(); } /** Runs non-interactive onboarding in local, remote, or migration-import mode. */ @@ -101,7 +113,7 @@ export async function runNonInteractiveSetup( if (opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import") { // Import flow owns its own commit path because migrations may intentionally // shrink legacy config after extracting credentials. - await runNonInteractiveMigrationImport({ opts, runtime, baseConfig, baseHash: snapshot.hash }); + await runNonInteractiveMigrationImport({ opts, runtime, baseConfig }); return; } diff --git a/src/plugin-sdk/migration.ts b/src/plugin-sdk/migration.ts index b025b175593f..21585b5c5a87 100644 --- a/src/plugin-sdk/migration.ts +++ b/src/plugin-sdk/migration.ts @@ -278,6 +278,11 @@ export function readMigrationConfigPatchDetails( return { path, value: item.details?.value }; } +/** Resolves the host-owned config mutation target for migration apply. */ +export function resolveMigrationConfigRuntime(ctx: MigrationProviderContext) { + return ctx.configRuntime ?? ctx.runtime?.config; +} + /** Applies one planned config patch through the runtime config writer and returns its final status. */ export async function applyMigrationConfigPatchItem( ctx: MigrationProviderContext, @@ -293,7 +298,7 @@ export async function applyMigrationConfigPatchItem( if (!isSafeMigrationConfigPath(details.path)) { return markMigrationItemError(item, MIGRATION_REASON_UNSAFE_CONFIG_PATCH_PATH); } - const configApi = ctx.runtime?.config; + const configApi = resolveMigrationConfigRuntime(ctx); if (!configApi?.current || !configApi.mutateConfigFile) { return markMigrationItemError(item, "config runtime unavailable"); } diff --git a/src/plugins/migration-provider.types.ts b/src/plugins/migration-provider.types.ts index 955adcd0b4e3..fa34c2552686 100644 --- a/src/plugins/migration-provider.types.ts +++ b/src/plugins/migration-provider.types.ts @@ -32,6 +32,13 @@ type MigrationItemAction = | "skip" | "manual"; +type MigrationApplyPhase = "before-promotion" | "after-promotion"; + +/** Provider guarantee required before onboarding defers non-rollbackable effects. */ +type MigrationDeferredApplyContract = { + retrySafe: true; +}; + export type MigrationItem = { id: string; kind: MigrationItemKind | (string & {}); @@ -42,6 +49,10 @@ export type MigrationItem = { message?: string; reason?: string; sensitive?: boolean; + /** Onboarding may defer non-rollbackable effects only for retry-safe providers. */ + applyPhase?: MigrationApplyPhase; + /** Retry-safe deferred apply may report a non-mutating already-satisfied terminal result. */ + deferredCompletion?: true; /** Core-owned source revision bound by reviewed embedded migration flows. */ sourceRevision?: { algorithm: "sha256"; digest: string }; details?: Record; @@ -85,9 +96,16 @@ type MigrationProviderPreparation = { dispose?: () => void | Promise; }; +export type MigrationConfigRuntime = Pick< + NonNullable, + "current" | "mutateConfigFile" +>; + export type MigrationProviderContext = { config: OpenClawConfig; runtime?: PluginRuntime; + /** Host-owned config mutation target for isolated embedded migration flows. */ + configRuntime?: MigrationConfigRuntime; logger: PluginLogger; stateDir: string; /** Explicit destination agent for embedded migration surfaces such as Control UI. */ @@ -110,6 +128,8 @@ export type MigrationProviderPlugin = { description?: string; /** Item kinds this provider can expose without requiring a full plan. */ supportedItemKinds?: readonly string[]; + /** Required when this provider plans items for `after-promotion`. */ + deferredApply?: MigrationDeferredApplyContract; detect?: (ctx: MigrationProviderContext) => MigrationDetection | Promise; prepareApply?: ( ctx: MigrationProviderContext, diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index cc6e5934e5f6..bed68d2f4c8c 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -434,6 +434,7 @@ async function prepareProviderPluginAuthMethod( profileId, credential, agentDir, + stateDir: params.env?.OPENCLAW_STATE_DIR, }); } profilesPersisted = true; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 90e35cf3fab6..3e5951550a3d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -143,6 +143,7 @@ export type { } from "./capability-provider.types.js"; export type { MigrationApplyResult, + MigrationConfigRuntime, MigrationDetection, MigrationItem, MigrationPlan, diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 6a34396a7b13..f5d271c2ea29 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -1,5 +1,6 @@ // OpenClaw state database manages shared persisted state and migrations. import { existsSync } from "node:fs"; +import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { pathToFileURL } from "node:url"; import { @@ -458,6 +459,22 @@ export function runOpenClawStateWriteTransaction( return result; } +/** Close one cached shared state database handle by exact pathname. */ +export function closeOpenClawStateDatabaseByPath(pathname: string): boolean { + const resolvedPath = path.resolve(pathname); + const database = cachedDatabases.get(resolvedPath); + if (!database) { + return false; + } + database.walMaintenance.close(); + clearNodeSqliteKyselyCacheForDatabase(database.db); + if (database.db.isOpen) { + database.db.close(); + } + cachedDatabases.delete(resolvedPath); + return true; +} + /** Close all cached shared state database handles. */ export function closeOpenClawStateDatabase(): void { for (const database of cachedDatabases.values()) { diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 312d0adb2533..b5342ddfb3db 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -2701,6 +2701,8 @@ export async function verifySetupInferenceConfig(params: { /** Candidate profiles staged in the isolated probe store, never the real agent store. */ authProfiles?: ProviderAuthResult["profiles"]; agentId?: string; + /** Explicit isolated agent directory for staged onboarding verification. */ + agentDir?: string; runtime: RuntimeEnv; timeoutMs?: number; deps?: ActivateSetupInferenceDeps; @@ -2747,7 +2749,9 @@ export async function verifySetupInferenceConfig(params: { error: builtPlan.error, }; } - let plan: SetupInferenceTestPlan = builtPlan; + let plan: SetupInferenceTestPlan = params.agentDir + ? { ...builtPlan, agentDir: params.agentDir } + : builtPlan; if (params.authProfiles && params.authProfiles.length > 0) { const selectedProfile = plan.authProfileId ? params.authProfiles.find((profile) => profile.profileId === plan.authProfileId) diff --git a/src/wizard/setup.inference-verification.test.ts b/src/wizard/setup.inference-verification.test.ts new file mode 100644 index 000000000000..4d67bb57547f --- /dev/null +++ b/src/wizard/setup.inference-verification.test.ts @@ -0,0 +1,58 @@ +// Setup inference verification tests keep noninteractive imports prompt-free. +import { describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "./prompts.js"; + +const mocks = vi.hoisted(() => ({ + repair: vi.fn(), + verify: vi.fn(), +})); + +vi.mock("../system-agent/setup-inference.js", () => ({ + verifySetupInferenceConfig: mocks.verify, +})); +vi.mock("../agents/auth-profiles/store.js", () => ({ + updateAuthProfileStoreWithLock: vi.fn(), +})); +vi.mock("../state/openclaw-agent-db.js", () => ({ + disposeOpenClawAgentDatabaseByPath: vi.fn(), +})); +vi.mock("./setup.model-auth.js", () => ({ + runSetupModelAuthStep: mocks.repair, +})); + +import { offerLiveModelVerification } from "./setup.inference-verification.js"; + +describe("offerLiveModelVerification", () => { + it("does not enter interactive repair for a failed noninteractive import", async () => { + mocks.verify.mockResolvedValue({ ok: false, status: "auth", error: "credential expired" }); + const select = vi.fn(); + const prompter = { + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + confirm: vi.fn(), + select, + multiselect: vi.fn(), + text: vi.fn(), + progress: vi.fn(() => ({ stop: vi.fn(), update: vi.fn() })), + } as unknown as WizardPrompter; + + await expect( + offerLiveModelVerification({ + config: { agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } } }, + opts: { nonInteractive: true }, + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as never, + workspaceDir: "/tmp/openclaw-test-workspace", + writeConfig: async (config) => config, + required: true, + }), + ).resolves.toEqual({ + config: { agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } } }, + verified: false, + }); + + expect(select).not.toHaveBeenCalled(); + expect(mocks.repair).not.toHaveBeenCalled(); + }); +}); diff --git a/src/wizard/setup.inference-verification.ts b/src/wizard/setup.inference-verification.ts new file mode 100644 index 000000000000..f88d23214351 --- /dev/null +++ b/src/wizard/setup.inference-verification.ts @@ -0,0 +1,124 @@ +// Setup inference verification owns the shared verify/repair loop used by onboarding imports. +import type { OnboardOptions } from "../commands/onboard-types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { withConsoleSubsystemsSuppressed } from "../logging/console.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { t } from "./i18n/index.js"; +import type { WizardPrompter } from "./prompts.js"; +import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js"; + +export async function offerLiveModelVerification(params: { + config: OpenClawConfig; + opts: OnboardOptions; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir: string; + agentDir?: string; + stateDir?: string; + writeConfig: (config: OpenClawConfig) => Promise; + required?: boolean; +}): Promise<{ config: OpenClawConfig; verified: boolean; modelRef?: string }> { + if (!params.required) { + const shouldTest = await params.prompter.confirm({ + message: t("wizard.setup.testAiAccess"), + initialValue: true, + }); + if (!shouldTest) { + return { config: params.config, verified: false }; + } + } + const [inference, authStore, agentDatabase] = await Promise.all([ + import("../system-agent/setup-inference.js"), + import("../agents/auth-profiles/store.js"), + import("../state/openclaw-agent-db.js"), + ]); + const stagedEnv = params.stateDir + ? { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } + : undefined; + const verify = async (candidate: SetupModelAuthCandidate) => { + const progress = params.prompter.progress(t("wizard.setup.testAiProgress")); + const result = await withConsoleSubsystemsSuppressed(() => + inference.verifySetupInferenceConfig({ + config: candidate.config, + runtime: params.runtime, + authProfiles: candidate.authProfiles, + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.stateDir + ? { + deps: { + updateAuthProfileStoreWithLock: async (updateParams) => + await authStore.updateAuthProfileStoreWithLock({ + ...updateParams, + stateDir: params.stateDir, + }), + disposeOpenClawAgentDatabaseByPath: (pathname) => + agentDatabase.disposeOpenClawAgentDatabaseByPath(pathname, { + env: stagedEnv!, + }), + }, + } + : {}), + }), + ); + progress.stop(); + if (result.ok) { + await params.prompter.note( + t("wizard.setup.testAiSuccess", { seconds: (result.latencyMs / 1000).toFixed(1) }), + t("wizard.setup.testAiTitle"), + ); + } else { + await params.prompter.note( + t("wizard.setup.testAiFailure", { reason: result.error }), + t("wizard.setup.testAiTitle"), + ); + } + return result; + }; + + let candidate: SetupModelAuthCandidate = { + config: params.config, + authProfiles: [], + persistAuthProfiles: async () => {}, + }; + let shouldPersistCandidate = false; + while (true) { + const result = await verify(candidate); + if (result.ok) { + if (!shouldPersistCandidate) { + return { config: params.config, verified: true, modelRef: result.modelRef }; + } + await candidate.persistAuthProfiles(result.authProfiles); + const config = await params.writeConfig(candidate.config); + return { config, verified: true, modelRef: result.modelRef }; + } + if (result.authProfiles) { + candidate.authProfiles = result.authProfiles; + } + if (params.opts.nonInteractive) { + return { config: params.config, verified: false }; + } + if ( + !params.required && + (await params.prompter.select({ + message: t("wizard.setup.testAiFailureChoice"), + options: [ + { value: "fix", label: t("wizard.setup.testAiFix") }, + { value: "continue", label: t("wizard.setup.testAiContinue") }, + ], + })) === "continue" + ) { + return { config: params.config, verified: false }; + } + + candidate = await runSetupModelAuthStep({ + config: params.config, + stagedCandidate: candidate, + opts: { ...params.opts, authChoice: undefined }, + prompter: params.prompter, + runtime: params.runtime, + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.stateDir ? { stateDir: params.stateDir } : {}), + }); + shouldPersistCandidate = true; + } +} diff --git a/src/wizard/setup.migration-finalize.ts b/src/wizard/setup.migration-finalize.ts new file mode 100644 index 000000000000..6d2d45a9bf4d --- /dev/null +++ b/src/wizard/setup.migration-finalize.ts @@ -0,0 +1,264 @@ +// Setup migration finalization owns deferred activation, reporting, and terminal acknowledgement. +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { writeMigrationReport } from "../plugin-sdk/migration-runtime.js"; +import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; +import type { + MigrationApplyResult, + MigrationConfigRuntime, + MigrationPlan, + MigrationProviderContext, + MigrationProviderPlugin, +} from "../plugins/types.js"; +import { t } from "./i18n/index.js"; +import type { WizardPrompter } from "./prompts.js"; +import { + buildSetupMigrationPhasePlan, + mergeSetupMigrationPhaseResults, + type SetupMigrationPromotionOutcome, + type SetupMigrationPromotionResume, +} from "./setup.migration-stage.js"; + +type SetupMigrationImportOutcome = SetupMigrationPromotionOutcome & { + acknowledgePromotion?: () => Promise; +}; + +function withPromotionAcknowledgement( + outcome: SetupMigrationImportOutcome, + acknowledgePromotion: () => Promise, +): SetupMigrationImportOutcome { + Object.defineProperty(outcome, "acknowledgePromotion", { + value: acknowledgePromotion, + enumerable: false, + }); + return outcome; +} + +function hasDeferredMigrationItems(plan: MigrationPlan): boolean { + return plan.items.some( + (item) => item.applyPhase === "after-promotion" && item.status === "planned", + ); +} + +export function assertDeferredMigrationApplyContract( + provider: MigrationProviderPlugin, + plan: MigrationPlan, +): void { + if (hasDeferredMigrationItems(plan) && provider.deferredApply?.retrySafe !== true) { + throw new Error( + `Migration provider "${provider.id}" cannot defer activation during onboarding because it does not declare retry-safe deferred apply.`, + ); + } +} + +function deferredRetryInstruction(providerId: string): string { + return `Some post-promotion migration activation steps are still pending. Retry only those steps with openclaw onboard --flow import --import-from ${providerId}.`; +} + +function deferredMigrationFailure(plan: MigrationPlan, error: unknown): MigrationApplyResult { + const reason = formatErrorMessage(error); + const retry = deferredRetryInstruction(plan.providerId); + const items = plan.items.map((item) => + item.applyPhase === "after-promotion" && (item.status === "planned" || item.status === "error") + ? { ...item, status: "warning" as const, reason } + : item, + ); + return { + ...plan, + items, + summary: summarizeMigrationItems(items), + warnings: [...new Set([...(plan.warnings ?? []), retry])], + nextSteps: [...new Set([retry, ...(plan.nextSteps ?? [])])], + }; +} + +const COMPLETED_AFTER_PROMOTION_REASON = "completed after promotion"; + +function isCompletedDeferredMigrationItem(item: MigrationPlan["items"][number]): boolean { + return item.status === "migrated" || item.deferredCompletion === true; +} + +function buildPendingDeferredMigrationPlan( + plan: MigrationPlan, + result: MigrationApplyResult | undefined, +): MigrationPlan { + const completedItemIds = new Set( + result?.items + .filter( + (item) => item.applyPhase === "after-promotion" && isCompletedDeferredMigrationItem(item), + ) + .map((item) => item.id), + ); + const deferredPlan = buildSetupMigrationPhasePlan(plan, "after-promotion"); + const items = deferredPlan.items.map((item) => + completedItemIds.has(item.id) + ? { + ...item, + status: "skipped" as const, + reason: COMPLETED_AFTER_PROMOTION_REASON, + deferredCompletion: true as const, + } + : item, + ); + return { ...deferredPlan, items, summary: summarizeMigrationItems(items) }; +} + +function mergeDeferredMigrationResults(params: { + previous: MigrationApplyResult | undefined; + next: MigrationApplyResult; +}): MigrationApplyResult { + if (!params.previous) { + return params.next; + } + const previousById = new Map(params.previous.items.map((item) => [item.id, item])); + const items = params.next.items.map((item) => + item.status === "skipped" && item.reason === COMPLETED_AFTER_PROMOTION_REASON + ? (previousById.get(item.id) ?? item) + : item, + ); + const retry = deferredRetryInstruction(params.next.providerId); + return { + ...params.next, + items, + summary: summarizeMigrationItems(items), + warnings: [ + ...new Set([ + ...(params.previous.warnings ?? []).filter((warning) => warning !== retry), + ...(params.next.warnings ?? []), + ]), + ], + nextSteps: [ + ...new Set([ + ...(params.previous.nextSteps ?? []).filter((nextStep) => nextStep !== retry), + ...(params.next.nextSteps ?? []), + ]), + ], + }; +} + +function hasPendingDeferredMigrationItems( + plan: MigrationPlan, + result: MigrationApplyResult | undefined, +): boolean { + const resultById = new Map(result?.items.map((item) => [item.id, item])); + return plan.items.some( + (item) => + item.applyPhase === "after-promotion" && + item.status === "planned" && + !isCompletedDeferredMigrationItem(resultById.get(item.id) ?? item), + ); +} + +async function createPromotionConfigRuntime( + config: OpenClawConfig, +): Promise { + const { mutateConfigFile } = await import("../config/mutate.js"); + let currentConfig = structuredClone(config); + return { + current: () => currentConfig, + async mutateConfigFile(mutation) { + const result = await mutateConfigFile(mutation); + currentConfig = structuredClone(result.nextConfig); + return result; + }, + }; +} + +export async function finalizeSetupMigrationPromotion(params: { + provider: MigrationProviderPlugin; + resume: SetupMigrationPromotionResume; + config: OpenClawConfig; + stateDir: string; + logger: MigrationProviderContext["logger"]; + prompter: WizardPrompter; + formatMigrationResult: (result: MigrationApplyResult) => string[]; +}): Promise { + const { continuation } = params.resume; + const reportDir = path.dirname(params.resume.journalPath); + await params.resume.copyReportArtifacts(); + + const configRuntime = await createPromotionConfigRuntime(params.config); + let deferredResult = continuation.deferredResult; + if ( + hasDeferredMigrationItems(continuation.plan) && + hasPendingDeferredMigrationItems(continuation.plan, deferredResult) + ) { + const previousDeferredResult = deferredResult; + const deferredPlan = buildPendingDeferredMigrationPlan( + continuation.plan, + previousDeferredResult, + ); + let preparation: + | Awaited>> + | undefined; + let retryResult: MigrationApplyResult; + try { + const deferredContext: MigrationProviderContext = { + config: params.config, + configRuntime, + stateDir: params.stateDir, + logger: params.logger, + reportDir, + ...(continuation.source ? { source: continuation.source } : {}), + ...(continuation.includeSecrets !== undefined + ? { includeSecrets: continuation.includeSecrets } + : {}), + ...(continuation.providerOptions ? { providerOptions: continuation.providerOptions } : {}), + overwrite: false, + }; + preparation = await params.provider.prepareApply?.(deferredContext); + retryResult = mergeDeferredMigrationResults({ + previous: previousDeferredResult, + next: await params.provider.apply(deferredContext, deferredPlan), + }); + if (hasPendingDeferredMigrationItems(continuation.plan, retryResult)) { + retryResult = deferredMigrationFailure( + retryResult, + "activation did not complete every deferred item", + ); + } + } catch (error) { + retryResult = mergeDeferredMigrationResults({ + previous: previousDeferredResult, + next: deferredMigrationFailure(deferredPlan, error), + }); + } finally { + const disposable = preparation as { dispose?: () => void | Promise } | undefined; + await disposable?.dispose?.(); + } + deferredResult = retryResult; + await params.resume.saveDeferredResult(deferredResult); + } + + const finalResult = mergeSetupMigrationPhaseResults({ + plan: continuation.plan, + staged: continuation.stagedResult, + ...(deferredResult ? { deferred: deferredResult } : {}), + }); + finalResult.reportDir = reportDir; + await writeMigrationReport(finalResult, { + title: `${continuation.providerLabel} Migration Report`, + }); + + const hasPendingActivation = hasPendingDeferredMigrationItems(continuation.plan, deferredResult); + if (!hasPendingActivation) { + await params.resume.complete(); + } + await params.resume.cleanup(); + await params.prompter.note( + params.formatMigrationResult(finalResult).join("\n"), + t("wizard.migration.appliedTitle"), + ); + if (!continuation.continueOnboarding) { + await params.prompter.outro(t("wizard.migration.complete")); + } else { + await params.prompter.note( + t("wizard.migration.continuing"), + t("wizard.migration.appliedTitle"), + ); + } + return hasPendingActivation + ? continuation.outcome + : withPromotionAcknowledgement(continuation.outcome, params.resume.acknowledge); +} diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index 9b08e20dbb7a..d5fdf794b490 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -3,6 +3,7 @@ import { ensureOnboardingPluginInstalled, type OnboardingPluginInstallEntry, } from "../commands/onboarding-plugin-install.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { @@ -26,14 +27,11 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +import { offerLiveModelVerification } from "./setup.inference-verification.js"; import { - createSetupMigrationAttempt, - prepareSetupMigrationRetryPlan, - resolveSetupMigrationRecovery, - runSetupMigrationAttempt, - setupMigrationAttemptMatchesSource, - setupMigrationProviderSupportsRecovery, -} from "./setup.migration-recovery.js"; + assertDeferredMigrationApplyContract, + finalizeSetupMigrationPromotion, +} from "./setup.migration-finalize.js"; import { assertFreshSetupMigrationTarget, buildSetupMigrationPlanSourceSnapshot, @@ -43,8 +41,13 @@ import { prepareSetupMigrationAttemptBoundary, withSetupMigrationTargetLock, } from "./setup.migration-snapshot.js"; +import { + buildSetupMigrationPhasePlan, + createSetupMigrationStage, + recoverSetupMigrationPromotion, + type SetupMigrationPromotionOutcome, +} from "./setup.migration-stage.js"; -// Onboarding migration import: detect, preview, back up, and apply into a fresh setup. type SetupMigrationDetection = { providerId: string; label: string; @@ -366,20 +369,21 @@ export async function runSetupMigrationImport(params: { prompter: WizardPrompter; runtime: RuntimeEnv; readConfigFile: () => Promise; - commitConfigFile: (config: OpenClawConfig) => Promise; + commitConfigFile: ( + config: OpenClawConfig, + expectedConfig: OpenClawConfig, + ) => Promise; continueOnboarding?: boolean; -}): Promise { +}): Promise>> { const [ { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig }, { createMigrationLogger, buildMigrationReportDir }, - { createPreMigrationBackup }, { assertApplySucceeded, assertConflictFreePlan, formatMigrationPreview, formatMigrationResult }, { resolveStateDir }, onboardHelpers, ] = await Promise.all([ import("../commands/onboard-config.js"), loadMigrationContextModule(), - import("../commands/migrate/apply.js"), import("../commands/migrate/output.js"), loadConfigPathsModule(), import("../commands/onboard-helpers.js"), @@ -401,34 +405,45 @@ export async function runSetupMigrationImport(params: { })); const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE); const stateDir = resolveStateDir(); - await withSetupMigrationTargetLock(stateDir, async () => { + return await withSetupMigrationTargetLock(stateDir, async () => { + const promotionResume = await recoverSetupMigrationPromotion({ + stateDir, + providerId, + readConfigFile: params.readConfigFile, + }); + if (promotionResume) { + const committedConfig = await params.readConfigFile(); + const resolvedProvider = await resolveSetupMigrationProvider({ + providerId, + baseConfig: committedConfig, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: promotionResume.continuation.workspaceDir, + }); + assertDeferredMigrationApplyContract( + resolvedProvider.provider, + promotionResume.continuation.plan, + ); + return await finalizeSetupMigrationPromotion({ + provider: resolvedProvider.provider, + resume: promotionResume, + config: committedConfig, + stateDir, + logger: createMigrationLogger(params.runtime), + prompter: params.prompter, + formatMigrationResult, + }); + } const lockedBaseConfig = preserveSetupMigrationSecurityAcknowledgement( await params.readConfigFile(), params.baseConfig, ); - const initialTargetSnapshotHash = await buildSetupMigrationTargetSnapshot({ - config: lockedBaseConfig, - stateDir, - workspaceDir, - }); const freshness = await inspectSetupMigrationFreshness({ baseConfig: lockedBaseConfig, stateDir, workspaceDir, }); - const recoveryState = !setupMigrationProviderSupportsRecovery(providerId) - ? ({ kind: "none" } as const) - : await resolveSetupMigrationRecovery({ - stateDir, - providerId, - workspaceDir, - targetSnapshotHash: initialTargetSnapshotHash, - }); - const recoveryAttempt = - !freshness.fresh && recoveryState.kind === "recoverable" ? recoveryState.attempt : undefined; - if (!recoveryAttempt) { - assertFreshSetupMigrationTarget(freshness); - } + assertFreshSetupMigrationTarget(freshness); const resolvedProvider = await resolveSetupMigrationProvider({ providerId, baseConfig: lockedBaseConfig, @@ -483,19 +498,11 @@ export async function runSetupMigrationImport(params: { message: t("wizard.migration.sourceAgentHome"), initialValue: providerId === "hermes" ? "~/.hermes" : undefined, })); - const retryingFailedAttempt = - recoveryAttempt !== undefined && - setupMigrationAttemptMatchesSource(recoveryAttempt, sourceDir); - if (!retryingFailedAttempt) { - assertFreshSetupMigrationTarget(freshness); - } else if (planningTargetSnapshotHash !== initialTargetSnapshotHash) { - throw new Error("Migration target changed while preparing the retry. Review it and retry."); - } let targetConfig = applyLocalSetupWorkspaceConfig(resolvedProvider.baseConfig, workspaceDir); if (params.opts.skipBootstrap) { targetConfig = applySkipBootstrapConfig(targetConfig); } - const initialCtx = { + const initialCtx: MigrationProviderContext = { config: targetConfig, stateDir, source: sourceDir, @@ -511,10 +518,8 @@ export async function runSetupMigrationImport(params: { }); const plannedSourceSnapshotHash = await buildSetupMigrationPlanSourceSnapshot(planned.plan); const ctx = planned.ctx; - const plan = - retryingFailedAttempt && recoveryAttempt - ? prepareSetupMigrationRetryPlan(planned.plan, recoveryAttempt, plannedSourceSnapshotHash) - : planned.plan; + const plan = planned.plan; + assertDeferredMigrationApplyContract(resolvedProvider.provider, plan); await params.prompter.note( formatMigrationPreview(plan).join("\n"), t("wizard.migration.previewTitle"), @@ -532,74 +537,122 @@ export async function runSetupMigrationImport(params: { throw new WizardCancelledError(t("wizard.migration.cancelled")); } - const reportDir = buildMigrationReportDir(providerId, stateDir); - const backupPath = await createPreMigrationBackup({}); targetConfig = onboardHelpers.applyWizardMetadata(targetConfig, { command: "onboard", mode: "local", }); - const boundary = await prepareSetupMigrationAttemptBoundary({ + await prepareSetupMigrationAttemptBoundary({ currentConfig: await params.readConfigFile(), targetConfig, stateDir, workspaceDir, - plan: planned.plan, + plan, expectedTargetSnapshotHash: planningTargetSnapshotHash, expectedSourceSnapshotHash: plannedSourceSnapshotHash, }); - const attempt = createSetupMigrationAttempt({ + const reportDir = buildMigrationReportDir(providerId, stateDir); + const stage = await createSetupMigrationStage({ providerId, - source: sourceDir, + stateDir, workspaceDir, - plan, - sourceSnapshotHash: boundary.sourceSnapshotHash, - preparedTargetSnapshotHash: boundary.preparedTargetSnapshotHash, - targetSnapshotHash: boundary.targetSnapshotHash, - ...(recoveryAttempt ? { previousAttempt: recoveryAttempt } : {}), - }); - const withReport = await runSetupMigrationAttempt({ reportDir, - attempt, - assertSucceeded: assertApplySucceeded, - async readTargetSnapshot() { - return await buildSetupMigrationTargetSnapshot({ + targetConfig, + }); + try { + const stagedPlan = stage.projectPlanToStage( + buildSetupMigrationPhasePlan(plan, "before-promotion"), + ); + const stagedRuntime = ctx.runtime + ? { + ...ctx.runtime, + config: { + ...ctx.runtime.config, + current: stage.configRuntime.current, + mutateConfigFile: stage.configRuntime.mutateConfigFile, + replaceConfigFile: async () => { + throw new Error("Full config replacement is unavailable during staged migration."); + }, + }, + } + : undefined; + const stagedResult = await resolvedProvider.provider.apply( + { + ...ctx, + ...(stagedRuntime ? { runtime: stagedRuntime } : {}), + config: stage.getStagedConfig(), + configRuntime: stage.configRuntime, + stateDir: stage.staged.stateDir, + reportDir: stage.staged.reportDir, + }, + stagedPlan, + ); + assertApplySucceeded(stagedResult); + const projectedStagedResult = stage.projectResultToFinal(stagedResult); + + let outcome: SetupMigrationPromotionOutcome = { kind: "no-imported-inference" }; + if (resolveAgentModelPrimaryValue(stage.getStagedConfig().agents?.defaults?.model)) { + const verification = await offerLiveModelVerification({ + config: stage.getStagedConfig(), + opts: params.opts, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: stage.staged.workspaceDir, + agentDir: stage.staged.agentDir, + stateDir: stage.staged.stateDir, + writeConfig: async (config) => { + stage.replaceStagedConfig(config); + return stage.getStagedConfig(); + }, + required: true, + }); + if (!verification.verified || !verification.modelRef) { + throw new Error("Imported inference was not verified."); + } + stage.replaceStagedConfig(verification.config); + outcome = { kind: "verified-inference", modelRef: verification.modelRef }; + } + + const [currentTargetSnapshotHash, currentSourceSnapshotHash] = await Promise.all([ + buildSetupMigrationTargetSnapshot({ config: await params.readConfigFile(), stateDir, workspaceDir, - }); - }, - async apply() { - targetConfig = await params.commitConfigFile(targetConfig); - // Provider config mutations persist; recommitting targetConfig would overwrite them. - const result = await resolvedProvider.provider.apply( - { - ...ctx, - config: targetConfig, - ...(backupPath ? { backupPath } : {}), - reportDir, - }, + }), + buildSetupMigrationPlanSourceSnapshot(plan), + ]); + if (currentTargetSnapshotHash !== planningTargetSnapshotHash) { + throw new Error("Migration target changed before promotion. Review it and retry."); + } + if (currentSourceSnapshotHash !== plannedSourceSnapshotHash) { + throw new Error("Migration source changed before promotion. Review it and retry."); + } + + const promoted = await stage.promote({ + expectedConfig: planningBaseConfig, + continuation: { + providerLabel: resolvedProvider.provider.label, + ...(ctx.source ? { source: ctx.source } : {}), + ...(ctx.includeSecrets !== undefined ? { includeSecrets: ctx.includeSecrets } : {}), + ...(ctx.providerOptions ? { providerOptions: ctx.providerOptions } : {}), plan, - ); - return { - ...result, - ...((result.backupPath ?? backupPath) - ? { backupPath: result.backupPath ?? backupPath } - : {}), - reportDir: result.reportDir ?? reportDir, - }; - }, - }); - await params.prompter.note( - formatMigrationResult(withReport).join("\n"), - t("wizard.migration.appliedTitle"), - ); - if (params.continueOnboarding) { - await params.prompter.note( - t("wizard.migration.continuing"), - t("wizard.migration.appliedTitle"), - ); - } else { - await params.prompter.outro(t("wizard.migration.complete")); + stagedResult: projectedStagedResult, + outcome, + continueOnboarding: params.continueOnboarding === true, + }, + readConfigFile: params.readConfigFile, + commitConfigFile: params.commitConfigFile, + }); + return await finalizeSetupMigrationPromotion({ + provider: resolvedProvider.provider, + resume: promoted.resume, + config: promoted.config, + stateDir, + logger: migrationLogger, + prompter: params.prompter, + formatMigrationResult, + }); + } finally { + await stage.cleanup(); } }); } diff --git a/src/wizard/setup.migration-promotion.ts b/src/wizard/setup.migration-promotion.ts new file mode 100644 index 000000000000..327a61a688b9 --- /dev/null +++ b/src/wizard/setup.migration-promotion.ts @@ -0,0 +1,546 @@ +// Setup migration promotion owns durable journals, rollback, and path validation. +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readDurableJsonFile, writeJsonAtomic } from "../infra/json-files.js"; +import { isNotFoundPathError } from "../infra/path-guards.js"; +import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; + +export const PROMOTION_JOURNAL_FILE = "onboarding-promotion.json"; +export const PROMOTION_JOURNAL_VERSION = 1; + +type PromotionStatus = + | "prepared" + | "promoting" + | "committed" + | "completed" + | "rolled-back" + | "indeterminate"; +export type PromotionComponent = { + name: "workspace" | "agent" | "state"; + stagedPath: string; + finalPath: string; + status: "staged" | "promoted" | "rolled-back"; + targetWasEmptyDirectory?: boolean; + emptyTargetBackupPath?: string; + createdParentPaths?: string[]; +}; +export type SetupMigrationPromotionOutcome = + | { kind: "verified-inference"; modelRef: string } + | { kind: "no-imported-inference" }; + +export type SetupMigrationPromotionContinuation = { + providerLabel: string; + source?: string; + includeSecrets?: boolean; + providerOptions?: Record; + plan: MigrationPlan; + stagedResult: MigrationApplyResult; + deferredResult?: MigrationApplyResult; + outcome: SetupMigrationPromotionOutcome; + continueOnboarding: boolean; + workspaceDir: string; + stagedReportDir: string; + stagedRoots: string[]; +}; + +export type PromotionJournal = { + version: typeof PROMOTION_JOURNAL_VERSION; + status: PromotionStatus; + providerId: string; + configHashBefore: string; + configHashTarget: string; + components: PromotionComponent[]; + continuation?: SetupMigrationPromotionContinuation; + updatedAt: string; +}; + +export type SetupMigrationPromotionResume = { + journalPath: string; + continuation: SetupMigrationPromotionContinuation; + copyReportArtifacts: () => Promise; + saveDeferredResult: (result: MigrationApplyResult) => Promise; + complete: () => Promise; + acknowledge: () => Promise; + cleanup: () => Promise; +}; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (!value || typeof value !== "object") { + return value; + } + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .toSorted() + .filter((key) => record[key] !== undefined) + .map((key) => [key, canonicalize(record[key])]), + ); +} + +function hashConfig(config: OpenClawConfig): string { + return crypto + .createHash("sha256") + .update(JSON.stringify(canonicalize(config))) + .digest("hex"); +} + +async function pathExists(candidate: string): Promise { + try { + await fs.lstat(candidate); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } +} + +async function readLatestPromotionJournal(params: { + stateDir: string; + providerId: string; +}): Promise<{ path: string; journal: PromotionJournal } | undefined> { + const root = path.join(params.stateDir, "migration", params.providerId); + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if (isNotFoundPathError(error)) { + return undefined; + } + throw error; + } + for (const entry of entries + .filter((candidate) => candidate.isDirectory()) + .toSorted((left, right) => right.name.localeCompare(left.name))) { + const journalPath = path.join(root, entry.name, PROMOTION_JOURNAL_FILE); + const value = await readDurableJsonFile(journalPath); + if (value?.version === PROMOTION_JOURNAL_VERSION && value.providerId === params.providerId) { + return { path: journalPath, journal: value }; + } + } + return undefined; +} + +export async function writePromotionJournal( + journalPath: string, + journal: PromotionJournal, +): Promise { + await writeJsonAtomic( + journalPath, + { ...journal, updatedAt: new Date().toISOString() }, + { mode: 0o600, dirMode: 0o700, trailingNewline: true }, + ); +} + +async function copyPromotionReportArtifacts(params: { + stagedReportDir: string; + reportDir: string; +}): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(params.stagedReportDir, { withFileTypes: true }); + } catch (error) { + if (isNotFoundPathError(error)) { + return; + } + throw error; + } + await fs.mkdir(params.reportDir, { recursive: true, mode: 0o700 }); + for (const entry of entries) { + if ( + entry.name === "report.json" || + entry.name === "summary.md" || + entry.name === PROMOTION_JOURNAL_FILE + ) { + continue; + } + await fs.cp( + path.join(params.stagedReportDir, entry.name), + path.join(params.reportDir, entry.name), + { recursive: true, force: true }, + ); + } +} + +async function cleanupPromotionStaging(continuation: SetupMigrationPromotionContinuation) { + await Promise.all( + continuation.stagedRoots.map( + async (root) => await fs.rm(root, { recursive: true, force: true }), + ), + ); +} + +export function createPromotionResume( + journalPath: string, + journal: PromotionJournal, +): SetupMigrationPromotionResume { + const continuation = journal.continuation; + if (!continuation) { + throw new Error(`Onboarding migration continuation is missing from ${journalPath}.`); + } + return { + journalPath, + continuation, + copyReportArtifacts: async () => + await copyPromotionReportArtifacts({ + stagedReportDir: continuation.stagedReportDir, + reportDir: path.dirname(journalPath), + }), + async saveDeferredResult(result) { + continuation.deferredResult = result; + await writePromotionJournal(journalPath, journal); + }, + async complete() { + journal.status = "completed"; + await writePromotionJournal(journalPath, journal); + }, + async acknowledge() { + await Promise.all( + journal.components.map(async (component) => { + if (component.emptyTargetBackupPath) { + await fs.rm(component.emptyTargetBackupPath, { recursive: true, force: true }); + } + }), + ); + await fs.rm(journalPath, { force: true }); + }, + cleanup: async () => await cleanupPromotionStaging(continuation), + }; +} + +async function removeCreatedPromotionParents(components: PromotionComponent[]): Promise { + const parents = [ + ...new Set(components.flatMap((component) => component.createdParentPaths ?? [])), + ].toSorted((left, right) => right.length - left.length || right.localeCompare(left)); + for (const parent of parents) { + try { + await fs.rmdir(parent); + } catch (error) { + if (isNotFoundPathError(error)) { + continue; + } + throw error; + } + } +} + +export async function rollbackComponents(components: PromotionComponent[]): Promise { + try { + for (const component of components.toReversed()) { + const stagedExists = await pathExists(component.stagedPath); + const finalExists = await pathExists(component.finalPath); + const backupExists = component.emptyTargetBackupPath + ? await pathExists(component.emptyTargetBackupPath) + : false; + if (!stagedExists && !finalExists) { + return false; + } + if (finalExists && !stagedExists) { + await fs.mkdir(path.dirname(component.stagedPath), { recursive: true, mode: 0o700 }); + await fs.rename(component.finalPath, component.stagedPath); + } else if (finalExists && stagedExists) { + if ( + backupExists || + !component.targetWasEmptyDirectory || + (await fs.readdir(component.finalPath)).length > 0 + ) { + return false; + } + } + if (backupExists) { + if (await pathExists(component.finalPath)) { + return false; + } + await fs.rename(component.emptyTargetBackupPath!, component.finalPath); + } else if (component.targetWasEmptyDirectory) { + await fs.mkdir(component.finalPath, { recursive: true, mode: 0o700 }); + } + component.status = "rolled-back"; + } + await removeCreatedPromotionParents(components); + return true; + } catch { + return false; + } +} + +async function hasPublishedPromotionComponent(components: PromotionComponent[]): Promise { + for (const component of components) { + if (component.status === "promoted") { + return true; + } + const [stagedExists, finalExists] = await Promise.all([ + pathExists(component.stagedPath), + pathExists(component.finalPath), + ]); + if (!stagedExists && finalExists) { + return true; + } + } + return false; +} + +/** Reconciles interrupted promotion and returns any committed finalization to resume. */ +export async function recoverSetupMigrationPromotion(params: { + stateDir: string; + providerId: string; + readConfigFile: () => Promise; +}): Promise { + const found = await readLatestPromotionJournal(params); + if (!found) { + return undefined; + } + const journal = found.journal; + if (journal.status === "rolled-back") { + if (journal.continuation) { + await cleanupPromotionStaging(journal.continuation); + } + return undefined; + } + if (journal.status === "indeterminate") { + throw new Error( + `An onboarding migration promotion is indeterminate. Review ${found.path} and run openclaw doctor before retrying.`, + ); + } + const currentConfigHash = hashConfig(await params.readConfigFile()); + const allFinal = ( + await Promise.all(journal.components.map((component) => pathExists(component.finalPath))) + ).every(Boolean); + if (journal.status === "completed") { + return createPromotionResume(found.path, journal); + } + if (journal.status === "committed") { + if (allFinal) { + return createPromotionResume(found.path, journal); + } + journal.status = "indeterminate"; + await writePromotionJournal(found.path, journal); + throw new Error( + `A committed onboarding migration no longer matches its promoted target. Review ${found.path} and run openclaw doctor before retrying.`, + ); + } + if (currentConfigHash === journal.configHashTarget && allFinal) { + journal.status = "committed"; + await writePromotionJournal(found.path, journal); + return createPromotionResume(found.path, journal); + } + if (currentConfigHash === journal.configHashBefore) { + if (await hasPublishedPromotionComponent(journal.components)) { + journal.status = "indeterminate"; + await writePromotionJournal(found.path, journal); + throw new Error( + `An interrupted onboarding migration published local data before config commit. Review ${found.path} and run openclaw doctor before retrying.`, + ); + } + if (await rollbackComponents(journal.components)) { + journal.status = "rolled-back"; + await writePromotionJournal(found.path, journal); + if (journal.continuation) { + await cleanupPromotionStaging(journal.continuation); + } + return undefined; + } + } + journal.status = "indeterminate"; + await writePromotionJournal(found.path, journal); + throw new Error( + `Could not reconcile an interrupted onboarding migration. Review ${found.path} and run openclaw doctor before retrying.`, + ); +} + +async function listMissingPromotionParents(target: string): Promise { + const missing: string[] = []; + let current = path.dirname(target); + while (!(await pathExists(current))) { + missing.push(current); + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Could not find an existing parent for migration promotion at ${target}.`); + } + current = parent; + } + return missing; +} + +async function reserveEmptyTargetBackupPath(target: string): Promise { + const reserved = await fs.mkdtemp(path.join(path.dirname(target), ".openclaw-migration-empty-")); + await fs.rmdir(reserved); + return reserved; +} + +export async function recordPromotionTargetState(component: PromotionComponent): Promise { + component.createdParentPaths = await listMissingPromotionParents(component.finalPath); + if (!(await pathExists(component.finalPath))) { + return; + } + const stat = await fs.lstat(component.finalPath); + if (!stat.isDirectory() || (await fs.readdir(component.finalPath)).length > 0) { + throw new Error(`Migration target changed before promotion: ${component.finalPath}`); + } + component.targetWasEmptyDirectory = true; + component.emptyTargetBackupPath = await reserveEmptyTargetBackupPath(component.finalPath); +} + +export async function moveRecordedEmptyTarget(component: PromotionComponent): Promise { + if (!component.targetWasEmptyDirectory) { + return; + } + const entries = await fs.readdir(component.finalPath); + if (entries.length > 0) { + throw new Error(`Migration target changed before promotion: ${component.finalPath}`); + } + if (component.emptyTargetBackupPath) { + await fs.rename(component.finalPath, component.emptyTargetBackupPath); + } else { + await fs.rmdir(component.finalPath); + } +} + +async function usesCaseInsensitivePaths(directory: string): Promise { + const probe = await fs.mkdtemp(path.join(directory, ".openclaw-case-probe-")); + try { + const alias = path.join(path.dirname(probe), path.basename(probe).toUpperCase()); + if (alias === probe) { + return false; + } + await fs.access(alias); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } finally { + await fs.rm(probe, { recursive: true, force: true }); + } +} + +async function usesNormalizationInsensitivePaths(directory: string): Promise { + const probe = await fs.mkdtemp(path.join(directory, ".openclaw-normalization-é-")); + try { + const alias = path.join(path.dirname(probe), path.basename(probe).normalize("NFD")); + if (alias === probe) { + return false; + } + await fs.access(alias); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } finally { + await fs.rm(probe, { recursive: true, force: true }); + } +} + +async function canonicalizePromotionPath( + candidate: string, +): Promise<{ path: string; caseInsensitive: boolean; normalizationInsensitive: boolean }> { + const suffix: string[] = []; + let current = path.resolve(candidate); + while (true) { + try { + const ancestor = await fs.realpath(current); + const probeDirectory = (await fs.stat(ancestor)).isDirectory() + ? ancestor + : path.dirname(ancestor); + return { + path: path.join(ancestor, ...suffix.toReversed()), + caseInsensitive: await usesCaseInsensitivePaths(probeDirectory), + normalizationInsensitive: await usesNormalizationInsensitivePaths(probeDirectory), + }; + } catch (error) { + if (!isNotFoundPathError(error)) { + throw error; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Could not resolve a promotion target for ${candidate}.`, { cause: error }); + } + suffix.push(path.basename(current)); + current = parent; + } + } +} + +function pathsOverlap(left: string, right: string): boolean { + const relative = path.relative(left, right); + return ( + relative.length === 0 || + (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + ); +} + +export async function assertSupportedStagedStateTree(params: { + stagedStateDir: string; + agentId: string; + providerId: string; + reportDirName: string; +}): Promise { + const assertEntries = async (directory: string, allowed: ReadonlySet) => { + let entries: string[]; + try { + entries = await fs.readdir(directory); + } catch (error) { + if (isNotFoundPathError(error)) { + return; + } + throw error; + } + const unexpected = entries.filter((entry) => !allowed.has(entry)); + if (unexpected.length > 0) { + throw new Error( + `Migration provider wrote unsupported staged state: ${unexpected + .map((entry) => path.join(directory, entry)) + .join(", ")}.`, + ); + } + }; + await assertEntries(params.stagedStateDir, new Set(["agents", "migration", "state"])); + await assertEntries(path.join(params.stagedStateDir, "agents"), new Set([params.agentId])); + await assertEntries( + path.join(params.stagedStateDir, "agents", params.agentId), + new Set(["agent"]), + ); + await assertEntries(path.join(params.stagedStateDir, "migration"), new Set([params.providerId])); + await assertEntries( + path.join(params.stagedStateDir, "migration", params.providerId), + new Set([params.reportDirName]), + ); +} + +export async function assertDisjointPromotionTargets( + components: ReadonlyArray>, +): Promise { + const canonicalPaths = await Promise.all( + components.map(async (component) => ({ + component, + path: await canonicalizePromotionPath(component.finalPath), + })), + ); + for (const [index, current] of canonicalPaths.entries()) { + for (const other of canonicalPaths.slice(index + 1)) { + const caseInsensitive = current.path.caseInsensitive || other.path.caseInsensitive; + const normalizationInsensitive = + current.path.normalizationInsensitive || other.path.normalizationInsensitive; + const normalizePath = (pathname: string) => { + const normalized = normalizationInsensitive ? pathname.normalize("NFC") : pathname; + return caseInsensitive ? normalized.toLocaleLowerCase("en-US") : normalized; + }; + const currentPath = normalizePath(current.path.path); + const otherPath = normalizePath(other.path.path); + if (pathsOverlap(currentPath, otherPath) || pathsOverlap(otherPath, currentPath)) { + throw new Error( + `Migration promotion targets overlap: ${current.component.finalPath} and ${other.component.finalPath}.`, + ); + } + } + } +} diff --git a/src/wizard/setup.migration-recovery.test.ts b/src/wizard/setup.migration-recovery.test.ts deleted file mode 100644 index 4e6e5ca92478..000000000000 --- a/src/wizard/setup.migration-recovery.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { createMigrationItem, summarizeMigrationItems } from "../plugin-sdk/migration.js"; -import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; -import { - createSetupMigrationAttempt, - prepareSetupMigrationRetryPlan, - resolveSetupMigrationRecovery, - runSetupMigrationAttempt, - setupMigrationAttemptMatchesSource, - setupMigrationProviderSupportsRecovery, -} from "./setup.migration-recovery.js"; -import { - buildSetupMigrationPlanSourceSnapshot, - buildSetupMigrationTargetSnapshot, -} from "./setup.migration-snapshot.js"; - -const tempRoots = useAutoCleanupTempDirTracker(afterEach); -const BEFORE_HASH = "a".repeat(64); -const AFTER_HASH = "b".repeat(64); -const CHANGED_HASH = "c".repeat(64); -const SOURCE_HASH = "d".repeat(64); - -async function makeTempRoot(): Promise { - return tempRoots.make("openclaw-setup-recovery-"); -} - -function buildPlan(root: string): MigrationPlan { - const items = [ - createMigrationItem({ - id: "config:mcp", - kind: "config", - action: "merge", - status: "planned", - target: "mcp.servers.safe", - details: { path: ["mcp", "servers"], value: { safe: { command: "echo" } } }, - }), - createMigrationItem({ - id: "workspace:SOUL.md", - kind: "workspace", - action: "copy", - status: "planned", - source: path.join(root, "hermes", "SOUL.md"), - target: path.join(root, "workspace", "SOUL.md"), - }), - createMigrationItem({ - id: "workspace:AGENTS.md", - kind: "workspace", - action: "copy", - status: "planned", - source: path.join(root, "hermes", "AGENTS.md"), - target: path.join(root, "workspace", "AGENTS.md"), - }), - createMigrationItem({ - id: "archive:state.db", - kind: "archive", - action: "archive", - status: "planned", - source: path.join(root, "hermes", "state.db"), - }), - ]; - return { - providerId: "hermes", - source: path.join(root, "hermes"), - target: path.join(root, "workspace"), - items, - summary: summarizeMigrationItems(items), - }; -} - -function buildFailedResult(plan: MigrationPlan): MigrationApplyResult { - const items = [ - { ...plan.items[0]!, status: "migrated" as const }, - { ...plan.items[1]!, status: "error" as const, reason: "permission denied" }, - { ...plan.items[2]!, status: "skipped" as const, reason: "not attempted" }, - { ...plan.items[3]!, status: "migrated" as const }, - ]; - return { ...plan, items, summary: summarizeMigrationItems(items) }; -} - -async function persistFailedSetupMigrationAttempt(params: { - reportDir: string; - attempt: ReturnType; - targetSnapshotHash: string; - result?: MigrationApplyResult; -}): Promise { - const failure = new Error("expected setup migration failure"); - await expect( - runSetupMigrationAttempt({ - reportDir: params.reportDir, - attempt: params.attempt, - apply: async () => { - if (!params.result) { - throw failure; - } - return params.result; - }, - assertSucceeded: () => { - throw failure; - }, - readTargetSnapshot: async () => params.targetSnapshotHash, - }), - ).rejects.toThrow(failure.message); -} - -async function persistSucceededSetupMigrationAttempt(params: { - reportDir: string; - attempt: ReturnType; - result: MigrationApplyResult; -}): Promise { - await runSetupMigrationAttempt({ - reportDir: params.reportDir, - attempt: params.attempt, - apply: async () => params.result, - assertSucceeded: () => {}, - readTargetSnapshot: async () => { - throw new Error("successful migration should not read the failure snapshot"); - }, - }); -} - -function createDeferred() { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -describe("setup migration recovery", () => { - it("limits recovery to the audited Hermes provider contract", () => { - expect(setupMigrationProviderSupportsRecovery("hermes")).toBe(true); - expect(setupMigrationProviderSupportsRecovery("other")).toBe(false); - }); - - it("retries only unchanged incomplete items against the exact failed target", async () => { - const stateDir = await makeTempRoot(); - const identity = { - providerId: "hermes", - source: path.join(stateDir, "hermes"), - workspaceDir: path.join(stateDir, "workspace"), - }; - const plan = buildPlan(stateDir); - const failed = createSetupMigrationAttempt( - { - ...identity, - plan, - sourceSnapshotHash: SOURCE_HASH, - targetSnapshotHash: BEFORE_HASH, - }, - new Date("2026-07-13T10:00:00Z"), - ); - const failedReportDir = path.join(stateDir, "migration", "hermes", "2026-07-13T10-00-00Z"); - await persistFailedSetupMigrationAttempt({ - reportDir: failedReportDir, - attempt: failed, - result: buildFailedResult(plan), - targetSnapshotHash: AFTER_HASH, - }); - - const recovery = await resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: AFTER_HASH, - }); - expect(recovery.kind).toBe("recoverable"); - if (recovery.kind !== "recoverable") { - throw new Error("expected recoverable attempt"); - } - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: CHANGED_HASH, - }), - ).resolves.toEqual({ kind: "none" }); - expect(setupMigrationAttemptMatchesSource(recovery.attempt, identity.source)).toBe(true); - expect( - setupMigrationAttemptMatchesSource(recovery.attempt, path.join(stateDir, "other-hermes")), - ).toBe(false); - - const retryItems = [ - { ...plan.items[0]!, status: "conflict" as const, reason: "target exists" }, - plan.items[1]!, - plan.items[2]!, - plan.items[3]!, - ]; - const retryPlan = { ...plan, items: retryItems, summary: summarizeMigrationItems(retryItems) }; - const prepared = prepareSetupMigrationRetryPlan(retryPlan, recovery.attempt, SOURCE_HASH); - expect(prepared.items.map((item) => item.status)).toEqual([ - "skipped", - "planned", - "planned", - "planned", - ]); - expect(prepared.items[0]?.reason).toContain("previous onboarding import attempt"); - - const retry = createSetupMigrationAttempt({ - ...identity, - plan: prepared, - sourceSnapshotHash: SOURCE_HASH, - targetSnapshotHash: AFTER_HASH, - previousAttempt: recovery.attempt, - }); - expect(retry.items.map((item) => item.resultStatus)).toEqual([ - "migrated", - "error", - "skipped", - "migrated", - ]); - - const retryResultItems = [ - prepared.items[0]!, - { ...prepared.items[1]!, status: "error" as const, reason: "still denied" }, - { ...prepared.items[2]!, status: "skipped" as const, reason: "not attempted" }, - { ...prepared.items[3]!, status: "migrated" as const }, - ]; - await persistFailedSetupMigrationAttempt({ - reportDir: path.join(stateDir, "migration", "hermes", "2026-07-13T10-30-00Z"), - attempt: retry, - result: { - ...prepared, - items: retryResultItems, - summary: summarizeMigrationItems(retryResultItems), - }, - targetSnapshotHash: CHANGED_HASH, - }); - const repeatedRecovery = await resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: CHANGED_HASH, - }); - expect(repeatedRecovery.kind).toBe("recoverable"); - if (repeatedRecovery.kind !== "recoverable") { - throw new Error("expected repeated recovery attempt"); - } - expect( - prepareSetupMigrationRetryPlan(retryPlan, repeatedRecovery.attempt, SOURCE_HASH).items.map( - (item) => item.status, - ), - ).toEqual(["skipped", "planned", "planned", "planned"]); - - const changedItems = [ - plan.items[0]!, - { ...plan.items[1]!, target: path.join(stateDir, "other-workspace", "SOUL.md") }, - ]; - expect(() => - prepareSetupMigrationRetryPlan( - { ...plan, items: changedItems, summary: summarizeMigrationItems(changedItems) }, - recovery.attempt, - SOURCE_HASH, - ), - ).toThrow("Migration retry plan changed"); - expect(() => prepareSetupMigrationRetryPlan(retryPlan, recovery.attempt, CHANGED_HASH)).toThrow( - "Migration source changed", - ); - expect(() => - prepareSetupMigrationRetryPlan( - { ...retryPlan, metadata: { changed: true } }, - recovery.attempt, - SOURCE_HASH, - ), - ).toThrow("Migration retry plan context changed"); - - const succeeded = createSetupMigrationAttempt({ - ...identity, - plan: prepared, - sourceSnapshotHash: SOURCE_HASH, - targetSnapshotHash: AFTER_HASH, - previousAttempt: recovery.attempt, - }); - await persistSucceededSetupMigrationAttempt({ - reportDir: path.join(stateDir, "migration", "hermes", "2026-07-13T11-00-00Z"), - attempt: succeeded, - result: buildFailedResult(prepared), - }); - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: AFTER_HASH, - }), - ).resolves.toEqual({ kind: "none" }); - - const persisted = await fs.readFile( - path.join(failedReportDir, "onboarding-attempt.json"), - "utf8", - ); - expect(persisted).not.toContain(identity.source); - expect(persisted).not.toContain(identity.workspaceDir); - }); - - it("recovers an interrupted applying attempt only before target side effects", async () => { - const stateDir = await makeTempRoot(); - const identity = { - providerId: "hermes", - source: path.join(stateDir, "hermes"), - workspaceDir: path.join(stateDir, "workspace"), - }; - const plan = buildPlan(stateDir); - const attempt = createSetupMigrationAttempt({ - ...identity, - plan, - sourceSnapshotHash: SOURCE_HASH, - preparedTargetSnapshotHash: BEFORE_HASH, - targetSnapshotHash: AFTER_HASH, - }); - const applyingReportDir = path.join(stateDir, "migration", "hermes", "2026-07-13T10-00-00Z"); - const apply = createDeferred(); - const applyingRun = runSetupMigrationAttempt({ - reportDir: applyingReportDir, - attempt, - apply: async () => await apply.promise, - assertSucceeded: () => {}, - readTargetSnapshot: async () => { - throw new Error("pending migration should not read the failure snapshot"); - }, - }); - await vi.waitFor(async () => { - await expect( - fs.readFile(path.join(applyingReportDir, "onboarding-attempt.json"), "utf8"), - ).resolves.toContain('"status": "applying"'); - }); - - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: BEFORE_HASH, - }), - ).resolves.toMatchObject({ kind: "recoverable" }); - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: AFTER_HASH, - }), - ).resolves.toMatchObject({ kind: "recoverable" }); - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: CHANGED_HASH, - }), - ).resolves.toEqual({ kind: "none" }); - - apply.resolve(buildFailedResult(plan)); - await applyingRun; - - const failedReportDir = path.join(stateDir, "migration", "hermes", "2026-07-13T11-00-00Z"); - await persistFailedSetupMigrationAttempt({ - reportDir: failedReportDir, - attempt, - targetSnapshotHash: BEFORE_HASH, - }); - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: BEFORE_HASH, - }), - ).resolves.toMatchObject({ kind: "recoverable" }); - - await persistFailedSetupMigrationAttempt({ - reportDir: failedReportDir, - attempt, - targetSnapshotHash: CHANGED_HASH, - }); - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: identity.providerId, - workspaceDir: identity.workspaceDir, - targetSnapshotHash: CHANGED_HASH, - }), - ).resolves.toEqual({ kind: "none" }); - }); - - it("binds retries to the planned source contents", async () => { - const root = await makeTempRoot(); - const sourceDir = path.join(root, "hermes"); - await fs.mkdir(sourceDir, { recursive: true }); - await fs.writeFile(path.join(sourceDir, "SOUL.md"), "Original soul.\n"); - await fs.writeFile(path.join(sourceDir, "AGENTS.md"), "Original agents.\n"); - const plan = buildPlan(root); - const initial = await buildSetupMigrationPlanSourceSnapshot(plan); - - await fs.writeFile(path.join(sourceDir, "SOUL.md"), "Changed soul.\n"); - await expect(buildSetupMigrationPlanSourceSnapshot(plan)).resolves.not.toBe(initial); - - const databasePath = path.join(sourceDir, "state.db"); - await fs.writeFile(databasePath, "database"); - const databaseItems = [ - createMigrationItem({ - id: "archive:state.db", - kind: "archive", - action: "archive", - source: databasePath, - }), - ]; - const databasePlan = { - ...plan, - items: databaseItems, - summary: summarizeMigrationItems(databaseItems), - }; - const databaseInitial = await buildSetupMigrationPlanSourceSnapshot(databasePlan); - await fs.writeFile(`${databasePath}-wal`, "committed rows"); - await expect(buildSetupMigrationPlanSourceSnapshot(databasePlan)).resolves.not.toBe( - databaseInitial, - ); - }); - - it.skipIf(process.platform === "win32")("hashes source symlink referent contents", async () => { - const root = await makeTempRoot(); - const sourceDir = path.join(root, "hermes"); - await fs.mkdir(sourceDir, { recursive: true }); - const referent = path.join(sourceDir, "soul-source.md"); - await fs.writeFile(referent, "Original soul.\n"); - await fs.symlink(path.basename(referent), path.join(sourceDir, "SOUL.md")); - const initial = await buildSetupMigrationPlanSourceSnapshot(buildPlan(root)); - - await fs.writeFile(referent, "Changed soul.\n"); - await expect(buildSetupMigrationPlanSourceSnapshot(buildPlan(root))).resolves.not.toBe(initial); - }); - - it("treats source paths beneath a non-directory as missing", async () => { - const root = await makeTempRoot(); - const plan = buildPlan(root); - const missingSnapshot = await buildSetupMigrationPlanSourceSnapshot(plan); - await fs.writeFile(path.join(root, "hermes"), "not a directory"); - - await expect(buildSetupMigrationPlanSourceSnapshot(plan)).resolves.toBe(missingSnapshot); - }); - - it("snapshots meaningful target changes but ignores migration reports", async () => { - const stateDir = await makeTempRoot(); - const workspaceDir = path.join(stateDir, "workspace"); - const initial = await buildSetupMigrationTargetSnapshot({ - config: {}, - stateDir, - workspaceDir, - }); - - await fs.mkdir(path.join(stateDir, "migration", "hermes", "report"), { recursive: true }); - await fs.writeFile(path.join(stateDir, "migration", "hermes", "report", "result.json"), "{}"); - await expect( - buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir }), - ).resolves.toBe(initial); - await expect( - buildSetupMigrationTargetSnapshot({ - config: { wizard: { securityAcknowledgedAt: "2026-07-13T23:00:00.000Z" } }, - stateDir, - workspaceDir, - }), - ).resolves.toBe(initial); - await expect( - buildSetupMigrationTargetSnapshot({ - config: { - wizard: { - securityAcknowledgedAt: "2026-07-13T23:00:00.000Z", - lastRunCommand: "onboard", - }, - }, - stateDir, - workspaceDir, - }), - ).resolves.not.toBe(initial); - - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "Be useful.\n"); - const workspaceChanged = await buildSetupMigrationTargetSnapshot({ - config: {}, - stateDir, - workspaceDir, - }); - expect(workspaceChanged).not.toBe(initial); - await expect( - buildSetupMigrationTargetSnapshot({ - config: { agents: { defaults: { workspace: workspaceDir } } }, - stateDir, - workspaceDir, - }), - ).resolves.not.toBe(workspaceChanged); - }); - - it("treats target paths beneath a non-directory as missing", async () => { - const stateDir = await makeTempRoot(); - const workspaceDir = path.join(stateDir, "workspace"); - const missingSnapshot = await buildSetupMigrationTargetSnapshot({ - config: {}, - stateDir, - workspaceDir, - }); - await fs.writeFile(workspaceDir, "not a directory"); - - await expect( - buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir }), - ).resolves.toBe(missingSnapshot); - }); - - it("fails closed when the newest recovery record is malformed", async () => { - const stateDir = await makeTempRoot(); - const reportDir = path.join(stateDir, "migration", "hermes", "2026-07-13T10-00-00Z"); - await fs.mkdir(reportDir, { recursive: true }); - await fs.writeFile(path.join(reportDir, "onboarding-attempt.json"), "{}\n", "utf8"); - - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: "hermes", - workspaceDir: path.join(stateDir, "workspace"), - targetSnapshotHash: BEFORE_HASH, - }), - ).rejects.toThrow("Invalid onboarding migration recovery record"); - }); - - it("treats a not-directory migration root as no recovery record", async () => { - // A file at the provider report path makes readdir return ENOTDIR; recovery - // treats that unavailable child path like a missing report directory. - const stateDir = await makeTempRoot(); - await fs.mkdir(path.join(stateDir, "migration"), { recursive: true }); - await fs.writeFile(path.join(stateDir, "migration", "hermes"), "not a directory"); - - await expect( - resolveSetupMigrationRecovery({ - stateDir, - providerId: "hermes", - workspaceDir: path.join(stateDir, "workspace"), - targetSnapshotHash: BEFORE_HASH, - }), - ).resolves.toEqual({ kind: "none" }); - }); -}); diff --git a/src/wizard/setup.migration-recovery.ts b/src/wizard/setup.migration-recovery.ts deleted file mode 100644 index 27fbbc9404ab..000000000000 --- a/src/wizard/setup.migration-recovery.ts +++ /dev/null @@ -1,397 +0,0 @@ -// Onboarding migration recovery records live with the generated migration report. -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { readDurableJsonFile, writeJsonAtomic } from "../infra/json-files.js"; -import { isNotFoundPathError } from "../infra/path-guards.js"; -import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; -import type { MigrationApplyResult, MigrationItem, MigrationPlan } from "../plugins/types.js"; -import { resolveUserPath } from "../utils.js"; - -const SETUP_MIGRATION_ATTEMPT_FILE = "onboarding-attempt.json"; -const SETUP_MIGRATION_ATTEMPT_VERSION = 1; - -type SetupMigrationAttemptStatus = "applying" | "failed" | "succeeded"; - -type SetupMigrationAttemptItem = { - id: string; - fingerprint: string; - resultStatus?: MigrationItem["status"]; -}; - -type SetupMigrationAttempt = { - version: typeof SETUP_MIGRATION_ATTEMPT_VERSION; - providerId: string; - sourceHash: string; - sourceSnapshotHash: string; - workspaceHash: string; - planFingerprint: string; - items: SetupMigrationAttemptItem[]; - itemStatusesCaptured: boolean; - targetSnapshotHashPrepared: string; - targetSnapshotHashBefore: string; - targetSnapshotHashAfter?: string; - status: SetupMigrationAttemptStatus; - startedAt: string; - updatedAt: string; -}; - -type SetupMigrationRecoveryState = - | { kind: "none" } - | { kind: "recoverable"; attempt: SetupMigrationAttempt }; - -type SetupMigrationIdentity = { - providerId: string; - source: string; - workspaceDir: string; -}; - -/** Hermes enumerates its replay inputs and has idempotent or conflict-checked item writes. */ -export function setupMigrationProviderSupportsRecovery(providerId: string): boolean { - return providerId === "hermes"; -} - -function buildPathHash(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -function buildSourceHash(source: string): string { - return buildPathHash(path.resolve(resolveUserPath(source.trim()))); -} - -function buildWorkspaceHash(workspaceDir: string): string { - return buildPathHash(path.resolve(workspaceDir)); -} - -function canonicalizeJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeJsonValue); - } - if (!value || typeof value !== "object") { - return value; - } - const record = value as Record; - return Object.fromEntries( - Object.keys(record) - .toSorted() - .filter((key) => record[key] !== undefined) - .map((key) => [key, canonicalizeJsonValue(record[key])]), - ); -} - -function buildMigrationItemFingerprint(item: MigrationItem): string { - const { status: _status, reason: _reason, ...identity } = item; - return buildPathHash(JSON.stringify(canonicalizeJsonValue(identity))); -} - -function buildMigrationPlanFingerprint(plan: MigrationPlan): string { - return buildPathHash( - JSON.stringify( - canonicalizeJsonValue({ - providerId: plan.providerId, - source: plan.source, - target: plan.target, - metadata: plan.metadata, - }), - ), - ); -} - -function isMigrationItemStatus(value: unknown): value is MigrationItem["status"] { - return ( - value === "planned" || - value === "migrated" || - value === "skipped" || - value === "warning" || - value === "conflict" || - value === "error" - ); -} - -function isSetupMigrationAttemptItem(value: unknown): value is SetupMigrationAttemptItem { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const item = value as Partial; - return ( - typeof item.id === "string" && - typeof item.fingerprint === "string" && - /^[a-f0-9]{64}$/.test(item.fingerprint) && - (item.resultStatus === undefined || isMigrationItemStatus(item.resultStatus)) - ); -} - -function isSetupMigrationAttempt(value: unknown): value is SetupMigrationAttempt { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const record = value as Partial; - return ( - record.version === SETUP_MIGRATION_ATTEMPT_VERSION && - typeof record.providerId === "string" && - typeof record.sourceHash === "string" && - /^[a-f0-9]{64}$/.test(record.sourceHash) && - typeof record.sourceSnapshotHash === "string" && - /^[a-f0-9]{64}$/.test(record.sourceSnapshotHash) && - typeof record.workspaceHash === "string" && - /^[a-f0-9]{64}$/.test(record.workspaceHash) && - typeof record.planFingerprint === "string" && - /^[a-f0-9]{64}$/.test(record.planFingerprint) && - Array.isArray(record.items) && - record.items.every(isSetupMigrationAttemptItem) && - typeof record.itemStatusesCaptured === "boolean" && - typeof record.targetSnapshotHashPrepared === "string" && - /^[a-f0-9]{64}$/.test(record.targetSnapshotHashPrepared) && - typeof record.targetSnapshotHashBefore === "string" && - /^[a-f0-9]{64}$/.test(record.targetSnapshotHashBefore) && - (record.targetSnapshotHashAfter === undefined || - (typeof record.targetSnapshotHashAfter === "string" && - /^[a-f0-9]{64}$/.test(record.targetSnapshotHashAfter))) && - (record.status === "applying" || record.status === "failed" || record.status === "succeeded") && - (record.status !== "failed" || record.targetSnapshotHashAfter !== undefined) && - typeof record.startedAt === "string" && - typeof record.updatedAt === "string" - ); -} - -export function createSetupMigrationAttempt( - params: SetupMigrationIdentity & { - plan: MigrationPlan; - sourceSnapshotHash: string; - preparedTargetSnapshotHash?: string; - targetSnapshotHash: string; - previousAttempt?: SetupMigrationAttempt; - }, - now = new Date(), -): SetupMigrationAttempt { - const timestamp = now.toISOString(); - const previousItems = params.previousAttempt?.items; - return { - version: SETUP_MIGRATION_ATTEMPT_VERSION, - providerId: params.providerId, - sourceHash: buildSourceHash(params.source), - sourceSnapshotHash: params.sourceSnapshotHash, - workspaceHash: buildWorkspaceHash(params.workspaceDir), - planFingerprint: buildMigrationPlanFingerprint(params.plan), - items: params.plan.items.map((item, index) => { - const fingerprint = buildMigrationItemFingerprint(item); - const previous = previousItems?.[index]; - return { - id: item.id, - fingerprint, - ...(previous?.id === item.id && previous.fingerprint === fingerprint - ? { resultStatus: previous.resultStatus } - : {}), - }; - }), - itemStatusesCaptured: false, - targetSnapshotHashPrepared: params.preparedTargetSnapshotHash ?? params.targetSnapshotHash, - targetSnapshotHashBefore: params.targetSnapshotHash, - status: "applying", - startedAt: timestamp, - updatedAt: timestamp, - }; -} - -async function writeSetupMigrationAttempt(params: { - reportDir: string; - attempt: SetupMigrationAttempt; - status: SetupMigrationAttemptStatus; - result?: MigrationApplyResult; - targetSnapshotHash?: string; -}): Promise { - const resultItems = params.result?.items; - const itemStatusesCaptured = - resultItems?.length === params.attempt.items.length && - resultItems.every((item, index) => item.id === params.attempt.items[index]?.id); - const items = itemStatusesCaptured - ? params.attempt.items.map((item, index) => ({ - ...item, - resultStatus: - item.resultStatus === "migrated" && resultItems?.[index]?.status === "skipped" - ? "migrated" - : resultItems?.[index]?.status, - })) - : params.attempt.items; - await writeJsonAtomic( - path.join(params.reportDir, SETUP_MIGRATION_ATTEMPT_FILE), - { - ...params.attempt, - items, - itemStatusesCaptured, - ...(params.targetSnapshotHash ? { targetSnapshotHashAfter: params.targetSnapshotHash } : {}), - status: params.status, - updatedAt: new Date().toISOString(), - }, - { mode: 0o600, dirMode: 0o700, trailingNewline: true }, - ); -} - -/** Runs provider apply while durably recording completion or a safe retry boundary. */ -export async function runSetupMigrationAttempt(params: { - reportDir: string; - attempt: SetupMigrationAttempt; - apply: () => Promise; - assertSucceeded: (result: MigrationApplyResult) => void; - readTargetSnapshot: () => Promise; -}): Promise { - await writeSetupMigrationAttempt({ - reportDir: params.reportDir, - attempt: params.attempt, - status: "applying", - }); - let result: MigrationApplyResult | undefined; - try { - result = await params.apply(); - params.assertSucceeded(result); - } catch (error) { - try { - await writeSetupMigrationAttempt({ - reportDir: params.reportDir, - attempt: params.attempt, - status: "failed", - result, - targetSnapshotHash: await params.readTargetSnapshot(), - }); - } catch (recoveryError) { - const failure = new AggregateError( - [error, recoveryError], - "Migration import failed and its retry record could not be updated.", - { cause: recoveryError }, - ); - throw failure; - } - throw error; - } - await writeSetupMigrationAttempt({ - reportDir: params.reportDir, - attempt: params.attempt, - status: "succeeded", - result, - }); - return result; -} - -async function findLatestSetupMigrationAttempt(params: { - stateDir: string; - providerId: string; - matches: (attempt: SetupMigrationAttempt) => boolean; -}): Promise { - const providerReportRoot = path.join(params.stateDir, "migration", params.providerId); - let entries: import("node:fs").Dirent[]; - try { - entries = await fs.readdir(providerReportRoot, { withFileTypes: true }); - } catch (error) { - if (isNotFoundPathError(error)) { - return undefined; - } - throw error; - } - for (const entry of entries - .filter((candidate) => candidate.isDirectory()) - .toSorted((left, right) => (left.name < right.name ? 1 : left.name > right.name ? -1 : 0))) { - const recordPath = path.join(providerReportRoot, entry.name, SETUP_MIGRATION_ATTEMPT_FILE); - let value: unknown; - try { - value = await readDurableJsonFile(recordPath); - } catch (error) { - throw new Error(`Invalid onboarding migration recovery record: ${recordPath}`, { - cause: error, - }); - } - if (value === null) { - continue; - } - if (!isSetupMigrationAttempt(value)) { - throw new Error(`Invalid onboarding migration recovery record: ${recordPath}`); - } - if (value.providerId === params.providerId && params.matches(value)) { - return value; - } - } - return undefined; -} - -/** Allows retry only while the target still matches the recorded attempt boundary. */ -export async function resolveSetupMigrationRecovery(params: { - stateDir: string; - providerId: string; - workspaceDir: string; - targetSnapshotHash: string; -}): Promise { - const workspaceHash = buildWorkspaceHash(params.workspaceDir); - const attempt = await findLatestSetupMigrationAttempt({ - stateDir: params.stateDir, - providerId: params.providerId, - matches: (candidate) => candidate.workspaceHash === workspaceHash, - }); - if (!attempt || attempt.status === "succeeded") { - return { kind: "none" }; - } - if (attempt.status === "applying") { - return attempt.targetSnapshotHashPrepared === params.targetSnapshotHash || - attempt.targetSnapshotHashBefore === params.targetSnapshotHash - ? { kind: "recoverable", attempt } - : { kind: "none" }; - } - if (attempt.targetSnapshotHashAfter !== params.targetSnapshotHash) { - return { kind: "none" }; - } - return attempt.itemStatusesCaptured || - attempt.targetSnapshotHashPrepared === params.targetSnapshotHash || - attempt.targetSnapshotHashBefore === params.targetSnapshotHash - ? { kind: "recoverable", attempt } - : { kind: "none" }; -} - -export function setupMigrationAttemptMatchesSource( - attempt: SetupMigrationAttempt, - source: string, -): boolean { - return attempt.sourceHash === buildSourceHash(source); -} - -/** Reuses an unchanged plan while suppressing items already completed by the failed run. */ -export function prepareSetupMigrationRetryPlan( - plan: MigrationPlan, - attempt: SetupMigrationAttempt, - sourceSnapshotHash: string, -): MigrationPlan { - if (attempt.sourceSnapshotHash !== sourceSnapshotHash) { - throw new Error( - "Migration source changed since the failed attempt. Review it before starting a new import.", - ); - } - if (attempt.planFingerprint !== buildMigrationPlanFingerprint(plan)) { - throw new Error( - "Migration retry plan context changed since the failed attempt. Review it before retrying.", - ); - } - if ( - plan.items.length !== attempt.items.length || - plan.items.some((item, index) => { - const previous = attempt.items[index]; - return ( - !previous || - previous.id !== item.id || - previous.fingerprint !== buildMigrationItemFingerprint(item) - ); - }) - ) { - throw new Error( - "Migration retry plan changed since the failed attempt. Review the source and target before retrying.", - ); - } - const items = plan.items.map((item, index) => { - const resultStatus = attempt.items[index]?.resultStatus; - if (resultStatus !== "migrated" || item.action === "archive") { - return item; - } - return { - ...item, - status: "skipped" as const, - reason: "already completed by the previous onboarding import attempt", - }; - }); - return { ...plan, items, summary: summarizeMigrationItems(items) }; -} diff --git a/src/wizard/setup.migration-snapshot.ts b/src/wizard/setup.migration-snapshot.ts index c739a5e46abd..20068cbc75ea 100644 --- a/src/wizard/setup.migration-snapshot.ts +++ b/src/wizard/setup.migration-snapshot.ts @@ -24,7 +24,7 @@ const MEANINGFUL_WORKSPACE_ENTRIES = [ "MEMORY.md", "skills", ] as const; -const MEANINGFUL_STATE_ENTRIES = ["credentials", "sessions", "agents"] as const; +const MEANINGFUL_STATE_ENTRIES = ["credentials", "sessions", "agents", "state"] as const; function canonicalizeJsonValue(value: unknown): unknown { if (Array.isArray(value)) { @@ -114,6 +114,12 @@ export async function inspectSetupMigrationFreshness(params: { reasons.push(`workspace ${entry} exists`); } } + if ( + reasons.every((reason) => !reason.startsWith("workspace ")) && + (await hasDirectoryEntries(params.workspaceDir)) + ) { + reasons.push("workspace directory is not empty"); + } for (const entry of MEANINGFUL_STATE_ENTRIES) { if (await hasDirectoryEntries(path.join(params.stateDir, entry))) { reasons.push(`state ${entry}/ exists`); @@ -240,9 +246,7 @@ export async function buildSetupMigrationTargetSnapshot(params: { const hash = crypto.createHash("sha256"); const targetConfig = buildSetupMigrationSnapshotConfig(params.config); hash.update(`config:${JSON.stringify(canonicalizeJsonValue(targetConfig))}\0`); - for (const entry of MEANINGFUL_WORKSPACE_ENTRIES) { - await hashTargetPath(hash, path.join(params.workspaceDir, entry), `workspace/${entry}`); - } + await hashTargetPath(hash, params.workspaceDir, "workspace"); for (const entry of MEANINGFUL_STATE_ENTRIES) { await hashTargetPath(hash, path.join(params.stateDir, entry), `state/${entry}`); } diff --git a/src/wizard/setup.migration-stage.test.ts b/src/wizard/setup.migration-stage.test.ts new file mode 100644 index 000000000000..36a71159d396 --- /dev/null +++ b/src/wizard/setup.migration-stage.test.ts @@ -0,0 +1,736 @@ +// Setup migration stage tests cover isolated SQLite writes and promotion rollback. +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; +import type { MigrationPlan } from "../plugins/types.js"; +import { listOpenClawRegisteredAgentDatabases } from "../state/openclaw-agent-db-registry.js"; +import type { SetupMigrationPromotionContinuation } from "./setup.migration-promotion.js"; +import { + createSetupMigrationStage, + recoverSetupMigrationPromotion, +} from "./setup.migration-stage.js"; + +const tempRoots = new Set(); + +async function makeTempRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-migration-stage-")); + tempRoots.add(root); + return root; +} + +function configHash(config: unknown): string { + return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex"); +} + +function continuation(): Omit< + SetupMigrationPromotionContinuation, + "workspaceDir" | "stagedReportDir" | "stagedRoots" +> { + const plan = { + providerId: "claude", + source: "fixture", + items: [], + summary: { + total: 0, + planned: 0, + migrated: 0, + skipped: 0, + conflicts: 0, + errors: 0, + sensitive: 0, + }, + }; + return { + providerLabel: "Claude", + plan, + stagedResult: plan, + outcome: { kind: "no-imported-inference" }, + continueOnboarding: true, + }; +} + +afterEach(async () => { + const [{ closeOpenClawAgentDatabasesForTest }, { closeOpenClawStateDatabaseForTest }] = + await Promise.all([ + import("../state/openclaw-agent-db.js"), + import("../state/openclaw-state-db.js"), + ]); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + for (const root of tempRoots) { + await fs.rm(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +describe("setup migration stage", () => { + it("executes provider config mutations once and projects staged paths", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + let mutationCalls = 0; + + await stage.configRuntime.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "none", reason: "staged migration test" }, + mutate(draft) { + mutationCalls += 1; + draft.mcp = { + servers: { + staged: { command: stage.staged.workspaceDir }, + }, + }; + }, + }); + + expect(mutationCalls).toBe(1); + expect(stage.getStagedConfig().mcp?.servers?.staged?.command).toBe(stage.staged.workspaceDir); + expect(stage.getFinalConfig().mcp?.servers?.staged?.command).toBe(workspaceDir); + await stage.cleanup(); + }); + + it("uses the most-specific path mapping when workspace lives under state", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(stateDir, "workspace"); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir: path.join(stateDir, "migration", "claude", "attempt"), + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + const target = path.join(workspaceDir, "MEMORY.md"); + const plan = { + providerId: "claude", + source: "fixture", + target, + items: [ + { + id: "workspace:memory", + kind: "memory", + action: "copy", + status: "planned", + target, + }, + ], + summary: { + total: 1, + planned: 1, + migrated: 0, + skipped: 0, + conflicts: 0, + errors: 0, + sensitive: 0, + }, + } satisfies MigrationPlan; + + const projected = stage.projectPlanToStage(plan); + + expect(projected.target).toBe(path.join(stage.staged.workspaceDir, "MEMORY.md")); + expect(projected.items[0]?.target).toBe(path.join(stage.staged.workspaceDir, "MEMORY.md")); + await stage.cleanup(); + }); + + it("routes staged auth writes to the staged shared registry", async () => { + const root = await makeTempRoot(); + const liveStateDir = path.join(root, "live-state"); + const stagedStateDir = path.join(root, "staged-state"); + const stagedAgentDir = path.join(stagedStateDir, "agents", "main", "agent"); + + const updated = await updateAuthProfileStoreWithLock({ + agentDir: stagedAgentDir, + stateDir: stagedStateDir, + updater(store) { + store.profiles["openai:imported"] = { + type: "api_key", + provider: "openai", + key: "test-key", + }; + return true; + }, + }); + + expect(updated?.profiles["openai:imported"]).toBeDefined(); + expect( + listOpenClawRegisteredAgentDatabases({ + env: { ...process.env, OPENCLAW_STATE_DIR: stagedStateDir }, + }), + ).toEqual([ + expect.objectContaining({ + agentId: "main", + path: path.join(stagedAgentDir, "openclaw-agent.sqlite"), + }), + ]); + expect( + listOpenClawRegisteredAgentDatabases({ + env: { ...process.env, OPENCLAW_STATE_DIR: liveStateDir }, + }), + ).toEqual([]); + await expect(fs.access(path.join(liveStateDir, "state", "openclaw.sqlite"))).rejects.toThrow(); + }); + + it("promotes the final agent registry path after verification closes the handle", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const targetConfig = { agents: { defaults: { workspace: workspaceDir } } }; + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig, + }); + const { disposeOpenClawAgentDatabaseByPath } = await import("../state/openclaw-agent-db.js"); + disposeOpenClawAgentDatabaseByPath(path.join(stage.staged.agentDir, "openclaw-agent.sqlite"), { + env: { ...process.env, OPENCLAW_STATE_DIR: stage.staged.stateDir }, + }); + + const promoted = await stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async (config) => config, + }); + + expect( + listOpenClawRegisteredAgentDatabases({ + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, + }), + ).toEqual([ + expect.objectContaining({ + agentId: "main", + path: path.join(stage.final.agentDir, "openclaw-agent.sqlite"), + }), + ]); + await promoted.resume.complete(); + await promoted.resume.acknowledge(); + await stage.cleanup(); + }); + + it("rolls back promoted directories when the config commit fails", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + await fs.mkdir(path.join(stateDir, "migration"), { recursive: true }); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async () => { + throw new Error("commit failed"); + }, + }), + ).rejects.toThrow("commit failed"); + + await expect(fs.access(path.join(workspaceDir, "MEMORY.md"))).rejects.toThrow(); + await expect(fs.access(path.join(stateDir, "agents"))).rejects.toThrow(); + const journalPath = path.join(reportDir, "onboarding-promotion.json"); + const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as { status: string }; + expect(journal.status).toBe("rolled-back"); + expect((await fs.stat(journalPath)).mode & 0o777).toBe(0o600); + await stage.cleanup(); + }); + + it("journals pre-existing empty targets before promotion starts", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.chmod(workspaceDir, 0o755); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async () => { + throw new Error("commit failed"); + }, + }), + ).rejects.toThrow("commit failed"); + + const journal = JSON.parse( + await fs.readFile(path.join(reportDir, "onboarding-promotion.json"), "utf8"), + ) as { + components: Array<{ + name: string; + targetWasEmptyDirectory?: boolean; + emptyTargetBackupPath?: string; + }>; + }; + expect(journal.components.find((component) => component.name === "workspace")).toMatchObject({ + targetWasEmptyDirectory: true, + emptyTargetBackupPath: expect.any(String), + }); + expect(await fs.readdir(workspaceDir)).toEqual([]); + expect((await fs.stat(workspaceDir)).mode & 0o777).toBe(0o755); + await stage.cleanup(); + }); + + it("removes shared promotion parents after rollback", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const sharedRoot = path.join(stateDir, "shared"); + const workspaceDir = path.join(sharedRoot, "workspace"); + const agentDir = path.join(sharedRoot, "agent"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const targetConfig = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true, agentDir }], + }, + }; + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async () => { + throw new Error("commit failed"); + }, + }), + ).rejects.toThrow("commit failed"); + + await expect(fs.access(sharedRoot)).rejects.toThrow(); + await stage.cleanup(); + }); + + it("rejects staged state that the promotion owner does not publish", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.mkdir(path.join(stage.staged.stateDir, "credentials"), { recursive: true }); + await fs.writeFile( + path.join(stage.staged.stateDir, "credentials", "provider.json"), + "{}\n", + "utf8", + ); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async (config) => config, + }), + ).rejects.toThrow("unsupported staged state"); + await stage.cleanup(); + }); + + it("rejects overlapping workspace and agent promotion targets", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(stateDir, "agents", "main", "agent", "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async (config) => config, + }), + ).rejects.toThrow("Migration promotion targets overlap"); + await expect(fs.access(path.join(reportDir, "onboarding-promotion.json"))).rejects.toThrow(); + await stage.cleanup(); + }); + + it("rejects overlap through a state-directory symlink", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const stateAlias = path.join(root, "state-alias"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.symlink(stateDir, stateAlias); + const workspaceDir = path.join(stateAlias, "agents", "main", "agent", "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async (config) => config, + }), + ).rejects.toThrow("Migration promotion targets overlap"); + await expect(fs.access(path.join(reportDir, "onboarding-promotion.json"))).rejects.toThrow(); + await stage.cleanup(); + }); + + it("rejects a report path that resolves inside a promotion target", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.symlink(workspaceDir, path.join(stateDir, "migration")); + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig: { agents: { defaults: { workspace: workspaceDir } } }, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + + await expect( + stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => ({}), + commitConfigFile: async (config) => config, + }), + ).rejects.toThrow("Migration promotion targets overlap"); + expect(await fs.readdir(workspaceDir)).toEqual([]); + await stage.cleanup(); + }); + + it("fails closed when an interrupted promotion already published data", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const reportDir = path.join(stateDir, "migration", "claude", "2026-07-21T000000Z"); + const stagedWorkspace = path.join(root, "staged-workspace"); + const finalWorkspace = path.join(root, "workspace"); + await fs.mkdir(reportDir, { recursive: true }); + await fs.mkdir(finalWorkspace, { recursive: true }); + await fs.writeFile(path.join(finalWorkspace, "MEMORY.md"), "promoted\n", "utf8"); + await fs.writeFile( + path.join(reportDir, "onboarding-promotion.json"), + JSON.stringify({ + version: 1, + status: "promoting", + providerId: "claude", + configHashBefore: configHash({}), + configHashTarget: configHash({ gateway: { mode: "local" } }), + components: [ + { + name: "workspace", + stagedPath: stagedWorkspace, + finalPath: finalWorkspace, + status: "promoted", + }, + ], + updatedAt: "2026-07-21T00:00:00.000Z", + }), + { mode: 0o600 }, + ); + + await expect( + recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => ({}), + }), + ).rejects.toThrow("published local data before config commit"); + + expect(await fs.readFile(path.join(finalWorkspace, "MEMORY.md"), "utf8")).toBe("promoted\n"); + await expect(fs.access(stagedWorkspace)).rejects.toThrow(); + const journal = JSON.parse( + await fs.readFile(path.join(reportDir, "onboarding-promotion.json"), "utf8"), + ) as { status: string }; + expect(journal.status).toBe("indeterminate"); + }); + + it("restores a pre-existing empty target when recovery starts before its rename", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const reportDir = path.join(stateDir, "migration", "claude", "2026-07-21T000000Z"); + const stagedRoot = path.join(root, "staged-root"); + const stagedWorkspace = path.join(stagedRoot, "workspace"); + const finalWorkspace = path.join(root, "workspace"); + await fs.mkdir(stagedWorkspace, { recursive: true }); + await fs.writeFile(path.join(stagedWorkspace, "MEMORY.md"), "staged\n", "utf8"); + await fs.mkdir(finalWorkspace, { recursive: true }); + await fs.mkdir(reportDir, { recursive: true }); + await fs.writeFile( + path.join(reportDir, "onboarding-promotion.json"), + JSON.stringify({ + version: 1, + status: "promoting", + providerId: "claude", + configHashBefore: configHash({}), + configHashTarget: configHash({ gateway: { mode: "local" } }), + components: [ + { + name: "workspace", + stagedPath: stagedWorkspace, + finalPath: finalWorkspace, + status: "staged", + targetWasEmptyDirectory: true, + }, + ], + continuation: { + ...continuation(), + workspaceDir: finalWorkspace, + stagedReportDir: path.join(stagedRoot, "report"), + stagedRoots: [stagedRoot], + }, + updatedAt: "2026-07-21T00:00:00.000Z", + }), + { mode: 0o600 }, + ); + + await recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => ({}), + }); + + expect(await fs.readdir(finalWorkspace)).toEqual([]); + await expect(fs.access(stagedRoot)).rejects.toThrow(); + }); + + it("reconciles an interrupted promotion after config commit", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const reportDir = path.join(stateDir, "migration", "claude", "2026-07-21T000001Z"); + const finalWorkspace = path.join(root, "workspace"); + const targetConfig = { gateway: { mode: "local" as const } }; + await fs.mkdir(reportDir, { recursive: true }); + await fs.mkdir(finalWorkspace, { recursive: true }); + await fs.writeFile( + path.join(reportDir, "onboarding-promotion.json"), + JSON.stringify({ + version: 1, + status: "promoting", + providerId: "claude", + configHashBefore: configHash({}), + configHashTarget: configHash(targetConfig), + components: [ + { + name: "workspace", + stagedPath: path.join(root, "staged-workspace"), + finalPath: finalWorkspace, + status: "promoted", + }, + ], + continuation: { + ...continuation(), + workspaceDir: finalWorkspace, + stagedReportDir: path.join(root, "staged-report"), + stagedRoots: [], + }, + updatedAt: "2026-07-21T00:00:01.000Z", + }), + { mode: 0o600 }, + ); + + const resume = await recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => targetConfig, + }); + + expect(resume?.continuation.outcome).toEqual({ kind: "no-imported-inference" }); + const journal = JSON.parse( + await fs.readFile(path.join(reportDir, "onboarding-promotion.json"), "utf8"), + ) as { status: string }; + expect(journal.status).toBe("committed"); + }); + + it("allows committed recovery after legitimate config changes", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const reportDir = path.join(stateDir, "migration", "claude", "2026-07-21T000002Z"); + const finalWorkspace = path.join(root, "workspace"); + const targetConfig = { gateway: { mode: "local" as const } }; + await fs.mkdir(reportDir, { recursive: true }); + await fs.mkdir(finalWorkspace, { recursive: true }); + await fs.writeFile( + path.join(reportDir, "onboarding-promotion.json"), + JSON.stringify({ + version: 1, + status: "committed", + providerId: "claude", + configHashBefore: configHash({}), + configHashTarget: configHash(targetConfig), + components: [ + { + name: "workspace", + stagedPath: path.join(root, "staged-workspace"), + finalPath: finalWorkspace, + status: "promoted", + }, + ], + continuation: { + ...continuation(), + workspaceDir: finalWorkspace, + stagedReportDir: path.join(root, "staged-report"), + stagedRoots: [], + }, + updatedAt: "2026-07-21T00:00:02.000Z", + }), + { mode: 0o600 }, + ); + + await expect( + recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => ({ gateway: { port: 23456 } }), + }), + ).resolves.toBeDefined(); + }); + + it("rejects committed recovery after the promoted target was reset", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const reportDir = path.join(stateDir, "migration", "claude", "2026-07-21T000002Z"); + const finalWorkspace = path.join(root, "workspace"); + const targetConfig = { gateway: { mode: "local" as const } }; + await fs.mkdir(reportDir, { recursive: true }); + await fs.writeFile( + path.join(reportDir, "onboarding-promotion.json"), + JSON.stringify({ + version: 1, + status: "committed", + providerId: "claude", + configHashBefore: configHash({}), + configHashTarget: configHash(targetConfig), + components: [ + { + name: "workspace", + stagedPath: path.join(root, "staged-workspace"), + finalPath: finalWorkspace, + status: "promoted", + }, + ], + continuation: { + ...continuation(), + workspaceDir: finalWorkspace, + stagedReportDir: path.join(root, "staged-report"), + stagedRoots: [], + }, + updatedAt: "2026-07-21T00:00:02.000Z", + }), + { mode: 0o600 }, + ); + + await expect( + recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => targetConfig, + }), + ).rejects.toThrow("no longer matches its promoted target"); + }); + + it("reconciles a config writer that commits and then throws", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + const reportDir = path.join(stateDir, "migration", "claude", "attempt"); + await fs.mkdir(path.join(stateDir, "migration"), { recursive: true }); + const targetConfig = { agents: { defaults: { workspace: workspaceDir } } }; + const stage = await createSetupMigrationStage({ + providerId: "claude", + stateDir, + workspaceDir, + reportDir, + targetConfig, + }); + await fs.writeFile(path.join(stage.staged.workspaceDir, "MEMORY.md"), "staged\n", "utf8"); + let currentConfig: typeof targetConfig | Record = {}; + + const promoted = await stage.promote({ + expectedConfig: {}, + continuation: continuation(), + readConfigFile: async () => structuredClone(currentConfig), + commitConfigFile: async (config) => { + currentConfig = structuredClone(config) as typeof targetConfig; + throw new Error("write result lost"); + }, + }); + + expect(promoted.config).toEqual(targetConfig); + expect(await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf8")).toBe("staged\n"); + const journal = JSON.parse( + await fs.readFile(path.join(reportDir, "onboarding-promotion.json"), "utf8"), + ) as { status: string }; + expect(journal.status).toBe("committed"); + await promoted.resume.complete(); + await fs.rm(workspaceDir, { recursive: true, force: true }); + const resumed = await recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => ({ gateway: { mode: "local" } }), + }); + expect(resumed?.continuation.outcome).toEqual({ kind: "no-imported-inference" }); + await resumed?.acknowledge(); + await expect( + recoverSetupMigrationPromotion({ + stateDir, + providerId: "claude", + readConfigFile: async () => structuredClone(currentConfig), + }), + ).resolves.toBeUndefined(); + await stage.cleanup(); + }); +}); diff --git a/src/wizard/setup.migration-stage.ts b/src/wizard/setup.migration-stage.ts new file mode 100644 index 000000000000..2c47bb1b8067 --- /dev/null +++ b/src/wizard/setup.migration-stage.ts @@ -0,0 +1,512 @@ +// Setup migration staging keeps provider writes isolated until verified promotion. +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveAgentDir } from "../agents/agent-scope-config.js"; +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { clearRuntimeAuthProfileStoreSnapshot } from "../agents/auth-profiles/store.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isNotFoundPathError } from "../infra/path-guards.js"; +import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; +import type { + MigrationApplyResult, + MigrationConfigRuntime, + MigrationItem, + MigrationPlan, +} from "../plugins/types.js"; +import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js"; +import { + disposeOpenClawAgentDatabaseByPath, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { + assertDisjointPromotionTargets, + assertSupportedStagedStateTree, + createPromotionResume, + moveRecordedEmptyTarget, + PROMOTION_JOURNAL_FILE, + PROMOTION_JOURNAL_VERSION, + recordPromotionTargetState, + rollbackComponents, + writePromotionJournal, + type PromotionComponent, + type PromotionJournal, + type SetupMigrationPromotionContinuation, + type SetupMigrationPromotionResume, +} from "./setup.migration-promotion.js"; + +export { recoverSetupMigrationPromotion } from "./setup.migration-promotion.js"; +export type { + SetupMigrationPromotionOutcome, + SetupMigrationPromotionResume, +} from "./setup.migration-promotion.js"; + +const DEFERRED_REASON = "deferred until durable onboarding promotion"; + +type SetupMigrationStagePaths = { + stateDir: string; + workspaceDir: string; + agentDir: string; + reportDir: string; +}; + +type SetupMigrationStage = { + staged: SetupMigrationStagePaths; + final: SetupMigrationStagePaths; + configRuntime: MigrationConfigRuntime; + getFinalConfig: () => OpenClawConfig; + getStagedConfig: () => OpenClawConfig; + replaceStagedConfig: (config: OpenClawConfig) => void; + projectPlanToStage: (plan: MigrationPlan) => MigrationPlan; + projectResultToFinal: (result: MigrationApplyResult) => MigrationApplyResult; + promote: (params: { + expectedConfig: OpenClawConfig; + continuation: Omit< + SetupMigrationPromotionContinuation, + "stagedReportDir" | "stagedRoots" | "workspaceDir" + >; + readConfigFile: () => Promise; + commitConfigFile: ( + config: OpenClawConfig, + expectedConfig: OpenClawConfig, + ) => Promise; + }) => Promise<{ config: OpenClawConfig; resume: SetupMigrationPromotionResume }>; + cleanup: () => Promise; +}; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (!value || typeof value !== "object") { + return value; + } + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .toSorted() + .filter((key) => record[key] !== undefined) + .map((key) => [key, canonicalize(record[key])]), + ); +} + +function hashConfig(config: OpenClawConfig): string { + return crypto + .createHash("sha256") + .update(JSON.stringify(canonicalize(config))) + .digest("hex"); +} + +async function pathExists(candidate: string): Promise { + try { + await fs.lstat(candidate); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } +} + +async function findExistingAncestor(candidate: string): Promise { + let current = path.resolve(candidate); + while (!(await pathExists(current))) { + const parent = path.dirname(current); + if (parent === current) { + throw new Error(`Could not find an existing parent for migration staging at ${candidate}.`); + } + current = parent; + } + return current; +} + +async function makePrivateStageNear(target: string, label: string): Promise { + const ancestor = await findExistingAncestor(path.dirname(path.resolve(target))); + const staged = await fs.mkdtemp(path.join(ancestor, `.openclaw-${label}-`)); + await fs.chmod(staged, 0o700); + return staged; +} + +function replacePathPrefix(value: string, from: string, to: string): string { + if (value === from) { + return to; + } + const prefix = `${from}${path.sep}`; + return value.startsWith(prefix) ? `${to}${value.slice(from.length)}` : value; +} + +function projectPath(value: string, mappings: ReadonlyArray): string { + const mapping = mappings + .filter(([from]) => value === from || value.startsWith(`${from}${path.sep}`)) + .toSorted(([left], [right]) => right.length - left.length)[0]; + return mapping ? replacePathPrefix(value, mapping[0], mapping[1]) : value; +} + +function projectValue(value: unknown, mappings: ReadonlyArray): unknown { + if (typeof value === "string") { + return projectPath(value, mappings); + } + if (Array.isArray(value)) { + return value.map((entry) => projectValue(entry, mappings)); + } + if (!value || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, projectValue(entry, mappings)]), + ); +} + +function projectPlanTargets( + plan: MigrationPlan, + mappings: ReadonlyArray, +): MigrationPlan { + return { + ...plan, + ...(plan.target ? { target: projectValue(plan.target, mappings) as string } : {}), + items: plan.items.map((item) => ({ + ...item, + ...(item.target ? { target: projectValue(item.target, mappings) as string } : {}), + })), + ...(plan.metadata + ? { metadata: projectValue(plan.metadata, mappings) as Record } + : {}), + }; +} + +function createInMemoryConfigRuntime(params: { + finalConfig: OpenClawConfig; + stagedConfig: OpenClawConfig; + projectToFinal: (config: OpenClawConfig) => OpenClawConfig; +}): { + runtime: MigrationConfigRuntime; + getFinalConfig: () => OpenClawConfig; + getStagedConfig: () => OpenClawConfig; + replaceConfigs: (next: { finalConfig: OpenClawConfig; stagedConfig: OpenClawConfig }) => void; +} { + let finalConfig = structuredClone(params.finalConfig); + let stagedConfig = structuredClone(params.stagedConfig); + const mutateConfigFile = async ( + mutation: Parameters[0], + ) => { + const stagedDraft = structuredClone(stagedConfig); + const context = { snapshot: {} as never, previousHash: null }; + const result = await mutation.mutate(stagedDraft, context); + // Provider mutations may carry state or generate values. Execute them once, + // then project the staged result into the publishable config. + stagedConfig = stagedDraft; + finalConfig = params.projectToFinal(stagedDraft); + return { + nextConfig: stagedConfig, + result, + path: "", + previousHash: null, + snapshot: {} as never, + persistedHash: null, + afterWrite: mutation.afterWrite, + followUp: { mode: "none", reason: "staged migration config", requiresRestart: false }, + }; + }; + const runtime: MigrationConfigRuntime = { + current: () => stagedConfig, + mutateConfigFile: mutateConfigFile as MigrationConfigRuntime["mutateConfigFile"], + }; + return { + runtime, + getFinalConfig: () => structuredClone(finalConfig), + getStagedConfig: () => structuredClone(stagedConfig), + replaceConfigs(next) { + finalConfig = structuredClone(next.finalConfig); + stagedConfig = structuredClone(next.stagedConfig); + }, + }; +} + +function phasePlan( + plan: MigrationPlan, + phase: "before-promotion" | "after-promotion", +): MigrationPlan { + const items = plan.items.map((item) => { + const itemPhase = item.applyPhase ?? "before-promotion"; + if (itemPhase === phase || item.status !== "planned") { + return item; + } + return { ...item, status: "skipped" as const, reason: DEFERRED_REASON }; + }); + return { ...plan, items, summary: summarizeMigrationItems(items) }; +} + +export function buildSetupMigrationPhasePlan( + plan: MigrationPlan, + phase: "before-promotion" | "after-promotion", +): MigrationPlan { + return phasePlan(plan, phase); +} + +function takeMatchingItem(items: MigrationItem[], item: MigrationItem): MigrationItem | undefined { + const index = items.findIndex((candidate) => candidate.id === item.id); + if (index < 0) { + return undefined; + } + return items.splice(index, 1)[0]; +} + +export function mergeSetupMigrationPhaseResults(params: { + plan: MigrationPlan; + staged: MigrationApplyResult; + deferred?: MigrationApplyResult; +}): MigrationApplyResult { + const stagedItems = [...params.staged.items]; + const deferredItems = [...(params.deferred?.items ?? [])]; + const items = params.plan.items.map((item) => { + const source = item.applyPhase === "after-promotion" ? deferredItems : stagedItems; + return takeMatchingItem(source, item) ?? item; + }); + const plannedItemIds = new Set(params.plan.items.map((item) => item.id)); + items.push( + ...stagedItems.filter((item) => !plannedItemIds.has(item.id)), + ...deferredItems.filter((item) => !plannedItemIds.has(item.id)), + ); + return { + ...params.staged, + items, + summary: summarizeMigrationItems(items), + warnings: [ + ...new Set([...(params.staged.warnings ?? []), ...(params.deferred?.warnings ?? [])]), + ], + nextSteps: [ + ...new Set([...(params.staged.nextSteps ?? []), ...(params.deferred?.nextSteps ?? [])]), + ], + }; +} + +export async function createSetupMigrationStage(params: { + providerId: string; + stateDir: string; + workspaceDir: string; + reportDir: string; + targetConfig: OpenClawConfig; +}): Promise { + const agentId = resolveDefaultAgentId(params.targetConfig); + const finalEnv = { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }; + const finalAgentDir = resolveAgentDir(params.targetConfig, agentId, finalEnv); + const stagedStateDir = await makePrivateStageNear(params.stateDir, "migration-state"); + const stagedWorkspaceDir = await makePrivateStageNear(params.workspaceDir, "migration-workspace"); + const stagedAgentDir = path.join(stagedStateDir, "agents", agentId, "agent"); + const stagedReportDir = path.join( + stagedStateDir, + "migration", + params.providerId, + path.basename(params.reportDir), + ); + const stageEnv = { ...process.env, OPENCLAW_STATE_DIR: stagedStateDir }; + const stagedConfig: OpenClawConfig = { + ...structuredClone(params.targetConfig), + agents: { + ...structuredClone(params.targetConfig.agents), + defaults: { + ...structuredClone(params.targetConfig.agents?.defaults), + workspace: stagedWorkspaceDir, + }, + }, + }; + const finalPaths: SetupMigrationStagePaths = { + stateDir: params.stateDir, + workspaceDir: params.workspaceDir, + agentDir: finalAgentDir, + reportDir: params.reportDir, + }; + const stagedPaths: SetupMigrationStagePaths = { + stateDir: stagedStateDir, + workspaceDir: stagedWorkspaceDir, + agentDir: stagedAgentDir, + reportDir: stagedReportDir, + }; + const toStage = [ + [finalPaths.workspaceDir, stagedPaths.workspaceDir], + [finalPaths.agentDir, stagedPaths.agentDir], + [finalPaths.stateDir, stagedPaths.stateDir], + [finalPaths.reportDir, stagedPaths.reportDir], + ] as const; + const toFinal = toStage.map(([finalPath, stagedPath]) => [stagedPath, finalPath] as const); + const projectConfigToFinal = (config: OpenClawConfig) => + projectValue(config, toFinal) as OpenClawConfig; + const configs = createInMemoryConfigRuntime({ + finalConfig: params.targetConfig, + stagedConfig, + projectToFinal: projectConfigToFinal, + }); + openOpenClawAgentDatabase({ agentId, env: stageEnv }); + let databasesDisposed = false; + let retainForRecovery = false; + + const disposeDatabases = () => { + if (databasesDisposed) { + return; + } + clearRuntimeAuthProfileStoreSnapshot(stagedAgentDir); + const stagedAgentDatabasePath = path.join(stagedAgentDir, "openclaw-agent.sqlite"); + disposeOpenClawAgentDatabaseByPath(stagedAgentDatabasePath, { env: stageEnv }); + // Verification may already close this handle. The staged registry still must + // publish the final path before its shared database is promoted. + registerOpenClawAgentDatabase({ + agentId, + path: path.join(finalAgentDir, "openclaw-agent.sqlite"), + env: stageEnv, + }); + closeOpenClawStateDatabaseByPath(resolveOpenClawStateSqlitePath(stageEnv)); + databasesDisposed = true; + }; + + return { + staged: stagedPaths, + final: finalPaths, + configRuntime: configs.runtime, + getFinalConfig: configs.getFinalConfig, + getStagedConfig: configs.getStagedConfig, + replaceStagedConfig(config) { + configs.replaceConfigs({ + stagedConfig: config, + finalConfig: projectConfigToFinal(config), + }); + }, + projectPlanToStage: (plan) => projectPlanTargets(plan, toStage), + projectResultToFinal: (result) => projectValue(result, toFinal) as MigrationApplyResult, + async promote({ expectedConfig, continuation, readConfigFile, commitConfigFile }) { + disposeDatabases(); + const configBefore = await readConfigFile(); + if (hashConfig(configBefore) !== hashConfig(expectedConfig)) { + throw new Error("Migration config changed before promotion. Review it and retry."); + } + const configTarget = configs.getFinalConfig(); + const components: PromotionComponent[] = [ + { + name: "workspace", + stagedPath: stagedWorkspaceDir, + finalPath: params.workspaceDir, + status: "staged", + }, + { + name: "agent", + stagedPath: stagedAgentDir, + finalPath: finalAgentDir, + status: "staged", + }, + { + name: "state", + stagedPath: path.join(stagedStateDir, "state"), + finalPath: path.join(params.stateDir, "state"), + status: "staged", + }, + ]; + const existingComponents: PromotionComponent[] = []; + for (const component of components) { + if (component.name === "workspace" || (await pathExists(component.stagedPath))) { + existingComponents.push(component); + } + } + await assertSupportedStagedStateTree({ + stagedStateDir, + agentId, + providerId: params.providerId, + reportDirName: path.basename(params.reportDir), + }); + await assertDisjointPromotionTargets([ + ...existingComponents, + { finalPath: params.reportDir }, + ]); + await fs.mkdir(params.reportDir, { recursive: true, mode: 0o700 }); + // Snapshot every permitted pre-existing empty target before the journal + // becomes recoverable, including components promoted later in this loop. + for (const component of existingComponents) { + await recordPromotionTargetState(component); + } + const journalPath = path.join(params.reportDir, PROMOTION_JOURNAL_FILE); + const journal: PromotionJournal = { + version: PROMOTION_JOURNAL_VERSION, + status: "prepared", + providerId: params.providerId, + configHashBefore: hashConfig(configBefore), + configHashTarget: hashConfig(configTarget), + components: existingComponents, + continuation: { + ...continuation, + workspaceDir: params.workspaceDir, + stagedReportDir, + stagedRoots: [stagedStateDir, stagedWorkspaceDir], + }, + updatedAt: new Date().toISOString(), + }; + await writePromotionJournal(journalPath, journal); + journal.status = "promoting"; + await writePromotionJournal(journalPath, journal); + try { + for (const component of journal.components) { + if (component.targetWasEmptyDirectory) { + // The target state was journaled before promotion began. Persist the + // current phase before removal so recovery can recreate the directory. + await writePromotionJournal(journalPath, journal); + await moveRecordedEmptyTarget(component); + } + await fs.mkdir(path.dirname(component.finalPath), { recursive: true, mode: 0o700 }); + await fs.rename(component.stagedPath, component.finalPath); + component.status = "promoted"; + await writePromotionJournal(journalPath, journal); + } + let committed: OpenClawConfig; + try { + committed = await commitConfigFile(configTarget, expectedConfig); + } catch (error) { + const current = await readConfigFile().catch(() => undefined); + if (current && hashConfig(current) === journal.configHashTarget) { + committed = current; + } else if (current && hashConfig(current) === journal.configHashBefore) { + throw error; + } else { + journal.status = "indeterminate"; + retainForRecovery = true; + await writePromotionJournal(journalPath, journal); + throw new Error( + `Migration config commit is indeterminate. Review ${journalPath} and run openclaw doctor before retrying.`, + { cause: error }, + ); + } + } + journal.configHashTarget = hashConfig(committed); + journal.status = "committed"; + retainForRecovery = true; + await writePromotionJournal(journalPath, journal); + return { config: committed, resume: createPromotionResume(journalPath, journal) }; + } catch (error) { + if (retainForRecovery) { + throw error; + } + if (await rollbackComponents(journal.components)) { + journal.status = "rolled-back"; + await writePromotionJournal(journalPath, journal); + throw error; + } + journal.status = "indeterminate"; + retainForRecovery = true; + await writePromotionJournal(journalPath, journal); + throw new Error( + `Migration promotion could not be rolled back. Review ${journalPath} and run openclaw doctor before retrying.`, + { cause: error }, + ); + } + }, + async cleanup() { + if (retainForRecovery) { + return; + } + disposeDatabases(); + await Promise.all([ + fs.rm(stagedStateDir, { recursive: true, force: true }), + fs.rm(stagedWorkspaceDir, { recursive: true, force: true }), + ]); + }, + }; +} diff --git a/src/wizard/setup.migration-transaction.test.ts b/src/wizard/setup.migration-transaction.test.ts new file mode 100644 index 000000000000..40dcf7d7428b --- /dev/null +++ b/src/wizard/setup.migration-transaction.test.ts @@ -0,0 +1,551 @@ +// Transactional onboarding migration tests exercise the classic full-import caller. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; +import type { + MigrationApplyResult, + MigrationConfigRuntime, + MigrationItem, + MigrationPlan, + MigrationProviderContext, + MigrationProviderPlugin, +} from "../plugins/types.js"; +import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; + +const mocks = vi.hoisted(() => ({ + canonicalMutateConfigFile: vi.fn(), + currentConfig: undefined as { value: Record } | undefined, + provider: undefined as MigrationProviderPlugin | undefined, + verify: vi.fn(), +})); + +vi.mock("../plugins/migration-provider-runtime.js", () => ({ + ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(), + resolvePluginMigrationProvider: () => mocks.provider, + resolvePluginMigrationProviders: () => (mocks.provider ? [mocks.provider] : []), +})); + +vi.mock("./setup.inference-verification.js", () => ({ + offerLiveModelVerification: mocks.verify, +})); + +vi.mock("../config/mutate.js", () => ({ + mutateConfigFile: mocks.canonicalMutateConfigFile, +})); + +import { runSetupMigrationImport } from "./setup.migration-import.js"; + +const tempRoots = new Set(); +let previousStateDir: string | undefined; + +async function makeTempRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-migration-transaction-")); + tempRoots.add(root); + return root; +} + +function runtime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} + +function prompter(): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + confirm: vi.fn(async () => true), + select: vi.fn(async () => "claude") as WizardPrompter["select"], + multiselect: vi.fn(async () => []) as WizardPrompter["multiselect"], + text: vi.fn(async () => "") as WizardPrompter["text"], + progress: vi.fn(() => ({ stop: vi.fn(), update: vi.fn() })), + } as WizardPrompter; +} + +function provider(params: { + source: string; + mutateDuringApply?: () => Promise; + importModel?: boolean; + deferred?: boolean; + retrySafeDeferred?: boolean; + deferredItemIds?: string[]; + onDeferredApply?: ( + itemId: string, + ctx: MigrationProviderContext, + ) => Promise<"already-satisfied" | "error" | "migrated">; +}): MigrationProviderPlugin { + return { + id: "claude", + label: "Claude", + ...(params.deferred && params.retrySafeDeferred !== false + ? { deferredApply: { retrySafe: true as const } } + : {}), + async plan(ctx) { + const workspace = ctx.config.agents?.defaults?.workspace; + if (!workspace) { + throw new Error("missing workspace"); + } + const items: MigrationPlan["items"] = [ + { + id: "workspace:memory", + kind: "memory", + action: "copy", + status: "planned", + source: params.source, + target: path.join(workspace, "MEMORY.md"), + }, + ]; + if (params.deferred) { + for (const itemId of params.deferredItemIds ?? ["plugin:calendar"]) { + items.push({ + id: itemId, + kind: "plugin", + action: "install", + status: "planned", + applyPhase: "after-promotion", + target: `plugins.entries.codex.config.codexPlugins.plugins.${itemId}`, + }); + } + } + return { + providerId: "claude", + source: params.source, + target: workspace, + items, + summary: summarizeMigrationItems(items), + }; + }, + async apply(ctx, plan): Promise { + if (!plan) { + throw new Error("missing plan"); + } + const items: MigrationItem[] = []; + for (const item of plan.items) { + if (item.status !== "planned") { + items.push(item); + continue; + } + if (item.id === "workspace:memory") { + await fs.mkdir(path.dirname(item.target!), { recursive: true }); + await fs.copyFile(item.source!, item.target!); + items.push({ ...item, status: "migrated" as const }); + continue; + } + if (item.applyPhase === "after-promotion") { + const status = (await params.onDeferredApply?.(item.id, ctx)) ?? "error"; + items.push( + status === "already-satisfied" + ? { + ...item, + status: "skipped", + deferredCompletion: true, + reason: "already satisfied", + } + : status === "migrated" + ? { ...item, status } + : { ...item, status, reason: "activation failed" }, + ); + continue; + } + items.push(item); + } + if (params.importModel) { + const configRuntime = ctx.configRuntime; + if (!configRuntime) { + throw new Error("missing staged config runtime"); + } + await configRuntime.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "none", reason: "staged migration test" }, + mutate(draft) { + draft.agents ??= {}; + draft.agents.defaults ??= {}; + draft.agents.defaults.model = { primary: "openai/gpt-5.6-sol" }; + }, + }); + } + await params.mutateDuringApply?.(); + return { + ...plan, + items, + summary: summarizeMigrationItems(items), + reportDir: ctx.reportDir, + }; + }, + }; +} + +async function runImport(params: { + root: string; + source: string; + currentConfig: { value: Record }; + commit?: ( + config: Record, + expectedConfig: Record, + ) => Promise>; +}) { + const workspace = path.join(params.root, "workspace"); + mocks.currentConfig = params.currentConfig; + process.env.OPENCLAW_STATE_DIR = path.join(params.root, "openclaw-state"); + return await runSetupMigrationImport({ + opts: { + importFrom: "claude", + importSource: params.source, + nonInteractive: true, + workspace, + }, + baseConfig: {}, + detections: [], + prompter: prompter(), + runtime: runtime(), + readConfigFile: async () => structuredClone(params.currentConfig.value), + commitConfigFile: async (config, expectedConfig) => { + const committed = params.commit + ? await params.commit( + config as Record, + expectedConfig as Record, + ) + : config; + params.currentConfig.value = structuredClone(committed as Record); + return committed; + }, + continueOnboarding: true, + }); +} + +beforeEach(() => { + previousStateDir = process.env.OPENCLAW_STATE_DIR; + mocks.currentConfig = undefined; + mocks.canonicalMutateConfigFile.mockReset(); + mocks.canonicalMutateConfigFile.mockImplementation( + async (mutation: Parameters[0]) => { + if (!mocks.currentConfig) { + throw new Error("missing current config fixture"); + } + const draft = structuredClone(mocks.currentConfig.value); + const result = await mutation.mutate(draft, { + snapshot: {} as never, + previousHash: "fixture-hash", + }); + mocks.currentConfig.value = structuredClone(draft); + return { + nextConfig: draft, + result, + path: "", + previousHash: "fixture-hash", + snapshot: {} as never, + persistedHash: "fixture-next-hash", + afterWrite: mutation.afterWrite, + followUp: { mode: "none", reason: "fixture", requiresRestart: false }, + }; + }, + ); + mocks.verify.mockReset(); + mocks.verify.mockResolvedValue({ + config: {}, + verified: true, + modelRef: "openai/gpt-5.6-sol", + }); +}); + +afterEach(async () => { + const [{ closeOpenClawAgentDatabasesForTest }, { closeOpenClawStateDatabaseForTest }] = + await Promise.all([ + import("../state/openclaw-agent-db.js"), + import("../state/openclaw-state-db.js"), + ]); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + mocks.provider = undefined; + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + for (const root of tempRoots) { + await fs.rm(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +describe("transactional setup migration import", () => { + it("promotes a Claude import with no model and returns no imported inference", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ source }); + const currentConfig = { value: {} }; + + const outcome = await runImport({ root, source, currentConfig }); + + expect(outcome).toEqual({ kind: "no-imported-inference" }); + expect(await fs.readFile(path.join(root, "workspace", "MEMORY.md"), "utf8")).toBe( + "remember this\n", + ); + expect(JSON.stringify(currentConfig.value)).not.toContain(".openclaw-migration-"); + expect(mocks.verify).not.toHaveBeenCalled(); + }); + + it("rejects deferred activation from providers without a retry-safe contract", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ source, deferred: true, retrySafeDeferred: false }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).rejects.toThrow( + "does not declare retry-safe deferred apply", + ); + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + expect(currentConfig.value).toEqual({}); + }); + + it("accepts an already-satisfied retry-safe deferred effect as complete", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ + source, + deferred: true, + onDeferredApply: async () => "already-satisfied", + }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).resolves.toEqual({ + kind: "no-imported-inference", + }); + + const reportRoot = path.join(root, "openclaw-state", "migration", "claude"); + const [reportDir] = await fs.readdir(reportRoot); + const report = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "report.json"), "utf8"), + ) as MigrationApplyResult; + expect(report.items.find((item) => item.id === "plugin:calendar")).toMatchObject({ + status: "skipped", + deferredCompletion: true, + }); + const journal = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "onboarding-promotion.json"), "utf8"), + ) as { status: string }; + expect(journal.status).toBe("completed"); + }); + + it("leaves the live target untouched when imported inference verification fails", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ source, importModel: true }); + mocks.verify.mockRejectedValueOnce(new Error("verification failed")); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).rejects.toThrow("verification failed"); + + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + expect(currentConfig.value).toEqual({}); + }); + + it("leaves the live target untouched when imported inference repair is cancelled", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ source, importModel: true }); + mocks.verify.mockRejectedValueOnce(new WizardCancelledError("cancelled")); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).rejects.toBeInstanceOf( + WizardCancelledError, + ); + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + expect(currentConfig.value).toEqual({}); + }); + + it("aborts promotion when the source changes after staged apply", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "before\n", "utf8"); + mocks.provider = provider({ + source, + mutateDuringApply: async () => { + await fs.appendFile(source, "after\n", "utf8"); + }, + }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).rejects.toThrow( + "Migration source changed before promotion", + ); + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + expect(currentConfig.value).toEqual({}); + }); + + it("aborts promotion when config changes during staged apply", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + const currentConfig = { value: {} }; + mocks.provider = provider({ + source, + mutateDuringApply: async () => { + currentConfig.value = { gateway: { port: 23456 } }; + }, + }); + + await expect(runImport({ root, source, currentConfig })).rejects.toThrow( + "Migration target changed before promotion", + ); + await expect(fs.access(path.join(root, "workspace", "MEMORY.md"))).rejects.toThrow(); + }); + + it("runs deferred activation only after promotion and keeps failures as warnings", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + const liveMemory = path.join(root, "workspace", "MEMORY.md"); + let deferredCalls = 0; + mocks.provider = provider({ + source, + deferred: true, + onDeferredApply: async () => { + deferredCalls += 1; + expect(await fs.readFile(liveMemory, "utf8")).toBe("remember this\n"); + return "error"; + }, + }); + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).resolves.toEqual({ + kind: "no-imported-inference", + }); + + expect(deferredCalls).toBe(1); + const reportRoot = path.join(root, "openclaw-state", "migration", "claude"); + const [reportDir] = await fs.readdir(reportRoot); + const report = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "report.json"), "utf8"), + ) as MigrationApplyResult; + expect(report.items.filter((item) => item.id === "plugin:calendar")).toHaveLength(1); + expect(report.items.find((item) => item.id === "plugin:calendar")?.status).toBe("warning"); + expect(report.warnings?.join("\n")).toContain( + "Retry only those steps with openclaw onboard --flow import --import-from claude", + ); + expect(JSON.stringify(report)).not.toContain(".openclaw-migration-"); + }); + + it("routes deferred config writes through the canonical runtime", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + mocks.provider = provider({ + source, + deferred: true, + onDeferredApply: async (_itemId, ctx) => { + await ctx.configRuntime?.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "none", reason: "migration activation test" }, + mutate(draft) { + draft.gateway = { ...draft.gateway, port: 23456 }; + }, + }); + return "migrated"; + }, + }); + const currentConfig = { value: {} }; + + await runImport({ root, source, currentConfig }); + + expect(mocks.canonicalMutateConfigFile).toHaveBeenCalledOnce(); + expect(currentConfig.value).toMatchObject({ gateway: { port: 23456 } }); + }); + + it("resumes only deferred activation after promotion without rerunning the import", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + let planCalls = 0; + let deferredCalls = 0; + const providerWithDeferredRetry = provider({ + source, + deferred: true, + onDeferredApply: async () => { + deferredCalls += 1; + return deferredCalls === 1 ? "error" : "migrated"; + }, + }); + const originalPlan = providerWithDeferredRetry.plan; + providerWithDeferredRetry.plan = async (ctx) => { + planCalls += 1; + return await originalPlan(ctx); + }; + mocks.provider = providerWithDeferredRetry; + const currentConfig = { value: {} }; + + await expect(runImport({ root, source, currentConfig })).resolves.toEqual({ + kind: "no-imported-inference", + }); + await expect(runImport({ root, source, currentConfig })).resolves.toEqual({ + kind: "no-imported-inference", + }); + + expect(planCalls).toBe(1); + expect(deferredCalls).toBe(2); + expect(await fs.readFile(path.join(root, "workspace", "MEMORY.md"), "utf8")).toBe( + "remember this\n", + ); + const reportRoot = path.join(root, "openclaw-state", "migration", "claude"); + const [reportDir] = await fs.readdir(reportRoot); + const report = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "report.json"), "utf8"), + ) as MigrationApplyResult; + expect(report.items.find((item) => item.id === "plugin:calendar")?.status).toBe("migrated"); + const journal = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "onboarding-promotion.json"), "utf8"), + ) as { status: string }; + expect(journal.status).toBe("completed"); + }); + + it("retries only deferred items that did not already activate", async () => { + const root = await makeTempRoot(); + const source = path.join(root, "source-memory.md"); + await fs.writeFile(source, "remember this\n", "utf8"); + const activationCalls: string[] = []; + mocks.provider = provider({ + source, + deferred: true, + deferredItemIds: ["plugin:calendar", "plugin:drive"], + onDeferredApply: async (itemId) => { + activationCalls.push(itemId); + return itemId === "plugin:calendar" ? "migrated" : "error"; + }, + }); + const currentConfig = { value: {} }; + + await runImport({ root, source, currentConfig }); + mocks.provider = provider({ + source, + deferred: true, + deferredItemIds: ["plugin:calendar", "plugin:drive"], + onDeferredApply: async (itemId) => { + activationCalls.push(itemId); + return "migrated"; + }, + }); + await runImport({ root, source, currentConfig }); + + expect(activationCalls).toEqual(["plugin:calendar", "plugin:drive", "plugin:drive"]); + const reportRoot = path.join(root, "openclaw-state", "migration", "claude"); + const [reportDir] = await fs.readdir(reportRoot); + const report = JSON.parse( + await fs.readFile(path.join(reportRoot, reportDir!, "report.json"), "utf8"), + ) as MigrationApplyResult; + expect(report.items.find((item) => item.id === "plugin:calendar")?.status).toBe("migrated"); + expect(report.items.find((item) => item.id === "plugin:drive")?.status).toBe("migrated"); + expect(report.warnings?.join("\n")).not.toContain("Retry only those steps"); + }); +}); diff --git a/src/wizard/setup.model-auth.test.ts b/src/wizard/setup.model-auth.test.ts index 7c60299fb992..23445e8110ef 100644 --- a/src/wizard/setup.model-auth.test.ts +++ b/src/wizard/setup.model-auth.test.ts @@ -92,6 +92,7 @@ describe("runSetupModelAuthStep", () => { expect(ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/ops-agent", { allowKeychainPrompt: false, + readOnly: true, }); expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: "/tmp/ops-workspace" }), diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts index a94c2e221360..fdac0391d8c5 100644 --- a/src/wizard/setup.model-auth.ts +++ b/src/wizard/setup.model-auth.ts @@ -131,8 +131,11 @@ export async function runSetupModelAuthStep(params: { opts: OnboardOptions; prompter: WizardPrompter; runtime: RuntimeEnv; + agentDir?: string; + stateDir?: string; }): Promise { const { opts, prompter, runtime } = params; + const env = params.stateDir ? { ...process.env, OPENCLAW_STATE_DIR: params.stateDir } : undefined; let nextConfig = params.stagedCandidate?.config ?? params.config; let replacementBaseConfig = params.config; let authProfiles: PreparedAuthChoiceResult["authProfiles"] = @@ -154,8 +157,9 @@ export async function runSetupModelAuthStep(params: { promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; const target = resolveOnboardingAgentTarget(nextConfig); - authStore = ensureAuthProfileStore(target.agentDir, { + authStore = ensureAuthProfileStore(params.agentDir ?? target.agentDir, { allowKeychainPrompt: false, + readOnly: true, }); } while (true) { @@ -244,9 +248,10 @@ export async function runSetupModelAuthStep(params: { prompter, runtime, agentId: target.agentId, - agentDir: target.agentDir, + agentDir: params.agentDir ?? target.agentDir, setDefaultModel: true, preserveExistingDefaultModel: true, + env, opts: { ...opts, token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index d1539b573452..4728ca7ae27f 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -32,6 +32,7 @@ type PrepareAuthChoice = typeof import("../commands/auth-choice.js").prepareAuth type VerifySetupInferenceConfig = typeof import("../system-agent/setup-inference.js").verifySetupInferenceConfig; type ConfigureGatewayForSetup = typeof import("./setup.gateway-config.js").configureGatewayForSetup; +type RunSetupMigrationImport = typeof import("./setup.migration-import.js").runSetupMigrationImport; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const); @@ -127,7 +128,9 @@ const enableDefaultOnboardingInternalHooks = vi.hoisted(() => ); const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => [])); const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => [])); -const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {})); +const runSetupMigrationImport = vi.hoisted(() => + vi.fn(async () => ({ kind: "no-imported-inference" })), +); const runSetupMemoryImportStep = vi.hoisted(() => vi.fn(async () => {})); const verifySetupInferenceConfig = vi.hoisted(() => vi.fn(async () => ({ @@ -569,6 +572,8 @@ describe("runSetupWizard", () => { warnIfModelConfigLooksOff.mockResolvedValue(undefined); buildPluginCompatibilitySnapshotNotices.mockReset(); buildPluginCompatibilitySnapshotNotices.mockReturnValue([]); + runSetupMigrationImport.mockReset(); + runSetupMigrationImport.mockResolvedValue({ kind: "no-imported-inference" }); verifySetupInferenceConfig.mockReset(); verifySetupInferenceConfig.mockResolvedValue({ ok: true, @@ -1157,8 +1162,42 @@ describe("runSetupWizard", () => { expect(runSetupMemoryImportStep).not.toHaveBeenCalled(); }); - it("requires a live check before keeping an imported default model", async () => { + it("continues onboarding after a recovered promotion", async () => { + const workspaceDir = await makeCaseDir("resumed-import-flow-"); + const acknowledgePromotion = vi.fn(async () => {}); + runSetupMigrationImport.mockResolvedValueOnce({ + kind: "no-imported-inference", + acknowledgePromotion, + }); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + importFrom: "hermes", + authChoice: "skip", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + workspace: workspaceDir, + }, + createRuntime(), + buildWizardPrompter(), + ); + + expect(finalizeSetupWizard).toHaveBeenCalledOnce(); + expect(acknowledgePromotion).toHaveBeenCalledOnce(); + }); + + it("consumes a verified imported model without testing it twice", async () => { const workspaceDir = await makeCaseDir("verified-import-flow-"); + runSetupMigrationImport.mockResolvedValueOnce({ + kind: "verified-inference", + modelRef: "openai/gpt-5.6-sol", + }); const importedConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } }, }; @@ -1186,23 +1225,47 @@ describe("runSetupWizard", () => { prompter, ); - expect(verifySetupInferenceConfig).toHaveBeenCalledOnce(); - expect(verifySetupInferenceConfig).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ - model: { primary: "openai/gpt-5.6-sol" }, - }), - }), - }), - }), - ); + expect(verifySetupInferenceConfig).not.toHaveBeenCalled(); + expect(applyAuthChoice).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalledWith( expect.objectContaining({ message: "Test AI access now with a live completion?" }), ); }); + it("does not reuse verification when the recovered model changed", async () => { + const workspaceDir = await makeCaseDir("changed-verified-import-flow-"); + runSetupMigrationImport.mockResolvedValueOnce({ + kind: "verified-inference", + modelRef: "openai/gpt-5.6-sol", + }); + const importedConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + readConfigFileSnapshot + .mockResolvedValueOnce(configSnapshot({}, false)) + .mockResolvedValue(configSnapshot(importedConfig)); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + importFrom: "hermes", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + workspace: workspaceDir, + }, + createRuntime(), + buildWizardPrompter(), + ); + + expect(applyAuthChoice).toHaveBeenCalledOnce(); + }); + it("keeps verification optional when provider setup supplies the post-import model", async () => { const workspaceDir = await makeCaseDir("provider-after-import-"); readConfigFileSnapshot @@ -1232,59 +1295,13 @@ describe("runSetupWizard", () => { prompter, ); + expect(applyAuthChoice).toHaveBeenCalledOnce(); expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: "Test AI access now with a live completion?" }), ); expect(verifySetupInferenceConfig).not.toHaveBeenCalled(); }); - it("repairs an imported route that fails its mandatory live check", async () => { - const workspaceDir = await makeCaseDir("repair-import-flow-"); - const importedConfig = { - agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } }, - }; - readConfigFileSnapshot - .mockResolvedValueOnce(configSnapshot({}, false)) - .mockResolvedValue(configSnapshot(importedConfig)); - promptAuthChoiceGrouped.mockResolvedValue("demo-provider"); - applyAuthChoice.mockResolvedValue({ - config: { agents: { defaults: { model: { primary: "openai/gpt-5.6" } } } }, - }); - verifySetupInferenceConfig - .mockResolvedValueOnce({ ok: false, status: "auth", error: "imported login expired" }) - .mockResolvedValueOnce({ - ok: true, - modelRef: "openai/gpt-5.6", - latencyMs: 250, - }); - const select = vi.fn() as unknown as WizardPrompter["select"]; - const prompter = buildWizardPrompter({ select }); - - await runSetupWizard( - { - acceptRisk: true, - flow: "quickstart", - importFrom: "hermes", - authChoice: "skip", - installDaemon: false, - skipChannels: true, - skipSkills: true, - skipSearch: true, - skipHealth: true, - skipUi: true, - workspace: workspaceDir, - }, - createRuntime(), - prompter, - ); - - expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(2); - expect(applyAuthChoice).toHaveBeenCalledOnce(); - expect(select).not.toHaveBeenCalledWith( - expect.objectContaining({ message: "How would you like to continue?" }), - ); - }); - it("treats --import-source alone as import intent instead of prompting for a setup mode", async () => { const workspaceDir = await makeCaseDir("import-source-intent-"); const prompter = buildWizardPrompter(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 26563fc4d508..27927b662ffc 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,13 +1,13 @@ +import { isDeepStrictEqual } from "node:util"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveOnboardingAgentTarget } from "../commands/onboard-agent-target.js"; import type { GatewayAuthChoice, OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; -import { resolveGatewayPort } from "../config/config.js"; +import { ConfigMutationConflictError, resolveGatewayPort } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { withConsoleSubsystemsSuppressed } from "../logging/console.js"; import { buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, @@ -19,12 +19,13 @@ import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; import { runWizardWithPromptNavigation } from "./navigation-prompter.js"; import type { WizardPrompter } from "./prompts.js"; +import { offerLiveModelVerification } from "./setup.inference-verification.js"; import { detectSetupMigrationSources, listSetupMigrationOptions, runSetupMigrationImport, } from "./setup.migration-import.js"; -import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js"; +import { runSetupModelAuthStep } from "./setup.model-auth.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { hasQuickstartGatewayOverrides, @@ -49,92 +50,6 @@ function hasConfiguredDefaultModel(config: OpenClawConfig): boolean { return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined; } -async function offerLiveModelVerification(params: { - config: OpenClawConfig; - opts: OnboardOptions; - prompter: WizardPrompter; - runtime: RuntimeEnv; - writeConfig: (config: OpenClawConfig) => Promise; - required?: boolean; -}): Promise<{ config: OpenClawConfig; verified: boolean }> { - if (!params.required) { - const shouldTest = await params.prompter.confirm({ - message: t("wizard.setup.testAiAccess"), - initialValue: true, - }); - if (!shouldTest) { - return { config: params.config, verified: false }; - } - } - const { verifySetupInferenceConfig } = await import("../system-agent/setup-inference.js"); - const verify = async (candidate: SetupModelAuthCandidate) => { - const progress = params.prompter.progress(t("wizard.setup.testAiProgress")); - const result = await withConsoleSubsystemsSuppressed(() => - verifySetupInferenceConfig({ - config: candidate.config, - runtime: params.runtime, - authProfiles: candidate.authProfiles, - }), - ); - progress.stop(); - if (result.ok) { - await params.prompter.note( - t("wizard.setup.testAiSuccess", { seconds: (result.latencyMs / 1000).toFixed(1) }), - t("wizard.setup.testAiTitle"), - ); - } else { - await params.prompter.note( - t("wizard.setup.testAiFailure", { reason: result.error }), - t("wizard.setup.testAiTitle"), - ); - } - return result; - }; - - let candidate: SetupModelAuthCandidate = { - config: params.config, - authProfiles: [], - persistAuthProfiles: async () => {}, - }; - let shouldPersistCandidate = false; - while (true) { - const result = await verify(candidate); - if (result.ok) { - if (!shouldPersistCandidate) { - return { config: params.config, verified: true }; - } - await candidate.persistAuthProfiles(result.authProfiles); - const config = await params.writeConfig(candidate.config); - return { config, verified: true }; - } - if (result.authProfiles) { - candidate.authProfiles = result.authProfiles; - } - if ( - !params.required && - (await params.prompter.select({ - message: t("wizard.setup.testAiFailureChoice"), - options: [ - { value: "fix", label: t("wizard.setup.testAiFix") }, - { value: "continue", label: t("wizard.setup.testAiContinue") }, - ], - })) === "continue" - ) { - return { config: params.config, verified: false }; - } - - // Attempts N>1 share the same gate and staged credentials until the user replaces them. - candidate = await runSetupModelAuthStep({ - config: params.config, - stagedCandidate: candidate, - opts: { ...params.opts, authChoice: undefined }, - prompter: params.prompter, - runtime: params.runtime, - }); - shouldPersistCandidate = true; - } -} - function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { return flow === "import" || flow.startsWith("import:"); } @@ -317,10 +232,12 @@ async function runSetupWizardOnce( } const usedImportFlow = Boolean(opts.importFrom || isSetupImportFlowChoice(flow)); + let acknowledgeMigrationPromotion: (() => Promise) | undefined; + let importedInferenceVerified = false; if (usedImportFlow) { const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow); prompter.disableBackNavigation?.(); - await runSetupMigrationImport({ + const migrationOutcome = await runSetupMigrationImport({ opts: { ...opts, ...(importFrom ? { importFrom } : {}), @@ -330,16 +247,37 @@ async function runSetupWizardOnce( prompter, runtime, readConfigFile: readValidSetupConfigFile, - commitConfigFile: (cfg) => writeWizardConfigFile(cfg, { allowConfigSizeDrop: true }), + async commitConfigFile(cfg, expectedConfig) { + const latest = await readSetupConfigFileSnapshot(); + if (!latest.valid) { + throw new Error("Migration target config became invalid. Run `openclaw doctor`."); + } + const latestConfig = latest.exists ? (latest.sourceConfig ?? latest.config) : {}; + if (!isDeepStrictEqual(latestConfig, expectedConfig)) { + throw new ConfigMutationConflictError("config changed during migration promotion", { + currentHash: latest.hash ?? null, + }); + } + return await writeWizardConfigFile(cfg, { + allowConfigSizeDrop: true, + baseSnapshot: latest, + ...(latest.hash !== undefined ? { baseHash: latest.hash } : {}), + }); + }, continueOnboarding: true, }); + acknowledgeMigrationPromotion = migrationOutcome.acknowledgePromotion; const migratedSnapshot = await readSetupConfigFileSnapshot(); if (!migratedSnapshot.valid) { throw new Error("Migration produced an invalid OpenClaw config. Run `openclaw doctor`."); } baseConfig = migratedSnapshot.sourceConfig ?? migratedSnapshot.config; pendingPluginInstallMigrationBaseConfig = baseConfig; - keepExistingModelConfig ||= hasConfiguredDefaultModel(baseConfig); + const importedModelRef = resolveAgentModelPrimaryValue(baseConfig.agents?.defaults?.model); + importedInferenceVerified = + migrationOutcome.kind === "verified-inference" && + importedModelRef === migrationOutcome.modelRef; + keepExistingModelConfig = importedInferenceVerified; flow = "quickstart"; } const wizardFlow: WizardFlow = flow === "advanced" ? "advanced" : "quickstart"; @@ -616,17 +554,19 @@ async function runSetupWizardOnce( // a route supplied by the import from one configured normally after the import. if ( opts.nonInteractive !== true && + !importedInferenceVerified && hasConfiguredDefaultModel(nextConfig) && - ((usedImportFlow && keepExistingModelConfig) || opts.authChoice !== "skip") + opts.authChoice !== "skip" ) { + const verificationTarget = resolveOnboardingAgentTarget(nextConfig); const verification = await offerLiveModelVerification({ config: nextConfig, opts, prompter, runtime, + workspaceDir: verificationTarget.workspaceDir, writeConfig: async (config) => await writeSetupConfigFile(config, { allowConfigSizeDrop: false }), - required: usedImportFlow && keepExistingModelConfig, }); nextConfig = verification.config; liveModelVerified = verification.verified; @@ -743,6 +683,7 @@ async function runSetupWizardOnce( prompter, runtime, }); + await acknowledgeMigrationPromotion?.(); if (finalizeResult.launchedTui) { runtime.exit(0); }