diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index 35aee576f87..a8b3b0c8eb2 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -38,21 +38,25 @@ export function resolveSandboxDashboardPort( sandboxName: string, deps: SandboxPortDeps = {}, ): number { + const getSandbox = deps.getSandbox ?? registry.getSandbox; + const sandbox = getSandbox(sandboxName); + if (isValidPort(sandbox?.dashboardPort)) { + return sandbox.dashboardPort; + } + const getSessionAgent = deps.getSessionAgent ?? agentRuntime.getSessionAgent; const agent = getSessionAgent(sandboxName); if (agent && agentRuntime.hasGatewayRuntime(agent) && isValidPort(agent.forwardPort)) { return agent.forwardPort; } - const getSandbox = deps.getSandbox ?? registry.getSandbox; - const sandbox = getSandbox(sandboxName); - return isValidPort(sandbox?.dashboardPort) ? sandbox.dashboardPort : DASHBOARD_PORT; + return DASHBOARD_PORT; } /** * Re-establish the dashboard port forward to the sandbox. - * Uses the recorded dashboard port for OpenClaw sandboxes, or the agent's - * declared forward port when a non-OpenClaw agent is active. + * Uses the recorded dashboard port when available, including custom ports for + * non-OpenClaw agents, then falls back to the active agent's declared port. * Returns true when `forward start` succeeded and a follow-up probe * confirms the new entry is running, false otherwise. */ diff --git a/src/lib/agent/dashboard-ui.ts b/src/lib/agent/dashboard-ui.ts index cbcbf2485f3..1168ae4b3e9 100644 --- a/src/lib/agent/dashboard-ui.ts +++ b/src/lib/agent/dashboard-ui.ts @@ -28,7 +28,7 @@ function readObject(record: ManifestRecordLike, key: string): ManifestRecordLike return value as ManifestRecordLike; } -function isValidPort(value: unknown): value is number { +function isValidNonPrivilegedPort(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535; } @@ -37,7 +37,7 @@ export function readDashboardUi(record: ManifestRecordLike): AgentDashboardUi | if (!dashboardUi) return null; const port = dashboardUi.port; - if (!isValidPort(port)) { + if (!isValidNonPrivilegedPort(port)) { throw new Error( "Agent manifest field 'dashboard_ui.port' must be an integer TCP port between 1024 and 65535", ); @@ -70,9 +70,14 @@ function dashboardUiEnabled(agent: AgentDefinition, env: NodeJS.ProcessEnv): boo return !!dashboardUi && isTruthyEnv(env[dashboardUi.enableEnv]); } -function dashboardUiPort(agent: AgentDefinition, env: NodeJS.ProcessEnv): number { +function dashboardUiPort( + agent: AgentDefinition, + env: NodeJS.ProcessEnv, + effectiveDashboardPort?: number, +): number { const dashboardUi = agent.dashboardUi; if (!dashboardUi) return agent.forwardPort; + if (isValidNonPrivilegedPort(effectiveDashboardPort)) return effectiveDashboardPort; const raw = env[dashboardUi.portEnv]; if (raw && /^\d+$/.test(raw.trim())) { const port = Number(raw.trim()); @@ -86,6 +91,7 @@ export function printOptionalDashboardUi( deps: { buildControlUiUrls: (token: string | null, port: number) => string[]; redactUrl: (url: string) => string; + effectiveDashboardPort?: number; env?: NodeJS.ProcessEnv; writeLine?: (message?: string) => void; }, @@ -95,7 +101,7 @@ export function printOptionalDashboardUi( if (!dashboardUi || !dashboardUiEnabled(agent, env)) return; const writeLine = deps.writeLine ?? console.log; - const port = dashboardUiPort(agent, env); + const port = dashboardUiPort(agent, env, deps.effectiveDashboardPort); writeLine(""); writeLine(` ${agent.displayName} ${dashboardUi.label}`); writeLine(` Port ${port} must be forwarded before opening this URL.`); diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 744c290136c..a7417066c1e 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -149,10 +149,10 @@ describe("printDashboardUi with port 8642 outside the chat UI (#2078)", () => { it("prints the optional Hermes web dashboard URL when dashboard mode is enabled", () => { process.env.NEMOCLAW_HERMES_DASHBOARD = "1"; - process.env.NEMOCLAW_HERMES_DASHBOARD_PORT = "9120"; printDashboardUi("sandbox-x", null, apiAgent, { note: noteSpy, + effectiveDashboardPort: 9120, buildControlUiUrls: buildUrlsLoopback, }); @@ -193,7 +193,7 @@ describe("printDashboardUi with port 8642 outside the chat UI (#2078)", () => { expect(noteSpy).not.toHaveBeenCalled(); }); - it("announces manifest-declared secondary forward_ports alongside the primary dashboard", () => { + it("uses the effective Hermes dashboard port while preserving the secondary API (#6277)", () => { const hermesShipped = makeAgent({ name: "hermes", displayName: "Hermes Agent", @@ -211,13 +211,15 @@ describe("printDashboardUi with port 8642 outside the chat UI (#2078)", () => { printDashboardUi("hermes-box", null, hermesShipped, { note: noteSpy, + effectiveDashboardPort: 9121, buildControlUiUrls: buildUrlsLoopback, }); const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n"); expect(output).toContain("Hermes Agent Dashboard"); - expect(output).toContain("Port 18789 must be forwarded before opening this URL."); - expect(output).toContain("http://127.0.0.1:18789/"); + expect(output).toContain("Port 9121 must be forwarded before opening this URL."); + expect(output).toContain("http://127.0.0.1:9121/"); + expect(output).not.toContain("http://127.0.0.1:18789/"); expect(output).toContain("Hermes Agent OpenAI-compatible API"); expect(output).toContain("Port 8642 must be forwarded before connecting."); expect(output).toContain("http://127.0.0.1:8642/v1"); diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index c7578c58406..29ac972e809 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -11,6 +11,7 @@ import type { JsonObject as LooseObject } from "../core/json-types"; import { sleepSeconds } from "../core/wait"; import { getProviderSelectionConfig } from "../inference/config"; import { runSandboxConfigSync } from "../onboard/config-sync"; +import { isValidForwardPort } from "../onboard/dashboard-runtime"; import { redact, run } from "../runner"; import * as baseImage from "./base-image"; import { describeAgentBinaryFailure, verifyAgentBinaryAvailable } from "./binary-availability"; @@ -408,11 +409,15 @@ export function printDashboardUi( deps: { note: (msg: string) => void; buildControlUiUrls: (token: string | null, port: number) => string[]; + effectiveDashboardPort?: number; }, ): void { const info = getAgentDashboardInfo(agent); const { auth, kind, label, path } = agent.dashboard; const cliName = getAgentBranding(agent.name).cli; + const effectiveDashboardPort = isValidForwardPort(deps.effectiveDashboardPort) + ? deps.effectiveDashboardPort + : info.port; if (kind === "api") { console.log(` ${info.displayName} ${label}`); @@ -433,20 +438,24 @@ export function printDashboardUi( if (auth !== "url_token") { console.log(` ${info.displayName} ${label}`); - console.log(` Port ${info.port} must be forwarded before opening this URL.`); - for (const url of deps.buildControlUiUrls(null, info.port)) { + console.log(` Port ${effectiveDashboardPort} must be forwarded before opening this URL.`); + for (const url of deps.buildControlUiUrls(null, effectiveDashboardPort)) { console.log(` ${dashboardUrlForDisplay(url)}`); } printBearerTokenApiAccess(sandboxName, agent, cliName); - printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay }); - printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls); + printOptionalDashboardUi(agent, { + ...deps, + effectiveDashboardPort, + redactUrl: dashboardUrlForDisplay, + }); + printAdditionalForwardPorts(agent, effectiveDashboardPort, deps.buildControlUiUrls); return; } if (token) { console.log(` ${info.displayName} ${label} (auth token redacted from displayed URLs)`); - console.log(` Port ${info.port} must be forwarded before opening this URL.`); - for (const url of deps.buildControlUiUrls(token, info.port)) { + console.log(` Port ${effectiveDashboardPort} must be forwarded before opening this URL.`); + for (const url of deps.buildControlUiUrls(token, effectiveDashboardPort)) { console.log(` ${dashboardUrlForDisplay(url)}`); } console.log(` Token: ${cliName} ${sandboxName} gateway-token --quiet`); @@ -454,13 +463,17 @@ export function printDashboardUi( } else { deps.note(" Could not read gateway token from the sandbox (download failed)."); console.log(` ${info.displayName} ${label}`); - console.log(` Port ${info.port} must be forwarded before opening this URL.`); - for (const url of deps.buildControlUiUrls(null, info.port)) { + console.log(` Port ${effectiveDashboardPort} must be forwarded before opening this URL.`); + for (const url of deps.buildControlUiUrls(null, effectiveDashboardPort)) { console.log(` ${dashboardUrlForDisplay(url)}`); } } - printOptionalDashboardUi(agent, { ...deps, redactUrl: dashboardUrlForDisplay }); - printAdditionalForwardPorts(agent, info.port, deps.buildControlUiUrls); + printOptionalDashboardUi(agent, { + ...deps, + effectiveDashboardPort, + redactUrl: dashboardUrlForDisplay, + }); + printAdditionalForwardPorts(agent, effectiveDashboardPort, deps.buildControlUiUrls); } /** @@ -493,7 +506,7 @@ function printAdditionalForwardPorts( const apiPort = agent.healthProbe?.port; for (const port of declared) { if (!Number.isInteger(port) || port < 1024 || port > 65535) continue; - if (port === primaryPort) continue; + if (port === primaryPort || port === agent.forwardPort) continue; const isApi = port === apiPort; const sectionLabel = isApi ? "OpenAI-compatible API" : "additional port"; console.log(""); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6f7da9ffc6c..a93c5ac27ea 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4514,7 +4514,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { requestedObservabilityEnabled: runtimeControlRequests.requestedObservabilityEnabled, authoritativePolicyTier: opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : null, - controlUiPort: opts.controlUiPort || null, + controlUiPort: _preflightDashboardPort, rootDir: ROOT, }, sandboxDeps: { diff --git a/src/lib/onboard/agent-dashboard-forward.test.ts b/src/lib/onboard/agent-dashboard-forward.test.ts index 487ed7b41b0..45a08dec750 100644 --- a/src/lib/onboard/agent-dashboard-forward.test.ts +++ b/src/lib/onboard/agent-dashboard-forward.test.ts @@ -1,11 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureAgentDashboardForward } from "./agent-dashboard-forward"; describe("ensureAgentDashboardForward", () => { + afterEach(() => { + delete process.env.CHAT_UI_URL; + }); + it("preserves additional host-forward ports during dashboard refresh", () => { const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "http://127.0.0.1:18789") => { const parsed = new URL(chatUiUrl); @@ -32,4 +36,129 @@ describe("ensureAgentDashboardForward", () => { allowPortReallocation: false, }); }); + + it("keeps an explicit effective port and omits the replaced manifest default (#6277)", () => { + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { + return Number(new URL(chatUiUrl).port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "hm", + agent: { + forwardPort: 18789, + forward_ports: [18789, 8642], + }, + ensureDashboardForward, + controlUiPort: 9120, + preserveForwardPorts: [3978], + }), + ).toBe(9120); + + expect(ensureDashboardForward).toHaveBeenNthCalledWith(1, "hm", "http://127.0.0.1:9120", { + preserveSandboxPorts: [9120, 8642, 3978], + }); + expect(ensureDashboardForward).toHaveBeenNthCalledWith(2, "hm", "http://127.0.0.1:8642", { + preserveSandboxPorts: [9120, 8642, 3978], + allowPortReallocation: false, + }); + expect(ensureDashboardForward).not.toHaveBeenCalledWith( + "hm", + "http://127.0.0.1:18789", + expect.anything(), + ); + expect(process.env.CHAT_UI_URL).toBe("http://127.0.0.1:9120"); + }); + + it("preserves a remote dashboard URL while refreshing its effective port (#6277)", () => { + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { + return Number(new URL(chatUiUrl).port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "hm", + agent: { + dashboard: { kind: "ui" }, + forwardPort: 18789, + forward_ports: [18789, 8642], + }, + ensureDashboardForward, + chatUiUrl: "https://hermes.example.test:9120/ui", + controlUiPort: 9120, + }), + ).toBe(9120); + + expect(ensureDashboardForward).toHaveBeenNthCalledWith( + 1, + "hm", + "https://hermes.example.test:9120/ui", + { preserveSandboxPorts: [9120, 8642] }, + ); + expect(process.env.CHAT_UI_URL).toBe("https://hermes.example.test:9120/ui"); + }); + + it("keeps an API-kind agent on its declared primary port", () => { + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { + return Number(new URL(chatUiUrl).port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "api-agent", + agent: { + dashboard: { kind: "api" }, + forwardPort: 8642, + forward_ports: [8642], + }, + ensureDashboardForward, + chatUiUrl: "http://127.0.0.1:9120", + controlUiPort: 9120, + }), + ).toBe(8642); + + expect(ensureDashboardForward).toHaveBeenCalledWith("api-agent", "http://127.0.0.1:8642", { + preserveSandboxPorts: [8642], + }); + expect(process.env.CHAT_UI_URL).toBeUndefined(); + }); + + it("preserves the canonical WebUI forward for an API-kind agent with an optional dashboard", () => { + process.env.CHAT_UI_URL = "https://hermes.example.test:9120/ui"; + const ensureDashboardForward = vi.fn((_sandboxName, chatUiUrl = "") => { + return Number(new URL(chatUiUrl).port); + }); + + expect( + ensureAgentDashboardForward({ + sandboxName: "legacy-hermes", + agent: { + dashboard: { kind: "api" }, + dashboardUi: { port: 9119 }, + forwardPort: 8642, + forward_ports: [8642], + }, + ensureDashboardForward, + chatUiUrl: process.env.CHAT_UI_URL, + controlUiPort: 9120, + }), + ).toBe(8642); + + expect(ensureDashboardForward).toHaveBeenNthCalledWith( + 1, + "legacy-hermes", + "http://127.0.0.1:8642", + { preserveSandboxPorts: [8642, 9120] }, + ); + expect(ensureDashboardForward).toHaveBeenNthCalledWith( + 2, + "legacy-hermes", + "https://hermes.example.test:9120/ui", + { + preserveSandboxPorts: [8642, 9120], + allowPortReallocation: false, + }, + ); + expect(process.env.CHAT_UI_URL).toBe("https://hermes.example.test:9120/ui"); + }); }); diff --git a/src/lib/onboard/agent-dashboard-forward.ts b/src/lib/onboard/agent-dashboard-forward.ts index 37894c2fde2..786bf79d0c8 100644 --- a/src/lib/onboard/agent-dashboard-forward.ts +++ b/src/lib/onboard/agent-dashboard-forward.ts @@ -6,6 +6,7 @@ import { type DashboardRuntimeAgent, getAgentDeclaredForwardPorts, getAgentPrimaryForwardPort, + isValidForwardPort, shouldManageDashboardForAgent, } from "./dashboard-runtime"; @@ -18,16 +19,16 @@ export type EnsureDashboardForward = ( }, ) => number; -export type AgentDashboardForwardConfig = NonNullable; - -function isValidPort(port: number | null | undefined): port is number { - return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535; -} +export type AgentDashboardForwardConfig = NonNullable & { + dashboard?: { kind?: unknown } | null; + dashboardUi?: unknown; +}; export function ensureAgentDashboardForward(options: { sandboxName: string; agent: AgentDashboardForwardConfig; ensureDashboardForward: EnsureDashboardForward; + chatUiUrl?: string; controlUiPort?: number; preserveForwardPorts?: readonly (number | null | undefined)[]; warn?: (message: string) => void; @@ -36,7 +37,8 @@ export function ensureAgentDashboardForward(options: { sandboxName, agent, ensureDashboardForward, - controlUiPort = DASHBOARD_PORT, + chatUiUrl, + controlUiPort, preserveForwardPorts = [], warn = (message: string) => console.warn(message), } = options; @@ -44,23 +46,45 @@ export function ensureAgentDashboardForward(options: { return 0; } - const declaredPorts = getAgentDeclaredForwardPorts(agent); - const agentDashboardPort = getAgentPrimaryForwardPort(agent, controlUiPort); - const preservePorts = [ - ...new Set([agentDashboardPort, ...declaredPorts, ...preserveForwardPorts]), - ].filter(isValidPort); - const actualAgentDashboardPort = ensureDashboardForward( - sandboxName, - `http://127.0.0.1:${agentDashboardPort}`, - { preserveSandboxPorts: preservePorts }, + const declaredPrimaryPort = getAgentPrimaryForwardPort(agent, DASHBOARD_PORT); + const usesFixedApiPort = agent.dashboard?.kind === "api"; + const agentDashboardPort = + !usesFixedApiPort && isValidForwardPort(controlUiPort) ? controlUiPort : declaredPrimaryPort; + const optionalDashboardPort = + usesFixedApiPort && agent.dashboardUi && isValidForwardPort(controlUiPort) + ? controlUiPort + : null; + const declaredPorts = getAgentDeclaredForwardPorts(agent).filter( + (port) => port !== declaredPrimaryPort || port === agentDashboardPort, ); - process.env.CHAT_UI_URL = `http://127.0.0.1:${actualAgentDashboardPort}`; + const preservePorts = [ + ...new Set([ + agentDashboardPort, + ...declaredPorts, + optionalDashboardPort, + ...preserveForwardPorts, + ]), + ].filter(isValidForwardPort); + const requestedDashboardUrl = + !usesFixedApiPort && chatUiUrl + ? replaceUrlPort(chatUiUrl, agentDashboardPort) + : `http://127.0.0.1:${agentDashboardPort}`; + const actualAgentDashboardPort = ensureDashboardForward(sandboxName, requestedDashboardUrl, { + preserveSandboxPorts: preservePorts, + }); + if (!usesFixedApiPort) { + process.env.CHAT_UI_URL = replaceUrlPort(requestedDashboardUrl, actualAgentDashboardPort); + } const portsToPreserve = [...new Set([...preservePorts, actualAgentDashboardPort])]; for (const port of preservePorts) { if (port === agentDashboardPort) continue; try { - ensureDashboardForward(sandboxName, `http://127.0.0.1:${port}`, { + const forwardUrl = + port === optionalDashboardPort && chatUiUrl + ? replaceUrlPort(chatUiUrl, port) + : `http://127.0.0.1:${port}`; + ensureDashboardForward(sandboxName, forwardUrl, { preserveSandboxPorts: portsToPreserve, allowPortReallocation: false, }); @@ -75,3 +99,13 @@ export function ensureAgentDashboardForward(options: { return actualAgentDashboardPort; } + +function replaceUrlPort(value: string, port: number): string { + try { + const parsed = new URL(value.includes("://") ? value : `http://${value}`); + parsed.port = String(port); + return parsed.toString().replace(/\/$/, ""); + } catch { + return `http://127.0.0.1:${port}`; + } +} diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 73f85a2ff27..0a88c2f966c 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -70,6 +70,7 @@ export interface OnboardDashboardDeps { deps: { note: (msg: string) => void; buildControlUiUrls: (token: string | null, port: number) => string[]; + effectiveDashboardPort?: number; }, ): void; } @@ -375,10 +376,13 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa sandboxName: string, agent: { forwardPort?: number | null; forward_ports?: number[] | null }, ): number { + const chatUiUrl = process.env.CHAT_UI_URL; return ensureAgentDashboardForwardForAgent({ sandboxName, agent, ensureDashboardForward, + chatUiUrl, + controlUiPort: chatUiUrl ? Number(getDashboardForwardPort(chatUiUrl)) : undefined, }); } @@ -477,6 +481,7 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa console.log(""); deps.printAgentDashboardUi(sandboxName, token, agent, { note: deps.note, + effectiveDashboardPort: chain.port, buildControlUiUrls: (tokenValue: string | null, port: number) => { const primary = buildControlUiUrls(tokenValue, port); const alternates = buildFallbackControlUiUrls(tokenValue, port, [ diff --git a/src/lib/onboard/hermes-dashboard.test.ts b/src/lib/onboard/hermes-dashboard.test.ts index 1075eb73f35..9ee1cb83ed7 100644 --- a/src/lib/onboard/hermes-dashboard.test.ts +++ b/src/lib/onboard/hermes-dashboard.test.ts @@ -11,14 +11,70 @@ import { } from "./hermes-dashboard"; describe("onboard Hermes dashboard helpers", () => { - it("rejects dashboard/API port overlap before sandbox create", () => { - expect(() => + it("uses a non-default NEMOCLAW_DASHBOARD_PORT as the Hermes WebUI public port (#6277)", () => { + expect( resolveHermesDashboardOnboardState({ agentName: "hermes", - effectivePort: 9119, + effectivePort: 9120, + env: { + NEMOCLAW_DASHBOARD_PORT: "9120", + NEMOCLAW_HERMES_DASHBOARD: "1", + }, + }), + ).toMatchObject({ + enabled: true, + config: { + port: 9120, + internalPort: 19119, + }, + }); + }); + + it("uses a non-default --control-ui-port as the Hermes WebUI public port (#6277)", () => { + expect( + resolveHermesDashboardOnboardState({ + agentName: "hermes", + effectivePort: 9121, env: { NEMOCLAW_HERMES_DASHBOARD: "1" }, }), - ).toThrow(/must not equal the Hermes API port/); + ).toMatchObject({ + enabled: true, + config: { + port: 9121, + internalPort: 19119, + }, + }); + }); + + it("accepts a matching legacy Hermes dashboard port alias (#6277)", () => { + expect( + resolveHermesDashboardOnboardState({ + agentName: "hermes", + effectivePort: 9119, + env: { + NEMOCLAW_HERMES_DASHBOARD: "1", + NEMOCLAW_HERMES_DASHBOARD_PORT: "9119", + }, + }), + ).toMatchObject({ + enabled: true, + config: { + port: 9119, + }, + }); + }); + + it("rejects a separate Hermes dashboard public port that would not match the OpenShell forward (#6277)", () => { + expect(() => + resolveHermesDashboardOnboardState({ + agentName: "hermes", + effectivePort: 18789, + env: { + NEMOCLAW_HERMES_DASHBOARD: "1", + NEMOCLAW_HERMES_DASHBOARD_PORT: "9119", + }, + }), + ).toThrow(/must match the NemoClaw dashboard port \(18789\)/); }); it("rejects the internal dashboard port colliding with the OpenClaw dashboard port", () => { @@ -31,14 +87,14 @@ describe("onboard Hermes dashboard helpers", () => { effectivePort: 19119, env: { NEMOCLAW_HERMES_DASHBOARD: "1" }, }), - ).toThrow(/NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT must not equal the Hermes API port/); + ).toThrow(/NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT must not equal the Hermes WebUI port/); }); it("tracks registry drift for enabled dashboard settings", () => { const state = resolveHermesDashboardOnboardState({ - // 18789 = realistic resolved dashboard port; 8642 is the reserved API port (#4984). + // 9120 = user-selected Hermes WebUI port; 8642 is the reserved API port (#4984). agentName: "hermes", - effectivePort: 18789, + effectivePort: 9120, env: { NEMOCLAW_HERMES_DASHBOARD: "1", NEMOCLAW_HERMES_DASHBOARD_PORT: "9120", @@ -145,6 +201,9 @@ describe("onboard Hermes dashboard helpers", () => { expect(() => ensure("my-hermes", true)).toThrow(/Failed to start Hermes dashboard forward/); expect(rollback).toHaveBeenCalledWith("my-hermes"); - expect(fail).toHaveBeenCalled(); + expect(fail).toHaveBeenCalledWith( + expect.stringMatching(/set NEMOCLAW_DASHBOARD_PORT, or pass --control-ui-port /i), + ); + expect(fail.mock.calls[0]?.[0]).not.toContain("NEMOCLAW_HERMES_DASHBOARD_PORT"); }); }); diff --git a/src/lib/onboard/hermes-dashboard.ts b/src/lib/onboard/hermes-dashboard.ts index b89ee29449c..7d0cd72b886 100644 --- a/src/lib/onboard/hermes-dashboard.ts +++ b/src/lib/onboard/hermes-dashboard.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { HERMES_OPENAI_API_PORT } from "../core/ports"; import { HERMES_DASHBOARD_ENABLE_ENV, HERMES_DASHBOARD_INTERNAL_PORT_ENV, @@ -9,9 +10,8 @@ import { type HermesDashboardConfig, readHermesDashboardConfig, } from "../hermes-dashboard"; -import { HERMES_OPENAI_API_PORT } from "../core/ports"; -import { RESERVED_HERMES_DASHBOARD_PORT_MESSAGE } from "./preflight-ports"; import type { SandboxEntry } from "../state/registry"; +import { RESERVED_HERMES_DASHBOARD_PORT_MESSAGE } from "./preflight-ports"; export interface HermesDashboardOnboardState { config: HermesDashboardConfig | null; @@ -59,18 +59,15 @@ export function resolveHermesDashboardOnboardState({ } if (config.enabled) { - if (config.port === effectivePort) { - const message = `${HERMES_DASHBOARD_PORT_ENV} must not equal the Hermes API port (${effectivePort}).`; + const rawHermesDashboardPort = env[HERMES_DASHBOARD_PORT_ENV]?.trim(); + if (rawHermesDashboardPort && config.port !== effectivePort) { + const message = `${HERMES_DASHBOARD_PORT_ENV} must match the NemoClaw dashboard port (${effectivePort}). Set NEMOCLAW_DASHBOARD_PORT or pass --control-ui-port to change the Hermes WebUI port.`; if (fail) return fail(message); throw new Error(message); } + config = { ...config, port: effectivePort }; if (config.port === config.internalPort) { - const message = `${HERMES_DASHBOARD_PORT_ENV} must not equal ${HERMES_DASHBOARD_INTERNAL_PORT_ENV}.`; - if (fail) return fail(message); - throw new Error(message); - } - if (config.internalPort === effectivePort) { - const message = `${HERMES_DASHBOARD_INTERNAL_PORT_ENV} must not equal the Hermes API port (${effectivePort}).`; + const message = `${HERMES_DASHBOARD_INTERNAL_PORT_ENV} must not equal the Hermes WebUI port (${config.port}).`; if (fail) return fail(message); throw new Error(message); } @@ -153,7 +150,7 @@ export function ensureHermesDashboardForwardIfEnabled({ export function formatHermesDashboardForwardFailure(state: HermesDashboardOnboardState): string { const port = state.config?.port ?? "unknown"; - return `Failed to start Hermes dashboard forward on port ${port}. Free the port and re-run onboarding, or set ${HERMES_DASHBOARD_PORT_ENV} to another port.`; + return `Failed to start Hermes dashboard forward on port ${port}. Free the port and re-run onboarding, set NEMOCLAW_DASHBOARD_PORT, or pass --control-ui-port to choose another port.`; } export function createHermesDashboardForwardEnsurer({ diff --git a/test/onboard-dashboard.test.ts b/test/onboard-dashboard.test.ts index d126cfe7fbc..d0d827757e6 100644 --- a/test/onboard-dashboard.test.ts +++ b/test/onboard-dashboard.test.ts @@ -4,6 +4,8 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { loadAgent } from "../src/lib/agent/defs"; +import { printDashboardUi } from "../src/lib/agent/onboard"; import type { OnboardDashboardDeps, OnboardDashboardHelpers } from "../src/lib/onboard/dashboard"; const { getPortConflictServiceHints } = require("../src/lib/onboard") as { @@ -295,6 +297,66 @@ describe("onboard dashboard helpers", () => { expect(urls.some((url) => url.includes(":18789"))).toBe(false); }); + it.each<[string, number, () => void]>([ + [ + "NEMOCLAW_DASHBOARD_PORT", + 9120, + () => { + process.env.NEMOCLAW_DASHBOARD_PORT = "9120"; + }, + ], + [ + "--control-ui-port", + 9121, + () => { + delete process.env.NEMOCLAW_DASHBOARD_PORT; + }, + ], + ])("prints the effective Hermes dashboard URL selected by %s (#6277)", (_source, port, configurePort) => { + const previousChatUiUrl = process.env.CHAT_UI_URL; + const previousDashboardPort = process.env.NEMOCLAW_DASHBOARD_PORT; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const helpers = createOnboardDashboardHelpers({ + runOpenshell: vi.fn(() => ({ status: 1 })), + runCaptureOpenshell: vi.fn(() => ""), + runCapture: vi.fn(() => ""), + openshellArgv: (args: string[]) => [process.execPath, "-e", "", ...args], + cliName: () => "nemohermes", + agentProductName: () => "NemoHermes", + getProviderLabel: (provider: string) => provider, + nimStatus: vi.fn(() => ({ running: false, container: "nemoclaw-nim-test" })), + shouldShowNimLine: vi.fn(() => false), + note: vi.fn(), + isWsl: () => false, + redact: (value: unknown) => String(value), + sleep: vi.fn(), + printAgentDashboardUi: printDashboardUi, + listSandboxes: () => ({ sandboxes: [] }), + }); + + let output = ""; + try { + process.env.CHAT_UI_URL = `http://127.0.0.1:${String(port)}`; + configurePort(); + helpers.printDashboard("my-hermes", "gpt-oss:20b", "ollama", null, loadAgent("hermes")); + output = logSpy.mock.calls.map(([line]) => String(line)).join("\n"); + } finally { + previousChatUiUrl === undefined + ? delete process.env.CHAT_UI_URL + : (process.env.CHAT_UI_URL = previousChatUiUrl); + previousDashboardPort === undefined + ? delete process.env.NEMOCLAW_DASHBOARD_PORT + : (process.env.NEMOCLAW_DASHBOARD_PORT = previousDashboardPort); + logSpy.mockRestore(); + } + + expect(output).toContain("Hermes Agent Dashboard"); + expect(output).toContain(`Port ${String(port)} must be forwarded before opening this URL.`); + expect(output).toContain(`http://127.0.0.1:${String(port)}/`); + expect(output).not.toContain("http://127.0.0.1:9119/"); + expect(output).not.toContain("http://127.0.0.1:18789/"); + }); + 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(); diff --git a/test/process-recovery-primitives.test.ts b/test/process-recovery-primitives.test.ts index 92453090ef8..4c090259422 100644 --- a/test/process-recovery-primitives.test.ts +++ b/test/process-recovery-primitives.test.ts @@ -59,12 +59,21 @@ describe("resolveSandboxDashboardPort", () => { ).toBe(18789); }); - it("keeps non-OpenClaw agents on their declared forward port", () => { + it("keeps non-OpenClaw agents on their recorded custom dashboard port (#6277)", () => { expect( resolveSandboxDashboardPort("hermes-box", { getSessionAgent: () => ({ forwardPort: 8642 }), getSandbox: () => ({ name: "hermes-box", dashboardPort: 18790 }), }), + ).toBe(18790); + }); + + it("falls back to a non-OpenClaw agent's declared port without registry metadata", () => { + expect( + resolveSandboxDashboardPort("hermes-box", { + getSessionAgent: () => ({ forwardPort: 8642 }), + getSandbox: () => null, + }), ).toBe(8642); }); diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index 88b31652480..ab563c92d2d 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -555,7 +555,7 @@ hermes-box 127.0.0.1 18789 12345 running`; displayName: "Hermes Agent", binary_path: "/usr/local/bin/hermes", gateway_command: "hermes gateway run", - forwardPort: 8642, + forwardPort: 18789, healthProbe: { url: "http://127.0.0.1:8642/health", port: 8642, @@ -575,7 +575,7 @@ hermes-box 127.0.0.1 18789 12345 running`; status: 0, output: "SANDBOX BIND PORT PID STATUS\n" + - "hermes-box 127.0.0.1 8642 12345 running\n" + + "hermes-box 127.0.0.1 18789 12345 running\n" + "hermes-box 127.0.0.1 9119 12346 running", }); vi.spyOn(openshellRuntime, "runOpenshell").mockReturnValue({ status: 0 } as never); @@ -1121,7 +1121,7 @@ hermes-box 127.0.0.1 8642 12346 running`; ); vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue({ name: "hermes", - forwardPort: 8642, + forwardPort: 18789, displayName: "Hermes Agent", }); vi.spyOn(registry, "getSandbox").mockReturnValue({ @@ -1132,13 +1132,13 @@ hermes-box 127.0.0.1 8642 12346 running`; vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => forwardStarted); vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ status: 0, - output: `SANDBOX BIND PORT PID STATUS\nhermes-box 127.0.0.1 8642 12346 ${forwardStarted ? "running" : "dead"}\nhermes-box 127.0.0.1 18789 12345 running`, + output: `SANDBOX BIND PORT PID STATUS\nhermes-box 127.0.0.1 18789 12345 ${forwardStarted ? "running" : "dead"}`, })); const runOpenshell = vi .spyOn(openshellRuntime, "runOpenshell") .mockImplementation((rawArgs: unknown) => { const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; - if (args[0] === "forward" && args[1] === "start" && args.includes("8642")) { + if (args[0] === "forward" && args[1] === "start" && args.includes("18789")) { forwardStarted = true; } return { status: 0 } as never; @@ -1159,7 +1159,7 @@ hermes-box 127.0.0.1 8642 12346 running`; expect(requestGatewaySupervisorAction).toHaveBeenCalledOnce(); expect(requestGatewaySupervisorAction).toHaveBeenCalledWith("hermes-box", "recover"); expect(runOpenshell).toHaveBeenCalledWith( - ["forward", "start", "--background", "8642", "hermes-box"], + ["forward", "start", "--background", "18789", "hermes-box"], { ignoreError: true }, ); });