From c08ce42cefc9df782c10e80da66f2ef860459105 Mon Sep 17 00:00:00 2001 From: Vito Cappello Date: Fri, 17 Jul 2026 21:29:44 -0400 Subject: [PATCH] fix(cli): exit cleanly on migration refusal (#110207) Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --- .../doctor-config-preflight.process.test.ts | 122 ++++++++++++++++++ src/commands/doctor-config-preflight.ts | 15 ++- 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/commands/doctor-config-preflight.process.test.ts diff --git a/src/commands/doctor-config-preflight.process.test.ts b/src/commands/doctor-config-preflight.process.test.ts new file mode 100644 index 000000000000..8db42523f362 --- /dev/null +++ b/src/commands/doctor-config-preflight.process.test.ts @@ -0,0 +1,122 @@ +// Process regression for typed gateway startup-migration refusal and lease cleanup. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; +import { hasActiveStartupMigrationLease } from "../infra/startup-migration-checkpoint.js"; + +const STARTUP_REFUSAL = + "OpenClaw startup migrations did not complete cleanly; refusing to report the gateway ready."; + +function seedPluginStateConflict(stateDir: string): void { + const sharedPath = path.join(stateDir, "state", "openclaw.sqlite"); + const sidecarPath = path.join(stateDir, "plugin-state", "state.sqlite"); + fs.mkdirSync(path.dirname(sharedPath), { recursive: true }); + fs.mkdirSync(path.dirname(sidecarPath), { recursive: true }); + + const shared = new DatabaseSync(sharedPath); + try { + shared.exec(` + CREATE TABLE plugin_state_entries ( + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + entry_key TEXT NOT NULL, + value_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (plugin_id, namespace, entry_key) + ); + `); + shared + .prepare(` + INSERT INTO plugin_state_entries ( + plugin_id, namespace, entry_key, value_json, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?) + `) + .run("discord", "components", "interaction:1", '{"ok":false}', 2_000, null); + } finally { + shared.close(); + } + + const sidecar = new DatabaseSync(sidecarPath); + try { + sidecar.exec(` + CREATE TABLE plugin_state_entries ( + plugin_id TEXT NOT NULL, + namespace TEXT NOT NULL, + entry_key TEXT NOT NULL, + value_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (plugin_id, namespace, entry_key) + ); + `); + sidecar + .prepare(` + INSERT INTO plugin_state_entries ( + plugin_id, namespace, entry_key, value_json, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?) + `) + // Older or equal sidecar rows can be archived; a newer divergent row must stay unresolved. + .run("discord", "components", "interaction:1", '{"ok":true}', 3_000, null); + } finally { + sidecar.close(); + } +} + +describe("gateway startup-migration refusal", () => { + it("exits cleanly after reporting the refusal once and releasing its lease", async () => { + const temporaryRoot = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "openclaw-startup-migration-exit-"), + ); + const root = await fs.promises.realpath(temporaryRoot); + const stateDir = path.join(root, "state"); + const configPath = path.join(root, "openclaw.json"); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: root, + USERPROFILE: root, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + NO_COLOR: "1", + }; + delete env.NODE_ENV; + delete env.OPENCLAW_HOME; + delete env.VITEST; + + try { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ gateway: { mode: "local", auth: { mode: "none" } } }), + ); + seedPluginStateConflict(stateDir); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", path.resolve("src/entry.ts"), "gateway", "run", "--allow-unconfigured"], + { + cwd: path.resolve("."), + encoding: "utf8", + env, + timeout: 30_000, + }, + ); + const output = `${result.stderr}\n${result.stdout}`; + + expect(result.error, output).toBeUndefined(); + expect(result.status, output).toBe(1); + expect(result.signal, output).toBeNull(); + expect(result.stderr).toContain(STARTUP_REFUSAL); + expect(result.stderr.split(STARTUP_REFUSAL)).toHaveLength(2); + expect(result.stderr).not.toContain("[openclaw] Could not start the CLI."); + expect(hasActiveStartupMigrationLease({ env })).toBe(false); + } finally { + await fs.promises.rm(root, { recursive: true, force: true }); + } + }, 45_000); +}); diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 259556432f4c..c6ae48b05920 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -15,6 +15,7 @@ import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { StartupMigrationLease } from "../infra/startup-migration-checkpoint.js"; +import { ExitError } from "../runtime.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveHomeDir } from "../utils.js"; import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js"; @@ -215,6 +216,12 @@ function formatStartupPluginVerificationFailure( ].join("\n"); } +function throwStartupMigrationRefusal(message: string): never { + // ExitError bypasses entry.ts's generic failure formatter, so report the owned reason here. + console.error(message); + throw new ExitError(1, message); +} + function throwStartupMigrationGuardRejected(): never { throw new Error( "OpenClaw startup migrations were skipped because the selected config changed during startup; refusing to report the gateway ready. Retry startup so the new config can be validated.", @@ -476,7 +483,7 @@ export async function runDoctorConfigPreflight( : new Error("OpenClaw startup migration lease heartbeat failed."); } if (startupMigrationWarnings.length > 0) { - throw new Error( + throwStartupMigrationRefusal( formatStartupMigrationFailure({ warnings: startupMigrationWarnings, blockers: [], @@ -484,7 +491,7 @@ export async function runDoctorConfigPreflight( ); } if (!snapshot.valid) { - throw new Error( + throwStartupMigrationRefusal( formatStartupMigrationFailure({ warnings: [], blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'], @@ -496,7 +503,9 @@ export async function runDoctorConfigPreflight( env: process.env, }); if (pluginVerificationDiagnostic) { - throw new Error(formatStartupPluginVerificationFailure(pluginVerificationDiagnostic)); + throwStartupMigrationRefusal( + formatStartupPluginVerificationFailure(pluginVerificationDiagnostic), + ); } startupCheckpoint?.recordSuccessfulStartupMigrations({ env: startupMigrationEnv,