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
49 changes: 49 additions & 0 deletions bin/lib/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const readline = require("readline");
const YAML = require("yaml");
const { ROOT, run, runCapture, shellQuote } = require("./runner");
const registry = require("./registry");
Expand Down Expand Up @@ -288,6 +289,53 @@ function getAppliedPresets(sandboxName) {
return sandbox ? sandbox.policies || [] : [];
}

function selectFromList(items, { applied = [] } = {}) {
return new Promise((resolve) => {
process.stderr.write("\n Available presets:\n");
items.forEach((item, i) => {
const marker = applied.includes(item.name) ? "●" : "○";
const description = item.description ? ` — ${item.description}` : "";
process.stderr.write(` ${i + 1}) ${marker} ${item.name}${description}\n`);
});
process.stderr.write("\n ● applied, ○ not applied\n\n");
const defaultIdx = items.findIndex((item) => !applied.includes(item.name));
const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null;
const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: ";
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(question, (answer) => {
rl.close();
if (!process.stdin.isTTY) {
if (typeof process.stdin.pause === "function") process.stdin.pause();
if (typeof process.stdin.unref === "function") process.stdin.unref();
}
const trimmed = answer.trim();
const effectiveInput = trimmed || (defaultNum ? String(defaultNum) : "");
if (!effectiveInput) {
resolve(null);
return;
}
if (!/^\d+$/.test(effectiveInput)) {
process.stderr.write("\n Invalid preset number.\n");
resolve(null);
return;
}
const num = Number(effectiveInput);
const item = items[num - 1];
if (!item) {
process.stderr.write("\n Invalid preset number.\n");
resolve(null);
return;
}
if (applied.includes(item.name)) {
process.stderr.write(`\n Preset '${item.name}' is already applied.\n`);
resolve(null);
return;
}
resolve(item.name);
});
});
}

module.exports = {
PRESETS_DIR,
listPresets,
Expand All @@ -300,4 +348,5 @@ module.exports = {
mergePresetIntoPolicy,
applyPreset,
getAppliedPresets,
selectFromList,
};
10 changes: 1 addition & 9 deletions bin/nemoclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -1109,16 +1109,8 @@ async function sandboxPolicyAdd(sandboxName) {
const allPresets = policies.listPresets();
const applied = policies.getAppliedPresets(sandboxName);

console.log("");
console.log(" Available presets:");
allPresets.forEach((p) => {
const marker = applied.includes(p.name) ? "●" : "○";
console.log(` ${marker} ${p.name} — ${p.description}`);
});
console.log("");

const { prompt: askPrompt } = require("./lib/credentials");
const answer = await askPrompt(" Preset to apply: ");
const answer = await policies.selectFromList(allPresets, { applied });
if (!answer) return;

const confirm = await askPrompt(` Apply '${answer}' to sandbox '${sandboxName}'? [Y/n]: `);
Expand Down
178 changes: 178 additions & 0 deletions test/policies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,95 @@
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";
import { spawnSync } from "node:child_process";
import policies from "../bin/lib/policies";

const REPO_ROOT = path.join(import.meta.dirname, "..");
const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "nemoclaw.js"));
const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "credentials.js"));
const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "policies.js"));
const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "bin", "lib", "registry.js"));
const SELECT_FROM_LIST_ITEMS = [
{ name: "npm", description: "npm and Yarn registry access" },
{ name: "pypi", description: "Python Package Index (PyPI) access" },
];

function runPolicyAdd(confirmAnswer) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-"));
const scriptPath = path.join(tmpDir, "policy-add-check.js");
const script = String.raw`
const registry = require(${REGISTRY_PATH});
const policies = require(${POLICIES_PATH});
const credentials = require(${CREDENTIALS_PATH});
const calls = [];
policies.selectFromList = async () => "pypi";
credentials.prompt = async (message) => {
calls.push({ type: "prompt", message });
return ${JSON.stringify(confirmAnswer)};
};
registry.getSandbox = (name) => (name === "test-sandbox" ? { name } : null);
registry.listSandboxes = () => ({ sandboxes: [{ name: "test-sandbox" }] });
policies.listPresets = () => [
{ name: "npm", description: "npm and Yarn registry access" },
{ name: "pypi", description: "Python Package Index (PyPI) access" },
];
policies.getAppliedPresets = () => [];
policies.applyPreset = (sandboxName, presetName) => {
calls.push({ type: "apply", sandboxName, presetName });
};
process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"];
require(${CLI_PATH});
setImmediate(() => {
process.stdout.write(JSON.stringify(calls));
});
`;

fs.writeFileSync(scriptPath, script);

return spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
},
});
}
Comment thread
cr7258 marked this conversation as resolved.

function runSelectFromList(input, { applied = [] } = {}) {
const script = String.raw`
const { selectFromList } = require(${POLICIES_PATH});
const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS);
const options = JSON.parse(process.env.NEMOCLAW_TEST_OPTIONS || "{}");

selectFromList(items, options)
.then((value) => {
process.stdout.write(String(value) + "\n");
})
.catch((error) => {
const message = error && error.message ? error.message : String(error);
process.stderr.write(message);
process.exit(1);
});
`;

return spawnSync(process.execPath, ["-e", script], {
cwd: REPO_ROOT,
encoding: "utf-8",
timeout: 5000,
input,
env: {
...process.env,
NEMOCLAW_TEST_ITEMS: JSON.stringify(SELECT_FROM_LIST_ITEMS),
NEMOCLAW_TEST_OPTIONS: JSON.stringify({ applied }),
},
});
}

describe("policies", () => {
describe("listPresets", () => {
it("returns all 9 presets", () => {
Expand Down Expand Up @@ -426,4 +512,96 @@ describe("policies", () => {
}
});
});

describe("selectFromList", () => {
it("returns preset name by number from stdin input", () => {
const result = runSelectFromList("1\n");

expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("npm");
expect(result.stderr).toContain("Choose preset [1]:");
});

it("uses the first preset as the default when input is empty", () => {
const result = runSelectFromList("\n");

expect(result.status).toBe(0);
expect(result.stderr).toContain("Choose preset [1]:");
expect(result.stdout.trim()).toBe("npm");
});

it("defaults to the first not-applied preset", () => {
const result = runSelectFromList("\n", { applied: ["npm"] });

expect(result.status).toBe(0);
expect(result.stderr).toContain("Choose preset [2]:");
expect(result.stdout.trim()).toBe("pypi");
});

it("rejects selecting an already-applied preset", () => {
const result = runSelectFromList("1\n", { applied: ["npm"] });

expect(result.status).toBe(0);
expect(result.stderr).toContain("Preset 'npm' is already applied.");
expect(result.stdout.trim()).toBe("null");
});

it("rejects out-of-range preset number", () => {
const result = runSelectFromList("99\n");

expect(result.status).toBe(0);
expect(result.stderr).toContain("Invalid preset number.");
expect(result.stdout.trim()).toBe("null");
});

it("rejects non-numeric preset input", () => {
const result = runSelectFromList("npm\n");

expect(result.status).toBe(0);
expect(result.stderr).toContain("Invalid preset number.");
expect(result.stdout.trim()).toBe("null");
});

it("prints numbered list with applied markers, legend, and default prompt", () => {
const result = runSelectFromList("2\n", { applied: ["npm"] });

expect(result.status).toBe(0);
expect(result.stderr).toMatch(/Available presets:/);
expect(result.stderr).toMatch(/1\) ● npm — npm and Yarn registry access/);
expect(result.stderr).toMatch(/2\) ○ pypi — Python Package Index \(PyPI\) access/);
expect(result.stderr).toMatch(/● applied, ○ not applied/);
expect(result.stderr).toMatch(/Choose preset \[2\]:/);
expect(result.stdout.trim()).toBe("pypi");
});
});

describe("policy-add confirmation", () => {
it("prompts for confirmation before applying a preset", () => {
const result = runPolicyAdd("y");

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.trim());
expect(calls).toContainEqual({
type: "prompt",
message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ",
});
expect(calls).toContainEqual({
type: "apply",
sandboxName: "test-sandbox",
presetName: "pypi",
});
});

it("skips applying the preset when confirmation is declined", () => {
const result = runPolicyAdd("n");

expect(result.status).toBe(0);
const calls = JSON.parse(result.stdout.trim());
expect(calls).toContainEqual({
type: "prompt",
message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ",
});
expect(calls.some((call) => call.type === "apply")).toBeFalsy();
});
});
});
Loading