diff --git a/src/lib/inventory-commands.test.ts b/src/lib/inventory-commands.test.ts index 6ec2c7084de..b7f25fe4c72 100644 --- a/src/lib/inventory-commands.test.ts +++ b/src/lib/inventory-commands.test.ts @@ -50,6 +50,39 @@ describe("inventory commands", () => { ); }); + it("prints the dashboard URL for each sandbox when dashboardPort is set", async () => { + const lines: string[] = []; + await listSandboxesCommand({ + recoverRegistryEntries: async () => ({ + sandboxes: [ + { + name: "alpha", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + dashboardPort: 18789, + }, + { + name: "beta", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + dashboardPort: 18790, + }, + ], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + loadLastSession: () => null, + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain(" dashboard: http://127.0.0.1:18789"); + expect(lines).toContain(" dashboard: http://127.0.0.1:18790"); + }); + it("shows stored sandbox inference instead of live gateway inference in list output", async () => { const lines: string[] = []; await listSandboxesCommand({ @@ -88,6 +121,25 @@ describe("inventory commands", () => { ); }); + it("prints the dashboard URL per sandbox in status when dashboardPort is set", () => { + const lines: string[] = []; + showStatusCommand({ + listSandboxes: () => ({ + sandboxes: [ + { name: "alpha", model: "m", dashboardPort: 18789 }, + { name: "beta", model: "m", dashboardPort: 18790 }, + ], + defaultSandbox: "alpha", + }), + getLiveInference: () => null, + showServiceStatus: vi.fn(), + log: (message = "") => lines.push(message), + }); + + expect(lines).toContain(" dashboard: http://127.0.0.1:18789"); + expect(lines).toContain(" dashboard: http://127.0.0.1:18790"); + }); + it("flags messaging bridge as degraded when checkMessagingBridgeHealth reports conflicts", () => { const lines: string[] = []; const checkMessagingBridgeHealth = vi.fn().mockReturnValue([ diff --git a/src/lib/inventory-commands.ts b/src/lib/inventory-commands.ts index a04a034231f..c6823094a27 100644 --- a/src/lib/inventory-commands.ts +++ b/src/lib/inventory-commands.ts @@ -11,6 +11,7 @@ export interface SandboxEntry { policies?: string[] | null; messagingChannels?: string[] | null; agent?: string | null; + dashboardPort?: number; } export interface MessagingBridgeHealth { @@ -98,6 +99,9 @@ export async function listSandboxesCommand(deps: ListSandboxesCommandDeps): Prom const connected = sessionCount !== null && sessionCount > 0 ? " ●" : ""; log(` ${sb.name}${def}${connected}`); log(` model: ${model} provider: ${provider} ${gpu} policies: ${presets}`); + if (typeof sb.dashboardPort === "number") { + log(` dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } } log(""); log(" * = default sandbox"); @@ -116,6 +120,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const def = isDefault ? " *" : ""; const model = sb.model; log(` ${sb.name}${def}${model ? ` (${model})` : ""}`); + if (typeof sb.dashboardPort === "number") { + log(` dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } } log(""); } diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index c6889d322b2..6137e04a2fc 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -32,6 +32,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: true, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -57,6 +58,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: true, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -81,6 +83,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); @@ -128,9 +131,43 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); + it("parses --control-ui-port ", () => { + const result = parseOnboardArgs( + ["--resume", "--control-ui-port", "18795"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(String(code)); + }) as never, + }, + ); + expect(result.controlUiPort).toBe(18795); + }); + + it("exits when --control-ui-port is out of range", () => { + expect(() => + parseOnboardArgs( + ["--control-ui-port", "80"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: ((code: number) => { + throw new Error(`exit:${code}`); + }) as never, + }, + ), + ).toThrow("exit:1"); + }); + it("exits when --from is missing its Dockerfile path", () => { expect(() => parseOnboardArgs( @@ -191,6 +228,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: "openclaw", dangerouslySkipPermissions: true, + controlUiPort: null, }); }); @@ -242,6 +280,7 @@ describe("onboard command", () => { acceptThirdPartySoftware: false, agent: null, dangerouslySkipPermissions: false, + controlUiPort: null, }); }); diff --git a/src/lib/onboard-command.ts b/src/lib/onboard-command.ts index f3f4c5d8f9f..978e78aff9d 100644 --- a/src/lib/onboard-command.ts +++ b/src/lib/onboard-command.ts @@ -9,6 +9,7 @@ export interface OnboardCommandOptions { acceptThirdPartySoftware: boolean; agent: string | null; dangerouslySkipPermissions: boolean; + controlUiPort: number | null; } export interface RunOnboardCommandDeps { @@ -36,7 +37,7 @@ const ONBOARD_BASE_ARGS = [ function onboardUsageLines(noticeAcceptFlag: string): string[] { return [ - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [--agent ] [--control-ui-port ] [--dangerously-skip-permissions] [${noticeAcceptFlag}]`, "", ]; } @@ -69,6 +70,28 @@ export function parseOnboardArgs( parsedArgs.splice(fromIdx, 2); } + let controlUiPort: number | null = null; + const controlUiPortIdx = parsedArgs.indexOf("--control-ui-port"); + if (controlUiPortIdx !== -1) { + const raw = parsedArgs[controlUiPortIdx + 1]; + if (typeof raw !== "string" || raw.startsWith("--")) { + error(" --control-ui-port requires a port number"); + printOnboardUsage(error, noticeAcceptFlag); + exit(1); + } + if (!/^\d+$/.test(raw)) { + error(` --control-ui-port '${raw}' must be an integer between 1024 and 65535`); + exit(1); + } + const parsed = Number(raw); + if (parsed < 1024 || parsed > 65535) { + error(` --control-ui-port '${raw}' must be an integer between 1024 and 65535`); + exit(1); + } + controlUiPort = parsed; + parsedArgs.splice(controlUiPortIdx, 2); + } + let agent: string | null = null; const agentIdx = parsedArgs.indexOf("--agent"); if (agentIdx !== -1) { @@ -105,6 +128,7 @@ export function parseOnboardArgs( parsedArgs.includes(noticeAcceptFlag) || String(deps.env[noticeAcceptEnv] || "") === "1", agent, dangerouslySkipPermissions: parsedArgs.includes("--dangerously-skip-permissions"), + controlUiPort, }; } @@ -116,6 +140,12 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); } - ensureDashboardForward(sandboxName, chatUiUrl); + const reuseUrl = reuseChatUiUrlFor(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, reuseUrl); + registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } } catch (err) { @@ -3533,7 +3540,8 @@ async function createSandbox( if (Object.keys(abortHashes).length > 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); } - ensureDashboardForward(sandboxName, chatUiUrl); + const reusePort = ensureDashboardForward(sandboxName, chatUiUrl); + registry.updateSandbox(sandboxName, { dashboardPort: reusePort }); return sandboxName; } } @@ -3944,33 +3952,61 @@ async function createSandbox( } } - // Release any stale forward on the dashboard port before claiming it for the new sandbox. - // A previous onboard run may have left the port forwarded to a different sandbox, - // which would silently prevent the new sandbox's dashboard from being reachable. - ensureDashboardForward(sandboxName, chatUiUrl); - - // Register only after confirmed ready — prevents phantom entries - const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); - const providerCredentialHashes = {}; - for (const { envKey, token } of messagingTokenDefs) { - if (token) { - providerCredentialHashes[envKey] = hashCredential(token); - } - } - registry.registerSandbox({ - name: sandboxName, - model: model || null, - provider: provider || null, - gpuEnabled: !!gpu, - agent: agent ? agent.name : null, - agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, - imageTag: `openshell/sandbox-from:${buildId}`, - dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, - providerCredentialHashes: - Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, - messagingChannels: activeMessagingChannels, - disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, - }); + // Release any stale forward on the dashboard port, then register the sandbox + // with nemoclaw's local registry. Any throw between "openshell sandbox create + // succeeded" and "registerSandbox returned" leaves a ghost state where openshell + // has a live sandbox that nemoclaw doesn't know about (#2174 title claim). + // We roll back: stop any forward we started, remove any partial registry entry, + // then delete the openshell sandbox. + let allocatedDashboardPort; + let registryWritten = false; + try { + allocatedDashboardPort = ensureDashboardForward(sandboxName, chatUiUrl); + // Register only after confirmed ready — prevents phantom entries + const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); + const providerCredentialHashes = {}; + for (const { envKey, token } of messagingTokenDefs) { + if (token) { + providerCredentialHashes[envKey] = hashCredential(token); + } + } + registry.registerSandbox({ + name: sandboxName, + model: model || null, + provider: provider || null, + gpuEnabled: !!gpu, + agent: agent ? agent.name : null, + agentVersion: fromDockerfile ? null : effectiveAgent.expectedVersion || null, + imageTag: `openshell/sandbox-from:${buildId}`, + dangerouslySkipPermissions: dangerouslySkipPermissions || undefined, + providerCredentialHashes: + Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined, + messagingChannels: activeMessagingChannels, + disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, + dashboardPort: allocatedDashboardPort, + }); + registryWritten = true; + } catch (err) { + if (allocatedDashboardPort !== undefined) { + runOpenshell(["forward", "stop", String(allocatedDashboardPort)], { ignoreError: true }); + } + if (registryWritten) { + try { + registry.removeSandbox(sandboxName); + } catch { + /* best effort */ + } + } + const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); + console.error(""); + console.error(` Post-create setup failed for '${sandboxName}'.`); + if (delResult.status === 0) { + console.error(" Rolled back the partially-created sandbox — safe to retry."); + } else { + console.error(` Automatic cleanup failed. Manual: openshell sandbox delete "${sandboxName}"`); + } + throw err; + } // Restore workspace state if we backed it up during credential rotation. if (pendingStateRestore?.success) { @@ -6031,31 +6067,92 @@ const CONTROL_UI_PORT = DASHBOARD_PORT; // isLoopbackHostname — see urlUtils import above const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; +/** + * Pick the chatUiUrl to use when re-forwarding an already-registered sandbox. + * Precedence: user-set CHAT_UI_URL env > stored dashboardPort > caller-supplied + * default. This prevents reuse paths from always re-requesting the default port + * and ratcheting an auto-allocated sandbox upward on each onboard (#2174). + */ +function reuseChatUiUrlFor(sandboxName, fallbackUrl) { + if (process.env.CHAT_UI_URL) return fallbackUrl; + const stored = registry.getSandbox(sandboxName)?.dashboardPort; + return typeof stored === "number" ? `http://127.0.0.1:${stored}` : fallbackUrl; +} + function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { - const portToStop = getDashboardForwardPort(chatUiUrl); - const forwardTarget = getDashboardForwardTarget(chatUiUrl); - // Detect port already claimed by a different sandbox and fail fast with an - // actionable message rather than silently stealing that sandbox's forward. - // (Same sandbox is always allowed — covers reconnect and resume paths.) - const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); - // Parse line-by-line to avoid false positives from substring matches. + const requestedPort = Number(getDashboardForwardPort(chatUiUrl)); + // Parse the forward list once; reuse for both conflict detection and picker. // openshell forward list columns: SANDBOX BIND PORT PID STATUS - // Port is at column index 2; sandbox name is at column index 0. - const portLine = existingForwards - ?.split("\n") + const existingForwards = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + const forwardEntries = (existingForwards ?? "") + .split("\n") .map((l) => l.trim()) - .find((l) => { + .filter(Boolean) + .map((l) => { const parts = l.split(/\s+/); - return parts[2] === portToStop; + return { sandbox: parts[0] ?? "", port: Number(parts[2]) }; + }) + .filter((e) => Number.isFinite(e.port) && e.port > 0); + const ownerOfRequested = + forwardEntries.find((e) => e.port === requestedPort)?.sandbox ?? null; + + let effectivePort = requestedPort; + if (ownerOfRequested !== null && ownerOfRequested !== sandboxName) { + // Port is held by a different sandbox. Decide: auto-allocate (loopback default) + // or fail fast (user explicitly pinned via CHAT_UI_URL env or non-loopback URL). + const userPinnedEnv = !!process.env.CHAT_UI_URL; + let isLoopback = true; + try { + const parsed = new URL( + /^[a-z]+:\/\//i.test(chatUiUrl) ? chatUiUrl : `http://${chatUiUrl}`, + ); + isLoopback = isLoopbackHostname(parsed.hostname); + } catch { + isLoopback = true; + } + if (userPinnedEnv || !isLoopback) { + throw new Error( + `Port ${requestedPort} is already forwarded for sandbox '${ownerOfRequested}'. ` + + `Unset CHAT_UI_URL or pick a free port to onboard '${sandboxName}'.`, + ); + } + const heldPorts = forwardEntries.map((e) => e.port); + // Also reject ports bound by non-openshell processes (Docker containers, + // other local servers). Without this, auto-alloc would hand out a port + // that `openshell forward start` then fails to bind, leaving a persisted + // dashboardPort pointing at a dead forward. + const isPortBoundLocally = (p) => { + const out = runCapture(["lsof", "-i", `:${p}`, "-sTCP:LISTEN", "-P", "-n"], { + ignoreError: true, + }); + return typeof out === "string" && out.trim().length > 0; + }; + const chosen = findFreeDashboardPort(requestedPort, { + probe: { + listForwardPorts: () => heldPorts, + probePortFree: (p) => !isPortBoundLocally(p), + }, }); - const portOwner = portLine ? (portLine.split(/\s+/)[0] ?? null) : null; - if (portOwner !== null && portOwner !== sandboxName) { - throw new Error( - `Port ${portToStop} is already forwarded for sandbox '${portOwner}'. ` + - `Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790) ` + - `before onboarding a second sandbox.`, + if (chosen === null) { + const windowSummary = forwardEntries + .filter((e) => e.port >= requestedPort && e.port < requestedPort + 10) + .map((e) => ` port ${e.port} → sandbox '${e.sandbox}'`) + .join("\n"); + throw new Error( + `All ports ${requestedPort}-${requestedPort + 9} are forwarded:\n${windowSummary}\n` + + ` Destroy an unused sandbox, or set CHAT_UI_URL=http://127.0.0.1:${requestedPort + 10} (or higher) and retry.`, + ); + } + effectivePort = chosen; + console.log( + ` Dashboard port ${requestedPort} in use by '${ownerOfRequested}'; allocated port ${effectivePort} for '${sandboxName}'.`, ); } + + const portToStop = String(effectivePort); + const effectiveChatUiUrl = + effectivePort === requestedPort ? chatUiUrl : `http://127.0.0.1:${effectivePort}`; + const forwardTarget = getDashboardForwardTarget(effectiveChatUiUrl); runOpenshell(["forward", "stop", portToStop], { 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 @@ -6077,6 +6174,7 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON ); console.warn(` Free the port, then reconnect: nemoclaw ${sandboxName} connect`); } + return effectivePort; } function findOpenclawJsonPath(dir) { @@ -6177,8 +6275,16 @@ function getDashboardAccessInfo(sandboxName, options = {}) { const token = Object.prototype.hasOwnProperty.call(options, "token") ? options.token : fetchGatewayAuthTokenFromSandbox(sandboxName); + // Precedence: caller-supplied > CHAT_UI_URL env > stored dashboardPort > default. + // The stored-port tier ensures the completion banner shows the port actually + // allocated by ensureDashboardForward, not the original default (#2174). + const storedPort = registry.getSandbox(sandboxName)?.dashboardPort; + const storedUrl = typeof storedPort === "number" ? `http://127.0.0.1:${storedPort}` : null; const chatUiUrl = - options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + options.chatUiUrl || + process.env.CHAT_UI_URL || + storedUrl || + `http://127.0.0.1:${CONTROL_UI_PORT}`; const dashboardPort = Number(getDashboardForwardPort(chatUiUrl)); const dashboardAccess = buildControlUiUrls(token, dashboardPort).map((url, index) => ({ label: index === 0 ? "Dashboard" : `Alt ${index}`, @@ -6200,8 +6306,14 @@ function getDashboardAccessInfo(sandboxName, options = {}) { } function getDashboardGuidanceLines(dashboardAccess = [], options = {}) { + const storedPort = + options.sandboxName && registry.getSandbox(options.sandboxName)?.dashboardPort; + const storedUrl = typeof storedPort === "number" ? `http://127.0.0.1:${storedPort}` : null; const dashboardPort = getDashboardForwardPort( - options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options.chatUiUrl || + process.env.CHAT_UI_URL || + storedUrl || + `http://127.0.0.1:${CONTROL_UI_PORT}`, ); const guidance = [`Port ${dashboardPort} must be forwarded before opening these URLs.`]; if (isWsl(options)) { @@ -6233,7 +6345,7 @@ function printDashboard(sandboxName, model, provider, nimContainer = null, agent const token = fetchGatewayAuthTokenFromSandbox(sandboxName); const dashboardAccess = getDashboardAccessInfo(sandboxName, { token }); - const guidanceLines = getDashboardGuidanceLines(dashboardAccess); + const guidanceLines = getDashboardGuidanceLines(dashboardAccess, { sandboxName }); console.log(""); console.log(` ${"─".repeat(50)}`); diff --git a/src/lib/ports.test.ts b/src/lib/ports.test.ts index 3769c2bf8bd..dd1fc2b7d27 100644 --- a/src/lib/ports.test.ts +++ b/src/lib/ports.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; // Import from compiled dist/ so coverage is attributed correctly. -import { parsePort } from "../../dist/lib/ports"; +import { parsePort, findFreeDashboardPort } from "../../dist/lib/ports"; describe("parsePort", () => { const ENV_KEY = "TEST_PORT"; @@ -70,3 +70,53 @@ describe("parsePort", () => { expect(() => parsePort(ENV_KEY, 8080)).toThrow("Invalid port"); }); }); + +describe("findFreeDashboardPort", () => { + it("skips ports already held by openshell forwards", () => { + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => [18789, 18790], + probePortFree: () => true, + }, + }); + expect(port).toBe(18791); + }); + + it("skips ports bound by other host processes", () => { + const bound = new Set([18789, 18790]); + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => [], + probePortFree: (p) => !bound.has(p), + }, + }); + expect(port).toBe(18791); + }); + + it("returns null when the 10-port window is exhausted", () => { + const allHeld = Array.from({ length: 10 }, (_, i) => 18789 + i); + const port = findFreeDashboardPort(18789, { + probe: { + listForwardPorts: () => allHeld, + probePortFree: () => true, + }, + }); + expect(port).toBeNull(); + }); + + it("does not allocate ports above 65535 when preferred is near the upper bound", () => { + const port = findFreeDashboardPort(65535, { + probe: { + listForwardPorts: () => [65535], + probePortFree: () => true, + }, + }); + expect(port).toBeNull(); + }); + + it("returns null when preferred port is out of range", () => { + expect(findFreeDashboardPort(0)).toBeNull(); + expect(findFreeDashboardPort(65536)).toBeNull(); + expect(findFreeDashboardPort(1023)).toBeNull(); + }); +}); diff --git a/src/lib/ports.ts b/src/lib/ports.ts index ad5d8166ad7..1d29c0135cc 100644 --- a/src/lib/ports.ts +++ b/src/lib/ports.ts @@ -46,3 +46,41 @@ export const VLLM_PORT = parsePort("NEMOCLAW_VLLM_PORT", 8000); export const OLLAMA_PORT = parsePort("NEMOCLAW_OLLAMA_PORT", 11434); /** Ollama auth proxy port (default 11435, override via NEMOCLAW_OLLAMA_PROXY_PORT). */ export const OLLAMA_PROXY_PORT = parsePort("NEMOCLAW_OLLAMA_PROXY_PORT", 11435); + +/** + * Injectable probes so tests can drive `findFreeDashboardPort` without touching + * real openshell or real sockets. + */ +export interface PortProbe { + listForwardPorts: () => number[]; + probePortFree: (port: number) => boolean; +} + +/** + * Try up to 10 ports starting at the preferred port before giving up. + * Anything beyond that and the operator has bigger problems; fail loudly. + */ +const PORT_WINDOW = 10; + +/** + * Find a free dashboard port, preferring `preferred` and walking upward. + * Skips ports already claimed by openshell forwards and ports bound by + * other host processes. Returns null if the 10-port window is exhausted. + */ +export function findFreeDashboardPort( + preferred: number, + options: { probe?: PortProbe } = {}, +): number | null { + if (!Number.isInteger(preferred) || preferred < 1024 || preferred > 65535) { + return null; + } + const probe = options.probe; + const held = new Set(probe?.listForwardPorts() ?? []); + const isFree = probe?.probePortFree ?? (() => true); + for (let offset = 0; offset < PORT_WINDOW; offset++) { + const port = preferred + offset; + if (port > 65535) break; + if (!held.has(port) && isFree(port)) return port; + } + return null; +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 445e20f17df..58bbbf09126 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -22,6 +22,8 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; disabledChannels?: string[]; + /** Host-side port for the openshell forward to the OpenClaw dashboard (#2174). */ + dashboardPort?: number; } export interface SandboxRegistry { @@ -176,6 +178,12 @@ export function registerSandbox(entry: SandboxEntry): void { Array.isArray(entry.disabledChannels) && entry.disabledChannels.length > 0 ? [...entry.disabledChannels] : undefined, + dashboardPort: + Number.isInteger(entry.dashboardPort) && + (entry.dashboardPort as number) >= 1024 && + (entry.dashboardPort as number) <= 65535 + ? entry.dashboardPort + : undefined, }; if (!data.defaultSandbox) { data.defaultSandbox = entry.name; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 231ab24f267..f53804c2c36 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1429,6 +1429,9 @@ async function sandboxStatus(sandboxName) { } console.log(` GPU: ${sb.gpuEnabled ? "yes" : "no"}`); console.log(` Policies: ${(sb.policies || []).join(", ") || "none"}`); + if (typeof sb.dashboardPort === "number") { + console.log(` Dashboard: http://127.0.0.1:${sb.dashboardPort}`); + } // Active session indicator try {