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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/lib/adapters/http/container-curl-probe.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
});
});
68 changes: 68 additions & 0 deletions src/lib/adapters/http/container-curl-probe.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

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,
);
};
}
18 changes: 18 additions & 0 deletions src/lib/inference/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const LARGE_OLLAMA_FIT_MEMORY_MB = Math.max(
);

import {
buildOllamaProbeOptions,
CONTAINER_REACHABILITY_IMAGE,
DEFAULT_OLLAMA_MODEL,
getBootstrapOllamaModelOptions,
Expand All @@ -40,6 +41,8 @@ import {
probeOllamaAuthProxyHealth,
QWEN3_6_OLLAMA_MODEL,
resetOllamaContainerPortCache,
resetOllamaHostCache,
setResolvedOllamaHost,
validateLocalProvider,
validateOllamaModel,
} from "./local";
Expand Down Expand Up @@ -82,13 +85,28 @@ describe("local inference helpers", () => {
});

afterEach(() => {
resetOllamaHostCache();
if (originalSandboxHostUrl === undefined) {
delete process.env[LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV];
} else {
process.env[LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV] = originalSandboxHostUrl;
}
});

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");
});
Expand Down
9 changes: 7 additions & 2 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}

Expand Down
129 changes: 87 additions & 42 deletions src/lib/inference/onboard-host-docker-internal.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<string> => {
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 },
},
);

Expand All @@ -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" })],
});
});
});
3 changes: 3 additions & 0 deletions src/lib/inference/onboard-host-docker-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -40,6 +42,7 @@ function getHostDockerInternalProbeFailure() {
module.exports = {
HOST_DOCKER_INTERNAL,
OLLAMA_PROXY_URL,
createContainerCurlProbeSpawn,
isHijackedDockerInternalUrl,
getHostDockerInternalProbeFailure,
};
Loading
Loading