Skip to content
Open
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
91 changes: 88 additions & 3 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand All @@ -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)}`);
Expand All @@ -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)) {
Expand Down Expand Up @@ -4207,6 +4287,11 @@ module.exports = {
repairRecordedSandbox,
recoverGatewayRuntime,
resolveDashboardForwardTarget,
buildAuthenticatedDashboardUrl,
getDashboardAccessInfo,
getDashboardForwardPort,
getDashboardForwardStartCommand,
getDashboardGuidanceLines,
startGatewayForRecovery,
runCaptureOpenshell,
setupInference,
Expand Down
41 changes: 38 additions & 3 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { describe, expect, it } from "vitest";
import {
buildSandboxConfigSyncScript,
classifySandboxCreateFailure,
getDashboardAccessInfo,
getDashboardForwardStartCommand,
getGatewayReuseState,
getPortConflictServiceHints,
getFutureShellPathHint,
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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)",
);
});

Expand Down