diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 1853514ea78..fa3f4de3f59 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -2,13 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { buildChain, buildControlUiUrls } from "./contract.js"; +import { buildChain, buildControlUiUrls, buildFallbackControlUiUrls } from "./contract.js"; describe("buildChain", () => { it("returns default loopback chain with no arguments", () => { const c = buildChain(); expect(c).toMatchObject({ accessUrl: "http://127.0.0.1:18789", + fallbackUrls: [], forwardTarget: "18789", healthEndpoint: "/health", port: 18789, @@ -36,14 +37,33 @@ describe("buildChain", () => { expect(c.shouldDisableDeviceAuth).toBe(true); }); - it("uses WSL host address and binds to 0.0.0.0", () => { + it("keeps loopback primary on WSL and offers the host IP as a fallback", () => { const c = buildChain({ isWsl: true, wslHostAddress: "172.24.240.1" }); + expect(c.accessUrl).toBe("http://127.0.0.1:18789"); + expect(c.fallbackUrls).toEqual(["http://172.24.240.1:18789"]); expect(c.forwardTarget).toBe("0.0.0.0:18789"); - expect(c.accessUrl).toBe("http://172.24.240.1:18789"); - expect(c.corsOrigins).toContain("http://172.24.240.1:18789"); + expect(c.bindAddress).toBe("0.0.0.0"); + expect(c.corsOrigins).toEqual(["http://127.0.0.1:18789", "http://172.24.240.1:18789"]); expect(c.shouldDisableDeviceAuth).toBe(true); }); + it("offers no fallback when WSL host address is unavailable", () => { + const c = buildChain({ isWsl: true, wslHostAddress: null }); + expect(c.accessUrl).toBe("http://127.0.0.1:18789"); + expect(c.fallbackUrls).toEqual([]); + expect(c.forwardTarget).toBe("0.0.0.0:18789"); + }); + + it("prefers an explicit non-loopback chatUiUrl over the WSL fallback", () => { + const c = buildChain({ + isWsl: true, + wslHostAddress: "172.24.240.1", + chatUiUrl: "https://example.com:18789", + }); + expect(c.accessUrl).toBe("https://example.com:18789"); + expect(c.fallbackUrls).toEqual([]); + }); + it("respects explicit port override", () => { expect(buildChain({ port: 19000 }).port).toBe(19000); }); @@ -155,3 +175,29 @@ describe("buildControlUiUrls", () => { expect(urls[0]).toContain("#token=a%3Db%26c"); }); }); + +describe("buildFallbackControlUiUrls", () => { + it("rewrites the fallback host's port to the requested port", () => { + const urls = buildFallbackControlUiUrls("tok", 8642, ["http://172.24.240.1:18789"]); + expect(urls).toEqual(["http://172.24.240.1:8642/#token=tok"]); + }); + + it("returns an empty array when there are no fallback URLs", () => { + expect(buildFallbackControlUiUrls(null, 8642, [])).toEqual([]); + }); + + it("drops an unparseable fallback URL", () => { + const urls = buildFallbackControlUiUrls(null, 8642, ["http://[invalid"]); + expect(urls).toEqual([]); + }); + + it("drops fallback URLs that are not http/https", () => { + const urls = buildFallbackControlUiUrls(null, 8642, [ + "ftp://x.com", + "javascript:alert(1)", + "data:text/html,hi", + "http://valid.example.com:18789", + ]); + expect(urls).toEqual(["http://valid.example.com:8642/"]); + }); +}); diff --git a/src/lib/dashboard/contract.ts b/src/lib/dashboard/contract.ts index 65e588a8181..45d63cbb2ed 100644 --- a/src/lib/dashboard/contract.ts +++ b/src/lib/dashboard/contract.ts @@ -29,6 +29,14 @@ export interface PlatformHints { export interface DashboardDeliveryChain { accessUrl: string; + /** + * Reachable URLs that are not the primary `accessUrl` but should be offered + * as fallbacks. On WSL2 this holds the `hostname -I` host IP: loopback is + * the primary URL because WSL forwards Windows' `127.0.0.1` into the VM by + * default, but when that forwarding is unavailable the host IP is the only + * address reachable from a Windows browser. (#6171) + */ + fallbackUrls: string[]; corsOrigins: string[]; forwardTarget: string; healthEndpoint: string; @@ -91,12 +99,18 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { const hasNonLoopbackUrl = chatUiUrl !== "" && !isLoopbackUrl(chatUiUrl); let accessUrl: string; + const fallbackUrls: string[] = []; if (hasNonLoopbackUrl) { accessUrl = ensureScheme(chatUiUrl); - } else if (h.isWsl && h.wslHostAddress) { - accessUrl = `http://${h.wslHostAddress}:${port}`; } else { + // Loopback is the primary URL on every host, including WSL: modern WSL2 + // forwards Windows' `127.0.0.1` into the VM, and the dashboard forward + // already binds `0.0.0.0` (see `forwardTarget` below). The WSL host IP is + // kept as a fallback for setups where that forwarding is unavailable. (#6171) accessUrl = `http://127.0.0.1:${port}`; + if (h.isWsl && h.wslHostAddress) { + fallbackUrls.push(`http://${h.wslHostAddress}:${port}`); + } } // #3259 — operator opt-in via NEMOCLAW_DASHBOARD_BIND=0.0.0.0 for remote-SSH-deployed @@ -107,17 +121,17 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { h.isWsl || hasNonLoopbackUrl || remoteBindOptIn ? `0.0.0.0:${port}` : String(port); const bindAddress = forwardTarget.includes(":") ? "0.0.0.0" : "127.0.0.1"; const loopbackOrigin = `http://127.0.0.1:${port}`; - const accessOrigin = (() => { + const toOrigin = (value: string): string | null => { try { - return new URL(accessUrl).origin; + return new URL(value).origin; } catch { return null; } - })(); - const corsOrigins = - accessOrigin && accessOrigin !== loopbackOrigin - ? [loopbackOrigin, accessOrigin] - : [loopbackOrigin]; + }; + const extraOrigins = [accessUrl, ...fallbackUrls] + .map(toOrigin) + .filter((origin): origin is string => origin !== null && origin !== loopbackOrigin); + const corsOrigins = [loopbackOrigin, ...new Set(extraOrigins)]; const shouldDisableDeviceAuth = hasNonLoopbackUrl || (h.isWsl ?? false) || remoteBindOptIn; const dashboardHealthEndpoint = normalizeEndpointPath(h.dashboardHealthEndpoint, "/health"); @@ -132,6 +146,7 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { return { accessUrl, + fallbackUrls, corsOrigins, forwardTarget, healthEndpoint: dashboardHealthEndpoint, @@ -159,3 +174,21 @@ export function buildControlUiUrls( } return [...new Set(urls)]; } + +export function buildFallbackControlUiUrls( + token: string | null, + port: number, + fallbackUrls: string[], +): string[] { + return fallbackUrls.flatMap((fallback) => { + let url: URL; + try { + url = new URL(fallback); + } catch { + return []; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return []; + url.port = String(port); + return buildControlUiUrls(token, port, url.toString()).slice(1); + }); +} diff --git a/src/lib/onboard/dashboard-access.ts b/src/lib/onboard/dashboard-access.ts index c9a68e83802..61a97095716 100644 --- a/src/lib/onboard/dashboard-access.ts +++ b/src/lib/onboard/dashboard-access.ts @@ -144,12 +144,8 @@ export function getDashboardAccessInfo( }), ); - const wslHostAddress = getWslHostAddress(options); - if (wslHostAddress) { - const wslUrl = buildAuthenticatedDashboardUrl( - `http://${wslHostAddress}:${chain.port}/`, - token ?? null, - ); + for (const fallback of chain.fallbackUrls) { + const wslUrl = buildAuthenticatedDashboardUrl(`${fallback.replace(/\/$/, "")}/`, token ?? null); const existing = dashboardAccess.find((access) => access.url === wslUrl); if (existing) { existing.label = "WSL fallback"; diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index c72027a308e..73f85a2ff27 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -7,12 +7,12 @@ import path from "node:path"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts"; import type { AgentDefinition } from "../agent/defs"; import { DASHBOARD_PORT } from "../core/ports"; -import { buildChain, buildControlUiUrls } from "../dashboard/contract"; +import { buildChain, buildControlUiUrls, buildFallbackControlUiUrls } from "../dashboard/contract"; import * as nim from "../inference/nim"; import { runCapture as defaultRunCapture } from "../runner"; -import { fetchAgentWebAuthTokenFromSandbox as fetchAgentWebAuthToken } from "./agent-web-auth-token"; import { ensureAgentDashboardForward as ensureAgentDashboardForwardForAgent } from "./agent-dashboard-forward"; import { ensureAgentFixedForward as ensureFixedAgentForward } from "./agent-fixed-forward"; +import { fetchAgentWebAuthTokenFromSandbox as fetchAgentWebAuthToken } from "./agent-web-auth-token"; import * as dashboardAccess from "./dashboard-access"; import { createSandboxForwardStopper, @@ -168,6 +168,15 @@ function dashboardUrlForDisplay(url: string, deps: OnboardDashboardDeps): string return dashboardAccess.dashboardUrlForDisplay(url, deps.redact); } +function printWslFallback(fallbackDashboardUrls: string[], indent: string): void { + if (fallbackDashboardUrls.length === 0) return; + console.log(""); + console.log(`${indent}Browser (WSL fallback, if 127.0.0.1 is unreachable from Windows):`); + for (const fallbackUrl of fallbackDashboardUrls) { + console.log(`${indent} ${fallbackUrl}`); + } +} + export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): OnboardDashboardHelpers { const runCapture = deps.runCapture ?? defaultRunCapture; @@ -439,13 +448,19 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa const chain = buildChain({ chatUiUrl, isWsl: deps.isWsl(), - wslHostAddress: getWslHostAddress(), + wslHostAddress: getWslHostAddress({ isWsl: deps.isWsl(), runCapture: deps.runCapture }), }); const dashboardBaseUrl = `${chain.accessUrl.replace(/\/$/, "")}/`; const dashboardUrl = dashboardUrlForDisplay( dashboardAccess.buildAuthenticatedDashboardUrl(dashboardBaseUrl, token), deps, ); + const fallbackDashboardUrls = chain.fallbackUrls.map((fallback) => + dashboardUrlForDisplay( + dashboardAccess.buildAuthenticatedDashboardUrl(`${fallback.replace(/\/$/, "")}/`, token), + deps, + ), + ); console.log(""); console.log(` ${"─".repeat(50)}`); @@ -463,7 +478,12 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa deps.printAgentDashboardUi(sandboxName, token, agent, { note: deps.note, buildControlUiUrls: (tokenValue: string | null, port: number) => { - return buildControlUiUrls(tokenValue, port, chain.accessUrl); + const primary = buildControlUiUrls(tokenValue, port); + const alternates = buildFallbackControlUiUrls(tokenValue, port, [ + chain.accessUrl, + ...chain.fallbackUrls, + ]); + return [...new Set([...primary, ...alternates])]; }, }); console.log(""); @@ -474,6 +494,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); + printWslFallback(fallbackDashboardUrls, " "); console.log(""); console.log(" Terminal:"); console.log(` ${deps.cliName()} ${sandboxName} connect`); @@ -487,6 +508,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); + printWslFallback(fallbackDashboardUrls, " "); console.log(""); console.log(" Terminal:"); console.log(` ${deps.cliName()} ${sandboxName} connect`); diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts index 442ee19c88e..72c394745df 100644 --- a/test/helpers/onboard-final-flow-phases.ts +++ b/test/helpers/onboard-final-flow-phases.ts @@ -253,6 +253,7 @@ export function createPhases( getChatUiUrl: () => "http://127.0.0.1:45123", buildVerifyChain: (): DashboardDeliveryChain => ({ accessUrl: "http://127.0.0.1:45123", + fallbackUrls: [], corsOrigins: ["http://127.0.0.1:45123"], forwardTarget: "45123", healthEndpoint: "/health", diff --git a/test/onboard-dashboard.test.ts b/test/onboard-dashboard.test.ts index 2ce5bc28323..d126cfe7fbc 100644 --- a/test/onboard-dashboard.test.ts +++ b/test/onboard-dashboard.test.ts @@ -13,6 +13,20 @@ const { createOnboardDashboardHelpers } = require("../src/lib/onboard/dashboard" createOnboardDashboardHelpers: (deps: OnboardDashboardDeps) => OnboardDashboardHelpers; }; +function createTokenDownloadRunOpenshell() { + return vi.fn((args: string[], _opts?: Record) => { + if (args.join(" ").startsWith("sandbox download ")) { + const destDir = args[4]; + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync( + path.join(destDir, "openclaw.json"), + JSON.stringify({ gateway: { auth: { token: "secret-token" } } }), + ); + } + return { status: 0 }; + }); +} + describe("onboard dashboard helpers", () => { it("prints platform-appropriate service hints for port conflicts", () => { expect(getPortConflictServiceHints("darwin").join("\n")).toMatch(/launchctl unload/); @@ -173,17 +187,7 @@ describe("onboard dashboard helpers", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const nimStatus = vi.fn(() => ({ running: false, container: "nemoclaw-nim-test" })); const shouldShowNimLine = vi.fn(() => false); - const runOpenshell = vi.fn((args: string[], _opts?: Record) => { - if (args.join(" ").startsWith("sandbox download ")) { - const destDir = args[4]; - fs.mkdirSync(destDir, { recursive: true }); - fs.writeFileSync( - path.join(destDir, "openclaw.json"), - JSON.stringify({ gateway: { auth: { token: "secret-token" } } }), - ); - } - return { status: 0 }; - }); + const runOpenshell = createTokenDownloadRunOpenshell(); const helpers = createOnboardDashboardHelpers({ runOpenshell, runCaptureOpenshell: vi.fn(() => ""), @@ -222,6 +226,75 @@ describe("onboard dashboard helpers", () => { expect(nimStatus).toHaveBeenCalledWith("my-gpt-claw"); }); + it("shows the loopback dashboard URL with a WSL host-IP fallback under WSL", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runOpenshell = createTokenDownloadRunOpenshell(); + const helpers = createOnboardDashboardHelpers({ + runOpenshell, + runCaptureOpenshell: vi.fn(() => ""), + runCapture: vi.fn(() => "172.22.1.1 10.0.0.2\n"), + openshellArgv: (args: string[]) => [process.execPath, "-e", "", ...args], + cliName: () => "nemoclaw", + agentProductName: () => "NemoClaw", + getProviderLabel: (provider: string) => provider, + nimStatus: vi.fn(() => ({ running: false, container: "nemoclaw-nim-test" })), + shouldShowNimLine: vi.fn(() => false), + note: vi.fn(), + isWsl: () => true, + redact: (value: unknown) => String(value), + sleep: vi.fn(), + printAgentDashboardUi: vi.fn(), + listSandboxes: () => ({ sandboxes: [] }), + }); + + let output = ""; + try { + helpers.printDashboard("my-gpt-claw", "gpt-oss:20b", "ollama"); + output = logSpy.mock.calls.map(([line]) => String(line)).join("\n"); + } finally { + logSpy.mockRestore(); + } + + expect(output).toContain("http://127.0.0.1:"); + expect(output).toContain("WSL fallback"); + expect(output).toContain("http://172.22.1.1:"); + // Loopback stays the primary browser URL; the WSL host IP follows it. + expect(output.indexOf("http://127.0.0.1:")).toBeLessThan(output.indexOf("http://172.22.1.1:")); + expect(output).not.toMatch(/secret[-_]?token/); + }); + + it("gives the agent dashboard both primary and port-rewritten WSL fallback URLs", () => { + const runOpenshell = createTokenDownloadRunOpenshell(); + const printAgentDashboardUi = vi.fn(); + const helpers = createOnboardDashboardHelpers({ + runOpenshell, + runCaptureOpenshell: vi.fn(() => ""), + runCapture: vi.fn(() => "172.22.1.1 10.0.0.2\n"), + openshellArgv: (args: string[]) => [process.execPath, "-e", "", ...args], + cliName: () => "nemoclaw", + agentProductName: () => "NemoClaw", + getProviderLabel: (provider: string) => provider, + nimStatus: vi.fn(() => ({ running: false, container: "nemoclaw-nim-test" })), + shouldShowNimLine: vi.fn(() => false), + note: vi.fn(), + isWsl: () => true, + redact: (value: unknown) => String(value), + sleep: vi.fn(), + printAgentDashboardUi, + listSandboxes: () => ({ sandboxes: [] }), + }); + const agent = { dashboard: { auth: "url_token" } } as never; + + helpers.printDashboard("my-hermes", "gpt-oss:20b", "ollama", null, agent); + + const [, , , agentDeps] = printAgentDashboardUi.mock.calls[0]; + const urls: string[] = agentDeps.buildControlUiUrls("secret-token", 8642); + + expect(urls).toContain("http://127.0.0.1:8642/#token=secret-token"); + expect(urls.some((url) => url.startsWith("http://172.22.1.1:8642/"))).toBe(true); + expect(urls.some((url) => url.includes(":18789"))).toBe(false); + }); + it("prints a token-free browser URL when the dashboard token is unavailable", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const note = vi.fn();