diff --git a/docs/deployment/deploy-to-remote-gpu.mdx b/docs/deployment/deploy-to-remote-gpu.mdx index 7324132912b..e436fc404ec 100644 --- a/docs/deployment/deploy-to-remote-gpu.mdx +++ b/docs/deployment/deploy-to-remote-gpu.mdx @@ -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 @ +``` + +When you run `nemoclaw` over SSH, the install summary and `nemoclaw dashboard-url` print this command for you, filled in with your remote username and the forwarded dashboard port. +The host stays a `` 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. On Brev, set `CHAT_UI_URL` in the launchable environment configuration so the installer can read it when it builds the sandbox image. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 40327dc4c13..abd5341fb6d 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -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 @ + 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. diff --git a/src/lib/dashboard-url-command.test.ts b/src/lib/dashboard-url-command.test.ts index 3b4f01b7178..114c3008e31 100644 --- a/src/lib/dashboard-url-command.test.ts +++ b/src/lib/dashboard-url-command.test.ts @@ -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@"); + }); + + 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@"); + }); + + 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"); diff --git a/src/lib/dashboard-url-command.ts b/src/lib/dashboard-url-command.ts index 3a782fb8955..fe69af22425 100644 --- a/src/lib/dashboard-url-command.ts +++ b/src/lib/dashboard-url-command.ts @@ -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"; @@ -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 { @@ -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 | null = null; if (deps.getSandbox) { try { @@ -168,6 +178,7 @@ export function runDashboardUrlCommand( } log(" Dashboard URL:"); log(` ${url}`); + printSshForwardHint(port, accessUrl); return; } @@ -195,5 +206,6 @@ export function runDashboardUrlCommand( log(" Dashboard URL:"); log(` ${url}`); + printSshForwardHint(port, accessUrl); error(SECURITY_WARNING); } diff --git a/src/lib/onboard/dashboard.ts b/src/lib/onboard/dashboard.ts index 73f85a2ff27..c2fc5ec4950 100644 --- a/src/lib/onboard/dashboard.ts +++ b/src/lib/onboard/dashboard.ts @@ -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; @@ -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 @@ -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, + }); + if (sshForwardHint) { + console.log(""); + for (const line of sshForwardHint) { + console.log(line); + } + } console.log(""); console.log(" Manage later"); console.log(""); diff --git a/src/lib/onboard/ssh-forward-hint.test.ts b/src/lib/onboard/ssh-forward-hint.test.ts new file mode 100644 index 00000000000..7096e88805e --- /dev/null +++ b/src/lib/onboard/ssh-forward-hint.test.ts @@ -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 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@", + " 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@"); + // 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 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@"); + }); + + 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 @"); + }); + + 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 @"); + }); + + 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@", + " 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(); + }); + }); +}); diff --git a/src/lib/onboard/ssh-forward-hint.ts b/src/lib/onboard/ssh-forward-hint.ts new file mode 100644 index 00000000000..a580b95cc9c --- /dev/null +++ b/src/lib/onboard/ssh-forward-hint.ts @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * SSH port-forward guidance for remote-deployed hosts (#5925). + * + * When the CLI is run inside an SSH session and the dashboard is bound to + * loopback (the default), the printed `http://127.0.0.1:/` URL is not + * reachable from the operator's workstation without a port forward. These pure + * helpers detect the SSH session and build a copy-pastable + * `ssh -L :127.0.0.1: @` example so the post-onboard + * block and `dashboard-url` output can show it. No I/O — env is passed in so + * callers/tests stay deterministic. + */ + +import { isLoopbackHostname } from "../core/url-utils"; + +const HOST_PLACEHOLDER = ""; +const USER_PLACEHOLDER = ""; + +/** Detect whether the current process is running inside an SSH session. */ +export function isSshSession(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY); +} + +/** + * Only surface usernames that are safe to show verbatim inside the example + * command. Anything outside the conservative POSIX set falls back to the + * `` placeholder rather than rendering an odd or misleading command. + */ +function safeUser(value: string | undefined): string | null { + if (!value) return null; + return /^[A-Za-z0-9._-]+$/.test(value) ? value : null; +} + +/** + * Only surface a destination that a caller supplied explicitly and that is safe + * to show verbatim (hostname, IP, or SSH config alias characters). We never + * infer the destination from `SSH_CONNECTION`: its server-IP field is the remote + * socket address, which loses the original host token, `-p` port, `ProxyJump`, + * and any `~/.ssh/config` alias, so it is not the address the operator typed and + * is not reliably reachable from their workstation. Unknown/unsafe destinations + * fall back to the `` placeholder. + */ +function safeDestination(value: string | undefined): string | null { + if (!value) return null; + return /^[A-Za-z0-9._-]+$/.test(value) ? value : null; +} + +/** True when the access URL still points at loopback (forward required). */ +function accessUrlNeedsForward(accessUrl: string | null | undefined): boolean { + const raw = String(accessUrl || "").trim(); + if (!raw) return true; + try { + const url = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); + return isLoopbackHostname(url.hostname); + } catch { + return true; + } +} + +export interface SshForwardHintOptions { + /** Dashboard port that must be forwarded. */ + port: number; + /** + * Resolved access URL. When it already points at a routable address (WSL + * fallback, `NEMOCLAW_DASHBOARD_BIND=0.0.0.0`, etc.) the forward is + * unnecessary and no hint is produced. + */ + accessUrl?: string | null; + /** Indent applied to every line. Defaults to two spaces. */ + indent?: string; + /** Trailing guidance line; defaults to a generic "open the URL above" hint. */ + openHint?: string; + /** + * Explicit SSH destination the operator used (host, IP, or `~/.ssh/config` + * alias). Rendered verbatim when safe; otherwise the example keeps the + * `` placeholder. Not inferred from `SSH_CONNECTION` -- see + * {@link safeDestination}. + */ + destination?: string; + env?: NodeJS.ProcessEnv; +} + +/** + * Build the copy-pastable SSH port-forward guidance block, or null when it + * does not apply (not an SSH session, or the dashboard is already reachable + * without a forward). + */ +export function buildSshForwardHintLines(options: SshForwardHintOptions): string[] | null { + const env = options.env ?? process.env; + if (!isSshSession(env)) return null; + if (!accessUrlNeedsForward(options.accessUrl)) return null; + + const indent = options.indent ?? " "; + // The host is a placeholder unless the caller supplies the original + // destination: `SSH_CONNECTION` cannot recover an alias, NAT'd hostname, or + // `ProxyJump` target, so its socket IP is not reliably copy-pastable. The + // username, by contrast, is the effective remote login user and stays correct. + const host = safeDestination(options.destination) ?? HOST_PLACEHOLDER; + const user = safeUser(env.USER ?? env.LOGNAME) ?? USER_PLACEHOLDER; + const port = options.port; + const openHint = options.openHint ?? "Then open the dashboard URL above in your local browser."; + + return [ + `${indent}Remote access (SSH session detected):`, + `${indent} On your workstation, run:`, + `${indent} ssh -L ${port}:127.0.0.1:${port} ${user}@${host}`, + `${indent} ${openHint}`, + ]; +}