Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,33 @@ sandbox@my-assistant:~$ openclaw agent --agent main --local -m "hello" --session

<!-- end-quickstart-guide -->

### 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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For CI we need to checkout the right branch and then run install.sh. We should probably mention that here. Dont want curl from main in CI.

```

> **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
Expand Down
28 changes: 27 additions & 1 deletion bin/lib/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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<string>}
*/
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) => {
Expand All @@ -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 │");
Expand Down Expand Up @@ -130,4 +155,5 @@ module.exports = {
ensureApiKey,
ensureGithubToken,
isRepoPrivate,
isNonInteractive,
};
173 changes: 135 additions & 38 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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);
Expand Down
Loading