diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0e9d6e6aad..4ae3f67469 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2978,6 +2978,14 @@ async function createSandbox( note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); } + const previousEntry = registry.getSandbox(sandboxName); + if (previousEntry?.policies?.length > 0) { + onboardSession.updateSession((current) => { + current.policyPresets = previousEntry.policies; + return current; + }); + } + note(` Deleting and recreating sandbox '${sandboxName}'...`); // Destroy old sandbox @@ -5916,8 +5924,9 @@ async function onboard(opts = {}) { onboardSession.markStepSkipped("agent_setup"); } - const recordedPolicyPresets = Array.isArray(session?.policyPresets) - ? session.policyPresets + const latestSession = onboardSession.loadSession(); + const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) + ? latestSession.policyPresets : null; if (dangerouslySkipPermissions) { step(8, 8, "Policy presets"); @@ -5952,10 +5961,7 @@ async function onboard(opts = {}) { }); const appliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { selectedPresets: - resume && - session?.steps?.policies?.status !== "complete" && - Array.isArray(recordedPolicyPresets) && - recordedPolicyPresets.length > 0 + Array.isArray(recordedPolicyPresets) && recordedPolicyPresets.length > 0 ? recordedPolicyPresets : null, enabledChannels: selectedMessagingChannels, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index a1b9366aa6..35951cae3f 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -3071,6 +3071,119 @@ const { createSandbox } = require(${onboardPath}); }, ); + it( + "recreating a sandbox preserves the user's policy preset selections", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-recreate-preserves-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "recreate-preserves.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const sessionModulePath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard-session.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 onboardSession = require(${sessionModulePath}); +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 ""; + if (command.includes("sandbox exec") && command.includes("curl")) return "ok"; + return ""; +}; + +// Existing sandbox has a custom preset selection: only "npm" (not the +// full "balanced" tier). Recreating the sandbox must preserve this +// customisation rather than reverting to the tier defaults. +registry.getSandbox = () => ({ + name: "my-assistant", + gpuEnabled: false, + policies: ["npm"], + policyTier: "balanced", +}); +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; + +const preflight = require(${JSON.stringify(path.join(repoRoot, "dist", "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"; + await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + const session = onboardSession.loadSession(); + console.log(JSON.stringify({ policyPresets: session && session.policyPresets })); +})().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.deepEqual( + payload.policyPresets, + ["npm"], + "createSandbox should write the previous sandbox's policy presets to the onboard session before destroying it so they can be reapplied after recreation", + ); + }, + ); + it( "interactive mode prompts before reusing an existing ready sandbox", { timeout: 60_000 },