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
14 changes: 9 additions & 5 deletions src/lib/actions/sandbox/forward-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
14 changes: 10 additions & 4 deletions src/lib/agent/dashboard-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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",
);
Expand Down Expand Up @@ -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());
Expand All @@ -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;
},
Expand All @@ -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.`);
Expand Down
10 changes: 6 additions & 4 deletions src/lib/agent/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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",
Expand All @@ -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");
Expand Down
35 changes: 24 additions & 11 deletions src/lib/agent/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`);
Expand All @@ -433,34 +438,42 @@ 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`);
console.log(` append #token=<token> locally if the browser asks for auth.`);
} 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);
}

/**
Expand Down Expand Up @@ -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("");
Expand Down
2 changes: 1 addition & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4514,7 +4514,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
requestedObservabilityEnabled: runtimeControlRequests.requestedObservabilityEnabled,
authoritativePolicyTier:
opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : null,
controlUiPort: opts.controlUiPort || null,
controlUiPort: _preflightDashboardPort,
rootDir: ROOT,
},
sandboxDeps: {
Expand Down
131 changes: 130 additions & 1 deletion src/lib/onboard/agent-dashboard-forward.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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");
});
});
Loading