Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
99 changes: 0 additions & 99 deletions scripts/ollama-auth-proxy.js

This file was deleted.

101 changes: 101 additions & 0 deletions scripts/ollama-auth-proxy.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/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)
*/

import crypto from "node:crypto";
import http from "node: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: http.IncomingMessage, clientRes: http.ServerResponse) => {
// Every request must present a valid Bearer token. The proxy binds 0.0.0.0
// so the OpenShell sandbox container can reach it via the docker bridge —
// which also means anything else with network reach to the host could,
// so unauthenticated requests are uniformly rejected (no health-check
// bypass for /api/tags). DevTest T5987914: "calls without
// Authorization: Bearer TOKEN should NOT return 200." See #3338.
// Compare buffers, not JS strings: a non-ASCII Authorization header
// can have the same .length as the expected string but a different byte
// length, which would make crypto.timingSafeEqual throw and crash the
// proxy (it binds 0.0.0.0). Build buffers first, gate timingSafeEqual on
// matching byte length.
const auth = clientReq.headers.authorization;
const expectedBuf = Buffer.from(`Bearer ${TOKEN}`);
const authBuf = typeof auth === "string" ? Buffer.from(auth) : null;
const tokenMatch =
authBuf !== null &&
authBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(authBuf, expectedBuf);
if (!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: http.IncomingMessage) => {
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(clientRes);
},
);

proxyReq.on("error", (err: Error) => {
clientRes.writeHead(502, { "Content-Type": "text/plain" });
clientRes.end(`Ollama backend error: ${err.message}`);
});

clientReq.pipe(proxyReq);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
);

// The proxy binds 0.0.0.0, so an unhandled listen error (most commonly
// EADDRINUSE when the port is already taken) would crash with an uncaught
// exception. Exit cleanly with a non-zero code instead; the host-side
// startOllamaAuthProxy() detects the missing process and reports the port
// owner with remediation. See #4820.
server.on("error", (err: NodeJS.ErrnoException) => {
if (err && err.code === "EADDRINUSE") {
console.error(`Ollama auth proxy: port ${LISTEN_PORT} is already in use`);
} else {
console.error(`Ollama auth proxy failed to start: ${err && err.message ? err.message : err}`);
}
process.exit(1);
});

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}`);
});
13 changes: 9 additions & 4 deletions src/lib/actions/uninstall/run-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,10 @@ describe("uninstall run plan", () => {
const logs: string[] = [];
const killed: number[] = [];
const exited = new Set<number>();
const stub = psStub("55678", { exited });
const stub = psStub("55678", {
exited,
cmdline: "/usr/bin/node /opt/nemoclaw/scripts/ollama-auth-proxy.mts\n",
});
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
{
Expand Down Expand Up @@ -563,13 +566,15 @@ describe("uninstall run plan", () => {
expect(logs).toContain("Stopped Ollama auth proxy 33333");
});

it("never kills a process on :11435 whose cmdline is not the auth proxy", () => {
it.each([
"ollama-auth-proxy-helper.mjs",
"ollama-auth-proxy.mts.backup",
])("never kills the near-named %s process on :11435", (scriptName) => {
const logs: string[] = [];
const killed: number[] = [];
// Same owner, different cmdline — exercises the cmdline gate specifically.
const stub = psStub("99999", {
exited: new Set(),
cmdline: "/usr/sbin/nginx -g daemon off;\n",
cmdline: `/usr/bin/node /opt/nemoclaw/scripts/${scriptName}\n`,
});
const result = runUninstallPlan(
{ assumeYes: true, deleteModels: false, keepOpenShell: true },
Expand Down
9 changes: 2 additions & 7 deletions src/lib/actions/uninstall/run-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type UninstallPaths,
} from "../../domain/uninstall/paths";
import { buildUninstallPlan, type UninstallPlan } from "../../domain/uninstall/plan";
import { isOllamaAuthProxyCommandLine } from "../../inference/ollama/process";
import { resolveGatewayName } from "../../onboard/gateway-binding";
import { stopHostGatewayProcesses } from "../../onboard/host-gateway-process";
import { isModelRouterCommandLineForPort } from "../../onboard/model-router-process";
Expand Down Expand Up @@ -503,12 +504,6 @@ function stopMatchingPids(pattern: string, runtime: UninstallRuntime, label: str
}
}

// Identifier we look for in `/proc/<pid>/cmdline` (via `ps -p <pid> -o args=`)
// to confirm a candidate PID is the Ollama auth proxy and not another node
// process that happens to be on the same port. Mirrors the
// `isOllamaProxyProcess` check in `src/lib/onboard-ollama-proxy.ts`.
const OLLAMA_AUTH_PROXY_CMDLINE_MARK = "ollama-auth-proxy.js";

// Resolve the proxy port from runtime.env (rather than `process.env` at
// module-load time) so a user who onboarded with NEMOCLAW_OLLAMA_PROXY_PORT
// set to a custom value sees uninstall scan that same port. Mirrors the
Expand All @@ -529,7 +524,7 @@ function resolveOllamaProxyPort(runtime: UninstallRuntime): number {
function isOllamaAuthProxyPid(pid: number, runtime: UninstallRuntime): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
const result = runtime.run("ps", ["-p", String(pid), "-o", "args="], { env: runtime.env });
return result.status === 0 && result.stdout.includes(OLLAMA_AUTH_PROXY_CMDLINE_MARK);
return result.status === 0 && isOllamaAuthProxyCommandLine(result.stdout);
}

// `ps -p <pid>` is preferred over `kill(pid, 0)` for existence probing here:
Expand Down
2 changes: 1 addition & 1 deletion src/lib/inference/bedrock-runtime-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ function isAdapterProcess(pid: number | null | undefined): boolean {
function killStaleAdapter(): void {
killLocalAdapterPid({
pidPath: PID_PATH,
processNeedle: ADAPTER_PROCESS_NEEDLE,
processMatcher: ADAPTER_PROCESS_NEEDLE,
run,
runCapture,
});
Expand Down
36 changes: 30 additions & 6 deletions src/lib/inference/local-adapter-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
writeLocalAdapterJsonFile,
writeLocalAdapterSecretFile,
} from "./local-adapter-lifecycle";
import { isOllamaAuthProxyCommandLine } from "./ollama/process";

const tempDirs: string[] = [];
const servers: http.Server[] = [];
Expand Down Expand Up @@ -81,28 +82,51 @@ describe("local adapter lifecycle", () => {
expect(fs.statSync(pidPath).mode & 0o777).toBe(0o600);
});

it("guards PID cleanup by process command line", () => {
it.each([
"ollama-auth-proxy.js",
"ollama-auth-proxy.mts",
])("guards PID cleanup for the supported %s script", (scriptName) => {
const pidPath = path.join(tempDir(), "adapter.pid");
persistLocalAdapterPid(pidPath, 789);
const killed: string[][] = [];
const commandLine = `node /opt/nemoclaw/scripts/${scriptName}`;

expect(
isLocalAdapterProcess(789, "ollama-auth-proxy.js", () => "node scripts/ollama-auth-proxy.js"),
).toBe(true);
expect(isLocalAdapterProcess(789, isOllamaAuthProxyCommandLine, () => commandLine)).toBe(true);

killLocalAdapterPid({
pidPath,
processNeedle: "ollama-auth-proxy.js",
processMatcher: isOllamaAuthProxyCommandLine,
run: (args) => {
killed.push(args);
},
runCapture: () => "node scripts/ollama-auth-proxy.js",
runCapture: () => commandLine,
});

expect(killed).toEqual([["kill", "789"]]);
expect(loadLocalAdapterPid(pidPath)).toBeNull();
});

it.each([
"ollama-auth-proxy-helper.mjs",
"ollama-auth-proxy.mts.backup",
])("does not clean up the near-named %s process", (scriptName) => {
const pidPath = path.join(tempDir(), "adapter.pid");
persistLocalAdapterPid(pidPath, 789);
const killed: string[][] = [];

killLocalAdapterPid({
pidPath,
processMatcher: isOllamaAuthProxyCommandLine,
run: (args) => {
killed.push(args);
},
runCapture: () => `node /opt/nemoclaw/scripts/${scriptName}`,
});

expect(killed).toEqual([]);
expect(loadLocalAdapterPid(pidPath)).toBeNull();
});

it("probes adapter health with the expected token hash", async () => {
const tokenHash = localAdapterTokenHash("secret-token");
const server = http.createServer((req, res) => {
Expand Down
17 changes: 10 additions & 7 deletions src/lib/inference/local-adapter-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type RunFn = (
options?: { ignoreError?: boolean; suppressOutput?: boolean },
) => unknown;

export type LocalAdapterProcessMatcher = string | RegExp | ((commandLine: string) => boolean);

export const DEFAULT_LOCAL_ADAPTER_STATE_DIR = nemoclawStateRoot(os.homedir(), GATEWAY_PORT);

export function ensureLocalAdapterStateDir(stateDir = DEFAULT_LOCAL_ADAPTER_STATE_DIR): void {
Expand Down Expand Up @@ -136,26 +138,27 @@ export function loadLocalAdapterPid(filePath: string): number | null {

export function isLocalAdapterProcess(
pid: number | null | undefined,
processNeedle: string | RegExp,
processMatcher: LocalAdapterProcessMatcher,
runCapture: RunCaptureFn,
): boolean {
if (!Number.isInteger(pid) || !pid || pid <= 0) return false;
const cmdline = String(
const commandLine = String(
runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true }) || "",
);
return typeof processNeedle === "string"
? cmdline.includes(processNeedle)
: processNeedle.test(cmdline);
if (typeof processMatcher === "string") return commandLine.includes(processMatcher);
return processMatcher instanceof RegExp
? processMatcher.test(commandLine)
: processMatcher(commandLine);
}

export function killLocalAdapterPid(options: {
pidPath: string;
processNeedle: string | RegExp;
processMatcher: LocalAdapterProcessMatcher;
run: RunFn;
runCapture: RunCaptureFn;
}): void {
const persistedPid = loadLocalAdapterPid(options.pidPath);
if (isLocalAdapterProcess(persistedPid, options.processNeedle, options.runCapture)) {
if (isLocalAdapterProcess(persistedPid, options.processMatcher, options.runCapture)) {
options.run(["kill", String(persistedPid)], { ignoreError: true, suppressOutput: true });
}
removeLocalAdapterFile(options.pidPath);
Expand Down
11 changes: 11 additions & 0 deletions src/lib/inference/ollama/process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Keep `.js` detection for upgrade/uninstall cleanup of proxies launched by
// pre-migration releases. Remove it under #6926 once the minimum supported
// upgrade source is newer than the last release that launched the `.js` file.
const OLLAMA_AUTH_PROXY_SCRIPT_PATTERN = /(?:^|[\s/\\])ollama-auth-proxy\.(?:js|mts)(?=$|\s)/;

export function isOllamaAuthProxyCommandLine(commandLine: string): boolean {
return OLLAMA_AUTH_PROXY_SCRIPT_PATTERN.test(commandLine);
}
Loading
Loading