Skip to content
Open
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
148 changes: 105 additions & 43 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3164,6 +3164,102 @@ function arePolicyPresetsApplied(sandboxName, selectedPresets = []) {
return selectedPresets.every((preset) => applied.has(preset));
}

/**
* Raw-mode TUI preset selector.
* Keys: ↑/↓ or k/j to move, Space to toggle, a to select/unselect all, Enter to confirm.
* Falls back to a simple line-based prompt when stdin is not a TTY.
*/
async function presetsCheckboxSelector(allPresets, initialSelected) {
const selected = new Set(initialSelected);
const n = allPresets.length;
Comment on lines +3173 to +3174
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard the zero-preset case before entering raw mode.

policies.listPresets() can legitimately return an empty array, and with n === 0 the navigation handlers do modulo-by-zero while Space dereferences allPresets[cursor].name. That can throw after raw mode is enabled and leave the terminal in a bad state.

Suggested fix
 async function presetsCheckboxSelector(allPresets, initialSelected) {
   const selected = new Set(initialSelected);
   const n = allPresets.length;
+  if (n === 0) {
+    console.log("  No policy presets are available.");
+    return [];
+  }
 
   // ── Fallback: non-TTY (piped input) ──────────────────────────────
   if (!process.stdin.isTTY) {

Also applies to: 3243-3256

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 3173 - 3174, Guard entering raw/interactive
mode when no presets exist: before creating the Set/starting the interactive
handlers, check if allPresets.length (n) === 0 and abort/return or show a
friendly message instead of enabling raw mode; also update any key handlers that
use cursor % n or access allPresets[cursor].name (e.g., Space/navigation
handlers) to avoid modulo-by-zero by short-circuiting when n === 0. Ensure the
early exit happens prior to enabling raw mode so the terminal state is not left
altered; apply the same guard to the other interactive block that uses n (the
block referenced around the second handler group).


// ── Fallback: non-TTY (piped input) ──────────────────────────────
if (!process.stdin.isTTY) {
console.log("");
console.log(" Available policy presets:");
allPresets.forEach((p) => {
const marker = selected.has(p.name) ? "[\x1b[32m✓\x1b[0m]" : "[ ]";
console.log(` ${marker} ${p.name.padEnd(14)} — ${p.description}`);
});
Comment on lines +3177 to +3183
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Only use the redraw UI when both terminal streams support it.

This path writes cursor-control and color escapes directly to process.stdout, so checking only process.stdin.isTTY will dump raw ANSI into redirected or tee'd output. The hardcoded green checkmarks also bypass the existing USE_COLOR gate.

Suggested fix
-  if (!process.stdin.isTTY) {
+  const canUseTui =
+    process.stdin.isTTY &&
+    process.stdout.isTTY &&
+    typeof process.stdin.setRawMode === "function";
+  if (!canUseTui) {
     console.log("");
     console.log("  Available policy presets:");
     allPresets.forEach((p) => {
-      const marker = selected.has(p.name) ? "[\x1b[32m✓\x1b[0m]" : "[ ]";
+      const marker = selected.has(p.name)
+        ? USE_COLOR
+          ? "[\x1b[32m✓\x1b[0m]"
+          : "[✓]"
+        : "[ ]";
       console.log(`    ${marker} ${p.name.padEnd(14)} — ${p.description}`);
     });
@@
   const renderLines = () => {
     const lines = ["  Available policy presets:"];
     allPresets.forEach((p, i) => {
-      const check = selected.has(p.name) ? "[\x1b[32m✓\x1b[0m]" : "[ ]";
+      const check = selected.has(p.name)
+        ? USE_COLOR
+          ? "[\x1b[32m✓\x1b[0m]"
+          : "[✓]"
+        : "[ ]";
       const arrow = i === cursor ? ">" : " ";
       lines.push(`   ${arrow} ${check} ${p.name.padEnd(14)} — ${p.description}`);
     });

Also applies to: 3193-3225

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 3177 - 3183, The preset-listing currently
writes raw ANSI escapes to stdout and only checks process.stdin.isTTY; change
the guard so the redraw UI runs only when both process.stdin.isTTY and
process.stdout.isTTY are true, and honor the existing USE_COLOR (or equivalent
color flag) instead of emitting hardcoded green escapes—i.e., compute marker
using the color helper only when USE_COLOR is true (fall back to plain "[✓]" or
"[ ]" without escapes when false) and avoid writing any cursor-control/color
codes when stdout is not a TTY; apply the same change to the other similar block
that renders policy presets (the code using allPresets, selected, and marker).

console.log("");
const raw = await prompt(" Select presets (comma-separated names, Enter to skip): ");
if (!raw.trim()) return [];
return raw
.split(",")
.map((s) => s.trim())
.filter((name) => allPresets.some((p) => p.name === name));
}

// ── Raw-mode TUI ─────────────────────────────────────────────────
let cursor = 0;

const HINT = " ↑/↓ j/k move Space toggle a all/none Enter confirm";

const renderLines = () => {
const lines = [" Available policy presets:"];
allPresets.forEach((p, i) => {
const check = selected.has(p.name) ? "[\x1b[32m✓\x1b[0m]" : "[ ]";
const arrow = i === cursor ? ">" : " ";
lines.push(` ${arrow} ${check} ${p.name.padEnd(14)} — ${p.description}`);
});
lines.push("");
lines.push(HINT);
return lines;
};

// Initial paint
process.stdout.write("\n");
const initial = renderLines();
for (const line of initial) process.stdout.write(`${line}\n`);
let lineCount = initial.length;

const redraw = () => {
process.stdout.write(`\x1b[${lineCount}A`);
const lines = renderLines();
for (const line of lines) process.stdout.write(`\r\x1b[2K${line}\n`);
lineCount = lines.length;
};

process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");

return new Promise((resolve) => {
const cleanup = () => {
process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.removeAllListeners("data");
};

process.stdin.on("data", (key) => {
if (key === "\r" || key === "\n") {
cleanup();
process.stdout.write("\n");
resolve([...selected]);
} else if (key === "\x03") {
// Ctrl+C
cleanup();
process.exit(1);
} else if (key === "\x1b[A" || key === "k") {
cursor = (cursor - 1 + n) % n;
redraw();
} else if (key === "\x1b[B" || key === "j") {
cursor = (cursor + 1) % n;
redraw();
} else if (key === " ") {
const name = allPresets[cursor].name;
if (selected.has(name)) selected.delete(name);
else selected.add(name);
redraw();
} else if (key === "a") {
if (selected.size === n) selected.clear();
else for (const p of allPresets) selected.add(p.name);
redraw();
}
});
});
}

// eslint-disable-next-line complexity
async function setupPoliciesWithSelection(sandboxName, options = {}) {
const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null;
Expand Down Expand Up @@ -3212,9 +3308,7 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) {
}
} else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") {
const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS);
if (envPresets.length > 0) {
chosen = envPresets;
}
if (envPresets.length > 0) chosen = envPresets;
} else {
console.error(` Unsupported NEMOCLAW_POLICY_MODE: ${policyMode}`);
console.error(" Valid values: suggested, custom, skip");
Expand Down Expand Up @@ -3256,46 +3350,14 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) {
return chosen;
}

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

const answer = await prompt(
` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `,
);

if (answer.toLowerCase() === "n") {
console.log(" Skipping policy presets.");
return [];
}

let interactiveChoice = suggestions;
if (answer.toLowerCase() === "list") {
const custom = await prompt(" Enter preset names (comma-separated): ");
interactiveChoice = parsePolicyPresetEnv(custom);
}

const knownPresets = new Set(allPresets.map((p) => p.name));
let invalidPresets = interactiveChoice.filter((name) => !knownPresets.has(name));
while (invalidPresets.length > 0) {
console.error(` Unknown policy preset(s): ${invalidPresets.join(", ")}`);
console.log(" Available presets:");
for (const p of allPresets) {
console.log(` - ${p.name}`);
}
const retry = await prompt(" Enter preset names (comma-separated), or leave empty to skip: ");
if (!retry.trim()) {
console.log(" Skipping policy presets.");
return [];
}
interactiveChoice = parsePolicyPresetEnv(retry);
invalidPresets = interactiveChoice.filter((name) => !knownPresets.has(name));
}
// Interactive: raw-mode TUI checkbox selector
// Seed selection with already-applied presets and credential-based suggestions
const knownNames = new Set(allPresets.map((p) => p.name));
const initialSelected = [
...applied.filter((name) => knownNames.has(name)),
...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)),
];
const interactiveChoice = await presetsCheckboxSelector(allPresets, initialSelected);

if (onSelection) onSelection(interactiveChoice);
if (!waitForSandboxReady(sandboxName)) {
Expand Down