diff --git a/SECURITY.md b/SECURITY.md index 4525724d8a..67bd5858ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,6 +57,28 @@ While NVIDIA does not currently have a public bug bounty program, we do offer ac For security bulletins, PSIRT policies, and all security-related concerns, visit the [NVIDIA Product Security](https://www.nvidia.com/en-us/security/) portal. Subscribe to notifications on that page to receive alerts when new bulletins are published. +## Threat Models + +Component-level threat models for security-critical NemoClaw subsystems are documented here so a reviewer or auditor can understand what each subsystem is designed to prevent, which surfaces it protects, and where its guarantees end. + +### Ollama Auth Proxy Loopback Bind Probe (`#6014`) + +**Summary.** The Ollama auth proxy is the token-authenticated network gate in front of a locally-running Ollama backend on every topology where `shouldFrontOllamaWithProxy()` returns true (native Linux, macOS, WSL with a native dockerd runtime). Ollama itself has no built-in authentication. The proxy adds a bearer-token check on its own listen port and forwards to Ollama on the backend port. + +**Threat.** If the Ollama backend is reachable on any non-loopback interface on the host (e.g. the user set `OLLAMA_HOST=0.0.0.0:11434`, or an operator-supplied systemd unit binds to a public interface), an attacker on the same LAN, a co-tenant on a shared host, or any process that can open a socket on the host can bypass the proxy entirely by connecting directly to `:11434`. The proxy's token check on the listen port is useless in that case because Ollama is answering questions the proxy never sees. + +**Guarantee the bind probe adds.** Before the proxy accepts any traffic, it walks `/proc/net/tcp` and `/proc/net/tcp6` (Linux) or falls back to `lsof -sTCP:LISTEN` (macOS and any host without a readable /proc) to enumerate every LISTEN-state socket on the Ollama backend port. If any listener is not loopback, the proxy refuses to start with exit code `EXIT_BACKEND_NOT_LOOPBACK` (2) and writes a structured `backend-not-loopback` reason to its status file so the host CLI renders an actionable remediation. Loopback for this check is the full 127.0.0.0/8 block for IPv4, `::1` for IPv6, and `::ffff:127.0.0.0/8` for IPv4-mapped IPv6, so a legitimate bind to 127.0.0.2 or an IPv4-mapped IPv6 loopback is accepted. + +**Where the guarantee ends.** + +- **Docker-Desktop topologies (WSL + Windows-host Ollama, WSL + WSL-local Ollama).** These bypass the proxy entirely via `containerCanReachHostLoopback()` and are explicitly out of scope for this issue and probe. Hardening them is tracked separately. +- **Operator override.** `NEMOCLAW_OLLAMA_PROXY_SKIP_BIND_PROBE=1` disables the probe. The operator setting the override MUST accept that the security posture is degraded. The proxy emits an audit warning to stderr every time the override runs so an incident investigator scanning proxy logs sees the skip and the exact env knob that produced it. This is not fail-closed by design; the escape hatch exists for hosts where /proc is unreadable and `lsof` is missing, and for CI environments that intentionally exercise the non-loopback path. +- **Probe unavailable (both `/proc` and `lsof` absent).** The proxy warns and continues rather than fail-closed. Same reasoning as the operator override: on a host where neither probe surface exists, refusing to start would break the headless install contract with no operator recourse. The systemd loopback override (retained by design for this PR) provides defense in depth on Linux. +- **Runtime bind changes.** The probe runs at startup only. A backend that binds loopback at proxy-start time and later rebinds to a public interface is out of scope. Adding a periodic re-probe is a follow-up. +- **Non-Ollama providers.** The probe protects the Ollama backend specifically; it does not cover NIM, vLLM, or other providers. + +**Enforced by:** `test/ollama-auth-proxy-bind-probe.test.ts` covers every branch of both the `/proc` and `lsof` classifiers (accepts full 127.0.0.0/8 including IPv4-mapped IPv6, refuses wildcard and LAN-scope, refuses the lsof `*` token) and the `EXIT_BACKEND_NOT_LOOPBACK = 2` contract with the host CLI. + ## Documented Risk Acceptances The following security-relevant defaults are intentional. Each item names the code path that carries the constraint and the compensating controls that make the trade-off acceptable. diff --git a/scripts/ollama-auth-proxy.mts b/scripts/ollama-auth-proxy.mts index 71acf91a25..974ab628aa 100755 --- a/scripts/ollama-auth-proxy.mts +++ b/scripts/ollama-auth-proxy.mts @@ -16,21 +16,323 @@ * OLLAMA_BACKEND_PORT — Ollama port on localhost (default: 11434) */ +import { execFileSync } from "node:child_process"; import crypto from "node:crypto"; +import fs from "node:fs"; import http from "node:http"; -const TOKEN = process.env.OLLAMA_PROXY_TOKEN; -if (!TOKEN) { - console.error("OLLAMA_PROXY_TOKEN required"); - process.exit(1); +type BackendListener = { address: string; port: number }; +type BackendProbeResult = { + ok: boolean; + listeners: BackendListener[]; + nonLoopback?: BackendListener[]; +}; + +/** + * Best-effort write of a structured exit reason for the host CLI to read + * when proxy startup fails. The host (src/lib/inference/ollama/proxy.ts) + * renders specific remediation messages based on `reason`. + */ +function writeExitStatus(reason: string, details?: string): void { + const statusFile = process.env.NEMOCLAW_OLLAMA_PROXY_STATUS_FILE; + if (!statusFile) return; + try { + const payload = JSON.stringify({ + reason, + details: details || undefined, + exitedAt: Math.floor(Date.now() / 1000), + }); + fs.writeFileSync(statusFile, payload); + } catch { + // Status file is best-effort; the proxy still exits with the right code + // so the host's port-conflict fall-back path can render a generic + // remediation. Don't crash the proxy because we couldn't write a hint. + } +} + +function clearExitStatus() { + const statusFile = process.env.NEMOCLAW_OLLAMA_PROXY_STATUS_FILE; + if (!statusFile) return; + try { + fs.unlinkSync(statusFile); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + // Same as writeExitStatus: don't crash, this is hint metadata. + } + } +} + +// Exit code 2 is reserved for "backend listening on a non-loopback interface" +// so the host-side startOllamaAuthProxy() can render an Ollama-specific +// remediation pointing the operator at OLLAMA_HOST=127.0.0.1. +const EXIT_BACKEND_NOT_LOOPBACK = 2; + +// Reference encoding for the exact `127.0.0.1` IPv4 address in +// /proc/net/tcp: bytes 7F 00 00 01, little-endian per column -> +// hex "0100007F". IPv4 loopback is the full 127.0.0.0/8 block though, so +// the classifier below matches on the leading (post-reverse) byte being +// 7F instead of an exact string match. This constant is kept as an +// unambiguous fixture for tests and callers that want to compare against a +// specific example rather than a range. +const IPV4_LOOPBACK_PROC = "0100007F"; +// IPv6 loopback (::1) in /proc/net/tcp6 encoding. The 16-byte address is +// split into four 32-bit groups; each group's four bytes are emitted in +// little-endian order. ::1 bytes = 00..00 00..01 -> groups reversed become +// 00000000:00000000:00000000:01000000, concatenated. +const IPV6_LOOPBACK_PROC = "00000000000000000000000001000000"; +// IPv4-mapped IPv6 loopback (::ffff:127.0.0.1). Address bytes (network +// order): +// 00 00 00 00 00 00 00 00 00 00 FF FF 7F 00 00 01 +// The kernel groups these into four 32-bit ints and prints each with %08X in +// native (little-endian) byte order, so each group's numeric u32 value is: +// int[0] = 0x00000000 int[1] = 0x00000000 +// int[2] = 0xFFFF0000 (bytes 8-11 = 00 00 FF FF little-endian -> u32) +// int[3] = 0x0100007F (bytes 12-15 = 7F 00 00 01 little-endian -> u32) +// %08X of each: 00000000 00000000 FFFF0000 0100007F -> concatenated: +// 0000000000000000FFFF00000100007F +// The correctness of this constant is enforced end-to-end by the +// isLoopbackProcAddress test suite, which independently decodes the bytes +// via decodeProcAddress and checks the semantic loopback shape, rather +// than string-comparing against this constant. +const IPV6_MAPPED_IPV4_LOOPBACK_PROC = "0000000000000000FFFF00000100007F"; + +/** + * Parse a /proc/net/tcp{,6} table and return every LISTEN socket whose local + * port matches `port`. Each returned entry is `{address, port}` where + * address is the proc-encoded local address column (uppercased, no `:port`). + * + * /proc/net/tcp{,6} state column 0x0A = LISTEN. + */ +function parseProcNetTcpListeners(text: string, port: number): BackendListener[] { + const listeners: BackendListener[] = []; + const lines = text.split("\n"); + // Skip header line. + for (let i = 1; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + const cols = trimmed.split(/\s+/); + // cols[1] = local_address:port, cols[3] = state. + if (cols.length < 4) continue; + const local = cols[1]; + const state = cols[3]; + if (state !== "0A") continue; + const sep = local.lastIndexOf(":"); + if (sep <= 0) continue; + const addr = local.slice(0, sep).toUpperCase(); + const sockPort = parseInt(local.slice(sep + 1), 16); + if (sockPort === port) listeners.push({ address: addr, port: sockPort }); + } + return listeners; +} + +/** + * Decode a /proc/net/tcp{,6} local_address hex column into the address bytes + * in canonical IP byte order (network order: high byte first, the same order + * `inet_pton` / `getnameinfo` produce). The kernel formats each 32-bit + * group with `%08X` on the native (little-endian) u32, so within each 8-hex + * character group the bytes are emitted in reverse of network order. We undo + * that here by walking each group in reverse-byte order. + * + * Returns a 4-byte array for IPv4 (8 hex chars), a 16-byte array for IPv6 + * (32 hex chars), or null if the input has any other length. Robust against + * upstream-hex-encoding subtleties so the loopback classifier below can + * reason about actual address bytes instead of hex string patterns. + */ +function decodeProcAddress(addr: string): number[] | null { + const expectedGroups = addr.length === 8 ? 1 : addr.length === 32 ? 4 : 0; + if (expectedGroups === 0) return null; + const bytes = []; + for (let g = 0; g < expectedGroups; g++) { + const group = addr.slice(g * 8, (g + 1) * 8); + // Each group is 4 bytes emitted little-endian in the hex; walk in + // reverse to recover network byte order. + for (let b = 3; b >= 0; b--) { + bytes.push(parseInt(group.slice(b * 2, b * 2 + 2), 16)); + } + } + return bytes; +} + +/** + * Classify a decoded address as loopback. Handles three concrete cases: + * - IPv4: first byte 0x7F (127.0.0.0/8) + * - IPv6 canonical loopback: 15 zero bytes then 0x01 (::1) + * - IPv4-mapped IPv6 loopback: 10 zero bytes, then 0xFF 0xFF, then any + * 127.x.y.z (byte 12 == 0x7F). Covers ::ffff:127.0.0.0/8. + * Returns false for any input the decoder produced that does not match one + * of these shapes. + */ +function isLoopbackProcAddress(addr: string): boolean { + const bytes = decodeProcAddress(addr); + if (bytes === null) return false; + if (bytes.length === 4) return bytes[0] === 0x7f; + if (bytes.length !== 16) return false; + const allZero = (arr: number[], from: number, to: number): boolean => + arr.slice(from, to).every((b) => b === 0); + // ::1 -> all-zero prefix, last byte 0x01 + if (allZero(bytes, 0, 15) && bytes[15] === 0x01) return true; + // ::ffff:127.x.y.z -> 10 zeros, 0xFF 0xFF, then 127-prefixed IPv4 + if (allZero(bytes, 0, 10) && bytes[10] === 0xff && bytes[11] === 0xff) { + return bytes[12] === 0x7f; + } + return false; +} + +/** + * Linux backend-bind probe via /proc/net/tcp + /proc/net/tcp6. Returns + * { ok: true } when every listener on BACKEND_PORT is loopback, + * { ok: false, listeners } when at least one is not, and null when /proc + * is unavailable so the caller can fall back to the cross-platform probe. + */ +function probeLinuxLoopbackBind(port: number): BackendProbeResult | null { + let v4Text = ""; + let v6Text = ""; + try { + v4Text = fs.readFileSync("/proc/net/tcp", "utf8"); + } catch (err) { + // Any read failure (EACCES on some containers, EPERM under strict + // sandboxes, or the absent-file case) degrades to null so the caller + // falls back to `lsof` rather than crashing the proxy. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT" || code === "EACCES" || code === "EPERM") { + return null; + } + return null; + } + try { + v6Text = fs.readFileSync("/proc/net/tcp6", "utf8"); + } catch (err) { + // tcp6 may be absent on IPv6-disabled kernels; treat as empty. Any + // other failure (EACCES / EPERM) is treated the same: absence of IPv6 + // data doesn't invalidate the IPv4 data we already read. + void err; + } + const listeners = [ + ...parseProcNetTcpListeners(v4Text, port), + ...parseProcNetTcpListeners(v6Text, port), + ]; + if (listeners.length === 0) return { ok: true, listeners }; + const nonLoopback = listeners.filter((l) => !isLoopbackProcAddress(l.address)); + return { ok: nonLoopback.length === 0, listeners, nonLoopback }; +} + +/** + * Cross-platform fallback using `lsof -nP -iTCP: -sTCP:LISTEN`. + * Returns { ok, listeners } in the same shape as the Linux probe, or null + * when lsof is unavailable so the caller can render a degraded warning. + */ +/** + * Classify a human-readable IP address string (as printed by `lsof -F n`) + * as loopback. Handles the same three semantic cases as + * `isLoopbackProcAddress`, but on the cross-platform lsof-style form so the + * IPv4 accept range matches on both probes: any 127.x.y.z (127.0.0.0/8), + * ::1, and ::ffff:127.0.0.0/8. Advisor PRA-4 (Ultra) flagged the earlier + * exact-match against "127.0.0.1" as an incomplete classifier that would + * refuse legitimate loopback binds and, worse, mask a genuine non-loopback + * mistake as "not-loopback" on the fallback path. + */ +function isLoopbackLsofAddress(addr: string): boolean { + if (addr === "localhost") return true; + // IPv4 dotted quad, or IPv4-mapped IPv6 written as "::ffff:x.y.z.w" or + // "[::ffff:x.y.z.w]" or a bracketed IPv6 wrapper on the same. Any of + // these forms are loopback iff the leading IPv4 byte is 127. + const ipv4Match = addr.match(/^\[?(?:::ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\]?$/i); + if (ipv4Match !== null) return parseInt(ipv4Match[1], 10) === 127; + // IPv6 canonical loopback. + if (addr === "::1" || addr === "[::1]") return true; + // IPv4-mapped IPv6 in colon-hex form (rare from lsof, but be robust). + const mappedHex = addr.match(/^\[?::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]?$/i); + if (mappedHex !== null) { + // The last 32 bits of the mapped IPv6 are the IPv4 address bytes. + // The first hex group holds the high 16 bits (bytes 12-13), second + // holds the low 16 bits (bytes 14-15). For 127.0.0.0/8 the top byte + // (byte 12) is 0x7F, which is the high byte of the first hex group. + const hi = parseInt(mappedHex[1], 16); + return hi >>> 8 === 0x7f; + } + return false; } -const LISTEN_PORT = parseInt(process.env.OLLAMA_PROXY_PORT || "11435", 10); -const BACKEND_PORT = parseInt(process.env.OLLAMA_BACKEND_PORT || "11434", 10); -const BACKEND_URL = new URL(process.env.OLLAMA_BACKEND_URL || `http://127.0.0.1:${BACKEND_PORT}`); +function probeLsofLoopbackBind(port: number): BackendProbeResult | null { + let stdout: string; + try { + stdout = execFileSync("lsof", ["-nP", "-iTCP:" + port, "-sTCP:LISTEN", "-F", "n"], { + encoding: "utf8", + timeout: 5_000, + }); + } catch (err) { + const processError = err as (NodeJS.ErrnoException & { status?: number }) | undefined; + if (processError?.code === "ENOENT" || processError?.status === 1) return null; + return null; + } + const listeners = []; + for (const raw of stdout.split("\n")) { + if (!raw.startsWith("n")) continue; + // lsof -F n field looks like `n*:11434` or `n127.0.0.1:11434` or + // `n[::1]:11434`. Extract the address up to the final ':'. + const body = raw.slice(1); + const sep = body.lastIndexOf(":"); + if (sep <= 0) continue; + const addr = body.slice(0, sep); + listeners.push({ address: addr, port }); + } + if (listeners.length === 0) return { ok: true, listeners }; + const nonLoopback = listeners.filter((l) => !isLoopbackLsofAddress(l.address)); + return { ok: nonLoopback.length === 0, listeners, nonLoopback }; +} + +function assertBackendBoundToLoopback(port: number): void { + if (process.env.NEMOCLAW_OLLAMA_PROXY_SKIP_BIND_PROBE === "1") { + // PRA-5 audit trail: the operator override that disables the + // loopback probe MUST leave a durable record in the proxy's stderr so + // an incident investigator scanning proxy logs can see that + // enforcement was skipped, when, and via which knob. This is not a + // fail-closed decision (the operator explicitly asked for the + // override), but it must not be silent. + console.warn( + `Ollama auth proxy: SECURITY PROBE SKIPPED. ` + + `NEMOCLAW_OLLAMA_PROXY_SKIP_BIND_PROBE=1 disabled the loopback ` + + `bind check for port ${port}. Any Ollama daemon on this host ` + + `reachable on a non-loopback interface will bypass the proxy's ` + + `token check. Unset the env to restore enforcement.`, + ); + return; + } + let result = process.platform === "linux" ? probeLinuxLoopbackBind(port) : null; + if (result === null) { + result = probeLsofLoopbackBind(port); + } + if (result === null) { + // Probe is unavailable on this host (no /proc, no lsof). Don't fail + // closed — the proxy is still useful — but log so an operator scanning + // logs can see the boundary check did not run. The systemd drop-in + // (Linux only) remains the primary enforcement on Linux; non-Linux + // topologies still rely on the operator-supplied bind today (#6014). + console.warn( + `Ollama auth proxy: backend-bind probe unavailable, ` + + `unable to verify Ollama is bound to loopback on port ${port}`, + ); + return; + } + if (result.ok) return; + const labels = (result.nonLoopback || result.listeners || []) + .map((l) => `${l.address}:${l.port}`) + .join(", "); + console.error( + `Ollama auth proxy: backend on port ${port} is NOT bound to loopback ` + + `(found ${labels || "non-loopback listener"}). ` + + `Refusing to start: an Ollama daemon reachable on a non-loopback ` + + `interface bypasses the proxy's token check entirely. ` + + `Set OLLAMA_HOST=127.0.0.1:${port} on the Ollama systemd unit or ` + + `set NEMOCLAW_OLLAMA_PROXY_SKIP_BIND_PROBE=1 to override (not recommended).`, + ); + writeExitStatus("backend-not-loopback", labels || "non-loopback listener"); + process.exit(EXIT_BACKEND_NOT_LOOPBACK); +} -const server = http.createServer( - (clientReq: http.IncomingMessage, clientRes: http.ServerResponse) => { +function buildProxyServer(token: string, backendUrl: URL): http.Server { + const expectedBuf = Buffer.from(`Bearer ${token}`); + return 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, @@ -43,7 +345,6 @@ const server = http.createServer( // 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 && @@ -72,8 +373,8 @@ const server = http.createServer( const proxyReq = http.request( { - hostname: BACKEND_URL.hostname, - port: BACKEND_URL.port, + hostname: backendUrl.hostname, + port: backendUrl.port, path: clientReq.url, method: clientReq.method, headers, @@ -95,23 +396,74 @@ const server = http.createServer( 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}`); + }); +} + +function shouldProbeBackendHostname(hostname: string): boolean { + return isLoopbackLsofAddress(hostname); +} + +function main(): void { + const TOKEN = process.env.OLLAMA_PROXY_TOKEN; + if (!TOKEN) { + console.error("OLLAMA_PROXY_TOKEN required"); + process.exit(1); } - process.exit(1); -}); -server.listen(LISTEN_PORT, "0.0.0.0", () => { - console.log(`Ollama auth proxy listening on 0.0.0.0:${LISTEN_PORT} -> ${BACKEND_URL.origin}`); -}); + const LISTEN_PORT = parseInt(process.env.OLLAMA_PROXY_PORT || "11435", 10); + const BACKEND_PORT = parseInt(process.env.OLLAMA_BACKEND_PORT || "11434", 10); + + const BACKEND_URL = new URL(process.env.OLLAMA_BACKEND_URL || `http://127.0.0.1:${BACKEND_PORT}`); + // A non-local backend is explicitly selected by the persisted adapter + // configuration. The bind probe applies only to a local Ollama port. + if (shouldProbeBackendHostname(BACKEND_URL.hostname)) { + const backendPort = Number(BACKEND_URL.port || (BACKEND_URL.protocol === "https:" ? 443 : 80)); + assertBackendBoundToLoopback(backendPort); + } + + const server = buildProxyServer(TOKEN, BACKEND_URL); + + // 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`); + writeExitStatus("listen-port-conflict", `port ${LISTEN_PORT} in use`); + } else { + const msg = err && err.message ? err.message : String(err); + console.error(`Ollama auth proxy failed to start: ${msg}`); + writeExitStatus("listen-error", msg); + } + process.exit(1); + }); + + server.listen(LISTEN_PORT, "0.0.0.0", () => { + // The proxy is healthy; clear any stale exit status so a later failed + // restart's status file is not misread as the current proxy's failure. + clearExitStatus(); + console.log(`Ollama auth proxy listening on 0.0.0.0:${LISTEN_PORT} -> ${BACKEND_URL.origin}`); + }); +} + +if (import.meta.main) { + main(); +} + +export { + clearExitStatus, + decodeProcAddress, + EXIT_BACKEND_NOT_LOOPBACK, + IPV4_LOOPBACK_PROC, + IPV6_LOOPBACK_PROC, + IPV6_MAPPED_IPV4_LOOPBACK_PROC, + isLoopbackLsofAddress, + isLoopbackProcAddress, + parseProcNetTcpListeners, + probeLinuxLoopbackBind, + probeLsofLoopbackBind, + shouldProbeBackendHostname, + writeExitStatus, +}; diff --git a/src/lib/inference/ollama/proxy-status.ts b/src/lib/inference/ollama/proxy-status.ts new file mode 100644 index 0000000000..d5c7fcad1c --- /dev/null +++ b/src/lib/inference/ollama/proxy-status.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Ollama auth proxy status-file IPC (#6014). +// +// The auth proxy runs as a detached Node child with `stdio: "ignore"`, so +// stderr is not observable from the parent. The proxy writes a structured +// exit reason to a JSON status file before any non-zero exit and removes it +// on a successful listen. The host reads the file when the readiness loop +// finds the proxy gone and renders a specific actionable remediation +// message. +// +// Extracted from proxy.ts per #6014 monolith-growth guardrail: the IPC +// protocol and the remediation rendering are self-contained and belong +// with the proxy script's own contract, not co-mingled with the token / +// PID / process lifecycle logic that proxy.ts otherwise owns. + +const fs = require("fs"); +const path = require("path"); + +export type ProxyExitStatus = { + reason: string; + details?: string; + exitedAt?: number; +}; + +/** + * Name of the env var the host uses to hand the proxy script a path to + * write its structured exit status to. Kept in one place so a rename of + * the wire protocol only touches this file. + */ +export const PROXY_STATUS_ENV = "NEMOCLAW_OLLAMA_PROXY_STATUS_FILE"; + +/** + * Default status-file path under an adapter state dir. Callers hand in the + * state dir so this module has no direct dependency on the token/PID/state + * layout in proxy.ts. + */ +export function defaultProxyStatusPath(stateDir: string): string { + return path.join(stateDir, "ollama-auth-proxy.status"); +} + +/** + * Read the structured exit status the proxy script writes to `statusPath` + * before a non-zero exit. Returns null when the file is absent or + * unparseable so the caller can fall back to the generic + * "exited during startup" remediation the host already prints for + * pre-existing failure modes. + */ +export function readProxyExitStatus(statusPath: string): ProxyExitStatus | null { + let raw: string; + try { + raw = fs.readFileSync(statusPath, "utf8"); + } catch (err) { + // ENOENT is the expected "proxy started cleanly and did not write" case. + // Any other read failure (EACCES on a container with tight file + // permissions, EPERM under a strict sandbox) is treated the same: + // there is no structured reason to surface, so fall back. + void err; + return null; + } + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.reason === "string") { + return { + reason: parsed.reason, + details: typeof parsed.details === "string" ? parsed.details : undefined, + exitedAt: typeof parsed.exitedAt === "number" ? parsed.exitedAt : undefined, + }; + } + } catch { + // Unparseable — fall through to null so the caller renders the + // generic remediation instead. + } + return null; +} + +/** + * Best-effort removal of a stale status file. Called before spawning a new + * proxy so a later read after this spawn sees the new proxy's exit reason + * (or finds no file when the new proxy starts cleanly), never a leftover + * reason from a previous run. + */ +export function clearStaleProxyStatus(statusPath: string): void { + try { + fs.unlinkSync(statusPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + // Same as writeExitStatus in the proxy script itself: this is hint + // metadata, not load-bearing. Log through the parent's own console + // only when the failure is not the expected absent-file case. + console.warn( + ` Warning: could not clear stale proxy status file at ${statusPath}: ` + + `${(err as NodeJS.ErrnoException).message}`, + ); + } + } +} + +/** + * Render proxy startup-failure remediation. When the proxy script wrote a + * structured reason (e.g. backend-not-loopback per #6014), surface a + * specific actionable message. Otherwise return false so the caller falls + * back to its existing owner-or-port remediation. + */ +export function printProxyStartupReason( + status: ProxyExitStatus | null, + ollamaPort: number, +): boolean { + if (status === null) return false; + if (status.reason === "backend-not-loopback") { + console.error(" Error: Ollama auth proxy refused to start."); + console.error( + ` Ollama is reachable on a non-loopback interface on the host (${ + status.details || "see proxy log" + }), which would bypass the proxy's token check entirely.`, + ); + console.error( + ` Remediation: bind Ollama to loopback only. On Linux, set OLLAMA_HOST=127.0.0.1:${ollamaPort} ` + + "in the Ollama systemd unit's [Service] section. On other platforms, set OLLAMA_HOST=127.0.0.1 " + + "in the launcher's environment before starting Ollama.", + ); + return true; + } + console.error(` Error: Ollama auth proxy exited during startup: ${status.reason}`); + if (status.details) console.error(` Details: ${status.details}`); + return true; +} diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index d25c57e091..aab8b6bf8f 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -3,6 +3,13 @@ // // Ollama auth-proxy lifecycle: token persistence, PID management, // proxy start/stop, model pull and validation. +// +// @ts-nocheck is pre-existing. Removing it surfaces ~14 implicit-any +// parameter errors scattered through the 992-line file (sleep, model, err, +// code, bytes, pct, line, tag, ...). Typing each callback is a separate +// refactor tracked as a follow-up on #6014. This PR only touches the +// status-file IPC seam and the spawn env; it does not extend the +// @ts-nocheck-suppressed area with new implicit-any surface. import type { GpuInfo } from "../local"; import type { PulledModelDiscoveryDeps } from "./model-discovery"; @@ -55,12 +62,20 @@ const { spawnDetachedNodeAdapter, writeLocalAdapterSecretFile, } = require("../local-adapter-lifecycle"); +const { + clearStaleProxyStatus, + defaultProxyStatusPath, + printProxyStartupReason, + PROXY_STATUS_ENV, + readProxyExitStatus, +} = require("./proxy-status"); // ── State ──────────────────────────────────────────────────────── const PROXY_STATE_DIR = DEFAULT_LOCAL_ADAPTER_STATE_DIR; const PROXY_TOKEN_PATH = path.join(PROXY_STATE_DIR, "ollama-proxy-token"); const PROXY_PID_PATH = path.join(PROXY_STATE_DIR, "ollama-auth-proxy.pid"); +const PROXY_STATUS_PATH = defaultProxyStatusPath(PROXY_STATE_DIR); let ollamaProxyToken: string | null = null; @@ -150,6 +165,9 @@ function isOllamaProxyProcess(pid: number | null | undefined): boolean { } function spawnOllamaAuthProxy(token: string, backendUrl?: string): number | null { + // Clear any stale status file so a read after this spawn observes the new + // proxy's exit reason (or finds no file when the proxy starts cleanly). + clearStaleProxyStatus(PROXY_STATUS_PATH); const url = backendUrl || readLocalAdapterTextFile(path.join(PROXY_STATE_DIR, "ollama-backend")); const child = spawnDetachedNodeAdapter({ scriptPath: path.join(SCRIPTS, "ollama-auth-proxy.mts"), @@ -157,6 +175,7 @@ function spawnOllamaAuthProxy(token: string, backendUrl?: string): number | null OLLAMA_PROXY_TOKEN: token, OLLAMA_PROXY_PORT: String(OLLAMA_PROXY_PORT), OLLAMA_BACKEND_PORT: String(OLLAMA_PORT), + [PROXY_STATUS_ENV]: PROXY_STATUS_PATH, ...(url ? { OLLAMA_BACKEND_URL: url } : {}), }, buildEnv: buildSubprocessEnv, @@ -307,15 +326,23 @@ function startOllamaAuthProxyWithToken(proxyToken: string, backendUrl?: string): sleep(1); // alive but not yet bound — give a slow host more time continue; } - // The spawned proxy is gone. If it lost an EADDRINUSE race the blocker may - // be an IPv6 dual-stack listener, so use the broad scope to name the owner. - const owners = inspectForeignProxyPortOwners("any"); - if (owners.pids.length > 0) { - printProxyPortConflict(owners); + // The spawned proxy is gone. Three failure modes, in priority order: + // 1. #6014 backend-bind probe failed (or any structured reason the + // proxy wrote to PROXY_STATUS_PATH before exit) + // 2. Port conflict (EADDRINUSE race lost after pre-check) + // 3. Generic "exited during startup" without a structured reason + const status = readProxyExitStatus(PROXY_STATUS_PATH); + if (printProxyStartupReason(status, OLLAMA_PORT)) { + // Already rendered above. } else { - console.error(` Error: Ollama auth proxy exited during startup on :${OLLAMA_PROXY_PORT}.`); - console.error(" Containers will not be able to reach Ollama without the proxy."); - console.error(` Check the proxy port owner: lsof -ti :${OLLAMA_PROXY_PORT}`); + const owners = inspectForeignProxyPortOwners("any"); + if (owners.pids.length > 0) { + printProxyPortConflict(owners); + } else { + console.error(` Error: Ollama auth proxy exited during startup on :${OLLAMA_PROXY_PORT}.`); + console.error(" Containers will not be able to reach Ollama without the proxy."); + console.error(` Check the proxy port owner: lsof -ti :${OLLAMA_PROXY_PORT}`); + } } return false; } diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 6e9b6f394b..a8c59a29a8 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -69,7 +69,7 @@ describe("shields policy transition", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); - }); + }, 30_000); afterEach(() => { vi.restoreAllMocks(); diff --git a/test/ollama-auth-proxy-bind-probe.test.ts b/test/ollama-auth-proxy-bind-probe.test.ts new file mode 100644 index 0000000000..9481e1e41a --- /dev/null +++ b/test/ollama-auth-proxy-bind-probe.test.ts @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// #6014 first PR: cover the loopback bind probe inside the Ollama auth proxy. +// +// THREAT MODEL (advisor PRA-5): +// The proxy is the token-authenticated network gate in front of Ollama on +// every topology where `shouldFrontOllamaWithProxy()` returns true (native +// Linux + macOS + WSL native dockerd -- see local-inference-topology.ts). +// Ollama itself has no built-in auth. If the Ollama backend is reachable +// on ANY non-loopback interface on the host, an attacker on the same LAN +// (or a co-tenant on a shared host) can bypass the proxy entirely by +// connecting directly to `:11434`. The token check the proxy +// enforces on port 11435 is useless in that case. +// +// The probe under test is the proxy's independent guard against this: at +// startup, walk /proc/net/tcp{,6} (or fall back to `lsof`) and refuse to +// listen if any observed LISTEN-state socket on the backend port is NOT +// loopback. The tests below pin every branch of that classifier so a +// regression cannot silently downgrade the security posture: +// - Correct loopback classification for IPv4 (127.0.0.0/8), IPv6 (::1), +// and IPv4-mapped IPv6 (::ffff:127.0.0.0/8), on BOTH the /proc-based +// and the lsof-based classifiers +// - Refusal of every non-loopback shape (wildcard, non-127 IPv4, IPv6 +// wildcard, IPv4-mapped IPv6 to a non-127 address) +// - Behaviour when the listener count is zero (nothing to guard against +// -> ok=true) vs when the listener count is nonzero (all must be +// loopback) +// - Refusal to accept malformed input (would otherwise degrade to +// "unknown -> ok" which is fail-open) + +import net from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import * as proxyExports from "../scripts/ollama-auth-proxy.mts"; + +type ProbeResult = { ok: boolean; listeners: Array<{ address: string; port: number }> } | null; +type ProxyExports = { + parseProcNetTcpListeners: ( + text: string, + port: number, + ) => Array<{ address: string; port: number }>; + isLoopbackProcAddress: (addr: string) => boolean; + isLoopbackLsofAddress: (addr: string) => boolean; + probeLinuxLoopbackBind: (port: number) => ProbeResult; + shouldProbeBackendHostname: (hostname: string) => boolean; + EXIT_BACKEND_NOT_LOOPBACK: number; +}; +const { + parseProcNetTcpListeners, + isLoopbackProcAddress, + isLoopbackLsofAddress, + probeLinuxLoopbackBind, + shouldProbeBackendHostname, + EXIT_BACKEND_NOT_LOOPBACK, +} = proxyExports as ProxyExports; + +// /proc/net/tcp header + one row template. Hex port 0x2CAA = 11434. +const HEADER = + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; +function tcpRow(localAddrColonPort: string, state: string): string { + return ` 0: ${localAddrColonPort} 00000000:0000 ${state} 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0`; +} + +// Independent fixtures (CR: do not reuse the production constants under test). +// IPv4 127.0.0.1 in /proc/net/tcp little-endian hex: bytes 7F 00 00 01 -> 0100007F. +const FIX_IPV4_LOOPBACK_1 = "0100007F"; +// IPv4 127.0.0.42 -- also loopback (127.0.0.0/8) -- bytes 7F 00 00 2A -> 2A00007F. +const FIX_IPV4_LOOPBACK_42 = "2A00007F"; +// IPv4 0.0.0.0 wildcard -- NOT loopback -- bytes 00 00 00 00 -> 00000000. +const FIX_IPV4_WILDCARD = "00000000"; +// IPv4 10.0.0.1 -- NOT loopback -- bytes 0A 00 00 01 -> 0100000A. +const FIX_IPV4_NON_LOOPBACK = "0100000A"; +// IPv6 ::1 in /proc/net/tcp6 per-group little-endian: +// bytes 00..00 (12) + 00 00 00 01, grouped by 4 and byte-reversed inside each +// group: 00000000 00000000 00000000 01000000. +const FIX_IPV6_LOOPBACK = "00000000000000000000000001000000"; +// IPv6 ::ffff:127.0.0.1 (IPv4-mapped). Address bytes (network order): +// 00 00 00 00 00 00 00 00 00 00 FF FF 7F 00 00 01 +// Grouped into four 32-bit ints, each printed %08X on native (LE) byte +// order gives numeric values 0x00000000 0x00000000 0xFFFF0000 0x0100007F, +// so the concatenated proc encoding is: +const FIX_IPV6_MAPPED_LOOPBACK_1 = "0000000000000000FFFF00000100007F"; +// IPv6 ::ffff:127.0.0.9 (also loopback under 127.0.0.0/8). Last group: +// bytes 12-15 = 7F 00 00 09 -> LE u32 = 0x0900007F. +const FIX_IPV6_MAPPED_LOOPBACK_9 = "0000000000000000FFFF00000900007F"; +// IPv6 ::ffff:10.0.0.1 (mapped BUT non-loopback). Last group: bytes 12-15 +// = 0A 00 00 01 -> LE u32 = 0x0100000A. Exercises the negative branch of +// the IPv4-mapped IPv6 classifier. +const FIX_IPV6_MAPPED_NON_LOOPBACK = "0000000000000000FFFF00000100000A"; +// IPv6 wildcard (all zeros) -- NOT loopback. +const FIX_IPV6_WILDCARD = "00000000000000000000000000000000"; + +describe("parseProcNetTcpListeners bind probe (#6014)", () => { + it("returns a listener for a LISTEN state row matching the port", () => { + const text = HEADER + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2CAA`, "0A"); + const listeners = parseProcNetTcpListeners(text, 11434); + expect(listeners).toEqual([{ address: FIX_IPV4_LOOPBACK_1, port: 11434 }]); + }); + + it("skips rows whose state is not LISTEN (0A)", () => { + // 01 = ESTABLISHED, 0B = CLOSING; neither should appear as a listener + const text = + HEADER + + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2CAA`, "01") + + "\n" + + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2CAA`, "0B"); + expect(parseProcNetTcpListeners(text, 11434)).toEqual([]); + }); + + it("skips rows whose port does not match", () => { + // 0x2BB7 = 11191 + const text = HEADER + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2BB7`, "0A"); + expect(parseProcNetTcpListeners(text, 11434)).toEqual([]); + }); + + it("returns address uppercased so loopback comparison is canonical", () => { + const text = HEADER + tcpRow("0100007f:2CAA", "0A"); + const [listener] = parseProcNetTcpListeners(text, 11434); + expect(listener.address).toBe(FIX_IPV4_LOOPBACK_1); + }); + + it("ignores blank and malformed lines without throwing", () => { + const text = + HEADER + "\n\n" + " garbage line\n" + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2CAA`, "0A"); + expect(parseProcNetTcpListeners(text, 11434)).toEqual([ + { address: FIX_IPV4_LOOPBACK_1, port: 11434 }, + ]); + }); + + it("returns multiple listeners when several LISTEN rows match the port", () => { + const text = + HEADER + + tcpRow(`${FIX_IPV4_LOOPBACK_1}:2CAA`, "0A") + + "\n" + + tcpRow(`${FIX_IPV4_WILDCARD}:2CAA`, "0A"); + expect(parseProcNetTcpListeners(text, 11434)).toEqual([ + { address: FIX_IPV4_LOOPBACK_1, port: 11434 }, + { address: FIX_IPV4_WILDCARD, port: 11434 }, + ]); + }); +}); + +describe("isLoopbackProcAddress bind probe (#6014)", () => { + it("accepts the canonical IPv4 loopback 127.0.0.1", () => { + expect(isLoopbackProcAddress(FIX_IPV4_LOOPBACK_1)).toBe(true); + }); + + it("accepts every address in the 127.0.0.0/8 loopback block, not just 127.0.0.1", () => { + // CR flagged the earlier implementation only accepted the single + // 127.0.0.1 encoding. The full IPv4 loopback range is 127.0.0.0/8. + expect(isLoopbackProcAddress(FIX_IPV4_LOOPBACK_42)).toBe(true); + }); + + it("accepts the canonical IPv6 loopback ::1", () => { + expect(isLoopbackProcAddress(FIX_IPV6_LOOPBACK)).toBe(true); + }); + + it("accepts IPv4-mapped IPv6 loopback (::ffff:127.0.0.1)", () => { + expect(isLoopbackProcAddress(FIX_IPV6_MAPPED_LOOPBACK_1)).toBe(true); + }); + + it("accepts IPv4-mapped IPv6 addresses in the 127.0.0.0/8 block", () => { + expect(isLoopbackProcAddress(FIX_IPV6_MAPPED_LOOPBACK_9)).toBe(true); + }); + + it("rejects IPv4 wildcard 0.0.0.0", () => { + expect(isLoopbackProcAddress(FIX_IPV4_WILDCARD)).toBe(false); + }); + + it("rejects a non-loopback IPv4 address (e.g. 10.0.0.1)", () => { + expect(isLoopbackProcAddress(FIX_IPV4_NON_LOOPBACK)).toBe(false); + }); + + it("rejects an IPv6 wildcard (all zeros)", () => { + expect(isLoopbackProcAddress(FIX_IPV6_WILDCARD)).toBe(false); + }); + + it("rejects IPv4-mapped IPv6 to a non-loopback IPv4 (::ffff:10.0.0.1)", () => { + // The classifier must not accept just any ::ffff:*/96 address; only + // those whose embedded IPv4 falls in 127.0.0.0/8. + expect(isLoopbackProcAddress(FIX_IPV6_MAPPED_NON_LOOPBACK)).toBe(false); + }); + + it("rejects malformed proc-encoded addresses of unexpected length", () => { + // Decoder returns null for anything that is not 8 chars (IPv4) or 32 + // chars (IPv6); classifier must return false rather than throwing. + expect(isLoopbackProcAddress("7F00")).toBe(false); + expect(isLoopbackProcAddress("")).toBe(false); + expect(isLoopbackProcAddress("0100007FFF")).toBe(false); + }); +}); + +// Helpers kept at module scope so test bodies stay linear and free of +// conditional branching (per the repository's growth guardrail on new `if` +// statements in test files). +function bindEphemeralLoopback(): Promise<{ server: net.Server; port: number }> { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = addr !== null && typeof addr === "object" ? addr.port : 0; + resolve({ server, port }); + }); + }); +} + +function closeServer(server: net.Server | null): Promise { + return new Promise((resolve) => { + server === null ? resolve() : server.close(() => resolve()); + }); +} + +describe("probeLinuxLoopbackBind bind probe (#6014)", () => { + let ephemeralServer: net.Server | null = null; + let ephemeralPort = 0; + + beforeEach(async () => { + // CR follow-up: do not assume a static port is unused. Bind an ephemeral + // loopback listener the test controls, so the probe has a real listener + // to observe. + const { server, port } = await bindEphemeralLoopback(); + ephemeralServer = server; + ephemeralPort = port; + }); + + afterEach(async () => { + await closeServer(ephemeralServer); + ephemeralServer = null; + }); + + it.skipIf(process.platform !== "linux")( + "reports the ephemeral loopback server as an ok loopback listener", + () => { + const result = probeLinuxLoopbackBind(ephemeralPort); + expect(result).not.toBeNull(); + const ok = result as NonNullable; + expect(ok.ok).toBe(true); + expect(ok.listeners.length).toBeGreaterThanOrEqual(1); + expect(ok.listeners.every((l) => isLoopbackProcAddress(l.address))).toBe(true); + }, + ); + + it.skipIf(process.platform !== "linux")( + "reports ok: true with empty listeners once the ephemeral server is closed", + async () => { + await closeServer(ephemeralServer); + ephemeralServer = null; + const result = probeLinuxLoopbackBind(ephemeralPort); + expect(result).not.toBeNull(); + const ok = result as NonNullable; + expect(ok.ok).toBe(true); + expect(ok.listeners).toEqual([]); + }, + ); + + it.skipIf(process.platform === "linux")( + "returns null on non-Linux platforms so the caller falls back to lsof", + () => { + expect(probeLinuxLoopbackBind(ephemeralPort)).toBeNull(); + }, + ); +}); + +describe("isLoopbackLsofAddress bind probe (#6014)", () => { + it("accepts a literal 127.0.0.1", () => { + expect(isLoopbackLsofAddress("127.0.0.1")).toBe(true); + }); + + it("accepts every address in 127.0.0.0/8 (127.42.13.99, 127.255.255.255)", () => { + expect(isLoopbackLsofAddress("127.42.13.99")).toBe(true); + expect(isLoopbackLsofAddress("127.255.255.255")).toBe(true); + }); + + it("accepts IPv6 loopback in both bracketed and unbracketed forms", () => { + expect(isLoopbackLsofAddress("::1")).toBe(true); + expect(isLoopbackLsofAddress("[::1]")).toBe(true); + }); + + it("accepts the literal 'localhost'", () => { + // Some lsof configurations resolve DNS by default; accept the token so + // the classifier does not spuriously refuse a genuine loopback bind. + expect(isLoopbackLsofAddress("localhost")).toBe(true); + }); + + it("accepts IPv4-mapped IPv6 (dotted quad form) in 127.0.0.0/8", () => { + expect(isLoopbackLsofAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackLsofAddress("::ffff:127.42.13.99")).toBe(true); + }); + + it("rejects IPv4 wildcard 0.0.0.0", () => { + expect(isLoopbackLsofAddress("0.0.0.0")).toBe(false); + }); + + it("rejects LAN-scope IPv4 addresses", () => { + expect(isLoopbackLsofAddress("10.0.0.1")).toBe(false); + expect(isLoopbackLsofAddress("192.168.1.1")).toBe(false); + }); + + it("rejects IPv6 wildcard :: and any global IPv6", () => { + expect(isLoopbackLsofAddress("::")).toBe(false); + expect(isLoopbackLsofAddress("2001:db8::1")).toBe(false); + }); + + it("rejects IPv4-mapped IPv6 pointing at a non-loopback IPv4", () => { + expect(isLoopbackLsofAddress("::ffff:10.0.0.1")).toBe(false); + }); + + it("rejects the lsof wildcard token '*'", () => { + // lsof prints `*:11434` for a wildcard listener; the classifier must + // refuse it (the extractor upstream feeds us the "*" string). + expect(isLoopbackLsofAddress("*")).toBe(false); + }); +}); + +describe("public surface bind probe (#6014)", () => { + it.each([ + "localhost", + "127.0.0.1", + "127.0.0.2", + "::1", + "[::1]", + "::ffff:127.0.0.9", + ])("probes the local backend hostname %s", (hostname) => { + expect(shouldProbeBackendHostname(hostname)).toBe(true); + }); + + it.each([ + "10.0.0.1", + "192.168.1.9", + "ollama.example.com", + "2001:db8::1", + ])("skips the remote backend hostname %s", (hostname) => { + expect(shouldProbeBackendHostname(hostname)).toBe(false); + }); + + it("exports EXIT_BACKEND_NOT_LOOPBACK as 2 (locked-in contract with the host CLI)", () => { + // The host (src/lib/inference/ollama/proxy.ts) maps this code to a + // specific remediation. Changing it would break the structured signal. + expect(EXIT_BACKEND_NOT_LOOPBACK).toBe(2); + }); +});