diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2fca8bfeac7..104359340fb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -66,6 +66,9 @@ const { const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); +const { + syncPresetSelection, +}: typeof import("./onboard/policy-preset-sync") = require("./onboard/policy-preset-sync"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -9280,51 +9283,6 @@ async function setupPoliciesWithSelection( return interactiveChoice; } -/** - * Reconcile the sandbox's currently-applied preset list with the user's - * target selection: - * - remove presets in `applied` but not in `target` (narrow) - * - apply presets in `target` but not in `applied` (widen) - * - leave unchanged presets untouched (no wasteful re-apply) - * - * Shared between the interactive and non-interactive paths so "narrow the - * selection" works identically in both. Fixes #2177 (non-interactive path - * was apply-only, so deselected presets lingered). - * - * @param {string} sandboxName Target sandbox. - * @param {string[]} applied Preset names currently applied to the sandbox. - * @param {string[]} target Preset names the user wants applied after this call. - * @param {Object|null} [accessByName=null] - * Optional map of preset name → access mode ("read" | "read-write"). - * When provided, applyPreset receives the mode per preset so the gateway - * can distinguish read vs read-write installs. - * @returns {void} - */ -function syncPresetSelection( - sandboxName: string, - applied: string[], - target: string[], - accessByName: Record | null = null, -): void { - const targetSet = new Set(target); - const appliedSet = new Set(applied); - const deselected = applied.filter((name) => !targetSet.has(name)); - const newlySelected = target.filter((name) => !appliedSet.has(name)); - - for (const name of deselected) { - waitForPolicyMutation(`removePreset(${name})`, () => - policies.removePreset(sandboxName, name), - ); - } - - for (const name of newlySelected) { - const options = accessByName ? { access: accessByName[name] } : undefined; - waitForPolicyMutation(`applyPreset(${name})`, () => - policies.applyPreset(sandboxName, name, options), - ); - } -} - // ── Dashboard ──────────────────────────────────────────────────── const CONTROL_UI_PORT = DASHBOARD_PORT; diff --git a/src/lib/onboard/policy-preset-sync.ts b/src/lib/onboard/policy-preset-sync.ts new file mode 100644 index 00000000000..0fa87d320e3 --- /dev/null +++ b/src/lib/onboard/policy-preset-sync.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const policies: typeof import("../policy") = require("../policy"); +const { waitUntil }: typeof import("../core/wait") = require("../core/wait"); + +function waitForPolicyMutation(description: string, mutate: () => boolean | void): void { + let lastError: Error | null = null; + const success = waitUntil(() => { + try { + const result = mutate(); + if (result === false) { + lastError = new Error(`${description} returned false`); + return false; + } + return true; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + lastError = error; + if (!error.message.includes("sandbox not found")) { + throw err; + } + return false; + } + }, 10, 2000); + + if (!success) { + throw lastError || new Error(`${description} timed out`); + } +} + +/** + * Reconcile the sandbox's currently-applied preset list with the user's + * target selection: + * - remove presets in `applied` but not in `target` (narrow) + * - apply presets in `target` but not in `applied` (widen) + * - leave unchanged presets untouched (no wasteful re-apply) + */ +function syncPresetSelection( + sandboxName: string, + applied: string[], + target: string[], + accessByName: Record | null = null, +): void { + const targetSet = new Set(target); + const appliedSet = new Set(applied); + const deselected = applied.filter((name) => !targetSet.has(name)); + const newlySelected = target.filter((name) => !appliedSet.has(name)); + + for (const name of deselected) { + waitForPolicyMutation(`removePreset(${name})`, () => policies.removePreset(sandboxName, name)); + } + + if (!accessByName) { + const builtInPresetNames = new Set(policies.listPresets().map((preset) => preset.name)); + const builtInNewlySelected = newlySelected.filter((name) => builtInPresetNames.has(name)); + const remainingNewlySelected = newlySelected.filter((name) => !builtInPresetNames.has(name)); + + if (builtInNewlySelected.length > 0 && remainingNewlySelected.length === 0) { + waitForPolicyMutation(`applyPresets(${builtInNewlySelected.join(",")})`, () => + policies.applyPresets(sandboxName, builtInNewlySelected), + ); + return; + } + + for (const name of newlySelected) { + waitForPolicyMutation(`applyPreset(${name})`, () => policies.applyPreset(sandboxName, name)); + } + return; + } + + for (const name of newlySelected) { + const options = { access: accessByName[name] }; + waitForPolicyMutation(`applyPreset(${name})`, () => + policies.applyPreset(sandboxName, name, options), + ); + } +} + +export { syncPresetSelection, waitForPolicyMutation }; diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index b2f58fac012..cee3472eeef 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -668,6 +668,95 @@ function applyPreset( return applyPresetContent(sandboxName, presetName, presetContent, options); } +/** + * Apply multiple built-in presets to a running sandbox with a single gateway + * policy mutation. This preserves final policy/registry state from applying + * presets one-by-one, while avoiding one `openshell policy set --wait` per + * preset during onboarding. + */ +function applyPresets(sandboxName: string, presetNames: string[]): boolean { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { + throw new Error( + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, + ); + } + + const uniquePresetNames = [...new Set(presetNames)].filter(Boolean); + if (uniquePresetNames.length === 0) return true; + + let rawPolicy = ""; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + } catch { + /* ignored */ + } + + let merged = parseCurrentPolicy(rawPolicy); + const endpointLogs: string[][] = []; + + for (const presetName of uniquePresetNames) { + const presetContent = loadPreset(presetName); + if (!presetContent) { + console.error(` Cannot load preset: ${presetName}`); + return false; + } + + const presetEntries = extractPresetEntries(presetContent); + if (!presetEntries) { + console.error(` Preset ${presetName} has no network_policies section.`); + return false; + } + + const endpoints = getPresetEndpoints(presetContent); + endpointLogs.push(endpoints); + merged = mergePresetIntoPolicy(merged, presetEntries); + } + + for (const endpoints of endpointLogs) { + if (endpoints.length > 0) { + console.log(` Widening sandbox egress — adding: ${endpoints.join(", ")}`); + } + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); + + try { + run(buildPolicySetCommand(tmpFile, sandboxName)); + + for (const presetName of uniquePresetNames) { + console.log(` Applied preset: ${presetName}`); + } + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } + } + + const sandbox = registry.getSandbox(sandboxName); + if (sandbox) { + const pols = sandbox.policies || []; + for (const presetName of uniquePresetNames) { + if (!pols.includes(presetName)) { + pols.push(presetName); + } + } + registry.updateSandbox(sandboxName, { policies: pols }); + } + + return true; +} + /** * Load a user-authored preset YAML from an arbitrary path on disk, validate * its shape, and return `{ presetName, content }` for use with @@ -992,6 +1081,7 @@ export { mergePresetNamesIntoPolicy, removePresetFromPolicy, applyPreset, + applyPresets, applyPresetContent, loadPresetFromFile, removePreset, diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 8d684da7c3c..1634e4dd644 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -989,7 +989,12 @@ exit 0 path.join(fakeBin, "docker"), `#!/usr/bin/env bash if [ "$1" = "info" ]; then - exit 1 + # Let the installer's early ensure_docker gate pass, then simulate Docker + # becoming unavailable for the shared host preflight after the CLI is linked. + if [ -x "$NPM_PREFIX/bin/nemoclaw" ]; then + exit 1 + fi + exit 0 fi exit 0 `, diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index a187a916f90..9e3aec75873 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -95,6 +95,13 @@ policies.applyPreset = (_name, preset) => { // and false on recoverable errors (unknown preset, malformed YAML, etc). return true; }; +policies.applyPresets = (_name, presets) => { + for (const preset of presets) { + appliedCalls.push(preset); + if (!appliedState.includes(preset)) appliedState.push(preset); + } + return true; +}; policies.removePreset = (_name, preset) => { removedCalls.push(preset); appliedState = appliedState.filter((p) => p !== preset); diff --git a/test/policies.test.ts b/test/policies.test.ts index 7a136f6ced9..81a89f2db21 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -282,6 +282,82 @@ describe("policies", () => { }); }); + describe("applyPresets", () => { + it("merges built-in presets and submits one policy update", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-batch-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const callsPath = path.join(tmpDir, "calls.log"); + const policyOut = path.join(tmpDir, "policy.yaml"); + const script = String.raw` +const fs = require("node:fs"); +const registry = require(${REGISTRY_PATH}); +const policies = require(${POLICIES_PATH}); +registry.registerSandbox({ name: "test-sandbox", policies: [] }); +const result = policies.applyPresets("test-sandbox", ["npm", "pypi"]); +process.stdout.write("\n__RESULT__" + JSON.stringify({ + result, + calls: fs.readFileSync(process.env.CALLS_PATH, "utf-8").trim().split("\n").filter(Boolean), + policy: fs.readFileSync(process.env.POLICY_OUT, "utf-8"), + registry: registry.getSandbox("test-sandbox"), +})); +`; + fs.writeFileSync( + fakeOpenshell, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} +if [ "$1 $2" = "policy get" ]; then + printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' + exit 0 +fi +if [ "$1 $2" = "policy set" ]; then + policy_file="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--policy" ]; then + policy_file="$2" + break + fi + shift + done + cp "$policy_file" ${JSON.stringify(policyOut)} + printf 'Policy version 2 submitted\nPolicy version 2 loaded\n' + exit 0 +fi +exit 1 +`, + { mode: 0o755 }, + ); + + try { + const result = spawnSync(process.execPath, ["-e", script], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_OPENSHELL_BIN: fakeOpenshell, + CALLS_PATH: callsPath, + POLICY_OUT: policyOut, + }, + }); + + expect(result.status).toBe(0); + const marker = "__RESULT__"; + const markerIndex = result.stdout.indexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + const payload = JSON.parse(result.stdout.slice(markerIndex + marker.length)); + expect(payload.result).toBe(true); + expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength(1); + expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength(1); + expect(payload.policy).toContain("npm_yarn:"); + expect(payload.policy).toContain("pypi:"); + expect(payload.registry.policies).toEqual(["npm", "pypi"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); + describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/test/policy-preset-sync.test.ts b/test/policy-preset-sync.test.ts new file mode 100644 index 00000000000..23680cb3c31 --- /dev/null +++ b/test/policy-preset-sync.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); + +function runScript(scriptBody: string): SpawnSyncReturns { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-sync-")); + const scriptPath = path.join(tmpDir, "script.js"); + fs.writeFileSync(scriptPath, scriptBody); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + }, + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +describe("policy preset sync", () => { + it("batches only all-built-in additions and preserves mixed preset order", () => { + const policiesPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "policy", "index.js")); + const syncPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "policy-preset-sync.js"), + ); + const script = String.raw` +const policies = require(${policiesPath}); +const calls = []; +policies.listPresets = () => [{ name: "npm" }, { name: "pypi" }]; +policies.applyPreset = (_sandbox, name) => { calls.push("single:" + name); return true; }; +policies.applyPresets = (_sandbox, names) => { calls.push("batch:" + names.join(",")); return true; }; +policies.removePreset = (_sandbox, name) => { calls.push("remove:" + name); return true; }; + +const { syncPresetSelection } = require(${syncPath}); +syncPresetSelection("test-sb", [], ["npm", "pypi"]); +syncPresetSelection("test-sb", [], ["npm", "custom", "pypi"]); +process.stdout.write(JSON.stringify(calls) + "\n"); +`; + + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout.trim()), [ + "batch:npm,pypi", + "single:npm", + "single:custom", + "single:pypi", + ]); + }); +}); diff --git a/test/policy-tiers-onboard.test.ts b/test/policy-tiers-onboard.test.ts index 84576cc5421..45fb4905924 100644 --- a/test/policy-tiers-onboard.test.ts +++ b/test/policy-tiers-onboard.test.ts @@ -262,6 +262,7 @@ console.log = (...args) => lines.push(args.join(" ")); String.raw` const policies = require(${policiesPath}); policies.applyPreset = () => {}; +policies.applyPresets = () => true; policies.getAppliedPresets = () => []; const lines = []; @@ -307,6 +308,7 @@ console.log = (...args) => lines.push(args.join(" ")); const policies = require(${policiesPath}); const appliedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.getAppliedPresets = () => []; console.log = () => {}; @@ -349,6 +351,7 @@ const policies = require(${policiesPath}); const appliedCalls = []; const removedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; policies.getAppliedPresets = () => ["brave", "npm"]; @@ -395,6 +398,7 @@ const policies = require(${policiesPath}); const appliedCalls = []; const removedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; policies.getAppliedPresets = () => ["brave"]; @@ -435,6 +439,7 @@ const policies = require(${policiesPath}); const appliedCalls = []; const removedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; policies.getAppliedPresets = () => ["brave"]; @@ -475,6 +480,7 @@ const policies = require(${policiesPath}); const appliedCalls = []; const removedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; policies.getAppliedPresets = () => ["brave"]; policies.listCustomPresets = () => [{ name: "brave", description: "custom preset" }]; @@ -516,6 +522,7 @@ const policies = require(${policiesPath}); const appliedCalls = []; const removedCalls = []; policies.applyPreset = (_sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (_sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.removePreset = (_sandbox, name) => { removedCalls.push(name); return true; }; policies.getAppliedPresets = () => ["brave"]; policies.listCustomPresets = () => [{ name: "brave", description: "custom preset" }]; @@ -564,6 +571,7 @@ console.log = () => {}; const policies = require(${policiesPath}); const appliedCalls = []; policies.applyPreset = (sandbox, name) => { appliedCalls.push(name); return true; }; +policies.applyPresets = (sandbox, names) => { for (const name of names) appliedCalls.push(name); return true; }; policies.getAppliedPresets = () => []; // Silence onboard's note()/console.log so stdout is pure JSON. @@ -606,6 +614,7 @@ console.log = () => {}; String.raw` const policies = require(${policiesPath}); policies.applyPreset = () => true; +policies.applyPresets = () => true; policies.getAppliedPresets = () => []; console.log = () => {};