Merge pull request #90 from alexanderwcheney/fix/bootstrap-gateway-mode-ordering

fix(bootstrap): set gateway.mode before onboard to prevent crash loop
This commit is contained in:
Kumar Abhirup 2026-03-15 02:16:57 -07:00 committed by GitHub
commit e490380d01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 177 additions and 5 deletions

View File

@ -162,6 +162,8 @@ describe("bootstrapCommand always-onboard behavior", () => {
let healthFailuresBeforeSuccess = 0;
let healthCallCount = 0;
let alwaysHealthFail = false;
let gatewayModeConfigValue = "local\n";
let driftGatewayModeAfterOnboard = false;
beforeEach(() => {
homeDir = createTempStateDir();
@ -173,6 +175,8 @@ describe("bootstrapCommand always-onboard behavior", () => {
healthFailuresBeforeSuccess = 0;
healthCallCount = 0;
alwaysHealthFail = false;
gatewayModeConfigValue = "local\n";
driftGatewayModeAfterOnboard = false;
process.env = {
...originalEnv,
HOME: homeDir,
@ -234,7 +238,22 @@ describe("bootstrapCommand always-onboard behavior", () => {
argList.includes("get") &&
argList.includes("gateway.mode")
) {
return createMockChild({ code: 0, stdout: "local\n" }) as never;
return createMockChild({ code: 0, stdout: gatewayModeConfigValue }) as never;
}
if (
commandString === "openclaw" &&
argList.includes("config") &&
argList.includes("set") &&
argList.includes("gateway.mode")
) {
gatewayModeConfigValue = `${argList.at(-1) ?? ""}\n`;
return createMockChild({ code: 0, stdout: "ok\n" }) as never;
}
if (commandString === "openclaw" && argList.includes("onboard")) {
if (driftGatewayModeAfterOnboard) {
gatewayModeConfigValue = "remote\n";
}
return createMockChild({ code: 0, stdout: "ok\n" }) as never;
}
if (commandString === "openclaw" && argList.includes("health")) {
healthCallCount += 1;
@ -316,6 +335,152 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(summary.onboarded).toBe(true);
});
it("sets gateway.mode before onboard when config is missing it (prevents first-start daemon crash loop)", async () => {
gatewayModeConfigValue = "\n";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const gatewayModeGetIndex = spawnCalls.findIndex(
(call) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("get") &&
call.args.includes("gateway.mode"),
);
const gatewayModeSetIndex = spawnCalls.findIndex(
(call) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("set") &&
call.args.includes("gateway.mode") &&
call.args.includes("local"),
);
const onboardIndex = spawnCalls.findIndex(
(call) => call.command === "openclaw" && call.args.includes("onboard"),
);
expect(gatewayModeGetIndex).toBeGreaterThan(-1);
expect(gatewayModeSetIndex).toBeGreaterThan(-1);
expect(onboardIndex).toBeGreaterThan(-1);
expect(gatewayModeGetIndex).toBeLessThan(gatewayModeSetIndex);
expect(gatewayModeSetIndex).toBeLessThan(onboardIndex);
});
it("sets gateway.port before onboard so the first daemon start uses DenchClaw's selected port", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const gatewayPortSetIndex = spawnCalls.findIndex(
(call) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("set") &&
call.args.includes("gateway.port"),
);
const onboardIndex = spawnCalls.findIndex(
(call) => call.command === "openclaw" && call.args.includes("onboard"),
);
expect(gatewayPortSetIndex).toBeGreaterThan(-1);
expect(onboardIndex).toBeGreaterThan(-1);
expect(gatewayPortSetIndex).toBeLessThan(onboardIndex);
});
it("rechecks gateway.mode after onboard when onboarding drifts it away from local (keeps DenchClaw on a local gateway)", async () => {
gatewayModeConfigValue = "\n";
driftGatewayModeAfterOnboard = true;
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const gatewayModeSetIndices = spawnCalls.flatMap((call, index) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("set") &&
call.args.includes("gateway.mode") &&
call.args.includes("local")
? [index]
: [],
);
const onboardIndex = spawnCalls.findIndex(
(call) => call.command === "openclaw" && call.args.includes("onboard"),
);
expect(gatewayModeSetIndices).toHaveLength(2);
expect(onboardIndex).toBeGreaterThan(-1);
expect(gatewayModeSetIndices[0]).toBeLessThan(onboardIndex);
expect(gatewayModeSetIndices[1]).toBeGreaterThan(onboardIndex);
});
it("reapplies gateway.port after onboard so onboarding defaults cannot desync DenchClaw's gateway target", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const gatewayPortSetIndices = spawnCalls.flatMap((call, index) =>
call.command === "openclaw" &&
call.args.includes("config") &&
call.args.includes("set") &&
call.args.includes("gateway.port")
? [index]
: [],
);
const onboardIndex = spawnCalls.findIndex(
(call) => call.command === "openclaw" && call.args.includes("onboard"),
);
expect(gatewayPortSetIndices).toHaveLength(2);
expect(onboardIndex).toBeGreaterThan(-1);
expect(gatewayPortSetIndices[0]).toBeLessThan(onboardIndex);
expect(gatewayPortSetIndices[1]).toBeGreaterThan(onboardIndex);
});
it("ignores bootstrap --profile override and keeps dench profile (prevents profile drift)", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),

View File

@ -1738,6 +1738,15 @@ export async function bootstrapCommand(
posthogKey: process.env.POSTHOG_KEY || "",
});
// Ensure gateway.mode=local BEFORE onboard so the daemon starts successfully.
// Previously this ran post-onboard, but onboard --install-daemon starts the
// gateway immediately — if gateway.mode is unset at that point the daemon
// blocks with "set gateway.mode=local" and enters a crash loop.
await ensureGatewayModeLocal(openclawCommand, profile);
// Persist the assigned port so the daemon binds to the correct port on first
// start rather than falling back to the default.
await ensureGatewayPort(openclawCommand, profile, gatewayPort);
const onboardArgv = [
"--profile",
profile,
@ -1781,12 +1790,10 @@ export async function bootstrapCommand(
const postOnboardSpinner = !opts.json ? spinner() : null;
postOnboardSpinner?.start("Finalizing configuration…");
// Ensure gateway.mode=local so the gateway never drifts to remote mode.
// Keep this post-onboard so we normalize any wizard defaults.
// Re-apply gateway settings after onboard so interactive/wizard flows cannot
// drift DenchClaw away from its required local gateway and selected port.
await ensureGatewayModeLocal(openclawCommand, profile);
postOnboardSpinner?.message("Configuring gateway port…");
// Persist the assigned port so all runtime clients (including web) resolve
// the same gateway target on subsequent requests.
await ensureGatewayPort(openclawCommand, profile, gatewayPort);
postOnboardSpinner?.message("Setting tools profile…");
// DenchClaw requires the full tool profile; onboarding defaults can drift to