Skip to content

Commit c3b6779

Browse files
cr7258cjagwani
authored andcommitted
feat(policy): allow selecting policy presets by number (NVIDIA#1195)
<!-- markdownlint-disable MD041 --> <!-- 1-3 sentences: what this PR does and why. --> Allow `nemoclaw <sandbox> policy-add` to be selected by number instead of typing preset names. ```bash root@se7en:/tmp/NemoClaw# node bin/nemoclaw.js seven-demo policy-add Available presets: 1) ○ discord — Discord API, gateway, and CDN access 2) ○ docker — Docker Hub and NVIDIA container registry access 3) ○ huggingface — Hugging Face Hub, LFS, and Inference API access 4) ○ jira — Jira and Atlassian Cloud access 5) ● npm — npm and Yarn registry access 6) ○ outlook — Microsoft Outlook and Graph API access 7) ○ pypi — Python Package Index (PyPI) access 8) ○ slack — Slack API, Socket Mode, and webhooks access 9) ○ telegram — Telegram Bot API access ● applied, ○ not applied Choose preset [1]: 7 Apply 'pypi' to sandbox 'seven-demo'? [Y/n]: y ✓ Policy version 3 submitted (hash: 462a3f55b4da) ✓ Policy version 3 loaded (active version: 3) Applied preset: pypi ``` <!-- Link to the issue: Fixes #NNN or Closes #NNN. Remove this section if none. --> Fixes NVIDIA#1164 <!-- Bullet list of key changes. --> <!-- Check the one that applies. --> - [x] Code change for a new feature, bug fix, or refactor. - [ ] Code change with doc updates. - [ ] Doc only. Prose changes without code sample modifications. - [ ] Doc only. Includes code sample changes. <!-- What testing was done? --> - [x] `npx prek run --all-files` passes (or equivalently `make check`). - [x] `npm test` passes. - [ ] `make docs` builds without warnings. (for doc-only changes) - [x] I have read and followed the [contributing guide](https://github.com/NVIDIA/NemoClaw/blob/main/CONTRIBUTING.md). - [x] I have read and followed the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md). (for doc-only changes) <!-- Skip if this is a doc-only PR. --> - [x] Formatters applied — `npx prek run --all-files` auto-fixes formatting (or `make format` for targeted runs). - [x] Tests added or updated for new or changed behavior. - [x] No secrets, API keys, or credentials committed. - [ ] Doc pages updated for any user-facing behavior changes (new commands, changed defaults, new features, bug fixes that contradict existing docs). <!-- Skip if this PR has no doc changes. --> - [ ] Follows the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md). Try running the `update-docs` agent skill to draft changes while complying with the style guide. For example, prompt your agent with "`/update-docs` catch up the docs for the new changes I made in this PR." - [ ] New pages include SPDX license header and frontmatter, if creating a new page. - [ ] Cross-references and links verified. --- <!-- DCO sign-off (required by CI). Replace with your real name and email. --> Signed-off-by: Seven Cheng <sevenc@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> * **New Features** * Interactive preset selection menu with visual status (● applied, ○ not applied) * Empty input selects the default (first non-applied) when available * Policy-add CLI now invokes the interactive selector and asks for confirmation before applying * **Validation** * Rejects invalid, non-numeric, out-of-range, or already-applied selections and prints feedback * **Tests** * Expanded tests covering selection behavior, defaulting, rejection cases, list rendering, and CLI confirmation flow <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a7ac8cc commit c3b6779

3 files changed

Lines changed: 228 additions & 9 deletions

File tree

bin/lib/policies.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
const fs = require("fs");
77
const path = require("path");
88
const os = require("os");
9+
const readline = require("readline");
910
const YAML = require("yaml");
1011
const { ROOT, run, runCapture, shellQuote } = require("./runner");
1112
const registry = require("./registry");
@@ -293,6 +294,53 @@ function getAppliedPresets(sandboxName) {
293294
return sandbox ? sandbox.policies || [] : [];
294295
}
295296

297+
function selectFromList(items, { applied = [] } = {}) {
298+
return new Promise((resolve) => {
299+
process.stderr.write("\n Available presets:\n");
300+
items.forEach((item, i) => {
301+
const marker = applied.includes(item.name) ? "●" : "○";
302+
const description = item.description ? ` — ${item.description}` : "";
303+
process.stderr.write(` ${i + 1}) ${marker} ${item.name}${description}\n`);
304+
});
305+
process.stderr.write("\n ● applied, ○ not applied\n\n");
306+
const defaultIdx = items.findIndex((item) => !applied.includes(item.name));
307+
const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null;
308+
const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: ";
309+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
310+
rl.question(question, (answer) => {
311+
rl.close();
312+
if (!process.stdin.isTTY) {
313+
if (typeof process.stdin.pause === "function") process.stdin.pause();
314+
if (typeof process.stdin.unref === "function") process.stdin.unref();
315+
}
316+
const trimmed = answer.trim();
317+
const effectiveInput = trimmed || (defaultNum ? String(defaultNum) : "");
318+
if (!effectiveInput) {
319+
resolve(null);
320+
return;
321+
}
322+
if (!/^\d+$/.test(effectiveInput)) {
323+
process.stderr.write("\n Invalid preset number.\n");
324+
resolve(null);
325+
return;
326+
}
327+
const num = Number(effectiveInput);
328+
const item = items[num - 1];
329+
if (!item) {
330+
process.stderr.write("\n Invalid preset number.\n");
331+
resolve(null);
332+
return;
333+
}
334+
if (applied.includes(item.name)) {
335+
process.stderr.write(`\n Preset '${item.name}' is already applied.\n`);
336+
resolve(null);
337+
return;
338+
}
339+
resolve(item.name);
340+
});
341+
});
342+
}
343+
296344
module.exports = {
297345
PRESETS_DIR,
298346
listPresets,
@@ -305,4 +353,5 @@ module.exports = {
305353
mergePresetIntoPolicy,
306354
applyPreset,
307355
getAppliedPresets,
356+
selectFromList,
308357
};

bin/nemoclaw.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,16 +1109,8 @@ async function sandboxPolicyAdd(sandboxName) {
11091109
const allPresets = policies.listPresets();
11101110
const applied = policies.getAppliedPresets(sandboxName);
11111111

1112-
console.log("");
1113-
console.log(" Available presets:");
1114-
allPresets.forEach((p) => {
1115-
const marker = applied.includes(p.name) ? "●" : "○";
1116-
console.log(` ${marker} ${p.name}${p.description}`);
1117-
});
1118-
console.log("");
1119-
11201112
const { prompt: askPrompt } = require("./lib/credentials");
1121-
const answer = await askPrompt(" Preset to apply: ");
1113+
const answer = await policies.selectFromList(allPresets, { applied });
11221114
if (!answer) return;
11231115

11241116
const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);

test/policies.test.js

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,95 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import assert from "node:assert/strict";
5+
import fs from "node:fs";
6+
import os from "node:os";
7+
import path from "node:path";
58
import { describe, it, expect, vi } from "vitest";
9+
import { spawnSync } from "node:child_process";
610
import policies from "../bin/lib/policies";
711

12+
const REPO_ROOT = path.join(import.meta.dirname, "..");
13+
const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "nemoclaw.js"));
14+
const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "credentials.js"));
15+
const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "policies.js"));
16+
const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "registry.js"));
17+
const SELECT_FROM_LIST_ITEMS = [
18+
{ name: "npm", description: "npm and Yarn registry access" },
19+
{ name: "pypi", description: "Python Package Index (PyPI) access" },
20+
];
21+
22+
function runPolicyAdd(confirmAnswer) {
23+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-"));
24+
const scriptPath = path.join(tmpDir, "policy-add-check.js");
25+
const script = String.raw`
26+
const registry = require(${REGISTRY_PATH});
27+
const policies = require(${POLICIES_PATH});
28+
const credentials = require(${CREDENTIALS_PATH});
29+
const calls = [];
30+
policies.selectFromList = async () => "pypi";
31+
credentials.prompt = async (message) => {
32+
calls.push({ type: "prompt", message });
33+
return ${JSON.stringify(confirmAnswer)};
34+
};
35+
registry.getSandbox = (name) => (name === "test-sandbox" ? { name } : null);
36+
registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] });
37+
policies.listPresets = () => [
38+
{ name: "npm", description: "npm and Yarn registry access" },
39+
{ name: "pypi", description: "Python Package Index (PyPI) access" },
40+
];
41+
policies.getAppliedPresets = () => [];
42+
policies.applyPreset = (sandboxName, presetName) => {
43+
calls.push({ type: "apply", sandboxName, presetName });
44+
};
45+
process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"];
46+
require(${CLI_PATH});
47+
setImmediate(() => {
48+
process.stdout.write(JSON.stringify(calls));
49+
});
50+
`;
51+
52+
fs.writeFileSync(scriptPath, script);
53+
54+
return spawnSync(process.execPath, [scriptPath], {
55+
cwd: REPO_ROOT,
56+
encoding: "utf-8",
57+
env: {
58+
...process.env,
59+
HOME: tmpDir,
60+
},
61+
});
62+
}
63+
64+
function runSelectFromList(input, { applied = [] } = {}) {
65+
const script = String.raw`
66+
const { selectFromList } = require(${POLICIES_PATH});
67+
const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS);
68+
const options = JSON.parse(process.env.NEMOCLAW_TEST_OPTIONS || "{}");
69+
70+
selectFromList(items, options)
71+
.then((value) => {
72+
process.stdout.write(String(value) + "\n");
73+
})
74+
.catch((error) => {
75+
const message = error && error.message ? error.message : String(error);
76+
process.stderr.write(message);
77+
process.exit(1);
78+
});
79+
`;
80+
81+
return spawnSync(process.execPath, ["-e", script], {
82+
cwd: REPO_ROOT,
83+
encoding: "utf-8",
84+
timeout: 5000,
85+
input,
86+
env: {
87+
...process.env,
88+
NEMOCLAW_TEST_ITEMS: JSON.stringify(SELECT_FROM_LIST_ITEMS),
89+
NEMOCLAW_TEST_OPTIONS: JSON.stringify({ applied }),
90+
},
91+
});
92+
}
93+
894
describe("policies", () => {
995
describe("listPresets", () => {
1096
it("returns all 9 presets", () => {
@@ -502,4 +588,96 @@ describe("policies", () => {
502588
}
503589
});
504590
});
591+
592+
describe("selectFromList", () => {
593+
it("returns preset name by number from stdin input", () => {
594+
const result = runSelectFromList("1\n");
595+
596+
expect(result.status).toBe(0);
597+
expect(result.stdout.trim()).toBe("npm");
598+
expect(result.stderr).toContain("Choose preset [1]:");
599+
});
600+
601+
it("uses the first preset as the default when input is empty", () => {
602+
const result = runSelectFromList("\n");
603+
604+
expect(result.status).toBe(0);
605+
expect(result.stderr).toContain("Choose preset [1]:");
606+
expect(result.stdout.trim()).toBe("npm");
607+
});
608+
609+
it("defaults to the first not-applied preset", () => {
610+
const result = runSelectFromList("\n", { applied: ["npm"] });
611+
612+
expect(result.status).toBe(0);
613+
expect(result.stderr).toContain("Choose preset [2]:");
614+
expect(result.stdout.trim()).toBe("pypi");
615+
});
616+
617+
it("rejects selecting an already-applied preset", () => {
618+
const result = runSelectFromList("1\n", { applied: ["npm"] });
619+
620+
expect(result.status).toBe(0);
621+
expect(result.stderr).toContain("Preset 'npm' is already applied.");
622+
expect(result.stdout.trim()).toBe("null");
623+
});
624+
625+
it("rejects out-of-range preset number", () => {
626+
const result = runSelectFromList("99\n");
627+
628+
expect(result.status).toBe(0);
629+
expect(result.stderr).toContain("Invalid preset number.");
630+
expect(result.stdout.trim()).toBe("null");
631+
});
632+
633+
it("rejects non-numeric preset input", () => {
634+
const result = runSelectFromList("npm\n");
635+
636+
expect(result.status).toBe(0);
637+
expect(result.stderr).toContain("Invalid preset number.");
638+
expect(result.stdout.trim()).toBe("null");
639+
});
640+
641+
it("prints numbered list with applied markers, legend, and default prompt", () => {
642+
const result = runSelectFromList("2\n", { applied: ["npm"] });
643+
644+
expect(result.status).toBe(0);
645+
expect(result.stderr).toMatch(/Available presets:/);
646+
expect(result.stderr).toMatch(/1\) npm npm and Yarn registry access/);
647+
expect(result.stderr).toMatch(/2\) pypi Python Package Index \(PyPI\) access/);
648+
expect(result.stderr).toMatch(/ applied, not applied/);
649+
expect(result.stderr).toMatch(/Choose preset \[2\]:/);
650+
expect(result.stdout.trim()).toBe("pypi");
651+
});
652+
});
653+
654+
describe("policy-add confirmation", () => {
655+
it("prompts for confirmation before applying a preset", () => {
656+
const result = runPolicyAdd("y");
657+
658+
expect(result.status).toBe(0);
659+
const calls = JSON.parse(result.stdout.trim());
660+
expect(calls).toContainEqual({
661+
type: "prompt",
662+
message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ",
663+
});
664+
expect(calls).toContainEqual({
665+
type: "apply",
666+
sandboxName: "test-sandbox",
667+
presetName: "pypi",
668+
});
669+
});
670+
671+
it("skips applying the preset when confirmation is declined", () => {
672+
const result = runPolicyAdd("n");
673+
674+
expect(result.status).toBe(0);
675+
const calls = JSON.parse(result.stdout.trim());
676+
expect(calls).toContainEqual({
677+
type: "prompt",
678+
message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ",
679+
});
680+
expect(calls.some((call) => call.type === "apply")).toBeFalsy();
681+
});
682+
});
505683
});

0 commit comments

Comments
 (0)