diff --git a/.agents/skills/nemoclaw-user-get-started/SKILL.md b/.agents/skills/nemoclaw-user-get-started/SKILL.md index 28bccd1ad2..53fde6e6fb 100644 --- a/.agents/skills/nemoclaw-user-get-started/SKILL.md +++ b/.agents/skills/nemoclaw-user-get-started/SKILL.md @@ -280,17 +280,31 @@ The example below shows the result if you picked an OpenAI-compatible endpoint d ```text ────────────────────────────────────────────────── -Sandbox my-gpt-claw (Landlock + seccomp + netns) -Model openai/openai/gpt-5.5 (Other OpenAI-compatible endpoint) -────────────────────────────────────────────────── -Run: nemoclaw my-gpt-claw connect -Status: nemoclaw my-gpt-claw status -Logs: nemoclaw my-gpt-claw logs --follow -────────────────────────────────────────────────── +NemoClaw is ready + +Sandbox: my-gpt-claw +Model: openai/openai/gpt-5.5 (Other OpenAI-compatible endpoint) + +Start chatting -To change settings later: - Model: nemoclaw inference get - nemoclaw inference set --model --provider --sandbox my-gpt-claw + Browser: + http://127.0.0.1:18789/ + + Terminal: + nemoclaw my-gpt-claw connect + then run: openclaw tui + +Authenticated dashboard URL, if needed: + nemoclaw my-gpt-claw dashboard-url --quiet + +Manage later + + Status: nemoclaw my-gpt-claw status + Logs: nemoclaw my-gpt-claw logs --follow + Model: nemoclaw inference set --model --provider --sandbox my-gpt-claw + Policies: nemoclaw my-gpt-claw policy-add + Credentials: nemoclaw credentials reset && nemoclaw onboard +────────────────────────────────────────────────── [INFO] === Installation complete === ``` @@ -307,21 +321,16 @@ The onboard wizard starts a background port forward to the sandbox dashboard, th The default host port is `18789`. If that port is already taken, NemoClaw uses the next free dashboard port, such as `18790`, and prints that port in the final URL. If the chosen port becomes occupied after the sandbox build starts, onboarding rolls back the newly-created sandbox and asks you to retry instead of printing an unreachable dashboard URL. -The gateway token is redacted from displayed output; retrieve it explicitly when the browser asks for authentication. +The install transcript does not print the gateway token. +If the browser requires authentication, use the `dashboard-url --quiet` command to print a complete URL explicitly. ```text -────────────────────────────────────────────────── -OpenClaw UI (auth token redacted from displayed URLs) -Port 18790 must be forwarded before opening these URLs. -Dashboard: http://127.0.0.1:18790/ -Token: nemoclaw my-gpt-claw gateway-token --quiet - append #token= locally if the browser asks for auth. -────────────────────────────────────────────────── +nemoclaw my-gpt-claw dashboard-url --quiet ``` Open the dashboard URL in your browser. -If the browser asks for authentication, run the printed `gateway-token --quiet` command and append `#token=` locally. -Treat the token like a password. +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. ### Chat with the Agent from the Terminal @@ -329,12 +338,8 @@ Connect to the sandbox and use the OpenClaw CLI. ```bash nemoclaw my-assistant connect -``` - -In the sandbox shell, send a single message and print the response. - -```bash -openclaw agent --agent main --local -m "hello" --session-id test +# inside the sandbox: +openclaw tui ``` ## References diff --git a/.agents/skills/nemoclaw-user-get-started/references/quickstart-hermes.md b/.agents/skills/nemoclaw-user-get-started/references/quickstart-hermes.md index 5af7cf0671..b658f3679e 100644 --- a/.agents/skills/nemoclaw-user-get-started/references/quickstart-hermes.md +++ b/.agents/skills/nemoclaw-user-get-started/references/quickstart-hermes.md @@ -79,16 +79,27 @@ Hermes exposes an OpenAI-compatible API on port `8642`, not a browser dashboard. ```text ────────────────────────────────────────────────── -Sandbox my-hermes (Landlock + seccomp + netns) -Model nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) -────────────────────────────────────────────────── -Run: nemohermes my-hermes connect -Status: nemohermes my-hermes status -Logs: nemohermes my-hermes logs --follow +NemoHermes is ready + +Sandbox: my-hermes +Model: nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) + +Access + + Hermes Agent OpenAI-compatible API + Port 8642 must be forwarded before connecting. + http://127.0.0.1:8642/v1 + +Terminal: + nemohermes my-hermes connect + +Manage later -Hermes Agent OpenAI-compatible API -Port 8642 must be forwarded before connecting. -http://127.0.0.1:8642/v1 + Status: nemohermes my-hermes status + Logs: nemohermes my-hermes logs --follow + Model: nemohermes inference set --model --provider --sandbox my-hermes + Policies: nemohermes my-hermes policy-add + Credentials: nemohermes credentials reset && nemohermes onboard ────────────────────────────────────────────────── ``` diff --git a/.agents/skills/nemoclaw-user-reference/SKILL.md b/.agents/skills/nemoclaw-user-reference/SKILL.md index 83fe6117a0..4a6d03e571 100644 --- a/.agents/skills/nemoclaw-user-reference/SKILL.md +++ b/.agents/skills/nemoclaw-user-reference/SKILL.md @@ -6,7 +6,7 @@ description: "Describes the NemoClaw plugin and blueprint architecture and how t -# Architecture +# Architecture Details ## References diff --git a/.agents/skills/nemoclaw-user-reference/references/architecture.md b/.agents/skills/nemoclaw-user-reference/references/architecture.md index d0464cf652..a9af81278f 100644 --- a/.agents/skills/nemoclaw-user-reference/references/architecture.md +++ b/.agents/skills/nemoclaw-user-reference/references/architecture.md @@ -1,6 +1,6 @@ -# Architecture +# Architecture Details NemoClaw combines a host CLI, a TypeScript plugin that runs with OpenClaw inside the sandbox, and a versioned YAML blueprint that defines the sandbox image, policies, and inference profiles applied through OpenShell. diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 7be3cff477..961b7953f9 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -425,10 +425,33 @@ If one log source is unavailable, NemoClaw prints a warning and keeps reading th $ nemoclaw my-assistant logs [--follow] [--tail |-n ] [--since ] ``` +### `nemoclaw dashboard-url` + +Print the authenticated OpenClaw dashboard URL for a running sandbox. +Use this when you are on a remote machine, using an SSH or reverse tunnel, or need a complete URL for a browser session. + +```console +$ nemoclaw my-assistant dashboard-url +$ nemoclaw my-assistant dashboard-url --quiet +``` + +The default output includes a label and a warning. +Pass `--quiet` or `-q` to print only the URL to stdout so scripts can capture it: + +```console +$ URL=$(nemoclaw my-assistant dashboard-url --quiet) +``` + +**Warning:** + +Treat the authenticated dashboard URL like a password. +Do not log it, share it, or commit it to version control. + ### `nemoclaw gateway-token` Print the OpenClaw gateway auth token for a running sandbox to stdout. -The token is required by `openclaw tui` and the OpenClaw dashboard URL, but onboarding only prints it once. +The token is required by `openclaw tui` and the OpenClaw dashboard URL. +Use `dashboard-url` for browser access; use `gateway-token` only when automation needs the raw token. Pipe it into automation or capture it into an environment variable: ```console diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index adf9284043..1a7d976479 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -88,16 +88,27 @@ Hermes exposes an OpenAI-compatible API on port `8642`, not a browser dashboard. ```text ────────────────────────────────────────────────── -Sandbox my-hermes (Landlock + seccomp + netns) -Model nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) -────────────────────────────────────────────────── -Run: nemohermes my-hermes connect -Status: nemohermes my-hermes status -Logs: nemohermes my-hermes logs --follow +NemoHermes is ready + +Sandbox: my-hermes +Model: nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) + +Access + + Hermes Agent OpenAI-compatible API + Port 8642 must be forwarded before connecting. + http://127.0.0.1:8642/v1 + +Terminal: + nemohermes my-hermes connect + +Manage later -Hermes Agent OpenAI-compatible API -Port 8642 must be forwarded before connecting. -http://127.0.0.1:8642/v1 + Status: nemohermes my-hermes status + Logs: nemohermes my-hermes logs --follow + Model: nemohermes inference set --model --provider --sandbox my-hermes + Policies: nemohermes my-hermes policy-add + Credentials: nemohermes credentials reset && nemohermes onboard ────────────────────────────────────────────────── ``` diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 3d7171ff03..c837b5d6be 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -294,17 +294,31 @@ The example below shows the result if you picked an OpenAI-compatible endpoint d ```text ────────────────────────────────────────────────── -Sandbox my-gpt-claw (Landlock + seccomp + netns) -Model openai/openai/gpt-5.5 (Other OpenAI-compatible endpoint) -────────────────────────────────────────────────── -Run: nemoclaw my-gpt-claw connect -Status: nemoclaw my-gpt-claw status -Logs: nemoclaw my-gpt-claw logs --follow -────────────────────────────────────────────────── +NemoClaw is ready + +Sandbox: my-gpt-claw +Model: openai/openai/gpt-5.5 (Other OpenAI-compatible endpoint) + +Start chatting -To change settings later: - Model: nemoclaw inference get - nemoclaw inference set --model --provider --sandbox my-gpt-claw + Browser: + http://127.0.0.1:18789/ + + Terminal: + nemoclaw my-gpt-claw connect + then run: openclaw tui + +Authenticated dashboard URL, if needed: + nemoclaw my-gpt-claw dashboard-url --quiet + +Manage later + + Status: nemoclaw my-gpt-claw status + Logs: nemoclaw my-gpt-claw logs --follow + Model: nemoclaw inference set --model --provider --sandbox my-gpt-claw + Policies: nemoclaw my-gpt-claw policy-add + Credentials: nemoclaw credentials reset && nemoclaw onboard +────────────────────────────────────────────────── [INFO] === Installation complete === ``` @@ -321,21 +335,16 @@ The onboard wizard starts a background port forward to the sandbox dashboard, th The default host port is `18789`. If that port is already taken, NemoClaw uses the next free dashboard port, such as `18790`, and prints that port in the final URL. If the chosen port becomes occupied after the sandbox build starts, onboarding rolls back the newly-created sandbox and asks you to retry instead of printing an unreachable dashboard URL. -The gateway token is redacted from displayed output; retrieve it explicitly when the browser asks for authentication. +The install transcript does not print the gateway token. +If the browser requires authentication, use the `dashboard-url --quiet` command to print a complete URL explicitly. ```text -────────────────────────────────────────────────── -OpenClaw UI (auth token redacted from displayed URLs) -Port 18790 must be forwarded before opening these URLs. -Dashboard: http://127.0.0.1:18790/ -Token: nemoclaw my-gpt-claw gateway-token --quiet - append #token= locally if the browser asks for auth. -────────────────────────────────────────────────── +nemoclaw my-gpt-claw dashboard-url --quiet ``` Open the dashboard URL in your browser. -If the browser asks for authentication, run the printed `gateway-token --quiet` command and append `#token=` locally. -Treat the token like a password. +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. ### Chat with the Agent from the Terminal @@ -343,12 +352,8 @@ Connect to the sandbox and use the OpenClaw CLI. ```bash nemoclaw my-assistant connect -``` - -In the sandbox shell, send a single message and print the response. - -```bash -openclaw agent --agent main --local -m "hello" --session-id test +# inside the sandbox: +openclaw tui ``` ## Next Steps diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8c1028330c..373b831e8d 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -432,10 +432,33 @@ If one log source is unavailable, NemoClaw prints a warning and keeps reading th $ nemoclaw my-assistant logs [--follow] [--tail |-n ] [--since ] ``` +### `nemoclaw dashboard-url` + +Print the authenticated OpenClaw dashboard URL for a running sandbox. +Use this when you are on a remote machine, using an SSH or reverse tunnel, or need a complete URL for a browser session. + +```console +$ nemoclaw my-assistant dashboard-url +$ nemoclaw my-assistant dashboard-url --quiet +``` + +The default output includes a label and a warning. +Pass `--quiet` or `-q` to print only the URL to stdout so scripts can capture it: + +```console +$ URL=$(nemoclaw my-assistant dashboard-url --quiet) +``` + + +Treat the authenticated dashboard URL like a password. +Do not log it, share it, or commit it to version control. + + ### `nemoclaw gateway-token` Print the OpenClaw gateway auth token for a running sandbox to stdout. -The token is required by `openclaw tui` and the OpenClaw dashboard URL, but onboarding only prints it once. +The token is required by `openclaw tui` and the OpenClaw dashboard URL. +Use `dashboard-url` for browser access; use `gateway-token` only when automation needs the raw token. Pipe it into automation or capture it into an environment variable: ```console diff --git a/scripts/install.sh b/scripts/install.sh index 9189967c35..deca731ef7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -466,41 +466,22 @@ print_done() { printf " ${C_GREEN}${C_BOLD}%s${C_RESET} ${C_DIM}(%ss)${C_RESET}\n" "$_CLI_DISPLAY" "$elapsed" printf "\n" if [[ "$ONBOARD_RAN" == true ]]; then - local sandbox_name agent_name - sandbox_name="$(resolve_default_sandbox_name)" + local agent_name agent_name="$(resolve_onboarded_agent)" if [[ "$_needs_cli_refresh" == true ]]; then printf " ${C_YELLOW}%s installed, but this shell needs PATH refresh before '%s' will run.${C_RESET}\n" "$_CLI_DISPLAY" "$_CLI_BIN" printf " ${C_DIM}Onboarding completed; refresh PATH before using the CLI from this terminal.${C_RESET}\n" + printf "\n" + printf " ${C_GREEN}For this terminal:${C_RESET}\n" + print_cli_path_refresh_actions else if [[ "$agent_name" == "openclaw" || -z "$agent_name" ]]; then printf " ${C_GREEN}Your OpenClaw Sandbox is live.${C_RESET}\n" else printf " ${C_GREEN}Your %s Sandbox is live.${C_RESET}\n" "$(agent_display_name "$agent_name")" fi - printf " ${C_DIM}Sandbox in, break things, and tell us what you find.${C_RESET}\n" - fi - printf "\n" - printf " ${C_GREEN}Next:${C_RESET}\n" - if [[ "$_needs_cli_refresh" == true ]]; then - print_cli_path_refresh_actions - else - printf " %s$%s source %s\n" "$C_GREEN" "$C_RESET" "$(detect_shell_profile)" + printf " ${C_DIM}Use the Start chatting section above for browser and terminal options.${C_RESET}\n" fi - printf " %s$%s %s %s connect\n" "$C_GREEN" "$C_RESET" "$_CLI_BIN" "$sandbox_name" - local agent_cmd - case "$agent_name" in - hermes) - agent_cmd="hermes" - ;; - "" | openclaw) - agent_cmd="openclaw tui" - ;; - *) - agent_cmd="$agent_name" - ;; - esac - printf " %ssandbox@%s$%s %s\n" "$C_GREEN" "$sandbox_name" "$C_RESET" "$agent_cmd" elif [[ "$NEMOCLAW_READY_NOW" == true ]]; then if [[ "$_needs_cli_refresh" == true ]]; then printf " ${C_YELLOW}%s CLI is installed, but this shell needs PATH refresh before '%s' will run.${C_RESET}\n" "$_CLI_DISPLAY" "$_CLI_BIN" diff --git a/src/commands/sandbox/dashboard-url.ts b/src/commands/sandbox/dashboard-url.ts new file mode 100644 index 0000000000..eb70d65be6 --- /dev/null +++ b/src/commands/sandbox/dashboard-url.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; +import { quietFlag } from "../../lib/cli/common-flags"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { + DashboardUrlCommandError, + runDashboardUrlCommand, +} from "../../lib/dashboard-url-command"; +import type { SandboxEntry } from "../../lib/state/registry"; + +type DashboardUrlRuntimeBridge = { + fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => string | null; + getSandbox: (sandboxName: string) => Pick | null; + getAccessUrl?: (port: number) => string | null; +}; + +let runtimeBridgeFactory = (): DashboardUrlRuntimeBridge => { + const onboard = require("../../lib/onboard") as Pick< + DashboardUrlRuntimeBridge, + "fetchGatewayAuthTokenFromSandbox" + >; + const registry = require("../../lib/state/registry") as { + getSandbox: (name: string) => SandboxEntry | null; + }; + const dashboardAccess = + require("../../lib/onboard/dashboard-access") as typeof import("../../lib/onboard/dashboard-access"); + const runner = require("../../lib/runner") as Pick; + return { + fetchGatewayAuthTokenFromSandbox: onboard.fetchGatewayAuthTokenFromSandbox, + getSandbox: (sandboxName: string) => { + try { + return registry.getSandbox(sandboxName); + } catch { + return null; + } + }, + getAccessUrl: (port: number) => + dashboardAccess.buildDashboardChain(`http://127.0.0.1:${port}`, { + runCapture: runner.runCapture, + }).accessUrl, + }; +}; + +export function setDashboardUrlRuntimeBridgeFactoryForTest( + factory: () => DashboardUrlRuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): DashboardUrlRuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class DashboardUrlCliCommand extends NemoClawCommand { + static id = "sandbox:dashboard-url"; + static strict = true; + static summary = "Print the authenticated OpenClaw dashboard URL"; + static description = "Print the authenticated OpenClaw dashboard URL for a running sandbox."; + static usage = [" [--quiet|-q]"]; + static examples = [ + "<%= config.bin %> sandbox dashboard-url alpha", + "<%= config.bin %> sandbox dashboard-url alpha --quiet", + ]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + quiet: quietFlag("Print only the URL"), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(DashboardUrlCliCommand); + process.stdout.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + this.setExitCode(0); + return; + } + throw err; + }); + + const runtime = getRuntimeBridge(); + try { + runDashboardUrlCommand( + args.sandboxName, + { quiet: flags.quiet === true }, + { + fetchToken: runtime.fetchGatewayAuthTokenFromSandbox, + getSandbox: runtime.getSandbox, + getAccessUrl: runtime.getAccessUrl, + }, + ); + this.setExitCode(0); + } catch (error) { + if (error instanceof DashboardUrlCommandError) { + this.failWithLines(error.lines, error.exitCode); + return; + } + throw error; + } + } +} diff --git a/src/commands/simple-global-oclif-adapters.test.ts b/src/commands/simple-global-oclif-adapters.test.ts index bd2de0d50b..9f75fb4d89 100644 --- a/src/commands/simple-global-oclif-adapters.test.ts +++ b/src/commands/simple-global-oclif-adapters.test.ts @@ -15,6 +15,17 @@ const mocks = vi.hoisted(() => { this.exitCode = exitCode; } } + class DashboardUrlCommandError extends Error { + lines: readonly string[]; + exitCode: number; + + constructor(lines: string | readonly string[], exitCode = 1) { + const normalized = Array.isArray(lines) ? lines : [lines]; + super(normalized.join("\n")); + this.lines = normalized; + this.exitCode = exitCode; + } + } return { buildVersionedUninstallUrl: vi.fn((version: string) => `https://example.test/${version}/uninstall.sh`), @@ -25,6 +36,7 @@ const mocks = vi.hoisted(() => { resolveOpenshell: vi.fn(() => "/usr/bin/openshell"), runDebugCommandWithOptions: vi.fn(), runDeployAction: vi.fn().mockResolvedValue(undefined), + runDashboardUrlCommand: vi.fn(() => undefined), runGatewayTokenCommand: vi.fn(() => undefined), runStartCommand: vi.fn().mockResolvedValue(undefined), runStopCommand: vi.fn(), @@ -34,6 +46,7 @@ const mocks = vi.hoisted(() => { spawnSync: vi.fn(), startAll: vi.fn(), stopAll: vi.fn(), + DashboardUrlCommandError, GatewayTokenCommandError, }; }); @@ -47,6 +60,10 @@ vi.mock("../lib/gateway-token-command", () => ({ GatewayTokenCommandError: mocks.GatewayTokenCommandError, runGatewayTokenCommand: mocks.runGatewayTokenCommand, })); +vi.mock("../lib/dashboard-url-command", () => ({ + DashboardUrlCommandError: mocks.DashboardUrlCommandError, + runDashboardUrlCommand: mocks.runDashboardUrlCommand, +})); vi.mock("../lib/actions/global", () => ({ runDeployAction: mocks.runDeployAction, showRootHelp: mocks.showRootHelp, @@ -68,6 +85,7 @@ vi.mock("../lib/core/version", () => ({ getVersion: mocks.getVersion })); import DebugCliCommand from "./debug"; import DeployCliCommand from "./deploy"; +import DashboardUrlCliCommand, { setDashboardUrlRuntimeBridgeFactoryForTest } from "./sandbox/dashboard-url"; import GatewayTokenCliCommand, { setGatewayTokenRuntimeBridgeFactoryForTest } from "./sandbox/gateway/token"; import DeprecatedStartCommand from "./start"; import DeprecatedStopCommand from "./stop"; @@ -127,6 +145,28 @@ describe("simple global oclif adapters", () => { ); }); + it("maps dashboard-url flags to the dashboard URL action", async () => { + const getSandbox = vi.fn(() => ({ agent: "openclaw", dashboardPort: 18789 })); + const getAccessUrl = vi.fn(() => "http://127.0.0.1:18789"); + setDashboardUrlRuntimeBridgeFactoryForTest(() => ({ + fetchGatewayAuthTokenFromSandbox: mocks.fetchGatewayAuthTokenFromSandbox, + getSandbox, + getAccessUrl, + })); + + await DashboardUrlCliCommand.run(["alpha", "--quiet"], rootDir); + + expect(mocks.runDashboardUrlCommand).toHaveBeenCalledWith( + "alpha", + { quiet: true }, + expect.objectContaining({ + fetchToken: mocks.fetchGatewayAuthTokenFromSandbox, + getSandbox, + getAccessUrl, + }), + ); + }); + it("uses process.exitCode (no @oclif/core ExitError) when the gateway-token action fails", async () => { // NCQ #3180: legacy dispatch did not catch the @oclif/core ExitError // thrown by this.exit(1), surfacing a raw JS stack trace to the user. diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 01b9661425..9a7d90ae74 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 60 commands", () => { + it("should contain exactly 61 commands", () => { // 27 global (21 visible + 6 hidden help/version aliases) - // 33 sandbox (27 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(60); + // 34 sandbox (28 visible + 6 hidden shields/config) + expect(COMMANDS).toHaveLength(61); }); it("should have no duplicate usage strings", () => { @@ -52,9 +52,9 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 33 entries", () => { - // 27 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(33); + it("should return exactly 34 entries", () => { + // 28 visible + 6 hidden (shields×3 + config get/set/rotate-token) + expect(sandboxCommands()).toHaveLength(34); }); it("every entry has scope sandbox", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (48 visible)", () => { + it("should exclude 12 hidden commands (49 visible)", () => { // 6 hidden global (help, --help, -h, version, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(48); + expect(visibleCommands()).toHaveLength(49); }); it("no visible command has hidden=true", () => { @@ -203,12 +203,13 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 22 unique action tokens including empty string", () => { + it("returns exactly 23 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(22); + expect(tokens).toHaveLength(23); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "connect", + "dashboard-url", "exec", "status", "doctor", diff --git a/src/lib/cli/public-argv-translation.test.ts b/src/lib/cli/public-argv-translation.test.ts index f19229a256..4a47d7d5d0 100644 --- a/src/lib/cli/public-argv-translation.test.ts +++ b/src/lib/cli/public-argv-translation.test.ts @@ -132,6 +132,11 @@ describe("translatePublicSandboxArgv", () => { "sandbox:doctor", ["alpha", "--json"], ); + expectNative( + translatePublicSandboxArgv("alpha", "dashboard-url", ["--quiet"]), + "sandbox:dashboard-url", + ["alpha", "--quiet"], + ); }); it("translates legacy hyphenated actions to native oclif argv", () => { diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 72e6c1af98..74847ae00f 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -196,6 +196,13 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "flags": "[--probe-only]" } ], + "sandbox:dashboard-url": [ + { + "group": "Sandbox Management", + "order": 3.2, + "flags": "[--quiet|-q]" + } + ], "sandbox:destroy": [ { "group": "Sandbox Management", diff --git a/src/lib/dashboard-url-command.test.ts b/src/lib/dashboard-url-command.test.ts new file mode 100644 index 0000000000..18472e0188 --- /dev/null +++ b/src/lib/dashboard-url-command.test.ts @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + DashboardUrlCommandError, + buildDashboardUrl, + runDashboardUrlCommand, +} from "./dashboard-url-command"; + +function makeSinks() { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + log: (m: string) => out.push(m), + error: (m: string) => err.push(m), + }; +} + +describe("dashboard-url command helpers", () => { + it("builds a tokenized dashboard URL for a dashboard port", () => { + expect(buildDashboardUrl("secret token", 18790)).toBe( + "http://127.0.0.1:18790/#token=secret%20token", + ); + }); + + it("builds a tokenized dashboard URL for an alternate access URL", () => { + expect(buildDashboardUrl("secret token", 18790, "http://172.22.1.1:18790")).toBe( + "http://172.22.1.1:18790/#token=secret%20token", + ); + }); + + it("rejects empty tokens before building a URL", () => { + expect(() => buildDashboardUrl("", 18790)).toThrow(/token is required/); + }); + + it("prints only the URL in quiet mode", () => { + const sinks = makeSinks(); + const fetchToken = vi.fn(() => "secret-token"); + const getSandbox = vi.fn(() => ({ agent: "openclaw", dashboardPort: 19000 })); + + runDashboardUrlCommand( + "alpha", + { quiet: true }, + { fetchToken, getSandbox, log: sinks.log, error: sinks.error }, + ); + + expect(fetchToken).toHaveBeenCalledWith("alpha"); + expect(getSandbox).toHaveBeenCalledWith("alpha"); + expect(sinks.out).toEqual(["http://127.0.0.1:19000/#token=secret-token"]); + expect(sinks.err).toEqual([]); + }); + + it("prints the resolved access URL when provided", () => { + const sinks = makeSinks(); + + runDashboardUrlCommand( + "alpha", + { quiet: true }, + { + fetchToken: () => "secret-token", + getSandbox: () => ({ agent: "openclaw", dashboardPort: 19000 }), + getAccessUrl: () => "http://172.22.1.1:19000", + log: sinks.log, + error: sinks.error, + }, + ); + + expect(sinks.out).toEqual(["http://172.22.1.1:19000/#token=secret-token"]); + }); + + it("prints a human label and warning outside quiet mode", () => { + const sinks = makeSinks(); + runDashboardUrlCommand( + "alpha", + { quiet: false }, + { + fetchToken: () => "secret-token", + getSandbox: () => ({ agent: null, dashboardPort: 18789 }), + log: sinks.log, + error: sinks.error, + }, + ); + + expect(sinks.out).toEqual([ + " Dashboard URL:", + " http://127.0.0.1:18789/#token=secret-token", + ]); + expect(sinks.err.join("\n")).toContain("Treat this URL like a password"); + }); + + it("fails for non-OpenClaw agents without fetching a token", () => { + const sinks = makeSinks(); + const fetchToken = vi.fn(() => "should-not-fetch"); + + expect(() => + runDashboardUrlCommand( + "hermes", + { quiet: true }, + { + fetchToken, + getSandbox: () => ({ agent: "hermes", dashboardPort: 8642 }), + log: sinks.log, + error: sinks.error, + }, + ), + ).toThrow(DashboardUrlCommandError); + + expect(fetchToken).not.toHaveBeenCalled(); + expect(sinks.out).toEqual([]); + }); + + it("fails when the token cannot be retrieved", () => { + const sinks = makeSinks(); + expect(() => + runDashboardUrlCommand( + "alpha", + { quiet: false }, + { + fetchToken: () => null, + getSandbox: () => ({ agent: "openclaw", dashboardPort: 18789 }), + log: sinks.log, + error: sinks.error, + }, + ), + ).toThrow(/Could not retrieve/); + expect(sinks.out).toEqual([]); + }); +}); diff --git a/src/lib/dashboard-url-command.ts b/src/lib/dashboard-url-command.ts new file mode 100644 index 0000000000..ddbe4b0d80 --- /dev/null +++ b/src/lib/dashboard-url-command.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * `nemoclaw dashboard-url` -- print the authenticated OpenClaw + * dashboard URL. This keeps the first-run UX from teaching users to handle + * the raw gateway token directly. + */ + +import { DASHBOARD_PORT } from "./core/ports"; +import type { SandboxEntry } from "./state/registry"; + +export interface DashboardUrlCommandDeps { + /** Pull gateway.auth.token from the sandbox config (host-side helper). */ + fetchToken: (sandboxName: string) => string | null; + /** Read sandbox metadata such as agent name and recorded dashboard port. */ + getSandbox?: (sandboxName: string) => Pick | null; + /** Resolve the browser-facing dashboard base URL for this host, when known. */ + getAccessUrl?: (port: number) => string | null; + /** Optional stdout sink -- defaults to console.log. */ + log?: (message: string) => void; + /** Optional stderr sink -- defaults to console.error. */ + error?: (message: string) => void; +} + +export interface DashboardUrlCommandOptions { + /** Print only the URL when set (`--quiet` / `-q`). */ + quiet?: boolean; +} + +export class DashboardUrlCommandError extends Error { + readonly lines: readonly string[]; + readonly exitCode: number; + + constructor(lines: string | readonly string[], exitCode = 1) { + const normalized = Array.isArray(lines) ? lines : [lines]; + super(normalized.join("\n")); + this.name = "DashboardUrlCommandError"; + this.lines = normalized; + this.exitCode = exitCode; + } +} + +const SECURITY_WARNING = + "Treat this URL like a password -- do not log, share, or commit it."; + +function dashboardUrlFail(lines: string | readonly string[], exitCode = 1): never { + throw new DashboardUrlCommandError(lines, exitCode); +} + +function resolveDashboardPort(sandbox: Pick | null): number { + const port = sandbox?.dashboardPort; + return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535 + ? port + : DASHBOARD_PORT; +} + +export function buildDashboardUrl( + token: string, + port = DASHBOARD_PORT, + baseUrl = `http://127.0.0.1:${port}/`, +): string { + if (!token) { + throw new Error("dashboard token is required"); + } + const normalizedBaseUrl = baseUrl.trim().endsWith("/") ? baseUrl.trim() : `${baseUrl.trim()}/`; + return `${normalizedBaseUrl}#token=${encodeURIComponent(token)}`; +} + +export function runDashboardUrlCommand( + sandboxName: string, + options: DashboardUrlCommandOptions, + deps: DashboardUrlCommandDeps, +): void { + const log = deps.log ?? ((m: string) => console.log(m)); + const error = deps.error ?? ((m: string) => console.error(m)); + + let sandbox: Pick | null = null; + if (deps.getSandbox) { + try { + sandbox = deps.getSandbox(sandboxName); + } catch { + sandbox = null; + } + } + + const agent = sandbox?.agent ?? null; + if (agent && agent !== "openclaw") { + dashboardUrlFail( + ` dashboard-url is not applicable for sandbox '${sandboxName}': it uses the '${agent}' agent, which does not expose an OpenClaw dashboard URL.`, + ); + } + + let token: string | null; + try { + token = deps.fetchToken(sandboxName); + } catch { + token = null; + } + + if (!token) { + dashboardUrlFail([ + ` Could not retrieve the dashboard auth token for sandbox '${sandboxName}'.`, + ` Make sure the sandbox is running: nemoclaw ${sandboxName} status`, + ]); + } + + const port = resolveDashboardPort(sandbox); + const accessUrl = deps.getAccessUrl?.(port) ?? null; + const url = buildDashboardUrl(token, port, accessUrl ?? undefined); + if (options.quiet) { + log(url); + return; + } + + log(" Dashboard URL:"); + log(` ${url}`); + error(SECURITY_WARNING); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bc231df3a5..e5d8ad7e39 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -8886,81 +8886,68 @@ function printDashboard( const token = fetchGatewayAuthTokenFromSandbox(sandboxName); const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; - const wslAddr = getWslHostAddress(); - const chain = buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: wslAddr }); - - // Build access info inline — uses chain instead of re-deriving from env - const dashboardAccess = buildControlUiUrls(token, chain.port, chain.accessUrl).map((url, i) => ({ - label: i === 0 ? "Dashboard" : `Alt ${i}`, - url, - })); - if (wslAddr) { - const wslUrl = `http://${wslAddr}:${chain.port}/${token ? `#token=${encodeURIComponent(token)}` : ""}`; - const existing = dashboardAccess.find((a) => a.url === wslUrl); - if (existing) existing.label = "VS Code/WSL"; - else dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl }); - } - const guidanceLines = [`Port ${chain.port} must be forwarded before opening these URLs.`]; - if (isWsl()) - guidanceLines.push( - "WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.", - ); - if (dashboardAccess.length === 0) guidanceLines.push("No dashboard URLs were generated."); + const chain = buildChain({ chatUiUrl, isWsl: isWsl(), wslHostAddress: getWslHostAddress() }); + const dashboardBaseUrl = `${chain.accessUrl.replace(/\/$/, "")}/`; + const dashboardUrl = dashboardUrlForDisplay( + dashboardAccess.buildAuthenticatedDashboardUrl(dashboardBaseUrl, token), + ); console.log(""); console.log(` ${"─".repeat(50)}`); - // console.log(` Dashboard http://localhost:${DASHBOARD_PORT}/`); - console.log(` Sandbox ${sandboxName} (Landlock + seccomp + netns)`); - console.log(` Model ${model} (${providerLabel})`); + console.log(` ${cliDisplayName()} is ready`); + console.log(""); + console.log(` Sandbox: ${sandboxName}`); + console.log(` Model: ${model} (${providerLabel})`); if (showNim) { - console.log(` NIM ${nimLabel}`); + console.log(` NIM: ${nimLabel}`); } - console.log(` ${"─".repeat(50)}`); - console.log(` Run: ${cliName()} ${sandboxName} connect`); - console.log(` Status: ${cliName()} ${sandboxName} status`); - console.log(` Logs: ${cliName()} ${sandboxName} logs --follow`); console.log(""); if (agent) { + console.log(" Access"); + console.log(""); agentOnboard.printDashboardUi(sandboxName, token, agent, { note, buildControlUiUrls: (tokenValue: string | null, port: number) => { return buildControlUiUrls(tokenValue, port, chain.accessUrl); }, }); + console.log(""); + console.log(" Terminal:"); + console.log(` ${cliName()} ${sandboxName} connect`); } else if (token) { - console.log( - ` ${agentProductName()} UI (auth token redacted from displayed URLs)`, - ); - for (const line of guidanceLines) { - console.log(` ${line}`); - } - for (const entry of dashboardAccess) { - console.log(` ${entry.label}: ${dashboardUrlForDisplay(entry.url)}`); - } - console.log(` Token: ${cliName()} ${sandboxName} gateway-token --quiet`); - console.log(` append #token= locally if the browser asks for auth.`); + console.log(" Start chatting"); + console.log(""); + console.log(" Browser:"); + console.log(` ${dashboardUrl}`); + console.log(""); + console.log(" Terminal:"); + console.log(` ${cliName()} ${sandboxName} connect`); + console.log(" then run: openclaw tui"); + console.log(""); + console.log(" Authenticated dashboard URL, if needed:"); + console.log(` ${cliName()} ${sandboxName} dashboard-url --quiet`); } else { note(" Could not read gateway token from the sandbox (download failed)."); - console.log(` ${agentProductName()} UI`); - for (const line of guidanceLines) { - console.log(` ${line}`); - } - for (const entry of dashboardAccess) { - console.log(` ${entry.label}: ${dashboardUrlForDisplay(entry.url)}`); - } - console.log( - ` Token: ${cliName()} ${sandboxName} connect → jq -r '.gateway.auth.token' /sandbox/.openclaw/openclaw.json`, - ); - console.log(` append #token= to the URL locally if needed.`); + console.log(" Start chatting"); + console.log(""); + console.log(" Browser:"); + console.log(` ${dashboardUrl}`); + console.log(""); + console.log(" Terminal:"); + console.log(` ${cliName()} ${sandboxName} connect`); + console.log(" then run: openclaw tui"); } - console.log(` ${"─".repeat(50)}`); console.log(""); - console.log(" To change settings later:"); + console.log(" Manage later"); + console.log(""); + console.log(` Status: ${cliName()} ${sandboxName} status`); + console.log(` Logs: ${cliName()} ${sandboxName} logs --follow`); console.log( - ` Model: ${cliName()} inference get\n ${cliName()} inference set --model --provider --sandbox ${sandboxName}`, + ` Model: ${cliName()} inference set --model --provider --sandbox ${sandboxName}`, ); console.log(` Policies: ${cliName()} ${sandboxName} policy-add`); - console.log(` Credentials: ${cliName()} credentials reset then ${cliName()} onboard`); + console.log(` Credentials: ${cliName()} credentials reset && ${cliName()} onboard`); + console.log(` ${"─".repeat(50)}`); console.log(""); } diff --git a/src/lib/onboard/dashboard-access.test.ts b/src/lib/onboard/dashboard-access.test.ts index 46ce5b91d3..e8557f85a1 100644 --- a/src/lib/onboard/dashboard-access.test.ts +++ b/src/lib/onboard/dashboard-access.test.ts @@ -57,7 +57,7 @@ describe("dashboard access helpers", () => { }); expect(access).toContainEqual({ - label: "Alt 1", + label: "WSL fallback", url: "http://172.22.1.1:18789/#token=secret", }); }); diff --git a/src/lib/onboard/dashboard-access.ts b/src/lib/onboard/dashboard-access.ts index 8b7c763731..b417af6975 100644 --- a/src/lib/onboard/dashboard-access.ts +++ b/src/lib/onboard/dashboard-access.ts @@ -136,8 +136,11 @@ export function getDashboardAccessInfo( const wslHostAddress = getWslHostAddress(options); if (wslHostAddress) { const wslUrl = buildAuthenticatedDashboardUrl(`http://${wslHostAddress}:${chain.port}/`, token ?? null); - if (!dashboardAccess.some((access) => access.url === wslUrl)) { - dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl }); + const existing = dashboardAccess.find((access) => access.url === wslUrl); + if (existing) { + existing.label = "WSL fallback"; + } else { + dashboardAccess.push({ label: "WSL fallback", url: wslUrl }); } } diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 5e70ce7a81..14f36ecbc5 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1754,8 +1754,9 @@ exit 0 expect(result.status).toBe(0); expect(output).not.toMatch(/current shell cannot resolve 'nemoclaw'/); expect(output).not.toMatch(/this shell needs PATH refresh/); - expect(output).toMatch(/\$ source /); - expect(output).toMatch(/\$ nemoclaw my-assistant connect/); + expect(output).not.toMatch(/\$ source /); + expect(output).not.toMatch(/\$ nemoclaw my-assistant connect/); + expect(output).toContain("Use the Start chatting section above"); }); it("makes current-shell PATH refresh obvious when the installer added the bin dir", () => { @@ -1846,7 +1847,7 @@ fi`, expect(output).not.toContain( "Onboarding did not run because this shell cannot resolve 'nemoclaw' yet.", ); - expect(output).toMatch(/\$ nemoclaw my-assistant connect/); + expect(output).not.toMatch(/\$ nemoclaw my-assistant connect/); }); });