diff --git a/src/lib/adapters/http/container-curl-probe.test.ts b/src/lib/adapters/http/container-curl-probe.test.ts new file mode 100644 index 00000000000..d8b18cc9da9 --- /dev/null +++ b/src/lib/adapters/http/container-curl-probe.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + CONTAINER_REACHABILITY_IMAGE, + createContainerCurlProbeSpawn, +} from "./container-curl-probe"; + +function successfulSpawn(): SpawnSyncReturns { + return { + pid: 123, + output: ["200", ""], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; +} + +describe("container curl probe", () => { + it("mounts only the temporary output directory and preserves curl arguments", () => { + const spawn = vi.fn( + (_command: string, _args: readonly string[], _options: SpawnSyncOptionsWithStringEncoding) => + successfulSpawn(), + ); + const outputPath = path.join(os.tmpdir(), "nemoclaw-curl-probe-test", "response.json"); + const args = ["-sS", "-o", outputPath, "-w", "%{http_code}", "http://example.test/v1"]; + + createContainerCurlProbeSpawn(spawn)("curl", args, { encoding: "utf8" }); + + expect(spawn).toHaveBeenCalledWith( + "docker", + expect.arrayContaining([ + "run", + "--rm", + "--volume", + `${path.dirname(outputPath)}:${path.dirname(outputPath)}`, + CONTAINER_REACHABILITY_IMAGE, + ...args, + ]), + { encoding: "utf8" }, + ); + }); + + it("rejects credential configs and output paths outside the temporary directory", () => { + const spawn = vi.fn(() => successfulSpawn()); + const run = createContainerCurlProbeSpawn(spawn); + const outputPath = path.join(os.tmpdir(), "nemoclaw-curl-probe-test", "response.json"); + + expect(() => + run("curl", ["--config", path.join(os.tmpdir(), "auth.conf"), "-o", outputPath], { + encoding: "utf8", + }), + ).toThrow(/does not accept credential config files/); + expect(() => + run("curl", ["-o", path.join(process.cwd(), "response.json")], { encoding: "utf8" }), + ).toThrow(/must stay inside the temporary directory/); + expect(spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/adapters/http/container-curl-probe.ts b/src/lib/adapters/http/container-curl-probe.ts new file mode 100644 index 00000000000..f1487817812 --- /dev/null +++ b/src/lib/adapters/http/container-curl-probe.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + spawnSync, +} from "node:child_process"; +import os from "node:os"; +import path from "node:path"; + +export const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1"; + +type CurlProbeSpawn = ( + command: string, + args: readonly string[], + options: SpawnSyncOptionsWithStringEncoding, +) => SpawnSyncReturns; + +function curlOutputPath(args: readonly string[]): string { + const outputIndex = args.indexOf("-o"); + const outputPath = outputIndex >= 0 ? args[outputIndex + 1] : undefined; + if (!outputPath || !path.isAbsolute(outputPath)) { + throw new Error("container curl probe requires an absolute output path"); + } + const relative = path.relative(path.resolve(os.tmpdir()), path.resolve(outputPath)); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("container curl probe output must stay inside the temporary directory"); + } + return outputPath; +} + +/** Run a credential-free curl probe from Docker Desktop's network context. */ +export function createContainerCurlProbeSpawn( + spawnSyncImpl: CurlProbeSpawn = spawnSync, +): CurlProbeSpawn { + return (command, args, options) => { + if (command !== "curl") { + throw new Error(`container curl probe expected curl, received ${command}`); + } + if ( + args.some( + (arg) => + arg === "--config" || arg === "-K" || arg.startsWith("--config=") || arg.startsWith("-K"), + ) + ) { + throw new Error("container curl probe does not accept credential config files"); + } + const outputPath = curlOutputPath(args); + const outputDir = path.dirname(outputPath); + const uid = typeof process.getuid === "function" ? process.getuid() : 0; + const gid = typeof process.getgid === "function" ? process.getgid() : 0; + return spawnSyncImpl( + "docker", + [ + "run", + "--rm", + "--user", + `${uid}:${gid}`, + "--volume", + `${outputDir}:${outputDir}`, + CONTAINER_REACHABILITY_IMAGE, + ...args, + ], + options, + ); + }; +} diff --git a/src/lib/inference/local.test.ts b/src/lib/inference/local.test.ts index 80fd0210690..635d03ccf78 100644 --- a/src/lib/inference/local.test.ts +++ b/src/lib/inference/local.test.ts @@ -17,6 +17,7 @@ const LARGE_OLLAMA_FIT_MEMORY_MB = Math.max( ); import { + buildOllamaProbeOptions, CONTAINER_REACHABILITY_IMAGE, DEFAULT_OLLAMA_MODEL, getBootstrapOllamaModelOptions, @@ -40,6 +41,8 @@ import { probeOllamaAuthProxyHealth, QWEN3_6_OLLAMA_MODEL, resetOllamaContainerPortCache, + resetOllamaHostCache, + setResolvedOllamaHost, validateLocalProvider, validateOllamaModel, } from "./local"; @@ -82,6 +85,7 @@ describe("local inference helpers", () => { }); afterEach(() => { + resetOllamaHostCache(); if (originalSandboxHostUrl === undefined) { delete process.env[LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV]; } else { @@ -89,6 +93,20 @@ describe("local inference helpers", () => { } }); + it("uses Docker-context validation only for Windows-host Ollama (#8127)", () => { + expect(buildOllamaProbeOptions(false)).toMatchObject({ + allowHostDockerInternal: false, + probeFromDocker: null, + }); + + setResolvedOllamaHost("host.docker.internal"); + + expect(buildOllamaProbeOptions(false)).toMatchObject({ + allowHostDockerInternal: true, + probeFromDocker: { expectedPort: 11434 }, + }); + }); + it("returns the expected base URL for vllm-local", () => { expect(getLocalProviderBaseUrl("vllm-local")).toBe("http://host.openshell.internal:8000/v1"); }); diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index c9c29d970b4..b42e036b65f 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -11,6 +11,7 @@ import os from "node:os"; import nodePath from "node:path"; import { detectContainerRuntimeFromDockerInfo } from "../adapters/docker/runtime"; import { createBearerAuthConfig } from "../adapters/http/auth-config"; +import { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-probe"; import { buildValidatedCurlCommandArgs } from "../adapters/http/curl-args"; import type { CurlProbeOptions, CurlProbeResult } from "../adapters/http/probe"; import { runCurlProbe } from "../adapters/http/probe"; @@ -70,7 +71,8 @@ export function resetOllamaContainerPortCache(): void { export const HOST_GATEWAY_URL = "http://host.openshell.internal"; export const LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV = "NEMOCLAW_LOCAL_INFERENCE_SANDBOX_HOST_URL"; -export const CONTAINER_REACHABILITY_IMAGE = "curlimages/curl:8.10.1"; +export { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-probe"; + // These tags are convenience aliases for callers that want to refer to a // specific bootstrap model by role rather than by string. The canonical // metadata (memory requirements, download sizes) lives in @@ -1456,11 +1458,14 @@ export function buildOllamaProbeOptions(allowToolsIncompatible: boolean): { skipResponsesProbe: true; requireChatCompletionsToolCalling: boolean; allowHostDockerInternal: boolean; + probeFromDocker: { expectedPort: number } | null; } { + const windowsHostOllama = getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL; return { skipResponsesProbe: true, requireChatCompletionsToolCalling: !allowToolsIncompatible, - allowHostDockerInternal: getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, + allowHostDockerInternal: windowsHostOllama, + probeFromDocker: windowsHostOllama ? { expectedPort: OLLAMA_PORT } : null, }; } diff --git a/src/lib/inference/onboard-host-docker-internal.test.ts b/src/lib/inference/onboard-host-docker-internal.test.ts index 31c23a25fa4..ef7d2b75b69 100644 --- a/src/lib/inference/onboard-host-docker-internal.test.ts +++ b/src/lib/inference/onboard-host-docker-internal.test.ts @@ -1,13 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SpawnSyncReturns } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; const { isHijackedDockerInternalUrl } = require("./onboard-host-docker-internal"); -const { isSandboxInternalUrl, probeOpenAiLikeEndpoint } = require("./onboard-probes"); +const { + isSandboxInternalUrl, + probeOpenAiLikeEndpoint, + probeOpenAiLikeEndpointOptimized, +} = require("./onboard-probes"); describe("host.docker.internal onboarding inference policy", () => { it("does not treat host.docker.internal as a usable sandbox URL", () => { @@ -44,46 +47,53 @@ describe("host.docker.internal onboarding inference policy", () => { expect(result.message).toMatch(/host\.openshell\.internal:11435/); }); - it("allows explicit Windows-host Ollama validation to probe host.docker.internal", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-docker-probe-")); - const fakeBin = path.join(tmpDir, "bin"); - const seenUrl = path.join(tmpDir, "url"); - fs.mkdirSync(fakeBin, { recursive: true }); - fs.writeFileSync( - path.join(fakeBin, "curl"), - `#!/usr/bin/env bash -outfile="" -url="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) outfile="$2"; shift 2 ;; - -w) shift 2 ;; - *) url="$1"; shift ;; - esac -done -printf '%s' "$url" > "${seenUrl}" -if [ -n "$outfile" ]; then - cat <<'JSON' > "$outfile" -{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"sessions_send","arguments":"{\\"message\\":\\"hello\\"}"}}]}}]} -JSON -fi -printf '200' -exit 0 -`, - { mode: 0o755 }, - ); + it("validates Windows-host Ollama from Docker for strict and compatibility paths (#8127)", async () => { + const seenCommands: Array<{ command: string; args: readonly string[] }> = []; + const containerProbeSpawnSyncImpl = ( + command: string, + args: readonly string[], + ): SpawnSyncReturns => { + seenCommands.push({ command, args }); + const outputIndex = args.indexOf("-o"); + const outputPath = args[outputIndex + 1]; + fs.writeFileSync( + outputPath, + JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "sessions_send", arguments: '{"message":"hello"}' }, + }, + ], + }, + }, + ], + }), + ); + return { + pid: 123, + output: ["200", ""], + stdout: "200", + stderr: "", + status: 0, + signal: null, + }; + }; - const originalPath = process.env.PATH; - process.env.PATH = `${fakeBin}:${originalPath || ""}`; - try { - const result = probeOpenAiLikeEndpoint( + for (const requireChatCompletionsToolCalling of [true, false]) { + const result = await probeOpenAiLikeEndpointOptimized( "http://host.docker.internal:11434/v1", "openai/nemotron-mini", "", { skipResponsesProbe: true, - requireChatCompletionsToolCalling: true, + requireChatCompletionsToolCalling, allowHostDockerInternal: true, + probeFromDocker: { expectedPort: 11434, spawnSyncImpl: containerProbeSpawnSyncImpl }, }, ); @@ -92,12 +102,47 @@ exit 0 api: "openai-completions", label: "Chat Completions API", }); - expect(fs.readFileSync(seenUrl, "utf8")).toBe( - "http://host.docker.internal:11434/v1/chat/completions", - ); - } finally { - process.env.PATH = originalPath; - fs.rmSync(tmpDir, { recursive: true, force: true }); } + expect(seenCommands).toHaveLength(2); + for (const { command, args } of seenCommands) { + expect(command).toBe("docker"); + expect(args).toContain("curlimages/curl:8.10.1"); + expect(args).toContain("http://host.docker.internal:11434/v1/chat/completions"); + } + }); + + it.each([ + { + endpointUrl: "http://host.docker.internal:11434/v1", + apiKey: "not-a-real-secret", + extraHeaders: undefined, + }, + { + endpointUrl: "http://host.docker.internal:11434/v1?debug=1", + apiKey: "", + extraHeaders: undefined, + }, + { + endpointUrl: "http://host.docker.internal:11434/v1", + apiKey: "", + extraHeaders: ["Authorization: Bearer not-a-real-secret"], + }, + ])("refuses credentials and non-canonical Windows-host Ollama routes in Docker-context validation (#8127)", ({ + endpointUrl, + apiKey, + extraHeaders, + }) => { + const result = probeOpenAiLikeEndpoint(endpointUrl, "openai/nemotron-mini", apiKey, { + skipResponsesProbe: true, + requireChatCompletionsToolCalling: true, + allowHostDockerInternal: true, + probeFromDocker: { expectedPort: 11434 }, + extraHeaders, + }); + + expect(result).toMatchObject({ + ok: false, + failures: [expect.objectContaining({ name: "Docker-context validation boundary" })], + }); }); }); diff --git a/src/lib/inference/onboard-host-docker-internal.ts b/src/lib/inference/onboard-host-docker-internal.ts index d406d50c689..c3720bfe078 100644 --- a/src/lib/inference/onboard-host-docker-internal.ts +++ b/src/lib/inference/onboard-host-docker-internal.ts @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +const { createContainerCurlProbeSpawn } = require("../adapters/http/container-curl-probe"); + const HOST_DOCKER_INTERNAL = "host.docker.internal"; const OLLAMA_PROXY_URL = "http://host.openshell.internal:11435/v1"; @@ -40,6 +42,7 @@ function getHostDockerInternalProbeFailure() { module.exports = { HOST_DOCKER_INTERNAL, OLLAMA_PROXY_URL, + createContainerCurlProbeSpawn, isHijackedDockerInternalUrl, getHostDockerInternalProbeFailure, }; diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index 326793c78a2..28aa8d2c859 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -31,6 +31,7 @@ const authConfigModule = require("../adapters/http/auth-config"); const openrouter = require("./openrouter"); const trace = require("../trace"); const { + createContainerCurlProbeSpawn, getHostDockerInternalProbeFailure, isHijackedDockerInternalUrl, } = require("./onboard-host-docker-internal"); @@ -278,6 +279,7 @@ function calibrateOpenAiLikeValidationTiming(baseUrl, options = {}) { timeoutMs: getProbeProcessTimeoutMs(args), pinnedAddresses: options.pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, + spawnSyncImpl: options.spawnSyncImpl, }); const durationMs = Date.now() - startedAtMs; const calibration = @@ -468,6 +470,7 @@ function probeChatCompletionsToolCalling(endpointUrl, model, apiKey, options = { trustedConfigFiles: authConfig.trustedConfigFiles, pinnedAddresses: options.pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, + spawnSyncImpl: options.spawnSyncImpl, }); if (!result.ok) { @@ -571,6 +574,7 @@ function runChatCompletionsProbe({ pinnedAddresses, trustedPrivateCapability, validationTiming, + spawnSyncImpl, }) { const args = getChatCompletionsProbeCurlArgs({ credentialArgs, @@ -584,6 +588,7 @@ function runChatCompletionsProbe({ timeoutMs: getProbeProcessTimeoutMs(args), pinnedAddresses, trustedPrivateCapability, + spawnSyncImpl, }; if (trustedConfigFiles && trustedConfigFiles.length > 0) { probeOpts.trustedConfigFiles = trustedConfigFiles; @@ -628,6 +633,7 @@ function runDoubledTimeoutChatCompletionsRetry({ timingArgs: doubledArgs, pinnedAddresses: options.pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, + spawnSyncImpl: options.spawnSyncImpl, }) : (() => { const retryArgs = buildRetryArgs(); @@ -636,6 +642,7 @@ function runDoubledTimeoutChatCompletionsRetry({ trustedConfigFiles: authConfig.trustedConfigFiles, pinnedAddresses: options.pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, + spawnSyncImpl: options.spawnSyncImpl, }); })(); return runChatCompletionsRetryLoop(runRetryProbe); @@ -671,6 +678,52 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }; } + if (options.probeFromDocker) { + let isWindowsHostOllama = false; + try { + const parsed = new URL(String(endpointUrl)); + const expectedPort = Number(options.probeFromDocker.expectedPort); + isWindowsHostOllama = + parsed.protocol === "http:" && + parsed.hostname === "host.docker.internal" && + Number.isInteger(expectedPort) && + expectedPort > 0 && + parsed.port === String(expectedPort) && + parsed.pathname.replace(/\/+$/, "") === "/v1" && + parsed.username === "" && + parsed.password === "" && + parsed.search === "" && + parsed.hash === ""; + } catch { + /* Invalid URLs fail the restricted Docker-context boundary below. */ + } + if ( + options.allowHostDockerInternal !== true || + options.skipResponsesProbe !== true || + !isWindowsHostOllama || + String(apiKey || "") !== "" || + (Array.isArray(options.extraHeaders) && options.extraHeaders.length > 0) + ) { + return { + ok: false, + message: "Docker-context validation is restricted to credential-free Windows-host Ollama.", + failures: [ + { + name: "Docker-context validation boundary", + httpStatus: 0, + curlStatus: 0, + message: "probe request is outside the approved Windows-host Ollama route", + body: "", + }, + ], + }; + } + options = { + ...options, + spawnSyncImpl: createContainerCurlProbeSpawn(options.probeFromDocker.spawnSyncImpl), + }; + } + // SSRF source boundary: reject a private/internal endpoint before any curl. // The sandbox-internal alias is handled above, and host.docker.internal is // gated by the allowHostDockerInternal check at the top of this function — @@ -807,6 +860,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, validationTiming, + spawnSyncImpl: options.spawnSyncImpl, }) : runChatCompletionsProbe({ credentialArgs: authConfig.args, @@ -817,6 +871,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { pinnedAddresses, trustedPrivateCapability: options.trustedPrivateCapability, validationTiming, + spawnSyncImpl: options.spawnSyncImpl, }), }; @@ -1001,6 +1056,9 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { } async function probeOpenAiLikeEndpointOptimized(endpointUrl, model, apiKey, options = {}) { + if (options.probeFromDocker) { + return probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options); + } const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); const validationTiming = resolveOpenAiLikeValidationTiming(baseUrl, options); diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index 47c0b54d9e6..1f2a7d665c5 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -94,6 +94,7 @@ export interface InferenceSelectionValidationHelpers { skipResponsesProbe?: boolean; probeStreaming?: boolean; allowHostDockerInternal?: boolean; + probeFromDocker?: { expectedPort: number } | null; capabilityCache?: OnboardInferenceCapabilityCache; }, ): Promise; @@ -261,6 +262,7 @@ export function createInferenceSelectionValidationHelpers( skipResponsesProbe?: boolean; probeStreaming?: boolean; allowHostDockerInternal?: boolean; + probeFromDocker?: { expectedPort: number } | null; capabilityCache?: OnboardInferenceCapabilityCache; } = {}, ): Promise {