Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,18 @@ jobs:

- name: Run gateway isolation E2E tests
run: NEMOCLAW_TEST_IMAGE=nemoclaw-production bash test/e2e-gateway-isolation.sh

test-e2e-ollama-proxy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"

- name: Run Ollama auth proxy E2E tests
run: bash test/e2e-ollama-proxy.sh
9 changes: 6 additions & 3 deletions bin/lib/local-inference.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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;
}
Expand Down Expand Up @@ -47,7 +48,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;
}
Expand Down Expand Up @@ -98,7 +101,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." };
Expand Down
56 changes: 46 additions & 10 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
getBootstrapOllamaModelOptions,
getLocalProviderBaseUrl,
Expand Down Expand Up @@ -1133,6 +1134,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);
Comment on lines +1145 to +1155
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't rotate the proxy token unless the new proxy actually owns :11435.

This helper always generates a new token and backgrounds a new proxy, but it never checks whether :11435 is already occupied or whether the child bound successfully. Since Step 1 only cleans up the OpenShell gateway, rerunning onboarding with Ollama can leave the old proxy serving the old token while ollama-local gets updated to the new one, which breaks later requests from the sandbox.

Suggested hardening
 function startOllamaAuthProxy() {
   const crypto = require("crypto");
-  ollamaProxyToken = crypto.randomBytes(24).toString("hex");
+  if (runCapture("curl -sf http://127.0.0.1:11435/api/tags 2>/dev/null", { ignoreError: true })) {
+    console.error("  Ollama auth proxy is already running on port 11435. Stop it before rerunning onboard.");
+    process.exit(1);
+  }
+  const token = crypto.randomBytes(24).toString("hex");
   run(
-    `OLLAMA_PROXY_TOKEN=${shellQuote(ollamaProxyToken)} ` +
+    `OLLAMA_PROXY_TOKEN=${shellQuote(token)} ` +
     `node "${SCRIPTS}/ollama-auth-proxy.js" > /dev/null 2>&1 &`,
     { ignoreError: true },
   );
   sleep(1);
+  if (!runCapture("curl -sf http://127.0.0.1:11435/api/tags 2>/dev/null", { ignoreError: true })) {
+    console.error("  Ollama auth proxy failed to start on port 11435.");
+    process.exit(1);
+  }
+  ollamaProxyToken = token;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 287 - 295, startOllamaAuthProxy currently
unconditionally rotates ollamaProxyToken and backgrounds a new
ollama-auth-proxy.js, which can leave the old proxy on :11435 serving the
previous token; change the flow so you first probe localhost:11435 to see if
it's already bound by a running proxy (or attempt a handshake to verify it
serves the current token), and only generate a new ollamaProxyToken and
background a new proxy when either the port is free or you can confirm the newly
spawned process successfully binds/serves :11435; after starting the child
process (the one that runs ollama-auth-proxy.js) verify ownership by attempting
a connection/handshake to :11435 and only replace ollamaProxyToken if that
verification succeeds, otherwise kill the spawned process and keep the existing
token/daemon running.

// 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;
}

async function ensureNamedCredential(envName, label, helpUrl = null) {
let key = getCredential(envName);
if (key) {
Expand Down Expand Up @@ -1871,11 +1902,12 @@ async function setupNim(gpu) {
break;
} 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";
credentialEnv = "OPENAI_API_KEY";
endpointUrl = getLocalProviderBaseUrl(provider);
Expand Down Expand Up @@ -1912,10 +1944,11 @@ async function setupNim(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";
credentialEnv = "OPENAI_API_KEY";
endpointUrl = getLocalProviderBaseUrl(provider);
Expand Down Expand Up @@ -2029,9 +2062,12 @@ async function setupInference(sandboxName, model, provider, endpointUrl = null,
console.error(" On macOS, local inference also depends on OpenShell host routing support.");
process.exit(1);
}
const baseUrl = getLocalProviderBaseUrl(provider);
upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", baseUrl, {
OPENAI_API_KEY: "ollama",
// 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`;
upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", proxyBaseUrl, {
OPENAI_API_KEY: proxyToken,
Comment on lines +2067 to +2070
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fallback token "ollama" will cause 401s if proxy is running with a different token.

getOllamaProxyToken() returns null if startOllamaAuthProxy() was never called in this process (e.g., re-running setupInference in isolation or if the call order changes). The fallback "ollama" will not match the token the running proxy expects, silently breaking inference.

Consider either:

  1. Making the proxy token persistent (e.g., write to a file in ~/.nemoclaw/ alongside credentials), or
  2. Failing explicitly when the token is unavailable for ollama-local.
Option 2: Fail explicitly
-    const proxyToken = getOllamaProxyToken() || "ollama";
+    const proxyToken = getOllamaProxyToken();
+    if (!proxyToken) {
+      console.error("  Ollama proxy token is not set. Run the full onboard flow to initialize the proxy.");
+      process.exit(1);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const proxyToken = getOllamaProxyToken() || "ollama";
const proxyBaseUrl = `${HOST_GATEWAY_URL}:11435/v1`;
upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", proxyBaseUrl, {
OPENAI_API_KEY: proxyToken,
const proxyToken = getOllamaProxyToken();
if (!proxyToken) {
console.error(" Ollama proxy token is not set. Run the full onboard flow to initialize the proxy.");
process.exit(1);
}
const proxyBaseUrl = `${HOST_GATEWAY_URL}:11435/v1`;
upsertProvider("ollama-local", "openai", "OPENAI_API_KEY", proxyBaseUrl, {
OPENAI_API_KEY: proxyToken,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/onboard.js` around lines 1989 - 1992, The current fallback token
"ollama" masks missing proxy tokens and causes 401s; replace the silent fallback
by checking getOllamaProxyToken() before calling upsertProvider and fail fast if
it returns null/undefined: in the block where proxyToken is assigned (calls to
getOllamaProxyToken and the subsequent upsertProvider("ollama-local", ...)), if
getOllamaProxyToken() returns falsy, log a clear error via processLogger.error
(or throw a descriptive Error) stating the Ollama proxy token is missing and
stop further execution (process.exit(1) or propagate the error) so you don't
register "ollama-local" with an incorrect token; alternatively, if you prefer
persistence, implement writing/reading the token to a known file under
~/.nemoclaw/ and read that via getOllamaProxyToken so upsertProvider receives a
stable token—choose one approach and apply it around
getOllamaProxyToken/startOllamaAuthProxy and the upsertProvider("ollama-local",
...) call.

});
runOpenshell(["inference", "set", "--no-verify", "--provider", "ollama-local", "--model", model]);
console.log(` Priming Ollama model: ${model}`);
Expand Down
73 changes: 73 additions & 0 deletions scripts/ollama-auth-proxy.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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}`);
});
14 changes: 9 additions & 5 deletions scripts/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
upsert_provider \
"ollama-local" \
"openai" \
"OPENAI_API_KEY=ollama" \
"OPENAI_API_KEY=$OLLAMA_PROXY_TOKEN" \
"OPENAI_BASE_URL=$OLLAMA_LOCAL_BASE_URL"
fi
fi
Expand Down
Loading
Loading