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: 12 additions & 2 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ It verifies that Docker is reachable, warns on untested runtimes such as Podman,
The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`).
If the installed OpenShell version falls outside this range, onboarding exits with an actionable error and a link to compatible releases.

When an existing gateway is detected for reuse, NemoClaw probes the host gateway HTTP endpoint (`http://127.0.0.1:${NEMOCLAW_GATEWAY_PORT}/`) before declaring it reusable, so a gateway whose container is running but whose upstream is still warming up (e.g. immediately after a Docker daemon restart) is rebuilt instead of trusted.
When NemoClaw finds an existing gateway to reuse, it probes the host gateway HTTP endpoint before declaring the gateway reusable.
If the container is running but the upstream is still warming up (for example, immediately after a Docker daemon restart), NemoClaw rebuilds the gateway instead of trusting stale metadata.
Tune the wait via `NEMOCLAW_REUSE_HEALTH_POLL_COUNT` (default `6`) and `NEMOCLAW_REUSE_HEALTH_POLL_INTERVAL` (default `5` seconds).
The poll count is clamped to a minimum of `1` so the probe always runs at least once, and the interval is clamped to a minimum of `0` (no sleep between attempts).

Expand Down Expand Up @@ -1070,21 +1071,30 @@ All ports must be non-privileged integers between 1024 and 65535.

| Variable | Default | Service |
|----------|---------|---------|
| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway |
| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port |
| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | OpenShell gateway bind address (`127.0.0.1` or `0.0.0.0`) |
| `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard UI |
| `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference |
| `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference |
| `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy |

If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error.
`NEMOCLAW_GATEWAY_PORT` also cannot overlap the configured dashboard, vLLM, Ollama, or Ollama proxy ports, and cannot use the dashboard auto-allocation range `18789` through `18799` or the default inference/proxy ports `8000`, `11434`, and `11435`.
On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different.
If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding.

`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`.
Binding the OpenShell gateway to `0.0.0.0` may make it reachable from other hosts on the network.

```console
$ export NEMOCLAW_DASHBOARD_PORT=19000
$ nemoclaw onboard
```

```console
$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
```

These overrides apply to onboarding, status checks, health probes, and the uninstaller.
Defaults are unchanged when no variable is set.
If `NEMOCLAW_DASHBOARD_PORT` or the port from `CHAT_UI_URL` is already occupied by another sandbox, onboarding scans `18789` through `18799` and uses the next free dashboard port.
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ Or set the port directly:
$ NEMOCLAW_DASHBOARD_PORT=19000 nemoclaw onboard
```

For an OpenShell gateway port conflict, set `NEMOCLAW_GATEWAY_PORT` to a free
non-privileged port that does not overlap NemoClaw's dashboard, vLLM, Ollama,
or Ollama proxy ports:

```console
$ NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
```

Remote/headless hosts can bind the OpenShell gateway to all IPv4 interfaces:

```console
$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
```

Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only when other hosts on the
network should be able to reach the gateway.

See [Environment Variables](commands.md#environment-variables) for the full list of port overrides.

### Running multiple sandboxes simultaneously
Expand Down
11 changes: 11 additions & 0 deletions docs/security/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,17 @@ Device authentication requires each connecting device to go through a pairing fl
| Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. |
| Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. |

### Gateway Bind Address

NemoClaw binds the OpenShell gateway to loopback by default.

| Aspect | Detail |
|---|---|
| Default | `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1`. |
| What you can change | Set `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` before onboarding to listen on all IPv4 interfaces. |
| Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway. |
| Recommendation | Keep the loopback default unless the gateway must be reachable from another host. |

### Insecure Auth Derivation

The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS authentication.
Expand Down
57 changes: 57 additions & 0 deletions src/lib/core/gateway-address.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it } from "vitest";

import {
getGatewayConnectHost,
getGatewayHttpEndpoint,
getGatewayHttpsEndpoint,
parseGatewayBindAddress,
} from "../../../dist/lib/core/gateway-address";

const ENV_KEY = "TEST_GATEWAY_BIND_ADDRESS";

afterEach(() => {
delete process.env[ENV_KEY];
});

describe("parseGatewayBindAddress", () => {
it("defaults to loopback", () => {
expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1");
});

it("accepts loopback", () => {
process.env[ENV_KEY] = "127.0.0.1";
expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1");
});

it("accepts all IPv4 interfaces", () => {
process.env[ENV_KEY] = "0.0.0.0";
expect(parseGatewayBindAddress(ENV_KEY)).toBe("0.0.0.0");
});

it("rejects comma-separated addresses", () => {
process.env[ENV_KEY] = "0.0.0.0,127.0.0.1";
expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either");
});

it.each(["localhost", "10.0.0.5", "::", "::1"])("rejects %s", (value) => {
process.env[ENV_KEY] = value;
expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either");
});
});

describe("gateway endpoint helpers", () => {
it("keeps loopback endpoints unchanged", () => {
expect(getGatewayConnectHost("127.0.0.1")).toBe("127.0.0.1");
expect(getGatewayHttpEndpoint(8080, "127.0.0.1")).toBe("http://127.0.0.1:8080");
expect(getGatewayHttpsEndpoint(8080, "127.0.0.1")).toBe("https://127.0.0.1:8080");
});

it("does not advertise wildcard bind addresses as client endpoints", () => {
expect(getGatewayConnectHost("0.0.0.0")).toBe("127.0.0.1");
expect(getGatewayHttpEndpoint(8990, "0.0.0.0")).toBe("http://127.0.0.1:8990");
expect(getGatewayHttpsEndpoint(8990, "0.0.0.0")).toBe("https://127.0.0.1:8990");
});
});
49 changes: 49 additions & 0 deletions src/lib/core/gateway-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { GATEWAY_PORT } from "./ports";

export const DEFAULT_GATEWAY_BIND_ADDRESS = "127.0.0.1";
export const WILDCARD_GATEWAY_BIND_ADDRESS = "0.0.0.0";

export type GatewayBindAddress =
| typeof DEFAULT_GATEWAY_BIND_ADDRESS
| typeof WILDCARD_GATEWAY_BIND_ADDRESS;

export function parseGatewayBindAddress(
envVar = "NEMOCLAW_GATEWAY_BIND_ADDRESS",
fallback: GatewayBindAddress = DEFAULT_GATEWAY_BIND_ADDRESS,
): GatewayBindAddress {
const raw = process.env[envVar];
if (raw === undefined || raw === "") return fallback;
const trimmed = String(raw).trim();
if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS;
if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS;
Comment on lines +18 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat whitespace-only bind values as empty input.

At Line 18, blank handling is checked before trimming, so NEMOCLAW_GATEWAY_BIND_ADDRESS=" " throws instead of using the fallback. Normalizing empties after trim avoids surprising config failures.

Suggested patch
 export function parseGatewayBindAddress(
   envVar = "NEMOCLAW_GATEWAY_BIND_ADDRESS",
   fallback: GatewayBindAddress = DEFAULT_GATEWAY_BIND_ADDRESS,
 ): GatewayBindAddress {
   const raw = process.env[envVar];
-  if (raw === undefined || raw === "") return fallback;
   const trimmed = String(raw).trim();
+  if (raw === undefined || trimmed === "") return fallback;
   if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS;
   if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS;
   throw new Error(
     `Invalid gateway bind address: ${envVar}="${raw}" — must be either ${DEFAULT_GATEWAY_BIND_ADDRESS} or ${WILDCARD_GATEWAY_BIND_ADDRESS}`,
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (raw === undefined || raw === "") return fallback;
const trimmed = String(raw).trim();
if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS;
if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS;
const trimmed = String(raw).trim();
if (raw === undefined || trimmed === "") return fallback;
if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS;
if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/core/gateway-address.ts` around lines 18 - 21, The current check uses
raw before trimming so whitespace-only values like "   " don't fall back; update
the logic to normalize first (e.g., compute trimmed = String(raw ?? "").trim()),
then treat trimmed === "" as the empty case and return fallback, and keep the
existing comparisons against DEFAULT_GATEWAY_BIND_ADDRESS and
WILDCARD_GATEWAY_BIND_ADDRESS using trimmed to decide returns.

throw new Error(
`Invalid gateway bind address: ${envVar}="${raw}" — must be either ${DEFAULT_GATEWAY_BIND_ADDRESS} or ${WILDCARD_GATEWAY_BIND_ADDRESS}`,
);
}

export const GATEWAY_BIND_ADDRESS = parseGatewayBindAddress();

export function getGatewayConnectHost(
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
): string {
return bindAddress === WILDCARD_GATEWAY_BIND_ADDRESS
? DEFAULT_GATEWAY_BIND_ADDRESS
: bindAddress;
}

export function getGatewayHttpEndpoint(
port: number = GATEWAY_PORT,
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
): string {
return `http://${getGatewayConnectHost(bindAddress)}:${port}`;
}

export function getGatewayHttpsEndpoint(
port: number = GATEWAY_PORT,
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
): string {
return `https://${getGatewayConnectHost(bindAddress)}:${port}`;
}
79 changes: 78 additions & 1 deletion src/lib/core/ports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@

import { describe, it, expect, beforeEach, afterEach } from "vitest";
// Import from compiled dist/ so coverage is attributed correctly.
import { parsePort } from "../../../dist/lib/core/ports";
import { parseGatewayPort, parsePort } from "../../../dist/lib/core/ports";

const GATEWAY_VALIDATION_OPTIONS = {
dashboardPort: 18789,
dashboardRangeStart: 18789,
dashboardRangeEnd: 18799,
vllmPort: 8000,
ollamaPort: 11434,
ollamaProxyPort: 11435,
};

describe("parsePort", () => {
const ENV_KEY = "TEST_PORT";
Expand Down Expand Up @@ -70,3 +79,71 @@ describe("parsePort", () => {
expect(() => parsePort(ENV_KEY, 8080)).toThrow("Invalid port");
});
});

describe("parseGatewayPort", () => {
const ENV_KEY = "TEST_GATEWAY_PORT";

beforeEach(() => {
delete process.env[ENV_KEY];
});

afterEach(() => {
delete process.env[ENV_KEY];
});

it("allows the default gateway port when no override is set", () => {
expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8080);
});

it("rejects the default gateway port when another service is configured there", () => {
expect(() =>
parseGatewayPort(ENV_KEY, 8080, {
...GATEWAY_VALIDATION_OPTIONS,
vllmPort: 8080,
}),
).toThrow("NEMOCLAW_VLLM_PORT");
});

it("accepts a non-conflicting gateway port override", () => {
process.env[ENV_KEY] = "8990";
expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8990);
});

it("rejects the dashboard auto-allocation range", () => {
process.env[ENV_KEY] = "18790";
expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow(
"18789-18799",
);
});

it("rejects overlap with the configured dashboard port", () => {
process.env[ENV_KEY] = "19000";
expect(() =>
parseGatewayPort(ENV_KEY, 8080, {
...GATEWAY_VALIDATION_OPTIONS,
dashboardPort: 19000,
}),
).toThrow("NEMOCLAW_DASHBOARD_PORT");
});

it("rejects overlap with a configured non-default service port", () => {
process.env[ENV_KEY] = "19001";
expect(() =>
parseGatewayPort(ENV_KEY, 8080, {
...GATEWAY_VALIDATION_OPTIONS,
vllmPort: 19001,
}),
).toThrow("NEMOCLAW_VLLM_PORT");
});

it.each([
["8000", "vLLM / NIM inference"],
["11434", "Ollama inference"],
["11435", "Ollama auth proxy"],
])("rejects overlap with default port %s", (port, label) => {
process.env[ENV_KEY] = port;
expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow(
label,
);
});
});
68 changes: 66 additions & 2 deletions src/lib/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ export function parsePort(envVar: string, fallback: number): number {
return parsed;
}

/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */
export const GATEWAY_PORT = parsePort("NEMOCLAW_GATEWAY_PORT", 8080);
export interface GatewayPortValidationOptions {
dashboardPort: number;
dashboardRangeStart: number;
dashboardRangeEnd: number;
vllmPort: number;
ollamaPort: number;
ollamaProxyPort: number;
}

/**
* The default port the OpenClaw dashboard listens on inside the sandbox.
* The sandbox image is built with CHAT_UI_URL=http://127.0.0.1:SANDBOX_DASHBOARD_PORT
Expand All @@ -50,3 +57,60 @@ 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);

export function validateGatewayPort(
envVar: string,
port: number,
options: GatewayPortValidationOptions,
): void {
if (port >= options.dashboardRangeStart && port <= options.dashboardRangeEnd) {
throw new Error(
`Invalid port: ${envVar}="${port}" — must not overlap the ${options.dashboardRangeStart}-${options.dashboardRangeEnd} dashboard port range`,
);
}

const reservedDefaults = [
{ label: "vLLM / NIM inference", port: 8000 },
{ label: "Ollama inference", port: 11434 },
{ label: "Ollama auth proxy", port: 11435 },
];
const reservedDefault = reservedDefaults.find((entry) => entry.port === port);
if (reservedDefault) {
throw new Error(
`Invalid port: ${envVar}="${port}" — must not overlap the ${reservedDefault.label} default port (${reservedDefault.port})`,
);
}

const conflicts = [
{ envVar: "NEMOCLAW_DASHBOARD_PORT", port: options.dashboardPort },
{ envVar: "NEMOCLAW_VLLM_PORT", port: options.vllmPort },
{ envVar: "NEMOCLAW_OLLAMA_PORT", port: options.ollamaPort },
{ envVar: "NEMOCLAW_OLLAMA_PROXY_PORT", port: options.ollamaProxyPort },
];
const conflict = conflicts.find((entry) => entry.port === port);
if (conflict) {
throw new Error(
`Invalid port: ${envVar}="${port}" — conflicts with ${conflict.envVar} (${conflict.port})`,
);
}
}

export function parseGatewayPort(
envVar: string,
fallback: number,
options: GatewayPortValidationOptions,
): number {
const port = parsePort(envVar, fallback);
validateGatewayPort(envVar, port, options);
return port;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */
export const GATEWAY_PORT = parseGatewayPort("NEMOCLAW_GATEWAY_PORT", 8080, {
dashboardPort: DASHBOARD_PORT,
dashboardRangeStart: DASHBOARD_PORT_RANGE_START,
dashboardRangeEnd: DASHBOARD_PORT_RANGE_END,
vllmPort: VLLM_PORT,
ollamaPort: OLLAMA_PORT,
ollamaProxyPort: OLLAMA_PROXY_PORT,
});
Loading
Loading