diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 3d14bb82e41..29ed2eefe0a 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -5,7 +5,12 @@ import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; const runTui = vi.hoisted(() => vi.fn(async () => {})); -const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); +const probeGatewayReachable = vi.hoisted(() => + vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), +); +const waitForGatewayReachable = vi.hoisted(() => + vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), +); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const buildGatewayInstallPlan = vi.hoisted(() => vi.fn(async () => ({ @@ -58,7 +63,7 @@ vi.mock("../commands/onboard-helpers.js", () => ({ httpUrl: "http://127.0.0.1:18789", wsUrl: "ws://127.0.0.1:18789", })), - waitForGatewayReachable: vi.fn(async () => {}), + waitForGatewayReachable, })); vi.mock("../commands/daemon-install-helpers.js", () => ({ @@ -233,6 +238,8 @@ describe("finalizeSetupWizard", () => { beforeEach(() => { runTui.mockClear(); probeGatewayReachable.mockClear(); + waitForGatewayReachable.mockReset(); + waitForGatewayReachable.mockResolvedValue({ ok: true }); setupWizardShellCompletion.mockClear(); buildGatewayInstallPlan.mockClear(); gatewayServiceInstall.mockClear(); @@ -505,4 +512,48 @@ describe("finalizeSetupWizard", () => { "Web search", ); }); + + it("shows actionable gateway guidance instead of a hard error in no-daemon onboarding", async () => { + waitForGatewayReachable.mockResolvedValue({ + ok: false, + detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", + }); + probeGatewayReachable.mockResolvedValue({ + ok: false, + detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", + }); + const prompter = createLaterPrompter(); + const runtime = createRuntime(); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: false, + skipUi: false, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime, + }); + + expect(runtime.error).not.toHaveBeenCalledWith("health failed"); + expect(prompter.note).toHaveBeenCalledWith( + expect.stringContaining("Setup was run without Gateway service install"), + "Gateway", + ); + expect(prompter.note).not.toHaveBeenCalledWith(expect.any(String), "Dashboard ready"); + }); }); diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index a3879d985ff..9e72ec1590d 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -51,6 +51,7 @@ export async function finalizeSetupWizard( options: FinalizeOnboardingOptions, ): Promise<{ launchedTui: boolean }> { const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; + let gatewayProbe: { ok: boolean; detail?: string } = { ok: true }; const withWizardProgress = async ( label: string, @@ -232,15 +233,33 @@ export async function finalizeSetupWizard( basePath: undefined, }); // Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail. - await waitForGatewayReachable({ + gatewayProbe = await waitForGatewayReachable({ url: probeLinks.wsUrl, token: settings.gatewayToken, deadlineMs: 15_000, }); - try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } catch (err) { - runtime.error(formatHealthCheckFailure(err)); + if (gatewayProbe.ok) { + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(formatHealthCheckFailure(err)); + await prompter.note( + [ + "Docs:", + "https://docs.openclaw.ai/gateway/health", + "https://docs.openclaw.ai/gateway/troubleshooting", + ].join("\n"), + "Health check help", + ); + } + } else if (installDaemon) { + runtime.error( + formatHealthCheckFailure( + new Error( + gatewayProbe.detail ?? `gateway did not become reachable at ${probeLinks.wsUrl}`, + ), + ), + ); await prompter.note( [ "Docs:", @@ -249,6 +268,17 @@ export async function finalizeSetupWizard( ].join("\n"), "Health check help", ); + } else { + await prompter.note( + [ + "Gateway not detected yet.", + "Setup was run without Gateway service install, so no background gateway is expected.", + `Start now: ${formatCliCommand("openclaw gateway run")}`, + `Or rerun with: ${formatCliCommand("openclaw onboard --install-daemon")}`, + `Or skip this probe next time: ${formatCliCommand("openclaw onboard --skip-health")}`, + ].join("\n"), + "Gateway", + ); } } @@ -304,11 +334,13 @@ export async function finalizeSetupWizard( } } - const gatewayProbe = await probeGatewayReachable({ - url: links.wsUrl, - token: settings.authMode === "token" ? settings.gatewayToken : undefined, - password: settings.authMode === "password" ? resolvedGatewayPassword : "", - }); + if (opts.skipHealth || !gatewayProbe.ok) { + gatewayProbe = await probeGatewayReachable({ + url: links.wsUrl, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + password: settings.authMode === "password" ? resolvedGatewayPassword : "", + }); + } const gatewayStatusLine = gatewayProbe.ok ? "Gateway: reachable" : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; @@ -446,6 +478,7 @@ export async function finalizeSetupWizard( const shouldOpenControlUi = !opts.skipUi && + gatewayProbe.ok && settings.authMode === "token" && Boolean(settings.gatewayToken) && hatchChoice === null;