From c46b06f59db5f00e10d28738fa9787e00701ceac Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 20 May 2026 01:39:58 +0800 Subject: [PATCH 1/4] fix(onboard): reject host.docker.internal inference URLs --- docs/reference/troubleshooting.md | 32 ++++++++++++++++++ src/lib/onboard-inference-probes.test.ts | 23 ++++++++++--- src/lib/onboard-inference-probes.ts | 41 +++++++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 65a150609d0..87d5a5baf64 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -879,6 +879,38 @@ permissions. If the file is missing or unreadable after a host reboot, re-running `nemoclaw onboard` regenerates it. +### `host.docker.internal` does not reliably reach the host from the sandbox + +Configuring an inference provider with a base URL like +`http://host.docker.internal:11434/v1` does not reliably reach a host Ollama +service from inside the OpenShell sandbox. +OpenShell runs sandboxes inside a k3s network, where `host.docker.internal` is +not a portable host-service route. Depending on the platform, it may fail DNS +resolution or resolve to an internal gateway/bridge address where the host's +port `11434` is not forwarded. The sandbox then sees a DNS failure or +`connection refused`: + +```console +$ getent hosts host.docker.internal +172.17.0.1 host.docker.internal host.openshell.internal +$ no_proxy=host.docker.internal curl -v http://host.docker.internal:11434/api/tags +* connect to 172.17.0.1 port 11434 failed: Connection refused +``` + +For local Ollama, use the auth-proxy URL that NemoClaw's "Local Ollama" onboard +option configures automatically: + +```text +http://host.openshell.internal:11435/v1 +``` + +`host.openshell.internal` resolves to the same gateway IP, but port `11435` +is bound by the [token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) +that forwards requests to `127.0.0.1:11434` on the host. +If you need a different host service exposed to the sandbox, route it through +the OpenShell gateway rather than relying on `host.docker.internal`. +See issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136). + ### Local inference health check resolves to IPv6 Local inference health checks now use `127.0.0.1` instead of `localhost`. diff --git a/src/lib/onboard-inference-probes.test.ts b/src/lib/onboard-inference-probes.test.ts index 80db0763e9b..0f687e7385b 100644 --- a/src/lib/onboard-inference-probes.test.ts +++ b/src/lib/onboard-inference-probes.test.ts @@ -11,6 +11,7 @@ const { getChatCompletionsProbePayload, getDeepSeekV4ProValidationProbeCurlArgs, getKimiK26ValidationProbeCurlArgs, + isHijackedDockerInternalUrl, isSandboxInternalUrl, probeOpenAiLikeEndpoint, RETRIABLE_HTTP_PROBE_STATUSES, @@ -95,9 +96,8 @@ describe("OpenAI-compatible inference probes", () => { }); describe("sandbox-internal URL handling", () => { - it("identifies host.openshell.internal and host.docker.internal as sandbox-internal", () => { + it("identifies host.openshell.internal as sandbox-internal", () => { expect(isSandboxInternalUrl("http://host.openshell.internal:8001/v1")).toBe(true); - expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); }); it("does not treat normal hostnames as sandbox-internal", () => { @@ -106,6 +106,15 @@ describe("OpenAI-compatible inference probes", () => { expect(isSandboxInternalUrl("http://127.0.0.1:8001/v1")).toBe(false); }); + // Issue #3136: host.docker.internal is not a stable host route from + // OpenShell k3s pods, so it must not be treated as a usable sandbox URL. + it("does not treat host.docker.internal as a usable sandbox URL", () => { + expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); + expect(isHijackedDockerInternalUrl("http://host.openshell.internal:11435/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("https://api.openai.com/v1")).toBe(false); + }); + it("skips the curl probe for sandbox-internal URLs and returns ok with a note", () => { const result = probeOpenAiLikeEndpoint( "http://host.openshell.internal:8001/v1", @@ -120,14 +129,18 @@ describe("OpenAI-compatible inference probes", () => { expect(result.note).toMatch(/only resolves inside the sandbox/); }); - it("skips the curl probe for host.docker.internal and returns ok with a note", () => { + it("rejects host.docker.internal URLs with an actionable proxy hint (#3136)", () => { const result = probeOpenAiLikeEndpoint( "http://host.docker.internal:11434/v1", "openai/nemotron-mini", "", ); - expect(result).toMatchObject({ ok: true, api: null }); - expect(result.note).toMatch(/host\.docker\.internal/); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.docker\.internal/); + expect(result.message).toMatch(/host\.openshell\.internal:11435/); + expect(result.failures).toEqual([ + expect.objectContaining({ name: "host.docker.internal reachability" }), + ]); }); }); diff --git a/src/lib/onboard-inference-probes.ts b/src/lib/onboard-inference-probes.ts index e1b00ffa4db..d94b7e023c3 100644 --- a/src/lib/onboard-inference-probes.ts +++ b/src/lib/onboard-inference-probes.ts @@ -26,7 +26,13 @@ const { // Hostnames that only resolve from inside the OpenShell sandbox network. // Probing them from the host always fails with curl exit 6 ("Could not // resolve host"), so we skip host-side validation for these URLs. See #893. -const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal", "host.docker.internal"]; +const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal"]; + +// host.docker.internal is not a stable host-service route from OpenShell k3s +// sandboxes. Depending on the platform it may fail DNS resolution or resolve to +// a gateway/bridge address where the user's host service is not forwarded. See +// issue #3136. Always steer users to a NemoClaw-managed proxy URL instead. +const HOST_DOCKER_INTERNAL = "host.docker.internal"; function isSandboxInternalUrl(url) { try { @@ -37,6 +43,14 @@ function isSandboxInternalUrl(url) { } } +function isHijackedDockerInternalUrl(url) { + try { + return new URL(String(url)).hostname === HOST_DOCKER_INTERNAL; + } catch { + return false; + } +} + function parseJsonObject(body) { if (!body) return null; try { @@ -299,6 +313,30 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }; } + if (isHijackedDockerInternalUrl(endpointUrl)) { + return { + ok: false, + message: + `${HOST_DOCKER_INTERNAL} does not reach the host machine from inside the sandbox: ` + + `OpenShell k3s sandboxes do not provide it as a reliable host-service route. ` + + `It may fail DNS resolution or resolve to a gateway/bridge address where port ` + + `11434 is not forwarded. For local Ollama, use the auth-proxy URL ` + + `http://host.openshell.internal:11435/v1 (the URL NemoClaw onboard configures ` + + `automatically when you pick "Local Ollama"). See issue #3136.`, + failures: [ + { + name: "host.docker.internal reachability", + httpStatus: 0, + curlStatus: 0, + message: + `${HOST_DOCKER_INTERNAL} is not a reliable host-service route from ` + + `OpenShell k3s sandboxes and cannot be used as an inference base URL.`, + body: "", + }, + ], + }; + } + const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); @@ -555,6 +593,7 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { module.exports = { isSandboxInternalUrl, + isHijackedDockerInternalUrl, parseJsonObject, hasResponsesToolCall, shouldRequireResponsesToolCalling, From dc10f4886ba91e989b90b27fb79f7c1432f9ead0 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 20 May 2026 01:39:58 +0800 Subject: [PATCH 2/4] fix(onboard): reject host.docker.internal inference URLs --- docs/reference/troubleshooting.mdx | 32 +++++++++++ src/lib/inference/onboard-probes.test.ts | 44 ++++++++++++--- src/lib/inference/onboard-probes.ts | 69 ++++++++++++++++++------ src/lib/onboard.ts | 3 ++ 4 files changed, 125 insertions(+), 23 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 20d82351cbc..49ee85704e3 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1001,6 +1001,38 @@ $ nemoclaw onboard Docker Desktop, WSL, and hosts without the OpenShell Docker network use different routing models. In those cases NemoClaw treats an unavailable sandbox-side probe as non-blocking and relies on the regular proxy health check. +### `host.docker.internal` does not reliably reach the host from the sandbox + +Configuring an inference provider with a base URL like +`http://host.docker.internal:11434/v1` does not reliably reach a host Ollama +service from inside the OpenShell sandbox. +OpenShell runs sandboxes inside a k3s network, where `host.docker.internal` is +not a portable host-service route. Depending on the platform, it may fail DNS +resolution or resolve to an internal gateway/bridge address where the host's +port `11434` is not forwarded. The sandbox then sees a DNS failure or +`connection refused`: + +```console +$ getent hosts host.docker.internal +172.17.0.1 host.docker.internal host.openshell.internal +$ no_proxy=host.docker.internal curl -v http://host.docker.internal:11434/api/tags +* connect to 172.17.0.1 port 11434 failed: Connection refused +``` + +For local Ollama, use the auth-proxy URL that NemoClaw's "Local Ollama" onboard +option configures automatically: + +```text +http://host.openshell.internal:11435/v1 +``` + +`host.openshell.internal` resolves to the same gateway IP, but port `11435` +is bound by the [token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) +that forwards requests to `127.0.0.1:11434` on the host. +If you need a different host service exposed to the sandbox, route it through +the OpenShell gateway rather than relying on `host.docker.internal`. +See issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136). + ### Local inference health check resolves to IPv6 Local inference health checks now use `127.0.0.1` instead of `localhost`. diff --git a/src/lib/inference/onboard-probes.test.ts b/src/lib/inference/onboard-probes.test.ts index ca624ca4256..2c6db064bdf 100644 --- a/src/lib/inference/onboard-probes.test.ts +++ b/src/lib/inference/onboard-probes.test.ts @@ -14,6 +14,7 @@ const { hasChatCompletionsToolCall, hasChatCompletionsToolCallLeak, hasResponsesToolCall, + isHijackedDockerInternalUrl, isSandboxInternalUrl, probeOpenAiLikeEndpoint, RETRIABLE_HTTP_PROBE_STATUSES, @@ -349,9 +350,8 @@ describe("OpenAI-compatible inference probes", () => { }); describe("sandbox-internal URL handling", () => { - it("identifies host.openshell.internal and host.docker.internal as sandbox-internal", () => { + it("identifies host.openshell.internal as sandbox-internal", () => { expect(isSandboxInternalUrl("http://host.openshell.internal:8001/v1")).toBe(true); - expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); }); it("does not treat normal hostnames as sandbox-internal", () => { @@ -360,6 +360,15 @@ describe("OpenAI-compatible inference probes", () => { expect(isSandboxInternalUrl("http://127.0.0.1:8001/v1")).toBe(false); }); + // Issue #3136: host.docker.internal is not a stable host route from + // OpenShell k3s pods, so it must not be treated as a usable sandbox URL. + it("does not treat host.docker.internal as a usable sandbox URL", () => { + expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); + expect(isHijackedDockerInternalUrl("http://host.openshell.internal:11435/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("https://api.openai.com/v1")).toBe(false); + }); + it("skips the curl probe for sandbox-internal URLs and returns ok with a note", () => { const result = probeOpenAiLikeEndpoint( "http://host.openshell.internal:8001/v1", @@ -374,17 +383,34 @@ describe("OpenAI-compatible inference probes", () => { expect(result.note).toMatch(/only resolves inside the sandbox/); }); - it("skips the curl probe for host.docker.internal and returns ok with a note", () => { + it("rejects host.docker.internal URLs with an actionable proxy hint (#3136)", () => { const result = probeOpenAiLikeEndpoint( "http://host.docker.internal:11434/v1", "openai/nemotron-mini", "", ); - expect(result).toMatchObject({ ok: true, api: null }); - expect(result.note).toMatch(/host\.docker\.internal/); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.docker\.internal/); + expect(result.message).toMatch(/host\.openshell\.internal:11435/); + expect(result.failures).toEqual([ + expect.objectContaining({ name: "host.docker.internal reachability" }), + ]); }); - it("probes host.docker.internal when strict chat-completions tool calling is required", () => { + it("rejects host.docker.internal even when strict chat-completions tool calling is required", () => { + const result = probeOpenAiLikeEndpoint( + "http://host.docker.internal:11434/v1", + "openai/nemotron-mini", + "", + { skipResponsesProbe: true, requireChatCompletionsToolCalling: true }, + ); + + expect(result).toMatchObject({ ok: false }); + expect(result.message).toMatch(/host\.docker\.internal/); + 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"); @@ -420,7 +446,11 @@ exit 0 "http://host.docker.internal:11434/v1", "openai/nemotron-mini", "", - { skipResponsesProbe: true, requireChatCompletionsToolCalling: true }, + { + skipResponsesProbe: true, + requireChatCompletionsToolCalling: true, + allowHostDockerInternal: true, + }, ); expect(result).toMatchObject({ diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index 641cd31a66f..1103d04aca7 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -28,7 +28,13 @@ const { // so host-side validation cannot prove reachability for that URL. For ordinary // verification we still skip these endpoints, but strict tool-call validation // must fail closed unless the host is probeable from the onboard process. -const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal", "host.docker.internal"]; +const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal"]; + +// host.docker.internal is not a stable host-service route from OpenShell k3s +// sandboxes. Depending on the platform it may fail DNS resolution or resolve to +// a gateway/bridge address where the user's host service is not forwarded. See +// issue #3136. Always steer users to a NemoClaw-managed proxy URL instead. +const HOST_DOCKER_INTERNAL = "host.docker.internal"; function isSandboxInternalUrl(url) { try { @@ -39,6 +45,14 @@ function isSandboxInternalUrl(url) { } } +function isHijackedDockerInternalUrl(url) { + try { + return new URL(String(url)).hostname === HOST_DOCKER_INTERNAL; + } catch { + return false; + } +} + function parseJsonObject(body) { if (!body) return null; try { @@ -484,6 +498,30 @@ function runChatCompletionsProbe({ authHeader, model, url, isWsl: isWslOverride } function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { + if (isHijackedDockerInternalUrl(endpointUrl) && options.allowHostDockerInternal !== true) { + return { + ok: false, + message: + `${HOST_DOCKER_INTERNAL} does not reach the host machine from inside the sandbox: ` + + `OpenShell k3s sandboxes do not provide it as a reliable host-service route. ` + + `It may fail DNS resolution or resolve to a gateway/bridge address where port ` + + `11434 is not forwarded. For local Ollama, use the auth-proxy URL ` + + `http://host.openshell.internal:11435/v1 (the URL NemoClaw onboard configures ` + + `automatically when you pick "Local Ollama"). See issue #3136.`, + failures: [ + { + name: "host.docker.internal reachability", + httpStatus: 0, + curlStatus: 0, + message: + `${HOST_DOCKER_INTERNAL} is not a reliable host-service route from ` + + `OpenShell k3s sandboxes and cannot be used as an inference base URL.`, + body: "", + }, + ], + }; + } + if (isSandboxInternalUrl(endpointUrl)) { const { hostname } = new URL(String(endpointUrl)); if (options.requireChatCompletionsToolCalling !== true) { @@ -494,21 +532,19 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { note: `${hostname} only resolves inside the sandbox — validation skipped. If the endpoint is unreachable at runtime, re-run onboard with a routable URL.`, }; } - if (hostname !== "host.docker.internal") { - return { - ok: false, - message: `${hostname} only resolves inside the sandbox and cannot be validated for required structured Chat Completions tool calls from the host. Use a routable endpoint URL and retry onboard.`, - failures: [ - { - name: "Chat Completions API with tool calling", - httpStatus: 0, - curlStatus: 0, - message: "sandbox-internal endpoint cannot be strictly validated from host", - body: "", - }, - ], - }; - } + return { + ok: false, + message: `${hostname} only resolves inside the sandbox and cannot be validated for required structured Chat Completions tool calls from the host. Use a routable endpoint URL and retry onboard.`, + failures: [ + { + name: "Chat Completions API with tool calling", + httpStatus: 0, + curlStatus: 0, + message: "sandbox-internal endpoint cannot be strictly validated from host", + body: "", + }, + ], + }; } const useQueryParam = options.authMode === "query-param"; @@ -787,6 +823,7 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { module.exports = { isSandboxInternalUrl, + isHijackedDockerInternalUrl, parseJsonObject, hasResponsesToolCall, hasChatCompletionsToolCall, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bc231df3a59..c9a5edc3a97 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -160,6 +160,7 @@ const { const localInference: typeof import("./inference/local") = require("./inference/local"); const { findReachableOllamaHost, + getResolvedOllamaHost, resetOllamaHostCache, getDefaultOllamaModel, getLocalProviderBaseUrl, @@ -2192,6 +2193,7 @@ async function validateOpenAiLikeSelection( requireChatCompletionsToolCalling?: boolean; skipResponsesProbe?: boolean; probeStreaming?: boolean; + allowHostDockerInternal?: boolean; } = {}, ): Promise { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; @@ -6071,6 +6073,7 @@ async function selectAndValidateOllamaModel( { skipResponsesProbe: true, requireChatCompletionsToolCalling: true, + allowHostDockerInternal: getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, }, ); if (validation.retry === "selection") return { outcome: "back-to-selection" }; From 6ce93d64d77898860bca7cc9860f47bbc737c303 Mon Sep 17 00:00:00 2001 From: Chengjie Wang Date: Thu, 21 May 2026 12:43:17 +0800 Subject: [PATCH 3/4] fix(onboard): extract host.docker.internal probe policy Signed-off-by: Chengjie Wang --- docs/reference/troubleshooting.mdx | 6 +- .../onboard-host-docker-internal.test.ts | 105 ++++++++++++++++++ .../inference/onboard-host-docker-internal.ts | 44 ++++++++ src/lib/inference/onboard-probes.test.ts | 94 ---------------- src/lib/inference/onboard-probes.ts | 40 +------ src/lib/onboard.ts | 9 +- 6 files changed, 160 insertions(+), 138 deletions(-) create mode 100644 src/lib/inference/onboard-host-docker-internal.test.ts create mode 100644 src/lib/inference/onboard-host-docker-internal.ts diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index c5ecccfb5f9..59dc2eb944a 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1073,9 +1073,9 @@ option configures automatically: http://host.openshell.internal:11435/v1 ``` -`host.openshell.internal` resolves to the same gateway IP, but port `11435` -is bound by the [token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) -that forwards requests to `127.0.0.1:11434` on the host. +`host.openshell.internal` resolves to the same gateway IP, and the +[token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) binds port +`11435` there and forwards requests to `127.0.0.1:11434` on the host. If you need a different host service exposed to the sandbox, route it through the OpenShell gateway rather than relying on `host.docker.internal`. See issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136). diff --git a/src/lib/inference/onboard-host-docker-internal.test.ts b/src/lib/inference/onboard-host-docker-internal.test.ts new file mode 100644 index 00000000000..7c157491279 --- /dev/null +++ b/src/lib/inference/onboard-host-docker-internal.test.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { + isHijackedDockerInternalUrl, +} = require("../../../dist/lib/inference/onboard-host-docker-internal"); +const { isSandboxInternalUrl, probeOpenAiLikeEndpoint } = require("../../../dist/lib/inference/onboard-probes"); + +describe("host.docker.internal onboarding inference policy", () => { + it("does not treat host.docker.internal as a usable sandbox URL", () => { + expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); + expect(isHijackedDockerInternalUrl("http://host.openshell.internal:11435/v1")).toBe(false); + expect(isHijackedDockerInternalUrl("https://api.openai.com/v1")).toBe(false); + }); + + it("rejects host.docker.internal URLs with an actionable proxy hint (#3136)", () => { + const result = probeOpenAiLikeEndpoint( + "http://host.docker.internal:11434/v1", + "openai/nemotron-mini", + "", + ); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.docker\.internal/); + expect(result.message).toMatch(/host\.openshell\.internal:11435/); + expect(result.failures).toEqual([ + expect.objectContaining({ name: "host.docker.internal reachability" }), + ]); + }); + + it("rejects host.docker.internal even when strict chat-completions tool calling is required", () => { + const result = probeOpenAiLikeEndpoint( + "http://host.docker.internal:11434/v1", + "openai/nemotron-mini", + "", + { skipResponsesProbe: true, requireChatCompletionsToolCalling: true }, + ); + + expect(result).toMatchObject({ ok: false }); + expect(result.message).toMatch(/host\.docker\.internal/); + 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 }, + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${fakeBin}:${originalPath || ""}`; + try { + const result = probeOpenAiLikeEndpoint( + "http://host.docker.internal:11434/v1", + "openai/nemotron-mini", + "", + { + skipResponsesProbe: true, + requireChatCompletionsToolCalling: true, + allowHostDockerInternal: true, + }, + ); + + expect(result).toMatchObject({ + ok: true, + 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 }); + } + }); +}); diff --git a/src/lib/inference/onboard-host-docker-internal.ts b/src/lib/inference/onboard-host-docker-internal.ts new file mode 100644 index 00000000000..92b26b5bf40 --- /dev/null +++ b/src/lib/inference/onboard-host-docker-internal.ts @@ -0,0 +1,44 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const HOST_DOCKER_INTERNAL = "host.docker.internal"; +const OLLAMA_PROXY_URL = "http://host.openshell.internal:11435/v1"; + +function isHijackedDockerInternalUrl(url) { + try { + return new URL(String(url)).hostname === HOST_DOCKER_INTERNAL; + } catch { + return false; + } +} + +function getHostDockerInternalProbeFailure() { + return { + ok: false, + message: + `${HOST_DOCKER_INTERNAL} does not reach the host machine from inside the sandbox: ` + + `OpenShell k3s sandboxes do not provide it as a reliable host-service route. ` + + `It may fail DNS resolution or resolve to a gateway/bridge address where port ` + + `11434 is not forwarded. For local Ollama, use the auth-proxy URL ` + + `${OLLAMA_PROXY_URL} (the URL NemoClaw onboard configures automatically ` + + `when you pick "Local Ollama"). See issue #3136.`, + failures: [ + { + name: "host.docker.internal reachability", + httpStatus: 0, + curlStatus: 0, + message: + `${HOST_DOCKER_INTERNAL} is not a reliable host-service route from ` + + `OpenShell k3s sandboxes and cannot be used as an inference base URL.`, + body: "", + }, + ], + }; +} + +module.exports = { + HOST_DOCKER_INTERNAL, + isHijackedDockerInternalUrl, + getHostDockerInternalProbeFailure, +}; diff --git a/src/lib/inference/onboard-probes.test.ts b/src/lib/inference/onboard-probes.test.ts index 2c6db064bdf..20a4a0cf3e0 100644 --- a/src/lib/inference/onboard-probes.test.ts +++ b/src/lib/inference/onboard-probes.test.ts @@ -14,7 +14,6 @@ const { hasChatCompletionsToolCall, hasChatCompletionsToolCallLeak, hasResponsesToolCall, - isHijackedDockerInternalUrl, isSandboxInternalUrl, probeOpenAiLikeEndpoint, RETRIABLE_HTTP_PROBE_STATUSES, @@ -360,15 +359,6 @@ describe("OpenAI-compatible inference probes", () => { expect(isSandboxInternalUrl("http://127.0.0.1:8001/v1")).toBe(false); }); - // Issue #3136: host.docker.internal is not a stable host route from - // OpenShell k3s pods, so it must not be treated as a usable sandbox URL. - it("does not treat host.docker.internal as a usable sandbox URL", () => { - expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(false); - expect(isHijackedDockerInternalUrl("http://host.docker.internal:11434/v1")).toBe(true); - expect(isHijackedDockerInternalUrl("http://host.openshell.internal:11435/v1")).toBe(false); - expect(isHijackedDockerInternalUrl("https://api.openai.com/v1")).toBe(false); - }); - it("skips the curl probe for sandbox-internal URLs and returns ok with a note", () => { const result = probeOpenAiLikeEndpoint( "http://host.openshell.internal:8001/v1", @@ -383,90 +373,6 @@ describe("OpenAI-compatible inference probes", () => { expect(result.note).toMatch(/only resolves inside the sandbox/); }); - it("rejects host.docker.internal URLs with an actionable proxy hint (#3136)", () => { - const result = probeOpenAiLikeEndpoint( - "http://host.docker.internal:11434/v1", - "openai/nemotron-mini", - "", - ); - expect(result.ok).toBe(false); - expect(result.message).toMatch(/host\.docker\.internal/); - expect(result.message).toMatch(/host\.openshell\.internal:11435/); - expect(result.failures).toEqual([ - expect.objectContaining({ name: "host.docker.internal reachability" }), - ]); - }); - - it("rejects host.docker.internal even when strict chat-completions tool calling is required", () => { - const result = probeOpenAiLikeEndpoint( - "http://host.docker.internal:11434/v1", - "openai/nemotron-mini", - "", - { skipResponsesProbe: true, requireChatCompletionsToolCalling: true }, - ); - - expect(result).toMatchObject({ ok: false }); - expect(result.message).toMatch(/host\.docker\.internal/); - 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 }, - ); - - const originalPath = process.env.PATH; - process.env.PATH = `${fakeBin}:${originalPath || ""}`; - try { - const result = probeOpenAiLikeEndpoint( - "http://host.docker.internal:11434/v1", - "openai/nemotron-mini", - "", - { - skipResponsesProbe: true, - requireChatCompletionsToolCalling: true, - allowHostDockerInternal: true, - }, - ); - - expect(result).toMatchObject({ - ok: true, - 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 }); - } - }); - it("fails closed for unprobeable sandbox-internal URLs when strict tool calling is required", () => { const result = probeOpenAiLikeEndpoint( "http://host.openshell.internal:8001/v1", diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index 1103d04aca7..ee3248312d1 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -8,6 +8,10 @@ const { getCredential, normalizeCredentialValue, resolveProviderCredential } = require("../credentials/store"); const { isWsl } = require("../platform"); const httpProbe = require("../adapters/http/probe"); +const { + getHostDockerInternalProbeFailure, + isHijackedDockerInternalUrl, +} = require("./onboard-host-docker-internal"); const { isNvcfFunctionNotFoundForAccount, nvcfFunctionNotFoundMessage, @@ -30,12 +34,6 @@ const { // must fail closed unless the host is probeable from the onboard process. const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal"]; -// host.docker.internal is not a stable host-service route from OpenShell k3s -// sandboxes. Depending on the platform it may fail DNS resolution or resolve to -// a gateway/bridge address where the user's host service is not forwarded. See -// issue #3136. Always steer users to a NemoClaw-managed proxy URL instead. -const HOST_DOCKER_INTERNAL = "host.docker.internal"; - function isSandboxInternalUrl(url) { try { const { hostname } = new URL(String(url)); @@ -45,14 +43,6 @@ function isSandboxInternalUrl(url) { } } -function isHijackedDockerInternalUrl(url) { - try { - return new URL(String(url)).hostname === HOST_DOCKER_INTERNAL; - } catch { - return false; - } -} - function parseJsonObject(body) { if (!body) return null; try { @@ -499,27 +489,7 @@ function runChatCompletionsProbe({ authHeader, model, url, isWsl: isWslOverride function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { if (isHijackedDockerInternalUrl(endpointUrl) && options.allowHostDockerInternal !== true) { - return { - ok: false, - message: - `${HOST_DOCKER_INTERNAL} does not reach the host machine from inside the sandbox: ` + - `OpenShell k3s sandboxes do not provide it as a reliable host-service route. ` + - `It may fail DNS resolution or resolve to a gateway/bridge address where port ` + - `11434 is not forwarded. For local Ollama, use the auth-proxy URL ` + - `http://host.openshell.internal:11435/v1 (the URL NemoClaw onboard configures ` + - `automatically when you pick "Local Ollama"). See issue #3136.`, - failures: [ - { - name: "host.docker.internal reachability", - httpStatus: 0, - curlStatus: 0, - message: - `${HOST_DOCKER_INTERNAL} is not a reliable host-service route from ` + - `OpenShell k3s sandboxes and cannot be used as an inference base URL.`, - body: "", - }, - ], - }; + return getHostDockerInternalProbeFailure(); } if (isSandboxInternalUrl(endpointUrl)) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1c0c649cbe6..6ca47d255b8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -168,8 +168,7 @@ const { } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); const { - findReachableOllamaHost, - getResolvedOllamaHost, + findReachableOllamaHost, getResolvedOllamaHost, resetOllamaHostCache, getDefaultOllamaModel, getLocalProviderBaseUrl, @@ -2146,10 +2145,8 @@ async function validateOpenAiLikeSelection( options: { authMode?: "bearer" | "query-param"; requireResponsesToolCalling?: boolean; - requireChatCompletionsToolCalling?: boolean; - skipResponsesProbe?: boolean; - probeStreaming?: boolean; - allowHostDockerInternal?: boolean; + requireChatCompletionsToolCalling?: boolean; skipResponsesProbe?: boolean; + probeStreaming?: boolean; allowHostDockerInternal?: boolean; } = {}, ): Promise { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; From 023a4947937baa2125ad09ab6e54e0121cfaa52e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 22 May 2026 18:09:42 -0700 Subject: [PATCH 4/4] fix(onboard): keep host-docker policy within entrypoint budget Signed-off-by: Aaron Erickson --- src/lib/inference/onboard-host-docker-internal.ts | 1 + src/lib/onboard.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/inference/onboard-host-docker-internal.ts b/src/lib/inference/onboard-host-docker-internal.ts index 92b26b5bf40..d406d50c689 100644 --- a/src/lib/inference/onboard-host-docker-internal.ts +++ b/src/lib/inference/onboard-host-docker-internal.ts @@ -39,6 +39,7 @@ function getHostDockerInternalProbeFailure() { module.exports = { HOST_DOCKER_INTERNAL, + OLLAMA_PROXY_URL, isHijackedDockerInternalUrl, getHostDockerInternalProbeFailure, }; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6ca47d255b8..4940e56d285 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -168,7 +168,7 @@ const { } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); const { - findReachableOllamaHost, getResolvedOllamaHost, + findReachableOllamaHost, resetOllamaHostCache, getDefaultOllamaModel, getLocalProviderBaseUrl, @@ -2120,9 +2120,6 @@ function verifyWebSearchInsideSandbox( }); } -// getSandboxInferenceConfig — moved to onboard-providers.ts - -// Inference probes — moved to inference/onboard-probes.ts const { hasResponsesToolCall, hasChatCompletionsToolCall, @@ -2145,8 +2142,10 @@ async function validateOpenAiLikeSelection( options: { authMode?: "bearer" | "query-param"; requireResponsesToolCalling?: boolean; - requireChatCompletionsToolCalling?: boolean; skipResponsesProbe?: boolean; - probeStreaming?: boolean; allowHostDockerInternal?: boolean; + requireChatCompletionsToolCalling?: boolean; + skipResponsesProbe?: boolean; + probeStreaming?: boolean; + allowHostDockerInternal?: boolean; } = {}, ): Promise { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; @@ -6024,7 +6023,8 @@ async function selectAndValidateOllamaModel( { skipResponsesProbe: true, requireChatCompletionsToolCalling: true, - allowHostDockerInternal: getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, + allowHostDockerInternal: + localInference.getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL, }, ); if (validation.retry === "selection") return { outcome: "back-to-selection" };