Skip to content

Commit 23b0538

Browse files
deepujainericksoa
andauthored
fix(cli): support non-interactive policy preset updates (Fixes #2067) (#2070)
## Summary `nemoclaw <sandbox> policy-add` and `policy-remove` were prompt-only flows, which made them unusable from headless shells, CI, and `NEMOCLAW_NON_INTERACTIVE=1` paths. This change adds an explicit preset-argument form so operators can script built-in preset changes without `expect` or an interactive TTY. ## Changes - **`src/nemoclaw.ts`** - accept `policy-add <preset>` and `policy-remove <preset>`; validate the preset name; support `--yes` and `NEMOCLAW_NON_INTERACTIVE=1` for the explicit-preset path; fail fast with a clear usage message when non-interactive mode is requested without a preset name; update CLI help text to show the new form. - **`test/policies.test.ts`** - add regression coverage for scripted add/remove with `--yes`, for the `NEMOCLAW_NON_INTERACTIVE=1` path, and for the new fast-fail error when non-interactive mode is used without a preset name. ## Testing - `npm run build:cli` - `npm run typecheck:cli` - Manual scripted repro against the built CLI: - `policy-add pypi --yes` applies without prompting - `NEMOCLAW_NON_INTERACTIVE=1 policy-remove pypi` removes without prompting - `npm test` - still fails in this environment with unrelated existing suite issues in `src/lib/sandbox-version.test.ts`, `src/lib/preflight.test.ts`, `test/install-preflight.test.ts`, and `test/legacy-path-guard.test.ts` Fixes #2067 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Non-interactive/scripted mode for policy commands via `--yes`/`--force` flags or an env var, allowing automated apply/remove operations. * Ability to specify a preset name as a positional argument for direct policy selection and validation, with clear usage guidance when missing. * **Tests** * Added tests covering scripted non-interactive flows, successful apply/remove with presets, and failure behavior when a preset is required. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Signed-off-by: Deepak Jain <deepujain@gmail.com> Co-authored-by: Aaron Erickson 🦞 <aerickson@nvidia.com>
1 parent 7732f5b commit 23b0538

2 files changed

Lines changed: 130 additions & 10 deletions

File tree

src/nemoclaw.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,10 +1519,34 @@ function sandboxLogs(sandboxName, follow) {
15191519

15201520
async function sandboxPolicyAdd(sandboxName, args = []) {
15211521
const dryRun = args.includes("--dry-run");
1522+
const skipConfirm =
1523+
args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
15221524
const allPresets = policies.listPresets();
15231525
const applied = policies.getAppliedPresets(sandboxName);
15241526

1525-
const answer = await policies.selectFromList(allPresets, { applied });
1527+
const presetArg = args.find((arg) => !arg.startsWith("-"));
1528+
let answer = null;
1529+
if (presetArg) {
1530+
const normalized = presetArg.trim().toLowerCase();
1531+
const preset = allPresets.find((item) => item.name === normalized);
1532+
if (!preset) {
1533+
console.error(` Unknown preset '${presetArg}'.`);
1534+
console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`);
1535+
process.exit(1);
1536+
}
1537+
if (applied.includes(preset.name)) {
1538+
console.error(` Preset '${preset.name}' is already applied.`);
1539+
process.exit(1);
1540+
}
1541+
answer = preset.name;
1542+
} else {
1543+
if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
1544+
console.error(" Non-interactive mode requires a preset name.");
1545+
console.error(" Usage: nemoclaw <sandbox> policy-add <preset> [--yes] [--dry-run]");
1546+
process.exit(1);
1547+
}
1548+
answer = await policies.selectFromList(allPresets, { applied });
1549+
}
15261550
if (!answer) return;
15271551

15281552
const presetContent = policies.loadPreset(answer);
@@ -1538,8 +1562,10 @@ async function sandboxPolicyAdd(sandboxName, args = []) {
15381562
return;
15391563
}
15401564

1541-
const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
1542-
if (confirm.toLowerCase() === "n") return;
1565+
if (!skipConfirm) {
1566+
const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
1567+
if (confirm.toLowerCase() === "n") return;
1568+
}
15431569

15441570
policies.applyPreset(sandboxName, answer);
15451571
}
@@ -1741,10 +1767,34 @@ async function sandboxSkillInstall(sandboxName, args = []) {
17411767

17421768
async function sandboxPolicyRemove(sandboxName, args = []) {
17431769
const dryRun = args.includes("--dry-run");
1770+
const skipConfirm =
1771+
args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1";
17441772
const allPresets = policies.listPresets();
17451773
const applied = policies.getAppliedPresets(sandboxName);
17461774

1747-
const answer = await policies.selectForRemoval(allPresets, { applied });
1775+
const presetArg = args.find((arg) => !arg.startsWith("-"));
1776+
let answer = null;
1777+
if (presetArg) {
1778+
const normalized = presetArg.trim().toLowerCase();
1779+
const preset = allPresets.find((item) => item.name === normalized);
1780+
if (!preset) {
1781+
console.error(` Unknown preset '${presetArg}'.`);
1782+
console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`);
1783+
process.exit(1);
1784+
}
1785+
if (!applied.includes(preset.name)) {
1786+
console.error(` Preset '${preset.name}' is not applied.`);
1787+
process.exit(1);
1788+
}
1789+
answer = preset.name;
1790+
} else {
1791+
if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") {
1792+
console.error(" Non-interactive mode requires a preset name.");
1793+
console.error(" Usage: nemoclaw <sandbox> policy-remove <preset> [--yes] [--dry-run]");
1794+
process.exit(1);
1795+
}
1796+
answer = await policies.selectForRemoval(allPresets, { applied });
1797+
}
17481798
if (!answer) return;
17491799

17501800
const presetContent = policies.loadPreset(answer);
@@ -1760,8 +1810,10 @@ async function sandboxPolicyRemove(sandboxName, args = []) {
17601810
return;
17611811
}
17621812

1763-
const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `);
1764-
if (confirm.toLowerCase() === "n") return;
1813+
if (!skipConfirm) {
1814+
const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `);
1815+
if (confirm.toLowerCase() === "n") return;
1816+
}
17651817

17661818
if (!policies.removePreset(sandboxName, answer)) {
17671819
process.exit(1);
@@ -2457,8 +2509,8 @@ function help() {
24572509
nemoclaw <name> skill install <path> Deploy a skill directory to the sandbox
24582510
24592511
${G}Policy Presets:${R}
2460-
nemoclaw <name> policy-add Add a network or filesystem policy preset ${D}(--dry-run to preview)${R}
2461-
nemoclaw <name> policy-remove Remove an applied policy preset ${D}(--dry-run to preview)${R}
2512+
nemoclaw <name> policy-add [preset] Add a network or filesystem policy preset ${D}(--yes, --dry-run)${R}
2513+
nemoclaw <name> policy-remove [preset] Remove an applied policy preset ${D}(--yes, --dry-run)${R}
24622514
nemoclaw <name> policy-list List presets ${D}(● = applied)${R}
24632515
24642516
${G}Compatibility Commands:${R}

test/policies.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const SELECT_FROM_LIST_ITEMS = [
2020
{ name: "pypi", description: "Python Package Index (PyPI) access" },
2121
];
2222

23-
function runPolicyAdd(confirmAnswer, extraArgs = []) {
23+
function runPolicyAdd(confirmAnswer, extraArgs = [], envOverrides = {}) {
2424
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-"));
2525
const scriptPath = path.join(tmpDir, "policy-add-check.js");
2626
const script = String.raw`
@@ -60,6 +60,7 @@ setImmediate(() => {
6060
env: {
6161
...process.env,
6262
HOME: tmpDir,
63+
...envOverrides,
6364
},
6465
});
6566
}
@@ -889,10 +890,43 @@ selectForRemoval(items, options)
889890
expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/);
890891
expect(result.stdout).toMatch(/--dry-run: no changes applied\./);
891892
});
893+
894+
it("accepts a preset name with --yes for headless use", () => {
895+
const result = runPolicyAdd("n", ["pypi", "--yes"]);
896+
897+
expect(result.status).toBe(0);
898+
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
899+
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
900+
expect(calls).toContainEqual({
901+
type: "apply",
902+
sandboxName: "test-sandbox",
903+
presetName: "pypi",
904+
});
905+
});
906+
907+
it("honors non-interactive mode when a preset name is provided", () => {
908+
const result = runPolicyAdd("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" });
909+
910+
expect(result.status).toBe(0);
911+
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
912+
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
913+
expect(calls).toContainEqual({
914+
type: "apply",
915+
sandboxName: "test-sandbox",
916+
presetName: "pypi",
917+
});
918+
});
919+
920+
it("fails fast in non-interactive mode without a preset name", () => {
921+
const result = runPolicyAdd("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" });
922+
923+
expect(result.status).not.toBe(0);
924+
expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/);
925+
});
892926
});
893927

894928
describe("policy-remove confirmation", () => {
895-
function runPolicyRemove(confirmAnswer, extraArgs = []) {
929+
function runPolicyRemove(confirmAnswer, extraArgs = [], envOverrides = {}) {
896930
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-"));
897931
const scriptPath = path.join(tmpDir, "policy-remove-check.js");
898932
const script = String.raw`
@@ -933,6 +967,7 @@ setImmediate(() => {
933967
env: {
934968
...process.env,
935969
HOME: tmpDir,
970+
...envOverrides,
936971
},
937972
});
938973
}
@@ -975,5 +1010,38 @@ setImmediate(() => {
9751010
expect(result.stdout).toMatch(/Endpoints that would be removed: pypi\.org/);
9761011
expect(result.stdout).toMatch(/--dry-run: no changes applied\./);
9771012
});
1013+
1014+
it("accepts a preset name with --yes for scripted removal", () => {
1015+
const result = runPolicyRemove("n", ["pypi", "--yes"]);
1016+
1017+
expect(result.status).toBe(0);
1018+
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
1019+
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
1020+
expect(calls).toContainEqual({
1021+
type: "remove",
1022+
sandboxName: "test-sandbox",
1023+
presetName: "pypi",
1024+
});
1025+
});
1026+
1027+
it("honors non-interactive mode when removing an explicit preset", () => {
1028+
const result = runPolicyRemove("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" });
1029+
1030+
expect(result.status).toBe(0);
1031+
const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim());
1032+
expect(calls.some((call) => call.type === "prompt")).toBeFalsy();
1033+
expect(calls).toContainEqual({
1034+
type: "remove",
1035+
sandboxName: "test-sandbox",
1036+
presetName: "pypi",
1037+
});
1038+
});
1039+
1040+
it("fails fast in non-interactive mode without a preset name", () => {
1041+
const result = runPolicyRemove("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" });
1042+
1043+
expect(result.status).not.toBe(0);
1044+
expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/);
1045+
});
9781046
});
9791047
});

0 commit comments

Comments
 (0)