From ea6b1bcca284bc5a31bb352d2cc143b42da3ab05 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Apr 2026 20:30:41 -0700 Subject: [PATCH] fix(cli): add local-inference policy preset for Ollama/vLLM host access (#693) Add a local-inference network policy preset that allows sandbox egress to host.openshell.internal on ports 11434 (Ollama) and 8000 (vLLM). Auto-suggest the preset during onboarding when provider is ollama-local or vllm-local. Also fixes API key exposure in setupSpark, telegram-bridge, and walkthrough by passing credentials via environment instead of command arguments. Supersedes #781. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/onboard.js | 20 +++++++++++- bin/nemoclaw.js | 6 ++-- .../policies/presets/local-inference.yaml | 28 ++++++++++++++++ scripts/install.sh | 18 +++++++++-- scripts/telegram-bridge.js | 9 +++--- scripts/walkthrough.sh | 7 ++-- test/policies.test.js | 32 +++++++++++++++++-- test/runner.test.js | 10 ++++-- 8 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 nemoclaw-blueprint/policies/presets/local-inference.yaml diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 02aa47904..43dcf1ab7 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3030,11 +3030,19 @@ async function setupOpenclaw(sandboxName, model, provider) { // ── Step 7: Policy presets ─────────────────────────────────────── // eslint-disable-next-line complexity -async function _setupPolicies(sandboxName) { +async function _setupPolicies(sandboxName, provider = null) { step(7, 7, "Policy presets"); const suggestions = ["pypi", "npm"]; + // Auto-detect local inference — sandbox needs host gateway egress + const sandbox = registry.getSandbox(sandboxName); + const sandboxProvider = provider || (sandbox ? sandbox.provider : null); + if (sandboxProvider === "ollama-local" || sandboxProvider === "vllm-local") { + suggestions.push("local-inference"); + console.log(` Auto-detected: ${sandboxProvider} → suggesting local-inference preset`); + } + // Auto-detect based on env tokens if (getCredential("TELEGRAM_BOT_TOKEN")) { suggestions.push("telegram"); @@ -3182,6 +3190,7 @@ function arePolicyPresetsApplied(sandboxName, selectedPresets = []) { async function setupPoliciesWithSelection(sandboxName, options = {}) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; + const provider = options.provider || null; step(7, 7, "Policy presets"); @@ -3191,6 +3200,14 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) suggestions.push("discord"); + // Auto-detect local inference — sandbox needs host gateway egress + const sandbox = registry.getSandbox(sandboxName); + const sandboxProvider = provider || (sandbox ? sandbox.provider : null); + if (sandboxProvider === "ollama-local" || sandboxProvider === "vllm-local") { + suggestions.push("local-inference"); + console.log(` Auto-detected: ${sandboxProvider} → suggesting local-inference preset`); + } + const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); let chosen = selectedPresets; @@ -3748,6 +3765,7 @@ async function onboard(opts = {}) { policyPresets: recordedPolicyPresets || [], }); const appliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { + provider, selectedPresets: resume && session?.steps?.policies?.status !== "complete" && diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 8a613c3c1..814512fef 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -647,8 +647,10 @@ async function setup(args = []) { } async function setupSpark() { - // setup-spark.sh configures Docker cgroups — it does not use NVIDIA_API_KEY. - run(`sudo bash "${SCRIPTS}/setup-spark.sh"`); + await ensureApiKey(); + run(`sudo -E bash "${SCRIPTS}/setup-spark.sh"`, { + env: { NVIDIA_API_KEY: process.env.NVIDIA_API_KEY }, + }); } // eslint-disable-next-line complexity diff --git a/nemoclaw-blueprint/policies/presets/local-inference.yaml b/nemoclaw-blueprint/policies/presets/local-inference.yaml new file mode 100644 index 000000000..c692cdce2 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/local-inference.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: local-inference + description: "Local inference access (Ollama, vLLM) via host gateway" + +network_policies: + local_inference: + name: local_inference + endpoints: + - host: host.openshell.internal + port: 11434 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: host.openshell.internal + port: 8000 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/openclaw } + - { path: /usr/local/bin/claude } diff --git a/scripts/install.sh b/scripts/install.sh index 9fd692af4..f7a224d3b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -827,7 +827,14 @@ install_nemoclaw() { spin "Installing NemoClaw dependencies" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm install --ignore-scripts" spin "Building NemoClaw CLI modules" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm run --if-present build:cli" spin "Building NemoClaw plugin" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\"/nemoclaw && npm install --ignore-scripts && npm run build" - spin "Linking NemoClaw CLI" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && npm link" + # Use sudo for npm link only when the global prefix is not writable + local npm_global_prefix + npm_global_prefix="$(npm config get prefix 2>/dev/null)" || true + local sudo_cmd="" + if [ -n "$npm_global_prefix" ] && [ ! -w "$npm_global_prefix" ] && [ "$(id -u)" -ne 0 ]; then + sudo_cmd="sudo" + fi + spin "Linking NemoClaw CLI" bash -c "cd \"$NEMOCLAW_SOURCE_ROOT\" && $sudo_cmd npm link" else info "Installing NemoClaw from GitHub…" # Resolve the latest release tag so we never install raw main. @@ -855,7 +862,14 @@ install_nemoclaw() { spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli" spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" - spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" + # Use sudo for npm link only when the global prefix is not writable + local npm_global_prefix + npm_global_prefix="$(npm config get prefix 2>/dev/null)" || true + local sudo_cmd="" + if [ -n "$npm_global_prefix" ] && [ ! -w "$npm_global_prefix" ] && [ "$(id -u)" -ne 0 ]; then + sudo_cmd="sudo" + fi + spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && $sudo_cmd npm link" fi refresh_path diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 96a29fd88..3db9bae57 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -104,15 +104,16 @@ function runAgentInSandbox(message, sessionId) { const confPath = `${confDir}/config`; require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 }); - // Pass message and API key via stdin to avoid shell interpolation. - // The remote command reads them from environment/stdin rather than + // Pass API key via SendEnv to avoid embedding secrets in the command string. + // The remote command reads them from environment rather than // embedding user content in a shell string. const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9-]/g, ""); - const cmd = `export NVIDIA_API_KEY=${shellQuote(API_KEY)} && nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`; + const cmd = `nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`; - const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { + const proc = spawn("ssh", ["-T", "-F", confPath, "-o", "SendEnv=NVIDIA_API_KEY", `openshell-${SANDBOX}`, cmd], { timeout: 120000, stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NVIDIA_API_KEY: API_KEY }, }); let stdout = ""; diff --git a/scripts/walkthrough.sh b/scripts/walkthrough.sh index 3a02ec638..6a03c2cda 100755 --- a/scripts/walkthrough.sh +++ b/scripts/walkthrough.sh @@ -84,10 +84,9 @@ tmux kill-session -t "$SESSION" 2>/dev/null || true # Create session with TUI on the left tmux new-session -d -s "$SESSION" -x 200 -y 50 "openshell term" -# Split right pane for the agent -# NVIDIA_API_KEY is not needed inside the sandbox — inference is proxied -# through the OpenShell gateway which injects credentials server-side. -tmux split-window -h -t "$SESSION" \ +# Split right pane for the agent — pass NVIDIA_API_KEY via tmux -e so it +# reaches the sandbox environment without being embedded in the command string. +tmux split-window -h -t "$SESSION" -e "NVIDIA_API_KEY=$NVIDIA_API_KEY" \ "openshell sandbox connect nemoclaw -- bash -c 'nemoclaw-start openclaw agent --agent main --local --session-id live'" # Even split diff --git a/test/policies.test.js b/test/policies.test.js index 43e820dfa..572c348fd 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -93,9 +93,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 9 presets", () => { + it("returns all 10 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(9); + expect(presets.length).toBe(10); }); it("each preset has name and description", () => { @@ -115,6 +115,7 @@ describe("policies", () => { "docker", "huggingface", "jira", + "local-inference", "npm", "outlook", "pypi", @@ -167,6 +168,33 @@ describe("policies", () => { }); }); + describe("local-inference preset", () => { + it("loads and contains host.openshell.internal", () => { + const content = policies.loadPreset("local-inference"); + expect(content).toBeTruthy(); + const hosts = policies.getPresetEndpoints(content); + expect(hosts.includes("host.openshell.internal")).toBeTruthy(); + }); + + it("allows Ollama port 11434 and vLLM port 8000", () => { + const content = policies.loadPreset("local-inference"); + expect(content.includes("port: 11434")).toBe(true); + expect(content.includes("port: 8000")).toBe(true); + }); + + it("has a binaries section", () => { + const content = policies.loadPreset("local-inference"); + expect(content.includes("binaries:")).toBe(true); + }); + + it("extracts valid network_policies entries", () => { + const content = policies.loadPreset("local-inference"); + const entries = policies.extractPresetEntries(content); + expect(entries).toBeTruthy(); + expect(entries.includes("local_inference")).toBe(true); + }); + }); + describe("buildPolicySetCommand", () => { it("shell-quotes sandbox name to prevent injection", () => { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); diff --git a/test/runner.test.js b/test/runner.test.js index 53885210f..fee53e330 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -508,7 +508,10 @@ describe("regression guards", () => { path.join(import.meta.dirname, "..", "scripts", "walkthrough.sh"), "utf-8", ); - // Check only executable lines (tmux spawn, openshell connect) — not comments/docs + // Check only executable lines (tmux spawn, openshell connect) — not comments/docs. + // The safe `tmux -e "NVIDIA_API_KEY=..."` pattern is allowed because it + // passes the key through the environment rather than embedding it in the + // shell command that runs inside the sandbox. const cmdLines = src .split("\n") .filter( @@ -518,7 +521,10 @@ describe("regression guards", () => { (l.includes("tmux") || l.includes("openshell sandbox connect")), ); for (const line of cmdLines) { - expect(line.includes("NVIDIA_API_KEY")).toBe(false); + if (line.includes("NVIDIA_API_KEY")) { + // Only the tmux -e env-passing pattern is acceptable + expect(line).toMatch(/-e\s+"NVIDIA_API_KEY=/); + } } });