diff --git a/src/lib/core/ports.ts b/src/lib/core/ports.ts index 99829908cb..cdcf05a897 100644 --- a/src/lib/core/ports.ts +++ b/src/lib/core/ports.ts @@ -54,6 +54,8 @@ 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); +/** Hermes OpenAI-compatible API port (manifest `forward_ports[1]` / start.sh `PUBLIC_PORT`); reserved — never a valid dashboard port, for any agent. (#4984) */ +export const HERMES_OPENAI_API_PORT = 8642; /** Bedrock Runtime adapter port (default 11436, override via NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT). */ export const BEDROCK_RUNTIME_ADAPTER_PORT = parsePort( "NEMOCLAW_BEDROCK_RUNTIME_ADAPTER_PORT", diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 08d65d27d0..535c90c5c6 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -481,6 +481,8 @@ const { preflightDashboardPortRangeAvailability, resolveCreateSandboxDashboardPort, } = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port"); +const { assertDashboardPortNotReserved, buildRequiredPreflightPorts } = + require("./onboard/preflight-ports") as typeof import("./onboard/preflight-ports"); const { tryCleanupOrphanedDashboardForward } = require("./onboard/orphaned-dashboard-forward") as typeof import("./onboard/orphaned-dashboard-forward"); const { destroyGatewayForReuse } = @@ -1750,22 +1752,14 @@ async function preflight( // skip the dashboard port check entirely — ensureDashboardForward will // find a free port. const dashboardPortToCheck = _preflightDashboardPort ?? null; - const requiredPorts = [ - { - port: GATEWAY_PORT, - label: "OpenShell gateway", - envVar: "NEMOCLAW_GATEWAY_PORT", - }, - ...(dashboardPortToCheck !== null - ? [ - { - port: dashboardPortToCheck, - label: `${cliDisplayName()} dashboard`, - envVar: "NEMOCLAW_DASHBOARD_PORT", - }, - ] - : []), - ]; + // #4984 — fail fast on an explicit reserved dashboard port; deferred paths + // (CHAT_UI_URL / persisted) are caught at createSandbox. + assertDashboardPortNotReserved(dashboardPortToCheck); + const requiredPorts = buildRequiredPreflightPorts({ + gatewayPort: GATEWAY_PORT, + dashboardPort: dashboardPortToCheck, + dashboardLabel: `${cliDisplayName()} dashboard`, + }); for (const { port, label, envVar } of requiredPorts) { const portCheckOptions = port === GATEWAY_PORT ? dockerDriverGatewayEnv.getGatewayPortCheckOptions() : undefined; diff --git a/src/lib/onboard/dashboard-preflight-ports.test.ts b/src/lib/onboard/dashboard-preflight-ports.test.ts new file mode 100644 index 0000000000..502ce911e4 --- /dev/null +++ b/src/lib/onboard/dashboard-preflight-ports.test.ts @@ -0,0 +1,52 @@ +// 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 { HERMES_OPENAI_API_PORT } from "../core/ports"; +import { assertDashboardPortNotReserved, buildRequiredPreflightPorts } from "./preflight-ports"; + +describe("buildRequiredPreflightPorts", () => { + it("returns the gateway only when no dashboard port is requested (auto-allocation)", () => { + expect( + buildRequiredPreflightPorts({ + gatewayPort: 8080, + dashboardPort: null, + dashboardLabel: "NemoClaw dashboard", + }), + ).toEqual([{ port: 8080, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }]); + }); + + it("includes the dashboard port when one is explicitly requested", () => { + expect( + buildRequiredPreflightPorts({ + gatewayPort: 8080, + dashboardPort: 18789, + dashboardLabel: "NemoClaw dashboard", + }), + ).toEqual([ + { port: 8080, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }, + { port: 18789, label: "NemoClaw dashboard", envVar: "NEMOCLAW_DASHBOARD_PORT" }, + ]); + }); +}); + +describe("assertDashboardPortNotReserved (#4984)", () => { + it("rejects the reserved Hermes API port 8642 via fail()", () => { + const fail = vi.fn((message: string): never => { + throw new Error(message); + }); + expect(() => assertDashboardPortNotReserved(HERMES_OPENAI_API_PORT, fail)).toThrow( + "[SECURITY] Invalid dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", + ); + expect(fail).toHaveBeenCalledOnce(); + }); + + it("allows a normal dashboard port and a null (auto-allocated) port", () => { + const fail = vi.fn((message: string): never => { + throw new Error(message); + }); + expect(() => assertDashboardPortNotReserved(18789, fail)).not.toThrow(); + expect(() => assertDashboardPortNotReserved(null, fail)).not.toThrow(); + expect(fail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/hermes-dashboard.test.ts b/src/lib/onboard/hermes-dashboard.test.ts index f0061980e1..400a198a8a 100644 --- a/src/lib/onboard/hermes-dashboard.test.ts +++ b/src/lib/onboard/hermes-dashboard.test.ts @@ -67,7 +67,7 @@ describe("onboard Hermes dashboard helpers", () => { env: { NEMOCLAW_DASHBOARD_PORT: "8642" }, }), ).toThrow( - "[SECURITY] Invalid Hermes dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", + "[SECURITY] Invalid dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", ); }); @@ -96,7 +96,7 @@ describe("onboard Hermes dashboard helpers", () => { env: {}, }), ).toThrow( - "[SECURITY] Invalid Hermes dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", + "[SECURITY] Invalid dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", ); }); @@ -110,14 +110,19 @@ describe("onboard Hermes dashboard helpers", () => { ).not.toThrow(); }); - it("does not apply the #4984 reserved-port guard to non-Hermes agents", () => { - expect( + it("rejects the reserved Hermes API port 8642 even for non-Hermes agents (#4984)", () => { + // A non-hermes onboard that binds 8642 squats the host port and silently + // breaks a later `nemoclaw onboard` of hermes (whose API forwards 8642), + // so the reserved-port guard is host-side and agent-agnostic. + expect(() => resolveHermesDashboardOnboardState({ agentName: "openclaw", effectivePort: 18789, env: { NEMOCLAW_DASHBOARD_PORT: "8642" }, }), - ).toEqual({ config: null, enabled: false }); + ).toThrow( + "[SECURITY] Invalid dashboard port 8642 - reserved for the Hermes OpenAI-compatible API", + ); }); it("rolls back and fails when an opted-in dashboard forward cannot start", () => { diff --git a/src/lib/onboard/hermes-dashboard.ts b/src/lib/onboard/hermes-dashboard.ts index daf6b971ad..b89ee29449 100644 --- a/src/lib/onboard/hermes-dashboard.ts +++ b/src/lib/onboard/hermes-dashboard.ts @@ -9,11 +9,10 @@ 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"; -/** Hermes OpenAI-compatible API port (manifest `forward_ports[1]` / start.sh `PUBLIC_PORT`); reserved — never a dashboard port. (#4984) */ -const HERMES_OPENAI_API_PORT = 8642; - export interface HermesDashboardOnboardState { config: HermesDashboardConfig | null; enabled: boolean; @@ -32,24 +31,24 @@ export function resolveHermesDashboardOnboardState({ env: NodeJS.ProcessEnv; fail?: (message: string) => never; }): HermesDashboardOnboardState { - if (agentName !== "hermes") return { config: null, enabled: false }; - - // #4984 — reject the reserved Hermes API port as the dashboard port, host-side, - // before any sandbox is built. Check both the resolved effectivePort (covers - // --control-ui-port / CHAT_UI_URL / persisted port) and the raw env override, - // which the host otherwise silently drops so effectivePort never shows it. - // Message mirrors agents/hermes/start.sh:164. + // #4984 — reject the reserved Hermes API port (HERMES_OPENAI_API_PORT) as the + // dashboard port for ANY agent, before any sandbox is built. Check both the + // resolved effectivePort (covers --control-ui-port / CHAT_UI_URL / persisted) + // and the raw env override, which the host otherwise silently drops so + // effectivePort never shows it. Message mirrors agents/hermes/start.sh:164. const rawDashboardPort = env.NEMOCLAW_DASHBOARD_PORT?.trim(); const requestedDashboardPort = rawDashboardPort ? Number(rawDashboardPort) : undefined; if ( effectivePort === HERMES_OPENAI_API_PORT || requestedDashboardPort === HERMES_OPENAI_API_PORT ) { - const message = `[SECURITY] Invalid Hermes dashboard port ${HERMES_OPENAI_API_PORT} - reserved for the Hermes OpenAI-compatible API`; + const message = RESERVED_HERMES_DASHBOARD_PORT_MESSAGE; if (fail) return fail(message); throw new Error(message); } + if (agentName !== "hermes") return { config: null, enabled: false }; + let config: HermesDashboardConfig; try { config = readHermesDashboardConfig(env); diff --git a/src/lib/onboard/preflight-ports.ts b/src/lib/onboard/preflight-ports.ts new file mode 100644 index 0000000000..ec81471786 --- /dev/null +++ b/src/lib/onboard/preflight-ports.ts @@ -0,0 +1,55 @@ +// 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"; + +/** Agent-neutral rejection message when {@link HERMES_OPENAI_API_PORT} is requested as a dashboard port; shared by both #4984 guards. */ +export const RESERVED_HERMES_DASHBOARD_PORT_MESSAGE = `[SECURITY] Invalid dashboard port ${HERMES_OPENAI_API_PORT} - reserved for the Hermes OpenAI-compatible API`; + +export interface PreflightPort { + port: number; + label: string; + envVar: string; +} + +/** + * Build the preflight required-ports list: the OpenShell gateway always, plus + * the dashboard port when an explicit one is requested (auto-allocation skips + * it). Extracted from onboard.ts so the reserved-port guard can live in a + * submodule. (#4984) + */ +export function buildRequiredPreflightPorts(opts: { + gatewayPort: number; + dashboardPort: number | null; + dashboardLabel: string; +}): PreflightPort[] { + return [ + { port: opts.gatewayPort, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }, + ...(opts.dashboardPort !== null + ? [ + { + port: opts.dashboardPort, + label: opts.dashboardLabel, + envVar: "NEMOCLAW_DASHBOARD_PORT", + }, + ] + : []), + ]; +} + +/** + * Reject the reserved {@link HERMES_OPENAI_API_PORT} as a dashboard port at + * preflight (any agent) so onboarding fails fast at [1/8], before any sandbox. + * Mirrors the createSandbox guard in resolveHermesDashboardOnboardState. (#4984) + */ +export function assertDashboardPortNotReserved( + dashboardPort: number | null, + fail: (message: string) => never = (message) => { + console.error(` ${message}`); + process.exit(1); + }, +): void { + if (dashboardPort === HERMES_OPENAI_API_PORT) { + fail(RESERVED_HERMES_DASHBOARD_PORT_MESSAGE); + } +}