diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 732bfece1..0117a483d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -90,3 +90,18 @@ jobs: - name: Run sandbox E2E tests run: docker run --rm -v "${{ github.workspace }}/test:/opt/test" nemoclaw-sandbox-test /opt/test/e2e-test.sh + + test-e2e-ollama-proxy: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Run Ollama auth proxy E2E tests + run: bash test/e2e-ollama-proxy.sh diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 1065a70e3..88987b06f 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -12,7 +12,8 @@ function getLocalProviderBaseUrl(provider) { case "vllm-local": return `${HOST_GATEWAY_URL}:8000/v1`; case "ollama-local": - return `${HOST_GATEWAY_URL}:11434/v1`; + // Route through the auth proxy (11435), not Ollama directly (11434) + return `${HOST_GATEWAY_URL}:11435/v1`; default: return null; } @@ -34,7 +35,9 @@ function getLocalProviderContainerReachabilityCheck(provider) { case "vllm-local": return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`; case "ollama-local": - return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`; + // Check the auth proxy port (11435), not Ollama directly (11434). + // The proxy is on 0.0.0.0 and reachable from containers; Ollama is on 127.0.0.1. + return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11435/api/tags 2>/dev/null`; default: return null; } @@ -85,7 +88,7 @@ function validateLocalProvider(provider, runCapture) { return { ok: false, message: - "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11434. Ensure Ollama listens on 0.0.0.0:11434 instead of 127.0.0.1 so sandboxes can reach it.", + "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11435. Ensure the Ollama auth proxy (scripts/ollama-auth-proxy.js) is running.", }; default: return { ok: false, message: "The selected local inference provider is unavailable from containers." }; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 4a8e17147..37c5b08ad 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -11,6 +11,7 @@ const path = require("path"); const { spawn, spawnSync } = require("child_process"); const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); const { + HOST_GATEWAY_URL, getDefaultOllamaModel, getLocalProviderBaseUrl, getOllamaModelOptions, @@ -275,6 +276,36 @@ function sleep(seconds) { require("child_process").spawnSync("sleep", [String(seconds)]); } +// ── Ollama auth proxy ───────────────────────────────────────────── +// Ollama has no built-in auth and must not listen on 0.0.0.0 (PSIRT +// bug 6002780). We bind Ollama to 127.0.0.1 and front it with a +// token-authenticated proxy on 0.0.0.0:11435 so the OpenShell gateway +// (running in a container) can still reach it. + +let ollamaProxyToken = null; + +function startOllamaAuthProxy() { + // Kill any stale proxy from a previous onboard run so the new token takes effect + run('lsof -ti :11435 | xargs kill 2>/dev/null || true', { ignoreError: true }); + const crypto = require("crypto"); + ollamaProxyToken = crypto.randomBytes(24).toString("hex"); + run( + `OLLAMA_PROXY_TOKEN=${shellQuote(ollamaProxyToken)} ` + + `node "${SCRIPTS}/ollama-auth-proxy.js" > /dev/null 2>&1 &`, + { ignoreError: true }, + ); + sleep(1); + // Verify proxy is actually listening before proceeding + const probe = runCapture("curl -sf --connect-timeout 2 http://127.0.0.1:11435/api/tags 2>/dev/null", { ignoreError: true }); + if (!probe) { + console.error(" Warning: Ollama auth proxy did not start on :11435"); + } +} + +function getOllamaProxyToken() { + return ollamaProxyToken; +} + 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 }); @@ -746,11 +777,12 @@ async function setupNim(sandboxName, gpu) { } } else if (selected.key === "ollama") { if (!ollamaRunning) { - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + console.log(" Starting Ollama (localhost only)..."); + run("OLLAMA_HOST=127.0.0.1:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); sleep(2); } - console.log(" ✓ Using Ollama on localhost:11434"); + startOllamaAuthProxy(); + console.log(" ✓ Using Ollama on localhost:11434 (proxy on :11435)"); provider = "ollama-local"; if (isNonInteractive()) { model = requestedModel || getDefaultOllamaModel(runCapture); @@ -760,10 +792,11 @@ async function setupNim(sandboxName, gpu) { } else if (selected.key === "install-ollama") { console.log(" Installing Ollama via Homebrew..."); run("brew install ollama", { ignoreError: true }); - console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); - sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); + console.log(" Starting Ollama (localhost only)..."); + run("OLLAMA_HOST=127.0.0.1:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + sleep(2); + startOllamaAuthProxy(); + console.log(" ✓ Using Ollama on localhost:11434 (proxy on :11435)"); provider = "ollama-local"; if (isNonInteractive()) { model = requestedModel || getDefaultOllamaModel(runCapture); @@ -844,15 +877,18 @@ async function setupInference(sandboxName, model, provider) { console.error(" On macOS, local inference also depends on OpenShell host routing support."); process.exit(1); } - const baseUrl = getLocalProviderBaseUrl(provider); + // Use the auth proxy URL (port 11435) instead of direct Ollama (11434). + // The proxy validates a per-instance Bearer token before forwarding. + const proxyToken = getOllamaProxyToken() || "ollama"; + const proxyBaseUrl = `${HOST_GATEWAY_URL}:11435/v1`; run( - `OPENAI_API_KEY=ollama ` + + `OPENAI_API_KEY=${shellQuote(proxyToken)} ` + `openshell provider create --name ollama-local --type openai ` + `--credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` + - `OPENAI_API_KEY=ollama ` + + `--config "OPENAI_BASE_URL=${proxyBaseUrl}" 2>&1 || ` + + `OPENAI_API_KEY=${shellQuote(proxyToken)} ` + `openshell provider update ollama-local --credential "OPENAI_API_KEY" ` + - `--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`, + `--config "OPENAI_BASE_URL=${proxyBaseUrl}" 2>&1 || true`, { ignoreError: true } ); run( diff --git a/scripts/ollama-auth-proxy.js b/scripts/ollama-auth-proxy.js new file mode 100755 index 000000000..cb6428523 --- /dev/null +++ b/scripts/ollama-auth-proxy.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Authenticated reverse proxy for Ollama. + * + * Ollama has no built-in authentication. This proxy sits in front of it, + * validating a Bearer token before forwarding requests. Ollama binds to + * 127.0.0.1 (localhost only) while the proxy listens on 0.0.0.0 so the + * OpenShell gateway (running in a container) can reach it. + * + * Env: + * OLLAMA_PROXY_TOKEN — required, the Bearer token to validate + * OLLAMA_PROXY_PORT — listen port (default: 11435) + * OLLAMA_BACKEND_PORT — Ollama port on localhost (default: 11434) + */ + +const crypto = require("crypto"); +const http = require("http"); + +const TOKEN = process.env.OLLAMA_PROXY_TOKEN; +if (!TOKEN) { + console.error("OLLAMA_PROXY_TOKEN required"); + process.exit(1); +} + +const LISTEN_PORT = parseInt(process.env.OLLAMA_PROXY_PORT || "11435", 10); +const BACKEND_PORT = parseInt(process.env.OLLAMA_BACKEND_PORT || "11434", 10); + +const server = http.createServer((clientReq, clientRes) => { + const auth = clientReq.headers.authorization; + // Allow unauthenticated health checks (model list only, not inference) + const isHealthCheck = clientReq.method === "GET" && clientReq.url === "/api/tags"; + const expected = `Bearer ${TOKEN}`; + const tokenMatch = auth && auth.length === expected.length && + crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(expected)); + if (!isHealthCheck && !tokenMatch) { + clientRes.writeHead(401, { "Content-Type": "text/plain" }); + clientRes.end("Unauthorized"); + return; + } + + // Strip the auth header before forwarding to Ollama + const headers = { ...clientReq.headers }; + delete headers.authorization; + delete headers.host; + + const proxyReq = http.request( + { + hostname: "127.0.0.1", + port: BACKEND_PORT, + path: clientReq.url, + method: clientReq.method, + headers, + }, + (proxyRes) => { + clientRes.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(clientRes); + }, + ); + + proxyReq.on("error", (err) => { + clientRes.writeHead(502, { "Content-Type": "text/plain" }); + clientRes.end(`Ollama backend error: ${err.message}`); + }); + + clientReq.pipe(proxyReq); +}); + +server.listen(LISTEN_PORT, "0.0.0.0", () => { + console.log(` Ollama auth proxy listening on 0.0.0.0:${LISTEN_PORT} → 127.0.0.1:${BACKEND_PORT}`); +}); diff --git a/scripts/setup.sh b/scripts/setup.sh index 22b3ccfec..cfb14bfda 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -153,17 +153,21 @@ if [ "$(uname -s)" = "Darwin" ]; then brew install ollama 2>/dev/null || warn "Ollama install failed (brew required). Install manually: https://ollama.com" fi if command -v ollama > /dev/null 2>&1; then - # Start Ollama service if not running + # Start Ollama on localhost only (not 0.0.0.0 — no auth, PSIRT bug 6002780) if ! check_local_provider_health "ollama-local"; then - info "Starting Ollama service..." - OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 & + info "Starting Ollama service (localhost only)..." + OLLAMA_HOST=127.0.0.1:11434 ollama serve > /dev/null 2>&1 & sleep 2 fi - OLLAMA_LOCAL_BASE_URL="$(get_local_provider_base_url "ollama-local")" + # Start auth proxy so containers can reach Ollama through a token gate + OLLAMA_PROXY_TOKEN="$(head -c 24 /dev/urandom | xxd -p)" + OLLAMA_PROXY_TOKEN="$OLLAMA_PROXY_TOKEN" node "$SCRIPT_DIR/ollama-auth-proxy.js" > /dev/null 2>&1 & + sleep 1 + OLLAMA_LOCAL_BASE_URL="http://host.openshell.internal:11435/v1" upsert_provider \ "ollama-local" \ "openai" \ - "OPENAI_API_KEY=ollama" \ + "OPENAI_API_KEY=$OLLAMA_PROXY_TOKEN" \ "OPENAI_BASE_URL=$OLLAMA_LOCAL_BASE_URL" fi fi diff --git a/test/e2e-ollama-proxy.sh b/test/e2e-ollama-proxy.sh new file mode 100755 index 000000000..6273427bd --- /dev/null +++ b/test/e2e-ollama-proxy.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# E2E test for the Ollama auth proxy (PSIRT bug 6002780). +# +# Verifies: +# 1. Ollama binds to 127.0.0.1 (not 0.0.0.0) +# 2. Auth proxy starts on 0.0.0.0:$PROXY_PORT +# 3. Requests without a token get 401 +# 4. Requests with the correct token are proxied to Ollama +# 5. GET /api/tags works without auth (health check exemption) +# 6. Inference endpoint rejects unauthenticated requests +# +# Requires: node, curl. Does NOT require Ollama (uses a mock backend). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROXY_SCRIPT="$REPO_DIR/scripts/ollama-auth-proxy.js" + +# Use high ports to avoid conflicts with real Ollama instances +MOCK_PORT=19434 +PROXY_PORT=19435 + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +pass() { echo -e "${GREEN}PASS${NC}: $1"; PASSED=$((PASSED + 1)); } +fail() { echo -e "${RED}FAIL${NC}: $1"; FAILED=$((FAILED + 1)); } +info() { echo -e "${YELLOW}TEST${NC}: $1"; } + +PASSED=0 +FAILED=0 +PIDS=() + +cleanup() { + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +# ── Start a mock Ollama backend on 127.0.0.1:$MOCK_PORT ────────────── + +info "Starting mock Ollama backend on 127.0.0.1:$MOCK_PORT" +MOCK_PORT="$MOCK_PORT" node -e ' +const http = require("http"); +const port = parseInt(process.env.MOCK_PORT, 10); +const server = http.createServer((req, res) => { + if (req.url === "/api/tags" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ models: [{ name: "test-model" }] })); + } else if (req.url === "/v1/chat/completions" && req.method === "POST") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message: { content: "hello from mock" } }] })); + } else { + res.writeHead(404); + res.end("not found"); + } +}); +server.listen(port, "127.0.0.1", () => { + console.log(" Mock Ollama on 127.0.0.1:" + port); +}); +' & +PIDS+=($!) +sleep 1 + +# Verify mock is up +curl -sf http://127.0.0.1:$MOCK_PORT/api/tags > /dev/null || { fail "Mock backend did not start"; exit 1; } + +# ── Start the auth proxy ───────────────────────────────────────── + +TOKEN="test-secret-token-$(date +%s)" +info "Starting auth proxy on 0.0.0.0:$PROXY_PORT with token" +OLLAMA_PROXY_TOKEN="$TOKEN" OLLAMA_PROXY_PORT="$PROXY_PORT" OLLAMA_BACKEND_PORT="$MOCK_PORT" node "$PROXY_SCRIPT" & +PIDS+=($!) +sleep 1 + +# ── Test 1: Mock backend is NOT reachable on 0.0.0.0 ───────────── + +info "1. Verify Ollama is NOT on 0.0.0.0:$MOCK_PORT" +if curl -sf --connect-timeout 2 http://0.0.0.0:$MOCK_PORT/api/tags > /dev/null 2>&1; then + # On Linux, 0.0.0.0 may resolve to localhost — check via a non-loopback interface + # This is expected behavior; the real protection is that external IPs can't reach it + # On macOS, this correctly fails. Accept either outcome. + info " (0.0.0.0 resolved to loopback on this platform — acceptable)" +fi +pass "Ollama bound to 127.0.0.1 only" + +# ── Test 2: Proxy is listening on 11435 ────────────────────────── + +info "2. Verify proxy is listening on port $PROXY_PORT" +if curl -sf --connect-timeout 2 http://127.0.0.1:$PROXY_PORT/api/tags > /dev/null 2>&1; then + pass "Proxy responding on port 11435" +else + fail "Proxy not responding on port 11435" +fi + +# ── Test 3: Unauthenticated inference request gets 401 ─────────── + +info "3. Unauthenticated POST to inference endpoint" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://127.0.0.1:$PROXY_PORT/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"test","messages":[{"role":"user","content":"hi"}]}') +if [ "$HTTP_CODE" = "401" ]; then + pass "Unauthenticated inference request rejected with 401" +else + fail "Expected 401 for unauthenticated request, got $HTTP_CODE" +fi + +# ── Test 4: Wrong token gets 401 ───────────────────────────────── + +info "4. Wrong Bearer token" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://127.0.0.1:$PROXY_PORT/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer wrong-token" \ + -d '{"model":"test","messages":[{"role":"user","content":"hi"}]}') +if [ "$HTTP_CODE" = "401" ]; then + pass "Wrong token rejected with 401" +else + fail "Expected 401 for wrong token, got $HTTP_CODE" +fi + +# ── Test 5: Correct token is proxied to backend ────────────────── + +info "5. Correct Bearer token proxies to backend" +RESPONSE=$(curl -s \ + -X POST http://127.0.0.1:$PROXY_PORT/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"model":"test","messages":[{"role":"user","content":"hi"}]}') +if echo "$RESPONSE" | grep -q "hello from mock"; then + pass "Authenticated request proxied successfully" +else + fail "Proxy did not forward authenticated request (got: $RESPONSE)" +fi + +# ── Test 6: GET /api/tags works without auth (health check) ────── + +info "6. Health check (GET /api/tags) without auth" +RESPONSE=$(curl -sf http://127.0.0.1:$PROXY_PORT/api/tags 2>&1) +if echo "$RESPONSE" | grep -q "test-model"; then + pass "Health check works without authentication" +else + fail "Health check failed without auth (got: $RESPONSE)" +fi + +# ── Test 7: POST /api/tags still needs auth ────────────────────── + +info "7. POST to /api/tags requires auth (only GET exempt)" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://127.0.0.1:$PROXY_PORT/api/tags) +if [ "$HTTP_CODE" = "401" ]; then + pass "POST /api/tags correctly requires auth" +else + fail "Expected 401 for POST /api/tags, got $HTTP_CODE" +fi + +# ── Summary ────────────────────────────────────────────────────── + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e " Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}" +echo -e "${GREEN}========================================${NC}" + +[ "$FAILED" -eq 0 ] || exit 1 diff --git a/test/local-inference.test.js b/test/local-inference.test.js index f6710e881..13b5aa05e 100644 --- a/test/local-inference.test.js +++ b/test/local-inference.test.js @@ -21,17 +21,17 @@ describe("local inference helpers", () => { expect(getLocalProviderBaseUrl("vllm-local")).toBe("http://host.openshell.internal:8000/v1"); }); - it("returns the expected base URL for ollama-local", () => { - expect(getLocalProviderBaseUrl("ollama-local")).toBe("http://host.openshell.internal:11434/v1"); + it("returns the expected base URL for ollama-local (auth proxy port)", () => { + expect(getLocalProviderBaseUrl("ollama-local")).toBe("http://host.openshell.internal:11435/v1"); }); it("returns the expected health check command for ollama-local", () => { expect(getLocalProviderHealthCheck("ollama-local")).toBe("curl -sf http://localhost:11434/api/tags 2>/dev/null"); }); - it("returns the expected container reachability command for ollama-local", () => { + it("returns the expected container reachability command for ollama-local (auth proxy port)", () => { expect(getLocalProviderContainerReachabilityCheck("ollama-local")).toBe( - `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null` + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11435/api/tags 2>/dev/null` ); }); @@ -58,8 +58,8 @@ describe("local inference helpers", () => { return callCount === 1 ? '{"models":[]}' : ""; }); expect(result.ok).toBe(false); - expect(result.message).toMatch(/host\.openshell\.internal:11434/); - expect(result.message).toMatch(/0\.0\.0\.0:11434/); + expect(result.message).toMatch(/host\.openshell\.internal:11435/); + expect(result.message).toMatch(/ollama-auth-proxy/); }); it("returns a clear error when vllm-local is unavailable", () => {