From a4a94fe1813c5c75a764f17ae76156c52814884b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 17 Apr 2026 02:30:06 +0000 Subject: [PATCH] fix(onboard): preserve user's policy preset selections across sandbox rebuild (Fixes #1980) Before recreating an existing sandbox, copy its applied policies from the registry into the onboard session. After the new sandbox comes up, the policy setup step reads those recorded presets and reapplies them instead of reverting to the tier defaults. Signed-off-by: Tinson Lai --- src/lib/onboard.ts | 18 ++++--- test/onboard.test.ts | 113 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 6 deletions(-) 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 },