From 59e0e7e4ffeb6853e4be24f3c25c5ceb1a212f2e Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 17 Feb 2026 00:17:14 +0530 Subject: [PATCH] Onboarding: fix webchat URL loopback and canonical session --- src/commands/onboard-helpers.e2e.test.ts | 31 ++++++ src/commands/onboard-helpers.ts | 47 ++++++++- src/wizard/onboarding.finalize.test.ts | 124 +++++++++++++++++++++++ src/wizard/onboarding.finalize.ts | 30 ++++-- 4 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 src/wizard/onboarding.finalize.test.ts diff --git a/src/commands/onboard-helpers.e2e.test.ts b/src/commands/onboard-helpers.e2e.test.ts index f0d7a1843..3ea706109 100644 --- a/src/commands/onboard-helpers.e2e.test.ts +++ b/src/commands/onboard-helpers.e2e.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildWebchatUrl, normalizeGatewayTokenInput, openUrl, resolveBrowserOpenCommand, resolveControlUiLinks, + resolveLocalBrowserControlUiLinks, validateGatewayPasswordInput, } from "./onboard-helpers.js"; @@ -107,6 +109,35 @@ describe("resolveControlUiLinks", () => { expect(links.httpUrl).toBe("http://127.0.0.1:18789/"); expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); }); + + it("coerces lan bind to loopback for local browser links", () => { + const links = resolveLocalBrowserControlUiLinks({ + port: 18789, + bind: "lan", + }); + expect(links.httpUrl).toBe("http://127.0.0.1:18789/"); + expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); + }); +}); + +describe("buildWebchatUrl", () => { + it("encodes canonical session key exactly once", () => { + const url = buildWebchatUrl({ + httpUrl: "http://127.0.0.1:18789/", + sessionKey: "agent:main:main", + }); + expect(url).toBe("http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain"); + }); + + it("preserves base path and appends token in fragment", () => { + const url = buildWebchatUrl({ + httpUrl: "http://127.0.0.1:18789/ui/", + sessionKey: "agent:main:main", + token: "abc 123", + }); + expect(url).toBe("http://127.0.0.1:18789/ui/chat?session=agent%3Amain%3Amain#token=abc%20123"); + expect(url).not.toContain("%2520"); + }); }); describe("normalizeGatewayTokenInput", () => { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index f1c40d3b7..cbdbba268 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -8,7 +8,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { CONFIG_PATH } from "../config/config.js"; -import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { resolveMainSessionKey, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { pickPrimaryLanIPv4, isValidIPv4 } from "../gateway/net.js"; @@ -454,12 +454,17 @@ function summarizeError(err: unknown): string { export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR; -export function resolveControlUiLinks(params: { +type ControlUiLinksParams = { port: number; bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; customBindHost?: string; basePath?: string; -}): { httpUrl: string; wsUrl: string } { +}; + +export function resolveControlUiLinks(params: ControlUiLinksParams): { + httpUrl: string; + wsUrl: string; +} { const port = params.port; const bind = params.bind ?? "loopback"; const customBindHost = params.customBindHost?.trim(); @@ -484,3 +489,39 @@ export function resolveControlUiLinks(params: { wsUrl: `ws://${host}:${port}${wsPath}`, }; } + +export function resolveLocalBrowserControlUiLinks(params: ControlUiLinksParams): { + httpUrl: string; + wsUrl: string; +} { + const bind = params.bind === "lan" ? "loopback" : params.bind; + return resolveControlUiLinks({ + ...params, + bind, + }); +} + +export function resolveCanonicalMainSessionKey(cfg: OpenClawConfig): string { + return resolveMainSessionKey(cfg); +} + +export function buildWebchatUrl(params: { + httpUrl: string; + sessionKey: string; + token?: string; +}): string { + const base = new URL(params.httpUrl); + if (!base.pathname.endsWith("/")) { + base.pathname = `${base.pathname}/`; + } + + const chatUrl = new URL("chat", base); + chatUrl.searchParams.set("session", params.sessionKey.trim()); + + const token = params.token?.trim(); + if (token) { + chatUrl.hash = `token=${encodeURIComponent(token)}`; + } + + return chatUrl.toString(); +} diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts new file mode 100644 index 000000000..b904b0b52 --- /dev/null +++ b/src/wizard/onboarding.finalize.test.ts @@ -0,0 +1,124 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OnboardOptions } from "../commands/onboard-types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "./prompts.js"; +import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; + +const mocks = vi.hoisted(() => ({ + detectBrowserOpenSupport: vi.fn(async () => ({ ok: true })), + openUrl: vi.fn(async () => true), + probeGatewayReachable: vi.fn(async () => ({ ok: true })), + ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })), + setupOnboardingShellCompletion: vi.fn(async () => {}), + runTui: vi.fn(async () => {}), +})); + +vi.mock("../commands/onboard-helpers.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + detectBrowserOpenSupport: mocks.detectBrowserOpenSupport, + openUrl: mocks.openUrl, + probeGatewayReachable: mocks.probeGatewayReachable, + }; +}); + +vi.mock("../infra/control-ui-assets.js", () => ({ + ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt, +})); + +vi.mock("./onboarding.completion.js", () => ({ + setupOnboardingShellCompletion: mocks.setupOnboardingShellCompletion, +})); + +vi.mock("../tui/tui.js", () => ({ + runTui: mocks.runTui, +})); + +function createPrompter(overrides?: Partial): WizardPrompter { + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "How do you want to hatch your bot?") { + return "web" as never; + } + return (params.initialValue ?? params.options[0]?.value) as never; + }); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select, + multiselect, + text: vi.fn(async () => ""), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + })), + ...overrides, + }; +} + +function createRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit:${code}`); + }), + }; +} + +describe("finalizeOnboardingWizard", () => { + afterEach(() => { + vi.restoreAllMocks(); + mocks.detectBrowserOpenSupport.mockClear(); + mocks.openUrl.mockClear(); + mocks.probeGatewayReachable.mockClear(); + mocks.ensureControlUiAssetsBuilt.mockClear(); + mocks.setupOnboardingShellCompletion.mockClear(); + mocks.runTui.mockClear(); + }); + + it("opens loopback webchat URL with canonical main session key when bind=lan", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboarding-finalize-")); + + const opts: OnboardOptions = { + installDaemon: false, + skipHealth: true, + skipProviders: true, + skipSkills: true, + }; + const prompter = createPrompter(); + const runtime = createRuntime(); + + await finalizeOnboardingWizard({ + flow: "quickstart", + opts, + baseConfig: {}, + nextConfig: {}, + workspaceDir, + settings: { + port: 18789, + bind: "lan", + authMode: "token", + gatewayToken: "test token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime, + }); + + expect(mocks.openUrl).toHaveBeenCalledWith( + "http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain#token=test%20token", + ); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); +}); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b73979915..a4951b459 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -18,12 +18,15 @@ import { import { formatHealthCheckFailure } from "../commands/health-format.js"; import { healthCommand } from "../commands/health.js"; import { + buildWebchatUrl, detectBrowserOpenSupport, formatControlUiSshHint, openUrl, probeGatewayReachable, + resolveCanonicalMainSessionKey, waitForGatewayReachable, resolveControlUiLinks, + resolveLocalBrowserControlUiLinks, } from "../commands/onboard-helpers.js"; import { resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; @@ -250,10 +253,22 @@ export async function finalizeOnboardingWizard( customBindHost: settings.customBindHost, basePath: controlUiBasePath, }); + const localBrowserLinks = resolveLocalBrowserControlUiLinks({ + bind: settings.bind, + port: settings.port, + customBindHost: settings.customBindHost, + basePath: controlUiBasePath, + }); + const canonicalSessionKey = resolveCanonicalMainSessionKey(nextConfig); const authedUrl = settings.authMode === "token" && settings.gatewayToken - ? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}` - : links.httpUrl; + ? `${localBrowserLinks.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}` + : localBrowserLinks.httpUrl; + const webchatUrl = buildWebchatUrl({ + httpUrl: localBrowserLinks.httpUrl, + sessionKey: canonicalSessionKey, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + }); const gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, @@ -273,10 +288,11 @@ export async function finalizeOnboardingWizard( await prompter.note( [ - `Web UI: ${links.httpUrl}`, + `Web UI: ${localBrowserLinks.httpUrl}`, settings.authMode === "token" && settings.gatewayToken ? `Web UI (with token): ${authedUrl}` : undefined, + `WebChat: ${webchatUrl}`, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, "Docs: https://docs.openclaw.ai/web/control-ui", @@ -342,7 +358,7 @@ export async function finalizeOnboardingWizard( } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { - controlUiOpened = await openUrl(authedUrl); + controlUiOpened = await openUrl(webchatUrl); if (!controlUiOpened) { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, @@ -359,7 +375,7 @@ export async function finalizeOnboardingWizard( } await prompter.note( [ - `Dashboard link (with token): ${authedUrl}`, + `WebChat link: ${webchatUrl}`, controlUiOpened ? "Opened in your browser. Keep that tab to control OpenClaw." : "Copy/paste this URL in a browser on this machine to control OpenClaw.", @@ -402,7 +418,7 @@ export async function finalizeOnboardingWizard( if (shouldOpenControlUi) { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { - controlUiOpened = await openUrl(authedUrl); + controlUiOpened = await openUrl(webchatUrl); if (!controlUiOpened) { controlUiOpenHint = formatControlUiSshHint({ port: settings.port, @@ -420,7 +436,7 @@ export async function finalizeOnboardingWizard( await prompter.note( [ - `Dashboard link (with token): ${authedUrl}`, + `WebChat link: ${webchatUrl}`, controlUiOpened ? "Opened in your browser. Keep that tab to control OpenClaw." : "Copy/paste this URL in a browser on this machine to control OpenClaw.",