diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 9b3cd89a715..e442429141b 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1459,10 +1459,34 @@ function sandboxLogs(sandboxName, follow) { async function sandboxPolicyAdd(sandboxName, args = []) { const dryRun = args.includes("--dry-run"); + const skipConfirm = + args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - const answer = await policies.selectFromList(allPresets, { applied }); + const presetArg = args.find((arg) => !arg.startsWith("-")); + let answer = null; + if (presetArg) { + const normalized = presetArg.trim().toLowerCase(); + const preset = allPresets.find((item) => item.name === normalized); + if (!preset) { + console.error(` Unknown preset '${presetArg}'.`); + console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`); + process.exit(1); + } + if (applied.includes(preset.name)) { + console.error(` Preset '${preset.name}' is already applied.`); + process.exit(1); + } + answer = preset.name; + } else { + if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") { + console.error(" Non-interactive mode requires a preset name."); + console.error(" Usage: nemoclaw policy-add [--yes] [--dry-run]"); + process.exit(1); + } + answer = await policies.selectFromList(allPresets, { applied }); + } if (!answer) return; const presetContent = policies.loadPreset(answer); @@ -1478,8 +1502,10 @@ async function sandboxPolicyAdd(sandboxName, args = []) { return; } - const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); - if (confirm.toLowerCase() === "n") return; + if (!skipConfirm) { + const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `); + if (confirm.toLowerCase() === "n") return; + } policies.applyPreset(sandboxName, answer); } @@ -1681,10 +1707,34 @@ async function sandboxSkillInstall(sandboxName, args = []) { async function sandboxPolicyRemove(sandboxName, args = []) { const dryRun = args.includes("--dry-run"); + const skipConfirm = + args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - const answer = await policies.selectForRemoval(allPresets, { applied }); + const presetArg = args.find((arg) => !arg.startsWith("-")); + let answer = null; + if (presetArg) { + const normalized = presetArg.trim().toLowerCase(); + const preset = allPresets.find((item) => item.name === normalized); + if (!preset) { + console.error(` Unknown preset '${presetArg}'.`); + console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`); + process.exit(1); + } + if (!applied.includes(preset.name)) { + console.error(` Preset '${preset.name}' is not applied.`); + process.exit(1); + } + answer = preset.name; + } else { + if (process.env.NEMOCLAW_NON_INTERACTIVE === "1") { + console.error(" Non-interactive mode requires a preset name."); + console.error(" Usage: nemoclaw policy-remove [--yes] [--dry-run]"); + process.exit(1); + } + answer = await policies.selectForRemoval(allPresets, { applied }); + } if (!answer) return; const presetContent = policies.loadPreset(answer); @@ -1700,8 +1750,10 @@ async function sandboxPolicyRemove(sandboxName, args = []) { return; } - const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `); - if (confirm.toLowerCase() === "n") return; + if (!skipConfirm) { + const confirm = await askPrompt(` Remove '${answer}' from sandbox '${sandboxName}'? [Y/n]: `); + if (confirm.toLowerCase() === "n") return; + } if (!policies.removePreset(sandboxName, answer)) { process.exit(1); @@ -2356,8 +2408,8 @@ function help() { nemoclaw skill install Deploy a skill directory to the sandbox ${G}Policy Presets:${R} - nemoclaw policy-add Add a network or filesystem policy preset ${D}(--dry-run to preview)${R} - nemoclaw policy-remove Remove an applied policy preset ${D}(--dry-run to preview)${R} + nemoclaw policy-add [preset] Add a network or filesystem policy preset ${D}(--yes, --dry-run)${R} + nemoclaw policy-remove [preset] Remove an applied policy preset ${D}(--yes, --dry-run)${R} nemoclaw policy-list List presets ${D}(● = applied)${R} ${G}Compatibility Commands:${R} diff --git a/test/policies.test.ts b/test/policies.test.ts index fac2571f5af..f95ae34f1ef 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -20,7 +20,7 @@ const SELECT_FROM_LIST_ITEMS = [ { name: "pypi", description: "Python Package Index (PyPI) access" }, ]; -function runPolicyAdd(confirmAnswer, extraArgs = []) { +function runPolicyAdd(confirmAnswer, extraArgs = [], envOverrides = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); const scriptPath = path.join(tmpDir, "policy-add-check.js"); const script = String.raw` @@ -60,6 +60,7 @@ setImmediate(() => { env: { ...process.env, HOME: tmpDir, + ...envOverrides, }, }); } @@ -889,10 +890,43 @@ selectForRemoval(items, options) expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/); expect(result.stdout).toMatch(/--dry-run: no changes applied\./); }); + + it("accepts a preset name with --yes for headless use", () => { + const result = runPolicyAdd("n", ["pypi", "--yes"]); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("honors non-interactive mode when a preset name is provided", () => { + const result = runPolicyAdd("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" }); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("fails fast in non-interactive mode without a preset name", () => { + const result = runPolicyAdd("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/); + }); }); describe("policy-remove confirmation", () => { - function runPolicyRemove(confirmAnswer, extraArgs = []) { + function runPolicyRemove(confirmAnswer, extraArgs = [], envOverrides = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-")); const scriptPath = path.join(tmpDir, "policy-remove-check.js"); const script = String.raw` @@ -933,6 +967,7 @@ setImmediate(() => { env: { ...process.env, HOME: tmpDir, + ...envOverrides, }, }); } @@ -975,5 +1010,38 @@ setImmediate(() => { expect(result.stdout).toMatch(/Endpoints that would be removed: pypi\.org/); expect(result.stdout).toMatch(/--dry-run: no changes applied\./); }); + + it("accepts a preset name with --yes for scripted removal", () => { + const result = runPolicyRemove("n", ["pypi", "--yes"]); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "remove", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("honors non-interactive mode when removing an explicit preset", () => { + const result = runPolicyRemove("n", ["pypi"], { NEMOCLAW_NON_INTERACTIVE: "1" }); + + expect(result.status).toBe(0); + const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); + expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "remove", + sandboxName: "test-sandbox", + presetName: "pypi", + }); + }); + + it("fails fast in non-interactive mode without a preset name", () => { + const result = runPolicyRemove("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/); + }); }); });