diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index f4d83aee124..43ba914bdd3 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1612,7 +1612,8 @@ $$nemoclaw my-assistant dashboard-url $$nemoclaw my-assistant dashboard-url --quiet ``` -The default output includes a label and a warning. +The default output includes the terminal connection command and management commands shown after onboarding. +It also includes a label and a warning. Pass `--quiet` or `-q` to print only the URL to stdout so scripts can capture it: ```bash @@ -1630,6 +1631,8 @@ This warning applies when the command prints an OpenClaw tokenized URL. Print the browser dashboard URL for a running Hermes sandbox. Hermes manages dashboard sessions itself, so this command prints a plain URL without an OpenClaw `#token=` fragment. +The default output also includes terminal connection and management commands. +Pass `--quiet` or `-q` to print only the URL to stdout. The built-in dashboard is forwarded on port `18789` by default. ```bash diff --git a/src/commands/sandbox/dashboard-url.test.ts b/src/commands/sandbox/dashboard-url.test.ts new file mode 100644 index 00000000000..ba8cb9bf17b --- /dev/null +++ b/src/commands/sandbox/dashboard-url.test.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Config as OclifConfig } from "@oclif/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import DashboardUrlCliCommand, { + resetDashboardUrlRuntimeBridgeFactoryForTest, + setDashboardUrlRuntimeBridgeFactoryForTest, +} from "./dashboard-url"; + +describe("dashboard-url CLI output", () => { + beforeEach(() => { + vi.restoreAllMocks(); + setDashboardUrlRuntimeBridgeFactoryForTest(() => ({ + fetchGatewayAuthTokenFromSandbox: () => "secret-token", + getSandbox: () => ({ agent: "openclaw", dashboardPort: 18789 }), + getAccessUrl: () => "http://127.0.0.1:18789", + })); + }); + + afterEach(() => { + resetDashboardUrlRuntimeBridgeFactoryForTest(); + vi.restoreAllMocks(); + }); + + it("prints the authenticated URL with connection and management guidance (#7473)", async () => { + const output: string[] = []; + const errors: string[] = []; + vi.spyOn(console, "log").mockImplementation((message: string) => output.push(message)); + vi.spyOn(console, "error").mockImplementation((message: string) => errors.push(message)); + + const previousExitCode = process.exitCode; + try { + await DashboardUrlCliCommand.run(["alpha"], process.cwd()); + + expect(output).toContain(" http://127.0.0.1:18789/#token=secret-token"); + expect(output).toContain(" nemoclaw alpha connect"); + expect(output).toContain(" Manage later"); + expect(output).toContain(" Status: nemoclaw alpha status"); + expect(output).toContain(" Logs: nemoclaw alpha logs --follow"); + expect(errors.join("\n")).toContain("Treat this URL like a password"); + } finally { + process.exitCode = previousExitCode; + } + }); + + it("uses the invoked CLI binary in every follow-up command (#7473)", async () => { + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((message: string) => output.push(message)); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const baseConfig = await OclifConfig.load(process.cwd()); + const config = await OclifConfig.load({ + root: process.cwd(), + pjson: { + ...baseConfig.pjson, + oclif: { ...baseConfig.pjson.oclif, bin: "nemohermes" }, + }, + }); + + const previousExitCode = process.exitCode; + try { + await DashboardUrlCliCommand.run(["alpha"], config); + + expect(output).toContain(" nemohermes alpha connect"); + expect(output).toContain(" Status: nemohermes alpha status"); + expect(output).toContain(" Logs: nemohermes alpha logs --follow"); + expect(output).toContain( + " Model: nemohermes inference set --model --provider --sandbox alpha", + ); + expect(output).toContain(" Policies: nemohermes alpha policy add"); + expect(output).toContain( + " Credentials: nemohermes credentials reset && nemohermes onboard", + ); + } finally { + process.exitCode = previousExitCode; + } + }); +}); diff --git a/src/commands/sandbox/dashboard-url.ts b/src/commands/sandbox/dashboard-url.ts index 9e03ec61564..4b60089657c 100644 --- a/src/commands/sandbox/dashboard-url.ts +++ b/src/commands/sandbox/dashboard-url.ts @@ -13,7 +13,7 @@ type DashboardUrlRuntimeBridge = { getAccessUrl?: (port: number) => string | null; }; -let runtimeBridgeFactory = (): DashboardUrlRuntimeBridge => { +const defaultDashboardUrlRuntimeBridgeFactory = (): DashboardUrlRuntimeBridge => { const onboard = require("../../lib/onboard") as Pick< DashboardUrlRuntimeBridge, "fetchGatewayAuthTokenFromSandbox" @@ -43,12 +43,18 @@ let runtimeBridgeFactory = (): DashboardUrlRuntimeBridge => { }; }; +let runtimeBridgeFactory = defaultDashboardUrlRuntimeBridgeFactory; + export function setDashboardUrlRuntimeBridgeFactoryForTest( factory: () => DashboardUrlRuntimeBridge, ): void { runtimeBridgeFactory = factory; } +export function resetDashboardUrlRuntimeBridgeFactoryForTest(): void { + runtimeBridgeFactory = defaultDashboardUrlRuntimeBridgeFactory; +} + function getRuntimeBridge(): DashboardUrlRuntimeBridge { return runtimeBridgeFactory(); } @@ -57,7 +63,8 @@ export default class DashboardUrlCliCommand extends NemoClawCommand { static id = "sandbox:dashboard-url"; static strict = true; static summary = "Print the dashboard URL"; - static description = "Print the browser-facing dashboard URL for a running sandbox."; + static description = + "Print the browser-facing dashboard URL and connection guidance for a running sandbox."; static usage = [" [--quiet|-q]"]; static examples = [ "<%= config.bin %> sandbox dashboard-url alpha", @@ -93,6 +100,7 @@ export default class DashboardUrlCliCommand extends NemoClawCommand { fetchToken: runtime.fetchGatewayAuthTokenFromSandbox, getSandbox: runtime.getSandbox, getAccessUrl: runtime.getAccessUrl, + cliName: this.config.bin, }, ); this.setExitCode(0); diff --git a/src/lib/dashboard-url-command.test.ts b/src/lib/dashboard-url-command.test.ts index 0f1d818afb4..6911da5debf 100644 --- a/src/lib/dashboard-url-command.test.ts +++ b/src/lib/dashboard-url-command.test.ts @@ -68,8 +68,9 @@ describe("dashboard-url command helpers", () => { expect(sinks.out).toEqual(["http://172.22.1.1:19000/#token=secret-token"]); }); - it("prints a human label and warning outside quiet mode", () => { + it("prints the dashboard URL, connection guidance, and warning outside quiet mode (#7473)", () => { const sinks = makeSinks(); + const events: string[] = []; runDashboardUrlCommand( "alpha", { quiet: false }, @@ -77,13 +78,39 @@ describe("dashboard-url command helpers", () => { fetchToken: () => "secret-token", getSandbox: () => ({ agent: null, dashboardPort: 18789 }), env: {}, - log: sinks.log, - error: sinks.error, + log: (message) => { + events.push(`stdout:${message}`); + sinks.log(message); + }, + error: (message) => { + events.push(`stderr:${message}`); + sinks.error(message); + }, }, ); - expect(sinks.out).toEqual([" Dashboard URL:", " http://127.0.0.1:18789/#token=secret-token"]); + expect(sinks.out).toEqual([ + " Dashboard URL:", + " http://127.0.0.1:18789/#token=secret-token", + "", + " Terminal:", + " nemoclaw alpha connect", + " then run: openclaw tui", + "", + " Manage later", + "", + " Status: nemoclaw alpha status", + " Logs: nemoclaw alpha logs --follow", + " Model: nemoclaw inference set --model --provider --sandbox alpha", + " Policies: nemoclaw alpha policy add", + " Credentials: nemoclaw credentials reset && nemoclaw onboard", + ]); expect(sinks.err.join("\n")).toContain("Treat this URL like a password"); + expect(events.slice(0, 3)).toEqual([ + "stdout: Dashboard URL:", + "stdout: http://127.0.0.1:18789/#token=secret-token", + "stderr:Treat this URL like a password -- do not log, share, or commit it.", + ]); }); it("appends an SSH port-forward hint when run over SSH (#5925)", () => { @@ -115,6 +142,7 @@ describe("dashboard-url command helpers", () => { fetchToken, getSandbox: () => ({ agent: "hermes", dashboardPort: 18790 }), getAgentDashboardAuth: () => "session", + cliName: "nemohermes", env: { SSH_CONNECTION: "10.0.0.9 51000 10.6.76.40 22", USER: "spark" }, log: sinks.log, error: sinks.error, @@ -126,6 +154,10 @@ describe("dashboard-url command helpers", () => { 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@"); + expect(sinks.out).toContain(" nemohermes hermes connect"); + expect(sinks.out).toContain(" Manage later"); + expect(sinks.out.join("\n")).not.toContain("openclaw tui"); + expect(sinks.out.join("\n")).not.toContain("#token="); }); it("omits the SSH port-forward hint outside an SSH session", () => { diff --git a/src/lib/dashboard-url-command.ts b/src/lib/dashboard-url-command.ts index fe69af22425..c9c296b08d5 100644 --- a/src/lib/dashboard-url-command.ts +++ b/src/lib/dashboard-url-command.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 /** - * `nemoclaw dashboard-url` -- print the browser-facing dashboard URL. - * OpenClaw sandboxes still receive an authenticated token fragment, while - * session-auth agent dashboards can return the plain URL. + * `nemoclaw dashboard-url` prints the browser-facing URL and connection + * guidance. Quiet mode prints only the URL. OpenClaw URLs include an + * authenticated token fragment, while session-auth dashboards return a plain URL. */ import { DASHBOARD_PORT } from "./core/ports"; @@ -30,6 +30,8 @@ export interface DashboardUrlCommandDeps { log?: (message: string) => void; /** Optional stderr sink -- defaults to console.error. */ error?: (message: string) => void; + /** CLI binary name used in follow-up commands. */ + cliName?: string; /** Environment used to detect an SSH session for the port-forward hint. */ env?: NodeJS.ProcessEnv; } @@ -65,6 +67,30 @@ function resolveDashboardPort(sandbox: Pick | nul : DASHBOARD_PORT; } +function printSandboxGuidance( + sandboxName: string, + agentName: string | null, + cliName: string, + log: (message: string) => void, +): void { + log(""); + log(" Terminal:"); + log(` ${cliName} ${sandboxName} connect`); + if (!agentName || agentName === "openclaw") { + log(" then run: openclaw tui"); + } + log(""); + log(" Manage later"); + log(""); + log(` Status: ${cliName} ${sandboxName} status`); + log(` Logs: ${cliName} ${sandboxName} logs --follow`); + log( + ` Model: ${cliName} inference set --model --provider --sandbox ${sandboxName}`, + ); + log(` Policies: ${cliName} ${sandboxName} policy add`); + log(` Credentials: ${cliName} credentials reset && ${cliName} onboard`); +} + export function buildDashboardUrl( token: string, port = DASHBOARD_PORT, @@ -179,6 +205,7 @@ export function runDashboardUrlCommand( log(" Dashboard URL:"); log(` ${url}`); printSshForwardHint(port, accessUrl); + printSandboxGuidance(sandboxName, agent, deps.cliName ?? "nemoclaw", log); return; } @@ -206,6 +233,7 @@ export function runDashboardUrlCommand( log(" Dashboard URL:"); log(` ${url}`); - printSshForwardHint(port, accessUrl); error(SECURITY_WARNING); + printSshForwardHint(port, accessUrl); + printSandboxGuidance(sandboxName, agent, deps.cliName ?? "nemoclaw", log); }