From 5196a9d8b205c69676aff34e09d0637858a2db6a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 07:02:40 +0000 Subject: [PATCH 01/11] fix(dashboard): keep loopback dashboard URL on WSL2 Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.test.ts | 6 +++--- src/lib/dashboard/contract.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 1853514ea78..2522558ea92 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -36,11 +36,11 @@ describe("buildChain", () => { expect(c.shouldDisableDeviceAuth).toBe(true); }); - it("uses WSL host address and binds to 0.0.0.0", () => { + it("binds to 0.0.0.0 for WSL but keeps the loopback access URL", () => { const c = buildChain({ isWsl: true, wslHostAddress: "172.24.240.1" }); 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.accessUrl).toBe("http://127.0.0.1:18789"); + expect(c.corsOrigins).toEqual(["http://127.0.0.1:18789"]); expect(c.shouldDisableDeviceAuth).toBe(true); }); diff --git a/src/lib/dashboard/contract.ts b/src/lib/dashboard/contract.ts index 65e588a8181..bc5b34bd0c3 100644 --- a/src/lib/dashboard/contract.ts +++ b/src/lib/dashboard/contract.ts @@ -93,8 +93,6 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { let accessUrl: string; if (hasNonLoopbackUrl) { accessUrl = ensureScheme(chatUiUrl); - } else if (h.isWsl && h.wslHostAddress) { - accessUrl = `http://${h.wslHostAddress}:${port}`; } else { accessUrl = `http://127.0.0.1:${port}`; } From 3397f5bbd556c2c117f9459b9abc54ff331cb451 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 08:21:58 +0000 Subject: [PATCH 02/11] fix(dashboard): keep WSL host IP as a dashboard fallback Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.test.ts | 16 ++++++-- src/lib/dashboard/contract.ts | 31 +++++++++++---- src/lib/onboard/dashboard.ts | 24 +++++++++++- test/helpers/onboard-final-flow-phases.ts | 1 + test/onboard-dashboard.test.ts | 47 +++++++++++++++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 2522558ea92..4edecf2f35c 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -9,6 +9,7 @@ describe("buildChain", () => { const c = buildChain(); expect(c).toMatchObject({ accessUrl: "http://127.0.0.1:18789", + fallbackUrls: [], forwardTarget: "18789", healthEndpoint: "/health", port: 18789, @@ -36,14 +37,23 @@ describe("buildChain", () => { expect(c.shouldDisableDeviceAuth).toBe(true); }); - it("binds to 0.0.0.0 for WSL but keeps the loopback access URL", () => { + 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.forwardTarget).toBe("0.0.0.0:18789"); expect(c.accessUrl).toBe("http://127.0.0.1:18789"); - expect(c.corsOrigins).toEqual(["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.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("respects explicit port override", () => { expect(buildChain({ port: 19000 }).port).toBe(19000); }); diff --git a/src/lib/dashboard/contract.ts b/src/lib/dashboard/contract.ts index bc5b34bd0c3..48cbc7f9411 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,10 +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 { + // 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 @@ -105,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"); @@ -130,6 +146,7 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain { return { accessUrl, + fallbackUrls, corsOrigins, forwardTarget, healthEndpoint: dashboardHealthEndpoint, diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index c72027a308e..a875b81c668 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -439,13 +439,27 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa const chain = buildChain({ chatUiUrl, isWsl: deps.isWsl(), - wslHostAddress: getWslHostAddress(), + wslHostAddress: getWslHostAddress({ isWsl: deps.isWsl() }), }); 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, + ), + ); + const printWslFallback = (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}`); + } + }; console.log(""); console.log(` ${"─".repeat(50)}`); @@ -463,7 +477,11 @@ 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, chain.accessUrl); + const fallbacks = chain.fallbackUrls.flatMap((fallback) => + buildControlUiUrls(tokenValue, port, fallback).slice(1), + ); + return [...new Set([...primary, ...fallbacks])]; }, }); console.log(""); @@ -474,6 +492,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); + printWslFallback(" "); console.log(""); console.log(" Terminal:"); console.log(` ${deps.cliName()} ${sandboxName} connect`); @@ -487,6 +506,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); + printWslFallback(" "); 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..97263859300 100644 --- a/test/onboard-dashboard.test.ts +++ b/test/onboard-dashboard.test.ts @@ -222,6 +222,53 @@ 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 = 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 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("prints a token-free browser URL when the dashboard token is unavailable", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const note = vi.fn(); From 0af84a1bcdd96e296db972e945f9e2789eb3cec8 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 09:29:22 +0000 Subject: [PATCH 03/11] fix(dashboard): make WSL fallback URLs port-aware for agent dashboards Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.test.ts | 18 +++++++++++++++++- src/lib/dashboard/contract.ts | 18 ++++++++++++++++++ src/lib/onboard/dashboard.ts | 8 +++----- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 4edecf2f35c..085102f3455 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -2,7 +2,7 @@ // 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", () => { @@ -165,3 +165,19 @@ 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("leaves an unparseable fallback URL's port unrewritten", () => { + const urls = buildFallbackControlUiUrls(null, 8642, ["http://[invalid"]); + expect(urls).toEqual(["http://[invalid/"]); + }); +}); diff --git a/src/lib/dashboard/contract.ts b/src/lib/dashboard/contract.ts index 48cbc7f9411..7d37c3e9b19 100644 --- a/src/lib/dashboard/contract.ts +++ b/src/lib/dashboard/contract.ts @@ -174,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 rewritten = fallback; + try { + const url = new URL(fallback); + url.port = String(port); + rewritten = url.toString(); + } catch { + rewritten = fallback; + } + return buildControlUiUrls(token, port, rewritten).slice(1); + }); +} diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index a875b81c668..6748def6f0c 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, @@ -478,9 +478,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa note: deps.note, buildControlUiUrls: (tokenValue: string | null, port: number) => { const primary = buildControlUiUrls(tokenValue, port, chain.accessUrl); - const fallbacks = chain.fallbackUrls.flatMap((fallback) => - buildControlUiUrls(tokenValue, port, fallback).slice(1), - ); + const fallbacks = buildFallbackControlUiUrls(tokenValue, port, chain.fallbackUrls); return [...new Set([...primary, ...fallbacks])]; }, }); From 5b8fb5af8bf2efd695df20ed1eb033ab8ea83518 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 09:29:41 +0000 Subject: [PATCH 04/11] test(onboard): dedupe token-download runOpenshell mock Signed-off-by: Tinson Lai --- test/onboard-dashboard.test.ts | 38 ++++++++++++++-------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/test/onboard-dashboard.test.ts b/test/onboard-dashboard.test.ts index 97263859300..9d7860ff213 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(() => ""), @@ -224,17 +228,7 @@ describe("onboard dashboard helpers", () => { it("shows the loopback dashboard URL with a WSL host-IP fallback under WSL", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); - 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(() => ""), From 22151604645967b0547285e0b2b64e74a0aed4df Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 10:02:46 +0000 Subject: [PATCH 05/11] refactor(dashboard): drop useless initializer in buildFallbackControlUiUrls Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/dashboard/contract.ts b/src/lib/dashboard/contract.ts index 7d37c3e9b19..b74aad901b0 100644 --- a/src/lib/dashboard/contract.ts +++ b/src/lib/dashboard/contract.ts @@ -181,7 +181,7 @@ export function buildFallbackControlUiUrls( fallbackUrls: string[], ): string[] { return fallbackUrls.flatMap((fallback) => { - let rewritten = fallback; + let rewritten: string; try { const url = new URL(fallback); url.port = String(port); From f1e21995fc86883c5f6973641e6db45f10d42fc9 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 10:06:38 +0000 Subject: [PATCH 06/11] refactor(dashboard): extract printWslFallback to module scope Signed-off-by: Tinson Lai --- src/lib/onboard/dashboard.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 6748def6f0c..1cbcb11bfd7 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -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; @@ -452,14 +461,6 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa deps, ), ); - const printWslFallback = (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}`); - } - }; console.log(""); console.log(` ${"─".repeat(50)}`); @@ -490,7 +491,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); - printWslFallback(" "); + printWslFallback(fallbackDashboardUrls, " "); console.log(""); console.log(" Terminal:"); console.log(` ${deps.cliName()} ${sandboxName} connect`); @@ -504,7 +505,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); console.log(" Browser:"); console.log(` ${dashboardUrl}`); - printWslFallback(" "); + printWslFallback(fallbackDashboardUrls, " "); console.log(""); console.log(" Terminal:"); console.log(` ${deps.cliName()} ${sandboxName} connect`); From 5c20b0c48fe216814f86cebade9cc1b84fe492cd Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 10:06:49 +0000 Subject: [PATCH 07/11] test(dashboard): cover non-loopback chatUiUrl precedence over WSL fallback Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 085102f3455..6f44c8f1da3 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -54,6 +54,16 @@ describe("buildChain", () => { 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); }); From 4d57f9586d0113161960d9dd5a9bfa8b84c45a0d Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 10:39:20 +0000 Subject: [PATCH 08/11] fix(dashboard): validate fallback URL scheme in buildFallbackControlUiUrls Signed-off-by: Tinson Lai --- src/lib/dashboard/contract.test.ts | 14 ++++++++++++-- src/lib/dashboard/contract.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/lib/dashboard/contract.test.ts b/src/lib/dashboard/contract.test.ts index 6f44c8f1da3..fa3f4de3f59 100644 --- a/src/lib/dashboard/contract.test.ts +++ b/src/lib/dashboard/contract.test.ts @@ -186,8 +186,18 @@ describe("buildFallbackControlUiUrls", () => { expect(buildFallbackControlUiUrls(null, 8642, [])).toEqual([]); }); - it("leaves an unparseable fallback URL's port unrewritten", () => { + it("drops an unparseable fallback URL", () => { const urls = buildFallbackControlUiUrls(null, 8642, ["http://[invalid"]); - expect(urls).toEqual(["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 b74aad901b0..45d63cbb2ed 100644 --- a/src/lib/dashboard/contract.ts +++ b/src/lib/dashboard/contract.ts @@ -181,14 +181,14 @@ export function buildFallbackControlUiUrls( fallbackUrls: string[], ): string[] { return fallbackUrls.flatMap((fallback) => { - let rewritten: string; + let url: URL; try { - const url = new URL(fallback); - url.port = String(port); - rewritten = url.toString(); + url = new URL(fallback); } catch { - rewritten = fallback; + return []; } - return buildControlUiUrls(token, port, rewritten).slice(1); + if (url.protocol !== "http:" && url.protocol !== "https:") return []; + url.port = String(port); + return buildControlUiUrls(token, port, url.toString()).slice(1); }); } From f2410862164bc6d39e6e89ff8aa43039bcc8e700 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 2 Jul 2026 10:39:27 +0000 Subject: [PATCH 09/11] fix(dashboard): stop leaking control-UI port into agent dashboard URLs Signed-off-by: Tinson Lai --- src/lib/onboard/dashboard.ts | 9 ++++++--- test/onboard-dashboard.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 1cbcb11bfd7..e443bbb11af 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -478,9 +478,12 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa deps.printAgentDashboardUi(sandboxName, token, agent, { note: deps.note, buildControlUiUrls: (tokenValue: string | null, port: number) => { - const primary = buildControlUiUrls(tokenValue, port, chain.accessUrl); - const fallbacks = buildFallbackControlUiUrls(tokenValue, port, chain.fallbackUrls); - return [...new Set([...primary, ...fallbacks])]; + const primary = buildControlUiUrls(tokenValue, port); + const alternates = buildFallbackControlUiUrls(tokenValue, port, [ + chain.accessUrl, + ...chain.fallbackUrls, + ]); + return [...new Set([...primary, ...alternates])]; }, }); console.log(""); diff --git a/test/onboard-dashboard.test.ts b/test/onboard-dashboard.test.ts index 9d7860ff213..d126cfe7fbc 100644 --- a/test/onboard-dashboard.test.ts +++ b/test/onboard-dashboard.test.ts @@ -263,6 +263,38 @@ describe("onboard dashboard helpers", () => { 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(); From 7f72174674c7822090a41a83b74ea1b1a8eb020e Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 3 Jul 2026 02:13:26 +0000 Subject: [PATCH 10/11] fix(dashboard): reuse chain fallback URLs in access info Signed-off-by: Tinson Lai --- src/lib/onboard/dashboard-access.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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"; From 96d52aea6b24cda60f6c8d9b2a81b446b1493113 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 3 Jul 2026 02:48:21 +0000 Subject: [PATCH 11/11] fix(dashboard): thread runCapture override into WSL host lookup Signed-off-by: Tinson Lai --- src/lib/onboard/dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index e443bbb11af..73f85a2ff27 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -448,7 +448,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa const chain = buildChain({ chatUiUrl, isWsl: deps.isWsl(), - wslHostAddress: getWslHostAddress({ isWsl: deps.isWsl() }), + wslHostAddress: getWslHostAddress({ isWsl: deps.isWsl(), runCapture: deps.runCapture }), }); const dashboardBaseUrl = `${chain.accessUrl.replace(/\/$/, "")}/`; const dashboardUrl = dashboardUrlForDisplay(