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..3d847a016 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"); @@ -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 }); @@ -133,7 +138,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 +152,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 +233,37 @@ async function setupNim(sandboxName, gpu) { let provider = "nvidia-nim"; let nimContainer = null; + // Check for Dynamo/external vLLM endpoint (for K8s/cloud deployments) + const dynamoEndpointRaw = process.env.NEMOCLAW_DYNAMO_ENDPOINT; + const dynamoModel = process.env.NEMOCLAW_DYNAMO_MODEL; + if (dynamoEndpointRaw) { + // Validate URL format and protocol (security: prevent shell injection via malformed URLs) + let parsedUrl; + try { + parsedUrl = new URL(dynamoEndpointRaw); + } catch { + 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(); + // 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"; + 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 +306,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 +439,29 @@ 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; + 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); + run( + `openshell provider create --name dynamo --type openai ` + + `--credential "OPENAI_API_KEY=dummy" ` + + `--config ${configArg} 2>&1 || ` + + `openshell provider update dynamo --credential "OPENAI_API_KEY=dummy" ` + + `--config ${configArg} 2>&1 || true`, + { ignoreError: true } + ); + run( + `openshell inference set --no-verify --provider dynamo --model ${modelArg} 2>/dev/null || true`, + { ignoreError: true } + ); } registry.updateSandbox(sandboxName, { model, provider }); @@ -427,33 +505,46 @@ 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 (isNonInteractive()) { + // 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(""); + 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(""); - if (answer.toLowerCase() === "n") { - console.log(" Skipping policy presets."); - return; - } + const answer = await prompt(` Apply suggested presets (${suggestions.join(", ")})? [Y/n/list]: `); - 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); + if (answer.toLowerCase() === "n") { + console.log(" Skipping policy presets."); + return; } - } else { - // Apply suggested - for (const name of suggestions) { - policies.applyPreset(sandboxName, name); + + 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 +582,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);