Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions src/lib/dashboard/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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/"]);
});
});
51 changes: 42 additions & 9 deletions src/lib/dashboard/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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");
Expand All @@ -132,6 +146,7 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain {

return {
accessUrl,
fallbackUrls,
corsOrigins,
forwardTarget,
healthEndpoint: dashboardHealthEndpoint,
Expand Down Expand Up @@ -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);
});
}
8 changes: 2 additions & 6 deletions src/lib/onboard/dashboard-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 26 additions & 4 deletions src/lib/onboard/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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)}`);
Expand All @@ -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("");
Expand All @@ -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`);
Expand All @@ -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`);
Expand Down
1 change: 1 addition & 0 deletions test/helpers/onboard-final-flow-phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
95 changes: 84 additions & 11 deletions test/onboard-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ const { createOnboardDashboardHelpers } = require("../src/lib/onboard/dashboard"
createOnboardDashboardHelpers: (deps: OnboardDashboardDeps) => OnboardDashboardHelpers;
};

function createTokenDownloadRunOpenshell() {
return vi.fn((args: string[], _opts?: Record<string, unknown>) => {
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/);
Expand Down Expand Up @@ -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<string, unknown>) => {
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(() => ""),
Expand Down Expand Up @@ -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();
Expand Down