Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading