diff --git a/scripts/ollama-auth-proxy.js b/scripts/ollama-auth-proxy.js deleted file mode 100755 index d2215fb738a..00000000000 --- a/scripts/ollama-auth-proxy.js +++ /dev/null @@ -1,99 +0,0 @@ -#!/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) => { - // 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) => { - 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); -}); - -// 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", (/** @type {NodeJS.ErrnoException} */ err) => { - 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}`); -}); diff --git a/scripts/ollama-auth-proxy.mts b/scripts/ollama-auth-proxy.mts new file mode 100755 index 00000000000..9f2a9b35854 --- /dev/null +++ b/scripts/ollama-auth-proxy.mts @@ -0,0 +1,116 @@ +#!/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 handleBackendError = (err: Error): void => { + if (clientRes.destroyed || clientRes.writableEnded) return; + if (clientRes.headersSent) { + clientRes.destroy(); + return; + } + clientRes.writeHead(502, { "Content-Type": "text/plain" }); + clientRes.end(`Ollama backend error: ${err.message}`); + }; + + const proxyReq = http.request( + { + hostname: "127.0.0.1", + port: BACKEND_PORT, + path: clientReq.url, + method: clientReq.method, + headers, + }, + (proxyRes: http.IncomingMessage) => { + proxyRes.once("error", handleBackendError); + clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(clientRes); + }, + ); + + const destroyUpstream = (): void => { + if (!proxyReq.destroyed) proxyReq.destroy(); + }; + clientReq.once("aborted", destroyUpstream); + clientRes.once("close", () => { + if (!clientRes.writableFinished) destroyUpstream(); + }); + proxyReq.once("error", handleBackendError); + + clientReq.pipe(proxyReq); + }, +); + +// 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}`); +}); diff --git a/src/lib/actions/uninstall/run-plan.test.ts b/src/lib/actions/uninstall/run-plan.test.ts index 480b60a9477..9974ff2b62a 100644 --- a/src/lib/actions/uninstall/run-plan.test.ts +++ b/src/lib/actions/uninstall/run-plan.test.ts @@ -433,7 +433,10 @@ describe("uninstall run plan", () => { const logs: string[] = []; const killed: number[] = []; const exited = new Set(); - 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 }, { @@ -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 }, diff --git a/src/lib/actions/uninstall/run-plan.ts b/src/lib/actions/uninstall/run-plan.ts index cc642fe6845..bff09d9b698 100644 --- a/src/lib/actions/uninstall/run-plan.ts +++ b/src/lib/actions/uninstall/run-plan.ts @@ -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"; @@ -503,12 +504,6 @@ function stopMatchingPids(pattern: string, runtime: UninstallRuntime, label: str } } -// Identifier we look for in `/proc//cmdline` (via `ps -p -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 @@ -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 ` is preferred over `kill(pid, 0)` for existence probing here: diff --git a/src/lib/inference/bedrock-runtime-adapter.ts b/src/lib/inference/bedrock-runtime-adapter.ts index 36f3691f738..b7f2e33eb90 100644 --- a/src/lib/inference/bedrock-runtime-adapter.ts +++ b/src/lib/inference/bedrock-runtime-adapter.ts @@ -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, }); diff --git a/src/lib/inference/local-adapter-lifecycle.test.ts b/src/lib/inference/local-adapter-lifecycle.test.ts index 22b884a7f45..64f36c0ab8c 100644 --- a/src/lib/inference/local-adapter-lifecycle.test.ts +++ b/src/lib/inference/local-adapter-lifecycle.test.ts @@ -22,6 +22,7 @@ import { writeLocalAdapterJsonFile, writeLocalAdapterSecretFile, } from "./local-adapter-lifecycle"; +import { isOllamaAuthProxyCommandLine } from "./ollama/process"; const tempDirs: string[] = []; const servers: http.Server[] = []; @@ -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) => { diff --git a/src/lib/inference/local-adapter-lifecycle.ts b/src/lib/inference/local-adapter-lifecycle.ts index 8aa2f81d31f..65a8ed9891f 100644 --- a/src/lib/inference/local-adapter-lifecycle.ts +++ b/src/lib/inference/local-adapter-lifecycle.ts @@ -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 { @@ -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); diff --git a/src/lib/inference/ollama/process.ts b/src/lib/inference/ollama/process.ts new file mode 100644 index 00000000000..e27a4a95a38 --- /dev/null +++ b/src/lib/inference/ollama/process.ts @@ -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); +} diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index 421f917d284..6a6a1ba5315 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -28,6 +28,7 @@ const { validateOllamaModel, } = require("../local"); const { anyRegistryModelFits, modelFitsAvailableMemory } = require("../ollama-model-registry"); +const { isOllamaAuthProxyCommandLine }: typeof import("./process") = require("./process"); const { buildSubprocessEnv } = require("../../subprocess-env"); const { prompt } = require("../../credentials/store"); const { promptManualModelId } = require("../model-prompts"); @@ -135,12 +136,12 @@ function loadPersistedProxyPid(): number | null { // ── Process management ─────────────────────────────────────────── function isOllamaProxyProcess(pid: number | null | undefined): boolean { - return isLocalAdapterProcess(pid, "ollama-auth-proxy.js", runCapture); + return isLocalAdapterProcess(pid, isOllamaAuthProxyCommandLine, runCapture); } function spawnOllamaAuthProxy(token: string): number | null { const child = spawnDetachedNodeAdapter({ - scriptPath: path.join(SCRIPTS, "ollama-auth-proxy.js"), + scriptPath: path.join(SCRIPTS, "ollama-auth-proxy.mts"), env: { OLLAMA_PROXY_TOKEN: token, OLLAMA_PROXY_PORT: String(OLLAMA_PROXY_PORT), @@ -156,7 +157,7 @@ function killStaleProxy(): void { try { killLocalAdapterPid({ pidPath: PROXY_PID_PATH, - processNeedle: "ollama-auth-proxy.js", + processMatcher: isOllamaAuthProxyCommandLine, run, runCapture, }); diff --git a/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts b/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts index 166df0fe10e..c901ca2b4ed 100644 --- a/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts +++ b/src/lib/inference/openrouter-runtime-adapter-lifecycle.ts @@ -127,7 +127,7 @@ function isAdapterProcess(pid: number | null | undefined): boolean { function killStaleAdapter(): void { killLocalAdapterPid({ pidPath: PID_PATH, - processNeedle: PROCESS_NEEDLE, + processMatcher: PROCESS_NEEDLE, run, runCapture, }); diff --git a/test/e2e/live/gpu-e2e-helpers.ts b/test/e2e/live/gpu-e2e-helpers.ts index 9daf570680f..0507d1a7822 100644 --- a/test/e2e/live/gpu-e2e-helpers.ts +++ b/test/e2e/live/gpu-e2e-helpers.ts @@ -242,7 +242,7 @@ sleep 2 curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" "http://127.0.0.1:$1/api/tags"`, "restart-proxy", PROXY_PORT, - path.join(REPO_ROOT, "scripts", "ollama-auth-proxy.js"), + path.join(REPO_ROOT, "scripts", "ollama-auth-proxy.mts"), ], { artifactName: "proxy-restart-from-token", diff --git a/test/e2e/live/ollama-auth-proxy.test.ts b/test/e2e/live/ollama-auth-proxy.test.ts index 4cc43263132..20e7243ba4a 100644 --- a/test/e2e/live/ollama-auth-proxy.test.ts +++ b/test/e2e/live/ollama-auth-proxy.test.ts @@ -21,7 +21,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; -const PROXY_SCRIPT = path.join(REPO_ROOT, "scripts", "ollama-auth-proxy.js"); +const PROXY_SCRIPT = path.join(REPO_ROOT, "scripts", "ollama-auth-proxy.mts"); const OLLAMA_PORT = parsePort("NEMOCLAW_E2E_OLLAMA_PORT", 11434); const PROXY_PORT = parsePort("NEMOCLAW_E2E_OLLAMA_PROXY_PORT", 11435); const MODEL = process.env.NEMOCLAW_E2E_OLLAMA_PROXY_MODEL ?? "qwen2.5:0.5b"; diff --git a/test/ollama-auth-proxy-handler-helpers.ts b/test/ollama-auth-proxy-handler-helpers.ts index 28a70d2711b..21f094b4835 100644 --- a/test/ollama-auth-proxy-handler-helpers.ts +++ b/test/ollama-auth-proxy-handler-helpers.ts @@ -17,7 +17,7 @@ export const PROXY_SCRIPT = path.resolve( import.meta.dirname, "..", "scripts", - "ollama-auth-proxy.js", + "ollama-auth-proxy.mts", ); const proxyOwners = new WeakMap(); @@ -60,6 +60,11 @@ export function startBackend(): Promise<{ }); } +export function closeServer(server: http.Server | undefined): Promise { + if (!server) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); +} + /** Grab an ephemeral free TCP port, then release it for the proxy to bind. */ export function freePort(): Promise { return new Promise((resolve, reject) => { @@ -217,6 +222,7 @@ export function request( body += chunk; }); res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("error", reject); }, ); req.on("error", reject); diff --git a/test/ollama-auth-proxy-handler.test.ts b/test/ollama-auth-proxy-handler.test.ts index 2b1ba7e70db..f0cab6f34c6 100644 --- a/test/ollama-auth-proxy-handler.test.ts +++ b/test/ollama-auth-proxy-handler.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // // Mocked unit coverage for the Bearer-token enforcement and header-stripping -// contract of scripts/ollama-auth-proxy.js. The live E2E target +// contract of scripts/ollama-auth-proxy.mts. The live E2E target // (test/e2e/live/ollama-auth-proxy.test.ts) exercises the same boundary but // needs a real Ollama install plus a model pull; this pins the security- // critical request-handler behavior hermetically. @@ -23,7 +23,10 @@ import { afterEach, beforeEach, describe, expect, vi } from "vitest"; import { test as it } from "./helpers/owned-test-resources"; import { + closeServer, + forceKill, freePort, + PROXY_SCRIPT, request, startBackend, startProxy, @@ -47,7 +50,7 @@ describe("ollama-auth-proxy request handler", () => { afterEach(async () => { await terminate(proxy); proxy = undefined; - await new Promise((resolve) => backend?.server.close(() => resolve())); + await closeServer(backend?.server); backend = undefined; }); @@ -124,9 +127,75 @@ describe("ollama-auth-proxy request handler", () => { expect(res.body).toMatch(/Ollama backend error/); expect(proxy?.exitCode).toBeNull(); }); + + it("stays alive when the backend disconnects after a partial response", async ({ resources }) => { + await terminate(proxy); + proxy = undefined; + await closeServer(backend?.server); + backend = undefined; + + const disconnectingBackend = resources.ownServer( + http.createServer((req, res) => { + req.resume(); + res.writeHead(200, { "Content-Type": "text/plain" }); + res.write("partial", () => res.socket?.destroy()); + }), + ); + await new Promise((resolve, reject) => { + disconnectingBackend.once("error", reject); + disconnectingBackend.listen(0, "127.0.0.1", resolve); + }); + + proxyPort = await freePort(); + proxy = await startProxy( + proxyPort, + (disconnectingBackend.address() as AddressInfo).port, + TOKEN, + ); + + await expect( + request(proxyPort, { path: "/api/tags", auth: `Bearer ${TOKEN}` }), + ).rejects.toBeInstanceOf(Error); + + const alive = await request(proxyPort, { path: "/api/tags" }); + expect(alive.status).toBe(401); + expect(proxy.exitCode).toBeNull(); + }); }); describe("ollama-auth-proxy process ownership", () => { + it("reports EADDRINUSE and exits nonzero when the configured port is occupied", async ({ + onTestFinished, + resources, + }) => { + const portOwner = resources.ownServer(net.createServer()); + await new Promise((resolve, reject) => { + portOwner.once("error", reject); + portOwner.listen(0, "0.0.0.0", resolve); + }); + const occupiedPort = (portOwner.address() as AddressInfo).port; + const child = spawn(process.execPath, [PROXY_SCRIPT], { + env: { + ...process.env, + OLLAMA_PROXY_TOKEN: TOKEN, + OLLAMA_PROXY_PORT: String(occupiedPort), + OLLAMA_BACKEND_PORT: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + onTestFinished(() => forceKill(child)); + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + + const [exitCode, signal] = (await once(child, "close")) as [number | null, string | null]; + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + + expect(signal).toBeNull(); + expect(exitCode).not.toBe(0); + expect(stderr).toContain(`Ollama auth proxy: port ${occupiedPort} is already in use`); + expect(stderr).not.toContain(TOKEN); + }); + it("reaps the proxy before reporting a readiness failure", async ({ onTestFinished, resources, diff --git a/test/ollama-proxy-recovery.test.ts b/test/ollama-proxy-recovery.test.ts index da9621496fe..d864560c9d0 100644 --- a/test/ollama-proxy-recovery.test.ts +++ b/test/ollama-proxy-recovery.test.ts @@ -105,7 +105,7 @@ console.log(JSON.stringify({ assert.equal(payload.proxySpawns.length, 1); assert.equal(payload.pid, "4242"); assert.equal(payload.proxySpawns[0].cmd, process.execPath); - assert.ok(payload.proxySpawns[0].args[0].endsWith("scripts/ollama-auth-proxy.js")); + assert.ok(payload.proxySpawns[0].args[0].endsWith("scripts/ollama-auth-proxy.mts")); assert.equal(payload.proxySpawns[0].detached, true); assert.equal(payload.proxySpawns[0].stdio, "ignore"); assert.equal(payload.proxySpawns[0].env.OLLAMA_PROXY_TOKEN, "persisted-token"); @@ -378,7 +378,7 @@ console.log(JSON.stringify({ assert.equal(payload.pid, "5000"); assert.deepEqual(payload.runCommands[0], ["kill", "4242"]); assert.equal(payload.proxySpawns[0].cmd, process.execPath); - assert.ok(payload.proxySpawns[0].args[0].endsWith("scripts/ollama-auth-proxy.js")); + assert.ok(payload.proxySpawns[0].args[0].endsWith("scripts/ollama-auth-proxy.mts")); assert.equal(payload.proxySpawns[0].env.OLLAMA_PROXY_TOKEN, "persisted-token"); assert.equal(payload.proxySpawns[0].env.OLLAMA_PROXY_PORT, "11435"); assert.equal(payload.proxySpawns[0].env.OLLAMA_BACKEND_PORT, "11434");