fix(cli): exit cleanly on migration refusal (#110207)

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
This commit is contained in:
Vito Cappello
2026-07-17 21:29:44 -04:00
committed by GitHub
parent e9553a80f9
commit c08ce42cef
2 changed files with 134 additions and 3 deletions

View File

@@ -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);
});

View File

@@ -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,