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
9 changes: 9 additions & 0 deletions docs/deployment/deploy-to-remote-gpu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ nemoclaw onboard
```

For SSH port-forwarding, the origin is typically the default `http://127.0.0.1:18789`, so you do not need extra configuration.
Forward the dashboard port from your workstation, substituting the port NemoClaw printed in the install summary (`18789` by default, or the next free port such as `18790`):

```bash
ssh -L 18789:127.0.0.1:18789 <user>@<remote-host>
```

When you run `nemoclaw` over SSH, the install summary and `nemoclaw <sandbox> dashboard-url` print this command for you, filled in with your remote username and the forwarded dashboard port.
The host stays a `<host>` placeholder that you replace with the address you SSH to, because NemoClaw cannot reliably recover it through an SSH config alias, NAT, or a jump host.
Then open the dashboard URL on your workstation.

<Warning>
On Brev, set `CHAT_UI_URL` in the launchable environment configuration so the installer can read it when it builds the sandbox image.
Expand Down
17 changes: 17 additions & 0 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,23 @@ Open the dashboard URL in your browser.
If the browser asks for authentication, run `nemoclaw my-gpt-claw dashboard-url --quiet` and open the returned URL.
Treat the authenticated URL like a password.

#### Open the Dashboard When You SSH'd into a Remote Host

The dashboard URL binds to `127.0.0.1` on the machine that runs `nemoclaw`.
If you SSH'd into a remote DGX Spark or GPU host, that loopback address is not reachable from your workstation browser until you forward the port.
When NemoClaw detects an SSH session, the install summary and `dashboard-url` output add a copy-pastable `ssh -L` example:

```text
Remote access (SSH session detected):
On your workstation, run:
ssh -L 18790:127.0.0.1:18790 <user>@<host>
Then open the dashboard URL above in your local browser.
```

Run that `ssh -L` command in a second terminal on your workstation, then open the dashboard URL locally.
The forwarded port matches the one NemoClaw printed, so substitute `18789` (or whichever port the summary shows) if it differs.
For Brev tunnels or binding the dashboard to all interfaces instead of forwarding, see [Remote Dashboard Access](../deployment/deploy-to-remote-gpu#remote-dashboard-access).

### Chat with the Agent from the Terminal

Connect to the sandbox and use the OpenClaw CLI.
Expand Down
76 changes: 76 additions & 0 deletions src/lib/dashboard-url-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,82 @@ describe("dashboard-url command helpers", () => {
expect(sinks.err.join("\n")).toContain("Treat this URL like a password");
});

it("appends an SSH port-forward hint when run over SSH (#5925)", () => {
const sinks = makeSinks();
runDashboardUrlCommand(
"alpha",
{ quiet: false },
{
fetchToken: () => "secret-token",
getSandbox: () => ({ agent: "openclaw", dashboardPort: 18790 }),
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toContain(" Remote access (SSH session detected):");
expect(sinks.out).toContain(" ssh -L 18790:127.0.0.1:18790 spark@<host>");
});

it("appends the SSH hint in the plain-URL (session-auth) branch over SSH (#5925)", () => {
const sinks = makeSinks();
const fetchToken = vi.fn(() => "should-not-fetch");

runDashboardUrlCommand(
"hermes",
{ quiet: false },
{
fetchToken,
getSandbox: () => ({ agent: "hermes", dashboardPort: 18790 }),
getAgentDashboardAuth: () => "session",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
log: sinks.log,
error: sinks.error,
},
);

expect(fetchToken).not.toHaveBeenCalled();
expect(sinks.out).toContain(" Dashboard URL:");
expect(sinks.out).toContain(" http://127.0.0.1:18790/");
expect(sinks.out).toContain(" Remote access (SSH session detected):");
expect(sinks.out).toContain(" ssh -L 18790:127.0.0.1:18790 spark@<host>");
});

it("omits the SSH port-forward hint outside an SSH session", () => {
const sinks = makeSinks();
runDashboardUrlCommand(
"alpha",
{ quiet: false },
{
fetchToken: () => "secret-token",
getSandbox: () => ({ agent: "openclaw", dashboardPort: 18790 }),
env: {},
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out.join("\n")).not.toContain("Remote access");
});

it("does not print the SSH hint in quiet mode even over SSH (#5925)", () => {
const sinks = makeSinks();
runDashboardUrlCommand(
"alpha",
{ quiet: true },
{
fetchToken: () => "secret-token",
getSandbox: () => ({ agent: "openclaw", dashboardPort: 18790 }),
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
log: sinks.log,
error: sinks.error,
},
);

expect(sinks.out).toEqual(["http://127.0.0.1:18790/#token=secret-token"]);
});

it("prints a plain dashboard URL for session-auth non-OpenClaw agents without fetching a token", () => {
const sinks = makeSinks();
const fetchToken = vi.fn(() => "should-not-fetch");
Expand Down
12 changes: 12 additions & 0 deletions src/lib/dashboard-url-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import { DASHBOARD_PORT } from "./core/ports";
import { buildSshForwardHintLines } from "./onboard/ssh-forward-hint";
import type { SandboxEntry } from "./state/registry";

type DashboardAuth = "url_token" | "session" | "none";
Expand All @@ -29,6 +30,8 @@ export interface DashboardUrlCommandDeps {
log?: (message: string) => void;
/** Optional stderr sink -- defaults to console.error. */
error?: (message: string) => void;
/** Environment used to detect an SSH session for the port-forward hint. */
env?: NodeJS.ProcessEnv;
}

export interface DashboardUrlCommandOptions {
Expand Down Expand Up @@ -131,6 +134,13 @@ export function runDashboardUrlCommand(
const log = deps.log ?? ((m: string) => console.log(m));
const error = deps.error ?? ((m: string) => console.error(m));

const printSshForwardHint = (port: number, accessUrl: string | null): void => {
const hint = buildSshForwardHintLines({ port, accessUrl, env: deps.env });
if (!hint) return;
log("");
for (const line of hint) log(line);
};

let sandbox: Pick<SandboxEntry, "agent" | "dashboardPort"> | null = null;
if (deps.getSandbox) {
try {
Expand Down Expand Up @@ -168,6 +178,7 @@ export function runDashboardUrlCommand(
}
log(" Dashboard URL:");
log(` ${url}`);
printSshForwardHint(port, accessUrl);
return;
}

Expand Down Expand Up @@ -195,5 +206,6 @@ export function runDashboardUrlCommand(

log(" Dashboard URL:");
log(` ${url}`);
printSshForwardHint(port, accessUrl);
error(SECURITY_WARNING);
}
14 changes: 14 additions & 0 deletions src/lib/onboard/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ensureMessagingHostForwardForSandbox,
resolveMessagingHostForwardForSandbox,
} from "./messaging-host-forward";
import { buildSshForwardHintLines } from "./ssh-forward-hint";

const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
export const CONTROL_UI_PORT = DASHBOARD_PORT;
Expand All @@ -58,6 +59,8 @@ export interface OnboardDashboardDeps {
isWsl(): boolean;
redact(value: unknown): string;
sleep(seconds: number): void;
/** Environment used to detect an SSH session for the port-forward hint. */
env?: NodeJS.ProcessEnv;
// Sandbox-registry lookup used by `ensureDashboardForward` for the
// cross-gateway dashboard port view. Tests inject a stub so the allocator
// never reads the runner's real `~/.nemoclaw/sandboxes.json`; production
Expand Down Expand Up @@ -514,6 +517,17 @@ export function createOnboardDashboardHelpers(deps: OnboardDashboardDeps): Onboa
console.log(` ${deps.cliName()} ${sandboxName} connect`);
console.log(" then run: openclaw tui");
}
const sshForwardHint = buildSshForwardHintLines({
port: chain.port,
accessUrl: chain.accessUrl,
env: deps.env,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (sshForwardHint) {
console.log("");
for (const line of sshForwardHint) {
console.log(line);
}
}
console.log("");
console.log(" Manage later");
console.log("");
Expand Down
127 changes: 127 additions & 0 deletions src/lib/onboard/ssh-forward-hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import { buildSshForwardHintLines, isSshSession } from "./ssh-forward-hint";

describe("ssh-forward-hint", () => {
describe("isSshSession", () => {
it("detects SSH_CONNECTION, SSH_CLIENT, and SSH_TTY", () => {
expect(isSshSession({ SSH_CONNECTION: "10.0.0.1 5000 10.0.0.2 22" })).toBe(true);
expect(isSshSession({ SSH_CLIENT: "10.0.0.1 5000 22" })).toBe(true);
expect(isSshSession({ SSH_TTY: "/dev/pts/0" })).toBe(true);
});

it("returns false outside an SSH session", () => {
expect(isSshSession({})).toBe(false);
});
});

describe("buildSshForwardHintLines", () => {
it("builds a copy-pastable ssh -L example with the real user and a <host> placeholder (#5925)", () => {
const lines = buildSshForwardHintLines({
port: 18790,
accessUrl: "http://127.0.0.1:18790",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
});

expect(lines).toEqual([
" Remote access (SSH session detected):",
" On your workstation, run:",
" ssh -L 18790:127.0.0.1:18790 spark@<host>",
" Then open the dashboard URL above in your local browser.",
]);
});

it("never leaks the SSH_CONNECTION socket IP across an alias, NAT, or ProxyJump (#5925)", () => {
// An `~/.ssh/config` alias or ProxyJump makes the SSH_CONNECTION server-IP
// (field 3) unrelated to what the operator typed, so it must never appear.
for (const sshConnection of [
"10.0.0.9 51000 10.6.76.40 22", // NAT'd / aliased direct host
"203.0.113.8 40222 10.10.0.5 2222", // private bastion-side (ProxyJump) target on a custom port
]) {
const lines = buildSshForwardHintLines({
port: 18790,
accessUrl: "http://127.0.0.1:18790",
env: { SSH_CONNECTION: sshConnection, USER: "spark" },
});

expect(lines?.[2]).toBe(" ssh -L 18790:127.0.0.1:18790 spark@<host>");
// No socket IP and no inferred -p port from the (untrusted) SSH_CONNECTION.
expect(lines?.join("\n")).not.toContain("10.6.76.40");
expect(lines?.join("\n")).not.toContain("10.10.0.5");
expect(lines?.join("\n")).not.toContain("-p ");
}
});

it("renders an explicitly supplied destination verbatim (#5925)", () => {
const lines = buildSshForwardHintLines({
port: 18790,
destination: "spark-host",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
});

expect(lines?.[2]).toBe(" ssh -L 18790:127.0.0.1:18790 spark@spark-host");
});

it("falls back to the <host> placeholder for an unsafe explicit destination", () => {
const lines = buildSshForwardHintLines({
port: 18790,
destination: "evil; rm -rf /",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
});

expect(lines?.[2]).toBe(" ssh -L 18790:127.0.0.1:18790 spark@<host>");
});

it("falls back to placeholders when user is unavailable", () => {
const lines = buildSshForwardHintLines({
port: 18790,
accessUrl: "http://127.0.0.1:18790",
env: { SSH_TTY: "/dev/pts/0" },
});

expect(lines?.[2]).toBe(" ssh -L 18790:127.0.0.1:18790 <user>@<host>");
});

it("rejects unsafe usernames in favor of the placeholder", () => {
const lines = buildSshForwardHintLines({
port: 18790,
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "evil; rm -rf" },
});

expect(lines?.[2]).toBe(" ssh -L 18790:127.0.0.1:18790 <user>@<host>");
});

it("respects a custom indent and open hint", () => {
const lines = buildSshForwardHintLines({
port: 18790,
indent: "",
openHint: "Then open: http://127.0.0.1:18790/",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
});

expect(lines).toEqual([
"Remote access (SSH session detected):",
" On your workstation, run:",
" ssh -L 18790:127.0.0.1:18790 spark@<host>",
" Then open: http://127.0.0.1:18790/",
]);
});

it("returns null outside an SSH session", () => {
expect(buildSshForwardHintLines({ port: 18790, env: {} })).toBeNull();
});

it("returns null when the dashboard already binds a routable address", () => {
expect(
buildSshForwardHintLines({
port: 18790,
accessUrl: "http://172.22.1.1:18790",
env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" },
}),
).toBeNull();
});
});
});
Loading
Loading