From 13cdeba7e30b2c1bfb8d9795295025dfde6a9980 Mon Sep 17 00:00:00 2001 From: Alex Cheney Date: Tue, 10 Mar 2026 14:37:24 -0700 Subject: [PATCH 1/2] fix(bootstrap): set gateway.mode before onboard to prevent crash loop During bootstrap, `onboard --install-daemon` starts the gateway daemon immediately. The gateway's startup guard requires `gateway.mode=local` but this was only set *after* onboard completed, causing the daemon to block with "Gateway start blocked: set gateway.mode=local" and enter a crash loop. The web UI then fails with "Gateway WebSocket connection failed". Move `ensureGatewayModeLocal()` and `ensureGatewayPort()` to run before the onboard command so the config is in place when the daemon first starts. Co-Authored-By: Claude Opus 4.6 --- src/cli/bootstrap-external.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index f08f77c3d75..1fc3d35f669 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -1709,6 +1709,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, @@ -1752,13 +1761,8 @@ 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. - 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); + // gateway.mode and gateway.port are now set pre-onboard (before + // --install-daemon) so the daemon can start without blocking. See above. postOnboardSpinner?.message("Setting tools profile…"); // DenchClaw requires the full tool profile; onboarding defaults can drift to // messaging-only, so enforce this on every bootstrap run. From 2efe2ecf4170e4b497190e578cc8934c1bd9220d Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Mar 2026 01:05:43 -0700 Subject: [PATCH 2/2] fix(bootstrap): preserve gateway config around onboarding Set gateway mode and port before onboarding so the first daemon start succeeds, then reapply them after onboarding so wizard defaults cannot drift DenchClaw off its expected local gateway. --- ...otstrap-external.bootstrap-command.test.ts | 167 +++++++++++++++++- src/cli/bootstrap-external.ts | 7 +- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 5e778207e1d..9cb45686050 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -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(), diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index 1fc3d35f669..babb6aec384 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -1761,8 +1761,11 @@ export async function bootstrapCommand( const postOnboardSpinner = !opts.json ? spinner() : null; postOnboardSpinner?.start("Finalizing configuration…"); - // gateway.mode and gateway.port are now set pre-onboard (before - // --install-daemon) so the daemon can start without blocking. See above. + // 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…"); + await ensureGatewayPort(openclawCommand, profile, gatewayPort); postOnboardSpinner?.message("Setting tools profile…"); // DenchClaw requires the full tool profile; onboarding defaults can drift to // messaging-only, so enforce this on every bootstrap run.