diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index cf96d03f8..f68cce370 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -46,6 +46,22 @@ const nim = require("./nim"); const onboardSession = require("./onboard-session"); const policies = require("./policies"); const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight"); +function secureTempFile(prefix, ext = "") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + return path.join(dir, `${prefix}${ext}`); +} + +/** + * Safely remove a mkdtemp-created directory. Guards against accidentally + * deleting the system temp root if a caller passes os.tmpdir() itself. + */ +function cleanupTempDir(filePath, expectedPrefix) { + const parentDir = path.dirname(filePath); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } +} + const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; @@ -683,10 +699,7 @@ function getProbeRecovery(probe, options = {}) { // eslint-disable-next-line complexity function runCurlProbe(argv) { - const bodyFile = path.join( - os.tmpdir(), - `nemoclaw-curl-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); + const bodyFile = secureTempFile("nemoclaw-curl-probe", ".json"); try { const args = [...argv]; const url = args.pop(); @@ -739,7 +752,7 @@ function runCurlProbe(argv) { message: summarizeCurlFailure(error?.status || 1, error?.message || String(error)), }; } finally { - fs.rmSync(bodyFile, { force: true }); + cleanupTempDir(bodyFile, "nemoclaw-curl-probe"); } } @@ -2244,23 +2257,32 @@ async function recoverGatewayRuntime() { // ── Step 3: Sandbox ────────────────────────────────────────────── async function promptValidatedSandboxName() { - const nameAnswer = await promptOrDefault( - " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", - "NEMOCLAW_SANDBOX_NAME", - "my-assistant", - ); - const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); + while (true) { + const nameAnswer = await promptOrDefault( + " Sandbox name (lowercase, numbers, hyphens) [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)) { + return sandboxName; + } - // 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)) { 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."); - process.exit(1); - } - return sandboxName; + // 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"); + } } // ── Step 5: Sandbox ────────────────────────────────────────────── @@ -3189,7 +3211,7 @@ async function setupOpenclaw(sandboxName, model, provider) { { stdio: ["ignore", "ignore", "inherit"] }, ); } finally { - fs.rmSync(path.dirname(scriptFile), { recursive: true, force: true }); + cleanupTempDir(scriptFile, "nemoclaw-sync"); } } diff --git a/test/onboard.test.js b/test/onboard.test.js index 820a0fae1..20f9a5c61 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -486,17 +486,23 @@ describe("onboard helpers", () => { }); it("writes sandbox sync scripts to a temp file for stdin redirection", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-test-")); + const scriptFile = writeSandboxConfigSyncFile("echo test"); try { - const scriptFile = writeSandboxConfigSyncFile("echo test", tmpDir); - expect(scriptFile).toMatch(/nemoclaw-sync-.*[/\\]sync\.sh$/); + expect(scriptFile).toMatch(/nemoclaw-sync.*\.sh$/); expect(fs.readFileSync(scriptFile, "utf8")).toBe("echo test\n"); + // Verify the file lives inside a mkdtemp-created directory (not directly in /tmp) + const parentDir = path.dirname(scriptFile); + expect(parentDir).not.toBe(os.tmpdir()); + expect(parentDir).toContain("nemoclaw-sync"); if (process.platform !== "win32") { const stat = fs.statSync(scriptFile); expect(stat.mode & 0o777).toBe(0o600); } } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + const parentDir = path.dirname(scriptFile); + if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith("nemoclaw-sync-")) { + fs.rmSync(parentDir, { recursive: true, force: true }); + } } }); @@ -1798,4 +1804,23 @@ const { setupInference } = require(${onboardPath}); const commands = JSON.parse(result.stdout.trim().split("\n").pop()); assert.equal(commands.length, 3); }); + + it("re-prompts on invalid sandbox names instead of exiting in interactive mode", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf-8", + ); + // Extract the promptValidatedSandboxName function body + const fnMatch = source.match( + /async function promptValidatedSandboxName\(\)\s*\{([\s\S]*?)\n\}/, + ); + 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\)/); + assert.match(fnBody, /Please try again/); + // Non-interactive still exits within this function + assert.match(fnBody, /isNonInteractive\(\)/); + assert.match(fnBody, /process\.exit\(1\)/); + }); });