diff --git a/docs/inference/use-local-inference.md b/docs/inference/use-local-inference.md index 9e24083f587..3eae942a61d 100644 --- a/docs/inference/use-local-inference.md +++ b/docs/inference/use-local-inference.md @@ -73,9 +73,10 @@ If both WSL and Windows-host Ollama are running, pick the intended menu entry du ### Authenticated Reverse Proxy On non-WSL hosts, NemoClaw keeps Ollama bound to `127.0.0.1:11434` and starts a token-gated reverse proxy on `0.0.0.0:11435`. +The native install/start paths also reset NemoClaw-managed systemd launches to the loopback binding. Containers and other hosts on the local network reach Ollama only through the proxy, which validates a Bearer token before forwarding requests. -Ollama itself is never exposed without authentication. +On that native path, NemoClaw never exposes Ollama without authentication. WSL Ollama paths do not use this proxy. Windows-host Ollama uses the Windows daemon through `host.docker.internal`. diff --git a/src/lib/local-inference.ts b/src/lib/local-inference.ts index f7539bae19c..df9f7de604e 100644 --- a/src/lib/local-inference.ts +++ b/src/lib/local-inference.ts @@ -30,8 +30,8 @@ export type RunCaptureFn = (cmd: string | string[], opts?: { ignoreError?: boole // Hosts that the WSL-side onboard CLI tries when probing Ollama. Native Linux // and macOS only ever reach Ollama on the local loopback. WSL with Docker // Desktop can also reach a Windows-host Ollama through the docker-desktop -// integration's `host.docker.internal` alias when Ollama is bound to a -// non-loopback interface (typically OLLAMA_HOST=0.0.0.0). +// integration's `host.docker.internal` alias when that host explicitly exposes +// Ollama outside Windows loopback. export const OLLAMA_LOCALHOST = "127.0.0.1"; export const OLLAMA_HOST_DOCKER_INTERNAL = "host.docker.internal"; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f7854230715..3f50b8850ff 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -6116,14 +6116,12 @@ async function setupNim( if (!checkOllamaPortsOrWarn()) continue selectionLoop; if (!ollamaRunning) { console.log(" Starting Ollama..."); - // On WSL2, binding to 0.0.0.0 creates a dual-stack socket that Docker - // cannot reach via host-gateway. The default 127.0.0.1 binding works - // because WSL2 relays IPv4-only sockets to the Windows host. + // Keep raw Ollama loopback-only. Non-WSL containers reach it through + // the authenticated proxy on OLLAMA_PROXY_PORT. // Shell required: backgrounding (&), env var prefix, output redirection. - const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} `; + const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} `; runShell(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); sleep(2); - if (!isWsl()) printOllamaExposureWarning(); } if (isWsl()) { // WSL2 doesn't need the proxy — Docker can reach the host directly. @@ -6242,7 +6240,7 @@ async function setupNim( // brew install doesn't auto-start a service; launch directly. // Shell required: backgrounding (&), env var prefix, output redirection. console.log(" Starting Ollama..."); - runShell(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { + runShell(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { ignoreError: true, }); sleep(2); @@ -6264,21 +6262,27 @@ async function setupNim( ], { ignoreError: true }, ).trim(); - // Linux native + systemd: override OLLAMA_HOST=0.0.0.0 via a drop-in + // Linux native + systemd: force a loopback-only OLLAMA_HOST drop-in // and let systemd own the daemon (avoids racing the installer's - // daemon with our own `ollama serve`). WSL keeps the default - // 127.0.0.1 binding (wslrelay forwards it). No-systemd / daemon - // failed to start: manual launch with the right binding. + // daemon with our own `ollama serve`). This also repairs older + // NemoClaw-created overrides that exposed raw Ollama on all interfaces. + // WSL keeps Ollama's default binding. No-systemd / daemon failed to + // start: manual launch with the loopback binding. if (!isWsl() && hasOllamaSystemdUnit) { - console.log(" Configuring Ollama systemd override..."); - const dropInBody = `[Service]\nEnvironment="OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT}"\n`; + console.log(" Configuring Ollama systemd loopback override..."); + const dropInBody = `[Service]\nEnvironment="OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT}"\n`; const tmpDropIn = secureTempFile("nemoclaw-ollama-override", ".conf"); fs.writeFileSync(tmpDropIn, dropInBody, { mode: 0o644 }); - runShell( + const overrideResult = runShell( `sudo install -D -m 0644 ${shellQuote(tmpDropIn)} ${shellQuote("/etc/systemd/system/ollama.service.d/override.conf")} && sudo systemctl daemon-reload && sudo systemctl restart ollama`, { ignoreError: true }, ); cleanupTempDir(tmpDropIn, "nemoclaw-ollama-override"); + if (overrideResult.error || overrideResult.status !== 0) { + console.error(" Failed to apply Ollama systemd loopback override."); + console.error(" Refusing to continue with a potentially non-loopback Ollama bind."); + process.exit(1); + } // Retry the probe for a few seconds before giving up — systemd's // daemon may still be binding the port; a single probe could falsely // conclude it's down and spawn a duplicate `ollama serve`. @@ -6290,7 +6294,7 @@ async function setupNim( // Fall back to manual start if systemd path failed or isn't present. if (!findReachableOllamaHost()) { console.log(" Starting Ollama..."); - const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} `; + const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} `; runShell(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); sleep(2); } @@ -6299,7 +6303,6 @@ async function setupNim( // WSL2 doesn't need the proxy — Docker reaches the host directly. console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT}`); } else { - printOllamaExposureWarning(); if (!startOllamaAuthProxy()) { process.exit(1); } diff --git a/test/e2e/test-gpu-double-onboard.sh b/test/e2e/test-gpu-double-onboard.sh index 4d17dc19bfb..6abe7a268b2 100755 --- a/test/e2e/test-gpu-double-onboard.sh +++ b/test/e2e/test-gpu-double-onboard.sh @@ -200,7 +200,7 @@ else fi # If the Ollama installer started a system service, stop it so onboard -# can start Ollama with OLLAMA_HOST=0.0.0.0:11434 (required for containers). +# can restart Ollama on loopback and expose only the authenticated proxy to containers. if curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then info "Ollama service is running — attempting to stop for clean onboard..." systemctl --user stop ollama 2>/dev/null || true diff --git a/test/e2e/test-gpu-e2e.sh b/test/e2e/test-gpu-e2e.sh index 6794dba836c..69faa8195fb 100755 --- a/test/e2e/test-gpu-e2e.sh +++ b/test/e2e/test-gpu-e2e.sh @@ -187,7 +187,7 @@ else fi # If the Ollama installer started a system service, stop it so onboard -# can start Ollama with OLLAMA_HOST=0.0.0.0:11434 (required for containers). +# can restart Ollama on loopback and expose only the authenticated proxy to containers. # This needs the ollama process to be owned by our user, or systemctl access. if curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then info "Ollama service is running — attempting to stop for clean onboard..." diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 6c48d1c04e8..062b3a646f3 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -796,6 +796,132 @@ const { setupNim } = require(${onboardPath}); ); }); + it("starts managed Ollama on loopback before exposing the auth proxy", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-loopback-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "ollama-loopback-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.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 +if [ -n "$outfile" ]; then + printf '%s' "$body" > "$outfile" + printf '%s' "$status" +else + printf '%s' "$body" +fi +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const child_process = require("child_process"); + +child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); +const originalSpawnSync = child_process.spawnSync; +child_process.spawnSync = (cmd, args, opts) => { + if (cmd === "ps") { + return { status: 0, stdout: "node ollama-auth-proxy.js", stderr: "", signal: null }; + } + return originalSpawnSync(cmd, args, opts); +}; + +const messages = []; +const runCommands = []; +const shellCommands = []; +const answers = ["7", "1"]; + +credentials.prompt = async (message) => { + messages.push(message); + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("command -v ollama")) return "/usr/bin/ollama"; + if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("ollama list")) return "qwen3:8b abc 5 GB now"; + if (cmd.includes("ps")) return "node ollama-auth-proxy.js"; + if (cmd.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; +runner.run = (command) => { + runCommands.push(Array.isArray(command) ? command.join(" ") : command); + return { status: 0 }; +}; +runner.runShell = (command) => { + shellCommands.push(command); + return { status: 0 }; +}; + +Object.defineProperty(process, "platform", { value: "linux" }); +platform.isWsl = () => false; + +const { setupNim } = require(${onboardPath}); + +(async () => { + 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, runCommands, shellCommands })); + } 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, "ollama-local"); + assert.ok( + payload.shellCommands.some((command: string) => + command.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"), + ), + "managed Ollama launch should be loopback-only", + ); + assert.ok( + !payload.shellCommands.some((command: string) => + command.includes("OLLAMA_HOST=0.0.0.0:11434"), + ), + "managed Ollama launch must not expose raw Ollama on all interfaces", + ); + }); + it("returns to provider selection when Ollama manual entry chooses back", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-back-")); @@ -3572,6 +3698,7 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); // Fake curl binary that returns a successful response — needed because // runCurlProbe and validateOllamaModel spawn real curl via child_process. @@ -3598,20 +3725,15 @@ fi { mode: 0o755 }, ); - // Simulate: no Ollama installed, no Ollama running, no vLLM — cloud + install-ollama should appear. - // On true Linux that's option 7. On WSL the menu also surfaces a Windows-host - // install entry first, so install-ollama (labelled "Install Ollama (WSL Linux)") - // shifts to option 8. Detect at runtime so the test works in both environments. - const isHostWsl = - !!process.env.WSL_DISTRO_NAME || - !!process.env.WSL_INTEROP || - /microsoft/i.test(os.release()); - const installOptionIndex = isHostWsl ? "8" : "7"; - const expectedInstallLabel = isHostWsl ? "Install Ollama (WSL Linux)" : "Install Ollama (Linux)"; + // Simulate: no Ollama installed, no Ollama running, no vLLM on native + // Linux, so cloud + install-ollama should appear. + const installOptionIndex = "7"; + const expectedInstallLabel = "Install Ollama (Linux)"; const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const registry = require(${registryPath}); +const platform = require(${platformPath}); // Mock child_process.spawn so startOllamaAuthProxy doesn't try to spawn a real process. const child_process = require("child_process"); @@ -3677,6 +3799,7 @@ registry.updateSandbox = (_name, update) => updates.push(update); // Force platform to linux for this test Object.defineProperty(process, 'platform', { value: 'linux' }); +platform.isWsl = () => false; const { setupNim } = require(${onboardPath}); @@ -3729,6 +3852,91 @@ const { setupNim } = require(${onboardPath}); !payload.runCommands.some((cmd: string) => cmd.includes("brew install")), "Should NOT use brew on Linux", ); + assert.ok( + payload.runCommands.some((cmd: string) => + cmd.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"), + ), + "Linux install fallback should start Ollama on loopback", + ); + assert.ok( + !payload.runCommands.some((cmd: string) => cmd.includes("OLLAMA_HOST=0.0.0.0:11434")), + "Linux install path must not expose raw Ollama on all interfaces", + ); + }); + + it("fails closed when the Linux systemd loopback override cannot be applied", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-systemd-fail-")); + const scriptPath = path.join(tmpDir, "systemd-fail-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); + +const menuLines = []; +const originalLog = console.log; +console.log = (...args) => { + const line = args.join(" "); + menuLines.push(line); + originalLog(...args); +}; + +function findInstallOllamaChoice() { + const option = menuLines.find((line) => /Install Ollama \((WSL )?Linux\)/.test(line)); + const match = option && option.match(/^\s*(\d+)\)/); + if (!match) { + throw new Error("Could not find Linux Ollama install option in menu:\\n" + menuLines.join("\\n")); + } + return match[1]; +} + +credentials.prompt = async () => findInstallOllamaChoice(); +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("command -v ollama")) return ""; + if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("systemctl list-unit-files ollama.service")) return "ollama.service enabled"; + return ""; +}; +runner.runShell = (command) => { + if (command.includes("ollama.com/install.sh")) return { status: 0 }; + if (command.includes("sudo install -D -m 0644")) return { status: 1 }; + return { status: 0 }; +}; + +Object.defineProperty(process, "platform", { value: "linux" }); +platform.isWsl = () => false; + +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim("systemd-fail-test", null); +})().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, + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Failed to apply Ollama systemd loopback override/); + assert.match(result.stderr, /Refusing to continue/); }); it("uses install-ollama for non-interactive NEMOCLAW_PROVIDER=ollama on fresh Linux", () => { @@ -3742,6 +3950,7 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "registry.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync( @@ -3770,6 +3979,7 @@ fi const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const registry = require(${registryPath}); +const platform = require(${platformPath}); const child_process = require("child_process"); child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); @@ -3814,6 +4024,7 @@ runner.runShell = (command) => { registry.updateSandbox = (_name, update) => updates.push(update); Object.defineProperty(process, "platform", { value: "linux" }); +platform.isWsl = () => false; const { setupNim } = require(${onboardPath}); @@ -3857,6 +4068,16 @@ const { setupNim } = require(${onboardPath}); payload.runCommands.some((cmd: string) => cmd.includes("ollama.com/install.sh")), "Should use the Ollama installer when requested non-interactively on a fresh host", ); + assert.ok( + payload.runCommands.some((cmd: string) => + cmd.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"), + ), + "non-interactive install fallback should start Ollama on loopback", + ); + assert.ok( + !payload.runCommands.some((cmd: string) => cmd.includes("OLLAMA_HOST=0.0.0.0:11434")), + "non-interactive install path must not expose raw Ollama on all interfaces", + ); }); it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => {