diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 0e370aa29ad..61d618e4e4c 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -177,9 +177,14 @@ const REMOTE_PROVIDER_CONFIG = { // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. let NON_INTERACTIVE = false; +let RECREATE_SANDBOX = false; function isNonInteractive() { - return NON_INTERACTIVE; + return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; +} + +function isRecreateSandbox() { + return RECREATE_SANDBOX || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; } function note(message) { @@ -2003,34 +2008,59 @@ async function createSandbox( hasMessagingTokens && messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); - if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { - if (needsProviderMigration) { - console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); - console.log(" Recreating to ensure credentials flow through the provider pipeline."); - } else { - // Upsert messaging providers even on reuse so credential changes take - // effect without requiring a full sandbox recreation. Only the - // --provider attachment flags need to be on the create path. - upsertMessagingProviders(messagingTokenDefs); - ensureDashboardForward(sandboxName, chatUiUrl); - if (isNonInteractive()) { + if (!isRecreateSandbox() && !needsProviderMigration) { + if (isNonInteractive()) { + if (existingSandboxState === "ready") { + // Upsert messaging providers even on reuse so credential changes take + // effect without requiring a full sandbox recreation. + upsertMessagingProviders(messagingTokenDefs); note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); - } else { - console.log(` Sandbox '${sandboxName}' already exists and is ready.`); - console.log(" Reusing existing sandbox."); - console.log(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it instead."); + note(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to force recreation."); + ensureDashboardForward(sandboxName, chatUiUrl); + return sandboxName; + } + console.error(` Sandbox '${sandboxName}' already exists but is not ready.`); + console.error(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to overwrite."); + process.exit(1); + } + + if (existingSandboxState === "ready") { + console.log(` Sandbox '${sandboxName}' already exists.`); + console.log(" Choosing 'n' will delete the existing sandbox and create a new one."); + const answer = await promptOrDefault(" Reuse existing sandbox? [Y/n]: ", null, "y"); + const normalizedAnswer = answer.trim().toLowerCase(); + if (normalizedAnswer !== "n" && normalizedAnswer !== "no") { + upsertMessagingProviders(messagingTokenDefs); + ensureDashboardForward(sandboxName, chatUiUrl); + return sandboxName; + } + } else { + console.log(` Sandbox '${sandboxName}' exists but is not ready.`); + console.log(" Selecting 'n' will abort onboarding."); + const answer = await promptOrDefault( + " Delete it and create a new one? [Y/n]: ", + null, + "y", + ); + const normalizedAnswer = answer.trim().toLowerCase(); + if (normalizedAnswer === "n" || normalizedAnswer === "no") { + console.log(" Aborting onboarding."); + process.exit(1); } - return sandboxName; } } - if (existingSandboxState === "ready" && needsProviderMigration) { - note(` Sandbox '${sandboxName}' exists — recreating to attach messaging providers.`); + if (needsProviderMigration) { + console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); + console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else if (existingSandboxState === "ready") { note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); } else { note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); } + + note(` Deleting and recreating sandbox '${sandboxName}'...`); + // Destroy old sandbox runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); registry.removeSandbox(sandboxName); @@ -3775,6 +3805,7 @@ function skippedStepMessage(stepName, detail, reason = "resume") { // eslint-disable-next-line complexity async function onboard(opts = {}) { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; // In non-interactive mode also accept the env var so CI pipelines can set it. diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index e7cfe8d58ea..02caf73ae4c 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -765,27 +765,39 @@ async function onboard(args) { if (!fromDockerfile || fromDockerfile.startsWith("--")) { console.error(" --from requires a path to a Dockerfile"); console.error( - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${NOTICE_ACCEPT_FLAG}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [${NOTICE_ACCEPT_FLAG}]`, ); process.exit(1); } args = [...args.slice(0, fromIdx), ...args.slice(fromIdx + 2)]; } - const allowedArgs = new Set(["--non-interactive", "--resume", NOTICE_ACCEPT_FLAG]); + const allowedArgs = new Set([ + "--non-interactive", + "--resume", + "--recreate-sandbox", + NOTICE_ACCEPT_FLAG, + ]); const unknownArgs = args.filter((arg) => !allowedArgs.has(arg)); if (unknownArgs.length > 0) { console.error(` Unknown onboard option(s): ${unknownArgs.join(", ")}`); console.error( - ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--from ] [${NOTICE_ACCEPT_FLAG}]`, + ` Usage: nemoclaw onboard [--non-interactive] [--resume] [--recreate-sandbox] [--from ] [${NOTICE_ACCEPT_FLAG}]`, ); process.exit(1); } const nonInteractive = args.includes("--non-interactive"); const resume = args.includes("--resume"); + const recreateSandbox = args.includes("--recreate-sandbox"); const acceptThirdPartySoftware = args.includes(NOTICE_ACCEPT_FLAG) || String(process.env[NOTICE_ACCEPT_ENV] || "") === "1"; - await runOnboard({ nonInteractive, resume, fromDockerfile, acceptThirdPartySoftware }); + await runOnboard({ + nonInteractive, + resume, + recreateSandbox, + fromDockerfile, + acceptThirdPartySoftware, + }); } async function setup(args = []) { diff --git a/test/onboard.test.js b/test/onboard.test.js index 1f59446fcf3..3dabad65c33 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1988,6 +1988,503 @@ const { createSandbox } = require(${onboardPath}); }, ); + it( + "non-interactive exits with error when existing sandbox is not ready", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-noninteractive-notready-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "noninteractive-notready.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const childProcess = require("node:child_process"); + +runner.run = (command) => { + if (command.includes("'sandbox' 'delete'")) { + throw new Error("unexpected sandbox delete"); + } + return { status: 0 }; +}; +runner.runCapture = (command) => { + // Existing sandbox that is NOT ready + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant NotReady"; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +childProcess.spawn = () => { + throw new Error("unexpected sandbox create"); +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log("ERROR_DID_NOT_EXIT"); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const env = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }; + delete env["NEMOCLAW_RECREATE_SANDBOX"]; + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.notEqual(result.status, 0, "expected non-zero exit for not-ready sandbox"); + assert.ok( + !result.stdout.includes("ERROR_DID_NOT_EXIT"), + "should have exited before reaching sandbox create", + ); + const output = (result.stdout || "") + (result.stderr || ""); + assert.ok( + output.includes("--recreate-sandbox") || output.includes("NEMOCLAW_RECREATE_SANDBOX"), + "should hint about --recreate-sandbox flag", + ); + }, + ); + + it( + "recreate-sandbox flag forces deletion and recreation of a ready sandbox", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-recreate-flag-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "recreate-flag.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("'forward' 'list'")) return ""; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; + +const preflight = require(${JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"))}); +preflight.checkPortAvailable = async () => ({ ok: true }); + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_RECREATE_SANDBOX = "1"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().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 || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'delete'")), + "should delete existing sandbox when --recreate-sandbox is set", + ); + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'create'")), + "should create a new sandbox when --recreate-sandbox is set", + ); + }, + ); + + it( + "interactive mode prompts before reusing an existing ready sandbox", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-interactive-reuse-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "interactive-reuse.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); + +// Mock prompt to return "y" (reuse) +credentials.prompt = async () => "y"; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path + const env = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }; + delete env["NEMOCLAW_NON_INTERACTIVE"]; + delete env["NEMOCLAW_RECREATE_SANDBOX"]; + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + assert.equal(payload.sandboxName, "my-assistant", "should reuse when user answers y"); + assert.ok( + payload.commands.every((entry) => !entry.command.includes("'sandbox' 'create'")), + "should NOT recreate sandbox when user chooses to reuse", + ); + assert.ok( + payload.commands.every((entry) => !entry.command.includes("'sandbox' 'delete'")), + "should NOT delete sandbox when user chooses to reuse", + ); + assert.ok( + result.stdout.includes("already exists"), + "should show 'already exists' message in interactive mode", + ); + }, + ); + + it( + "interactive mode deletes and recreates sandbox when user declines reuse", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-interactive-decline-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "interactive-decline.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("'forward' 'list'")) return ""; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; + +const preflight = require(${JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"))}); +preflight.checkPortAvailable = async () => ({ ok: true }); + +// Mock prompt to return "n" (decline reuse) +credentials.prompt = async () => "n"; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path + const env = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }; + delete env["NEMOCLAW_NON_INTERACTIVE"]; + delete env["NEMOCLAW_RECREATE_SANDBOX"]; + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'delete'")), + "should delete existing sandbox when user declines reuse", + ); + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'create'")), + "should create a new sandbox when user declines reuse", + ); + assert.ok( + result.stdout.includes("already exists"), + "should show 'already exists' message before prompting", + ); + }, + ); + + it( + "interactive mode auto-recreates when existing sandbox is not ready", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-interactive-notready-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "interactive-notready.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +let sandboxDeleted = false; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + if (command.includes("'sandbox' 'delete'")) sandboxDeleted = true; + return { status: 0 }; +}; +runner.runCapture = (command) => { + // Existing sandbox that is NOT ready initially, becomes Ready after recreation + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) { + return sandboxDeleted ? "my-assistant Ready" : "my-assistant NotReady"; + } + if (command.includes("'forward' 'list'")) return ""; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; + +const preflight = require(${JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js"))}); +preflight.checkPortAvailable = async () => ({ ok: true }); + +// User confirms recreation when prompted +credentials.prompt = async () => "y"; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path + const env = { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }; + delete env["NEMOCLAW_NON_INTERACTIVE"]; + delete env["NEMOCLAW_RECREATE_SANDBOX"]; + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'delete'")), + "should delete not-ready sandbox after user confirms", + ); + assert.ok( + payload.commands.some((entry) => entry.command.includes("'sandbox' 'create'")), + "should recreate sandbox when existing one is not ready", + ); + assert.ok(result.stdout.includes("not ready"), "should mention sandbox is not ready"); + }, + ); + it("upsertProvider creates a new provider and returns ok on success", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-upsert-provider-create-"));