diff --git a/docs/inference/set-up-ollama.mdx b/docs/inference/set-up-ollama.mdx index 50ca968bb58..a05ed64b345 100644 --- a/docs/inference/set-up-ollama.mdx +++ b/docs/inference/set-up-ollama.mdx @@ -49,6 +49,8 @@ When neither side can be read, the failure asks you to check that Ollama is inst Fresh installs skip this second probe because the bundled installers provide a daemon at or above the minimum. The version gate does not apply to Windows-host Ollama reached from Docker Desktop through `host.docker.internal`. +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. +Ambiguous evidence or a separate WSL-local listener stays on the Linux install and upgrade path. The Windows-host menu entries perform their own actions on the Windows side. ## Choose a Linux Install Mode diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index b680519e1d7..1d196bf3271 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -83,6 +83,7 @@ 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 { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-probe"; +export { OLLAMA_PORT }; // These tags are convenience aliases for callers that want to refer to a // specific bootstrap model by role rather than by string. The canonical diff --git a/src/lib/onboard/ollama-install-menu.test.ts b/src/lib/onboard/ollama-install-menu.test.ts index d7c8e5ef6a7..af114df0871 100644 --- a/src/lib/onboard/ollama-install-menu.test.ts +++ b/src/lib/onboard/ollama-install-menu.test.ts @@ -200,6 +200,27 @@ describe("resolveOllamaInstallMenuEntry", () => { expect(result.entry).toBeNull(); }); + it("does not route mirrored Windows-host Ollama through the WSL installer (#9300)", () => { + const result = resolveOllamaInstallMenuEntry({ + hasOllama: true, + ollamaRunning: true, + hasWindowsOllama: true, + windowsHostOllamaSupported: true, + isWindowsHostOllama: true, + ollamaHost: "127.0.0.1", + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + platform: "linux", + isWsl: true, + }); + + expect(result).toEqual({ + entry: null, + hasUpgradableOllama: false, + binaryNeedsUpgrade: false, + }); + }); + it("omits the entry when only Windows-host Ollama is present", () => { const result = resolveOllamaInstallMenuEntry({ hasOllama: false, diff --git a/src/lib/onboard/ollama-install-menu.ts b/src/lib/onboard/ollama-install-menu.ts index d7d5048d9f0..ef574283a53 100644 --- a/src/lib/onboard/ollama-install-menu.ts +++ b/src/lib/onboard/ollama-install-menu.ts @@ -20,6 +20,9 @@ export interface OllamaInstallMenuInput { * routing covers nothing, so the WSL-local install entry stays on offer. * Only read when `hasWindowsOllama` is set; defaults to reachable. */ windowsHostOllamaSupported?: boolean; + /** True when the responding daemon is known to run on Windows, including + * WSL mirrored networking where it is observed through distro loopback. */ + isWindowsHostOllama?: boolean; platform: NodeJS.Platform; isWsl: boolean; /** Resolved host for the running Ollama daemon. `host.docker.internal` @@ -138,7 +141,9 @@ export function resolveOllamaInstallMenuEntry( // 127.0.0.1/localhost. A Windows-host daemon reached via // `host.docker.internal` is handled by separate menu entries // (`install-windows-ollama` / `start-windows-ollama`). - const daemonProbeApplies = input.ollamaRunning && isLocalOllamaHost(input.ollamaHost); + const localUpgradeApplies = input.isWindowsHostOllama !== true; + const daemonProbeApplies = + localUpgradeApplies && input.ollamaRunning && isLocalOllamaHost(input.ollamaHost); const runningOllamaVersion = input.runningOllamaVersion !== undefined ? input.runningOllamaVersion @@ -150,14 +155,18 @@ export function resolveOllamaInstallMenuEntry( // on the old version (and vice versa). Upgrade when either source is below // the minimum. const installedBinaryMeetsMinimum = - input.hasOllama && isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION); + localUpgradeApplies && + input.hasOllama && + isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION); const daemonNeedsUpgrade = daemonProbeApplies && !isOllamaVersionAtLeast(runningOllamaVersion, MIN_OLLAMA_VERSION); // Restart-only recovery is safe only with positive evidence that the // installed binary meets the floor. A stale daemon without a local binary // still needs the installer to provide one. const binaryNeedsUpgrade = - !installedBinaryMeetsMinimum && (input.hasOllama || daemonNeedsUpgrade); + localUpgradeApplies && + !installedBinaryMeetsMinimum && + (input.hasOllama || daemonNeedsUpgrade); const hasUpgradableOllama = binaryNeedsUpgrade || daemonNeedsUpgrade; // A Windows-host install only covers the local-inference need when the // sandbox can route to it. Under a container runtime without that routing, diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts index 0994b9858ae..4bbee7d0d80 100644 --- a/src/lib/onboard/provider-host-state.test.ts +++ b/src/lib/onboard/provider-host-state.test.ts @@ -6,6 +6,7 @@ import { MIN_OLLAMA_VERSION } from "../inference/ollama-version"; import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; import { type DetectInferenceProviderHostStateDeps, + detectLocalTcpListener, detectInferenceProviderHostState, type InferenceProviderHostGpu, } from "./provider-host-state"; @@ -40,6 +41,7 @@ function buildDeps( getWindowsHostOllamaDockerRequirement: vi.fn(() => SUPPORTED_WINDOWS_OLLAMA), detectVllmProfile: vi.fn(() => null), getLocalProviderAvailabilityEndpoint: vi.fn(() => "http://127.0.0.1:8000/v1/models"), + detectLocalTcpListener: vi.fn(() => null), ...overrides, }; } @@ -294,7 +296,7 @@ describe("detectInferenceProviderHostState", () => { expect(isWsl).toHaveBeenCalledWith({ platform: "linux", env }); }); - it("suppresses the duplicate-daemon warning when WSL mirrored networking makes the probes equivalent", () => { + it("classifies a mirrored loopback daemon as Windows-host Ollama (#9300)", () => { const logs: string[] = []; const deps = buildDeps({ isWsl: vi.fn(() => true), @@ -310,6 +312,7 @@ describe("detectInferenceProviderHostState", () => { return ""; }), dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")), + detectLocalTcpListener: vi.fn(() => false), }); const state = detectInferenceProviderHostState({ @@ -324,9 +327,103 @@ describe("detectInferenceProviderHostState", () => { }); expect(state.windowsOllamaReachable).toBe(true); + expect(state.isWindowsHostOllama).toBe(true); + expect(state.ollamaInstallMenu.entry).toBeNull(); expect(logs).toEqual([]); }); + it("keeps a mirrored WSL-local daemon on the Linux upgrade path (#9300)", () => { + const logs: string[] = []; + const deps = buildDeps({ + isWsl: vi.fn(() => true), + hostCommandExists: vi.fn((command) => command === "ollama"), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "", + ), + dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")), + detectLocalTcpListener: vi.fn(() => true), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: (message = "") => logs.push(message), + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + expect(state.windowsOllamaReachable).toBe(true); + expect(state.isWindowsHostOllama).toBe(false); + expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama"); + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true); + expect(logs.join("\n")).toContain("Ollama is running on both WSL and the Windows host"); + }); + + it("fails closed when mirrored listener identity is unavailable (#9300)", () => { + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "", + ), + dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")), + detectLocalTcpListener: vi.fn(() => null), + }); + + const state = detectWithDeps(deps); + + expect(state.isWindowsHostOllama).toBe(false); + }); + + it("keeps an unrecognized WSL networking mode on the Linux upgrade path (#9300)", () => { + const detectLocalTcpListener = vi.fn(() => false); + const deps = buildDeps({ + isWsl: vi.fn(() => true), + hostCommandExists: vi.fn((command) => command === "ollama"), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "future-mode\n" : "", + ), + dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")), + detectLocalTcpListener, + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: () => undefined, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + expect(state.isWindowsHostOllama).toBe(false); + expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama"); + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true); + expect(detectLocalTcpListener).not.toHaveBeenCalled(); + }); + it("does not probe the Windows-host switch path when running Ollama already resolves to the Windows host", () => { const runCapture = vi.fn(() => ""); const dockerCapture = vi.fn(() => ""); @@ -349,3 +446,24 @@ describe("detectInferenceProviderHostState", () => { expect(dockerCapture).not.toHaveBeenCalled(); }); }); + +describe("detectLocalTcpListener", () => { + it("distinguishes Linux listeners from an empty procfs socket table (#9300)", () => { + const header = " sl local_address rem_address st\n"; + const listener = `${header} 0: 0100007F:2CAA 00000000:0000 0A\n`; + + expect(detectLocalTcpListener(11434, () => listener)).toBe(true); + expect(detectLocalTcpListener(11434, () => header)).toBe(false); + }); + + it("fails closed when procfs is unavailable or malformed (#9300)", () => { + expect(detectLocalTcpListener(11434, () => null)).toBeNull(); + expect(detectLocalTcpListener(11434, () => "header\nmalformed\n")).toBeNull(); + expect( + detectLocalTcpListener(11434, (filePath) => + filePath.endsWith("tcp") ? " sl local_address rem_address st\n" : null, + ), + ).toBeNull(); + expect(detectLocalTcpListener(0, () => "unused")).toBeNull(); + }); +}); diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts index c2a2458b512..94d9ddce756 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; + import { dockerCapture as defaultDockerCapture } from "../adapters/docker"; import { findReachableOllamaHost, @@ -8,6 +10,7 @@ import { getWindowsHostOllamaDockerReachabilityArgs, isLocalProviderProbeOutputHealthy, OLLAMA_HOST_DOCKER_INTERNAL, + OLLAMA_PORT, } from "../inference/local"; import type { NvidiaPlatform } from "../inference/nim"; import { detectVllmProfile, type VllmProfile } from "../inference/vllm"; @@ -33,6 +36,7 @@ type DockerCapture = ( args: string[], options?: { env?: NodeJS.ProcessEnv; ignoreError?: boolean; timeout?: number }, ) => string; +type ReadTextFile = (filePath: string) => string | null; export interface InferenceProviderHostGpu { nimCapable?: boolean; @@ -86,10 +90,52 @@ export interface DetectInferenceProviderHostStateDeps { ) => WindowsHostOllamaDockerRequirement; detectVllmProfile: (gpu: InferenceProviderHostGpu | null | undefined) => VllmProfile | null; getLocalProviderAvailabilityEndpoint: (provider: string) => string | null; + detectLocalTcpListener: (port: number) => boolean | null; } const LOCAL_PROVIDER_PROBE_CURL_ARGS = ["--connect-timeout", "2", "--max-time", "5"] as const; +function readTextFileOrNull(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +/** + * Return whether Linux owns a listening TCP socket for `port`, or `null` when + * procfs cannot establish that fact. Windows sockets forwarded into WSL by + * mirrored networking do not belong to a Linux process and are not listed in + * these tables. + */ +export function detectLocalTcpListener( + port: number, + readTextFile: ReadTextFile = readTextFileOrNull, +): boolean | null { + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + const expectedPort = port.toString(16).toUpperCase().padStart(4, "0"); + for (const filePath of ["/proc/net/tcp", "/proc/net/tcp6"]) { + const table = readTextFile(filePath); + if (table === null) return null; + const lines = table.trimEnd().split(/\r?\n/); + const header = lines.shift(); + if (!header?.includes("local_address") || !header.includes("st")) return null; + for (const line of lines) { + if (!line.trim()) continue; + const columns = line.trim().split(/\s+/); + const localAddress = columns[1]; + const state = columns[3]; + const portMatch = /:([0-9A-Fa-f]{4})$/.exec(localAddress ?? ""); + if (!portMatch || typeof state !== "string") return null; + if (state.toUpperCase() === "0A" && portMatch[1].toUpperCase() === expectedPort) { + return true; + } + } + } + return false; +} + function hostCommandExists(commandName: string, runCapture: RunCapture): boolean { return !!runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], { ignoreError: true, @@ -116,6 +162,7 @@ function buildDeps( ((gpu) => detectVllmProfile(gpu as Parameters[0])), getLocalProviderAvailabilityEndpoint: overrides.getLocalProviderAvailabilityEndpoint ?? getLocalProviderAvailabilityEndpoint, + detectLocalTcpListener: overrides.detectLocalTcpListener ?? detectLocalTcpListener, }; } @@ -154,17 +201,21 @@ function probeWindowsOllamaReachable(input: { function maybeWarnAboutDuplicateOllamaDaemons(input: { isWsl: boolean; ollamaHost: string | null; + isWindowsHostOllama: boolean; windowsOllamaReachable: boolean; - runCapture: RunCapture; + wslNetworkingMode: string | null; + hasWslLocalOllamaListener: boolean | null; log: (message?: string) => void; }): void { - if (!input.isWsl || input.ollamaHost !== "127.0.0.1" || !input.windowsOllamaReachable) return; - const networkingMode = input - .runCapture(["wslinfo", "--networking-mode"], { - ignoreError: true, - }) - .trim(); - if (networkingMode === "mirrored") return; + if ( + !input.isWsl || + input.isWindowsHostOllama || + input.ollamaHost !== "127.0.0.1" || + !input.windowsOllamaReachable + ) { + return; + } + if (input.wslNetworkingMode === "mirrored" && input.hasWslLocalOllamaListener !== true) return; input.log(""); input.log(" ⚠ Ollama is running on both WSL and the Windows host."); input.log(" Stop one to avoid duplicated GPU memory and model caches."); @@ -181,7 +232,7 @@ export function detectInferenceProviderHostState( const hasOllama = deps.hostCommandExists("ollama"); const ollamaHost = input.probeOllama === false ? null : deps.findReachableOllamaHost(); const ollamaRunning = ollamaHost !== null; - const isWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL; + const directlyResolvedWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL; const vllmRunning = input.probeVllm === false ? false : probeVllmRunning(deps); const vllmProfile = deps.detectVllmProfile(input.gpu); const hasVllmImage = !!( @@ -204,16 +255,41 @@ export function detectInferenceProviderHostState( ? false : probeWindowsOllamaReachable({ isWsl, - isWindowsHostOllama, + isWindowsHostOllama: directlyResolvedWindowsHostOllama, dockerRequirementSupported: windowsHostOllamaDockerRequirement.supported, dockerCapture: deps.dockerCapture, }); + const wslNetworkingMode = + isWsl && ollamaHost === "127.0.0.1" && windowsOllamaReachable + ? deps + .runCapture(["wslinfo", "--networking-mode"], { ignoreError: true }) + .trim() + .toLowerCase() + : null; + const hasWslLocalOllamaListener = + wslNetworkingMode === "mirrored" ? deps.detectLocalTcpListener(OLLAMA_PORT) : null; + // Under WSL mirrored networking, a live Windows daemon answers through the + // distro's 127.0.0.1 before host.docker.internal is considered. Require + // positive evidence that Windows Ollama is installed and Docker-reachable, + // plus procfs evidence that Linux does not own a listener on the same port. + // Ambiguous evidence and dual-daemon topologies stay on the WSL-local + // version-upgrade/systemd path (#9300). + const isWindowsHostOllama = + directlyResolvedWindowsHostOllama || + (ollamaHost === "127.0.0.1" && + wslNetworkingMode === "mirrored" && + hasWindowsOllama && + windowsOllamaReachable && + hasWslLocalOllamaListener === false); + maybeWarnAboutDuplicateOllamaDaemons({ isWsl, ollamaHost, + isWindowsHostOllama, windowsOllamaReachable, - runCapture: deps.runCapture, + wslNetworkingMode, + hasWslLocalOllamaListener, log, }); const gpuNimCapable = Boolean(input.gpu?.nimCapable); @@ -233,6 +309,7 @@ export function detectInferenceProviderHostState( ollamaHost, platform, isWsl, + isWindowsHostOllama, installedOllamaVersion: input.installedOllamaVersion, runningOllamaVersion: input.runningOllamaVersion, });