Skip to content

Commit 2db21d8

Browse files
chengjiewTest Usercvericksoa
authored
fix(onboard): reject host.docker.internal inference URLs (#3804)
## Summary - Reject `host.docker.internal` during OpenAI-compatible inference endpoint validation for OpenShell sandboxes. - Keep `host.openshell.internal` as the supported sandbox-internal host and point local Ollama users at the auth proxy URL. - Document why direct host `:11434` routing is unreliable from OpenShell/k3s sandboxes. ## Validation - `npm test -- src/lib/inference/onboard-probes.test.ts` - `npm test -- test/onboard-selection.test.ts -t "Windows-host"` - `npm run build:cli` - `git diff --check` - Reproduced #3136 on `aits-log-worker-6`: a host mock service on `0.0.0.0:11434` was reachable on the host, but a real OpenClaw sandbox resolved `host.docker.internal` to `172.17.0.1` and got connection refused for `:11434`. ## Hook note - The full local pre-push CLI suite still fails in this worktree with broader local/repo-environment failures unrelated to this focused change, including `ssrf-parity` missing `../nemoclaw/dist/...`, version fallback tests reading the current git describe, and macOS `stat -c` incompatibility output. Fixes #3136 Signed-off-by: Chengjie Wang <chengjiew@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * New Runtime troubleshooting: explains sandbox limits reaching host services and recommends using the local Ollama proxy (host.openshell.internal:11435) or routing via the gateway. * **Bug Fixes / Behavior** * Validation now treats host.docker.internal as unreliable in the sandbox and directs users to a routable proxy; sandbox-internal handling now focuses on host.openshell.internal. * **Tests** * Probe and onboarding tests updated to match the new validation behavior and permit an explicit override for Windows-local validation. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/3804?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Chengjie Wang <chengjiew@nvidia.com> Signed-off-by: Aaron Erickson <aerickson@nvidia.com> Co-authored-by: Test User <test@example.com> Co-authored-by: Carlos Villela <cvillela@nvidia.com> Co-authored-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 2f4cd70 commit 2db21d8

6 files changed

Lines changed: 209 additions & 84 deletions

File tree

docs/reference/troubleshooting.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,38 @@ $ nemoclaw onboard
10811081
Docker Desktop, WSL, and hosts without the OpenShell Docker network use different routing models.
10821082
In those cases NemoClaw treats an unavailable sandbox-side probe as non-blocking and relies on the regular proxy health check.
10831083

1084+
### `host.docker.internal` does not reliably reach the host from the sandbox
1085+
1086+
Configuring an inference provider with a base URL like
1087+
`http://host.docker.internal:11434/v1` does not reliably reach a host Ollama
1088+
service from inside the OpenShell sandbox.
1089+
OpenShell runs sandboxes inside a k3s network, where `host.docker.internal` is
1090+
not a portable host-service route. Depending on the platform, it may fail DNS
1091+
resolution or resolve to an internal gateway/bridge address where the host's
1092+
port `11434` is not forwarded. The sandbox then sees a DNS failure or
1093+
`connection refused`:
1094+
1095+
```console
1096+
$ getent hosts host.docker.internal
1097+
172.17.0.1 host.docker.internal host.openshell.internal
1098+
$ no_proxy=host.docker.internal curl -v http://host.docker.internal:11434/api/tags
1099+
* connect to 172.17.0.1 port 11434 failed: Connection refused
1100+
```
1101+
1102+
For local Ollama, use the auth-proxy URL that NemoClaw's "Local Ollama" onboard
1103+
option configures automatically:
1104+
1105+
```text
1106+
http://host.openshell.internal:11435/v1
1107+
```
1108+
1109+
`host.openshell.internal` resolves to the same gateway IP, and the
1110+
[token-gated Ollama auth proxy](#ollama-auth-proxy-did-not-start) binds port
1111+
`11435` there and forwards requests to `127.0.0.1:11434` on the host.
1112+
If you need a different host service exposed to the sandbox, route it through
1113+
the OpenShell gateway rather than relying on `host.docker.internal`.
1114+
See issue [#3136](https://github.com/NVIDIA/NemoClaw/issues/3136).
1115+
10841116
### Local inference health check resolves to IPv6
10851117

10861118
Local inference health checks now use `127.0.0.1` instead of `localhost`.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, expect, it } from "vitest";
5+
import fs from "node:fs";
6+
import os from "node:os";
7+
import path from "node:path";
8+
9+
const {
10+
isHijackedDockerInternalUrl,
11+
} = require("../../../dist/lib/inference/onboard-host-docker-internal");
12+
const { isSandboxInternalUrl, probeOpenAiLikeEndpoint } = require("../../../dist/lib/inference/onboard-probes");
13+
14+
describe("host.docker.internal onboarding inference policy", () => {
15+
it("does not treat host.docker.internal as a usable sandbox URL", () => {
16+
expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(false);
17+
expect(isHijackedDockerInternalUrl("http://host.docker.internal:11434/v1")).toBe(true);
18+
expect(isHijackedDockerInternalUrl("http://host.openshell.internal:11435/v1")).toBe(false);
19+
expect(isHijackedDockerInternalUrl("https://api.openai.com/v1")).toBe(false);
20+
});
21+
22+
it("rejects host.docker.internal URLs with an actionable proxy hint (#3136)", () => {
23+
const result = probeOpenAiLikeEndpoint(
24+
"http://host.docker.internal:11434/v1",
25+
"openai/nemotron-mini",
26+
"",
27+
);
28+
expect(result.ok).toBe(false);
29+
expect(result.message).toMatch(/host\.docker\.internal/);
30+
expect(result.message).toMatch(/host\.openshell\.internal:11435/);
31+
expect(result.failures).toEqual([
32+
expect.objectContaining({ name: "host.docker.internal reachability" }),
33+
]);
34+
});
35+
36+
it("rejects host.docker.internal even when strict chat-completions tool calling is required", () => {
37+
const result = probeOpenAiLikeEndpoint(
38+
"http://host.docker.internal:11434/v1",
39+
"openai/nemotron-mini",
40+
"",
41+
{ skipResponsesProbe: true, requireChatCompletionsToolCalling: true },
42+
);
43+
44+
expect(result).toMatchObject({ ok: false });
45+
expect(result.message).toMatch(/host\.docker\.internal/);
46+
expect(result.message).toMatch(/host\.openshell\.internal:11435/);
47+
});
48+
49+
it("allows explicit Windows-host Ollama validation to probe host.docker.internal", () => {
50+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-docker-probe-"));
51+
const fakeBin = path.join(tmpDir, "bin");
52+
const seenUrl = path.join(tmpDir, "url");
53+
fs.mkdirSync(fakeBin, { recursive: true });
54+
fs.writeFileSync(
55+
path.join(fakeBin, "curl"),
56+
`#!/usr/bin/env bash
57+
outfile=""
58+
url=""
59+
while [ "$#" -gt 0 ]; do
60+
case "$1" in
61+
-o) outfile="$2"; shift 2 ;;
62+
-w) shift 2 ;;
63+
*) url="$1"; shift ;;
64+
esac
65+
done
66+
printf '%s' "$url" > "${seenUrl}"
67+
if [ -n "$outfile" ]; then
68+
cat <<'JSON' > "$outfile"
69+
{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"sessions_send","arguments":"{\\"message\\":\\"hello\\"}"}}]}}]}
70+
JSON
71+
fi
72+
printf '200'
73+
exit 0
74+
`,
75+
{ mode: 0o755 },
76+
);
77+
78+
const originalPath = process.env.PATH;
79+
process.env.PATH = `${fakeBin}:${originalPath || ""}`;
80+
try {
81+
const result = probeOpenAiLikeEndpoint(
82+
"http://host.docker.internal:11434/v1",
83+
"openai/nemotron-mini",
84+
"",
85+
{
86+
skipResponsesProbe: true,
87+
requireChatCompletionsToolCalling: true,
88+
allowHostDockerInternal: true,
89+
},
90+
);
91+
92+
expect(result).toMatchObject({
93+
ok: true,
94+
api: "openai-completions",
95+
label: "Chat Completions API",
96+
});
97+
expect(fs.readFileSync(seenUrl, "utf8")).toBe(
98+
"http://host.docker.internal:11434/v1/chat/completions",
99+
);
100+
} finally {
101+
process.env.PATH = originalPath;
102+
fs.rmSync(tmpDir, { recursive: true, force: true });
103+
}
104+
});
105+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// @ts-nocheck
2+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
const HOST_DOCKER_INTERNAL = "host.docker.internal";
6+
const OLLAMA_PROXY_URL = "http://host.openshell.internal:11435/v1";
7+
8+
function isHijackedDockerInternalUrl(url) {
9+
try {
10+
return new URL(String(url)).hostname === HOST_DOCKER_INTERNAL;
11+
} catch {
12+
return false;
13+
}
14+
}
15+
16+
function getHostDockerInternalProbeFailure() {
17+
return {
18+
ok: false,
19+
message:
20+
`${HOST_DOCKER_INTERNAL} does not reach the host machine from inside the sandbox: ` +
21+
`OpenShell k3s sandboxes do not provide it as a reliable host-service route. ` +
22+
`It may fail DNS resolution or resolve to a gateway/bridge address where port ` +
23+
`11434 is not forwarded. For local Ollama, use the auth-proxy URL ` +
24+
`${OLLAMA_PROXY_URL} (the URL NemoClaw onboard configures automatically ` +
25+
`when you pick "Local Ollama"). See issue #3136.`,
26+
failures: [
27+
{
28+
name: "host.docker.internal reachability",
29+
httpStatus: 0,
30+
curlStatus: 0,
31+
message:
32+
`${HOST_DOCKER_INTERNAL} is not a reliable host-service route from ` +
33+
`OpenShell k3s sandboxes and cannot be used as an inference base URL.`,
34+
body: "",
35+
},
36+
],
37+
};
38+
}
39+
40+
module.exports = {
41+
HOST_DOCKER_INTERNAL,
42+
OLLAMA_PROXY_URL,
43+
isHijackedDockerInternalUrl,
44+
getHostDockerInternalProbeFailure,
45+
};

src/lib/inference/onboard-probes.test.ts

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,8 @@ describe("OpenAI-compatible inference probes", () => {
349349
});
350350

351351
describe("sandbox-internal URL handling", () => {
352-
it("identifies host.openshell.internal and host.docker.internal as sandbox-internal", () => {
352+
it("identifies host.openshell.internal as sandbox-internal", () => {
353353
expect(isSandboxInternalUrl("http://host.openshell.internal:8001/v1")).toBe(true);
354-
expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(true);
355354
});
356355

357356
it("does not treat normal hostnames as sandbox-internal", () => {
@@ -374,69 +373,6 @@ describe("OpenAI-compatible inference probes", () => {
374373
expect(result.note).toMatch(/only resolves inside the sandbox/);
375374
});
376375

377-
it("skips the curl probe for host.docker.internal and returns ok with a note", () => {
378-
const result = probeOpenAiLikeEndpoint(
379-
"http://host.docker.internal:11434/v1",
380-
"openai/nemotron-mini",
381-
"",
382-
);
383-
expect(result).toMatchObject({ ok: true, api: null });
384-
expect(result.note).toMatch(/host\.docker\.internal/);
385-
});
386-
387-
it("probes host.docker.internal when strict chat-completions tool calling is required", () => {
388-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-host-docker-probe-"));
389-
const fakeBin = path.join(tmpDir, "bin");
390-
const seenUrl = path.join(tmpDir, "url");
391-
fs.mkdirSync(fakeBin, { recursive: true });
392-
fs.writeFileSync(
393-
path.join(fakeBin, "curl"),
394-
`#!/usr/bin/env bash
395-
outfile=""
396-
url=""
397-
while [ "$#" -gt 0 ]; do
398-
case "$1" in
399-
-o) outfile="$2"; shift 2 ;;
400-
-w) shift 2 ;;
401-
*) url="$1"; shift ;;
402-
esac
403-
done
404-
printf '%s' "$url" > "${seenUrl}"
405-
if [ -n "$outfile" ]; then
406-
cat <<'JSON' > "$outfile"
407-
{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"sessions_send","arguments":"{\\"message\\":\\"hello\\"}"}}]}}]}
408-
JSON
409-
fi
410-
printf '200'
411-
exit 0
412-
`,
413-
{ mode: 0o755 },
414-
);
415-
416-
const originalPath = process.env.PATH;
417-
process.env.PATH = `${fakeBin}:${originalPath || ""}`;
418-
try {
419-
const result = probeOpenAiLikeEndpoint(
420-
"http://host.docker.internal:11434/v1",
421-
"openai/nemotron-mini",
422-
"",
423-
{ skipResponsesProbe: true, requireChatCompletionsToolCalling: true },
424-
);
425-
426-
expect(result).toMatchObject({
427-
ok: true,
428-
api: "openai-completions",
429-
label: "Chat Completions API",
430-
});
431-
expect(fs.readFileSync(seenUrl, "utf8")).toBe(
432-
"http://host.docker.internal:11434/v1/chat/completions",
433-
);
434-
} finally {
435-
process.env.PATH = originalPath;
436-
fs.rmSync(tmpDir, { recursive: true, force: true });
437-
}
438-
});
439-
440376
it("fails closed for unprobeable sandbox-internal URLs when strict tool calling is required", () => {
441377
const result = probeOpenAiLikeEndpoint(
442378
"http://host.openshell.internal:8001/v1",

src/lib/inference/onboard-probes.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
const { getCredential, normalizeCredentialValue, resolveProviderCredential } = require("../credentials/store");
99
const { isWsl } = require("../platform");
1010
const httpProbe = require("../adapters/http/probe");
11+
const {
12+
getHostDockerInternalProbeFailure,
13+
isHijackedDockerInternalUrl,
14+
} = require("./onboard-host-docker-internal");
1115
const {
1216
isNvcfFunctionNotFoundForAccount,
1317
nvcfFunctionNotFoundMessage,
@@ -28,7 +32,7 @@ const {
2832
// so host-side validation cannot prove reachability for that URL. For ordinary
2933
// verification we still skip these endpoints, but strict tool-call validation
3034
// must fail closed unless the host is probeable from the onboard process.
31-
const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal", "host.docker.internal"];
35+
const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal"];
3236

3337
function isSandboxInternalUrl(url) {
3438
try {
@@ -484,6 +488,10 @@ function runChatCompletionsProbe({ authHeader, model, url, isWsl: isWslOverride
484488
}
485489

486490
function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) {
491+
if (isHijackedDockerInternalUrl(endpointUrl) && options.allowHostDockerInternal !== true) {
492+
return getHostDockerInternalProbeFailure();
493+
}
494+
487495
if (isSandboxInternalUrl(endpointUrl)) {
488496
const { hostname } = new URL(String(endpointUrl));
489497
if (options.requireChatCompletionsToolCalling !== true) {
@@ -494,21 +502,19 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) {
494502
note: `${hostname} only resolves inside the sandbox — validation skipped. If the endpoint is unreachable at runtime, re-run onboard with a routable URL.`,
495503
};
496504
}
497-
if (hostname !== "host.docker.internal") {
498-
return {
499-
ok: false,
500-
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.`,
501-
failures: [
502-
{
503-
name: "Chat Completions API with tool calling",
504-
httpStatus: 0,
505-
curlStatus: 0,
506-
message: "sandbox-internal endpoint cannot be strictly validated from host",
507-
body: "",
508-
},
509-
],
510-
};
511-
}
505+
return {
506+
ok: false,
507+
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.`,
508+
failures: [
509+
{
510+
name: "Chat Completions API with tool calling",
511+
httpStatus: 0,
512+
curlStatus: 0,
513+
message: "sandbox-internal endpoint cannot be strictly validated from host",
514+
body: "",
515+
},
516+
],
517+
};
512518
}
513519

514520
const useQueryParam = options.authMode === "query-param";
@@ -787,6 +793,7 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
787793

788794
module.exports = {
789795
isSandboxInternalUrl,
796+
isHijackedDockerInternalUrl,
790797
parseJsonObject,
791798
hasResponsesToolCall,
792799
hasChatCompletionsToolCall,

src/lib/onboard.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2105,9 +2105,6 @@ function verifyWebSearchInsideSandbox(
21052105
});
21062106
}
21072107

2108-
// getSandboxInferenceConfig — moved to onboard-providers.ts
2109-
2110-
// Inference probes — moved to inference/onboard-probes.ts
21112108
const {
21122109
hasResponsesToolCall,
21132110
hasChatCompletionsToolCall,
@@ -2133,6 +2130,7 @@ async function validateOpenAiLikeSelection(
21332130
requireChatCompletionsToolCalling?: boolean;
21342131
skipResponsesProbe?: boolean;
21352132
probeStreaming?: boolean;
2133+
allowHostDockerInternal?: boolean;
21362134
} = {},
21372135
): Promise<EndpointValidationResult> {
21382136
const apiKey = credentialEnv ? getCredential(credentialEnv) : "";
@@ -6013,6 +6011,8 @@ async function selectAndValidateOllamaModel(
60136011
{
60146012
skipResponsesProbe: true,
60156013
requireChatCompletionsToolCalling: true,
6014+
allowHostDockerInternal:
6015+
localInference.getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL,
60166016
},
60176017
);
60186018
if (validation.retry === "selection") return { outcome: "back-to-selection" };

0 commit comments

Comments
 (0)