diff --git a/src/cli/help-exit.process.test.ts b/src/cli/help-exit.process.test.ts index e2574679725e..cb02b16540a9 100644 --- a/src/cli/help-exit.process.test.ts +++ b/src/cli/help-exit.process.test.ts @@ -111,8 +111,12 @@ async function runCliProcess(params: { stateEnv?: (stateDir: string) => Record; timeoutMs?: number; expectedExitCode?: number; + pristineHome?: boolean; }) { - const fixture = await createHelpProcessFixture(params.config); + const fixture = await createHelpProcessFixture(params.pristineHome ? undefined : params.config); + if (params.pristineHome) { + await fs.rm(fixture.stateDir, { force: true, recursive: true }); + } if (params.stateEnv) { const lines = Object.entries(params.stateEnv(fixture.stateDir)).map( ([key, value]) => `${key}=${value}`, @@ -151,9 +155,9 @@ async function runCliProcess(params: { NODE_ENV: undefined, NODE_OPTIONS: undefined, NODE_USE_SYSTEM_CA: "1", - OPENCLAW_CONFIG_PATH: fixture.configPath, + OPENCLAW_CONFIG_PATH: params.pristineHome ? undefined : fixture.configPath, OPENCLAW_NO_RESPAWN: params.allowRespawn ? undefined : "1", - OPENCLAW_STATE_DIR: fixture.stateDir, + OPENCLAW_STATE_DIR: params.pristineHome ? undefined : fixture.stateDir, VITEST: undefined, ...params.env, }, @@ -219,7 +223,7 @@ async function runCliProcess(params: { }), ); } - return { stderr, stdout }; + return { root: fixture.root, stderr, stdout }; } function parseJsonLines(stdout: string): Array> { @@ -370,6 +374,32 @@ describe("CLI help process exit", () => { }); }); +describe("rejected CLI process state isolation", () => { + it("does not scaffold a selected profile before option validation", async () => { + const profile = "rejected-profile"; + const result = await runCliProcess({ + args: [ + "onboard", + "--non-interactive", + "--accept-risk", + "--gateway-port", + "99999", + "--profile", + profile, + ], + expectedExitCode: 1, + pristineHome: true, + }); + + expect(result.stderr).toContain( + "Error: --gateway-port must be an integer between 1 and 65535.", + ); + await expect(fs.access(path.join(result.root, `.openclaw-${profile}`))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); + describe("JSON console style process output", () => { const loggingConfig = { logging: { diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 8d133411ce0e..3513d367b276 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -218,7 +218,7 @@ export async function runDoctorConfigPreflight( const gatewayStartupCheckpointRequired = options.requireStartupMigrationCheckpoint === true; const migrationCheckpointRequired = gatewayStartupCheckpointRequired || options.requireStateMigrationCheckpoint === true; - const migrationCheckpoint = migrationCheckpointRequired + let migrationCheckpoint = migrationCheckpointRequired ? await measurePreflightStep( "startup-checkpoint-import", () => import("../infra/startup-migration-checkpoint.js"), @@ -342,6 +342,11 @@ export async function runDoctorConfigPreflight( skipPristineStartupStateMigrations = pristineStatePlan.skipAllStateMigrations; skipPristineCoreStateMigrations ||= pristineStatePlan.skipCoreStateMigrations; } + if (skipPristineStartupStateMigrations && !gatewayStartupCheckpointRequired) { + // A pristine non-Gateway command has nothing to checkpoint. Leave the state root absent + // until command execution reaches a real state consumer. + migrationCheckpoint = undefined; + } // The gateway uses this last-moment guard to ensure its prepared config did not change before // any automatic migration mutates state. A rejected guard skips every state migration stage. const stateMigrationsAllowed = diff --git a/src/commands/doctor/shared/pristine-startup-state.test.ts b/src/commands/doctor/shared/pristine-startup-state.test.ts index 7513352f775e..c0df600b7a4c 100644 --- a/src/commands/doctor/shared/pristine-startup-state.test.ts +++ b/src/commands/doctor/shared/pristine-startup-state.test.ts @@ -106,6 +106,25 @@ afterEach(() => { }); describe("pristine startup state", () => { + it("accepts a missing explicitly selected profile root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pristine-profile-")); + roots.push(root); + const stateDir = path.join(root, ".openclaw-typo"); + fs.mkdirSync(path.join(root, ".clawdbot")); + + expect( + planPristineStartupStateMigrations({ + HOME: root, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + OPENCLAW_STATE_DIR: stateDir, + }), + ).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); + expect(fs.existsSync(stateDir)).toBe(false); + }); + it("accepts the core-only Gateway benchmark config", () => { const env = createFixture({ browser: { enabled: false }, diff --git a/src/commands/doctor/shared/pristine-startup-state.ts b/src/commands/doctor/shared/pristine-startup-state.ts index 968e5e12e070..882798042433 100644 --- a/src/commands/doctor/shared/pristine-startup-state.ts +++ b/src/commands/doctor/shared/pristine-startup-state.ts @@ -263,16 +263,20 @@ export function planPristineStartupStateMigrations( if (!homeDir) { return { skipAllStateMigrations: false, skipCoreStateMigrations: false }; } - const legacyStateAbsent = resolveLegacyStateDirs(() => homeDir).every((legacyDir) => { - if (path.resolve(legacyDir) === path.resolve(stateDir)) { - return false; - } - return !fs.existsSync(legacyDir); - }); + const explicitStateDir = env.OPENCLAW_STATE_DIR?.trim(); + const legacyStateAbsent = + Boolean(explicitStateDir) || + resolveLegacyStateDirs(() => homeDir).every((legacyDir) => { + if (path.resolve(legacyDir) === path.resolve(stateDir)) { + return false; + } + return !fs.existsSync(legacyDir); + }); if (!legacyStateAbsent) { return { skipAllStateMigrations: false, skipCoreStateMigrations: false }; } - const configPlan = planPristineStartupConfigMigrations(tryReadJsonSync(configPath), env); + const config = fs.existsSync(configPath) ? tryReadJsonSync(configPath) : {}; + const configPlan = planPristineStartupConfigMigrations(config, env); return { skipAllStateMigrations: configPlan.skipAllStateMigrations, skipCoreStateMigrations: configPlan.skipCoreStateMigrations,