diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 40a8c0855..232abd44f 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3695,7 +3695,9 @@ const CONTROL_UI_PORT = 18789; const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { - const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); + const forwardTarget = isWsl() + ? `0.0.0.0:${CONTROL_UI_PORT}` + : resolveDashboardForwardTarget(chatUiUrl); runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. // The --background flag forks a child that inherits stdout/stderr; if those are @@ -3752,6 +3754,74 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { // buildControlUiUrls — see dashboard import above +function getDashboardForwardPort() { + return CONTROL_UI_PORT; +} + +function getDashboardForwardStartCommand(sandboxName, options = {}) { + const forwardTarget = isWsl(options) + ? `0.0.0.0:${CONTROL_UI_PORT}` + : resolveDashboardForwardTarget(`http://127.0.0.1:${CONTROL_UI_PORT}`); + return `${openshellShellCommand(["forward", "start", "--background", forwardTarget, sandboxName])}`; +} + +function buildAuthenticatedDashboardUrl(baseUrl, token = null) { + if (!token) return baseUrl; + return `${baseUrl}#token=${encodeURIComponent(token)}`; +} + +function getWslHostAddress(options = {}) { + if (options.wslHostAddress) { + return options.wslHostAddress; + } + if (!isWsl(options)) { + return null; + } + const runCaptureFn = options.runCapture || runCapture; + const output = runCaptureFn("hostname -I 2>/dev/null", { ignoreError: true }); + const candidates = String(output || "") + .trim() + .split(/\s+/) + .filter(Boolean); + return candidates[0] || null; +} + +function getDashboardAccessInfo(sandboxName, options = {}) { + const token = Object.prototype.hasOwnProperty.call(options, "token") + ? options.token + : fetchGatewayAuthTokenFromSandbox(sandboxName); + const dashboardAccess = buildControlUiUrls(token).map((url, index) => ({ + label: index === 0 ? "Dashboard" : `Alt ${index}`, + url: buildAuthenticatedDashboardUrl(url, null), + })); + + const wslHostAddress = getWslHostAddress(options); + if (wslHostAddress) { + const wslUrl = buildAuthenticatedDashboardUrl( + `http://${wslHostAddress}:${CONTROL_UI_PORT}/`, + token, + ); + if (!dashboardAccess.some((access) => access.url === wslUrl)) { + dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl }); + } + } + + return dashboardAccess; +} + +function getDashboardGuidanceLines(dashboardAccess = []) { + const guidance = [`Port ${CONTROL_UI_PORT} must be forwarded before opening these URLs.`]; + if (isWsl()) { + guidance.push( + "WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.", + ); + } + if (dashboardAccess.length === 0) { + guidance.push("No dashboard URLs were generated."); + } + return guidance; +} + function printDashboard(sandboxName, model, provider, nimContainer = null) { const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -3767,7 +3837,9 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; - const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + const dashboardAccess = getDashboardAccessInfo(sandboxName); + const guidanceLines = getDashboardGuidanceLines(dashboardAccess); + const token = dashboardAccess.length > 0 ? null : fetchGatewayAuthTokenFromSandbox(sandboxName); console.log(""); console.log(` ${"─".repeat(50)}`); @@ -3780,7 +3852,15 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { console.log(` Status: nemoclaw ${sandboxName} status`); console.log(` Logs: nemoclaw ${sandboxName} logs --follow`); console.log(""); - if (token) { + if (dashboardAccess.length > 0) { + console.log(" OpenClaw UI (tokenized URL; treat it like a password)"); + for (const line of guidanceLines) { + console.log(` ${line}`); + } + for (const entry of dashboardAccess) { + console.log(` ${entry.label}: ${entry.url}`); + } + } else if (token) { console.log(" OpenClaw UI (tokenized URL; treat it like a password)"); console.log(` Port ${CONTROL_UI_PORT} must be forwarded before opening this URL.`); for (const url of buildControlUiUrls(token)) { @@ -4207,6 +4287,11 @@ module.exports = { repairRecordedSandbox, recoverGatewayRuntime, resolveDashboardForwardTarget, + buildAuthenticatedDashboardUrl, + getDashboardAccessInfo, + getDashboardForwardPort, + getDashboardForwardStartCommand, + getDashboardGuidanceLines, startGatewayForRecovery, runCaptureOpenshell, setupInference, diff --git a/test/onboard.test.js b/test/onboard.test.js index b6f0a858f..c9ba917d2 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -11,6 +11,8 @@ import { describe, expect, it } from "vitest"; import { buildSandboxConfigSyncScript, classifySandboxCreateFailure, + getDashboardAccessInfo, + getDashboardForwardStartCommand, getGatewayReuseState, getPortConflictServiceHints, getFutureShellPathHint, @@ -177,6 +179,35 @@ describe("onboard helpers", () => { expect(resolveDashboardForwardTarget("http://10.0.0.25:18789")).toBe("0.0.0.0:18789"); }); + it("includes a VS Code/WSL dashboard URL when running under WSL", () => { + const access = getDashboardAccessInfo("the-crucible", { + token: "secret-token", + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + release: "6.6.87.2-microsoft-standard-WSL2", + runCapture: (command) => (command.includes("hostname -I") ? "172.24.240.1\n" : ""), + }); + + expect(access).toEqual([ + { label: "Dashboard", url: "http://127.0.0.1:18789/#token=secret-token" }, + { label: "VS Code/WSL", url: "http://172.24.240.1:18789/#token=secret-token" }, + ]); + }); + + it("binds the dashboard forward to all interfaces under WSL", () => { + const command = getDashboardForwardStartCommand("the-crucible", { + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + release: "6.6.87.2-microsoft-standard-WSL2", + }); + + expect(command).toContain("forward"); + expect(command).toContain("start"); + expect(command).toContain("--background"); + expect(command).toContain("0.0.0.0:18789"); + expect(command).toContain("the-crucible"); + }); + it("prints platform-appropriate service hints for port conflicts", () => { expect(getPortConflictServiceHints("darwin").join("\n")).toMatch(/launchctl unload/); expect(getPortConflictServiceHints("darwin").join("\n")).not.toMatch(/systemctl --user/); @@ -1401,10 +1432,14 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.ok( - payload.commands.some((entry) => - entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'"), + payload.commands.some( + (entry) => + entry.command.includes("'forward' 'start' '--background' '18789' 'my-assistant'") || + entry.command.includes( + "'forward' 'start' '--background' '0.0.0.0:18789' 'my-assistant'", + ), ), - "expected default loopback dashboard forward", + "expected dashboard forward (loopback or WSL 0.0.0.0)", ); });