diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 6829375fcd8..63a19f70916 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 5588141b61a..52c877cd7e9 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -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