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
48 changes: 3 additions & 45 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<string, string>|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<string, string> | 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;
Expand Down
80 changes: 80 additions & 0 deletions src/lib/onboard/policy-preset-sync.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
}

for (const name of newlySelected) {
const options = { access: accessByName[name] };
waitForPolicyMutation(`applyPreset(${name})`, () =>
policies.applyPreset(sandboxName, name, options),
);
}
}

export { syncPresetSelection, waitForPolicyMutation };
90 changes: 90 additions & 0 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -992,6 +1081,7 @@ export {
mergePresetNamesIntoPolicy,
removePresetFromPolicy,
applyPreset,
applyPresets,
applyPresetContent,
loadPresetFromFile,
removePreset,
Expand Down
7 changes: 6 additions & 1 deletion test/install-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
Expand Down
7 changes: 7 additions & 0 deletions test/onboard-preset-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
Loading
Loading