From 31149367786f2ba8b1ca05547706e38d86581fd9 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 10:05:35 +0000 Subject: [PATCH 1/6] fix(cli): surface copy-paste recovery hints in onboard error paths Signed-off-by: Tinson Lai --- src/lib/name-validation.ts | 29 ++++++++++++++++++ src/lib/onboard.ts | 2 ++ test/onboard-sandbox-name.test.ts | 49 +++++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/lib/name-validation.ts b/src/lib/name-validation.ts index a01f994430c..dd85d92939f 100644 --- a/src/lib/name-validation.ts +++ b/src/lib/name-validation.ts @@ -6,6 +6,8 @@ export const NAME_ALLOWED_FORMAT = `1-${NAME_MAX_LENGTH} characters, lowercase, starts with a letter, ` + "letters/numbers/internal hyphens only, ends with letter/number"; +const NAME_VALID_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; + function validationSubject(label: string): string { const normalized = label.trim().toLowerCase(); if (normalized === "sandbox name") return "Sandbox names"; @@ -14,6 +16,29 @@ function validationSubject(label: string): string { return "Names"; } +// Derive a copy-paste-ready RFC 1123 label from arbitrary user input. Returns +// null when no recoverable slug exists (empty, all-symbol input, or the input +// is already valid). The transform mirrors what a user would do by hand: +// lowercase, replace illegal chars with `-`, collapse runs of `-`, trim +// terminal `-`, prefix a leading non-letter with `s-`, and truncate to the +// max length without leaving a dangling hyphen. +export function suggestNameSlug(value: string): string | null { + if (typeof value !== "string") return null; + let slug = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-"); + slug = slug.replace(/^-+|-+$/g, ""); + if (!slug) return null; + if (!/^[a-z]/.test(slug)) { + slug = `s-${slug}`; + } + if (slug.length > NAME_MAX_LENGTH) { + slug = slug.slice(0, NAME_MAX_LENGTH); + } + slug = slug.replace(/[^a-z0-9]+$/g, ""); + if (!slug || !NAME_VALID_PATTERN.test(slug)) return null; + if (slug === value) return null; + return slug; +} + export function getNameValidationGuidance( label: string, value: string, @@ -29,5 +54,9 @@ export function getNameValidationGuidance( if (opts.includeAllowedFormat !== false) { lines.push(`Allowed format: ${NAME_ALLOWED_FORMAT}.`); } + const suggestion = suggestNameSlug(value); + if (suggestion) { + lines.push(`Try: ${suggestion}`); + } return lines; } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 761c3c24540..6470410135d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4437,6 +4437,8 @@ async function setupNim( console.error( " NVIDIA_API_KEY (or NEMOCLAW_PROVIDER_KEY) is required for NVIDIA Endpoints in non-interactive mode.", ); + console.error(" Set with: export NVIDIA_API_KEY=nvapi-..."); + console.error(` Get a key from ${REMOTE_PROVIDER_CONFIG.build.helpUrl}`); process.exit(1); } const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey); diff --git a/test/onboard-sandbox-name.test.ts b/test/onboard-sandbox-name.test.ts index a9cc45b7454..5d77f1c13fb 100644 --- a/test/onboard-sandbox-name.test.ts +++ b/test/onboard-sandbox-name.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it } from "vitest"; import { loadAgent } from "../dist/lib/agent/defs.js"; -import { getNameValidationGuidance, NAME_ALLOWED_FORMAT } from "../dist/lib/name-validation.js"; +import { + getNameValidationGuidance, + NAME_ALLOWED_FORMAT, + suggestNameSlug, +} from "../dist/lib/name-validation.js"; const { getDefaultSandboxNameForAgent, @@ -82,8 +86,49 @@ describe("onboard sandbox naming helpers", () => { expect(getNameValidationGuidance("sandbox name", "a".repeat(64))).toEqual([ "Sandbox names must be 63 characters or fewer.", `Allowed format: ${NAME_ALLOWED_FORMAT}.`, + `Try: ${"a".repeat(63)}`, ]); expect(getNameValidationGuidance("sandbox name", "bad name", { includeAllowedFormat: false })) - .toEqual(["Sandbox names cannot contain spaces."]); + .toEqual(["Sandbox names cannot contain spaces.", "Try: bad-name"]); + }); + + describe("suggestNameSlug", () => { + it("lowercases mixed-case input (T5987921 spec: 'MyAssistant' -> 'myassistant')", () => { + expect(suggestNameSlug("MyAssistant")).toBe("myassistant"); + }); + + it("replaces spaces and other illegal characters with hyphens", () => { + expect(suggestNameSlug("bad name")).toBe("bad-name"); + expect(suggestNameSlug("My Project Sandbox")).toBe("my-project-sandbox"); + expect(suggestNameSlug("agent_007")).toBe("agent-007"); + }); + + it("collapses runs of hyphens and trims terminal hyphens", () => { + expect(suggestNameSlug("--legacy--")).toBe("legacy"); + expect(suggestNameSlug("foo bar")).toBe("foo-bar"); + expect(suggestNameSlug("a---b")).toBe("a-b"); + }); + + it("prefixes 's-' when the slug would otherwise start with a digit", () => { + expect(suggestNameSlug("123-leading")).toBe("s-123-leading"); + expect(suggestNameSlug("9lives")).toBe("s-9lives"); + }); + + it("truncates over-length inputs to the max name length", () => { + const slug = suggestNameSlug("a".repeat(80)); + expect(slug).toBe("a".repeat(63)); + expect(slug!.length).toBe(63); + }); + + it("returns null when the input is already a valid name", () => { + expect(suggestNameSlug("my-assistant")).toBeNull(); + expect(suggestNameSlug("openclaw")).toBeNull(); + }); + + it("returns null when no recoverable slug can be derived", () => { + expect(suggestNameSlug("")).toBeNull(); + expect(suggestNameSlug("---")).toBeNull(); + expect(suggestNameSlug("!!!")).toBeNull(); + }); }); }); From bb712df22d89f81aa0df314564e8781260a22822 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 10:24:38 +0000 Subject: [PATCH 2/6] refactor(onboard): extract missing-credential helper + drop ticket ref Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 7 +- src/lib/onboard/missing-credential-hints.ts | 12 +++ test/onboard-sandbox-name.test.ts | 2 +- test/onboard-selection.test.ts | 114 ++++++++++++++++++++ 4 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 src/lib/onboard/missing-credential-hints.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6470410135d..139a023e117 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -153,6 +153,7 @@ const { const errnoUtils: typeof import("./core/errno") = require("./core/errno"); const { isErrnoException } = errnoUtils; const { requireValue }: typeof import("./core/require-value") = require("./core/require-value"); +const { logMissingNvidiaApiKeyHelp }: typeof import("./onboard/missing-credential-hints") = require("./onboard/missing-credential-hints"); type RunnerOptions = { env?: NodeJS.ProcessEnv; @@ -4434,11 +4435,7 @@ async function setupNim( if (isNonInteractive()) { const resolvedNvidiaKey = resolveProviderCredential("NVIDIA_API_KEY"); if (!resolvedNvidiaKey) { - console.error( - " NVIDIA_API_KEY (or NEMOCLAW_PROVIDER_KEY) is required for NVIDIA Endpoints in non-interactive mode.", - ); - console.error(" Set with: export NVIDIA_API_KEY=nvapi-..."); - console.error(` Get a key from ${REMOTE_PROVIDER_CONFIG.build.helpUrl}`); + logMissingNvidiaApiKeyHelp(REMOTE_PROVIDER_CONFIG.build.helpUrl); process.exit(1); } const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey); diff --git a/src/lib/onboard/missing-credential-hints.ts b/src/lib/onboard/missing-credential-hints.ts new file mode 100644 index 00000000000..75765a7a1c9 --- /dev/null +++ b/src/lib/onboard/missing-credential-hints.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function logMissingNvidiaApiKeyHelp(helpUrl: string | null | undefined): void { + console.error( + " NVIDIA_API_KEY (or NEMOCLAW_PROVIDER_KEY) is required for NVIDIA Endpoints in non-interactive mode.", + ); + console.error(" Set with: export NVIDIA_API_KEY=nvapi-..."); + if (helpUrl) { + console.error(` Get a key from ${helpUrl}`); + } +} diff --git a/test/onboard-sandbox-name.test.ts b/test/onboard-sandbox-name.test.ts index 5d77f1c13fb..ccb2953e0a0 100644 --- a/test/onboard-sandbox-name.test.ts +++ b/test/onboard-sandbox-name.test.ts @@ -93,7 +93,7 @@ describe("onboard sandbox naming helpers", () => { }); describe("suggestNameSlug", () => { - it("lowercases mixed-case input (T5987921 spec: 'MyAssistant' -> 'myassistant')", () => { + it("lowercases mixed-case input", () => { expect(suggestNameSlug("MyAssistant")).toBe("myassistant"); }); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index e13d9ebfdc2..cfa93de5679 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -4643,6 +4643,120 @@ const { setupNim, __setNonInteractive } = onboardModule.exports; ); }); + it("fails early in non-interactive mode with copy-paste recovery hints when no NVIDIA_API_KEY is set", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-missingkey-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "build-missingkey-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + + const script = String.raw` +const fs = require("fs"); +const path = require("path"); +const Module = require("module"); +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const prompts = []; +credentials.prompt = async (message) => { + prompts.push(message); + throw new Error("unexpected prompt"); +}; +credentials.ensureApiKey = async () => { + throw new Error("unexpected ensureApiKey"); +}; +runner.runCapture = () => ""; + +const onboardFile = ${onboardPath}; +const source = fs.readFileSync(onboardFile, "utf-8"); +const injected = source + "\nmodule.exports.__setNonInteractive = (value) => { NON_INTERACTIVE = value; };"; +const onboardModule = new Module(onboardFile, module); +onboardModule.filename = onboardFile; +onboardModule.paths = Module._nodeModulePaths(path.dirname(onboardFile)); +onboardModule._compile(injected, onboardFile); + +const { setupNim, __setNonInteractive } = onboardModule.exports; + +(async () => { + delete process.env.NVIDIA_API_KEY; + delete process.env.NEMOCLAW_PROVIDER_KEY; + __setNonInteractive(true); + const originalLog = console.log; + const originalError = console.error; + const originalExit = process.exit; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + process.exit = (code) => { + const error = new Error("process.exit:" + code); + error.exitCode = code; + throw error; + }; + try { + await setupNim(null); + originalLog(JSON.stringify({ completed: true, prompts, lines })); + } catch (error) { + originalLog( + JSON.stringify({ + completed: false, + prompts, + lines, + message: error.message, + exitCode: error.exitCode ?? null, + }), + ); + } finally { + console.log = originalLog; + console.error = originalError; + process.exit = originalExit; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NVIDIA_API_KEY: "", + NEMOCLAW_PROVIDER_KEY: "", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.completed, false); + assert.equal(payload.exitCode, 1); + assert.equal(payload.prompts.length, 0); + assert.ok( + payload.lines.some((line: string) => + line.includes( + "NVIDIA_API_KEY (or NEMOCLAW_PROVIDER_KEY) is required for NVIDIA Endpoints in non-interactive mode.", + ), + ), + ); + assert.ok( + payload.lines.some((line: string) => + line.includes("Set with: export NVIDIA_API_KEY=nvapi-..."), + ), + ); + assert.ok( + payload.lines.some((line: string) => + line.includes("Get a key from https://build.nvidia.com/settings/api-keys"), + ), + ); + }); + it("lets users re-enter an NVIDIA API key after authorization failure without restarting selection", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-auth-retry-")); From 2cd366566d9ca71fbd854f4c7390c5fd6224ea2c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 11:20:26 +0000 Subject: [PATCH 3/6] docs: replace stale sandbox-name auto-lowercase claim with Try: recovery hint Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 2 +- docs/reference/troubleshooting.mdx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 85451dca94b..1a2e10dfd50 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -157,7 +157,7 @@ After fixing the key, re-enable web search with `nemoclaw config web-search`. The wizard prompts for a sandbox name. Names must be 1 to 63 characters, lowercase, start with a letter, contain only letters, numbers, and internal hyphens, and end with a letter or number. -Uppercase letters are automatically lowercased. +Names that do not match these rules are rejected; the CLI prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input (for example, `MyAssistant` produces `Try: myassistant`). Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index e1c52b6729f..16c0615aa67 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -367,7 +367,8 @@ Run `nemoclaw status` for a broader gateway health report. ### Invalid sandbox name Sandbox names must be lowercase, start with a letter, contain only letters, numbers, and internal hyphens, and end with a letter or number. -Uppercase letters are automatically lowercased. +Names that do not match these rules are rejected; the wizard prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input. +For example, passing `--name MyAssistant` rejects the upper-cased input and prints `Try: myassistant` so you can rerun with the suggested slug. Names that collide with global CLI commands are also rejected. Reserved names include `onboard`, `list`, `deploy`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`. From f8b6404b11c7b6f310d902c7180812b21ab4d4ca Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 11:34:59 +0000 Subject: [PATCH 4/6] fix(onboard): put export command on own line + share name-valid regex Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 3 ++- src/lib/name-validation.ts | 2 +- src/lib/onboard/missing-credential-hints.ts | 3 ++- src/lib/runner.ts | 4 ++-- test/onboard-selection.test.ts | 12 ++++++++---- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1a2e10dfd50..73ed913845d 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -157,7 +157,8 @@ After fixing the key, re-enable web search with `nemoclaw config web-search`. The wizard prompts for a sandbox name. Names must be 1 to 63 characters, lowercase, start with a letter, contain only letters, numbers, and internal hyphens, and end with a letter or number. -Names that do not match these rules are rejected; the CLI prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input (for example, `MyAssistant` produces `Try: myassistant`). +The CLI rejects names that do not match these rules. +It also prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant`. Names that match global CLI commands (`status`, `list`, `debug`, etc.) are rejected to avoid routing conflicts. Use `--agent ` to target a specific installed agent profile during onboarding. diff --git a/src/lib/name-validation.ts b/src/lib/name-validation.ts index dd85d92939f..6629ef3ebca 100644 --- a/src/lib/name-validation.ts +++ b/src/lib/name-validation.ts @@ -6,7 +6,7 @@ export const NAME_ALLOWED_FORMAT = `1-${NAME_MAX_LENGTH} characters, lowercase, starts with a letter, ` + "letters/numbers/internal hyphens only, ends with letter/number"; -const NAME_VALID_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; +export const NAME_VALID_PATTERN = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; function validationSubject(label: string): string { const normalized = label.trim().toLowerCase(); diff --git a/src/lib/onboard/missing-credential-hints.ts b/src/lib/onboard/missing-credential-hints.ts index 75765a7a1c9..bd154491ad3 100644 --- a/src/lib/onboard/missing-credential-hints.ts +++ b/src/lib/onboard/missing-credential-hints.ts @@ -5,7 +5,8 @@ export function logMissingNvidiaApiKeyHelp(helpUrl: string | null | undefined): console.error( " NVIDIA_API_KEY (or NEMOCLAW_PROVIDER_KEY) is required for NVIDIA Endpoints in non-interactive mode.", ); - console.error(" Set with: export NVIDIA_API_KEY=nvapi-..."); + console.error(" Set with:"); + console.error(" export NVIDIA_API_KEY=nvapi-..."); if (helpUrl) { console.error(` Get a key from ${helpUrl}`); } diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 4ea1ba57184..bda565a5796 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -6,7 +6,7 @@ import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns, } from "node:child_process"; -import { NAME_ALLOWED_FORMAT, NAME_MAX_LENGTH } from "./name-validation"; +import { NAME_ALLOWED_FORMAT, NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "./name-validation"; const { spawnSync } = require("child_process"); const path = require("path"); @@ -322,7 +322,7 @@ function validateName(name: string, label = "name"): string { `${label} too long (max ${NAME_MAX_LENGTH} chars): '${name.slice(0, 20)}...'. Allowed format: ${NAME_ALLOWED_FORMAT}.`, ); } - if (!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(name)) { + if (!NAME_VALID_PATTERN.test(name)) { throw new Error( `Invalid ${label}: '${name}'. Allowed format: ${NAME_ALLOWED_FORMAT}.`, ); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index cfa93de5679..402e0fdaed8 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -4745,10 +4745,14 @@ const { setupNim, __setNonInteractive } = onboardModule.exports; ), ), ); - assert.ok( - payload.lines.some((line: string) => - line.includes("Set with: export NVIDIA_API_KEY=nvapi-..."), - ), + const setWithIndex = payload.lines.findIndex( + (line: string) => line.trim() === "Set with:", + ); + assert.ok(setWithIndex >= 0, "expected a standalone 'Set with:' line"); + assert.equal( + payload.lines[setWithIndex + 1].trim(), + "export NVIDIA_API_KEY=nvapi-...", + "expected the export command on its own line so it can be copy-pasted", ); assert.ok( payload.lines.some((line: string) => From 4ed192a37892ee6c2e73f472e33408277e4408c1 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 11:50:19 +0000 Subject: [PATCH 5/6] test(onboard): add CLI-boundary --name MyAssistant test + treat already-valid input as no-op Signed-off-by: Tinson Lai --- src/lib/name-validation.ts | 12 +++--- test/onboard-sandbox-name.test.ts | 72 ++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/lib/name-validation.ts b/src/lib/name-validation.ts index 6629ef3ebca..4c40c22d96b 100644 --- a/src/lib/name-validation.ts +++ b/src/lib/name-validation.ts @@ -17,13 +17,15 @@ function validationSubject(label: string): string { } // Derive a copy-paste-ready RFC 1123 label from arbitrary user input. Returns -// null when no recoverable slug exists (empty, all-symbol input, or the input -// is already valid). The transform mirrors what a user would do by hand: -// lowercase, replace illegal chars with `-`, collapse runs of `-`, trim -// terminal `-`, prefix a leading non-letter with `s-`, and truncate to the -// max length without leaving a dangling hyphen. +// null when no recoverable slug exists (empty, all-symbol input) or when the +// input is already a valid name (no canonicalisation is performed against +// inputs the validator would accept). The transform mirrors what a user would +// do by hand: lowercase, replace illegal chars with `-`, collapse runs of `-`, +// trim terminal `-`, prefix a leading non-letter with `s-`, and truncate to +// the max length without leaving a dangling hyphen. export function suggestNameSlug(value: string): string | null { if (typeof value !== "string") return null; + if (value.length <= NAME_MAX_LENGTH && NAME_VALID_PATTERN.test(value)) return null; let slug = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-"); slug = slug.replace(/^-+|-+$/g, ""); if (!slug) return null; diff --git a/test/onboard-sandbox-name.test.ts b/test/onboard-sandbox-name.test.ts index ccb2953e0a0..0b77d836370 100644 --- a/test/onboard-sandbox-name.test.ts +++ b/test/onboard-sandbox-name.test.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it } from "vitest"; import { loadAgent } from "../dist/lib/agent/defs.js"; @@ -106,7 +112,10 @@ describe("onboard sandbox naming helpers", () => { it("collapses runs of hyphens and trims terminal hyphens", () => { expect(suggestNameSlug("--legacy--")).toBe("legacy"); expect(suggestNameSlug("foo bar")).toBe("foo-bar"); - expect(suggestNameSlug("a---b")).toBe("a-b"); + }); + + it("returns null for inputs that are already valid even with internal hyphen runs", () => { + expect(suggestNameSlug("a---b")).toBeNull(); }); it("prefixes 's-' when the slug would otherwise start with a digit", () => { @@ -131,4 +140,65 @@ describe("onboard sandbox naming helpers", () => { expect(suggestNameSlug("!!!")).toBeNull(); }); }); + + it( + "rejects --name MyAssistant at the onboard boundary and prints Try: myassistant", + () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-bad-name-")); + const scriptPath = path.join(tmpDir, "onboard-bad-name.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + + const script = String.raw` +const onboardModule = require(${onboardPath}); + +(async () => { + const lines = []; + const originalError = console.error; + const originalExit = process.exit; + console.error = (...args) => lines.push(args.join(" ")); + process.exit = (code) => { + const error = new Error("process.exit:" + code); + error.exitCode = code; + throw error; + }; + let exitCode = null; + try { + await onboardModule.onboard({ sandboxName: "MyAssistant", nonInteractive: true }); + process.stdout.write(JSON.stringify({ completed: true, exitCode, lines })); + } catch (error) { + exitCode = error.exitCode ?? null; + process.stdout.write( + JSON.stringify({ completed: false, exitCode, lines, message: error.message }), + ); + } finally { + console.error = originalError; + process.exit = originalExit; + } +})().catch((error) => { + process.stderr.write(error.stack || String(error)); + process.exit(2); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir }, + }); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.completed, false); + assert.equal(payload.exitCode, 1); + assert.ok( + payload.lines.some((line: string) => line.includes("Invalid sandbox name: 'MyAssistant'.")), + `expected 'Invalid sandbox name' line, got ${JSON.stringify(payload.lines)}`, + ); + assert.ok( + payload.lines.some((line: string) => line.trim() === "Try: myassistant"), + `expected standalone 'Try: myassistant' line, got ${JSON.stringify(payload.lines)}`, + ); + }, + ); }); From 80d92189290b884d56b525900b07fc5a242b3341 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 28 May 2026 12:02:39 +0000 Subject: [PATCH 6/6] docs(troubleshooting): note CLI prints Try: hint, not the wizard Signed-off-by: Tinson Lai --- docs/reference/troubleshooting.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 16c0615aa67..b1289ac27e0 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -367,8 +367,8 @@ Run `nemoclaw status` for a broader gateway health report. ### Invalid sandbox name Sandbox names must be lowercase, start with a letter, contain only letters, numbers, and internal hyphens, and end with a letter or number. -Names that do not match these rules are rejected; the wizard prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input. -For example, passing `--name MyAssistant` rejects the upper-cased input and prints `Try: myassistant` so you can rerun with the suggested slug. +The CLI rejects names that do not match these rules. +It prints a `Try: ` recovery line whenever it can derive a valid lowercase, hyphen-separated form from the input, so passing `--name MyAssistant` reports `Try: myassistant` and you can rerun with the suggested slug. Names that collide with global CLI commands are also rejected. Reserved names include `onboard`, `list`, `deploy`, `setup`, `start`, `stop`, `status`, `debug`, `uninstall`, `credentials`, and `help`.