Skip to content

Commit e515de6

Browse files
authored
fix(onboard): reuse mirrored Windows Ollama (#9345)
<!-- markdownlint-disable MD041 --> ## Summary Reuse Windows-host Ollama when WSL mirrored networking exposes that daemon through `127.0.0.1`. Before this change, NemoClaw treated the loopback response as a WSL-local daemon and could route Ollama 0.32.5 through the Linux upgrade installer even though the usable daemon was installed on Windows. ## Related Issue Fixes #9300 ## Changes - Classify WSL loopback as Windows-host Ollama only when `wslinfo` reports mirrored networking, a Windows Ollama installation is present, Docker can reach the Windows daemon, and Linux procfs shows no WSL-owned listener on the Ollama port. - Exclude that positively identified Windows daemon from the WSL-local binary and daemon version-upgrade path. - Keep fail-closed behavior for ordinary WSL loopback daemons, dual-daemon topologies, unavailable socket identity, and unrecognized WSL networking modes. - Add regression coverage for mirrored-network classification, Ollama 0.32.5, local-listener identity, and ambiguous modes; document the topology rule. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Approved by @cv: #9345 (review) - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: Not applicable - Station profile/scenario: Not applicable - Result: Not applicable - Supporting evidence: Not applicable ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — `npm run test -- --run src/lib/onboard/provider-host-state.test.ts src/lib/onboard/ollama-install-menu.test.ts src/lib/onboard/provider-menu.test.ts test/install-express-wsl-ollama.test.ts test/onboard-selection-windows-provider-rejection.test.ts test/onboard-selection.test.ts` (133 passed) - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: Not applicable; the change is isolated to Ollama topology classification and its install menu. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) — build succeeded with 0 errors and 2 pre-existing warnings - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com> --------- Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com>
1 parent b4b6680 commit e515de6

6 files changed

Lines changed: 243 additions & 15 deletions

File tree

docs/inference/set-up-ollama.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ When neither side can be read, the failure asks you to check that Ollama is inst
4949
Fresh installs skip this second probe because the bundled installers provide a daemon at or above the minimum.
5050

5151
The version gate does not apply to Windows-host Ollama reached from Docker Desktop through `host.docker.internal`.
52+
With WSL mirrored networking, the same daemon can answer on `127.0.0.1`; NemoClaw treats it as Windows-host Ollama only when Windows installation and Docker reachability checks match and Linux procfs shows no WSL-local listener on the Ollama port.
53+
Ambiguous evidence or a separate WSL-local listener stays on the Linux install and upgrade path.
5254
The Windows-host menu entries perform their own actions on the Windows side.
5355

5456
## Choose a Linux Install Mode

src/lib/inference/local.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function resetOllamaContainerPortCache(): void {
8383
export const HOST_GATEWAY_URL = "http://host.openshell.internal";
8484
export const LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV = "NEMOCLAW_LOCAL_INFERENCE_SANDBOX_HOST_URL";
8585
export { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-probe";
86+
export { OLLAMA_PORT };
8687

8788
// These tags are convenience aliases for callers that want to refer to a
8889
// specific bootstrap model by role rather than by string. The canonical

src/lib/onboard/ollama-install-menu.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,27 @@ describe("resolveOllamaInstallMenuEntry", () => {
200200
expect(result.entry).toBeNull();
201201
});
202202

203+
it("does not route mirrored Windows-host Ollama through the WSL installer (#9300)", () => {
204+
const result = resolveOllamaInstallMenuEntry({
205+
hasOllama: true,
206+
ollamaRunning: true,
207+
hasWindowsOllama: true,
208+
windowsHostOllamaSupported: true,
209+
isWindowsHostOllama: true,
210+
ollamaHost: "127.0.0.1",
211+
installedOllamaVersion: "0.32.5",
212+
runningOllamaVersion: "0.32.5",
213+
platform: "linux",
214+
isWsl: true,
215+
});
216+
217+
expect(result).toEqual({
218+
entry: null,
219+
hasUpgradableOllama: false,
220+
binaryNeedsUpgrade: false,
221+
});
222+
});
223+
203224
it("omits the entry when only Windows-host Ollama is present", () => {
204225
const result = resolveOllamaInstallMenuEntry({
205226
hasOllama: false,

src/lib/onboard/ollama-install-menu.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export interface OllamaInstallMenuInput {
2020
* routing covers nothing, so the WSL-local install entry stays on offer.
2121
* Only read when `hasWindowsOllama` is set; defaults to reachable. */
2222
windowsHostOllamaSupported?: boolean;
23+
/** True when the responding daemon is known to run on Windows, including
24+
* WSL mirrored networking where it is observed through distro loopback. */
25+
isWindowsHostOllama?: boolean;
2326
platform: NodeJS.Platform;
2427
isWsl: boolean;
2528
/** Resolved host for the running Ollama daemon. `host.docker.internal`
@@ -138,7 +141,9 @@ export function resolveOllamaInstallMenuEntry(
138141
// 127.0.0.1/localhost. A Windows-host daemon reached via
139142
// `host.docker.internal` is handled by separate menu entries
140143
// (`install-windows-ollama` / `start-windows-ollama`).
141-
const daemonProbeApplies = input.ollamaRunning && isLocalOllamaHost(input.ollamaHost);
144+
const localUpgradeApplies = input.isWindowsHostOllama !== true;
145+
const daemonProbeApplies =
146+
localUpgradeApplies && input.ollamaRunning && isLocalOllamaHost(input.ollamaHost);
142147
const runningOllamaVersion =
143148
input.runningOllamaVersion !== undefined
144149
? input.runningOllamaVersion
@@ -150,14 +155,18 @@ export function resolveOllamaInstallMenuEntry(
150155
// on the old version (and vice versa). Upgrade when either source is below
151156
// the minimum.
152157
const installedBinaryMeetsMinimum =
153-
input.hasOllama && isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION);
158+
localUpgradeApplies &&
159+
input.hasOllama &&
160+
isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION);
154161
const daemonNeedsUpgrade =
155162
daemonProbeApplies && !isOllamaVersionAtLeast(runningOllamaVersion, MIN_OLLAMA_VERSION);
156163
// Restart-only recovery is safe only with positive evidence that the
157164
// installed binary meets the floor. A stale daemon without a local binary
158165
// still needs the installer to provide one.
159166
const binaryNeedsUpgrade =
160-
!installedBinaryMeetsMinimum && (input.hasOllama || daemonNeedsUpgrade);
167+
localUpgradeApplies &&
168+
!installedBinaryMeetsMinimum &&
169+
(input.hasOllama || daemonNeedsUpgrade);
161170
const hasUpgradableOllama = binaryNeedsUpgrade || daemonNeedsUpgrade;
162171
// A Windows-host install only covers the local-inference need when the
163172
// sandbox can route to it. Under a container runtime without that routing,

src/lib/onboard/provider-host-state.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MIN_OLLAMA_VERSION } from "../inference/ollama-version";
66
import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology";
77
import {
88
type DetectInferenceProviderHostStateDeps,
9+
detectLocalTcpListener,
910
detectInferenceProviderHostState,
1011
type InferenceProviderHostGpu,
1112
} from "./provider-host-state";
@@ -40,6 +41,7 @@ function buildDeps(
4041
getWindowsHostOllamaDockerRequirement: vi.fn(() => SUPPORTED_WINDOWS_OLLAMA),
4142
detectVllmProfile: vi.fn(() => null),
4243
getLocalProviderAvailabilityEndpoint: vi.fn(() => "http://127.0.0.1:8000/v1/models"),
44+
detectLocalTcpListener: vi.fn(() => null),
4345
...overrides,
4446
};
4547
}
@@ -294,7 +296,7 @@ describe("detectInferenceProviderHostState", () => {
294296
expect(isWsl).toHaveBeenCalledWith({ platform: "linux", env });
295297
});
296298

297-
it("suppresses the duplicate-daemon warning when WSL mirrored networking makes the probes equivalent", () => {
299+
it("classifies a mirrored loopback daemon as Windows-host Ollama (#9300)", () => {
298300
const logs: string[] = [];
299301
const deps = buildDeps({
300302
isWsl: vi.fn(() => true),
@@ -310,6 +312,7 @@ describe("detectInferenceProviderHostState", () => {
310312
return "";
311313
}),
312314
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
315+
detectLocalTcpListener: vi.fn(() => false),
313316
});
314317

315318
const state = detectInferenceProviderHostState({
@@ -324,9 +327,103 @@ describe("detectInferenceProviderHostState", () => {
324327
});
325328

326329
expect(state.windowsOllamaReachable).toBe(true);
330+
expect(state.isWindowsHostOllama).toBe(true);
331+
expect(state.ollamaInstallMenu.entry).toBeNull();
327332
expect(logs).toEqual([]);
328333
});
329334

335+
it("keeps a mirrored WSL-local daemon on the Linux upgrade path (#9300)", () => {
336+
const logs: string[] = [];
337+
const deps = buildDeps({
338+
isWsl: vi.fn(() => true),
339+
hostCommandExists: vi.fn((command) => command === "ollama"),
340+
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
341+
detectWindowsHostOllama: vi.fn(() => ({
342+
installed: true,
343+
installedPath: "C:\\Ollama\\ollama.exe",
344+
loopbackOnly: false,
345+
})),
346+
runCapture: vi.fn((command) =>
347+
command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "",
348+
),
349+
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
350+
detectLocalTcpListener: vi.fn(() => true),
351+
});
352+
353+
const state = detectInferenceProviderHostState({
354+
gpu: null,
355+
experimental: false,
356+
platform: "linux",
357+
env: {},
358+
log: (message = "") => logs.push(message),
359+
installedOllamaVersion: "0.32.5",
360+
runningOllamaVersion: "0.32.5",
361+
deps,
362+
});
363+
364+
expect(state.windowsOllamaReachable).toBe(true);
365+
expect(state.isWindowsHostOllama).toBe(false);
366+
expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama");
367+
expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true);
368+
expect(logs.join("\n")).toContain("Ollama is running on both WSL and the Windows host");
369+
});
370+
371+
it("fails closed when mirrored listener identity is unavailable (#9300)", () => {
372+
const deps = buildDeps({
373+
isWsl: vi.fn(() => true),
374+
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
375+
detectWindowsHostOllama: vi.fn(() => ({
376+
installed: true,
377+
installedPath: "C:\\Ollama\\ollama.exe",
378+
loopbackOnly: false,
379+
})),
380+
runCapture: vi.fn((command) =>
381+
command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "",
382+
),
383+
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
384+
detectLocalTcpListener: vi.fn(() => null),
385+
});
386+
387+
const state = detectWithDeps(deps);
388+
389+
expect(state.isWindowsHostOllama).toBe(false);
390+
});
391+
392+
it("keeps an unrecognized WSL networking mode on the Linux upgrade path (#9300)", () => {
393+
const detectLocalTcpListener = vi.fn(() => false);
394+
const deps = buildDeps({
395+
isWsl: vi.fn(() => true),
396+
hostCommandExists: vi.fn((command) => command === "ollama"),
397+
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
398+
detectWindowsHostOllama: vi.fn(() => ({
399+
installed: true,
400+
installedPath: "C:\\Ollama\\ollama.exe",
401+
loopbackOnly: false,
402+
})),
403+
runCapture: vi.fn((command) =>
404+
command.join(" ").includes("wslinfo --networking-mode") ? "future-mode\n" : "",
405+
),
406+
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
407+
detectLocalTcpListener,
408+
});
409+
410+
const state = detectInferenceProviderHostState({
411+
gpu: null,
412+
experimental: false,
413+
platform: "linux",
414+
env: {},
415+
log: () => undefined,
416+
installedOllamaVersion: "0.32.5",
417+
runningOllamaVersion: "0.32.5",
418+
deps,
419+
});
420+
421+
expect(state.isWindowsHostOllama).toBe(false);
422+
expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama");
423+
expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true);
424+
expect(detectLocalTcpListener).not.toHaveBeenCalled();
425+
});
426+
330427
it("does not probe the Windows-host switch path when running Ollama already resolves to the Windows host", () => {
331428
const runCapture = vi.fn<DetectInferenceProviderHostStateDeps["runCapture"]>(() => "");
332429
const dockerCapture = vi.fn<DetectInferenceProviderHostStateDeps["dockerCapture"]>(() => "");
@@ -349,3 +446,24 @@ describe("detectInferenceProviderHostState", () => {
349446
expect(dockerCapture).not.toHaveBeenCalled();
350447
});
351448
});
449+
450+
describe("detectLocalTcpListener", () => {
451+
it("distinguishes Linux listeners from an empty procfs socket table (#9300)", () => {
452+
const header = " sl local_address rem_address st\n";
453+
const listener = `${header} 0: 0100007F:2CAA 00000000:0000 0A\n`;
454+
455+
expect(detectLocalTcpListener(11434, () => listener)).toBe(true);
456+
expect(detectLocalTcpListener(11434, () => header)).toBe(false);
457+
});
458+
459+
it("fails closed when procfs is unavailable or malformed (#9300)", () => {
460+
expect(detectLocalTcpListener(11434, () => null)).toBeNull();
461+
expect(detectLocalTcpListener(11434, () => "header\nmalformed\n")).toBeNull();
462+
expect(
463+
detectLocalTcpListener(11434, (filePath) =>
464+
filePath.endsWith("tcp") ? " sl local_address rem_address st\n" : null,
465+
),
466+
).toBeNull();
467+
expect(detectLocalTcpListener(0, () => "unused")).toBeNull();
468+
});
469+
});

0 commit comments

Comments
 (0)