diff --git a/.dockerignore b/.dockerignore index 153efb0e2..8d42c6b17 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules /dist -!nemoclaw/dist .git *.pyc __pycache__ diff --git a/Dockerfile b/Dockerfile index 4967deb62..f269ed548 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,7 +80,12 @@ RUN chmod +x /usr/local/bin/nemoclaw-start # Build args for config that varies per deployment. # nemoclaw onboard passes these at image build time. ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b +ARG NEMOCLAW_PROVIDER_KEY=nvidia +ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b ARG CHAT_UI_URL=http://127.0.0.1:18789 +ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 +ARG NEMOCLAW_INFERENCE_API=openai-completions +ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= # Unique per build to ensure each image gets a fresh auth token. # Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache. ARG NEMOCLAW_BUILD_ID=default @@ -89,7 +94,12 @@ ARG NEMOCLAW_BUILD_ID=default # via os.environ, never via string interpolation into Python source code. # Direct ARG interpolation into python3 -c is a code injection vector (C-2). ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ - CHAT_UI_URL=${CHAT_UI_URL} + NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ + NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \ + CHAT_UI_URL=${CHAT_UI_URL} \ + NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ + NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ + NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} WORKDIR /sandbox USER sandbox @@ -100,30 +110,30 @@ USER sandbox # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. # Auth token is generated per build so each image has a unique token. RUN python3 -c "\ -import json, os, secrets; \ +import base64, json, os, secrets; \ from urllib.parse import urlparse; \ model = os.environ['NEMOCLAW_MODEL']; \ chat_ui_url = os.environ['CHAT_UI_URL']; \ +provider_key = os.environ['NEMOCLAW_PROVIDER_KEY']; \ +primary_model_ref = os.environ['NEMOCLAW_PRIMARY_MODEL_REF']; \ +inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \ +inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ +inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ origins = ['http://127.0.0.1:18789']; \ origins = list(dict.fromkeys(origins + [chat_origin])); \ +providers = { \ + provider_key: { \ + 'baseUrl': inference_base_url, \ + 'apiKey': 'unused', \ + 'api': inference_api, \ + 'models': [{**({'compat': inference_compat} if inference_compat else {}), 'id': model, 'name': primary_model_ref, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ + } \ +}; \ config = { \ - 'agents': {'defaults': {'model': {'primary': f'inference/{model}'}}}, \ - 'models': {'mode': 'merge', 'providers': { \ - 'nvidia': { \ - 'baseUrl': 'https://inference.local/v1', \ - 'apiKey': 'openshell-managed', \ - 'api': 'openai-completions', \ - 'models': [{'id': model.split('/')[-1], 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ - }, \ - 'inference': { \ - 'baseUrl': 'https://inference.local/v1', \ - 'apiKey': 'unused', \ - 'api': 'openai-completions', \ - 'models': [{'id': model, 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ - } \ - }}, \ + 'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \ + 'models': {'mode': 'merge', 'providers': providers}, \ 'channels': {'defaults': {'configWrites': False}}, \ 'gateway': { \ 'mode': 'local', \ diff --git a/README.md b/README.md index 3f318420a..38bdb4c23 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ When the install completes, a summary confirms the running environment: ```text ────────────────────────────────────────────────── Sandbox my-assistant (Landlock + seccomp + netns) -Model nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoint API) +Model nvidia/nemotron-3-super-120b-a12b (NVIDIA Endpoints) ────────────────────────────────────────────────── Run: nemoclaw my-assistant connect Status: nemoclaw my-assistant status @@ -162,14 +162,14 @@ curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uni ## How It Works -NemoClaw installs the NVIDIA OpenShell runtime and Nemotron models, then uses a versioned blueprint to create a sandboxed environment where every network request, file access, and inference call is governed by declarative policy. The `nemoclaw` CLI orchestrates the full stack: OpenShell gateway, sandbox, inference provider, and network policy. +NemoClaw installs the NVIDIA OpenShell runtime, then creates a sandboxed OpenClaw environment where every network request, file access, and inference call is governed by declarative policy. The `nemoclaw` CLI orchestrates the full stack: OpenShell gateway, sandbox, inference provider, and network policy. | Component | Role | |------------------|-------------------------------------------------------------------------------------------| | **Plugin** | TypeScript CLI commands for launch, connect, status, and logs. | | **Blueprint** | Versioned Python artifact that orchestrates sandbox creation, policy, and inference setup. | | **Sandbox** | Isolated OpenShell container running OpenClaw with policy-enforced egress and filesystem. | -| **Inference** | NVIDIA Endpoint model calls, routed through the OpenShell gateway, transparent to the agent. | +| **Inference** | Provider-routed model calls, routed through the OpenShell gateway, transparent to the agent. | The blueprint lifecycle follows four stages: resolve the artifact, verify its digest, plan the resources, and apply through the OpenShell CLI. @@ -179,15 +179,28 @@ When something goes wrong, errors may originate from either NemoClaw or the Open ## Inference -Inference requests from the agent never leave the sandbox directly. OpenShell intercepts every call and routes it to the NVIDIA Endpoint provider. +Inference requests from the agent never leave the sandbox directly. OpenShell intercepts every call and routes it to the provider you selected during onboarding. -| Provider | Model | Use Case | -|--------------|--------------------------------------|-------------------------------------------------| -| NVIDIA Endpoint | `nvidia/nemotron-3-super-120b-a12b` | Production. Requires an NVIDIA API key. | +Supported non-experimental onboarding paths: -Get an API key from [build.nvidia.com](https://build.nvidia.com). The `nemoclaw onboard` command prompts for this key during setup. +| Provider | Notes | +|---|---| +| NVIDIA Endpoints | Curated hosted models on `integrate.api.nvidia.com`. | +| OpenAI | Curated GPT models plus `Other...` for manual model entry. | +| Other OpenAI-compatible endpoint | For proxies and compatible gateways. | +| Anthropic | Curated Claude models plus `Other...` for manual model entry. | +| Other Anthropic-compatible endpoint | For Claude proxies and compatible gateways. | +| Google Gemini | Google's OpenAI-compatible endpoint. | -Local inference options such as Ollama and vLLM are still experimental. On macOS, they also depend on OpenShell host-routing support in addition to the local service itself being reachable on the host. +During onboarding, NemoClaw validates the selected provider and model before it creates the sandbox: + +- OpenAI-compatible providers: tries `/responses` first, then `/chat/completions` +- Anthropic-compatible providers: tries `/v1/messages` +- If validation fails, the wizard prompts you to fix the selection before continuing + +Credentials stay on the host in `~/.nemoclaw/credentials.json`. The sandbox only sees the routed `inference.local` endpoint, not your raw provider key. + +Local Ollama is supported in the standard onboarding flow. Local vLLM remains experimental, and local host-routed inference on macOS still depends on OpenShell host-routing support in addition to the local service itself being reachable on the host. --- @@ -252,7 +265,7 @@ Refer to the documentation for more information on NemoClaw. - [Overview](https://docs.nvidia.com/nemoclaw/latest/about/overview.html): Learn what NemoClaw does and how it fits together. - [How It Works](https://docs.nvidia.com/nemoclaw/latest/about/how-it-works.html): Learn about the plugin, blueprint, and sandbox lifecycle. - [Architecture](https://docs.nvidia.com/nemoclaw/latest/reference/architecture.html): Learn about the plugin structure, blueprint lifecycle, and sandbox environment. -- [Inference Profiles](https://docs.nvidia.com/nemoclaw/latest/reference/inference-profiles.html): Learn about the NVIDIA Endpoint inference configuration. +- [Inference Profiles](https://docs.nvidia.com/nemoclaw/latest/reference/inference-profiles.html): Learn how NemoClaw configures routed inference providers. - [Network Policies](https://docs.nvidia.com/nemoclaw/latest/reference/network-policies.html): Learn about egress control and policy customization. - [CLI Commands](https://docs.nvidia.com/nemoclaw/latest/reference/commands.html): Learn about the full command reference. - [Troubleshooting](https://docs.nvidia.com/nemoclaw/latest/reference/troubleshooting.html): Troubleshoot common issues and resolution steps. diff --git a/bin/lib/credentials.js b/bin/lib/credentials.js index b48c73c4a..09fe46eaf 100644 --- a/bin/lib/credentials.js +++ b/bin/lib/credentials.js @@ -31,8 +31,98 @@ function getCredential(key) { return creds[key] || null; } -function prompt(question) { - return new Promise((resolve) => { +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") { + answer = answer.slice(0, -1); + 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); + 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(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 }); rl.question(question, (answer) => { rl.close(); @@ -67,7 +157,7 @@ async function ensureApiKey() { console.log(" └─────────────────────────────────────────────────────────────────┘"); console.log(""); - key = await prompt(" NVIDIA API Key: "); + key = await prompt(" NVIDIA API Key: ", { secret: true }); if (!key || !key.startsWith("nvapi-")) { console.error(" Invalid key. Must start with nvapi-"); @@ -114,7 +204,7 @@ async function ensureGithubToken() { console.log(" └──────────────────────────────────────────────────┘"); console.log(""); - token = await prompt(" GitHub Token: "); + token = await prompt(" GitHub Token: ", { secret: true }); if (!token) { console.error(" Token required for deploy (repo is private)."); diff --git a/bin/lib/inference-config.js b/bin/lib/inference-config.js index 0f6683dfd..ac4acbd2d 100644 --- a/bin/lib/inference-config.js +++ b/bin/lib/inference-config.js @@ -18,6 +18,7 @@ const { DEFAULT_OLLAMA_MODEL } = require("./local-inference"); function getProviderSelectionConfig(provider, model) { switch (provider) { + case "nvidia-prod": case "nvidia-nim": return { endpointType: "custom", @@ -27,7 +28,62 @@ function getProviderSelectionConfig(provider, model) { profile: DEFAULT_ROUTE_PROFILE, credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, provider, - providerLabel: "NVIDIA Endpoint API", + providerLabel: "NVIDIA Endpoints", + }; + case "openai-api": + return { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: model || "gpt-5.4", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "OPENAI_API_KEY", + provider, + providerLabel: "OpenAI", + }; + case "anthropic-prod": + return { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: model || "claude-sonnet-4-6", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "ANTHROPIC_API_KEY", + provider, + providerLabel: "Anthropic", + }; + case "compatible-anthropic-endpoint": + return { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: model || "custom-anthropic-model", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + provider, + providerLabel: "Other Anthropic-compatible endpoint", + }; + case "gemini-api": + return { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: model || "gemini-2.5-flash", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "GEMINI_API_KEY", + provider, + providerLabel: "Google Gemini", + }; + case "compatible-endpoint": + return { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: model || "custom-model", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_API_KEY", + provider, + providerLabel: "Other OpenAI-compatible endpoint", }; case "vllm-local": return { diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 1065a70e3..629bfd623 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -6,6 +6,8 @@ const { shellQuote } = require("./runner"); const HOST_GATEWAY_URL = "http://host.openshell.internal"; const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1"; const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b"; +const SMALL_OLLAMA_MODEL = "qwen2.5:7b"; +const LARGE_OLLAMA_MIN_MEMORY_MB = 32768; function getLocalProviderBaseUrl(provider) { switch (provider) { @@ -18,6 +20,17 @@ function getLocalProviderBaseUrl(provider) { } } +function getLocalProviderValidationBaseUrl(provider) { + switch (provider) { + case "vllm-local": + return "http://localhost:8000/v1"; + case "ollama-local": + return "http://localhost:11434/v1"; + default: + return null; + } +} + function getLocalProviderHealthCheck(provider) { switch (provider) { case "vllm-local": @@ -105,14 +118,23 @@ function parseOllamaList(output) { function getOllamaModelOptions(runCapture) { const output = runCapture("ollama list 2>/dev/null", { ignoreError: true }); const parsed = parseOllamaList(output); - if (parsed.length > 0) { - return parsed; + return parsed; +} + +function getBootstrapOllamaModelOptions(gpu) { + const options = [SMALL_OLLAMA_MODEL]; + if (gpu && gpu.totalMemoryMB >= LARGE_OLLAMA_MIN_MEMORY_MB) { + options.push(DEFAULT_OLLAMA_MODEL); } - return [DEFAULT_OLLAMA_MODEL]; + return options; } -function getDefaultOllamaModel(runCapture) { +function getDefaultOllamaModel(runCapture, gpu = null) { const models = getOllamaModelOptions(runCapture); + if (models.length === 0) { + const bootstrap = getBootstrapOllamaModelOptions(gpu); + return bootstrap[0]; + } return models.includes(DEFAULT_OLLAMA_MODEL) ? DEFAULT_OLLAMA_MODEL : models[0]; } @@ -164,8 +186,12 @@ module.exports = { CONTAINER_REACHABILITY_IMAGE, DEFAULT_OLLAMA_MODEL, HOST_GATEWAY_URL, + LARGE_OLLAMA_MIN_MEMORY_MB, + SMALL_OLLAMA_MODEL, getDefaultOllamaModel, + getBootstrapOllamaModelOptions, getLocalProviderBaseUrl, + getLocalProviderValidationBaseUrl, getLocalProviderContainerReachabilityCheck, getLocalProviderHealthCheck, getOllamaModelOptions, diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 548b2db23..b52c5b400 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -127,6 +127,10 @@ function pullNimImage(model) { function startNimContainer(sandboxName, model, port = 8000) { const name = containerName(sandboxName); + return startNimContainerByName(name, model, port); +} + +function startNimContainerByName(name, model, port = 8000) { const image = getImageForModel(model); if (!image) { console.error(` Unknown model: ${model}`); @@ -169,6 +173,10 @@ function waitForNimHealth(port = 8000, timeout = 300) { function stopNimContainer(sandboxName) { const name = containerName(sandboxName); + stopNimContainerByName(name); +} + +function stopNimContainerByName(name) { const qn = shellQuote(name); console.log(` Stopping NIM container: ${name}`); run(`docker stop ${qn} 2>/dev/null || true`, { ignoreError: true }); @@ -177,6 +185,10 @@ function stopNimContainer(sandboxName) { function nimStatus(sandboxName) { const name = containerName(sandboxName); + return nimStatusByName(name); +} + +function nimStatusByName(name) { try { const state = runCapture( `docker inspect --format '{{.State.Status}}' ${shellQuote(name)} 2>/dev/null`, @@ -204,7 +216,10 @@ module.exports = { detectGpu, pullNimImage, startNimContainer, + startNimContainerByName, waitForNimHealth, stopNimContainer, + stopNimContainerByName, nimStatus, + nimStatusByName, }; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ace8d6e97..63d34a253 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -12,7 +12,9 @@ const { spawn, spawnSync } = require("child_process"); const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); const { getDefaultOllamaModel, + getBootstrapOllamaModelOptions, getLocalProviderBaseUrl, + getLocalProviderValidationBaseUrl, getOllamaModelOptions, getOllamaWarmupCommand, validateOllamaModel, @@ -28,7 +30,8 @@ const { isUnsupportedMacosRuntime, shouldPatchCoredns, } = require("./platform"); -const { prompt, ensureApiKey, getCredential } = require("./credentials"); +const { resolveOpenshell } = require("./resolve-openshell"); +const { prompt, ensureApiKey, getCredential, saveCredential } = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); @@ -37,6 +40,101 @@ const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; +let OPENSHELL_BIN = null; +const GATEWAY_NAME = "nemoclaw"; + +const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; +const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; +const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; +const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; + +const REMOTE_PROVIDER_CONFIG = { + build: { + label: "NVIDIA Endpoints", + providerName: "nvidia-prod", + providerType: "nvidia", + credentialEnv: "NVIDIA_API_KEY", + endpointUrl: BUILD_ENDPOINT_URL, + helpUrl: "https://build.nvidia.com/settings/api-keys", + modelMode: "catalog", + defaultModel: DEFAULT_CLOUD_MODEL, + }, + openai: { + label: "OpenAI", + providerName: "openai-api", + providerType: "openai", + credentialEnv: "OPENAI_API_KEY", + endpointUrl: OPENAI_ENDPOINT_URL, + helpUrl: "https://platform.openai.com/api-keys", + modelMode: "curated", + defaultModel: "gpt-5.4", + skipVerify: true, + }, + anthropic: { + label: "Anthropic", + providerName: "anthropic-prod", + providerType: "anthropic", + credentialEnv: "ANTHROPIC_API_KEY", + endpointUrl: ANTHROPIC_ENDPOINT_URL, + helpUrl: "https://console.anthropic.com/settings/keys", + modelMode: "curated", + defaultModel: "claude-sonnet-4-6", + }, + anthropicCompatible: { + label: "Other Anthropic-compatible endpoint", + providerName: "compatible-anthropic-endpoint", + providerType: "anthropic", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + endpointUrl: "", + helpUrl: null, + modelMode: "input", + defaultModel: "", + }, + gemini: { + label: "Google Gemini", + providerName: "gemini-api", + providerType: "openai", + credentialEnv: "GEMINI_API_KEY", + endpointUrl: GEMINI_ENDPOINT_URL, + helpUrl: "https://aistudio.google.com/app/apikey", + modelMode: "curated", + defaultModel: "gemini-2.5-flash", + skipVerify: true, + }, + custom: { + label: "Other OpenAI-compatible endpoint", + providerName: "compatible-endpoint", + providerType: "openai", + credentialEnv: "COMPATIBLE_API_KEY", + endpointUrl: "", + helpUrl: null, + modelMode: "input", + defaultModel: "", + skipVerify: true, + }, +}; + +const REMOTE_MODEL_OPTIONS = { + openai: [ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.4-pro-2026-03-05", + ], + anthropic: [ + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-opus-4-6", + ], + gemini: [ + "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ], +}; // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. @@ -83,13 +181,13 @@ function isSandboxReady(output, sandboxName) { * @returns {boolean} */ function hasStaleGateway(gwInfoOutput) { - return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes("nemoclaw"); + return typeof gwInfoOutput === "string" && gwInfoOutput.length > 0 && gwInfoOutput.includes(GATEWAY_NAME); } -function streamSandboxCreate(command) { +function streamSandboxCreate(command, env = process.env) { const child = spawn("bash", ["-lc", command], { cwd: ROOT, - env: process.env, + env, stdio: ["ignore", "pipe", "pipe"], }); @@ -176,6 +274,90 @@ function getStableGatewayImageRef(versionOutput = null) { return `ghcr.io/nvidia/openshell/cluster:${version}`; } +function getOpenshellBinary() { + if (OPENSHELL_BIN) return OPENSHELL_BIN; + const resolved = resolveOpenshell(); + if (!resolved) { + console.error(" openshell CLI not found."); + console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); + process.exit(1); + } + OPENSHELL_BIN = resolved; + return OPENSHELL_BIN; +} + +function openshellShellCommand(args) { + return [shellQuote(getOpenshellBinary()), ...args.map((arg) => shellQuote(arg))].join(" "); +} + +function runOpenshell(args, opts = {}) { + return run(openshellShellCommand(args), opts); +} + +function runCaptureOpenshell(args, opts = {}) { + return runCapture(openshellShellCommand(args), opts); +} + +function formatEnvAssignment(name, value) { + return `${name}=${value}`; +} + +function getCurlTimingArgs() { + return ["--connect-timeout 5", "--max-time 20"]; +} + +function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { + const args = + action === "create" + ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] + : ["provider", "update", name, "--credential", credentialEnv]; + if (baseUrl && type === "openai") { + args.push("--config", `OPENAI_BASE_URL=${baseUrl}`); + } else if (baseUrl && type === "anthropic") { + args.push("--config", `ANTHROPIC_BASE_URL=${baseUrl}`); + } + return args; +} + +function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { + const createArgs = buildProviderArgs("create", name, type, credentialEnv, baseUrl); + const createResult = runOpenshell(createArgs, { ignoreError: true, env }); + if (createResult.status === 0) return; + + const updateArgs = buildProviderArgs("update", name, type, credentialEnv, baseUrl); + const updateResult = runOpenshell(updateArgs, { ignoreError: true, env }); + if (updateResult.status !== 0) { + console.error(` Failed to create or update provider '${name}'.`); + process.exit(updateResult.status || createResult.status || 1); + } +} + +function verifyInferenceRoute(provider, model) { + const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); + if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { + console.error(" OpenShell inference route was not configured."); + process.exit(1); + } +} + +function sandboxExistsInGateway(sandboxName) { + const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); + return Boolean(output); +} + +function pruneStaleSandboxEntry(sandboxName) { + const existing = registry.getSandbox(sandboxName); + const liveExists = sandboxExistsInGateway(sandboxName); + if (existing && !liveExists) { + registry.removeSandbox(sandboxName); + } + return liveExists; +} + +function pythonLiteralJson(value) { + return JSON.stringify(JSON.stringify(value)); +} + function buildSandboxConfigSyncScript(selectionConfig) { // openclaw.json is immutable (root:root 444, Landlock read-only) — never // write to it at runtime. Model routing is handled by the host-side @@ -197,34 +379,647 @@ function writeSandboxConfigSyncFile(script, tmpDir = os.tmpdir(), now = Date.now return scriptFile; } +function encodeDockerJsonArg(value) { + return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); +} + +function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { + let providerKey = "inference"; + let primaryModelRef = model; + let inferenceBaseUrl = "https://inference.local/v1"; + let inferenceApi = preferredInferenceApi || "openai-completions"; + let inferenceCompat = null; + + switch (provider) { + case "openai-api": + providerKey = "openai"; + primaryModelRef = `openai/${model}`; + break; + case "anthropic-prod": + case "compatible-anthropic-endpoint": + providerKey = "anthropic"; + primaryModelRef = `anthropic/${model}`; + inferenceBaseUrl = "https://inference.local"; + inferenceApi = "anthropic-messages"; + break; + case "gemini-api": + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "compatible-endpoint": + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + inferenceCompat = { + supportsStore: false, + }; + break; + case "nvidia-prod": + case "nvidia-nim": + default: + providerKey = "inference"; + primaryModelRef = `inference/${model}`; + break; + } + + return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; +} + +function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { + const { + providerKey, + primaryModelRef, + inferenceBaseUrl, + inferenceApi, + inferenceCompat, + } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MODEL=.*$/m, + `ARG NEMOCLAW_MODEL=${model}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROVIDER_KEY=.*$/m, + `ARG NEMOCLAW_PROVIDER_KEY=${providerKey}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PRIMARY_MODEL_REF=.*$/m, + `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}` + ); + dockerfile = dockerfile.replace( + /^ARG CHAT_UI_URL=.*$/m, + `ARG CHAT_UI_URL=${chatUiUrl}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, + `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_API=.*$/m, + `ARG NEMOCLAW_INFERENCE_API=${inferenceApi}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INFERENCE_COMPAT_B64=.*$/m, + `ARG NEMOCLAW_INFERENCE_COMPAT_B64=${encodeDockerJsonArg(inferenceCompat)}` + ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_BUILD_ID=.*$/m, + `ARG NEMOCLAW_BUILD_ID=${buildId}` + ); + fs.writeFileSync(dockerfilePath, dockerfile); +} + +function summarizeProbeError(body, status) { + if (!body) return `HTTP ${status} with no response body`; + try { + const parsed = JSON.parse(body); + const message = + parsed?.error?.message || + parsed?.error?.details || + parsed?.message || + parsed?.detail || + parsed?.details; + if (message) return `HTTP ${status}: ${String(message)}`; + } catch {} + const compact = String(body).replace(/\s+/g, " ").trim(); + return `HTTP ${status}: ${compact.slice(0, 200)}`; +} + +function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { + const probes = [ + { + name: "Responses API", + api: "openai-responses", + url: `${String(endpointUrl).replace(/\/+$/, "")}/responses`, + body: JSON.stringify({ + model, + input: "Reply with exactly: OK", + }), + }, + { + name: "Chat Completions API", + api: "openai-completions", + url: `${String(endpointUrl).replace(/\/+$/, "")}/chat/completions`, + body: JSON.stringify({ + model, + messages: [ + { role: "user", content: "Reply with exactly: OK" }, + ], + }), + }, + ]; + + const failures = []; + for (const probe of probes) { + const bodyFile = path.join(os.tmpdir(), `nemoclaw-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + try { + const cmd = [ + "curl -sS", + ...getCurlTimingArgs(), + `-o ${shellQuote(bodyFile)}`, + "-w '%{http_code}'", + "-H 'Content-Type: application/json'", + ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), + `-d ${shellQuote(probe.body)}`, + shellQuote(probe.url), + ].join(" "); + const result = spawnSync("bash", ["-c", cmd], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_PROBE_API_KEY: apiKey, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const status = Number(String(result.stdout || "").trim()); + if (result.status === 0 && status >= 200 && status < 300) { + return { ok: true, api: probe.api, label: probe.name }; + } + failures.push({ + name: probe.name, + httpStatus: Number.isFinite(status) ? status : 0, + curlStatus: result.status || 0, + message: summarizeProbeError(body, status || result.status || 0), + }); + } finally { + fs.rmSync(bodyFile, { force: true }); + } + } + + return { + ok: false, + message: failures.map((failure) => `${failure.name}: ${failure.message}`).join(" | "), + failures, + }; +} + +function probeAnthropicEndpoint(endpointUrl, model, apiKey) { + const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-probe-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + try { + const cmd = [ + "curl -sS", + ...getCurlTimingArgs(), + `-o ${shellQuote(bodyFile)}`, + "-w '%{http_code}'", + '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', + "-H 'anthropic-version: 2023-06-01'", + "-H 'content-type: application/json'", + `-d ${shellQuote(JSON.stringify({ + model, + max_tokens: 16, + messages: [{ role: "user", content: "Reply with exactly: OK" }], + }))}`, + shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/messages`), + ].join(" "); + const result = spawnSync("bash", ["-c", cmd], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_PROBE_API_KEY: apiKey, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const status = Number(String(result.stdout || "").trim()); + if (result.status === 0 && status >= 200 && status < 300) { + return { ok: true, api: "anthropic-messages", label: "Anthropic Messages API" }; + } + return { + ok: false, + message: summarizeProbeError(body, status || result.status || 0), + failures: [ + { + name: "Anthropic Messages API", + httpStatus: Number.isFinite(status) ? status : 0, + curlStatus: result.status || 0, + }, + ], + }; + } finally { + fs.rmSync(bodyFile, { force: true }); + } +} + +function shouldRetryProviderSelection(probe) { + const failures = Array.isArray(probe?.failures) ? probe.failures : []; + if (failures.length === 0) return true; + return failures.some((failure) => { + if ((failure.curlStatus || 0) !== 0) return true; + return [0, 401, 403, 404].includes(failure.httpStatus || 0); + }); +} + +async function validateOpenAiLikeSelection( + label, + endpointUrl, + model, + credentialEnv = null, + retryMessage = "Please choose a provider/model again." +) { + const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; + const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); + if (!probe.ok) { + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(` ${retryMessage}`); + console.log(""); + return null; + } + console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); + return probe.api; +} + +async function validateAnthropicSelection(label, endpointUrl, model, credentialEnv) { + const apiKey = getCredential(credentialEnv); + const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + if (!probe.ok) { + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Please choose a provider/model again."); + console.log(""); + return null; + } + console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); + return probe.api; +} + +async function validateAnthropicSelectionWithRetryMessage( + label, + endpointUrl, + model, + credentialEnv, + retryMessage = "Please choose a provider/model again." +) { + const apiKey = getCredential(credentialEnv); + const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + if (!probe.ok) { + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(` ${retryMessage}`); + console.log(""); + return null; + } + console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); + return probe.api; +} + +async function validateCustomOpenAiLikeSelection(label, endpointUrl, model, credentialEnv) { + const apiKey = getCredential(credentialEnv); + const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey); + if (probe.ok) { + console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); + return { ok: true, api: probe.api }; + } + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + if (shouldRetryProviderSelection(probe)) { + console.log(" Please choose a provider/model again."); + console.log(""); + return { ok: false, retry: "selection" }; + } + console.log(` Please enter a different ${label} model name.`); + console.log(""); + return { ok: false, retry: "model" }; +} + +async function validateCustomAnthropicSelection(label, endpointUrl, model, credentialEnv) { + const apiKey = getCredential(credentialEnv); + const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); + if (probe.ok) { + console.log(` ${probe.label} available — OpenClaw will use ${probe.api}.`); + return { ok: true, api: probe.api }; + } + console.error(` ${label} endpoint validation failed.`); + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + if (shouldRetryProviderSelection(probe)) { + console.log(" Please choose a provider/model again."); + console.log(""); + return { ok: false, retry: "selection" }; + } + console.log(` Please enter a different ${label} model name.`); + console.log(""); + return { ok: false, retry: "model" }; +} + +function fetchNvidiaEndpointModels(apiKey) { + const bodyFile = path.join(os.tmpdir(), `nemoclaw-nvidia-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + try { + const cmd = [ + "curl -sS", + ...getCurlTimingArgs(), + `-o ${shellQuote(bodyFile)}`, + "-w '%{http_code}'", + "-H 'Content-Type: application/json'", + '-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"', + shellQuote(`${BUILD_ENDPOINT_URL}/models`), + ].join(" "); + const result = spawnSync("bash", ["-c", cmd], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_PROBE_API_KEY: apiKey, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const status = Number(String(result.stdout || "").trim()); + if (result.status !== 0 || !(status >= 200 && status < 300)) { + return { ok: false, message: summarizeProbeError(body, status || result.status || 0) }; + } + const parsed = JSON.parse(body); + const ids = Array.isArray(parsed?.data) + ? parsed.data.map((item) => item && item.id).filter(Boolean) + : []; + return { ok: true, ids }; + } catch (error) { + return { ok: false, message: error.message || String(error) }; + } finally { + fs.rmSync(bodyFile, { force: true }); + } +} + +function validateNvidiaEndpointModel(model, apiKey) { + const available = fetchNvidiaEndpointModels(apiKey); + if (!available.ok) { + return { + ok: false, + message: `Could not validate model against ${BUILD_ENDPOINT_URL}/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from NVIDIA Endpoints. Checked ${BUILD_ENDPOINT_URL}/models.`, + }; +} + +function fetchOpenAiLikeModels(endpointUrl, apiKey) { + const bodyFile = path.join(os.tmpdir(), `nemoclaw-openai-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + try { + const cmd = [ + "curl -sS", + ...getCurlTimingArgs(), + `-o ${shellQuote(bodyFile)}`, + "-w '%{http_code}'", + ...(apiKey ? ['-H "Authorization: Bearer $NEMOCLAW_PROBE_API_KEY"'] : []), + shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/models`), + ].join(" "); + const result = spawnSync("bash", ["-c", cmd], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_PROBE_API_KEY: apiKey, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const status = Number(String(result.stdout || "").trim()); + if (result.status !== 0 || !(status >= 200 && status < 300)) { + return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + } + const parsed = JSON.parse(body); + const ids = Array.isArray(parsed?.data) + ? parsed.data.map((item) => item && item.id).filter(Boolean) + : []; + return { ok: true, ids }; + } catch (error) { + return { ok: false, status: 0, message: error.message || String(error) }; + } finally { + fs.rmSync(bodyFile, { force: true }); + } +} + +function fetchAnthropicModels(endpointUrl, apiKey) { + const bodyFile = path.join(os.tmpdir(), `nemoclaw-anthropic-models-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + try { + const cmd = [ + "curl -sS", + ...getCurlTimingArgs(), + `-o ${shellQuote(bodyFile)}`, + "-w '%{http_code}'", + '-H "x-api-key: $NEMOCLAW_PROBE_API_KEY"', + "-H 'anthropic-version: 2023-06-01'", + shellQuote(`${String(endpointUrl).replace(/\/+$/, "")}/v1/models`), + ].join(" "); + const result = spawnSync("bash", ["-c", cmd], { + cwd: ROOT, + encoding: "utf8", + env: { + ...process.env, + NEMOCLAW_PROBE_API_KEY: apiKey, + }, + }); + const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; + const status = Number(String(result.stdout || "").trim()); + if (result.status !== 0 || !(status >= 200 && status < 300)) { + return { ok: false, status, message: summarizeProbeError(body, status || result.status || 0) }; + } + const parsed = JSON.parse(body); + const ids = Array.isArray(parsed?.data) + ? parsed.data.map((item) => item && (item.id || item.name)).filter(Boolean) + : []; + return { ok: true, ids }; + } catch (error) { + return { ok: false, status: 0, message: error.message || String(error) }; + } finally { + fs.rmSync(bodyFile, { force: true }); + } +} + +function validateAnthropicModel(endpointUrl, model, apiKey) { + const available = fetchAnthropicModels(endpointUrl, apiKey); + if (!available.ok) { + if (available.status === 404 || available.status === 405) { + return { ok: true, validated: false }; + } + return { + ok: false, + message: `Could not validate model against ${String(endpointUrl).replace(/\/+$/, "")}/v1/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true, validated: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from Anthropic. Checked ${String(endpointUrl).replace(/\/+$/, "")}/v1/models.`, + }; +} + +function validateOpenAiLikeModel(label, endpointUrl, model, apiKey) { + const available = fetchOpenAiLikeModels(endpointUrl, apiKey); + if (!available.ok) { + if (available.status === 404 || available.status === 405) { + return { ok: true, validated: false }; + } + return { + ok: false, + message: `Could not validate model against ${String(endpointUrl).replace(/\/+$/, "")}/models: ${available.message}`, + }; + } + if (available.ids.includes(model)) { + return { ok: true, validated: true }; + } + return { + ok: false, + message: `Model '${model}' is not available from ${label}. Checked ${String(endpointUrl).replace(/\/+$/, "")}/models.`, + }; +} + +async function promptManualModelId(promptLabel, errorLabel, validator = null) { + while (true) { + const manual = await prompt(promptLabel); + const trimmed = manual.trim(); + if (!trimmed || !isSafeModelId(trimmed)) { + console.error(` Invalid ${errorLabel} model id.`); + continue; + } + if (validator) { + const validation = validator(trimmed); + if (!validation.ok) { + console.error(` ${validation.message}`); + continue; + } + } + return trimmed; + } +} + async function promptCloudModel() { console.log(""); console.log(" Cloud models:"); CLOUD_MODEL_OPTIONS.forEach((option, index) => { console.log(` ${index + 1}) ${option.label} (${option.id})`); }); + console.log(` ${CLOUD_MODEL_OPTIONS.length + 1}) Other...`); console.log(""); const choice = await prompt(" Choose model [1]: "); const index = parseInt(choice || "1", 10) - 1; - return (CLOUD_MODEL_OPTIONS[index] || CLOUD_MODEL_OPTIONS[0]).id; + if (index >= 0 && index < CLOUD_MODEL_OPTIONS.length) { + return CLOUD_MODEL_OPTIONS[index].id; + } + + return promptManualModelId( + " NVIDIA Endpoints model id: ", + "NVIDIA Endpoints", + (model) => validateNvidiaEndpointModel(model, getCredential("NVIDIA_API_KEY")) + ); +} + +async function promptRemoteModel(label, providerKey, defaultModel, validator = null) { + const options = REMOTE_MODEL_OPTIONS[providerKey] || []; + const defaultIndex = Math.max(0, options.indexOf(defaultModel)); + + console.log(""); + console.log(` ${label} models:`); + options.forEach((option, index) => { + console.log(` ${index + 1}) ${option}`); + }); + console.log(` ${options.length + 1}) Other...`); + console.log(""); + + const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); + const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; + if (index >= 0 && index < options.length) { + return options[index]; + } + + return promptManualModelId(` ${label} model id: `, label, validator); } -async function promptOllamaModel() { - const options = getOllamaModelOptions(runCapture); - const defaultModel = getDefaultOllamaModel(runCapture); +async function promptInputModel(label, defaultModel, validator = null) { + while (true) { + const value = await prompt(` ${label} model [${defaultModel}]: `); + const trimmed = (value || defaultModel).trim(); + if (!trimmed || !isSafeModelId(trimmed)) { + console.error(` Invalid ${label} model id.`); + continue; + } + if (validator) { + const validation = validator(trimmed); + if (!validation.ok) { + console.error(` ${validation.message}`); + continue; + } + } + return trimmed; + } +} + +async function promptOllamaModel(gpu = null) { + const installed = getOllamaModelOptions(runCapture); + const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); + const defaultModel = getDefaultOllamaModel(runCapture, gpu); const defaultIndex = Math.max(0, options.indexOf(defaultModel)); console.log(""); - console.log(" Ollama models:"); + console.log(installed.length > 0 ? " Ollama models:" : " Ollama starter models:"); options.forEach((option, index) => { console.log(` ${index + 1}) ${option}`); }); + console.log(` ${options.length + 1}) Other...`); + if (installed.length === 0) { + console.log(""); + console.log(" No local Ollama models are installed yet. Choose one to pull and load now."); + } console.log(""); const choice = await prompt(` Choose model [${defaultIndex + 1}]: `); const index = parseInt(choice || String(defaultIndex + 1), 10) - 1; - return options[index] || options[defaultIndex] || defaultModel; + if (index >= 0 && index < options.length) { + return options[index]; + } + return promptManualModelId(" Ollama model id: ", "Ollama"); +} + +function pullOllamaModel(model) { + const result = spawnSync("bash", ["-c", `ollama pull ${shellQuote(model)}`], { + cwd: ROOT, + encoding: "utf8", + stdio: "inherit", + env: { ...process.env }, + }); + return result.status === 0; +} + +function prepareOllamaModel(model, installedModels = []) { + const alreadyInstalled = installedModels.includes(model); + if (!alreadyInstalled) { + console.log(` Pulling Ollama model: ${model}`); + if (!pullOllamaModel(model)) { + return { + ok: false, + message: + `Failed to pull Ollama model '${model}'. ` + + "Check the model name and that Ollama can access the registry, then try another model.", + }; + } + } + + console.log(` Loading Ollama model: ${model}`); + run(getOllamaWarmupCommand(model), { ignoreError: true }); + return validateOllamaModel(model, runCapture); } function isDockerRunning() { @@ -242,12 +1037,7 @@ function getContainerRuntime() { } function isOpenshellInstalled() { - try { - runCapture("command -v openshell"); - return true; - } catch { - return false; - } + return resolveOpenshell() !== null; } function getFutureShellPathHint(binDir, pathValue = process.env.PATH || "") { @@ -284,15 +1074,48 @@ function installOpenshell() { localBin, futureShellPathHint, }; + OPENSHELL_BIN = resolveOpenshell(); + return { + installed: OPENSHELL_BIN !== null, + localBin, + futureShellPathHint, + }; } function sleep(seconds) { require("child_process").spawnSync("sleep", [String(seconds)]); } +async function ensureNamedCredential(envName, label, helpUrl = null) { + let key = getCredential(envName); + if (key) { + process.env[envName] = key; + return key; + } + + if (helpUrl) { + console.log(""); + console.log(` Get your ${label} from: ${helpUrl}`); + console.log(""); + } + + key = await prompt(` ${label}: `, { secret: true }); + if (!key) { + console.error(` ${label} is required.`); + process.exit(1); + } + + saveCredential(envName, key); + process.env[envName] = key; + console.log(""); + console.log(` Key saved to ~/.nemoclaw/credentials.json (mode 600)`); + console.log(""); + return key; +} + function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { for (let i = 0; i < attempts; i += 1) { - const exists = runCapture(`openshell sandbox get "${sandboxName}" 2>/dev/null`, { ignoreError: true }); + const exists = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); if (exists) return true; sleep(delaySeconds); } @@ -313,15 +1136,21 @@ function isSafeModelId(value) { function getNonInteractiveProvider() { const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); if (!providerKey) return null; - - const validProviders = new Set(["cloud", "ollama", "vllm", "nim"]); - if (!validProviders.has(providerKey)) { + const aliases = { + cloud: "build", + nim: "nim-local", + vllm: "vllm", + anthropiccompatible: "anthropicCompatible", + }; + const normalized = aliases[providerKey] || providerKey; + const validProviders = new Set(["build", "openai", "anthropic", "anthropicCompatible", "gemini", "ollama", "custom", "nim-local", "vllm"]); + if (!validProviders.has(normalized)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error(" Valid values: cloud, ollama, vllm, nim"); + console.error(" Valid values: build, openai, anthropic, anthropicCompatible, gemini, ollama, custom, nim-local, vllm"); process.exit(1); } - return providerKey; + return normalized; } function getNonInteractiveModel(providerKey) { @@ -369,7 +1198,7 @@ async function preflight() { process.exit(1); } } - console.log(` ✓ openshell CLI: ${runCapture("openshell --version 2>/dev/null || echo unknown", { ignoreError: true })}`); + console.log(` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`); if (openshellInstall.futureShellPathHint) { console.log(` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`); console.log(` Future shells may still need: ${openshellInstall.futureShellPathHint}`); @@ -380,11 +1209,11 @@ async function preflight() { // A previous onboard run may have left the gateway container and port // forward running. If a NemoClaw-owned gateway is still present, tear // it down so the port check below doesn't fail on our own leftovers. - const gwInfo = runCapture("openshell gateway info -g nemoclaw 2>/dev/null", { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); if (hasStaleGateway(gwInfo)) { console.log(" Cleaning up previous NemoClaw session..."); - run("openshell forward stop 18789 2>/dev/null || true", { ignoreError: true }); - run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); + runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); console.log(" ✓ Previous session cleaned up"); } @@ -440,12 +1269,12 @@ async function preflight() { // ── Step 2: Gateway ────────────────────────────────────────────── async function startGateway(gpu) { - step(2, 7, "Starting OpenShell gateway"); + step(3, 7, "Starting OpenShell gateway"); // Destroy old gateway - run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); + runOpenshell(["gateway", "destroy", "-g", GATEWAY_NAME], { ignoreError: true }); - const gwArgs = ["--name", "nemoclaw"]; + const gwArgs = ["--name", GATEWAY_NAME]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is // routed through a host-side provider (Ollama, vLLM, or cloud API) — the // sandbox itself does not need direct GPU access. Passing --gpu causes @@ -462,14 +1291,11 @@ async function startGateway(gpu) { console.log(` Using pinned OpenShell gateway image: ${stableGatewayImage}`); } - run(`openshell gateway start ${gwArgs.join(" ")}`, { - ignoreError: false, - env: gatewayEnv, - }); + runOpenshell(["gateway", "start", ...gwArgs], { ignoreError: false, env: gatewayEnv }); // Verify health for (let i = 0; i < 5; i++) { - const status = runCapture("openshell status 2>&1", { ignoreError: true }); + const status = runCaptureOpenshell(["status"], { ignoreError: true }); if (status.includes("Connected")) { console.log(" ✓ Gateway is healthy"); break; @@ -485,16 +1311,18 @@ async function startGateway(gpu) { const runtime = getContainerRuntime(); if (shouldPatchCoredns(runtime)) { console.log(" Patching CoreDNS for Colima..."); - run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" nemoclaw 2>&1 || true`, { ignoreError: true }); + run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" ${GATEWAY_NAME} 2>&1 || true`, { ignoreError: true }); } // Give DNS a moment to propagate sleep(5); + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; } // ── Step 3: Sandbox ────────────────────────────────────────────── -async function createSandbox(gpu) { - step(3, 7, "Creating sandbox"); +async function createSandbox(gpu, model, provider, preferredInferenceApi = null) { + step(5, 7, "Creating sandbox"); const nameAnswer = await promptOrDefault( " Sandbox name (lowercase, numbers, hyphens) [my-assistant]: ", @@ -511,9 +1339,10 @@ async function createSandbox(gpu) { process.exit(1); } - // Check if sandbox already exists in registry - const existing = registry.getSandbox(sandboxName); - if (existing) { + // Reconcile local registry state with the live OpenShell gateway state. + const liveExists = pruneStaleSandboxEntry(sandboxName); + + if (liveExists) { if (isNonInteractive()) { if (process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { console.error(` Sandbox '${sandboxName}' already exists.`); @@ -529,7 +1358,7 @@ async function createSandbox(gpu) { } } // Destroy old sandbox - run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); + runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); registry.removeSandbox(sandboxName); } @@ -537,7 +1366,8 @@ async function createSandbox(gpu) { const { mkdtempSync } = require("fs"); const os = require("os"); const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); - fs.copyFileSync(path.join(ROOT, "Dockerfile"), path.join(buildCtx, "Dockerfile")); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + fs.copyFileSync(path.join(ROOT, "Dockerfile"), stagedDockerfile); run(`cp -r "${path.join(ROOT, "nemoclaw")}" "${buildCtx}/nemoclaw"`); run(`cp -r "${path.join(ROOT, "nemoclaw-blueprint")}" "${buildCtx}/nemoclaw-blueprint"`); run(`cp -r "${path.join(ROOT, "scripts")}" "${buildCtx}/scripts"`); @@ -547,34 +1377,43 @@ async function createSandbox(gpu) { // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); const createArgs = [ - `--from "${buildCtx}/Dockerfile"`, - `--name "${sandboxName}"`, - `--policy "${basePolicyPath}"`, + "--from", `${buildCtx}/Dockerfile`, + "--name", sandboxName, + "--policy", basePolicyPath, ]; // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - const chatUiUrl = process.env.CHAT_UI_URL || 'http://127.0.0.1:18789'; - const envArgs = [`CHAT_UI_URL=${shellQuote(chatUiUrl)}`]; + const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; + patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; + const sandboxEnv = { ...process.env }; if (process.env.NVIDIA_API_KEY) { - envArgs.push(`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)}`); + sandboxEnv.NVIDIA_API_KEY = process.env.NVIDIA_API_KEY; } const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { - envArgs.push(`DISCORD_BOT_TOKEN=${shellQuote(discordToken)}`); + sandboxEnv.DISCORD_BOT_TOKEN = discordToken; } const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; if (slackToken) { - envArgs.push(`SLACK_BOT_TOKEN=${shellQuote(slackToken)}`); + sandboxEnv.SLACK_BOT_TOKEN = slackToken; } // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline // command (awk, always 0) unless pipefail is set. Removing the pipe // lets the real exit code flow through to run(). - const createResult = await streamSandboxCreate( - `openshell sandbox create ${createArgs.join(" ")} -- env ${envArgs.join(" ")} nemoclaw-start 2>&1` - ); + const createCommand = `${openshellShellCommand([ + "sandbox", + "create", + ...createArgs, + "--", + "env", + ...envArgs, + "nemoclaw-start", + ])} 2>&1`; + const createResult = await streamSandboxCreate(createCommand, sandboxEnv); // Clean up build context regardless of outcome run(`rm -rf "${buildCtx}"`, { ignoreError: true }); @@ -598,7 +1437,7 @@ async function createSandbox(gpu) { console.log(" Waiting for sandbox to become ready..."); let ready = false; for (let i = 0; i < 30; i++) { - const list = runCapture("openshell sandbox list 2>&1", { ignoreError: true }); + const list = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); if (isSandboxReady(list, sandboxName)) { ready = true; break; @@ -609,7 +1448,7 @@ async function createSandbox(gpu) { if (!ready) { // Clean up the orphaned sandbox so the next onboard retry with the same // name doesn't fail on "sandbox already exists". - const delResult = run(`openshell sandbox delete "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); + const delResult = runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); console.error(""); console.error(` Sandbox '${sandboxName}' was created but did not become ready within 60s.`); if (delResult.status === 0) { @@ -625,9 +1464,9 @@ async function createSandbox(gpu) { // Release any stale forward on port 18789 before claiming it for the new sandbox. // A previous onboard run may have left the port forwarded to a different sandbox, // which would silently prevent the new sandbox's dashboard from being reachable. - run(`openshell forward stop 18789 2>/dev/null || true`, { ignoreError: true }); + runOpenshell(["forward", "stop", "18789"], { ignoreError: true }); // Forward dashboard port to the new sandbox - run(`openshell forward start --background 18789 "${sandboxName}"`, { ignoreError: true }); + runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -641,30 +1480,34 @@ async function createSandbox(gpu) { // ── Step 4: NIM ────────────────────────────────────────────────── -async function setupNim(sandboxName, gpu) { - step(4, 7, "Configuring inference (NIM)"); +async function setupNim(gpu) { + step(2, 7, "Configuring inference (NIM)"); let model = null; - let provider = "nvidia-nim"; + let provider = REMOTE_PROVIDER_CONFIG.build.providerName; let nimContainer = null; + let endpointUrl = REMOTE_PROVIDER_CONFIG.build.endpointUrl; + let credentialEnv = REMOTE_PROVIDER_CONFIG.build.credentialEnv; + let preferredInferenceApi = null; // 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 }); const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; - const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "cloud") : null; - // Build options list — only show local options with NEMOCLAW_EXPERIMENTAL=1 + const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; const options = []; - if (EXPERIMENTAL && gpu && gpu.nimCapable) { - options.push({ key: "nim", label: "Local NIM container (NVIDIA GPU) [experimental]" }); - } options.push({ - key: "cloud", + key: "build", label: - "NVIDIA Endpoint API (build.nvidia.com)" + + "NVIDIA Endpoints" + (!ollamaRunning && !(EXPERIMENTAL && vllmRunning) ? " (recommended)" : ""), }); + options.push({ key: "openai", label: "OpenAI" }); + options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); + options.push({ key: "anthropic", label: "Anthropic" }); + options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); + options.push({ key: "gemini", label: "Google Gemini" }); if (hasOllama || ollamaRunning) { options.push({ key: "ollama", @@ -673,10 +1516,13 @@ async function setupNim(sandboxName, gpu) { (ollamaRunning ? " (suggested)" : ""), }); } + if (EXPERIMENTAL && gpu && gpu.nimCapable) { + options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); + } if (EXPERIMENTAL && vllmRunning) { options.push({ key: "vllm", - label: "Existing vLLM instance (localhost:8000) — running [experimental] (suggested)", + label: "Local vLLM [experimental] — running", }); } @@ -686,10 +1532,12 @@ async function setupNim(sandboxName, gpu) { } if (options.length > 1) { + selectionLoop: + while (true) { let selected; if (isNonInteractive()) { - const providerKey = requestedProvider || "cloud"; + const providerKey = requestedProvider || "build"; selected = options.find((o) => o.key === providerKey); if (!selected) { console.error(` Requested provider '${providerKey}' is not available in this environment.`); @@ -702,7 +1550,7 @@ async function setupNim(sandboxName, gpu) { if (ollamaRunning) suggestions.push("Ollama"); if (suggestions.length > 0) { console.log(` Detected local inference option${suggestions.length > 1 ? "s" : ""}: ${suggestions.join(", ")}`); - console.log(" Select one explicitly to use it. Press Enter to keep the cloud default."); + console.log(" Select one explicitly to use it. Press Enter to keep NVIDIA Endpoints."); console.log(""); } @@ -713,13 +1561,144 @@ async function setupNim(sandboxName, gpu) { }); console.log(""); - const defaultIdx = options.findIndex((o) => o.key === "cloud") + 1; + const defaultIdx = options.findIndex((o) => o.key === "build") + 1; const choice = await prompt(` Choose [${defaultIdx}]: `); const idx = parseInt(choice || String(defaultIdx), 10) - 1; selected = options[idx] || options[defaultIdx - 1]; } - if (selected.key === "nim") { + if (REMOTE_PROVIDER_CONFIG[selected.key]) { + const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; + provider = remoteConfig.providerName; + credentialEnv = remoteConfig.credentialEnv; + endpointUrl = remoteConfig.endpointUrl; + preferredInferenceApi = null; + + if (selected.key === "custom") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" OpenAI-compatible base URL (e.g., https://openrouter.ai/api/v1): "); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other OpenAI-compatible endpoint."); + process.exit(1); + } + } else if (selected.key === "anthropicCompatible") { + endpointUrl = isNonInteractive() + ? (process.env.NEMOCLAW_ENDPOINT_URL || "").trim() + : await prompt(" Anthropic-compatible base URL (e.g., https://proxy.example.com): "); + if (!endpointUrl) { + console.error(" Endpoint URL is required for Other Anthropic-compatible endpoint."); + process.exit(1); + } + } + + if (selected.key === "build") { + if (isNonInteractive()) { + if (!process.env.NVIDIA_API_KEY) { + console.error(" NVIDIA_API_KEY is required for NVIDIA Endpoints in non-interactive mode."); + process.exit(1); + } + } else { + await ensureApiKey(); + } + model = requestedModel || (isNonInteractive() ? DEFAULT_CLOUD_MODEL : await promptCloudModel()) || DEFAULT_CLOUD_MODEL; + } else { + if (isNonInteractive()) { + if (!process.env[credentialEnv]) { + console.error(` ${credentialEnv} is required for ${remoteConfig.label} in non-interactive mode.`); + process.exit(1); + } + } else { + await ensureNamedCredential(credentialEnv, remoteConfig.label + " API key", remoteConfig.helpUrl); + } + const defaultModel = requestedModel || remoteConfig.defaultModel; + let modelValidator = null; + if (selected.key === "openai" || selected.key === "gemini") { + modelValidator = (candidate) => + validateOpenAiLikeModel(remoteConfig.label, endpointUrl, candidate, getCredential(credentialEnv)); + } else if (selected.key === "anthropic") { + modelValidator = (candidate) => + validateAnthropicModel(endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, getCredential(credentialEnv)); + } + while (true) { + if (isNonInteractive()) { + model = defaultModel; + } else if (remoteConfig.modelMode === "curated") { + model = await promptRemoteModel(remoteConfig.label, selected.key, defaultModel, modelValidator); + } else { + model = await promptInputModel(remoteConfig.label, defaultModel, modelValidator); + } + + if (selected.key === "custom") { + const validation = await validateCustomOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else if (selected.key === "anthropicCompatible") { + const validation = await validateCustomAnthropicSelection( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv + ); + if (validation.ok) { + preferredInferenceApi = validation.api; + break; + } + if (validation.retry === "selection") { + continue selectionLoop; + } + } else { + const retryMessage = "Please choose a provider/model again."; + if (selected.key === "anthropic") { + preferredInferenceApi = await validateAnthropicSelectionWithRetryMessage( + remoteConfig.label, + endpointUrl || ANTHROPIC_ENDPOINT_URL, + model, + credentialEnv, + retryMessage + ); + } else { + preferredInferenceApi = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv, + retryMessage + ); + } + if (preferredInferenceApi) { + break; + } + continue selectionLoop; + } + } + } + + if (selected.key === "build") { + preferredInferenceApi = await validateOpenAiLikeSelection( + remoteConfig.label, + endpointUrl, + model, + credentialEnv + ); + if (!preferredInferenceApi) { + continue selectionLoop; + } + } + + console.log(` Using ${remoteConfig.label} with model: ${model}`); + break; + } else if (selected.key === "nim-local") { // List models that fit GPU VRAM const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); if (models.length === 0) { @@ -755,7 +1734,7 @@ async function setupNim(sandboxName, gpu) { nim.pullNimImage(model); console.log(" Starting NIM container..."); - nimContainer = nim.startNimContainer(sandboxName, model); + nimContainer = nim.startNimContainerByName(nim.containerName(GATEWAY_NAME), model); console.log(" Waiting for NIM to become healthy..."); if (!nim.waitForNimHealth()) { @@ -764,8 +1743,20 @@ async function setupNim(sandboxName, gpu) { nimContainer = null; } else { provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local NVIDIA NIM", + endpointUrl, + model, + credentialEnv + ); + if (!preferredInferenceApi) { + continue selectionLoop; + } } } + break; } else if (selected.key === "ollama") { if (!ollamaRunning) { console.log(" Starting Ollama..."); @@ -774,11 +1765,38 @@ async function setupNim(sandboxName, gpu) { } console.log(" ✓ Using Ollama on localhost:11434"); provider = "ollama-local"; - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture); - } else { - model = await promptOllamaModel(); + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other." + ); + if (!preferredInferenceApi) { + continue; + } + break; } + break; } else if (selected.key === "install-ollama") { console.log(" Installing Ollama via Homebrew..."); run("brew install ollama", { ignoreError: true }); @@ -787,14 +1805,43 @@ async function setupNim(sandboxName, gpu) { sleep(2); console.log(" ✓ Using Ollama on localhost:11434"); provider = "ollama-local"; - if (isNonInteractive()) { - model = requestedModel || getDefaultOllamaModel(runCapture); - } else { - model = await promptOllamaModel(); + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); + while (true) { + const installedModels = getOllamaModelOptions(runCapture); + if (isNonInteractive()) { + model = requestedModel || getDefaultOllamaModel(runCapture, gpu); + } else { + model = await promptOllamaModel(gpu); + } + const probe = prepareOllamaModel(model, installedModels); + if (!probe.ok) { + console.error(` ${probe.message}`); + if (isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different Ollama model or select Other."); + console.log(""); + continue; + } + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local Ollama", + getLocalProviderValidationBaseUrl(provider), + model, + null, + "Choose a different Ollama model or select Other." + ); + if (!preferredInferenceApi) { + continue; + } + break; } + break; } else if (selected.key === "vllm") { console.log(" ✓ Using existing vLLM on localhost:8000"); provider = "vllm-local"; + credentialEnv = "OPENAI_API_KEY"; + endpointUrl = getLocalProviderBaseUrl(provider); // Query vLLM for the actual model ID const vllmModelsRaw = runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); try { @@ -814,48 +1861,43 @@ async function setupNim(sandboxName, gpu) { console.error(" Could not query vLLM models endpoint. Is vLLM running on localhost:8000?"); process.exit(1); } - } - // else: cloud — fall through to default below - } - - if (provider === "nvidia-nim") { - if (isNonInteractive()) { - // In non-interactive mode, NVIDIA_API_KEY must be set via env var - if (!process.env.NVIDIA_API_KEY) { - console.error(" NVIDIA_API_KEY is required for cloud provider in non-interactive mode."); - console.error(" Set it via: NVIDIA_API_KEY=nvapi-... nemoclaw onboard --non-interactive"); - process.exit(1); + preferredInferenceApi = await validateOpenAiLikeSelection( + "Local vLLM", + getLocalProviderValidationBaseUrl(provider), + model, + credentialEnv + ); + if (!preferredInferenceApi) { + continue selectionLoop; } - } else { - await ensureApiKey(); - model = model || (await promptCloudModel()) || DEFAULT_CLOUD_MODEL; + break; } - model = model || requestedModel || DEFAULT_CLOUD_MODEL; - console.log(` Using NVIDIA Endpoint API with model: ${model}`); + } } - registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - - return { model, provider }; + return { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer }; } // ── Step 5: Inference provider ─────────────────────────────────── -async function setupInference(sandboxName, model, provider) { - step(5, 7, "Setting up inference provider"); - - if (provider === "nvidia-nim") { - // Create nvidia-nim provider - run( - `openshell provider create --name nvidia-nim --type openai ` + - `--credential ${shellQuote("NVIDIA_API_KEY")} ` + - `--config "OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1" 2>&1 || true`, - { ignoreError: true } - ); - run( - `openshell inference set --no-verify --provider nvidia-nim --model ${shellQuote(model)} 2>/dev/null || true`, - { ignoreError: true } - ); +async function setupInference(sandboxName, model, provider, endpointUrl = null, credentialEnv = null) { + step(4, 7, "Setting up inference provider"); + runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); + + if (provider === "nvidia-prod" || provider === "nvidia-nim" || provider === "openai-api" || provider === "anthropic-prod" || provider === "compatible-anthropic-endpoint" || provider === "gemini-api" || provider === "compatible-endpoint") { + const config = provider === "nvidia-nim" + ? REMOTE_PROVIDER_CONFIG.build + : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); + const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); + const env = resolvedCredentialEnv ? { [resolvedCredentialEnv]: process.env[resolvedCredentialEnv] } : {}; + upsertProvider(provider, config.providerType, resolvedCredentialEnv, resolvedEndpointUrl, env); + const args = ["inference", "set"]; + if (config.skipVerify) { + args.push("--no-verify"); + } + args.push("--provider", provider, "--model", model); + runOpenshell(args); } else if (provider === "vllm-local") { const validation = validateLocalProvider(provider, runCapture); if (!validation.ok) { @@ -863,20 +1905,10 @@ async function setupInference(sandboxName, model, provider) { process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); - run( - `OPENAI_API_KEY=dummy ` + - `openshell provider create --name vllm-local --type openai ` + - `--credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` + - `OPENAI_API_KEY=dummy ` + - `openshell provider update vllm-local --credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`, - { ignoreError: true } - ); - run( - `openshell inference set --no-verify --provider vllm-local --model ${shellQuote(model)} 2>/dev/null || true`, - { ignoreError: true } - ); + upsertProvider("vllm-local", "openai", "OPENAI_API_KEY", baseUrl, { + OPENAI_API_KEY: "dummy", + }); + runOpenshell(["inference", "set", "--no-verify", "--provider", "vllm-local", "--model", model]); } else if (provider === "ollama-local") { const validation = validateLocalProvider(provider, runCapture); if (!validation.ok) { @@ -885,20 +1917,10 @@ async function setupInference(sandboxName, model, provider) { process.exit(1); } const baseUrl = getLocalProviderBaseUrl(provider); - run( - `OPENAI_API_KEY=ollama ` + - `openshell provider create --name ollama-local --type openai ` + - `--credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` + - `OPENAI_API_KEY=ollama ` + - `openshell provider update ollama-local --credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`, - { ignoreError: true } - ); - run( - `openshell inference set --no-verify --provider ollama-local --model ${shellQuote(model)} 2>/dev/null || true`, - { ignoreError: true } - ); + upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, { + OPENAI_API_KEY: "ollama", + }); + runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]); console.log(` Priming Ollama model: ${model}`); run(getOllamaWarmupCommand(model), { ignoreError: true }); const probe = validateOllamaModel(model, runCapture); @@ -908,6 +1930,7 @@ async function setupInference(sandboxName, model, provider) { } } + verifyInferenceRoute(provider, model); registry.updateSandbox(sandboxName, { model, provider }); console.log(` ✓ Inference route set: ${provider} / ${model}`); } @@ -926,9 +1949,10 @@ async function setupOpenclaw(sandboxName, model, provider) { const script = buildSandboxConfigSyncScript(sandboxConfig); const scriptFile = writeSandboxConfigSyncFile(script); try { - run(`openshell sandbox connect "${sandboxName}" < ${shellQuote(scriptFile)}`, { - stdio: ["ignore", "ignore", "inherit"], - }); + run( + `${openshellShellCommand(["sandbox", "connect", sandboxName])} < ${shellQuote(scriptFile)}`, + { stdio: ["ignore", "ignore", "inherit"] } + ); } finally { fs.unlinkSync(scriptFile); } @@ -1006,6 +2030,11 @@ async function setupPolicies(sandboxName) { break; } catch (err) { const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); + throw err; + } if (!message.includes("sandbox not found") || attempt === 2) { throw err; } @@ -1035,12 +2064,30 @@ async function setupPolicies(sandboxName) { 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); + try { + policies.applyPreset(sandboxName, name); + } catch (err) { + const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); + } + throw err; + } } } else { // Apply suggested for (const name of suggestions) { - policies.applyPreset(sandboxName, name); + try { + policies.applyPreset(sandboxName, name); + } catch (err) { + const message = err && err.message ? err.message : String(err); + if (message.includes("Unimplemented")) { + console.error(" OpenShell policy updates are not supported by this gateway build."); + console.error(" This is a known issue tracked in NemoClaw #536."); + } + throw err; + } } } } @@ -1050,12 +2097,17 @@ async function setupPolicies(sandboxName) { // ── Dashboard ──────────────────────────────────────────────────── -function printDashboard(sandboxName, model, provider) { - const nimStat = nim.nimStatus(sandboxName); +function printDashboard(sandboxName, model, provider, nimContainer = null) { + const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; let providerLabel = provider; - if (provider === "nvidia-nim") providerLabel = "NVIDIA Endpoint API"; + if (provider === "nvidia-prod" || provider === "nvidia-nim") providerLabel = "NVIDIA Endpoints"; + else if (provider === "openai-api") providerLabel = "OpenAI"; + else if (provider === "anthropic-prod") providerLabel = "Anthropic"; + else if (provider === "compatible-anthropic-endpoint") providerLabel = "Other Anthropic-compatible endpoint"; + else if (provider === "gemini-api") providerLabel = "Google Gemini"; + else if (provider === "compatible-endpoint") providerLabel = "Other OpenAI-compatible endpoint"; else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; @@ -1078,6 +2130,7 @@ function printDashboard(sandboxName, model, provider) { async function onboard(opts = {}) { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + delete process.env.OPENSHELL_GATEWAY; console.log(""); console.log(" NemoClaw Onboarding"); @@ -1085,23 +2138,33 @@ async function onboard(opts = {}) { console.log(" ==================="); const gpu = await preflight(); + const { model, provider, endpointUrl, credentialEnv, preferredInferenceApi, nimContainer } = await setupNim(gpu); + process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); await startGateway(gpu); - const sandboxName = await createSandbox(gpu); - const { model, provider } = await setupNim(sandboxName, gpu); - await setupInference(sandboxName, model, provider); + await setupInference(GATEWAY_NAME, model, provider, endpointUrl, credentialEnv); + const sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi); + if (nimContainer) { + registry.updateSandbox(sandboxName, { nimContainer }); + } await setupOpenclaw(sandboxName, model, provider); await setupPolicies(sandboxName); - printDashboard(sandboxName, model, provider); + printDashboard(sandboxName, model, provider, nimContainer); } module.exports = { buildSandboxConfigSyncScript, getFutureShellPathHint, + createSandbox, + getSandboxInferenceConfig, getInstalledOpenshellVersion, getStableGatewayImageRef, hasStaleGateway, isSandboxReady, onboard, + pruneStaleSandboxEntry, + runCaptureOpenshell, + setupInference, setupNim, writeSandboxConfigSyncFile, + patchStagedDockerfile, }; diff --git a/bin/lib/policies.js b/bin/lib/policies.js index 240294bda..50d94b66f 100644 --- a/bin/lib/policies.js +++ b/bin/lib/policies.js @@ -11,6 +11,12 @@ 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 @@ -77,14 +83,14 @@ function parseCurrentPolicy(raw) { * Build the openshell policy set command with properly quoted arguments. */ function buildPolicySetCommand(policyFile, sandboxName) { - return `openshell policy set --policy ${shellQuote(policyFile)} --wait ${shellQuote(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 `openshell policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; + return `${getOpenshellCommand()} policy get --full ${shellQuote(sandboxName)} 2>/dev/null`; } function applyPreset(sandboxName, presetName) { diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 7f191413d..4ceeebbbd 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -6,6 +6,44 @@ const net = require("net"); const { runCapture } = require("./runner"); +async function probePortAvailability(port, opts = {}) { + if (typeof opts.probeImpl === "function") { + return opts.probeImpl(port); + } + + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once("error", (err) => { + if (err.code === "EADDRINUSE") { + resolve({ + ok: false, + process: "unknown", + pid: null, + reason: `port ${port} is in use (EADDRINUSE)`, + }); + return; + } + + if (err.code === "EPERM" || err.code === "EACCES") { + resolve({ + ok: true, + warning: `port probe skipped: ${err.message}`, + }); + return; + } + + // Unexpected probe failure: do not report a false conflict. + resolve({ + ok: true, + warning: `port probe inconclusive: ${err.message}`, + }); + }); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve({ ok: true })); + }); + }); +} + /** * Check whether a TCP port is available for listening. * @@ -15,9 +53,11 @@ const { runCapture } = require("./runner"); * * opts.lsofOutput — inject fake lsof output for testing (skips shell) * opts.skipLsof — force the net-probe fallback path + * opts.probeImpl — async (port) => probe result for testing * * Returns: * { ok: true } + * { ok: true, warning: string } * { ok: false, process: string, pid: number|null, reason: string } */ async function checkPortAvailable(port, opts) { @@ -62,30 +102,7 @@ async function checkPortAvailable(port, opts) { } // ── net probe fallback ───────────────────────────────────────── - return new Promise((resolve) => { - const srv = net.createServer(); - srv.once("error", (err) => { - if (err.code === "EADDRINUSE") { - resolve({ - ok: false, - process: "unknown", - pid: null, - reason: `port ${p} is in use (EADDRINUSE)`, - }); - } else { - // Unexpected error — treat port as unavailable - resolve({ - ok: false, - process: "unknown", - pid: null, - reason: `port probe failed: ${err.message}`, - }); - } - }); - srv.listen(p, "127.0.0.1", () => { - srv.close(() => resolve({ ok: true })); - }); - }); + return probePortAvailability(p, o); } -module.exports = { checkPortAvailable }; +module.exports = { checkPortAvailable, probePortAvailability }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 2010cfeb2..380bdf677 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -318,7 +318,7 @@ function sandboxStatus(sandboxName) { run(`openshell sandbox get ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); // NIM health - const nimStat = nim.nimStatus(sandboxName); + const nimStat = sb && sb.nimContainer ? nim.nimStatusByName(sb.nimContainer) : nim.nimStatus(sandboxName); console.log(` NIM: ${nimStat.running ? `running (${nimStat.container})` : "not running"}`); if (nimStat.running) { console.log(` Healthy: ${nimStat.healthy ? "yes" : "no"}`); @@ -380,7 +380,9 @@ async function sandboxDestroy(sandboxName, args = []) { } console.log(` Stopping NIM for '${sandboxName}'...`); - nim.stopNimContainer(sandboxName); + const sb = registry.getSandbox(sandboxName); + if (sb && sb.nimContainer) nim.stopNimContainerByName(sb.nimContainer); + else nim.stopNimContainer(sandboxName); console.log(` Deleting sandbox '${sandboxName}'...`); run(`openshell sandbox delete ${shellQuote(sandboxName)} 2>/dev/null || true`, { ignoreError: true }); diff --git a/docs/about/how-it-works.md b/docs/about/how-it-works.md index a05fb9476..1c566983c 100644 --- a/docs/about/how-it-works.md +++ b/docs/about/how-it-works.md @@ -113,7 +113,8 @@ After the sandbox starts, the agent runs inside it with all network, filesystem, Inference requests from the agent never leave the sandbox directly. OpenShell intercepts every inference call and routes it to the configured provider. -NemoClaw routes inference to NVIDIA Endpoints, specifically Nemotron 3 Super 120B through [build.nvidia.com](https://build.nvidia.com). You can switch models at runtime without restarting the sandbox. +During onboarding, NemoClaw validates the selected provider and model, configures the OpenShell route, and bakes the matching model reference into the sandbox image. +The sandbox then talks to `inference.local`, while the host owns the actual provider credential and upstream endpoint. ## Network and Filesystem Policy diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index 582c7bf13..89ed8aef2 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -30,14 +30,46 @@ No restart is required. ## Switch to a Different Model -Set the provider to `nvidia-nim` and specify a model from [build.nvidia.com](https://build.nvidia.com): +Switching happens through the OpenShell inference route. +Use the provider and model that match the upstream you want to use. + +### NVIDIA Endpoints + +```console +$ openshell inference set --provider nvidia-prod --model nvidia/nemotron-3-super-120b-a12b +``` + +### OpenAI + +```console +$ openshell inference set --provider openai-api --model gpt-5.4 +``` + +### Anthropic + +```console +$ openshell inference set --provider anthropic-prod --model claude-sonnet-4-6 +``` + +### Google Gemini ```console -$ openshell inference set --provider nvidia-nim --model nvidia/nemotron-3-super-120b-a12b +$ openshell inference set --provider gemini-api --model gemini-2.5-flash ``` -This requires the `NVIDIA_API_KEY` environment variable. -The `nemoclaw onboard` command stores this key in `~/.nemoclaw/credentials.json` on first run. +### Compatible Endpoints + +If you onboarded a custom compatible endpoint, switch models with the provider created for that endpoint: + +```console +$ openshell inference set --provider compatible-endpoint --model +``` + +```console +$ openshell inference set --provider compatible-anthropic-endpoint --model +``` + +If the provider itself needs to change, rerun `nemoclaw onboard`. ## Verify the Active Model @@ -55,17 +87,11 @@ $ nemoclaw status --json The output includes the active provider, model, and endpoint. -## Available Models - -The following table lists the models registered with the `nvidia-nim` provider. -You can switch to any of these models at runtime. +## Notes -| Model ID | Label | Context Window | Max Output | -|---|---|---|---| -| `nvidia/nemotron-3-super-120b-a12b` | Nemotron 3 Super 120B | 131,072 | 8,192 | -| `nvidia/llama-3.1-nemotron-ultra-253b-v1` | Nemotron Ultra 253B | 131,072 | 4,096 | -| `nvidia/llama-3.3-nemotron-super-49b-v1.5` | Nemotron Super 49B v1.5 | 131,072 | 4,096 | -| `nvidia/nemotron-3-nano-30b-a3b` | Nemotron 3 Nano 30B | 131,072 | 4,096 | +- The host keeps provider credentials. +- The sandbox continues to use `inference.local`. +- Runtime switching changes the OpenShell route. It does not rewrite your stored credentials. ## Related Topics diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5acfa8198..82f92405f 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -44,7 +44,9 @@ Use this command for new installs and for recreating a sandbox after changes to $ nemoclaw onboard ``` -The first run prompts for your NVIDIA API key and saves it to `~/.nemoclaw/credentials.json`. +The wizard prompts for a provider first, then collects the provider credential if needed. +Supported non-experimental choices include NVIDIA Endpoints, OpenAI, Anthropic, Google Gemini, and compatible OpenAI or Anthropic endpoints. +Credentials are stored in `~/.nemoclaw/credentials.json`. The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. diff --git a/docs/reference/inference-profiles.md b/docs/reference/inference-profiles.md index 028a47e0f..eb518b94e 100644 --- a/docs/reference/inference-profiles.md +++ b/docs/reference/inference-profiles.md @@ -1,9 +1,9 @@ --- title: - page: "NemoClaw Inference Profiles — NVIDIA Endpoint" + page: "NemoClaw Inference Profiles" nav: "Inference Profiles" -description: "Configuration reference for NVIDIA Endpoint inference profiles." -keywords: ["nemoclaw inference profiles", "nemoclaw nvidia endpoint provider"] +description: "Configuration reference for NemoClaw routed inference providers." +keywords: ["nemoclaw inference profiles", "nemoclaw provider routing"] topics: ["generative_ai", "ai_agents"] tags: ["openclaw", "openshell", "inference_routing", "llms"] content: @@ -20,54 +20,66 @@ status: published # Inference Profiles -NemoClaw ships with an inference profile defined in `blueprint.yaml`. -The profile configures an OpenShell inference provider and model route. -The agent inside the sandbox uses whichever model is active. -Inference requests are routed transparently through the OpenShell gateway. +NemoClaw configures inference through the OpenShell gateway. +The agent inside the sandbox talks to `inference.local`, and OpenShell routes that traffic to the provider you selected during onboarding. -## Profile Summary +## Routed Provider Model -| Profile | Provider | Model | Endpoint | Use Case | -|---|---|---|---|---| -| `default` | NVIDIA Endpoint | `nvidia/nemotron-3-super-120b-a12b` | `integrate.api.nvidia.com` | Production. Requires an NVIDIA API key. | +NemoClaw keeps provider credentials on the host. +The sandbox does not receive your raw OpenAI, Anthropic, Gemini, or NVIDIA API key. -## Available Models +At onboard time, NemoClaw configures: -The `nvidia-nim` provider registers the following models from [build.nvidia.com](https://build.nvidia.com): +- an OpenShell provider +- an OpenShell inference route +- the baked OpenClaw model reference inside the sandbox -| Model ID | Label | Context Window | Max Output | -|---|---|---|---| -| `nvidia/nemotron-3-super-120b-a12b` | Nemotron 3 Super 120B | 131,072 | 8,192 | -| `nvidia/llama-3.1-nemotron-ultra-253b-v1` | Nemotron Ultra 253B | 131,072 | 4,096 | -| `nvidia/llama-3.3-nemotron-super-49b-v1.5` | Nemotron Super 49B v1.5 | 131,072 | 4,096 | -| `nvidia/nemotron-3-nano-30b-a3b` | Nemotron 3 Nano 30B | 131,072 | 4,096 | +That means the sandbox knows which model family to use, while OpenShell owns the actual provider credential and upstream endpoint. -The default profile uses Nemotron 3 Super 120B. -You can switch to any model in the catalog at runtime. +## Supported Providers -## `default` -- NVIDIA Endpoint +The following non-experimental provider paths are available through `nemoclaw onboard`. -The default profile routes inference to NVIDIA's hosted API through [build.nvidia.com](https://build.nvidia.com). +| Provider | Endpoint Type | Notes | +|---|---|---| +| NVIDIA Endpoints | OpenAI-compatible | Hosted models on `integrate.api.nvidia.com` | +| OpenAI | Native OpenAI-compatible | Uses OpenAI model IDs | +| Other OpenAI-compatible endpoint | Custom OpenAI-compatible | For compatible proxies and gateways | +| Anthropic | Native Anthropic | Uses `anthropic-messages` | +| Other Anthropic-compatible endpoint | Custom Anthropic-compatible | For Claude proxies and compatible gateways | +| Google Gemini | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | -- **Provider type:** `nvidia` -- **Endpoint:** `https://integrate.api.nvidia.com/v1` -- **Model:** `nvidia/nemotron-3-super-120b-a12b` -- **Credential:** `NVIDIA_API_KEY` environment variable +## Validation During Onboarding -Get an API key from [build.nvidia.com](https://build.nvidia.com). -The `nemoclaw onboard` command prompts for this key and stores it in `~/.nemoclaw/credentials.json`. +NemoClaw validates the selected provider and model before it creates the sandbox. -```console -$ openshell inference set --provider nvidia-nim --model nvidia/nemotron-3-super-120b-a12b -``` +- OpenAI-compatible providers: + NemoClaw tries `/responses` first, then `/chat/completions`. +- Anthropic-compatible providers: + NemoClaw tries `/v1/messages`. +- NVIDIA Endpoints manual model entry: + NemoClaw also validates the model name against `https://integrate.api.nvidia.com/v1/models`. +- Compatible endpoint flows: + NemoClaw validates by sending a real inference request, because many proxies do not expose a reliable `/models` endpoint. -## Switching Models at Runtime +If validation fails, the wizard does not continue to sandbox creation. -After the sandbox is running, switch models with the OpenShell CLI: +## Local Providers -```console -$ openshell inference set --provider nvidia-nim --model -``` +Local providers are available behind the `NEMOCLAW_EXPERIMENTAL=1` gate. +These use the same routed `inference.local` pattern, but the upstream runtime is local to the host. -The change takes effect immediately. -No sandbox restart is needed. +- Local Ollama +- Local NVIDIA NIM +- Local vLLM + +Ollama gets additional onboarding help: + +- if no models are installed, NemoClaw offers starter models +- it pulls the selected model +- it warms the model +- it validates the model before continuing + +## Runtime Switching + +For runtime switching guidance, refer to [Switch Inference Models](../inference/switch-inference-providers.md). diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 16f345423..aa654184a 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -165,7 +165,8 @@ Check the active provider and endpoint: $ nemoclaw status ``` -If the endpoint is correct but requests still fail, check for network policy rules that may block the connection, and verify that your NVIDIA API key is valid. +If the endpoint is correct but requests still fail, check for network policy rules that may block the connection. +Then verify the credential and base URL for the provider you selected during onboarding. ### Agent cannot reach an external host diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index a292ea8ad..8a8ac9f09 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -249,7 +249,7 @@ export default function register(api: OpenClawPluginApi): void { api.registerProvider(registeredProviderForConfig(onboardCfg, providerCredentialEnv)); const bannerEndpoint = onboardCfg ? describeOnboardEndpoint(onboardCfg) : "build.nvidia.com"; - const bannerProvider = onboardCfg ? describeOnboardProvider(onboardCfg) : "NVIDIA Endpoint API"; + const bannerProvider = onboardCfg ? describeOnboardProvider(onboardCfg) : "NVIDIA Endpoints"; const bannerModel = onboardCfg?.model ?? "nvidia/nemotron-3-super-120b-a12b"; api.logger.info(""); diff --git a/nemoclaw/src/onboard/config.test.ts b/nemoclaw/src/onboard/config.test.ts index 3b45e2c92..d098f0571 100644 --- a/nemoclaw/src/onboard/config.test.ts +++ b/nemoclaw/src/onboard/config.test.ts @@ -84,12 +84,15 @@ describe("onboard/config", () => { }); const endpointCases: [EndpointType, string][] = [ - ["build", "NVIDIA Endpoint API"], + ["build", "NVIDIA Endpoints"], + ["openai", "OpenAI"], + ["anthropic", "Anthropic"], + ["gemini", "Google Gemini"], ["ollama", "Local Ollama"], ["vllm", "Local vLLM"], - ["nim-local", "Local NIM"], + ["nim-local", "Local NVIDIA NIM"], ["ncp", "NVIDIA Cloud Partner"], - ["custom", "Managed Inference Route"], + ["custom", "Other OpenAI-compatible endpoint"], ]; for (const [endpointType, expected] of endpointCases) { @@ -98,6 +101,16 @@ describe("onboard/config", () => { expect(describeOnboardProvider(config)).toBe(expected); }); } + + it("returns Unknown for unsupported endpoint types", () => { + const config = makeConfig({ + endpointType: "build", + providerLabel: undefined, + }); + expect(describeOnboardProvider({ ...config, endpointType: "bogus" as EndpointType })).toBe( + "Unknown", + ); + }); }); // ------------------------------------------------------------------------- diff --git a/nemoclaw/src/onboard/config.ts b/nemoclaw/src/onboard/config.ts index 30da5e651..888714433 100644 --- a/nemoclaw/src/onboard/config.ts +++ b/nemoclaw/src/onboard/config.ts @@ -2,11 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -const CONFIG_DIR = join(process.env.HOME ?? "/tmp", ".nemoclaw"); +let configDir = join(process.env.HOME ?? tmpdir(), ".nemoclaw"); -export type EndpointType = "build" | "ncp" | "nim-local" | "vllm" | "ollama" | "custom"; +export type EndpointType = + | "build" + | "openai" + | "anthropic" + | "gemini" + | "ncp" + | "nim-local" + | "vllm" + | "ollama" + | "custom"; export interface NemoClawOnboardConfig { endpointType: EndpointType; @@ -35,17 +45,23 @@ export function describeOnboardProvider(config: NemoClawOnboardConfig): string { switch (config.endpointType) { case "build": - return "NVIDIA Endpoint API"; + return "NVIDIA Endpoints"; + case "openai": + return "OpenAI"; + case "anthropic": + return "Anthropic"; + case "gemini": + return "Google Gemini"; case "ollama": return "Local Ollama"; case "vllm": return "Local vLLM"; case "nim-local": - return "Local NIM"; + return "Local NVIDIA NIM"; case "ncp": return "NVIDIA Cloud Partner"; case "custom": - return "Managed Inference Route"; + return "Other OpenAI-compatible endpoint"; default: return "Unknown"; } @@ -55,14 +71,21 @@ let configDirCreated = false; function ensureConfigDir(): void { if (configDirCreated) return; - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); + if (!existsSync(configDir)) { + try { + mkdirSync(configDir, { recursive: true }); + } catch { + configDir = join(tmpdir(), ".nemoclaw"); + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + } } configDirCreated = true; } function configPath(): string { - return join(CONFIG_DIR, "config.json"); + return join(configDir, "config.json"); } export function loadOnboardConfig(): NemoClawOnboardConfig | null { diff --git a/package-lock.json b/package-lock.json index 1d54cdc1f..d2527c969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -941,6 +941,14 @@ "scripts/actions/documentation" ] }, + "node_modules/@buape/carbon/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@buape/carbon/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", @@ -1333,6 +1341,14 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@discordjs/voice/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", diff --git a/scripts/setup-spark.sh b/scripts/setup-spark.sh index 2cbcf6e55..9ab3ed80a 100755 --- a/scripts/setup-spark.sh +++ b/scripts/setup-spark.sh @@ -138,3 +138,5 @@ fi echo "" info "DGX Spark Docker configuration complete." info "" +info "Next step: run 'nemoclaw onboard' to set up your sandbox." +info " nemoclaw onboard" diff --git a/spark-install.md b/spark-install.md index 7974cba04..1eeb1187e 100644 --- a/spark-install.md +++ b/spark-install.md @@ -2,33 +2,20 @@ > **WIP** — This page is actively being updated as we work through Spark installs. Expect changes. -## Prerequisites - -- **Docker** (pre-installed, v28.x) -- **Node.js 22** (installed by the install.sh) -- **OpenShell CLI** (installed via the Quick Start steps below) -- **NVIDIA API Key** from [build.nvidia.com](https://build.nvidia.com) — prompted on first run - ## Quick Start ```bash -# Install OpenShell: -curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - -# Clone NemoClaw: +# Clone and install git clone https://github.com/NVIDIA/NemoClaw.git cd NemoClaw +sudo npm install -g . -# Spark-specific setup -sudo ./scripts/setup-spark.sh - -# Install NemoClaw using the NemoClaw/install.sh: -./install.sh - -# Alternatively, you can use the hosted install script: -curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash +# Spark-specific setup (configures Docker for cgroup v2, then runs normal setup) +nemoclaw setup-spark ``` +That's it. `setup-spark` handles everything below automatically. + ## What's Different on Spark DGX Spark ships **Ubuntu 24.04 + Docker 28.x** but no k8s/k3s. OpenShell embeds k3s inside a Docker container, which hits two problems on Spark: @@ -55,6 +42,28 @@ Failed to start ContainerManager: failed to initialize top level QOS containers **Fix**: `setup-spark` sets `"default-cgroupns-mode": "host"` in `/etc/docker/daemon.json` and restarts Docker. This makes all containers use the host cgroup namespace, which is what k3s needs. +## Prerequisites + +These should already be on your Spark: + +- **Docker** (pre-installed, v28.x) +- **Node.js 22** — if not installed: + + ```bash + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + ``` + +- **OpenShell CLI**: + + ```bash + ARCH=$(uname -m) # aarch64 on Spark + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/openshell-linux-${ARCH}" -o /usr/local/bin/openshell + chmod +x /usr/local/bin/openshell + ``` + +- **NVIDIA API Key** from [build.nvidia.com](https://build.nvidia.com) — prompted on first run + ## Manual Setup (if setup-spark doesn't work) ### Fix Docker cgroup namespace diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index a6de7e62d..7008cd554 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -66,23 +66,26 @@ describe("credential exposure in process arguments", () => { it("onboard.js --credential flags pass env var names only", () => { const src = fs.readFileSync(ONBOARD_JS, "utf-8"); - // Find all --credential arguments and verify they contain only a key name - // (no "=" sign in the credential value) - const credentialArgs = src.match(/--credential\s+"([^"]+)"/g) || []; - const credentialShellQuote = - src.match(/--credential\s+\$\{shellQuote\("([^"]+)"\)\}/g) || []; + expect(src).toMatch(/"--credential", credentialEnv/); + expect(src).not.toMatch(/"--credential",\s*["'][A-Z_]+=/); + expect(src).not.toMatch(/"--credential",\s*process\.env\./); + }); + + it("onboard.js does not embed sandbox secrets in the sandbox create command line", () => { + const src = fs.readFileSync(ONBOARD_JS, "utf-8"); - const allArgs = [...credentialArgs, ...credentialShellQuote]; - expect(allArgs.length).toBeGreaterThan(0); + expect(src).toMatch(/const sandboxEnv = \{ \.\.\.process\.env \};/); + expect(src).toMatch(/streamSandboxCreate\(createCommand, sandboxEnv\)/); + expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("NVIDIA_API_KEY"/); + expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("DISCORD_BOT_TOKEN"/); + expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("SLACK_BOT_TOKEN"/); + }); + + it("onboard.js curl probes use explicit timeouts", () => { + const src = fs.readFileSync(ONBOARD_JS, "utf-8"); - for (const arg of allArgs) { - // Extract the credential value from the match - const valueMatch = - arg.match(/--credential\s+"([^"]+)"/) || - arg.match(/--credential\s+\$\{shellQuote\("([^"]+)"\)\}/); - if (valueMatch) { - expect(valueMatch[1]).not.toContain("="); - } - } + expect(src).toMatch(/function getCurlTimingArgs\(\)/); + expect(src).toMatch(/--connect-timeout 5/); + expect(src).toMatch(/--max-time 20/); }); }); diff --git a/test/credentials.test.js b/test/credentials.test.js index b5e56c516..932092f11 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { describe, it, expect } from "vitest"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -28,4 +29,15 @@ describe("credential prompts", () => { expect(result.status).toBe(0); }); + + it("settles the outer prompt promise on secret prompt errors", () => { + const source = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "credentials.js"), + "utf-8" + ); + + expect(source).toMatch(/return new Promise\(\(resolve, reject\) => \{/); + expect(source).toMatch(/reject\(err\);\s*process\.kill\(process\.pid, "SIGINT"\);/); + expect(source).toMatch(/reject\(err\);\s*\}\);/); + }); }); diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index 6e7b6f33a..afb534f8d 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -5,7 +5,7 @@ # Full E2E: install → onboard → verify inference (REAL services, no mocks) # # Proves the COMPLETE user journey including real inference against -# the NVIDIA Endpoint API. Runs install.sh --non-interactive which handles +# NVIDIA Endpoints. Runs install.sh --non-interactive which handles # Node.js, openshell, NemoClaw, and onboard setup automatically. # # Prerequisites: @@ -17,7 +17,7 @@ # NEMOCLAW_NON_INTERACTIVE=1 — required (enables non-interactive install + onboard) # NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-nightly) # NEMOCLAW_RECREATE_SANDBOX=1 — recreate sandbox if it exists from a previous run -# NVIDIA_API_KEY — required for NVIDIA Endpoint API inference +# NVIDIA_API_KEY — required for NVIDIA Endpoints inference # # Usage: # NEMOCLAW_NON_INTERACTIVE=1 NVIDIA_API_KEY=nvapi-... bash test/e2e/test-full-e2e.sh @@ -154,11 +154,15 @@ wait $tail_pid 2>/dev/null || true # Source shell profile to pick up nvm/PATH changes from install.sh if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null source "$HOME/.bashrc" 2>/dev/null || true fi # Ensure nvm is loaded in current shell export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" -[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi # Ensure ~/.local/bin is on PATH (openshell may be installed there in non-interactive mode) if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then export PATH="$HOME/.local/bin:$PATH" @@ -187,9 +191,11 @@ else exit 1 fi -nemoclaw --help >/dev/null 2>&1 \ - && pass "nemoclaw --help exits 0" \ - || fail "nemoclaw --help failed" +if nemoclaw --help >/dev/null 2>&1; then + pass "nemoclaw --help exits 0" +else + fail "nemoclaw --help failed" +fi # ══════════════════════════════════════════════════════════════════ # Phase 3: Sandbox verification @@ -198,16 +204,17 @@ section "Phase 3: Sandbox verification" # 3a: nemoclaw list if list_output=$(nemoclaw list 2>&1); then - echo "$list_output" | grep -Fq -- "$SANDBOX_NAME" \ - && pass "nemoclaw list contains '${SANDBOX_NAME}'" \ - || fail "nemoclaw list does not contain '${SANDBOX_NAME}'" + if echo "$list_output" | grep -Fq -- "$SANDBOX_NAME"; then + pass "nemoclaw list contains '${SANDBOX_NAME}'" + else + fail "nemoclaw list does not contain '${SANDBOX_NAME}'" + fi else fail "nemoclaw list failed: ${list_output:0:200}" fi # 3b: nemoclaw status -status_output=$(nemoclaw "$SANDBOX_NAME" status 2>&1) -if [ $? -eq 0 ]; then +if status_output=$(nemoclaw "$SANDBOX_NAME" status 2>&1); then pass "nemoclaw ${SANDBOX_NAME} status exits 0" else fail "nemoclaw ${SANDBOX_NAME} status failed: ${status_output:0:200}" @@ -216,23 +223,29 @@ fi # 3c: Inference must be configured by onboard (no fallback — if onboard # failed to configure it, that's a bug we want to catch) if inf_check=$(openshell inference get 2>&1); then - echo "$inf_check" | grep -qi "nvidia-nim" \ - && pass "Inference configured via onboard" \ - || fail "Inference not configured — onboard did not set up nvidia-nim provider" + if echo "$inf_check" | grep -qi "nvidia-prod"; then + pass "Inference configured via onboard" + else + fail "Inference not configured — onboard did not set up nvidia-prod provider" + fi else fail "openshell inference get failed: ${inf_check:0:200}" fi # 3d: Policy presets applied if policy_output=$(openshell policy get --full "$SANDBOX_NAME" 2>&1); then - echo "$policy_output" | grep -qi "network_policies" \ - && pass "Policy applied to sandbox" \ - || fail "No network policy found on sandbox" + if echo "$policy_output" | grep -qi "network_policies"; then + pass "Policy applied to sandbox" + else + fail "No network policy found on sandbox" + fi # Check that at least npm or pypi preset endpoints are present (onboard auto-suggests these) - echo "$policy_output" | grep -qi "registry.npmjs.org\|pypi.org" \ - && pass "Policy presets (npm/pypi) detected in sandbox policy" \ - || skip "Could not confirm npm/pypi presets in policy (may vary by environment)" + if echo "$policy_output" | grep -qi "registry.npmjs.org\|pypi.org"; then + pass "Policy presets (npm/pypi) detected in sandbox policy" + else + skip "Could not confirm npm/pypi presets in policy (may vary by environment)" + fi else fail "openshell policy get failed: ${policy_output:0:200}" fi @@ -242,7 +255,7 @@ fi # ══════════════════════════════════════════════════════════════════ section "Phase 4: Live inference" -# ── Test 4a: Direct NVIDIA Endpoint API ── +# ── Test 4a: Direct NVIDIA Endpoints ── info "[LIVE] Direct API test → integrate.api.nvidia.com..." api_response=$(curl -s --max-time 30 \ -X POST https://integrate.api.nvidia.com/v1/chat/completions \ @@ -292,7 +305,7 @@ if [ -n "$sandbox_response" ]; then sandbox_content=$(echo "$sandbox_response" | parse_chat_content 2>/dev/null) || true if echo "$sandbox_content" | grep -qi "PONG"; then pass "[LIVE] Sandbox inference: model responded with PONG through sandbox" - info "Full path proven: user → sandbox → openshell gateway → NVIDIA Endpoint API → response" + info "Full path proven: user → sandbox → openshell gateway → NVIDIA Endpoints → response" else fail "[LIVE] Sandbox inference: expected PONG, got: ${sandbox_content:0:200}" fi @@ -329,9 +342,11 @@ nemoclaw "$SANDBOX_NAME" destroy 2>&1 | tail -3 || true openshell gateway destroy -g nemoclaw 2>/dev/null || true list_after=$(nemoclaw list 2>&1) -echo "$list_after" | grep -Fq -- "$SANDBOX_NAME" \ - && fail "Sandbox ${SANDBOX_NAME} still in list after destroy" \ - || pass "Sandbox ${SANDBOX_NAME} removed" +if echo "$list_after" | grep -Fq -- "$SANDBOX_NAME"; then + fail "Sandbox ${SANDBOX_NAME} still in list after destroy" +else + pass "Sandbox ${SANDBOX_NAME} removed" +fi # ══════════════════════════════════════════════════════════════════ # Summary diff --git a/test/inference-config.test.js b/test/inference-config.test.js index 2c4ad5c5d..9d16dbadc 100644 --- a/test/inference-config.test.js +++ b/test/inference-config.test.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { describe, it, expect } from "vitest"; import { @@ -50,11 +51,98 @@ describe("inference selection config", () => { profile: DEFAULT_ROUTE_PROFILE, credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, provider: "nvidia-nim", - providerLabel: "NVIDIA Endpoint API", + providerLabel: "NVIDIA Endpoints", }); }); + it("maps compatible-anthropic-endpoint to the sandbox inference route", () => { + assert.deepEqual(getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), { + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "claude-sonnet-proxy", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + provider: "compatible-anthropic-endpoint", + providerLabel: "Other Anthropic-compatible endpoint", + }); + }); + + it("maps the remaining hosted providers to the sandbox inference route", () => { + expect(getProviderSelectionConfig("openai-api", "gpt-5.4-mini")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "gpt-5.4-mini", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "OPENAI_API_KEY", + provider: "openai-api", + providerLabel: "OpenAI", + }); + + expect(getProviderSelectionConfig("anthropic-prod", "claude-sonnet-4-6")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "claude-sonnet-4-6", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "ANTHROPIC_API_KEY", + provider: "anthropic-prod", + providerLabel: "Anthropic", + }); + + expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "gemini-2.5-pro", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "GEMINI_API_KEY", + provider: "gemini-api", + providerLabel: "Google Gemini", + }); + + expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "openrouter/auto", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_API_KEY", + provider: "compatible-endpoint", + providerLabel: "Other OpenAI-compatible endpoint", + }); + + expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "meta-llama", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + provider: "vllm-local", + providerLabel: "Local vLLM", + }); + }); + + it("returns null for unknown providers", () => { + expect(getProviderSelectionConfig("bogus-provider")).toBe(null); + }); + it("builds a qualified OpenClaw primary model for ollama-local", () => { expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe(`${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`); }); + + it("falls back to provider defaults when model is omitted", () => { + expect(getProviderSelectionConfig("openai-api").model).toBe("gpt-5.4"); + expect(getProviderSelectionConfig("anthropic-prod").model).toBe("claude-sonnet-4-6"); + expect(getProviderSelectionConfig("gemini-api").model).toBe("gemini-2.5-flash"); + expect(getProviderSelectionConfig("compatible-endpoint").model).toBe("custom-model"); + expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe("custom-anthropic-model"); + expect(getProviderSelectionConfig("vllm-local").model).toBe("vllm-local"); + }); + + it("builds a default OpenClaw primary model for non-ollama providers", () => { + expect(getOpenClawPrimaryModel("nvidia-prod")).toBe(`${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`); + }); }); diff --git a/test/local-inference.test.js b/test/local-inference.test.js index 671d9c657..fc9d73bf7 100644 --- a/test/local-inference.test.js +++ b/test/local-inference.test.js @@ -86,8 +86,8 @@ describe("local inference helpers", () => { ).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); }); - it("falls back to the default ollama model when list output is empty", () => { - expect(getOllamaModelOptions(() => "")).toEqual([DEFAULT_OLLAMA_MODEL]); + it("returns no installed ollama models when list output is empty", () => { + expect(getOllamaModelOptions(() => "")).toEqual([]); }); it("prefers the default ollama model when present", () => { diff --git a/test/onboard-selection.test.js b/test/onboard-selection.test.js index 9f252a116..8fceee219 100644 --- a/test/onboard-selection.test.js +++ b/test/onboard-selection.test.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; @@ -11,11 +12,31 @@ describe("onboard provider selection UX", () => { it("prompts explicitly instead of silently auto-selecting detected Ollama", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-")); + const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "selection-check.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); @@ -65,23 +86,1222 @@ const { setupNim } = require(${onboardPath}); env: { ...process.env, HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, }, }); expect(result.status).toBe(0); expect(result.stdout.trim()).not.toBe(""); const payload = JSON.parse(result.stdout.trim()); - expect(payload.result.provider).toBe("nvidia-nim"); - expect(payload.result.model).toBe("nvidia/nemotron-3-super-120b-a12b"); - expect(payload.promptCalls).toBe(2); - expect(payload.messages[0]).toMatch(/Choose \[/); - expect(payload.messages[1]).toMatch(/Choose model \[1\]/); - expect( - payload.lines.some((line) => line.includes("Detected local inference option")) - ).toBeTruthy(); - expect( - payload.lines.some((line) => line.includes("Press Enter to keep the cloud default")) - ).toBeTruthy(); - expect(payload.lines.some((line) => line.includes("Cloud models:"))).toBeTruthy(); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.equal(payload.promptCalls, 2); + assert.match(payload.messages[0], /Choose \[/); + assert.match(payload.messages[1], /Choose model \[1\]/); + assert.ok(payload.lines.some((line) => line.includes("Detected local inference option"))); + assert.ok(payload.lines.some((line) => line.includes("Press Enter to keep NVIDIA Endpoints"))); + assert.ok(payload.lines.some((line) => line.includes("Cloud models:"))); + assert.ok(payload.lines.some((line) => line.includes("Responses API available"))); + }); + + it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-model-selection-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "build-model-selection-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models$'; then + body='{"data":[{"id":"moonshotai/kimi-k2.5"},{"id":"custom/provider-model"}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["1", "7", "custom/provider-model"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { process.env.NVIDIA_API_KEY = "nvapi-test"; }; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434/api/tags")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "nvidia-prod"); + assert.equal(payload.result.model, "custom/provider-model"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.match(payload.messages[1], /Choose model \[1\]/); + assert.match(payload.messages[2], /NVIDIA Endpoints model id:/); + assert.ok(payload.lines.some((line) => line.includes("Other..."))); + }); + + it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-build-model-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "build-model-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models$'; then + body='{"data":[{"id":"moonshotai/kimi-k2.5"},{"id":"z-ai/glm5"}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["1", "7", "bad/model", "z-ai/glm5"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => { process.env.NVIDIA_API_KEY = "nvapi-test"; }; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return ""; + if (command.includes("localhost:11434/api/tags")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.model, "z-ai/glm5"); + assert.equal(payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, 2); + assert.ok(payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints"))); + }); + + it("shows curated Gemini models and supports Other for manual entry", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-gemini-selection-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "gemini-selection-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body="" +status="404" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -d) body="$2"; shift 2 ;; + *) + url="$1" + shift + ;; + esac +done +if echo "$url" | grep -q '/chat/completions$'; then + status="200" + body='{"choices":[{"message":{"content":"OK"}}]}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + + const answers = ["6", "7", "gemini-custom"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.GEMINI_API_KEY = "gemini-secret"; + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "gemini-api"); + assert.equal(payload.result.model, "gemini-custom"); + assert.equal(payload.result.preferredInferenceApi, "openai-completions"); + assert.match(payload.messages[0], /Choose \[/); + assert.match(payload.messages[1], /Choose model \[5\]/); + assert.match(payload.messages[2], /Google Gemini model id:/); + assert.ok(payload.lines.some((line) => line.includes("Google Gemini models:"))); + assert.ok(payload.lines.some((line) => line.includes("gemini-2.5-flash"))); + assert.ok(payload.lines.some((line) => line.includes("Other..."))); + assert.ok(payload.lines.some((line) => line.includes("Chat Completions API available"))); + }); + + it("warms and validates Ollama via localhost before moving on", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-validation-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "ollama-validation-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7", "1"]; +const messages = []; +const commands = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.run = (command, opts = {}) => { + commands.push(command); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return "/usr/bin/ollama"; + if (command.includes("localhost:11434/api/tags")) return JSON.stringify({ models: [{ name: "nemotron-3-nano:30b" }] }); + if (command.includes("ollama list")) return "nemotron-3-nano:30b abc 24 GB now"; + if (command.includes("localhost:8000/v1/models")) return ""; + if (command.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines, commands })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "ollama-local"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.ok(payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b"))); + assert.ok(payload.commands.some((command) => command.includes("http://localhost:11434/api/generate"))); + }); + + it("offers starter Ollama models when none are installed and pulls the selected model", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-bootstrap-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "ollama-bootstrap-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const pullLog = path.join(tmpDir, "pulls.log"); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(fakeBin, "ollama"), + `#!/usr/bin/env bash +if [ "$1" = "pull" ]; then + echo "$2" >> ${JSON.stringify(pullLog)} + exit 0 +fi +exit 0 +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7", "1"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return "/usr/bin/ollama"; + if (command.includes("localhost:11434/api/tags")) return JSON.stringify({ models: [] }); + if (command.includes("ollama list")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + if (command.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "ollama-local"); + assert.equal(payload.result.model, "qwen2.5:7b"); + assert.ok(payload.lines.some((line) => line.includes("Ollama starter models:"))); + assert.ok(payload.lines.some((line) => line.includes("No local Ollama models are installed yet"))); + assert.ok(payload.lines.some((line) => line.includes("Pulling Ollama model: qwen2.5:7b"))); + assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b"); + }); + + it("reprompts inside the Ollama model flow when a pull fails", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "ollama-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const pullLog = path.join(tmpDir, "pulls.log"); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"resp_123"}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(fakeBin, "ollama"), + `#!/usr/bin/env bash +if [ "$1" = "pull" ]; then + echo "$2" >> ${JSON.stringify(pullLog)} + if [ "$2" = "qwen2.5:7b" ]; then + exit 1 + fi + exit 0 +fi +exit 0 +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["7", "1", "2", "llama3.2:3b"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = (command) => { + if (command.includes("command -v ollama")) return "/usr/bin/ollama"; + if (command.includes("localhost:11434/api/tags")) return JSON.stringify({ models: [] }); + if (command.includes("ollama list")) return ""; + if (command.includes("localhost:8000/v1/models")) return ""; + if (command.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "ollama-local"); + assert.equal(payload.result.model, "llama3.2:3b"); + assert.ok(payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'"))); + assert.ok(payload.lines.some((line) => line.includes("Choose a different Ollama model or select Other."))); + assert.equal(payload.messages.filter((message) => /Ollama model id:/.test(message)).length, 1); + assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b\nllama3.2:3b"); + }); + + it("reprompts for an OpenAI Other model when /models validation rejects it", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-openai-model-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "openai-model-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"ok"}' +status="200" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/models$'; then + body='{"data":[{"id":"gpt-5.4"},{"id":"gpt-5.4-mini"}]}' +elif echo "$url" | grep -q '/responses$'; then + body='{"id":"resp_123"}' +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "5", "bad-model", "gpt-5.4-mini"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.OPENAI_API_KEY = "sk-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.model, "gpt-5.4-mini"); + assert.equal(payload.messages.filter((message) => /OpenAI model id:/.test(message)).length, 2); + assert.ok(payload.lines.some((line) => line.includes("is not available from OpenAI"))); + }); + + it("reprompts for an Anthropic Other model when /v1/models validation rejects it", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-model-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "anthropic-model-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"data":[{"id":"claude-sonnet-4-6"},{"id":"claude-haiku-4-5"}]}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["4", "4", "claude-bad", "claude-haiku-4-5"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.ANTHROPIC_API_KEY = "anthropic-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.model, "claude-haiku-4-5"); + assert.equal(payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, 2); + assert.ok(payload.lines.some((line) => line.includes("is not available from Anthropic"))); + }); + + it("returns to provider selection when Anthropic live validation fails interactively", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-validation-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "anthropic-validation-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"invalid model"}}' +status="400" +outfile="" +url="" +args="$*" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/models$'; then + body='{"data":[{"id":"claude-sonnet-4-6"},{"id":"claude-haiku-4-5"}]}' + status="200" +elif echo "$url" | grep -q '/v1/messages$' && printf '%s' "$args" | grep -q 'claude-haiku-4-5'; then + body='{"id":"msg_123","content":[{"type":"text","text":"OK"}]}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["4", "", "4", "2"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.ANTHROPIC_API_KEY = "anthropic-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "anthropic-prod"); + assert.equal(payload.result.model, "claude-haiku-4-5"); + assert.ok(payload.lines.some((line) => line.includes("Anthropic endpoint validation failed"))); + assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + }); + + it("supports Other Anthropic-compatible endpoint with live validation", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-compatible-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "anthropic-compatible-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"id":"msg_123","content":[{"type":"text","text":"OK"}]}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["5", "https://proxy.example.com", "claude-sonnet-proxy"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_ANTHROPIC_API_KEY = "proxy-key"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "compatible-anthropic-endpoint"); + assert.equal(payload.result.model, "claude-sonnet-proxy"); + assert.equal(payload.result.endpointUrl, "https://proxy.example.com"); + assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); + assert.match(payload.messages[1], /Anthropic-compatible base URL/); + assert.match(payload.messages[2], /Other Anthropic-compatible endpoint model/); + assert.ok(payload.lines.some((line) => line.includes("Anthropic Messages API available"))); + }); + + it("reprompts only for model name when Other OpenAI-compatible endpoint validation fails", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-openai-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-openai-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"bad model"}}' +status="400" +outfile="" +body_arg="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -d) body_arg="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/responses$' && echo "$body_arg" | grep -q 'good-model'; then + body='{"id":"resp_123"}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["3", "https://proxy.example.com/v1", "bad-model", "good-model"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_API_KEY = "proxy-key"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "compatible-endpoint"); + assert.equal(payload.result.model, "good-model"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.ok(payload.lines.some((line) => line.includes("Other OpenAI-compatible endpoint endpoint validation failed"))); + assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other OpenAI-compatible endpoint model name."))); + assert.equal(payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, 1); + assert.equal(payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)).length, 2); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + }); + + it("reprompts only for model name when Other Anthropic-compatible endpoint validation fails", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-custom-anthropic-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "custom-anthropic-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"bad model"}}' +status="400" +outfile="" +body_arg="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + -d) body_arg="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +if echo "$url" | grep -q '/v1/messages$' && echo "$body_arg" | grep -q 'good-claude'; then + body='{"id":"msg_123","content":[{"type":"text","text":"OK"}]}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["5", "https://proxy.example.com", "bad-claude", "good-claude"]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.COMPATIBLE_ANTHROPIC_API_KEY = "proxy-key"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "compatible-anthropic-endpoint"); + assert.equal(payload.result.model, "good-claude"); + assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); + assert.ok(payload.lines.some((line) => line.includes("Other Anthropic-compatible endpoint endpoint validation failed"))); + assert.ok(payload.lines.some((line) => line.includes("Please enter a different Other Anthropic-compatible endpoint model name."))); + assert.equal(payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, 1); + assert.equal(payload.messages.filter((message) => /Other Anthropic-compatible endpoint model/.test(message)).length, 2); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + }); + + it("returns to provider selection when endpoint validation fails interactively", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-selection-retry-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "selection-retry-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='{"error":{"message":"bad request"}}' +status="400" +outfile="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) + url="$1" + shift + ;; + esac +done +if echo "$url" | grep -q 'generativelanguage.googleapis.com' && echo "$url" | grep -q '/responses$'; then + body='{"id":"ok"}' + status="200" +fi +printf '%s' "$body" > "$outfile" +printf '%s' "$status" +`, + { mode: 0o755 } + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); + +const answers = ["2", "", "6", ""]; +const messages = []; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +runner.runCapture = () => ""; + +const { setupNim } = require(${onboardPath}); + +(async () => { + process.env.OPENAI_API_KEY = "sk-test"; + process.env.GEMINI_API_KEY = "gemini-test"; + const originalLog = console.log; + const originalError = console.error; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + console.error = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim(null); + originalLog(JSON.stringify({ result, messages, lines })); + } finally { + console.log = originalLog; + console.error = originalError; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.equal(payload.result.provider, "gemini-api"); + assert.equal(payload.result.preferredInferenceApi, "openai-responses"); + assert.ok(payload.lines.some((line) => line.includes("OpenAI endpoint validation failed"))); + assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); + assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); }); }); diff --git a/test/onboard.test.js b/test/onboard.test.js index a0573702d..3917be86e 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1,16 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { buildSandboxConfigSyncScript, getFutureShellPathHint, + createSandbox, + getSandboxInferenceConfig, + getFutureShellPathHint, + createSandbox, + getSandboxInferenceConfig, getInstalledOpenshellVersion, getStableGatewayImageRef, + patchStagedDockerfile, + pruneStaleSandboxEntry, + runCaptureOpenshell, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; @@ -26,17 +36,117 @@ describe("onboard helpers", () => { onboardedAt: "2026-03-18T12:00:00.000Z", }); - // Writes NemoClaw selection config to writable ~/.nemoclaw/ - expect(script).toMatch(/cat > ~\/\.nemoclaw\/config\.json/); - expect(script).toMatch(/"model": "nemotron-3-nano:30b"/); - expect(script).toMatch(/"credentialEnv": "OPENAI_API_KEY"/); + assert.match(script, /cat > ~\/\.nemoclaw\/config\.json/); + assert.match(script, /"model": "nemotron-3-nano:30b"/); + assert.match(script, /"credentialEnv": "OPENAI_API_KEY"/); + assert.doesNotMatch(script, /cat > ~\/\.openclaw\/openclaw\.json/); + assert.doesNotMatch(script, /openclaw models set/); + assert.match(script, /^exit$/m); + }); + + it("patches the staged Dockerfile with the selected model and chat UI URL", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n") + ); + + try { + patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-123", "openai-api"); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); + assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=openai$/m); + assert.match(patched, /^ARG NEMOCLAW_PRIMARY_MODEL_REF=openai\/gpt-5\.4$/m); + assert.match(patched, /^ARG CHAT_UI_URL=http:\/\/127\.0\.0\.1:19999$/m); + assert.match(patched, /^ARG NEMOCLAW_BUILD_ID=build-123$/m); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("maps NVIDIA Endpoints to the routed inference provider", () => { + assert.deepEqual( + getSandboxInferenceConfig("qwen/qwen3.5-397b-a17b", "nvidia-prod", "openai-completions"), + { + providerKey: "inference", + primaryModelRef: "inference/qwen/qwen3.5-397b-a17b", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-completions", + inferenceCompat: null, + } + ); + }); + + it("patches the staged Dockerfile for Anthropic with anthropic-messages routing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-anthropic-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n") + ); - // Must NOT modify openclaw config from inside the sandbox — model routing - // is handled by the host-side gateway (openshell inference set) - expect(script).not.toMatch(/openclaw\.json/); - expect(script).not.toMatch(/openclaw models set/); + try { + patchStagedDockerfile( + dockerfilePath, + "claude-sonnet-4-5", + "http://127.0.0.1:18789", + "build-claude", + "anthropic-prod" + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_MODEL=claude-sonnet-4-5$/m); + assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=anthropic$/m); + assert.match(patched, /^ARG NEMOCLAW_PRIMARY_MODEL_REF=anthropic\/claude-sonnet-4-5$/m); + assert.match(patched, /^ARG NEMOCLAW_INFERENCE_BASE_URL=https:\/\/inference\.local$/m); + assert.match(patched, /^ARG NEMOCLAW_INFERENCE_API=anthropic-messages$/m); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); - expect(script).toMatch(/^exit$/m); + it("maps Gemini to the routed inference provider with supportsStore disabled", () => { + assert.deepEqual( + getSandboxInferenceConfig("gemini-2.5-flash", "gemini-api"), + { + providerKey: "inference", + primaryModelRef: "inference/gemini-2.5-flash", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-completions", + inferenceCompat: { + supportsStore: false, + }, + } + ); + }); + + it("uses a probed Responses API override when one is available", () => { + assert.deepEqual( + getSandboxInferenceConfig("gpt-5.4", "openai-api", "openai-responses"), + { + providerKey: "openai", + primaryModelRef: "openai/gpt-5.4", + inferenceBaseUrl: "https://inference.local/v1", + inferenceApi: "openai-responses", + inferenceCompat: null, + } + ); }); it("pins the gateway image to the installed OpenShell release version", () => { @@ -70,4 +180,480 @@ describe("onboard helpers", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("passes credential names to openshell without embedding secret values in argv", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-inference-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: nvidia-nim", + " Model: nvidia/nemotron-3-super-120b-a12b", + " Version: 1", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.NVIDIA_API_KEY = "nvapi-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "nvidia/nemotron-3-super-120b-a12b", "nvidia-nim"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + expect(result.status).toBe(0); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /'--credential' 'NVIDIA_API_KEY'/); + assert.doesNotMatch(commands[1].command, /nvapi-secret-value/); + assert.match(commands[1].command, /provider' 'create'/); + assert.match(commands[2].command, /inference' 'set'/); + }); + + it("uses native Anthropic provider creation without embedding the secret in argv", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-anthropic-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-anthropic-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: anthropic-prod", + " Model: claude-sonnet-4-5", + " Version: 1", + ].join("\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.ANTHROPIC_API_KEY = "sk-ant-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "claude-sonnet-4-5", "anthropic-prod", "https://api.anthropic.com", "ANTHROPIC_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /'--type' 'anthropic'/); + assert.match(commands[1].command, /'--credential' 'ANTHROPIC_API_KEY'/); + assert.doesNotMatch(commands[1].command, /sk-ant-secret-value/); + assert.match(commands[2].command, /'--provider' 'anthropic-prod'/); + }); + + it("updates OpenAI-compatible providers without passing an unsupported --type flag", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-openai-update-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "setup-openai-update-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +let callIndex = 0; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + callIndex += 1; + return { status: callIndex === 2 ? 1 : 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: openai-api", + " Model: gpt-5.4", + " Version: 1", + ].join("\n"); + } + return ""; +}; +registry.updateSandbox = () => true; + +process.env.OPENAI_API_KEY = "sk-secret-value"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 4); + assert.match(commands[0].command, /gateway' 'select' 'nemoclaw'/); + assert.match(commands[1].command, /provider' 'create'/); + assert.match(commands[2].command, /provider' 'update' 'openai-api'/); + assert.doesNotMatch(commands[2].command, /'--type'/); + assert.match(commands[3].command, /inference' 'set' '--no-verify'/); + }); + + it("drops stale local sandbox registry entries when the live sandbox is gone", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-stale-sandbox-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "stale-sandbox-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const registry = require(${registryPath}); +const runner = require(${runnerPath}); +runner.runCapture = (command) => (command.includes("'sandbox' 'get' 'my-assistant'") ? "" : ""); + +registry.registerSandbox({ name: "my-assistant" }); + +const { pruneStaleSandboxEntry } = require(${onboardPath}); + +const liveExists = pruneStaleSandboxEntry("my-assistant"); +console.log(JSON.stringify({ liveExists, sandbox: registry.getSandbox("my-assistant") })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.liveExists, false); + assert.equal(payload.sandbox, null); + }); + + it("builds the sandbox without uploading an external OpenClaw config file", async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-sandbox-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "create-sandbox-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + const sandboxName = await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.sandboxName, "my-assistant"); + const createCommand = payload.commands.find((entry) => entry.command.includes("'sandbox' 'create'")); + assert.ok(createCommand, "expected sandbox create command"); + assert.match(createCommand.command, /'nemoclaw-start'/); + assert.doesNotMatch(createCommand.command, /'--upload'/); + assert.doesNotMatch(createCommand.command, /OPENCLAW_CONFIG_PATH/); + assert.doesNotMatch(createCommand.command, /NVIDIA_API_KEY=/); + assert.doesNotMatch(createCommand.command, /DISCORD_BOT_TOKEN=/); + assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); + }); + + it("accepts gateway inference when system inference is separately not configured", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-get-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "inference-get-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Route: inference.local", + " Provider: openai-api", + " Model: gpt-5.4", + " Version: 1", + "", + "System inference:", + "", + " Not configured", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; +process.env.OPENAI_API_KEY = "sk-secret-value"; +process.env.OPENSHELL_GATEWAY = "nemoclaw"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + }); + + it("accepts gateway inference output that omits the Route line", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-route-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "inference-route-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("inference") && command.includes("get")) { + return [ + "Gateway inference:", + "", + " Provider: openai-api", + " Model: gpt-5.4", + " Version: 1", + "", + "System inference:", + "", + " Not configured", + ].join("\\n"); + } + return ""; +}; +registry.updateSandbox = () => true; +process.env.OPENAI_API_KEY = "sk-secret-value"; +process.env.OPENSHELL_GATEWAY = "nemoclaw"; + +const { setupInference } = require(${onboardPath}); + +(async () => { + await setupInference("test-box", "gpt-5.4", "openai-api", "https://api.openai.com/v1", "OPENAI_API_KEY"); + console.log(JSON.stringify(commands)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(commands.length, 3); + }); + }); diff --git a/test/policies.test.js b/test/policies.test.js index e1b1de0df..50559f443 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { describe, it, expect } from "vitest"; import path from "node:path"; import policies from "../bin/lib/policies"; @@ -85,6 +86,16 @@ describe("policies", () => { const nameIdx = cmd.indexOf("'test-box'"); expect(waitIdx < nameIdx).toBeTruthy(); }); + + it("uses the resolved openshell binary when provided by the installer path", () => { + process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; + try { + const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); + assert.equal(cmd, "'/tmp/fake path/openshell' policy set --policy '/tmp/policy.yaml' --wait 'my-assistant'"); + } finally { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } + }); }); describe("buildPolicyGetCommand", () => { diff --git a/test/preflight.test.js b/test/preflight.test.js index 06ae425d5..90e1f4d9b 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -1,41 +1,39 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; -import net from "node:net"; +import { describe, expect, it } from "vitest"; + import { checkPortAvailable } from "../bin/lib/preflight"; describe("checkPortAvailable", () => { - it("falls through to net probe when lsof output is empty", async () => { - // Empty lsof output is not authoritative (non-root can't see root-owned - // listeners), so the function must fall through to the net probe. - // Use a guaranteed-free port so the net probe confirms availability. - const freePort = await new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, "127.0.0.1", () => { - const port = srv.address().port; - srv.close(() => resolve(port)); - }); + it("falls through to the probe when lsof output is empty", async () => { + let probedPort = null; + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, }); - const result = await checkPortAvailable(freePort, { lsofOutput: "" }); + + expect(probedPort).toBe(18789); expect(result).toEqual({ ok: true }); }); - it("net probe catches occupied port even when lsof returns empty", async () => { - // Simulates the non-root-can't-see-root-listener scenario: - // lsof returns empty, but net probe detects the port is taken. - const srv = net.createServer(); - const port = await new Promise((resolve) => { - srv.listen(0, "127.0.0.1", () => resolve(srv.address().port)); + it("probe catches occupied port even when lsof returns empty", async () => { + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 18789 is in use (EADDRINUSE)", + }), }); - try { - const result = await checkPortAvailable(port, { lsofOutput: "" }); - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason.includes("EADDRINUSE")).toBeTruthy(); - } finally { - await new Promise((resolve) => srv.close(resolve)); - } + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); }); it("parses process and PID from lsof output", async () => { @@ -44,10 +42,11 @@ describe("checkPortAvailable", () => { "openclaw 12345 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", ].join("\n"); const result = await checkPortAvailable(18789, { lsofOutput }); + expect(result.ok).toBe(false); expect(result.process).toBe("openclaw"); expect(result.pid).toBe(12345); - expect(result.reason.includes("openclaw")).toBeTruthy(); + expect(result.reason).toContain("openclaw"); }); it("picks first listener when lsof shows multiple", async () => { @@ -57,67 +56,61 @@ describe("checkPortAvailable", () => { "node 222 root 8u IPv4 54322 0t0 TCP *:18789 (LISTEN)", ].join("\n"); const result = await checkPortAvailable(18789, { lsofOutput }); + expect(result.ok).toBe(false); expect(result.process).toBe("gateway"); expect(result.pid).toBe(111); }); - it("net probe returns ok for a free port", async () => { - // Find a free port by binding then releasing - const freePort = await new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, "127.0.0.1", () => { - const port = srv.address().port; - srv.close(() => resolve(port)); - }); + it("returns ok for a free port probe", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ ok: true }), }); - const result = await checkPortAvailable(freePort, { skipLsof: true }); + expect(result).toEqual({ ok: true }); }); - it("net probe detects occupied port", async () => { - const srv = net.createServer(); - const port = await new Promise((resolve) => { - srv.listen(0, "127.0.0.1", () => resolve(srv.address().port)); + it("returns occupied for EADDRINUSE probe results", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 8080 is in use (EADDRINUSE)", + }), }); - try { - const result = await checkPortAvailable(port, { skipLsof: true }); - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason.includes("EADDRINUSE")).toBeTruthy(); - } finally { - await new Promise((resolve) => srv.close(resolve)); - } + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); }); - it("smoke test with live detection on a dynamically selected free port", async () => { - const freePort = await new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, "127.0.0.1", () => { - const port = srv.address().port; - srv.close(() => resolve(port)); - }); + it("treats restricted probe environments as inconclusive instead of occupied", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: true, + warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", + }), }); - const result = await checkPortAvailable(freePort); - expect(result.ok).toBe(true); - }); - it("defaults to port 18789 when no args given", async () => { - // Should not throw — just verify it returns a valid result object - const result = await checkPortAvailable(); - expect(typeof result.ok).toBe("boolean"); + expect(result.ok).toBe(true); + expect(result.warning).toContain("EPERM"); }); - it("checks gateway port 8080", async () => { - const freePort = await new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, "127.0.0.1", () => { - const port = srv.address().port; - srv.close(() => resolve(port)); - }); + it("defaults to port 18789 when no port is given", async () => { + let probedPort = null; + const result = await checkPortAvailable(undefined, { + skipLsof: true, + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, }); - // Verify the function works with any port (including 8080-range) - const result = await checkPortAvailable(freePort); + + expect(probedPort).toBe(18789); expect(result.ok).toBe(true); }); }); diff --git a/test/runner.test.js b/test/runner.test.js index f35c88256..13f4013de 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -1,12 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import childProcess from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import childProcess from "node:child_process"; -const { spawnSync } = childProcess; +import { describe, expect, it } from "vitest"; + +import { runCapture } from "../bin/lib/runner"; const runnerPath = path.join(import.meta.dirname, "..", "bin", "lib", "runner"); @@ -49,12 +51,31 @@ describe("runner helpers", () => { delete require.cache[require.resolve(runnerPath)]; } - expect(calls.length).toBe(2); + expect(calls).toHaveLength(2); expect(calls[0][2].stdio).toEqual(["ignore", "inherit", "inherit"]); expect(calls[1][2].stdio).toBe("inherit"); }); +}); + +describe("runner env merging", () => { + it("preserves process env when opts.env is provided to runCapture", () => { + const originalGateway = process.env.OPENSHELL_GATEWAY; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + try { + const output = runCapture("printf '%s %s' \"$OPENSHELL_GATEWAY\" \"$OPENAI_API_KEY\"", { + env: { OPENAI_API_KEY: "sk-test-secret" }, + }); + expect(output).toBe("nemoclaw sk-test-secret"); + } finally { + if (originalGateway === undefined) { + delete process.env.OPENSHELL_GATEWAY; + } else { + process.env.OPENSHELL_GATEWAY = originalGateway; + } + } + }); - it("preserves process env when opts.env is provided", () => { + it("preserves process env when opts.env is provided to run", () => { const calls = []; const originalSpawnSync = childProcess.spawnSync; const originalPath = process.env.PATH; @@ -78,134 +99,129 @@ describe("runner helpers", () => { delete require.cache[require.resolve(runnerPath)]; } - expect(calls.length).toBe(1); + expect(calls).toHaveLength(1); expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin"); }); +}); - describe("shellQuote", () => { - it("wraps in single quotes", () => { - const { shellQuote } = require(runnerPath); - expect(shellQuote("hello")).toBe("'hello'"); - }); - - it("escapes embedded single quotes", () => { - const { shellQuote } = require(runnerPath); - expect(shellQuote("it's")).toBe("'it'\\''s'"); - }); +describe("shellQuote", () => { + it("wraps in single quotes", () => { + const { shellQuote } = require(runnerPath); + expect(shellQuote("hello")).toBe("'hello'"); + }); - it("neutralizes shell metacharacters", () => { - const { shellQuote } = require(runnerPath); - const dangerous = "test; rm -rf /"; - const quoted = shellQuote(dangerous); - expect(quoted).toBe("'test; rm -rf /'"); - const result = spawnSync("bash", ["-c", `echo ${quoted}`], { encoding: "utf-8" }); - expect(result.stdout.trim()).toBe(dangerous); - }); + it("escapes embedded single quotes", () => { + const { shellQuote } = require(runnerPath); + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); - it("handles backticks and dollar signs", () => { - const { shellQuote } = require(runnerPath); - const payload = "test`whoami`$HOME"; - const quoted = shellQuote(payload); - const result = spawnSync("bash", ["-c", `echo ${quoted}`], { encoding: "utf-8" }); - expect(result.stdout.trim()).toBe(payload); - }); + it("neutralizes shell metacharacters", () => { + const { shellQuote } = require(runnerPath); + const dangerous = "test; rm -rf /"; + const quoted = shellQuote(dangerous); + expect(quoted).toBe("'test; rm -rf /'"); + const result = spawnSync("bash", ["-c", `echo ${quoted}`], { encoding: "utf-8" }); + expect(result.stdout.trim()).toBe(dangerous); }); - describe("validateName", () => { - it("accepts valid RFC 1123 names", () => { - const { validateName } = require(runnerPath); - expect(validateName("my-sandbox")).toBe("my-sandbox"); - expect(validateName("test123")).toBe("test123"); - expect(validateName("a")).toBe("a"); - }); + it("handles backticks and dollar signs", () => { + const { shellQuote } = require(runnerPath); + const payload = "test`whoami`$HOME"; + const quoted = shellQuote(payload); + const result = spawnSync("bash", ["-c", `echo ${quoted}`], { encoding: "utf-8" }); + expect(result.stdout.trim()).toBe(payload); + }); +}); - it("rejects names with shell metacharacters", () => { - const { validateName } = require(runnerPath); - expect(() => validateName("test; whoami")).toThrow(/Invalid/); - expect(() => validateName("test`id`")).toThrow(/Invalid/); - expect(() => validateName("test$(cat /etc/passwd)")).toThrow(/Invalid/); - expect(() => validateName("../etc/passwd")).toThrow(/Invalid/); - }); +describe("validateName", () => { + it("accepts valid RFC 1123 names", () => { + const { validateName } = require(runnerPath); + expect(validateName("my-sandbox")).toBe("my-sandbox"); + expect(validateName("test123")).toBe("test123"); + expect(validateName("a")).toBe("a"); + }); - it("rejects empty and overlength names", () => { - const { validateName } = require(runnerPath); - expect(() => validateName("")).toThrow(/required/); - expect(() => validateName(null)).toThrow(/required/); - expect(() => validateName("a".repeat(64))).toThrow(/too long/); - }); + it("rejects names with shell metacharacters", () => { + const { validateName } = require(runnerPath); + expect(() => validateName("test; whoami")).toThrow(/Invalid/); + expect(() => validateName("test`id`")).toThrow(/Invalid/); + expect(() => validateName("test$(cat /etc/passwd)")).toThrow(/Invalid/); + expect(() => validateName("../etc/passwd")).toThrow(/Invalid/); + }); - it("rejects uppercase and special characters", () => { - const { validateName } = require(runnerPath); - expect(() => validateName("MyBox")).toThrow(/Invalid/); - expect(() => validateName("my_box")).toThrow(/Invalid/); - expect(() => validateName("-leading")).toThrow(/Invalid/); - expect(() => validateName("trailing-")).toThrow(/Invalid/); - }); + it("rejects empty and overlength names", () => { + const { validateName } = require(runnerPath); + expect(() => validateName("")).toThrow(/required/); + expect(() => validateName(null)).toThrow(/required/); + expect(() => validateName("a".repeat(64))).toThrow(/too long/); }); - describe("regression guards", () => { - it("nemoclaw.js does not use execSync", () => { + it("rejects uppercase and special characters", () => { + const { validateName } = require(runnerPath); + expect(() => validateName("MyBox")).toThrow(/Invalid/); + expect(() => validateName("my_box")).toThrow(/Invalid/); + expect(() => validateName("-leading")).toThrow(/Invalid/); + expect(() => validateName("trailing-")).toThrow(/Invalid/); + }); +}); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); - const lines = src.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes("execSync") && !lines[i].includes("execFileSync")) { - expect.unreachable(`bin/nemoclaw.js:${i + 1} uses execSync — use execFileSync instead`); - } +describe("regression guards", () => { + it("nemoclaw.js does not use execSync", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), "utf-8"); + const lines = src.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].includes("execSync") && !lines[i].includes("execFileSync")) { + expect.unreachable(`bin/nemoclaw.js:${i + 1} uses execSync — use execFileSync instead`); } - }); - - it("no duplicate shellQuote definitions in bin/", () => { + } + }); - const binDir = path.join(import.meta.dirname, "..", "bin"); - const files = []; - function walk(dir) { - for (const f of fs.readdirSync(dir, { withFileTypes: true })) { - if (f.isDirectory() && f.name !== "node_modules") walk(path.join(dir, f.name)); - else if (f.name.endsWith(".js")) files.push(path.join(dir, f.name)); - } - } - walk(binDir); - - const defs = []; - for (const file of files) { - const src = fs.readFileSync(file, "utf-8"); - if (src.includes("function shellQuote")) { - defs.push(file.replace(binDir, "bin")); - } + it("no duplicate shellQuote definitions in bin/", () => { + const binDir = path.join(import.meta.dirname, "..", "bin"); + const files = []; + function walk(dir) { + for (const f of fs.readdirSync(dir, { withFileTypes: true })) { + if (f.isDirectory() && f.name !== "node_modules") walk(path.join(dir, f.name)); + else if (f.name.endsWith(".js")) files.push(path.join(dir, f.name)); } - expect(defs.length).toBe(1); - expect(defs[0].includes("runner")).toBeTruthy(); - }); + } + walk(binDir); - it("CLI rejects malicious sandbox names before shell commands (e2e)", () => { - - - const canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-canary-")); - const canary = path.join(canaryDir, "executed"); - try { - const result = spawnSync("node", [ - path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), - `test; touch ${canary}`, - "connect", - ], { - encoding: "utf-8", - timeout: 10000, - cwd: path.join(import.meta.dirname, ".."), - }); - expect(result.status).not.toBe(0); - expect(fs.existsSync(canary)).toBe(false); - } finally { - fs.rmSync(canaryDir, { recursive: true, force: true }); + const defs = []; + for (const file of files) { + const src = fs.readFileSync(file, "utf-8"); + if (src.includes("function shellQuote")) { + defs.push(file.replace(binDir, "bin")); } - }); + } + expect(defs).toHaveLength(1); + expect(defs[0].includes("runner")).toBeTruthy(); + }); - it("telegram bridge validates SANDBOX_NAME on startup", () => { + it("CLI rejects malicious sandbox names before shell commands (e2e)", () => { + const canaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-canary-")); + const canary = path.join(canaryDir, "executed"); + try { + const result = spawnSync("node", [ + path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"), + `test; touch ${canary}`, + "connect", + ], { + encoding: "utf-8", + timeout: 10000, + cwd: path.join(import.meta.dirname, ".."), + }); + expect(result.status).not.toBe(0); + expect(fs.existsSync(canary)).toBe(false); + } finally { + fs.rmSync(canaryDir, { recursive: true, force: true }); + } + }); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); - expect(src.includes("validateName(SANDBOX")).toBeTruthy(); - expect(!src.includes("execSync")).toBeTruthy(); - }); + it("telegram bridge validates SANDBOX_NAME on startup", () => { + const src = fs.readFileSync(path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), "utf-8"); + expect(src.includes("validateName(SANDBOX")).toBeTruthy(); + expect(src.includes("execSync")).toBeFalsy(); }); });