From 734a63cbcc40ff776b3698b46d20a843d483a7a9 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Wed, 18 Mar 2026 23:10:59 +0000 Subject: [PATCH 1/4] feat: add Dynamo vLLM provider support for external endpoints Add NEMOCLAW_PROVIDER=dynamo option for connecting to external vLLM endpoints (e.g., Dynamo on Kubernetes). This enables NemoClaw to use GPU inference from K8s clusters without running GPUs locally. New environment variables: - NEMOCLAW_PROVIDER=dynamo - select the Dynamo provider - NEMOCLAW_DYNAMO_ENDPOINT - required URL to the vLLM endpoint - NEMOCLAW_DYNAMO_MODEL - optional model name (defaults to "dynamo") Security: - URL validation ensures only http/https protocols are accepted - shQuote() helper prevents shell injection in endpoint URLs - Credentials and query params are redacted from log output Co-Authored-By: Claude Opus 4.5 --- bin/lib/onboard.js | 124 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 6 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 23f19b01a..b540cb69c 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -57,6 +57,13 @@ async function promptOrDefault(question, envVar, defaultValue) { // ── Helpers ────────────────────────────────────────────────────── +// Shell-safe single-quote escaping to prevent injection when interpolating +// user-supplied values into shell commands. Wraps value in single quotes +// and escapes any embedded single quotes. +function shQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + function step(n, total, msg) { console.log(""); console.log(` [${n}/${total}] ${msg}`); @@ -221,10 +228,10 @@ function getNonInteractiveProvider() { const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); if (!providerKey) return null; - const validProviders = new Set(["cloud", "ollama", "vllm", "nim"]); + const validProviders = new Set(["cloud", "ollama", "vllm", "nim", "dynamo"]); if (!validProviders.has(providerKey)) { console.error(` Unsupported NEMOCLAW_PROVIDER: ${providerKey}`); - console.error(" Valid values: cloud, ollama, vllm, nim"); + console.error(" Valid values: cloud, ollama, vllm, nim, dynamo"); process.exit(1); } @@ -473,6 +480,90 @@ async function setupNim(sandboxName, gpu) { 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; + + // Auto-select only with NEMOCLAW_EXPERIMENTAL=1 (prevents silent misconfiguration) + if (EXPERIMENTAL) { + if (vllmRunning) { + console.log(" ✓ vLLM detected on localhost:8000 — using it [experimental]"); + provider = "vllm-local"; + model = "vllm-local"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); + return { model, provider }; + } + if (ollamaRunning) { + console.log(" ✓ Ollama detected on localhost:11434 — using it [experimental]"); + provider = "ollama-local"; + model = "nemotron-3-nano"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); + return { model, provider }; + } + } + + // Non-interactive: honor NEMOCLAW_PROVIDER before building interactive options + if (isNonInteractive() && requestedProvider) { + const providerKey = requestedProvider; + console.log(` [non-interactive] Provider: ${providerKey}`); + if (providerKey === "dynamo") { + // Dynamo: external vLLM endpoint (e.g., K8s service) + const dynamoEndpointRaw = (process.env.NEMOCLAW_DYNAMO_ENDPOINT || "").trim(); + if (!dynamoEndpointRaw) { + console.error(" NEMOCLAW_DYNAMO_ENDPOINT is required when NEMOCLAW_PROVIDER=dynamo."); + process.exit(1); + } + // Validate URL format and protocol + let parsedUrl; + try { + parsedUrl = new URL(dynamoEndpointRaw); + } catch { + console.error(` Invalid NEMOCLAW_DYNAMO_ENDPOINT URL: ${dynamoEndpointRaw}`); + process.exit(1); + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + console.error(` NEMOCLAW_DYNAMO_ENDPOINT must use http or https: ${dynamoEndpointRaw}`); + process.exit(1); + } + // Log sanitized URL (redact credentials and query params if present) + const safeLogUrl = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; + const redacted = []; + if (parsedUrl.username || parsedUrl.password) redacted.push("credentials"); + if (parsedUrl.search) redacted.push("query"); + console.log(` Using Dynamo endpoint: ${safeLogUrl}${redacted.length ? ` [REDACTED: ${redacted.join(", ")}]` : ""}`); + + provider = "dynamo"; + model = (process.env.NEMOCLAW_DYNAMO_MODEL || "").trim() || "dynamo"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer, dynamoEndpoint: dynamoEndpointRaw }); + return { model, provider, dynamoEndpoint: dynamoEndpointRaw }; + } else if (providerKey === "ollama") { + if (!ollamaRunning) { + console.error(" Ollama is not running on localhost:11434. Start it first."); + process.exit(1); + } + provider = "ollama-local"; + model = requestedModel || "nemotron-3-nano"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); + return { model, provider }; + } else if (providerKey === "vllm") { + if (!vllmRunning) { + console.error(" vLLM is not running on localhost:8000. Start it first."); + process.exit(1); + } + provider = "vllm-local"; + model = requestedModel || "vllm-local"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); + return { model, provider }; + } else if (providerKey === "nim") { + if (!EXPERIMENTAL) { + console.error(" NEMOCLAW_PROVIDER=nim requires NEMOCLAW_EXPERIMENTAL=1."); + process.exit(1); + } + if (!gpu || !gpu.nimCapable) { + console.error(" Local NIM requires a compatible NVIDIA GPU."); + process.exit(1); + } + } + // "cloud" or "nim" fall through to normal flow below + } + // Build options list — only show local options with NEMOCLAW_EXPERIMENTAL=1 const options = []; if (EXPERIMENTAL && gpu && gpu.nimCapable) { @@ -642,10 +733,30 @@ async function setupNim(sandboxName, gpu) { // ── Step 5: Inference provider ─────────────────────────────────── -async function setupInference(sandboxName, model, provider) { +async function setupInference(sandboxName, model, provider, opts = {}) { step(5, 7, "Setting up inference provider"); - if (provider === "nvidia-nim") { + if (provider === "dynamo") { + // Dynamo: external vLLM endpoint (e.g., K8s service) + const dynamoEndpoint = opts.dynamoEndpoint; + if (!dynamoEndpoint) { + console.error(" Dynamo provider requires dynamoEndpoint in options."); + process.exit(1); + } + // Use shQuote for shell-safe escaping of the endpoint URL + run( + `openshell provider create --name dynamo --type openai ` + + `--credential "OPENAI_API_KEY=dummy" ` + + `--config "OPENAI_BASE_URL=${shQuote(dynamoEndpoint)}" 2>&1 || ` + + `openshell provider update dynamo --credential "OPENAI_API_KEY=dummy" ` + + `--config "OPENAI_BASE_URL=${shQuote(dynamoEndpoint)}" 2>&1 || true`, + { ignoreError: true } + ); + run( + `openshell inference set --no-verify --provider dynamo --model ${shQuote(model)} 2>/dev/null || true`, + { ignoreError: true } + ); + } else if (provider === "nvidia-nim") { // Create nvidia-nim provider run( `openshell provider create --name nvidia-nim --type openai ` + @@ -850,6 +961,7 @@ function printDashboard(sandboxName, model, provider) { if (provider === "nvidia-nim") providerLabel = "NVIDIA Cloud API"; else if (provider === "vllm-local") providerLabel = "Local vLLM"; else if (provider === "ollama-local") providerLabel = "Local Ollama"; + else if (provider === "dynamo") providerLabel = "Dynamo vLLM"; console.log(""); console.log(` ${"─".repeat(50)}`); @@ -878,8 +990,8 @@ async function onboard(opts = {}) { const gpu = await preflight(); await startGateway(gpu); const sandboxName = await createSandbox(gpu); - const { model, provider } = await setupNim(sandboxName, gpu); - await setupInference(sandboxName, model, provider); + const { model, provider, dynamoEndpoint } = await setupNim(sandboxName, gpu); + await setupInference(sandboxName, model, provider, { dynamoEndpoint }); await setupOpenclaw(sandboxName, model, provider); await setupPolicies(sandboxName); printDashboard(sandboxName, model, provider); From cc6abf8fd9deeaecd722eba82e939ee1bf91a00d Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Thu, 19 Mar 2026 14:57:22 +0000 Subject: [PATCH 2/4] fix: address code review feedback for Dynamo provider - Remove duplicate shQuote function, use existing shellQuote - Fix shell quoting: remove outer double quotes so shellQuote works correctly - Store sanitized URL in registry (no credentials), pass raw URL to setupInference Signed-off-by: Robert Wipfel Co-Authored-By: Claude Opus 4.5 --- bin/lib/onboard.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index b540cb69c..5419f6b66 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -57,13 +57,6 @@ async function promptOrDefault(question, envVar, defaultValue) { // ── Helpers ────────────────────────────────────────────────────── -// Shell-safe single-quote escaping to prevent injection when interpolating -// user-supplied values into shell commands. Wraps value in single quotes -// and escapes any embedded single quotes. -function shQuote(value) { - return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; -} - function step(n, total, msg) { console.log(""); console.log(` [${n}/${total}] ${msg}`); @@ -522,16 +515,17 @@ async function setupNim(sandboxName, gpu) { console.error(` NEMOCLAW_DYNAMO_ENDPOINT must use http or https: ${dynamoEndpointRaw}`); process.exit(1); } - // Log sanitized URL (redact credentials and query params if present) - const safeLogUrl = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; + // Build sanitized URL for logging and storage (no credentials or query params) + const safeUrl = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; const redacted = []; if (parsedUrl.username || parsedUrl.password) redacted.push("credentials"); if (parsedUrl.search) redacted.push("query"); - console.log(` Using Dynamo endpoint: ${safeLogUrl}${redacted.length ? ` [REDACTED: ${redacted.join(", ")}]` : ""}`); + console.log(` Using Dynamo endpoint: ${safeUrl}${redacted.length ? ` [REDACTED: ${redacted.join(", ")}]` : ""}`); provider = "dynamo"; model = (process.env.NEMOCLAW_DYNAMO_MODEL || "").trim() || "dynamo"; - registry.updateSandbox(sandboxName, { model, provider, nimContainer, dynamoEndpoint: dynamoEndpointRaw }); + // Store sanitized URL in registry (no credentials), pass raw URL to setupInference + registry.updateSandbox(sandboxName, { model, provider, nimContainer, dynamoEndpoint: safeUrl }); return { model, provider, dynamoEndpoint: dynamoEndpointRaw }; } else if (providerKey === "ollama") { if (!ollamaRunning) { @@ -743,17 +737,17 @@ async function setupInference(sandboxName, model, provider, opts = {}) { console.error(" Dynamo provider requires dynamoEndpoint in options."); process.exit(1); } - // Use shQuote for shell-safe escaping of the endpoint URL + // Use shellQuote for shell-safe escaping of the endpoint URL run( `openshell provider create --name dynamo --type openai ` + - `--credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${shQuote(dynamoEndpoint)}" 2>&1 || ` + - `openshell provider update dynamo --credential "OPENAI_API_KEY=dummy" ` + - `--config "OPENAI_BASE_URL=${shQuote(dynamoEndpoint)}" 2>&1 || true`, + `--credential OPENAI_API_KEY=dummy ` + + `--config OPENAI_BASE_URL=${shellQuote(dynamoEndpoint)} 2>&1 || ` + + `openshell provider update dynamo --credential OPENAI_API_KEY=dummy ` + + `--config OPENAI_BASE_URL=${shellQuote(dynamoEndpoint)} 2>&1 || true`, { ignoreError: true } ); run( - `openshell inference set --no-verify --provider dynamo --model ${shQuote(model)} 2>/dev/null || true`, + `openshell inference set --no-verify --provider dynamo --model ${shellQuote(model)} 2>/dev/null || true`, { ignoreError: true } ); } else if (provider === "nvidia-nim") { From cc907e9b716f8e08ae27a0ee2d874ffb7b85c9d0 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Tue, 24 Mar 2026 09:40:30 +0000 Subject: [PATCH 3/4] fix: simplify Dynamo provider to minimal implementation Remove unnecessary code: - Remove auto-detection block for vLLM/Ollama (not part of Dynamo feature) - Remove URL parsing and validation (let it fail naturally if invalid) - Remove credential redaction logic - Keep only essential: check env var is set, configure provider Signed-off-by: Robert Wipfel Co-Authored-By: Claude Opus 4.5 --- bin/lib/onboard.js | 91 ++++++---------------------------------------- 1 file changed, 11 insertions(+), 80 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 5419f6b66..04e87462d 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -474,88 +474,19 @@ async function setupNim(sandboxName, gpu) { const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "cloud") : null; - // Auto-select only with NEMOCLAW_EXPERIMENTAL=1 (prevents silent misconfiguration) - if (EXPERIMENTAL) { - if (vllmRunning) { - console.log(" ✓ vLLM detected on localhost:8000 — using it [experimental]"); - provider = "vllm-local"; - model = "vllm-local"; - registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - return { model, provider }; - } - if (ollamaRunning) { - console.log(" ✓ Ollama detected on localhost:11434 — using it [experimental]"); - provider = "ollama-local"; - model = "nemotron-3-nano"; - registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - return { model, provider }; + // Non-interactive Dynamo provider: handle before the interactive options flow + if (isNonInteractive() && requestedProvider === "dynamo") { + const dynamoEndpoint = (process.env.NEMOCLAW_DYNAMO_ENDPOINT || "").trim(); + if (!dynamoEndpoint) { + console.error(" NEMOCLAW_DYNAMO_ENDPOINT is required when NEMOCLAW_PROVIDER=dynamo."); + process.exit(1); } - } + console.log(` [non-interactive] Using Dynamo provider`); - // Non-interactive: honor NEMOCLAW_PROVIDER before building interactive options - if (isNonInteractive() && requestedProvider) { - const providerKey = requestedProvider; - console.log(` [non-interactive] Provider: ${providerKey}`); - if (providerKey === "dynamo") { - // Dynamo: external vLLM endpoint (e.g., K8s service) - const dynamoEndpointRaw = (process.env.NEMOCLAW_DYNAMO_ENDPOINT || "").trim(); - if (!dynamoEndpointRaw) { - console.error(" NEMOCLAW_DYNAMO_ENDPOINT is required when NEMOCLAW_PROVIDER=dynamo."); - process.exit(1); - } - // Validate URL format and protocol - let parsedUrl; - try { - parsedUrl = new URL(dynamoEndpointRaw); - } catch { - console.error(` Invalid NEMOCLAW_DYNAMO_ENDPOINT URL: ${dynamoEndpointRaw}`); - process.exit(1); - } - if (!["http:", "https:"].includes(parsedUrl.protocol)) { - console.error(` NEMOCLAW_DYNAMO_ENDPOINT must use http or https: ${dynamoEndpointRaw}`); - process.exit(1); - } - // Build sanitized URL for logging and storage (no credentials or query params) - const safeUrl = `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; - const redacted = []; - if (parsedUrl.username || parsedUrl.password) redacted.push("credentials"); - if (parsedUrl.search) redacted.push("query"); - console.log(` Using Dynamo endpoint: ${safeUrl}${redacted.length ? ` [REDACTED: ${redacted.join(", ")}]` : ""}`); - - provider = "dynamo"; - model = (process.env.NEMOCLAW_DYNAMO_MODEL || "").trim() || "dynamo"; - // Store sanitized URL in registry (no credentials), pass raw URL to setupInference - registry.updateSandbox(sandboxName, { model, provider, nimContainer, dynamoEndpoint: safeUrl }); - return { model, provider, dynamoEndpoint: dynamoEndpointRaw }; - } else if (providerKey === "ollama") { - if (!ollamaRunning) { - console.error(" Ollama is not running on localhost:11434. Start it first."); - process.exit(1); - } - provider = "ollama-local"; - model = requestedModel || "nemotron-3-nano"; - registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - return { model, provider }; - } else if (providerKey === "vllm") { - if (!vllmRunning) { - console.error(" vLLM is not running on localhost:8000. Start it first."); - process.exit(1); - } - provider = "vllm-local"; - model = requestedModel || "vllm-local"; - registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - return { model, provider }; - } else if (providerKey === "nim") { - if (!EXPERIMENTAL) { - console.error(" NEMOCLAW_PROVIDER=nim requires NEMOCLAW_EXPERIMENTAL=1."); - process.exit(1); - } - if (!gpu || !gpu.nimCapable) { - console.error(" Local NIM requires a compatible NVIDIA GPU."); - process.exit(1); - } - } - // "cloud" or "nim" fall through to normal flow below + provider = "dynamo"; + model = (process.env.NEMOCLAW_DYNAMO_MODEL || "").trim() || "dynamo"; + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); + return { model, provider, dynamoEndpoint }; } // Build options list — only show local options with NEMOCLAW_EXPERIMENTAL=1 From 6b74ffcc908414f3b76cd0f06f8df49afa7bea73 Mon Sep 17 00:00:00 2001 From: Robert Wipfel Date: Tue, 24 Mar 2026 09:48:39 +0000 Subject: [PATCH 4/4] fix: read dynamo endpoint directly in setupInference Remove dynamoEndpoint from function chain - setupInference reads NEMOCLAW_DYNAMO_ENDPOINT directly when provider is dynamo. Keeps the main onboard flow unchanged from upstream. Signed-off-by: Robert Wipfel Co-Authored-By: Claude Opus 4.5 --- bin/lib/onboard.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 04e87462d..709c3dc51 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -486,7 +486,7 @@ async function setupNim(sandboxName, gpu) { provider = "dynamo"; model = (process.env.NEMOCLAW_DYNAMO_MODEL || "").trim() || "dynamo"; registry.updateSandbox(sandboxName, { model, provider, nimContainer }); - return { model, provider, dynamoEndpoint }; + return { model, provider }; } // Build options list — only show local options with NEMOCLAW_EXPERIMENTAL=1 @@ -658,16 +658,12 @@ async function setupNim(sandboxName, gpu) { // ── Step 5: Inference provider ─────────────────────────────────── -async function setupInference(sandboxName, model, provider, opts = {}) { +async function setupInference(sandboxName, model, provider) { step(5, 7, "Setting up inference provider"); if (provider === "dynamo") { // Dynamo: external vLLM endpoint (e.g., K8s service) - const dynamoEndpoint = opts.dynamoEndpoint; - if (!dynamoEndpoint) { - console.error(" Dynamo provider requires dynamoEndpoint in options."); - process.exit(1); - } + const dynamoEndpoint = process.env.NEMOCLAW_DYNAMO_ENDPOINT; // Use shellQuote for shell-safe escaping of the endpoint URL run( `openshell provider create --name dynamo --type openai ` + @@ -915,8 +911,8 @@ async function onboard(opts = {}) { const gpu = await preflight(); await startGateway(gpu); const sandboxName = await createSandbox(gpu); - const { model, provider, dynamoEndpoint } = await setupNim(sandboxName, gpu); - await setupInference(sandboxName, model, provider, { dynamoEndpoint }); + const { model, provider } = await setupNim(sandboxName, gpu); + await setupInference(sandboxName, model, provider); await setupOpenclaw(sandboxName, model, provider); await setupPolicies(sandboxName); printDashboard(sandboxName, model, provider);