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
7 changes: 7 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,7 @@ All ports must be non-privileged integers between 1024 and 65535.
| `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference |
| `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference |
| `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy |
| `NEMOCLAW_DASHBOARD_BIND` | *unset* (loopback) | Dashboard forward bind address — set to `0.0.0.0` to opt in to remote bind for SSH-deployed hosts |

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`.
Expand All @@ -1103,6 +1104,12 @@ If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another fre
`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.

`NEMOCLAW_DASHBOARD_BIND` controls the dashboard port forward bind address.
By default the forward stays on `127.0.0.1` (loopback only).
Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` before `nemoclaw onboard` (or `nemoclaw <sandbox> connect`) to bind the dashboard on all interfaces — useful when the host is reached over SSH (Brev, cloud workstations) and the dashboard URL needs to be opened from a different machine on the network.
Only `0.0.0.0` enables the remote bind; other values are ignored.
When the remote bind is opted in, the dashboard auth flow accepts non-loopback origins.

```console
$ export NEMOCLAW_DASHBOARD_PORT=19000
$ nemoclaw onboard
Expand Down
28 changes: 28 additions & 0 deletions src/lib/dashboard/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ describe("buildChain", () => {
it("shouldDisableDeviceAuth is false for IPv6 loopback", () => {
expect(buildChain({ chatUiUrl: "http://[::1]:18789" }).shouldDisableDeviceAuth).toBe(false);
});

// #3259 — explicit operator opt-in to bind dashboard on all interfaces
// for remote-SSH-deployed hosts (Brev / cloud workstations).
it("binds to 0.0.0.0 when bindOverride='0.0.0.0' is set, even for loopback URL", () => {
const c = buildChain({ chatUiUrl: "http://127.0.0.1:18789", bindOverride: "0.0.0.0" });
expect(c.forwardTarget).toBe("0.0.0.0:18789");
expect(c.bindAddress).toBe("0.0.0.0");
});

it("does not bind to 0.0.0.0 when bindOverride is empty/loopback", () => {
const c1 = buildChain({ chatUiUrl: "http://127.0.0.1:18789", bindOverride: "" });
expect(c1.forwardTarget).toBe("18789");
expect(c1.bindAddress).toBe("127.0.0.1");
const c2 = buildChain({ chatUiUrl: "http://127.0.0.1:18789", bindOverride: "127.0.0.1" });
expect(c2.forwardTarget).toBe("18789");
expect(c2.bindAddress).toBe("127.0.0.1");
});

it("ignores invalid bindOverride values (security: only 0.0.0.0 / 127.0.0.1 accepted)", () => {
const c = buildChain({ chatUiUrl: "http://127.0.0.1:18789", bindOverride: "10.0.0.5" });
expect(c.forwardTarget).toBe("18789");
expect(c.bindAddress).toBe("127.0.0.1");
});

it("disables device auth when bindOverride='0.0.0.0' (cross-host access opted in)", () => {
const c = buildChain({ chatUiUrl: "http://127.0.0.1:18789", bindOverride: "0.0.0.0" });
expect(c.shouldDisableDeviceAuth).toBe(true);
});
});

describe("buildControlUiUrls", () => {
Expand Down
17 changes: 15 additions & 2 deletions src/lib/dashboard/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export interface PlatformHints {
port?: number;
isWsl?: boolean;
wslHostAddress?: string | null;
/**
* Explicit operator opt-in to bind the dashboard forward on all interfaces.
* Only `"0.0.0.0"` enables remote bind; anything else (including
* `"127.0.0.1"`, empty string, or arbitrary IPs) leaves the default
* loopback bind in place. Surfaces through `NEMOCLAW_DASHBOARD_BIND` at the
* I/O boundary (`dashboard-access.ts`). (#3259)
*/
bindOverride?: string;
}

export interface DashboardDeliveryChain {
Expand Down Expand Up @@ -69,14 +77,19 @@ export function buildChain(hints?: PlatformHints): DashboardDeliveryChain {
accessUrl = `http://127.0.0.1:${port}`;
}

const forwardTarget = h.isWsl || hasNonLoopbackUrl ? `0.0.0.0:${port}` : String(port);
// #3259 — operator opt-in via NEMOCLAW_DASHBOARD_BIND=0.0.0.0 for remote-SSH-deployed
// hosts (Brev, cloud workstations). Only "0.0.0.0" is honored; arbitrary IPs are
// rejected silently to keep the surface narrow.
const remoteBindOptIn = h.bindOverride === "0.0.0.0";
const forwardTarget =
h.isWsl || hasNonLoopbackUrl || remoteBindOptIn ? `0.0.0.0:${port}` : String(port);
const bindAddress = forwardTarget.includes(":") ? "0.0.0.0" : "127.0.0.1";
const loopbackOrigin = `http://127.0.0.1:${port}`;
const accessOrigin = (() => { try { return new URL(accessUrl).origin; } catch { return null; } })();
const corsOrigins = accessOrigin && accessOrigin !== loopbackOrigin
? [loopbackOrigin, accessOrigin] : [loopbackOrigin];

const shouldDisableDeviceAuth = hasNonLoopbackUrl || (h.isWsl ?? false);
const shouldDisableDeviceAuth = hasNonLoopbackUrl || (h.isWsl ?? false) || remoteBindOptIn;

return { accessUrl, corsOrigins, forwardTarget, healthEndpoint: "/health", port, bindAddress, shouldDisableDeviceAuth };
}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/onboard/dashboard-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ export function getWslHostAddress(options: DashboardAccessOptions = {}): string
);
}

/**
* Read the operator-opt-in remote-bind env var. Only "0.0.0.0" enables the
* remote bind; anything else (empty, "127.0.0.1", invalid IPs) leaves the
* default loopback bind. (#3259)
*/
function readBindOverride(options: DashboardAccessOptions): string | undefined {
const raw = options.env?.NEMOCLAW_DASHBOARD_BIND ?? process.env.NEMOCLAW_DASHBOARD_BIND;
return typeof raw === "string" ? raw : undefined;
}

/**
* I/O-boundary wrapper around the pure `buildChain` function. Resolves the
* platform hints (`isWsl`, `wslHostAddress`, `bindOverride`) from the host
* environment and config, then delegates the actual decision to `buildChain`
* so the contract stays a pure function and tests can call `buildChain`
* directly without env mocks. Callers in onboard / status / doctor share
* this entry point so the same hints apply consistently across the CLI.
*/
export function buildDashboardChain(
chatUiUrl = defaultChatUiUrl(),
options: DashboardAccessOptions = {},
Expand All @@ -57,6 +75,7 @@ export function buildDashboardChain(
chatUiUrl,
isWsl: isWsl(options),
wslHostAddress: getWslHostAddress(options),
bindOverride: readBindOverride(options),
});
}

Expand Down
Loading