From 06e020bfe25fb24c05fcfbb3b8e0e6d03a636539 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Tue, 17 Mar 2026 16:18:31 +0000 Subject: [PATCH 1/3] feat: add unattended/CI install support Add support for non-interactive installation mode for automated deployments, CI pipelines, and containerized environments. New environment variables: - NEMOCLAW_NONINTERACTIVE: Skip all interactive prompts - NEMOCLAW_SANDBOX_NAME: Custom sandbox name (default: my-assistant) - NEMOCLAW_RECREATE_SANDBOX: Control sandbox recreation behavior - NEMOCLAW_DYNAMO_ENDPOINT: External vLLM/Dynamo endpoint URL - NEMOCLAW_DYNAMO_MODEL: Model name for Dynamo endpoint - CI=true: Auto-enables non-interactive mode Changes: - credentials.js: Add isNonInteractive() helper, update prompt() to accept defaults - onboard.js: Add Dynamo provider support, auto-apply policies in CI mode - README.md: Document unattended install workflow and env vars Signed-off-by: rwipfelnv --- README.md | 27 ++++++++ bin/lib/credentials.js | 28 +++++++- bin/lib/onboard.js | 143 ++++++++++++++++++++++++++++++----------- 3 files changed, 159 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 03087ffc4..b328e2000 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,33 @@ sandbox@my-assistant:~$ openclaw agent --agent main --local -m "hello" --session +### Unattended / CI Install + +For automated deployments, CI pipelines, or containerized environments, NemoClaw supports non-interactive mode: + +```console +$ export NVIDIA_API_KEY=nvapi-xxx +$ export NEMOCLAW_NONINTERACTIVE=1 +$ export NEMOCLAW_SANDBOX_NAME=my-sandbox # optional, defaults to "my-assistant" +$ curl -fsSL https://nvidia.com/nemoclaw.sh | bash +``` + +> **For CI pipelines:** Pin to a specific version by cloning and checking out a tag: +> ```console +> $ git clone --depth 1 --branch v1.0.0 https://github.com/NVIDIA/NemoClaw.git +> $ cd NemoClaw && bash install.sh +> ``` + +| Environment Variable | Description | +|---------------------|-------------| +| `NEMOCLAW_NONINTERACTIVE` | Set to `1` to skip all interactive prompts and use defaults | +| `NEMOCLAW_SANDBOX_NAME` | Custom sandbox name (default: `my-assistant`) | +| `NEMOCLAW_RECREATE_SANDBOX` | Set to `0` to keep existing sandbox instead of recreating | +| `NVIDIA_API_KEY` | Required for cloud inference in non-interactive mode | +| `NEMOCLAW_DYNAMO_ENDPOINT` | External vLLM/Dynamo endpoint URL (e.g., `http://vllm.svc:8000/v1`) | +| `NEMOCLAW_DYNAMO_MODEL` | Model name for Dynamo endpoint (default: `meta-llama/Llama-3.1-8B-Instruct`) | +| `CI` | Automatically enables non-interactive mode when set to `true` | + --- ## How It Works diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index 1ac405bed..3895449c1 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -9,6 +9,14 @@ const { execSync } = require("child_process"); const CREDS_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); const CREDS_FILE = path.join(CREDS_DIR, "credentials.json"); +// Non-interactive mode: skip prompts and use defaults +// Enabled via NEMOCLAW_NONINTERACTIVE=1 or CI=true +function isNonInteractive() { + return process.env.NEMOCLAW_NONINTERACTIVE === "1" || + process.env.NEMOCLAW_NONINTERACTIVE === "true" || + process.env.CI === "true"; +} + function loadCredentials() { try { if (fs.existsSync(CREDS_FILE)) { @@ -31,7 +39,17 @@ function getCredential(key) { return creds[key] || null; } -function prompt(question) { +/** + * Prompt for user input. In non-interactive mode, returns defaultValue or empty string. + * @param {string} question - The prompt text + * @param {string} [defaultValue] - Value to return in non-interactive mode + * @returns {Promise} + */ +function prompt(question, defaultValue = "") { + if (isNonInteractive()) { + // In non-interactive mode, return default without prompting + return Promise.resolve(defaultValue); + } return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { @@ -48,6 +66,13 @@ async function ensureApiKey() { return; } + // In non-interactive mode, fail if no key is available + if (isNonInteractive()) { + console.error(" NVIDIA_API_KEY environment variable required for non-interactive mode."); + console.error(" Set it via: export NVIDIA_API_KEY=nvapi-xxx"); + process.exit(1); + } + console.log(""); console.log(" ┌─────────────────────────────────────────────────────────────────┐"); console.log(" │ NVIDIA API Key required │"); @@ -130,4 +155,5 @@ module.exports = { ensureApiKey, ensureGithubToken, isRepoPrivate, + isNonInteractive, }; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index be320ad8f..ba4485bc4 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -6,7 +6,7 @@ const fs = require("fs"); const path = require("path"); const { ROOT, SCRIPTS, run, runCapture } = require("./runner"); -const { prompt, ensureApiKey, getCredential } = require("./credentials"); +const { prompt, ensureApiKey, getCredential, isNonInteractive } = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); @@ -133,7 +133,9 @@ async function startGateway(gpu) { async function createSandbox(gpu) { step(3, 7, "Creating sandbox"); - const nameAnswer = await prompt(" Sandbox name (lowercase, numbers, hyphens) [my-assistant]: "); + // Support NEMOCLAW_SANDBOX_NAME env var for unattended installs + const envSandboxName = process.env.NEMOCLAW_SANDBOX_NAME; + const nameAnswer = envSandboxName || await prompt(" Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", ""); const sandboxName = (nameAnswer || "my-assistant").trim().toLowerCase(); // Validate: RFC 1123 subdomain — lowercase alphanumeric and hyphens, @@ -145,15 +147,24 @@ async function createSandbox(gpu) { process.exit(1); } + if (isNonInteractive()) { + console.log(` Using sandbox name: ${sandboxName}`); + } + // Check if sandbox already exists in registry const existing = registry.getSandbox(sandboxName); if (existing) { - const recreate = await prompt(` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `); - if (recreate.toLowerCase() !== "y") { + // In non-interactive mode, recreate by default (use NEMOCLAW_RECREATE_SANDBOX=0 to keep) + const shouldRecreate = isNonInteractive() + ? process.env.NEMOCLAW_RECREATE_SANDBOX !== "0" + : (await prompt(` Sandbox '${sandboxName}' already exists. Recreate? [y/N]: `)).toLowerCase() === "y"; + + if (!shouldRecreate) { console.log(" Keeping existing sandbox."); return sandboxName; } // Destroy old sandbox + console.log(` Recreating sandbox '${sandboxName}'...`); run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); registry.removeSandbox(sandboxName); } @@ -217,6 +228,25 @@ async function setupNim(sandboxName, gpu) { let provider = "nvidia-nim"; let nimContainer = null; + // Check for Dynamo/external vLLM endpoint (for K8s/cloud deployments) + const dynamoEndpoint = process.env.NEMOCLAW_DYNAMO_ENDPOINT; + const dynamoModel = process.env.NEMOCLAW_DYNAMO_MODEL; + if (dynamoEndpoint) { + // Validate URL format + try { + new URL(dynamoEndpoint); + } catch { + console.error(` Invalid NEMOCLAW_DYNAMO_ENDPOINT URL: ${dynamoEndpoint}`); + process.exit(1); + } + console.log(` Using Dynamo endpoint: ${dynamoEndpoint}`); + console.log(` Model: ${dynamoModel || "default"}`); + provider = "dynamo"; + model = dynamoModel || "meta-llama/Llama-3.1-8B-Instruct"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer, dynamoEndpoint }); + return { model, provider, dynamoEndpoint }; + } + // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); @@ -259,17 +289,25 @@ async function setupNim(sandboxName, gpu) { } if (options.length > 1) { - console.log(""); - console.log(" Inference options:"); - options.forEach((o, i) => { - console.log(` ${i + 1}) ${o.label}`); - }); - console.log(""); - const defaultIdx = options.findIndex((o) => o.key === "cloud") + 1; - const choice = await prompt(` Choose [${defaultIdx}]: `); - const idx = parseInt(choice || String(defaultIdx), 10) - 1; - const selected = options[idx] || options[defaultIdx - 1]; + let selected; + + if (isNonInteractive()) { + // In non-interactive mode, use cloud inference by default + selected = options[defaultIdx - 1]; + console.log(` Using default inference: ${selected.label}`); + } else { + console.log(""); + console.log(" Inference options:"); + options.forEach((o, i) => { + console.log(` ${i + 1}) ${o.label}`); + }); + console.log(""); + + const choice = await prompt(` Choose [${defaultIdx}]: `); + const idx = parseInt(choice || String(defaultIdx), 10) - 1; + selected = options[idx] || options[defaultIdx - 1]; + } if (selected.key === "nim") { // List models that fit GPU VRAM @@ -384,6 +422,21 @@ async function setupInference(sandboxName, model, provider) { `openshell inference set --no-verify --provider ollama-local --model ${model} 2>/dev/null || true`, { ignoreError: true } ); + } else if (provider === "dynamo") { + // Dynamo/external vLLM endpoint (e.g., K8s Dynamo deployment) + const dynamoEndpoint = registry.getSandbox(sandboxName)?.dynamoEndpoint || process.env.NEMOCLAW_DYNAMO_ENDPOINT; + run( + `openshell provider create --name dynamo --type openai ` + + `--credential "OPENAI_API_KEY=dummy" ` + + `--config "OPENAI_BASE_URL=${dynamoEndpoint}" 2>&1 || ` + + `openshell provider update dynamo --credential "OPENAI_API_KEY=dummy" ` + + `--config "OPENAI_BASE_URL=${dynamoEndpoint}" 2>&1 || true`, + { ignoreError: true } + ); + run( + `openshell inference set --no-verify --provider dynamo --model ${model} 2>/dev/null || true`, + { ignoreError: true } + ); } registry.updateSandbox(sandboxName, { model, provider }); @@ -427,33 +480,41 @@ async function setupPolicies(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); - 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; - } - - if (answer.toLowerCase() === "list") { - // Let user pick - const picks = await prompt(" Enter preset names (comma-separated): "); - const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); - for (const name of selected) { + if (isNonInteractive()) { + // In non-interactive mode, apply suggested presets automatically + console.log(` Applying suggested presets: ${suggestions.join(", ")}`); + for (const name of suggestions) { policies.applyPreset(sandboxName, name); } } else { - // Apply suggested - for (const name of suggestions) { - policies.applyPreset(sandboxName, name); + 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; + } + + if (answer.toLowerCase() === "list") { + // Let user pick + const picks = await prompt(" Enter preset names (comma-separated): "); + const selected = picks.split(",").map((s) => s.trim()).filter(Boolean); + for (const name of selected) { + policies.applyPreset(sandboxName, name); + } + } else { + // Apply suggested + for (const name of suggestions) { + policies.applyPreset(sandboxName, name); + } } } @@ -491,6 +552,12 @@ async function onboard() { console.log(" NemoClaw Onboarding"); console.log(" ==================="); + if (isNonInteractive()) { + console.log(""); + console.log(" Running in non-interactive mode (NEMOCLAW_NONINTERACTIVE=1 or CI=true)"); + console.log(" Using defaults for all prompts. Set NEMOCLAW_SANDBOX_NAME to customize."); + } + const gpu = await preflight(); await startGateway(gpu); const sandboxName = await createSandbox(gpu); From 64ad096b7fa98bfeb18b3038d2b148f851034a02 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Wed, 18 Mar 2026 17:18:29 +0000 Subject: [PATCH 2/3] fix: prevent shell injection in Dynamo endpoint handling - Add shQuote() helper for shell-safe single-quote escaping - Validate URL protocol is http/https before use - Use sanitized URL from parser instead of raw input - Shell-escape dynamoEndpoint and model in openshell commands Addresses CodeRabbit security review comment. Signed-off-by: rwipfelnv --- bin/lib/onboard.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ba4485bc4..14a466e69 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -21,6 +21,11 @@ function step(n, total, msg) { console.log(` ${"─".repeat(50)}`); } +// Shell-safe single-quote escaping to prevent injection +function shQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + function isDockerRunning() { try { runCapture("docker info", { ignoreError: false }); @@ -229,16 +234,23 @@ async function setupNim(sandboxName, gpu) { let nimContainer = null; // Check for Dynamo/external vLLM endpoint (for K8s/cloud deployments) - const dynamoEndpoint = process.env.NEMOCLAW_DYNAMO_ENDPOINT; + const dynamoEndpointRaw = process.env.NEMOCLAW_DYNAMO_ENDPOINT; const dynamoModel = process.env.NEMOCLAW_DYNAMO_MODEL; - if (dynamoEndpoint) { - // Validate URL format + if (dynamoEndpointRaw) { + // Validate URL format and protocol (security: prevent shell injection via malformed URLs) + let parsedUrl; try { - new URL(dynamoEndpoint); + parsedUrl = new URL(dynamoEndpointRaw); } catch { - console.error(` Invalid NEMOCLAW_DYNAMO_ENDPOINT URL: ${dynamoEndpoint}`); + console.error(` Invalid NEMOCLAW_DYNAMO_ENDPOINT URL: ${dynamoEndpointRaw}`); + process.exit(1); + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + console.error(` NEMOCLAW_DYNAMO_ENDPOINT must use http or https: ${dynamoEndpointRaw}`); process.exit(1); } + // Use sanitized URL string from parser + const dynamoEndpoint = parsedUrl.toString(); console.log(` Using Dynamo endpoint: ${dynamoEndpoint}`); console.log(` Model: ${dynamoModel || "default"}`); provider = "dynamo"; @@ -425,16 +437,19 @@ async function setupInference(sandboxName, model, provider) { } else if (provider === "dynamo") { // Dynamo/external vLLM endpoint (e.g., K8s Dynamo deployment) const dynamoEndpoint = registry.getSandbox(sandboxName)?.dynamoEndpoint || process.env.NEMOCLAW_DYNAMO_ENDPOINT; + // Shell-escape values to prevent injection attacks + const configArg = shQuote(`OPENAI_BASE_URL=${dynamoEndpoint}`); + const modelArg = shQuote(model); run( `openshell provider create --name dynamo --type openai ` + `--credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${dynamoEndpoint}" 2>&1 || ` + + `--config ${configArg} 2>&1 || ` + `openshell provider update dynamo --credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${dynamoEndpoint}" 2>&1 || true`, + `--config ${configArg} 2>&1 || true`, { ignoreError: true } ); run( - `openshell inference set --no-verify --provider dynamo --model ${model} 2>/dev/null || true`, + `openshell inference set --no-verify --provider dynamo --model ${modelArg} 2>/dev/null || true`, { ignoreError: true } ); } From 3982fe3d19b839c910cbe768b3aaba101d5fb375 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Wed, 18 Mar 2026 18:20:58 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit review feedback - Sanitize URL logging to omit credentials and query params - Validate dynamoEndpoint before use, fail fast if missing - Skip already-applied presets in non-interactive mode Signed-off-by: rwipfelnv --- bin/lib/onboard.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 14a466e69..3d847a016 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -251,7 +251,12 @@ async function setupNim(sandboxName, gpu) { } // Use sanitized URL string from parser const dynamoEndpoint = parsedUrl.toString(); - console.log(` Using Dynamo endpoint: ${dynamoEndpoint}`); + // Log sanitized URL (omit credentials and query params for security) + const safeLogUrl = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; + const redacted = []; + if (parsedUrl.username || parsedUrl.password) redacted.push("credentials"); + if (parsedUrl.search) redacted.push("query"); + console.log(` Using Dynamo endpoint: ${safeLogUrl}${redacted.length ? ` [REDACTED: ${redacted.join(", ")}]` : ""}`); console.log(` Model: ${dynamoModel || "default"}`); provider = "dynamo"; model = dynamoModel || "meta-llama/Llama-3.1-8B-Instruct"; @@ -437,6 +442,11 @@ async function setupInference(sandboxName, model, provider) { } else if (provider === "dynamo") { // Dynamo/external vLLM endpoint (e.g., K8s Dynamo deployment) const dynamoEndpoint = registry.getSandbox(sandboxName)?.dynamoEndpoint || process.env.NEMOCLAW_DYNAMO_ENDPOINT; + if (!dynamoEndpoint) { + console.error(" Dynamo provider selected but no endpoint configured."); + console.error(" Set NEMOCLAW_DYNAMO_ENDPOINT or re-run onboard."); + process.exit(1); + } // Shell-escape values to prevent injection attacks const configArg = shQuote(`OPENAI_BASE_URL=${dynamoEndpoint}`); const modelArg = shQuote(model); @@ -496,10 +506,15 @@ async function setupPolicies(sandboxName) { const applied = policies.getAppliedPresets(sandboxName); if (isNonInteractive()) { - // In non-interactive mode, apply suggested presets automatically - console.log(` Applying suggested presets: ${suggestions.join(", ")}`); - for (const name of suggestions) { - policies.applyPreset(sandboxName, name); + // In non-interactive mode, apply suggested presets automatically (skip already-applied) + const toApply = suggestions.filter((name) => !applied.includes(name)); + if (toApply.length > 0) { + console.log(` Applying suggested presets: ${toApply.join(", ")}`); + for (const name of toApply) { + policies.applyPreset(sandboxName, name); + } + } else if (suggestions.length > 0) { + console.log(` Suggested presets already applied: ${suggestions.join(", ")}`); } } else { console.log("");