Skip to content
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <suggested-slug>` 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 <name>` to target a specific installed agent profile during onboarding.

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <suggested-slug>` 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`.
Expand Down
29 changes: 29 additions & 0 deletions src/lib/name-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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;
}
5 changes: 2 additions & 3 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -4434,9 +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.",
);
logMissingNvidiaApiKeyHelp(REMOTE_PROVIDER_CONFIG.build.helpUrl);
process.exit(1);
}
const keyError = validateNvidiaApiKeyValue(resolvedNvidiaKey);
Expand Down
12 changes: 12 additions & 0 deletions src/lib/onboard/missing-credential-hints.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
49 changes: 47 additions & 2 deletions test/onboard-sandbox-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
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();
});
});
});
114 changes: 114 additions & 0 deletions test/onboard-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
Loading