Skip to content

Commit d0281e2

Browse files
committed
fix(cli): exit cleanly on migration refusal
1 parent 82fa956 commit d0281e2

2 files changed

Lines changed: 134 additions & 3 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Process regression for typed gateway startup-migration refusal and lease cleanup.
2+
import { spawnSync } from "node:child_process";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
import { DatabaseSync } from "node:sqlite";
7+
import { describe, expect, it } from "vitest";
8+
import { hasActiveStartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
9+
10+
const STARTUP_REFUSAL =
11+
"OpenClaw startup migrations did not complete cleanly; refusing to report the gateway ready.";
12+
13+
function seedPluginStateConflict(stateDir: string): void {
14+
const sharedPath = path.join(stateDir, "state", "openclaw.sqlite");
15+
const sidecarPath = path.join(stateDir, "plugin-state", "state.sqlite");
16+
fs.mkdirSync(path.dirname(sharedPath), { recursive: true });
17+
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
18+
19+
const shared = new DatabaseSync(sharedPath);
20+
try {
21+
shared.exec(`
22+
CREATE TABLE plugin_state_entries (
23+
plugin_id TEXT NOT NULL,
24+
namespace TEXT NOT NULL,
25+
entry_key TEXT NOT NULL,
26+
value_json TEXT NOT NULL,
27+
created_at INTEGER NOT NULL,
28+
expires_at INTEGER,
29+
PRIMARY KEY (plugin_id, namespace, entry_key)
30+
);
31+
`);
32+
shared
33+
.prepare(`
34+
INSERT INTO plugin_state_entries (
35+
plugin_id, namespace, entry_key, value_json, created_at, expires_at
36+
) VALUES (?, ?, ?, ?, ?, ?)
37+
`)
38+
.run("discord", "components", "interaction:1", '{"ok":false}', 2_000, null);
39+
} finally {
40+
shared.close();
41+
}
42+
43+
const sidecar = new DatabaseSync(sidecarPath);
44+
try {
45+
sidecar.exec(`
46+
CREATE TABLE plugin_state_entries (
47+
plugin_id TEXT NOT NULL,
48+
namespace TEXT NOT NULL,
49+
entry_key TEXT NOT NULL,
50+
value_json TEXT NOT NULL,
51+
created_at INTEGER NOT NULL,
52+
expires_at INTEGER,
53+
PRIMARY KEY (plugin_id, namespace, entry_key)
54+
);
55+
`);
56+
sidecar
57+
.prepare(`
58+
INSERT INTO plugin_state_entries (
59+
plugin_id, namespace, entry_key, value_json, created_at, expires_at
60+
) VALUES (?, ?, ?, ?, ?, ?)
61+
`)
62+
// Older or equal sidecar rows can be archived; a newer divergent row must stay unresolved.
63+
.run("discord", "components", "interaction:1", '{"ok":true}', 3_000, null);
64+
} finally {
65+
sidecar.close();
66+
}
67+
}
68+
69+
describe("gateway startup-migration refusal", () => {
70+
it("exits cleanly after reporting the refusal once and releasing its lease", async () => {
71+
const temporaryRoot = await fs.promises.mkdtemp(
72+
path.join(os.tmpdir(), "openclaw-startup-migration-exit-"),
73+
);
74+
const root = await fs.promises.realpath(temporaryRoot);
75+
const stateDir = path.join(root, "state");
76+
const configPath = path.join(root, "openclaw.json");
77+
const env: NodeJS.ProcessEnv = {
78+
...process.env,
79+
HOME: root,
80+
USERPROFILE: root,
81+
OPENCLAW_CONFIG_PATH: configPath,
82+
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
83+
OPENCLAW_STATE_DIR: stateDir,
84+
OPENCLAW_TEST_FAST: "1",
85+
NO_COLOR: "1",
86+
};
87+
delete env.NODE_ENV;
88+
delete env.OPENCLAW_HOME;
89+
delete env.VITEST;
90+
91+
try {
92+
fs.mkdirSync(stateDir, { recursive: true });
93+
fs.writeFileSync(
94+
configPath,
95+
JSON.stringify({ gateway: { mode: "local", auth: { mode: "none" } } }),
96+
);
97+
seedPluginStateConflict(stateDir);
98+
99+
const result = spawnSync(
100+
process.execPath,
101+
["--import", "tsx", path.resolve("src/entry.ts"), "gateway", "run", "--allow-unconfigured"],
102+
{
103+
cwd: path.resolve("."),
104+
encoding: "utf8",
105+
env,
106+
timeout: 30_000,
107+
},
108+
);
109+
const output = `${result.stderr}\n${result.stdout}`;
110+
111+
expect(result.error, output).toBeUndefined();
112+
expect(result.status, output).toBe(1);
113+
expect(result.signal, output).toBeNull();
114+
expect(result.stderr).toContain(STARTUP_REFUSAL);
115+
expect(result.stderr.split(STARTUP_REFUSAL)).toHaveLength(2);
116+
expect(result.stderr).not.toContain("[openclaw] Could not start the CLI.");
117+
expect(hasActiveStartupMigrationLease({ env })).toBe(false);
118+
} finally {
119+
await fs.promises.rm(root, { recursive: true, force: true });
120+
}
121+
}, 45_000);
122+
});

src/commands/doctor-config-preflight.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js";
1515
import type { OpenClawConfig } from "../config/types.openclaw.js";
1616
import { isTruthyEnvValue } from "../infra/env.js";
1717
import type { StartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
18+
import { ExitError } from "../runtime.js";
1819
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
1920
import { resolveHomeDir } from "../utils.js";
2021
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
@@ -215,6 +216,12 @@ function formatStartupPluginVerificationFailure(
215216
].join("\n");
216217
}
217218

219+
function throwStartupMigrationRefusal(message: string): never {
220+
// ExitError bypasses entry.ts's generic failure formatter, so report the owned reason here.
221+
console.error(message);
222+
throw new ExitError(1, message);
223+
}
224+
218225
function throwStartupMigrationGuardRejected(): never {
219226
throw new Error(
220227
"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,15 +483,15 @@ export async function runDoctorConfigPreflight(
476483
: new Error("OpenClaw startup migration lease heartbeat failed.");
477484
}
478485
if (startupMigrationWarnings.length > 0) {
479-
throw new Error(
486+
throwStartupMigrationRefusal(
480487
formatStartupMigrationFailure({
481488
warnings: startupMigrationWarnings,
482489
blockers: [],
483490
}),
484491
);
485492
}
486493
if (!snapshot.valid) {
487-
throw new Error(
494+
throwStartupMigrationRefusal(
488495
formatStartupMigrationFailure({
489496
warnings: [],
490497
blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'],
@@ -496,7 +503,9 @@ export async function runDoctorConfigPreflight(
496503
env: process.env,
497504
});
498505
if (pluginVerificationDiagnostic) {
499-
throw new Error(formatStartupPluginVerificationFailure(pluginVerificationDiagnostic));
506+
throwStartupMigrationRefusal(
507+
formatStartupPluginVerificationFailure(pluginVerificationDiagnostic),
508+
);
500509
}
501510
startupCheckpoint?.recordSuccessfulStartupMigrations({
502511
env: startupMigrationEnv,

0 commit comments

Comments
 (0)