diff --git a/bin/lib/config-io.js b/bin/lib/config-io.js new file mode 100644 index 000000000..e2361d3cf --- /dev/null +++ b/bin/lib/config-io.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/config-io.ts, +// compiled to dist/lib/config-io.js. + +module.exports = require("../../dist/lib/config-io"); diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 683ba80b8..ef466e190 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -1,327 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/credentials.ts, +// compiled to dist/lib/credentials.js. -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const readline = require("readline"); -const { execFileSync } = require("child_process"); +const mod = require("../../dist/lib/credentials"); -const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); +const exports_ = { ...mod }; -function resolveHomeDir() { - const raw = process.env.HOME || os.homedir(); - if (!raw) { - throw new Error( - "Cannot determine safe home directory for credential storage. " + - "Set the HOME environment variable to a user-owned directory.", - ); - } - const home = path.resolve(raw); - try { - const real = fs.realpathSync(home); - if (UNSAFE_HOME_PATHS.has(real)) { - throw new Error( - "Cannot store credentials: HOME resolves to '" + - real + - "' which is world-readable. " + - "Set the HOME environment variable to a user-owned directory.", - ); - } - } catch (e) { - if (e.code !== "ENOENT") throw e; - } - if (UNSAFE_HOME_PATHS.has(home)) { - throw new Error( - "Cannot store credentials: HOME resolves to '" + - home + - "' which is world-readable. " + - "Set the HOME environment variable to a user-owned directory.", - ); - } - return home; -} - -let _credsDir = null; -let _credsFile = null; - -function getCredsDir() { - if (!_credsDir) _credsDir = path.join(resolveHomeDir(), ".nemoclaw"); - return _credsDir; -} - -function getCredsFile() { - if (!_credsFile) _credsFile = path.join(getCredsDir(), "credentials.json"); - return _credsFile; -} - -function loadCredentials() { - try { - const file = getCredsFile(); - if (fs.existsSync(file)) { - return JSON.parse(fs.readFileSync(file, "utf-8")); - } - } catch { - /* ignored */ - } - return {}; -} - -function normalizeCredentialValue(value) { - if (typeof value !== "string") return ""; - return value.replace(/\r/g, "").trim(); -} - -function saveCredential(key, value) { - const dir = getCredsDir(); - const file = getCredsFile(); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.chmodSync(dir, 0o700); - const creds = loadCredentials(); - creds[key] = normalizeCredentialValue(value); - fs.writeFileSync(file, JSON.stringify(creds, null, 2), { mode: 0o600 }); - fs.chmodSync(file, 0o600); -} - -function getCredential(key) { - if (process.env[key]) return normalizeCredentialValue(process.env[key]); - const creds = loadCredentials(); - const value = normalizeCredentialValue(creds[key]); - return value || null; -} - -function promptSecret(question) { - return new Promise((resolve, reject) => { - const input = process.stdin; - const output = process.stderr; - let answer = ""; - let rawModeEnabled = false; - let finished = false; - - function cleanup() { - input.removeListener("data", onData); - if (rawModeEnabled && typeof input.setRawMode === "function") { - input.setRawMode(false); - } - if (typeof input.pause === "function") { - input.pause(); - } - } - - function finish(fn, value) { - if (finished) return; - finished = true; - cleanup(); - output.write("\n"); - fn(value); - } - - function onData(chunk) { - const text = chunk.toString("utf8"); - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - - if (ch === "\u0003") { - finish(reject, Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); - return; - } - - if (ch === "\r" || ch === "\n") { - finish(resolve, answer.trim()); - return; - } - - if (ch === "\u0008" || ch === "\u007f") { - if (answer.length > 0) { - answer = answer.slice(0, -1); - output.write("\b \b"); - } - continue; - } - - if (ch === "\u001b") { - // Ignore terminal escape/control sequences such as Delete, arrows, - // Home/End, etc. while leaving the buffered secret untouched. - const rest = text.slice(i); - // eslint-disable-next-line no-control-regex - const match = rest.match(/^\u001b(?:\[[0-9;?]*[~A-Za-z]|\][^\u0007]*\u0007|.)/); - if (match) { - i += match[0].length - 1; - } - continue; - } - - if (ch >= " ") { - answer += ch; - output.write("*"); - } - } - } - - output.write(question); - input.setEncoding("utf8"); - if (typeof input.resume === "function") { - input.resume(); - } - if (typeof input.setRawMode === "function") { - input.setRawMode(true); - rawModeEnabled = true; - } - input.on("data", onData); - }); -} - -function prompt(question, opts = {}) { - return new Promise((resolve, reject) => { - const silent = opts.secret === true && process.stdin.isTTY && process.stderr.isTTY; - if (silent) { - promptSecret(question) - .then(resolve) - .catch((err) => { - if (err && err.code === "SIGINT") { - reject(err); - process.kill(process.pid, "SIGINT"); - return; - } - reject(err); - }); - return; - } - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - let finished = false; - function finish(fn, value) { - if (finished) return; - finished = true; - rl.close(); - if (!process.stdin.isTTY) { - if (typeof process.stdin.pause === "function") { - process.stdin.pause(); - } - if (typeof process.stdin.unref === "function") { - process.stdin.unref(); - } - } - fn(value); - } - rl.on("SIGINT", () => { - const err = Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }); - finish(reject, err); - process.kill(process.pid, "SIGINT"); - }); - rl.question(question, (answer) => { - finish(resolve, answer.trim()); - }); - }); -} - -async function ensureApiKey() { - let key = getCredential("NVIDIA_API_KEY"); - if (key) { - process.env.NVIDIA_API_KEY = key; - return; - } - - console.log(""); - console.log(" ┌─────────────────────────────────────────────────────────────────┐"); - console.log(" │ NVIDIA API Key required │"); - console.log(" │ │"); - console.log(" │ 1. Go to https://build.nvidia.com/settings/api-keys │"); - console.log(" │ 2. Sign in with your NVIDIA account │"); - console.log(" │ 3. Click 'Generate API Key' button │"); - console.log(" │ 4. Paste the key below (starts with nvapi-) │"); - console.log(" └─────────────────────────────────────────────────────────────────┘"); - console.log(""); - - while (true) { - key = normalizeCredentialValue(await prompt(" NVIDIA API Key: ", { secret: true })); - - if (!key) { - console.error(" NVIDIA API Key is required."); - continue; - } - - if (!key.startsWith("nvapi-")) { - console.error(" Invalid key. Must start with nvapi-"); - continue; - } - - break; - } - - saveCredential("NVIDIA_API_KEY", key); - process.env.NVIDIA_API_KEY = key; - console.log(""); - console.log(" Key saved to ~/.nemoclaw/credentials.json (mode 600)"); - console.log(""); -} - -function isRepoPrivate(repo) { - try { - const json = execFileSync("gh", ["api", `repos/${repo}`, "--jq", ".private"], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - return json === "true"; - } catch { - return false; - } -} - -async function ensureGithubToken() { - let token = getCredential("GITHUB_TOKEN"); - if (token) { - process.env.GITHUB_TOKEN = token; - return; - } - - try { - token = execFileSync("gh", ["auth", "token"], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - if (token) { - process.env.GITHUB_TOKEN = token; - return; - } - } catch { - /* ignored */ - } - - console.log(""); - console.log(" ┌──────────────────────────────────────────────────┐"); - console.log(" │ GitHub token required (private repo detected) │"); - console.log(" │ │"); - console.log(" │ Option A: gh auth login (if you have gh CLI) │"); - console.log(" │ Option B: Paste a PAT with read:packages scope │"); - console.log(" └──────────────────────────────────────────────────┘"); - console.log(""); - - token = await prompt(" GitHub Token: ", { secret: true }); - - if (!token) { - console.error(" Token required for deploy (repo is private)."); - process.exit(1); - } - - saveCredential("GITHUB_TOKEN", token); - process.env.GITHUB_TOKEN = token; - console.log(""); - console.log(" Token saved to ~/.nemoclaw/credentials.json (mode 600)"); - console.log(""); -} - -const exports_ = { - loadCredentials, - normalizeCredentialValue, - saveCredential, - getCredential, - prompt, - ensureApiKey, - ensureGithubToken, - isRepoPrivate, -}; - -Object.defineProperty(exports_, "CREDS_DIR", { get: getCredsDir, enumerable: true }); -Object.defineProperty(exports_, "CREDS_FILE", { get: getCredsFile, enumerable: true }); +// Preserve lazy getter behavior for CREDS_DIR and CREDS_FILE +Object.defineProperty(exports_, "CREDS_DIR", { get: mod._getCredsDir, enumerable: true }); +Object.defineProperty(exports_, "CREDS_FILE", { get: mod._getCredsFile, enumerable: true }); module.exports = exports_; diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 6a9accc4f..5c6779e40 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -1,352 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Policy preset management — list, load, merge, and apply presets. +// Thin re-export shim — the implementation lives in src/lib/policies.ts, +// compiled to dist/lib/policies.js. -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"); - -const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); -function getOpenshellCommand() { - const binary = process.env.NEMOCLAW_OPENSHELL_BIN; - if (!binary) return "openshell"; - return shellQuote(binary); -} - -function listPresets() { - if (!fs.existsSync(PRESETS_DIR)) return []; - return fs - .readdirSync(PRESETS_DIR) - .filter((f) => f.endsWith(".yaml")) - .map((f) => { - const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8"); - const nameMatch = content.match(/^\s*name:\s*(.+)$/m); - const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m); - return { - file: f, - name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""), - description: descMatch ? descMatch[1].trim() : "", - }; - }); -} - -function loadPreset(name) { - const file = path.resolve(PRESETS_DIR, `${name}.yaml`); - if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { - console.error(` Invalid preset name: ${name}`); - return null; - } - if (!fs.existsSync(file)) { - console.error(` Preset not found: ${name}`); - return null; - } - return fs.readFileSync(file, "utf-8"); -} - -function getPresetEndpoints(content) { - const hosts = []; - const regex = /host:\s*([^\s,}]+)/g; - let match; - while ((match = regex.exec(content)) !== null) { - hosts.push(match[1]); - } - return hosts; -} - -/** - * Extract just the network_policies entries (indented content under - * the `network_policies:` key) from a preset file, stripping the - * `preset:` metadata header. - */ -function extractPresetEntries(presetContent) { - if (!presetContent) return null; - const npMatch = presetContent.match(/^network_policies:\n([\s\S]*)$/m); - if (!npMatch) return null; - return npMatch[1].trimEnd(); -} - -/** - * Parse the output of `openshell policy get --full` which has a metadata - * header (Version, Hash, etc.) followed by `---` and then the actual YAML. - */ -function parseCurrentPolicy(raw) { - if (!raw) return ""; - const sep = raw.indexOf("---"); - const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); - if (!candidate) return ""; - if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) { - return ""; - } - if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) { - return ""; - } - try { - const parsed = YAML.parse(candidate); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return ""; - } - } catch { - return ""; - } - return candidate; -} - -/** - * Build the openshell policy set command with properly quoted arguments. - */ -function buildPolicySetCommand(policyFile, sandboxName) { - return `${getOpenshellCommand()} policy set --policy ${shellQuote(policyFile)} --wait ${shellQuote(sandboxName)}`; -} - -/** - * Build the openshell policy get command with properly quoted arguments. - */ -function buildPolicyGetCommand(sandboxName) { - return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; -} - -/** - * Text-based fallback for merging preset entries into policy YAML. - * Used when preset entries cannot be parsed as structured YAML. - */ -function textBasedMerge(currentPolicy, presetEntries) { - if (!currentPolicy) { - return "version: 1\n\nnetwork_policies:\n" + presetEntries; - } - let merged; - if (/^network_policies\s*:/m.test(currentPolicy)) { - const lines = currentPolicy.split("\n"); - const result = []; - let inNp = false; - let inserted = false; - for (const line of lines) { - if (/^network_policies\s*:/.test(line)) { - inNp = true; - result.push(line); - continue; - } - if (inNp && /^\S.*:/.test(line) && !inserted) { - result.push(presetEntries); - inserted = true; - inNp = false; - } - result.push(line); - } - if (inNp && !inserted) result.push(presetEntries); - merged = result.join("\n"); - } else { - merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; - } - if (!merged.trimStart().startsWith("version:")) merged = "version: 1\n\n" + merged; - return merged; -} - -/** - * Merge preset entries into existing policy YAML using structured YAML - * parsing. Replaces the previous text-based manipulation which could - * produce invalid YAML when indentation or ordering varied. - * - * Behavior: - * - Parses both current policy and preset entries as YAML - * - Merges network_policies by name (preset overrides on collision) - * - Preserves all non-network sections (filesystem_policy, process, etc.) - * - Ensures version: 1 exists - * - * @param {string} currentPolicy - Existing policy YAML (may be empty/versionless) - * @param {string} presetEntries - Indented network_policies entries from preset - * @returns {string} Merged YAML - */ -function mergePresetIntoPolicy(currentPolicy, presetEntries) { - const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); - if (!presetEntries) { - return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; - } - - // Parse preset entries. They come as indented content under network_policies:, - // so we wrap them to make valid YAML for parsing. - let presetPolicies; - try { - const wrapped = "network_policies:\n" + presetEntries; - const parsed = YAML.parse(wrapped); - presetPolicies = parsed?.network_policies; - } catch { - presetPolicies = null; - } - - // If YAML parsing failed or entries are not a mergeable object, - // fall back to the text-based approach for backward compatibility. - if (!presetPolicies || typeof presetPolicies !== "object" || Array.isArray(presetPolicies)) { - return textBasedMerge(normalizedCurrentPolicy, presetEntries); - } - - if (!normalizedCurrentPolicy) { - return YAML.stringify({ version: 1, network_policies: presetPolicies }); - } - - // Parse the current policy as structured YAML - let current; - try { - current = YAML.parse(normalizedCurrentPolicy); - } catch { - return textBasedMerge(normalizedCurrentPolicy, presetEntries); - } - - if (!current || typeof current !== "object") current = {}; - - // Structured merge: preset entries override existing on name collision. - // Guard: network_policies may be an array in legacy policies — only - // object-merge when both sides are plain objects. - const existingNp = current.network_policies; - let mergedNp; - if (existingNp && typeof existingNp === "object" && !Array.isArray(existingNp)) { - mergedNp = { ...existingNp, ...presetPolicies }; - } else { - mergedNp = presetPolicies; - } - - const output = { version: current.version || 1 }; - for (const [key, val] of Object.entries(current)) { - if (key !== "version" && key !== "network_policies") output[key] = val; - } - output.network_policies = mergedNp; - - return YAML.stringify(output); -} -function applyPreset(sandboxName, presetName) { - // Guard against truncated sandbox names — WSL can truncate hyphenated - // names during argument parsing, e.g. "my-assistant" → "m" - const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); - if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { - throw new Error( - `Invalid or truncated sandbox name: '${sandboxName}'. ` + - `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, - ); - } - - const presetContent = loadPreset(presetName); - if (!presetContent) { - console.error(` Cannot load preset: ${presetName}`); - return false; - } - - const presetEntries = extractPresetEntries(presetContent); - if (!presetEntries) { - console.error(` Preset ${presetName} has no network_policies section.`); - return false; - } - - // Get current policy YAML from sandbox - let rawPolicy = ""; - try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); - } catch { - /* ignored */ - } - - const currentPolicy = parseCurrentPolicy(rawPolicy); - const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); - const tmpFile = path.join(tmpDir, "policy.yaml"); - fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); - - try { - run(buildPolicySetCommand(tmpFile, sandboxName)); - - console.log(` Applied preset: ${presetName}`); - } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - /* ignored */ - } - try { - fs.rmdirSync(tmpDir); - } catch { - /* ignored */ - } - } - - const sandbox = registry.getSandbox(sandboxName); - if (sandbox) { - const pols = sandbox.policies || []; - if (!pols.includes(presetName)) { - pols.push(presetName); - } - registry.updateSandbox(sandboxName, { policies: pols }); - } - - return true; -} - -function getAppliedPresets(sandboxName) { - const sandbox = registry.getSandbox(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, - loadPreset, - getPresetEndpoints, - extractPresetEntries, - parseCurrentPolicy, - buildPolicySetCommand, - buildPolicyGetCommand, - mergePresetIntoPolicy, - applyPreset, - getAppliedPresets, - selectFromList, -}; +module.exports = require("../../dist/lib/policies"); diff --git a/bin/lib/registry.js b/bin/lib/registry.js index 885ea8c2e..66ab30da6 100644 --- a/bin/lib/registry.js +++ b/bin/lib/registry.js @@ -1,246 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Multi-sandbox registry at ~/.nemoclaw/sandboxes.json +// Thin re-export shim — the implementation lives in src/lib/registry.ts, +// compiled to dist/lib/registry.js. -const fs = require("fs"); -const path = require("path"); - -const REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json"); -const LOCK_DIR = REGISTRY_FILE + ".lock"; -const LOCK_OWNER = path.join(LOCK_DIR, "owner"); -const LOCK_STALE_MS = 10_000; -const LOCK_RETRY_MS = 100; -const LOCK_MAX_RETRIES = 120; - -/** - * Acquire an advisory lock using mkdir (atomic on POSIX). - * Writes an owner file with PID for stale-lock detection via process liveness. - */ -function acquireLock() { - fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true, mode: 0o700 }); - const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); - for (let i = 0; i < LOCK_MAX_RETRIES; i++) { - try { - fs.mkdirSync(LOCK_DIR); - const ownerTmp = LOCK_OWNER + ".tmp." + process.pid; - try { - fs.writeFileSync(ownerTmp, String(process.pid), { mode: 0o600 }); - fs.renameSync(ownerTmp, LOCK_OWNER); - } catch (ownerErr) { - // Remove the directory we just created so it doesn't look like a stale lock - try { - fs.unlinkSync(ownerTmp); - } catch { - /* best effort */ - } - try { - fs.unlinkSync(LOCK_OWNER); - } catch { - /* best effort */ - } - try { - fs.rmdirSync(LOCK_DIR); - } catch { - /* best effort */ - } - throw ownerErr; - } - return; - } catch (err) { - if (err.code !== "EEXIST") throw err; - // Check if the lock owner is still alive - let ownerChecked = false; - try { - const ownerPid = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); - if (Number.isFinite(ownerPid) && ownerPid > 0) { - ownerChecked = true; - let alive; - try { - process.kill(ownerPid, 0); - alive = true; - } catch (killErr) { - // EPERM means the process exists but we lack permission — still alive - alive = killErr.code === "EPERM"; - } - if (!alive) { - // Verify PID hasn't changed (TOCTOU guard) - const recheck = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); - if (recheck === ownerPid) { - fs.rmSync(LOCK_DIR, { recursive: true, force: true }); - continue; - } - } - } - // Owner file empty/corrupt — another process may be mid-write - // (between mkdirSync and renameSync). Fall through to mtime check. - } catch { - // No owner file or lock dir released — fall through to mtime staleness - } - if (!ownerChecked) { - // No valid owner PID available — use mtime as fallback - try { - const stat = fs.statSync(LOCK_DIR); - if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { - fs.rmSync(LOCK_DIR, { recursive: true, force: true }); - continue; - } - } catch { - // Lock was released between our check — retry immediately - continue; - } - } - Atomics.wait(sleepBuf, 0, 0, LOCK_RETRY_MS); - } - } - throw new Error(`Failed to acquire lock on ${REGISTRY_FILE} after ${LOCK_MAX_RETRIES} retries`); -} - -function releaseLock() { - try { - fs.unlinkSync(LOCK_OWNER); - } catch (err) { - if (err.code !== "ENOENT") throw err; - } - // rmSync handles leftover tmp files from crashed acquireLock attempts - try { - fs.rmSync(LOCK_DIR, { recursive: true, force: true }); - } catch (err) { - if (err.code !== "ENOENT") throw err; - } -} - -/** Run fn while holding the registry lock. Returns fn's return value. */ -function withLock(fn) { - acquireLock(); - try { - return fn(); - } finally { - releaseLock(); - } -} - -function load() { - try { - if (fs.existsSync(REGISTRY_FILE)) { - return JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf-8")); - } - } catch { - /* ignored */ - } - return { sandboxes: {}, defaultSandbox: null }; -} - -/** Atomic write: tmp file + rename on the same filesystem. */ -function save(data) { - const dir = path.dirname(REGISTRY_FILE); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - const tmp = REGISTRY_FILE + ".tmp." + process.pid; - try { - fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); - fs.renameSync(tmp, REGISTRY_FILE); - } catch (err) { - // Clean up partial temp file on failure - try { - fs.unlinkSync(tmp); - } catch { - /* best effort */ - } - throw err; - } -} - -function getSandbox(name) { - const data = load(); - return data.sandboxes[name] || null; -} - -function getDefault() { - const data = load(); - if (data.defaultSandbox && data.sandboxes[data.defaultSandbox]) { - return data.defaultSandbox; - } - // Fall back to first sandbox if default is missing - const names = Object.keys(data.sandboxes); - return names.length > 0 ? names[0] : null; -} - -function registerSandbox(entry) { - return withLock(() => { - const data = load(); - data.sandboxes[entry.name] = { - name: entry.name, - createdAt: entry.createdAt || new Date().toISOString(), - model: entry.model || null, - nimContainer: entry.nimContainer || null, - provider: entry.provider || null, - gpuEnabled: entry.gpuEnabled || false, - policies: entry.policies || [], - }; - if (!data.defaultSandbox) { - data.defaultSandbox = entry.name; - } - save(data); - }); -} - -function updateSandbox(name, updates) { - return withLock(() => { - const data = load(); - if (!data.sandboxes[name]) return false; - if (Object.prototype.hasOwnProperty.call(updates, "name") && updates.name !== name) { - return false; - } - Object.assign(data.sandboxes[name], updates); - save(data); - return true; - }); -} - -function removeSandbox(name) { - return withLock(() => { - const data = load(); - if (!data.sandboxes[name]) return false; - delete data.sandboxes[name]; - if (data.defaultSandbox === name) { - const remaining = Object.keys(data.sandboxes); - data.defaultSandbox = remaining.length > 0 ? remaining[0] : null; - } - save(data); - return true; - }); -} - -function listSandboxes() { - const data = load(); - return { - sandboxes: Object.values(data.sandboxes), - defaultSandbox: data.defaultSandbox, - }; -} - -function setDefault(name) { - return withLock(() => { - const data = load(); - if (!data.sandboxes[name]) return false; - data.defaultSandbox = name; - save(data); - return true; - }); -} - -module.exports = { - load, - save, - getSandbox, - getDefault, - registerSandbox, - updateSandbox, - removeSandbox, - listSandboxes, - setDefault, - // Exported for testing - acquireLock, - releaseLock, - withLock, -}; +module.exports = require("../../dist/lib/registry"); diff --git a/src/lib/config-io.test.ts b/src/lib/config-io.test.ts new file mode 100644 index 000000000..4fa47ea6c --- /dev/null +++ b/src/lib/config-io.test.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Import from compiled dist/ for coverage attribution. +import { + ensureConfigDir, + readConfigFile, + writeConfigFile, + ConfigPermissionError, +} from "../../dist/lib/config-io"; + +describe("config-io", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-io-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("ensureConfigDir", () => { + it("creates a directory with mode 0o700", () => { + const dir = path.join(tmpDir, "nested", "config"); + ensureConfigDir(dir); + expect(fs.existsSync(dir)).toBe(true); + const stat = fs.statSync(dir); + expect(stat.mode & 0o777).toBe(0o700); + }); + + it("succeeds if directory already exists", () => { + const dir = path.join(tmpDir, "existing"); + fs.mkdirSync(dir, { mode: 0o700 }); + expect(() => ensureConfigDir(dir)).not.toThrow(); + }); + }); + + describe("writeConfigFile + readConfigFile", () => { + it("round-trips JSON data with atomic write", () => { + const file = path.join(tmpDir, "test.json"); + const data = { key: "value", nested: { a: 1 } }; + writeConfigFile(file, data); + + expect(fs.existsSync(file)).toBe(true); + const stat = fs.statSync(file); + expect(stat.mode & 0o777).toBe(0o600); + + const loaded = readConfigFile(file, {}); + expect(loaded).toEqual(data); + }); + + it("creates parent directories", () => { + const file = path.join(tmpDir, "deep", "nested", "config.json"); + writeConfigFile(file, { ok: true }); + expect(readConfigFile(file, null)).toEqual({ ok: true }); + }); + + it("returns default for missing files", () => { + const file = path.join(tmpDir, "nonexistent.json"); + expect(readConfigFile(file, { fallback: true })).toEqual({ fallback: true }); + }); + + it("returns default for corrupt JSON", () => { + const file = path.join(tmpDir, "corrupt.json"); + fs.writeFileSync(file, "not-json"); + expect(readConfigFile(file, "default")).toBe("default"); + }); + + it("cleans up temp file on write failure", () => { + // Write to a read-only directory to trigger failure + const readonlyDir = path.join(tmpDir, "readonly"); + fs.mkdirSync(readonlyDir, { mode: 0o700 }); + const file = path.join(readonlyDir, "test.json"); + // Write once successfully, then make dir read-only + writeConfigFile(file, { first: true }); + fs.chmodSync(readonlyDir, 0o500); + + try { + expect(() => writeConfigFile(file, { second: true })).toThrow(); + } finally { + fs.chmodSync(readonlyDir, 0o700); + } + + // No temp files left behind + const files = fs.readdirSync(readonlyDir); + expect(files.filter((f) => f.includes(".tmp."))).toHaveLength(0); + }); + }); + + describe("ConfigPermissionError", () => { + it("includes remediation message and config path", () => { + const err = new ConfigPermissionError("test error", "/some/path"); + expect(err.name).toBe("ConfigPermissionError"); + expect(err.code).toBe("EACCES"); + expect(err.configPath).toBe("/some/path"); + expect(err.message).toContain("test error"); + expect(err.remediation).toContain("sudo chown"); + expect(err.remediation).toContain(".nemoclaw"); + }); + }); +}); diff --git a/src/lib/config-io.ts b/src/lib/config-io.ts new file mode 100644 index 000000000..281b7d36b --- /dev/null +++ b/src/lib/config-io.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Safe config file I/O with EACCES error handling (#692, #606, #719). + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +/** + * Custom error for config permission problems. Carries the path and + * a user-facing remediation message so callers can display it cleanly. + */ +export class ConfigPermissionError extends Error { + code = "EACCES"; + configPath: string; + remediation: string; + + constructor(message: string, configPath: string, cause?: Error) { + const remediation = buildRemediation(); + super(`${message}\n\n${remediation}`); + this.name = "ConfigPermissionError"; + this.configPath = configPath; + this.remediation = remediation; + if (cause) this.cause = cause; + } +} + +function buildRemediation(): string { + const home = process.env.HOME || os.homedir(); + const nemoclawDir = path.join(home, ".nemoclaw"); + return [ + " To fix, run one of:", + "", + ` sudo chown -R $(whoami) ${nemoclawDir}`, + ` # or, if the directory was created by another user:`, + ` sudo rm -rf ${nemoclawDir} && nemoclaw onboard`, + "", + " This usually happens when NemoClaw was first run with sudo", + " or the config directory was created by a different user.", + ].join("\n"); +} + +/** + * Ensure a directory exists with mode 0o700. On EACCES, throws + * ConfigPermissionError with remediation hints. + */ +export function ensureConfigDir(dir: string): void { + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw new ConfigPermissionError(`Cannot create config directory: ${dir}`, dir, err as Error); + } + throw err; + } + + try { + fs.accessSync(dir, fs.constants.W_OK); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw new ConfigPermissionError( + `Config directory exists but is not writable: ${dir}`, + dir, + err as Error, + ); + } + throw err; + } +} + +/** + * Write a JSON config file atomically with mode 0o600. + * Uses write-to-temp + rename to avoid partial writes on crash. + */ +export function writeConfigFile(filePath: string, data: unknown): void { + const dir = path.dirname(filePath); + ensureConfigDir(dir); + + const content = JSON.stringify(data, null, 2); + const tmpFile = filePath + ".tmp." + process.pid; + + try { + fs.writeFileSync(tmpFile, content, { mode: 0o600 }); + fs.renameSync(tmpFile, filePath); + } catch (err: unknown) { + try { + fs.unlinkSync(tmpFile); + } catch { + /* best effort cleanup */ + } + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw new ConfigPermissionError( + `Cannot write config file: ${filePath}`, + filePath, + err as Error, + ); + } + throw err; + } +} + +/** + * Read and parse a JSON config file. Returns defaultValue on missing + * or corrupt files. On EACCES, throws ConfigPermissionError. + */ +export function readConfigFile(filePath: string, defaultValue: T): T { + try { + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw new ConfigPermissionError( + `Cannot read config file: ${filePath}`, + filePath, + err as Error, + ); + } + // Corrupt JSON or other non-permission error — return default + } + return defaultValue; +} diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts new file mode 100644 index 000000000..b83804978 --- /dev/null +++ b/src/lib/credentials.ts @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import readline from "node:readline"; +import { execFileSync } from "node:child_process"; + +import { readConfigFile, writeConfigFile } from "./config-io"; + +const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); + +function resolveHomeDir(): string { + const raw = process.env.HOME || os.homedir(); + if (!raw) { + throw new Error( + "Cannot determine safe home directory for credential storage. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + const home = path.resolve(raw); + try { + const real = fs.realpathSync(home); + if (UNSAFE_HOME_PATHS.has(real)) { + throw new Error( + "Cannot store credentials: HOME resolves to '" + + real + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + } + if (UNSAFE_HOME_PATHS.has(home)) { + throw new Error( + "Cannot store credentials: HOME resolves to '" + + home + + "' which is world-readable. " + + "Set the HOME environment variable to a user-owned directory.", + ); + } + return home; +} + +// Cache keyed by HOME so tests that stub HOME get fresh paths. +let _cachedHome: string | null = null; +let _credsDir: string | null = null; +let _credsFile: string | null = null; + +function getCredsDir(): string { + const home = resolveHomeDir(); + if (_cachedHome !== home) { + _cachedHome = home; + _credsDir = null; + _credsFile = null; + } + if (!_credsDir) _credsDir = path.join(home, ".nemoclaw"); + return _credsDir; +} + +function getCredsFile(): string { + // Ensure dir cache is up to date with current HOME + getCredsDir(); + if (!_credsFile) _credsFile = path.join(getCredsDir(), "credentials.json"); + return _credsFile; +} + +export function loadCredentials(): Record { + return readConfigFile(getCredsFile(), {}); +} + +export function normalizeCredentialValue(value: unknown): string { + if (typeof value !== "string") return ""; + return value.replace(/\r/g, "").trim(); +} + +export function saveCredential(key: string, value: string): void { + const creds = loadCredentials(); + creds[key] = normalizeCredentialValue(value); + writeConfigFile(getCredsFile(), creds); +} + +export function getCredential(key: string): string | null { + if (process.env[key]) return normalizeCredentialValue(process.env[key]); + const creds = loadCredentials(); + const value = normalizeCredentialValue(creds[key]); + return value || null; +} + +function promptSecret(question: string): Promise { + return new Promise((resolve, reject) => { + const input = process.stdin; + const output = process.stderr; + let answer = ""; + let rawModeEnabled = false; + let finished = false; + + function cleanup() { + input.removeListener("data", onData); + if (rawModeEnabled && typeof input.setRawMode === "function") { + input.setRawMode(false); + } + if (typeof input.pause === "function") { + input.pause(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function finish(fn: (value: any) => void, value: unknown) { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + fn(value); + } + + function onData(chunk: Buffer) { + const text = chunk.toString("utf8"); + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + + if (ch === "\u0003") { + finish(reject, Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + return; + } + + if (ch === "\r" || ch === "\n") { + finish(resolve, answer.trim()); + return; + } + + if (ch === "\u0008" || ch === "\u007f") { + if (answer.length > 0) { + answer = answer.slice(0, -1); + output.write("\b \b"); + } + continue; + } + + if (ch === "\u001b") { + const rest = text.slice(i); + // eslint-disable-next-line no-control-regex + const match = rest.match(/^\u001b(?:\[[0-9;?]*[~A-Za-z]|\][^\u0007]*\u0007|.)/); + if (match) { + i += match[0].length - 1; + } + continue; + } + + if (ch >= " ") { + answer += ch; + output.write("*"); + } + } + } + + output.write(question); + input.setEncoding("utf8"); + if (typeof input.resume === "function") { + input.resume(); + } + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + rawModeEnabled = true; + } + input.on("data", onData); + }); +} + +export function prompt( + question: string, + opts: { secret?: boolean } = {}, +): Promise { + return new Promise((resolve, reject) => { + const silent = opts.secret === true && process.stdin.isTTY && process.stderr.isTTY; + if (silent) { + promptSecret(question) + .then(resolve) + .catch((err: { code?: string }) => { + if (err && err.code === "SIGINT") { + reject(err); + process.kill(process.pid, "SIGINT"); + return; + } + reject(err); + }); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + let finished = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function finish(fn: (value: any) => void, value: unknown) { + if (finished) return; + finished = true; + rl.close(); + if (!process.stdin.isTTY) { + if (typeof process.stdin.pause === "function") { + process.stdin.pause(); + } + if (typeof process.stdin.unref === "function") { + process.stdin.unref(); + } + } + fn(value); + } + rl.on("SIGINT", () => { + const err = Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }); + finish(reject, err); + process.kill(process.pid, "SIGINT"); + }); + rl.question(question, (answer) => { + finish(resolve, answer.trim()); + }); + }); +} + +export async function ensureApiKey(): Promise { + let key = getCredential("NVIDIA_API_KEY"); + if (key) { + process.env.NVIDIA_API_KEY = key; + return; + } + + console.log(""); + console.log(" ┌─────────────────────────────────────────────────────────────────┐"); + console.log(" │ NVIDIA API Key required │"); + console.log(" │ │"); + console.log(" │ 1. Go to https://build.nvidia.com/settings/api-keys │"); + console.log(" │ 2. Sign in with your NVIDIA account │"); + console.log(" │ 3. Click 'Generate API Key' button │"); + console.log(" │ 4. Paste the key below (starts with nvapi-) │"); + console.log(" └─────────────────────────────────────────────────────────────────┘"); + console.log(""); + + while (true) { + key = normalizeCredentialValue(await prompt(" NVIDIA API Key: ", { secret: true })); + + if (!key) { + console.error(" NVIDIA API Key is required."); + continue; + } + + if (!key.startsWith("nvapi-")) { + console.error(" Invalid key. Must start with nvapi-"); + continue; + } + + break; + } + + saveCredential("NVIDIA_API_KEY", key); + process.env.NVIDIA_API_KEY = key; + console.log(""); + console.log(" Key saved to ~/.nemoclaw/credentials.json (mode 600)"); + console.log(""); +} + +export function isRepoPrivate(repo: string): boolean { + try { + const json = execFileSync("gh", ["api", `repos/${repo}`, "--jq", ".private"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return json === "true"; + } catch { + return false; + } +} + +export async function ensureGithubToken(): Promise { + let token = getCredential("GITHUB_TOKEN"); + if (token) { + process.env.GITHUB_TOKEN = token; + return; + } + + try { + token = execFileSync("gh", ["auth", "token"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (token) { + process.env.GITHUB_TOKEN = token; + return; + } + } catch { + /* ignored */ + } + + console.log(""); + console.log(" ┌──────────────────────────────────────────────────┐"); + console.log(" │ GitHub token required (private repo detected) │"); + console.log(" │ │"); + console.log(" │ Option A: gh auth login (if you have gh CLI) │"); + console.log(" │ Option B: Paste a PAT with read:packages scope │"); + console.log(" └──────────────────────────────────────────────────┘"); + console.log(""); + + token = await prompt(" GitHub Token: ", { secret: true }); + + if (!token) { + console.error(" Token required for deploy (repo is private)."); + process.exit(1); + } + + saveCredential("GITHUB_TOKEN", token); + process.env.GITHUB_TOKEN = token; + console.log(""); + console.log(" Token saved to ~/.nemoclaw/credentials.json (mode 600)"); + console.log(""); +} + +// Exported for the bin/lib shim to wire as lazy getters, and for +// direct access from tests. Re-evaluates when HOME changes. +export { getCredsDir as _getCredsDir, getCredsFile as _getCredsFile }; + +// Direct exports for TS consumers and jsconfig type checking. +// These are evaluated at import time but the HOME-aware cache +// ensures they stay correct across HOME changes in tests. +export const CREDS_DIR: string = getCredsDir(); +export const CREDS_FILE: string = getCredsFile(); diff --git a/src/lib/policies.ts b/src/lib/policies.ts new file mode 100644 index 000000000..bfdaef976 --- /dev/null +++ b/src/lib/policies.ts @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Policy preset management — list, load, merge, and apply presets. + +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import readline from "node:readline"; +import YAML from "yaml"; + +// CJS deps that aren't yet migrated or must stay CJS +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { ROOT, run, runCapture, shellQuote } = require("../../bin/lib/runner"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const registry = require("../../bin/lib/registry"); + +export const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); + +function getOpenshellCommand(): string { + const binary = process.env.NEMOCLAW_OPENSHELL_BIN; + if (!binary) return "openshell"; + return shellQuote(binary); +} + +export interface PresetInfo { + file: string; + name: string; + description: string; +} + +export function listPresets(): PresetInfo[] { + if (!fs.existsSync(PRESETS_DIR)) return []; + return fs + .readdirSync(PRESETS_DIR) + .filter((f) => f.endsWith(".yaml")) + .map((f) => { + const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8"); + const nameMatch = content.match(/^\s*name:\s*(.+)$/m); + const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m); + return { + file: f, + name: nameMatch ? nameMatch[1].trim() : f.replace(".yaml", ""), + description: descMatch ? descMatch[1].trim() : "", + }; + }); +} + +export function loadPreset(name: string): string | null { + const file = path.resolve(PRESETS_DIR, `${name}.yaml`); + if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { + console.error(` Invalid preset name: ${name}`); + return null; + } + if (!fs.existsSync(file)) { + console.error(` Preset not found: ${name}`); + return null; + } + return fs.readFileSync(file, "utf-8"); +} + +export function getPresetEndpoints(content: string): string[] { + const hosts: string[] = []; + const regex = /host:\s*([^\s,}]+)/g; + let match; + while ((match = regex.exec(content)) !== null) { + hosts.push(match[1]); + } + return hosts; +} + +export function validatePreset(presetContent: string, presetName: string): boolean { + if (!presetContent.includes("binaries:")) { + console.warn( + ` Warning: preset '${presetName}' has no binaries section — ` + + `this will cause 403 errors in the sandbox (ref: #676)`, + ); + return false; + } + return true; +} + +export function extractPresetEntries(presetContent: string): string | null { + if (!presetContent) return null; + const npMatch = presetContent.match(/^network_policies:\n([\s\S]*)$/m); + if (!npMatch) return null; + return npMatch[1].trimEnd(); +} + +export function parseCurrentPolicy(raw: string): string { + if (!raw) return ""; + const sep = raw.indexOf("---"); + const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); + if (!candidate) return ""; + if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) { + return ""; + } + if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) { + return ""; + } + try { + const parsed = YAML.parse(candidate); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return ""; + } + } catch { + return ""; + } + return candidate; +} + +export function buildPolicySetCommand(policyFile: string, sandboxName: string): string { + return `${getOpenshellCommand()} policy set --policy ${shellQuote(policyFile)} --wait ${shellQuote(sandboxName)}`; +} + +export function buildPolicyGetCommand(sandboxName: string): string { + return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; +} + +function textBasedMerge(currentPolicy: string, presetEntries: string): string { + if (!currentPolicy) { + return "version: 1\n\nnetwork_policies:\n" + presetEntries; + } + let merged; + if (/^network_policies\s*:/m.test(currentPolicy)) { + const lines = currentPolicy.split("\n"); + const result: string[] = []; + let inNp = false; + let inserted = false; + for (const line of lines) { + if (/^network_policies\s*:/.test(line)) { + inNp = true; + result.push(line); + continue; + } + if (inNp && /^\S.*:/.test(line) && !inserted) { + result.push(presetEntries); + inserted = true; + inNp = false; + } + result.push(line); + } + if (inNp && !inserted) result.push(presetEntries); + merged = result.join("\n"); + } else { + merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; + } + if (!merged.trimStart().startsWith("version:")) merged = "version: 1\n\n" + merged; + return merged; +} + +export function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): string { + const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); + if (!presetEntries) { + return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; + } + + let presetPolicies: Record | null; + try { + const wrapped = "network_policies:\n" + presetEntries; + const parsed = YAML.parse(wrapped) as { network_policies?: unknown }; + presetPolicies = parsed?.network_policies as Record | null; + } catch { + presetPolicies = null; + } + + if (!presetPolicies || typeof presetPolicies !== "object" || Array.isArray(presetPolicies)) { + return textBasedMerge(normalizedCurrentPolicy, presetEntries); + } + + if (!normalizedCurrentPolicy) { + return YAML.stringify({ version: 1, network_policies: presetPolicies }); + } + + let current: Record; + try { + current = YAML.parse(normalizedCurrentPolicy) as Record; + } catch { + return textBasedMerge(normalizedCurrentPolicy, presetEntries); + } + + if (!current || typeof current !== "object") current = {}; + + const existingNp = current.network_policies; + let mergedNp: Record; + if (existingNp && typeof existingNp === "object" && !Array.isArray(existingNp)) { + mergedNp = { ...(existingNp as Record), ...presetPolicies }; + } else { + mergedNp = presetPolicies; + } + + const output: Record = { version: (current.version as number) || 1 }; + for (const [key, val] of Object.entries(current)) { + if (key !== "version" && key !== "network_policies") output[key] = val; + } + output.network_policies = mergedNp; + + return YAML.stringify(output); +} + +export function applyPreset(sandboxName: string, presetName: string): boolean { + const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); + if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { + throw new Error( + `Invalid or truncated sandbox name: '${sandboxName}'. ` + + `Names must be 1-63 chars, lowercase alphanumeric, with optional internal hyphens.`, + ); + } + + const presetContent = loadPreset(presetName); + if (!presetContent) { + console.error(` Cannot load preset: ${presetName}`); + return false; + } + + validatePreset(presetContent, presetName); + + const presetEntries = extractPresetEntries(presetContent); + if (!presetEntries) { + console.error(` Preset ${presetName} has no network_policies section.`); + return false; + } + + let rawPolicy = ""; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + } catch { + /* ignored */ + } + + const currentPolicy = parseCurrentPolicy(rawPolicy); + const merged = mergePresetIntoPolicy(currentPolicy, presetEntries); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-")); + const tmpFile = path.join(tmpDir, "policy.yaml"); + fs.writeFileSync(tmpFile, merged, { encoding: "utf-8", mode: 0o600 }); + + try { + run(buildPolicySetCommand(tmpFile, sandboxName)); + console.log(` Applied preset: ${presetName}`); + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + /* ignored */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignored */ + } + } + + const sandbox = registry.getSandbox(sandboxName); + if (sandbox) { + const pols = sandbox.policies || []; + if (!pols.includes(presetName)) { + pols.push(presetName); + } + registry.updateSandbox(sandboxName, { policies: pols }); + } + + return true; +} + +export function getAppliedPresets(sandboxName: string): string[] { + const sandbox = registry.getSandbox(sandboxName); + return sandbox ? sandbox.policies || [] : []; +} + +export interface SelectFromListOptions { + applied?: string[]; +} + +export function selectFromList( + items: Array<{ name: string; description?: string }>, + { applied = [] }: SelectFromListOptions = {}, +): Promise { + 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); + }); + }); +} diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 000000000..aad6d63c5 --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Multi-sandbox registry at ~/.nemoclaw/sandboxes.json + +import fs from "node:fs"; +import path from "node:path"; + +import { readConfigFile, writeConfigFile } from "./config-io"; + +const REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json"); +const LOCK_DIR = REGISTRY_FILE + ".lock"; +const LOCK_OWNER = path.join(LOCK_DIR, "owner"); +const LOCK_STALE_MS = 10_000; +const LOCK_RETRY_MS = 100; +const LOCK_MAX_RETRIES = 120; + +export interface SandboxEntry { + name: string; + createdAt?: string; + model?: string | null; + nimContainer?: string | null; + provider?: string | null; + gpuEnabled?: boolean; + policies?: string[]; +} + +export interface RegistryData { + sandboxes: Record; + defaultSandbox: string | null; +} + +/** + * Acquire an advisory lock using mkdir (atomic on POSIX). + * Writes an owner file with PID for stale-lock detection via process liveness. + */ +export function acquireLock(): void { + fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true, mode: 0o700 }); + const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); + for (let i = 0; i < LOCK_MAX_RETRIES; i++) { + try { + fs.mkdirSync(LOCK_DIR); + const ownerTmp = LOCK_OWNER + ".tmp." + process.pid; + try { + fs.writeFileSync(ownerTmp, String(process.pid), { mode: 0o600 }); + fs.renameSync(ownerTmp, LOCK_OWNER); + } catch (ownerErr) { + try { + fs.unlinkSync(ownerTmp); + } catch { + /* best effort */ + } + try { + fs.unlinkSync(LOCK_OWNER); + } catch { + /* best effort */ + } + try { + fs.rmdirSync(LOCK_DIR); + } catch { + /* best effort */ + } + throw ownerErr; + } + return; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + let ownerChecked = false; + try { + const ownerPid = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); + if (Number.isFinite(ownerPid) && ownerPid > 0) { + ownerChecked = true; + let alive: boolean; + try { + process.kill(ownerPid, 0); + alive = true; + } catch (killErr: unknown) { + alive = (killErr as NodeJS.ErrnoException).code === "EPERM"; + } + if (!alive) { + const recheck = parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); + if (recheck === ownerPid) { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + continue; + } + } + } + } catch { + // No owner file or lock dir released + } + if (!ownerChecked) { + try { + const stat = fs.statSync(LOCK_DIR); + if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + continue; + } + } catch { + continue; + } + } + Atomics.wait(sleepBuf, 0, 0, LOCK_RETRY_MS); + } + } + throw new Error(`Failed to acquire lock on ${REGISTRY_FILE} after ${LOCK_MAX_RETRIES} retries`); +} + +export function releaseLock(): void { + try { + fs.unlinkSync(LOCK_OWNER); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + try { + fs.rmSync(LOCK_DIR, { recursive: true, force: true }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } +} + +export function withLock(fn: () => T): T { + acquireLock(); + try { + return fn(); + } finally { + releaseLock(); + } +} + +export function load(): RegistryData { + return readConfigFile(REGISTRY_FILE, { sandboxes: {}, defaultSandbox: null }); +} + +export function save(data: RegistryData): void { + writeConfigFile(REGISTRY_FILE, data); +} + +export function getSandbox(name: string): SandboxEntry | null { + const data = load(); + return data.sandboxes[name] || null; +} + +export function getDefault(): string | null { + const data = load(); + if (data.defaultSandbox && data.sandboxes[data.defaultSandbox]) { + return data.defaultSandbox; + } + const names = Object.keys(data.sandboxes); + return names.length > 0 ? names[0] : null; +} + +export function registerSandbox(entry: SandboxEntry): void { + withLock(() => { + const data = load(); + data.sandboxes[entry.name] = { + name: entry.name, + createdAt: entry.createdAt || new Date().toISOString(), + model: entry.model || null, + nimContainer: entry.nimContainer || null, + provider: entry.provider || null, + gpuEnabled: entry.gpuEnabled || false, + policies: entry.policies || [], + }; + if (!data.defaultSandbox) { + data.defaultSandbox = entry.name; + } + save(data); + }); +} + +export function updateSandbox( + name: string, + updates: Partial, +): boolean { + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + if ( + Object.prototype.hasOwnProperty.call(updates, "name") && + updates.name !== name + ) { + return false; + } + Object.assign(data.sandboxes[name], updates); + save(data); + return true; + }); +} + +export function removeSandbox(name: string): boolean { + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + delete data.sandboxes[name]; + if (data.defaultSandbox === name) { + const remaining = Object.keys(data.sandboxes); + data.defaultSandbox = remaining.length > 0 ? remaining[0] : null; + } + save(data); + return true; + }); +} + +export function listSandboxes(): { + sandboxes: SandboxEntry[]; + defaultSandbox: string | null; +} { + const data = load(); + return { + sandboxes: Object.values(data.sandboxes), + defaultSandbox: data.defaultSandbox, + }; +} + +export function setDefault(name: string): boolean { + return withLock(() => { + const data = load(); + if (!data.sandboxes[name]) return false; + data.defaultSandbox = name; + save(data); + return true; + }); +} diff --git a/test/credentials.test.js b/test/credentials.test.js index d84c04701..9ba6a747a 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -89,7 +89,7 @@ describe("credential prompts", () => { it("settles the outer prompt promise on secret prompt errors", () => { const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), "utf-8", ); @@ -100,7 +100,7 @@ describe("credential prompts", () => { it("re-raises SIGINT from standard readline prompts instead of treating it like an empty answer", () => { const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), "utf-8", ); @@ -114,7 +114,7 @@ describe("credential prompts", () => { expect(credentials.normalizeCredentialValue(" nvapi-good-key\r\n")).toBe("nvapi-good-key"); const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), "utf-8", ); expect(source).toMatch(/while \(true\) \{/); @@ -124,7 +124,7 @@ describe("credential prompts", () => { it("masks secret input with asterisks while preserving the underlying value", () => { const source = fs.readFileSync( - path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + path.join(import.meta.dirname, "..", "src", "lib", "credentials.ts"), "utf-8", ); diff --git a/test/registry.test.js b/test/registry.test.js index 80fd0dded..2318d2743 100644 --- a/test/registry.test.js +++ b/test/registry.test.js @@ -153,7 +153,9 @@ describe("atomic writes", () => { throw Object.assign(new Error("EACCES"), { code: "EACCES" }); }; try { - expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow("EACCES"); + expect(() => registry.save({ sandboxes: {}, defaultSandbox: null })).toThrow( + "Cannot write config file", + ); } finally { fs.renameSync = original; }