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
2 changes: 2 additions & 0 deletions src/lib/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 10 additions & 16 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions src/lib/onboard/dashboard-preflight-ports.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 10 additions & 5 deletions src/lib/onboard/hermes-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down Expand Up @@ -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",
);
});

Expand All @@ -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", () => {
Expand Down
21 changes: 10 additions & 11 deletions src/lib/onboard/hermes-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions src/lib/onboard/preflight-ports.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading