diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 5ab4e8629..5616e1507 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2052,23 +2052,28 @@ async function recoverGatewayRuntime() { // ── Step 3: Sandbox ────────────────────────────────────────────── async function promptValidatedSandboxName() { - while (true) { + const MAX_ATTEMPTS = 3; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const nameAnswer = await promptOrDefault( - " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", + " Sandbox name (lowercase, starts with letter, hyphens ok) [my-assistant]: ", "NEMOCLAW_SANDBOX_NAME", "my-assistant", ); const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); // Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens, - // must start and end with alphanumeric (required by Kubernetes/OpenShell) - if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { + // must start with a letter (not a digit) to satisfy Kubernetes naming. + if (/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName)) { return sandboxName; } console.error(` Invalid sandbox name: '${sandboxName}'`); - console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,"); - console.error(" and must start and end with a letter or number."); + if (/^[0-9]/.test(sandboxName)) { + console.error(" Names must start with a letter, not a digit."); + } else { + console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,"); + console.error(" must start with a letter, and end with a letter or number."); + } // Non-interactive runs cannot re-prompt — abort so the caller can fix the // NEMOCLAW_SANDBOX_NAME env var and retry. @@ -2076,8 +2081,13 @@ async function promptValidatedSandboxName() { process.exit(1); } - console.error(" Please try again.\n"); + if (attempt < MAX_ATTEMPTS - 1) { + console.error(" Please try again.\n"); + } } + + console.error(" Too many invalid attempts."); + process.exit(1); } // ── Step 5: Sandbox ────────────────────────────────────────────── diff --git a/test/onboard.test.js b/test/onboard.test.js index 267696119..b8bdbe2a5 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -507,6 +507,24 @@ describe("onboard helpers", () => { } }); + it("rejects sandbox names starting with a digit", () => { + // The validation regex must require names to start with a letter, + // not a digit — Kubernetes rejects digit-prefixed names downstream. + const SANDBOX_NAME_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?$/; + + expect(SANDBOX_NAME_REGEX.test("my-assistant")).toBe(true); + expect(SANDBOX_NAME_REGEX.test("a")).toBe(true); + expect(SANDBOX_NAME_REGEX.test("agent-1")).toBe(true); + expect(SANDBOX_NAME_REGEX.test("test-sandbox-v2")).toBe(true); + + expect(SANDBOX_NAME_REGEX.test("7racii")).toBe(false); + expect(SANDBOX_NAME_REGEX.test("1sandbox")).toBe(false); + expect(SANDBOX_NAME_REGEX.test("123")).toBe(false); + expect(SANDBOX_NAME_REGEX.test("-start-hyphen")).toBe(false); + expect(SANDBOX_NAME_REGEX.test("end-hyphen-")).toBe(false); + expect(SANDBOX_NAME_REGEX.test("")).toBe(false); + }); + it("passes credential names to openshell without embedding secret values in argv", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-")); @@ -1817,9 +1835,12 @@ const { setupInference } = require(${onboardPath}); ); assert.ok(fnMatch, "promptValidatedSandboxName function not found"); const fnBody = fnMatch[1]; - // Verify the retry loop exists within this function - assert.match(fnBody, /while\s*\(true\)/); + // Verify the bounded retry loop exists within this function + assert.match(fnBody, /MAX_ATTEMPTS/); + assert.match(fnBody, /for\s*\(let attempt/); assert.match(fnBody, /Please try again/); + // Exits after too many invalid attempts + assert.match(fnBody, /Too many invalid attempts/); // Non-interactive still exits within this function assert.match(fnBody, /isNonInteractive\(\)/); assert.match(fnBody, /process\.exit\(1\)/);