Skip to content
Open
24 changes: 17 additions & 7 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2052,32 +2052,42 @@ 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.
if (isNonInteractive()) {
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 ──────────────────────────────────────────────
Expand Down
25 changes: 23 additions & 2 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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\)/);
Expand Down