diff --git a/docs/inference/set-up-ollama.mdx b/docs/inference/set-up-ollama.mdx index 50ca968bb5..3d0c613958 100644 --- a/docs/inference/set-up-ollama.mdx +++ b/docs/inference/set-up-ollama.mdx @@ -29,6 +29,9 @@ When either the Ollama CLI or running daemon is below `0.32.9`, the wizard displ Older versions can return tool calls as message text instead of structured tool calls, which causes onboarding validation to stop. The wizard checks `ollama --version` and `/api/version` on port `11434` independently, so the entry appears when either side is stale. If NemoClaw detects an installed CLI or local running daemon but cannot read its version, onboarding uses the upgrade path instead of reusing it. +On WSL with mirrored networking, Docker Desktop can expose a Windows-host Ollama daemon on the WSL loopback address. +When NemoClaw confirms this topology, it reuses that daemon and does not offer the WSL Linux upgrade, which cannot replace a Windows install. +Upgrade Ollama on Windows instead. On macOS, the wizard uses `brew upgrade ollama` for the platform upgrade path. On Linux, the wizard uses the official `https://ollama.com/install.sh` path and asks it for `0.32.9` by name when the installed binary is stale, because the version the installer calls latest is below the minimum on some hosts. diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts index b680519e1d..5343afb4b5 100644 --- a/src/lib/inference/local.ts +++ b/src/lib/inference/local.ts @@ -362,7 +362,7 @@ export function probeVllmModels( // `{ "models": [...] }`. An empty array is fine — that just means no models // pulled yet — but a body that doesn't parse as JSON-with-array-`models` did // not come from Ollama and the probe should not call it healthy. (#4275) -function isValidOllamaTagsResponseBody(body: string): boolean { +export function isValidOllamaTagsResponseBody(body: string): boolean { if (!body) return false; try { const parsed = JSON.parse(body); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1cae38016e..c8ff127202 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -216,6 +216,7 @@ const { } = require("./onboard/ollama-install-menu"); const { detectInferenceProviderHostState, + detectWindowsDaemonOnWslLoopback, }: typeof import("./onboard/provider-host-state") = require("./onboard/provider-host-state"); const { ensureOllamaAuthProxy, @@ -3424,6 +3425,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { ...reasoningMode.compatibleEndpointReasoningConfigureDeps, ...reasoningMode.compatibleEndpointReasoningClearDeps, repairLocalInferenceSystemdOverrideOrExit, + detectWindowsDaemonOnWslLoopback, isNonInteractive, getOpenshellBinary, needsBedrockRuntimeAdapter: (providerName, url) => providerName === "compatible-anthropic-endpoint" && bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url), diff --git a/src/lib/onboard/local-inference-topology.test.ts b/src/lib/onboard/local-inference-topology.test.ts index 14e2d47543..000f469d4c 100644 --- a/src/lib/onboard/local-inference-topology.test.ts +++ b/src/lib/onboard/local-inference-topology.test.ts @@ -124,6 +124,26 @@ describe("repairLocalInferenceSystemdOverrideOrExit (#6760)", () => { }); } + it("skips Linux service repair for a mirrored Windows daemon on WSL loopback (#9300)", () => { + mockedFindReachableHost.mockReturnValue("127.0.0.1"); + mockedValidateModel.mockReturnValue({ ok: true }); + mockedApplyRuntimeContext.mockReturnValue({ ok: true }); + + repairLocalInferenceSystemdOverrideOrExit({ + provider: "ollama-local", + model: recordedModel, + contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW, + isNonInteractive, + detectWindowsDaemonOnWslLoopbackImpl: () => true, + }); + + // The recorded route points at the Windows daemon, which has no Linux + // service to repair. Model warm-up and validation still run. + expect(mockedEnsureSystemdOverride).not.toHaveBeenCalled(); + expect(mockedValidateModel).toHaveBeenCalledWith(recordedModel); + expect(mockedApplyRuntimeContext).toHaveBeenCalled(); + }); + function expectFailure(run: () => void, message: string): void { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); const exit = vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/src/lib/onboard/local-inference-topology.ts b/src/lib/onboard/local-inference-topology.ts index a42fc4a842..0a16841db5 100644 --- a/src/lib/onboard/local-inference-topology.ts +++ b/src/lib/onboard/local-inference-topology.ts @@ -16,6 +16,37 @@ import { import { type ContainerRuntime, containerCanReachHostLoopback } from "../platform"; import { ensureOllamaLoopbackSystemdOverride } from "./ollama-systemd"; +type TopologyRunCapture = (args: string[], options?: { ignoreError?: boolean }) => string; + +/** + * True when the daemon answering WSL loopback is the Windows host's own + * Ollama. + * + * Mirrored WSL networking shares one loopback interface between Windows and + * the distro, so a single process owns `:11434` for both. `127.0.0.1` and + * `host.docker.internal` therefore cannot reach two different daemons, and a + * valid Ollama answer through the Windows-side probe identifies the process + * that loopback discovery already selected. Neither the Linux installer nor + * Linux service management applies to that daemon (#9300). + */ +export function isWindowsDaemonOnWslLoopback(input: { + isWsl: boolean; + ollamaHost: string | null; + windowsOllamaReachable: boolean; + runCapture: TopologyRunCapture; +}): boolean { + if (!input.isWsl || input.ollamaHost !== "127.0.0.1" || !input.windowsOllamaReachable) { + return false; + } + return ( + input + .runCapture(["wslinfo", "--networking-mode"], { + ignoreError: true, + }) + .trim() === "mirrored" + ); +} + export function getContainerRuntime(): ContainerRuntime { return detectContainerRuntimeFromDockerInfo(); } @@ -156,6 +187,9 @@ export interface RepairLocalInferenceSystemdOverrideOptions { model: string | null | undefined; contextWindowFloor: number; isNonInteractive: () => boolean; + /** Resolve the recorded route's daemon topology. Defaults to false, which + * keeps Linux service repair; `onboard.ts` wires the real detector. */ + detectWindowsDaemonOnWslLoopbackImpl?: () => boolean; } function failOllamaResumeRepair(message: string): never { @@ -173,11 +207,18 @@ export function repairLocalInferenceSystemdOverrideOrExit( const { provider, model, isNonInteractive } = options; if (provider !== "ollama-local") return; const contextWindowFloor = resolveOllamaContextWindowFloor(options.contextWindowFloor); - const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive, contextWindowFloor }); - if (state === "failed") { - failOllamaResumeRepair( - "Ollama systemd restart did not recover after applying the loopback override.", - ); + // A recorded `ollama-local` route carries no topology, so re-detect it. The + // Windows daemon that mirrored WSL networking exposes on loopback has no + // Linux service to repair, and touching a residual `ollama.service` would + // ask for sudo and could take the port from the recorded route (#9300). + const detectTopology = options.detectWindowsDaemonOnWslLoopbackImpl ?? (() => false); + if (!detectTopology()) { + const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive, contextWindowFloor }); + if (state === "failed") { + failOllamaResumeRepair( + "Ollama systemd restart did not recover after applying the loopback override.", + ); + } } if (contextWindowFloor <= MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW) return; if (!model) { diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index deaa29b0af..8b40c25479 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -116,6 +116,7 @@ function createPhases( }, deps: { checkGatewayRouteCompatibility: () => ({ ok: true }), + detectWindowsDaemonOnWslLoopback: () => false, preflightGatewayRouteDiscovery: () => ({ ok: true, requiredModel: null, diff --git a/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts b/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts index 0a1addd878..45c53ebd5b 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts @@ -31,6 +31,7 @@ describe("handleProviderInferenceState Ollama context resume (#6760)", () => { model: "qwen3.5:35b", contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW, isNonInteractive: deps.isNonInteractive, + detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback, }); expect(calls.setupNim).not.toHaveBeenCalled(); expect(routeReady).toHaveBeenCalledWith("nemoclaw", "ollama-local", "qwen3.5:35b"); diff --git a/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts index 89a1680bb6..eb30bd6822 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-route-containment.test.ts @@ -85,6 +85,7 @@ function createDeps() { }; const deps: Options["deps"] = { checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility, + detectWindowsDaemonOnWslLoopback: () => false, preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery, getSandboxRecoveryAuthority: (): "missing" => "missing", withGatewayRouteMutationLock: async (_gatewayName, operation) => await operation(), diff --git a/src/lib/onboard/machine/handlers/provider-inference.test-support.ts b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts index f0fe5a7f1b..56cc193424 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test-support.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test-support.ts @@ -199,6 +199,7 @@ export function createDeps( }, clearCompatibleEndpointReasoningEffort: () => null, repairLocalInferenceSystemdOverrideOrExit: calls.repair, + detectWindowsDaemonOnWslLoopback: () => false, isNonInteractive: () => true, getOpenshellBinary: () => "/usr/bin/openshell", needsBedrockRuntimeAdapter: () => false, diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts index 05237753bc..d8ddc9f2d7 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts @@ -462,6 +462,7 @@ describe("handleProviderInferenceState", () => { model: "llama3.1", contextWindowFloor: 16_384, isNonInteractive: deps.isNonInteractive, + detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback, }); expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", { state: "provider_selection", diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index dd39d539cf..a01ed7b792 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -245,6 +245,7 @@ export interface ProviderInferenceStateOptions { repairLocalInferenceSystemdOverrideOrExit( options: RepairLocalInferenceSystemdOverrideOptions, ): void; + detectWindowsDaemonOnWslLoopback(): boolean; isNonInteractive(): boolean; getOpenshellBinary(): string; needsBedrockRuntimeAdapter(provider: string, endpointUrl: string | null): boolean; @@ -846,7 +847,9 @@ async function configureResumeReasoning( type LocalInferenceRepairDeps = Pick< ProviderInferenceStateOptions["deps"], - "recordRepairEvent" | "repairLocalInferenceSystemdOverrideOrExit" + | "recordRepairEvent" + | "repairLocalInferenceSystemdOverrideOrExit" + | "detectWindowsDaemonOnWslLoopback" >; async function repairResumedLocalInference( @@ -860,6 +863,9 @@ async function repairResumedLocalInference( model, contextWindowFloor: getOllamaContextWindowFloorForAgent(agentName(agent)), isNonInteractive: () => false, + // A recorded route carries no daemon topology, so resume re-detects it and + // skips Linux service repair for a mirrored Windows-host daemon (#9300). + detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback, }; if (provider !== "ollama-local") { deps.repairLocalInferenceSystemdOverrideOrExit(options); diff --git a/src/lib/onboard/ollama-install-menu.test.ts b/src/lib/onboard/ollama-install-menu.test.ts index d7c8e5ef6a..81f52e3565 100644 --- a/src/lib/onboard/ollama-install-menu.test.ts +++ b/src/lib/onboard/ollama-install-menu.test.ts @@ -200,6 +200,43 @@ describe("resolveOllamaInstallMenuEntry", () => { expect(result.entry).toBeNull(); }); + it("does not flag the daemon as upgradable when mirrored WSL networking answers the Windows host on loopback (#9300)", () => { + const result = resolveOllamaInstallMenuEntry({ + hasOllama: true, + ollamaRunning: true, + hasWindowsOllama: true, + windowsHostOllamaSupported: true, + ollamaHost: "127.0.0.1", + windowsDaemonOnWslLoopback: true, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + platform: "linux", + isWsl: true, + }); + expect(result.hasUpgradableOllama).toBe(false); + expect(result.binaryNeedsUpgrade).toBe(false); + expect(result.entry).toBeNull(); + }); + + it("still flags a stale WSL-local daemon on loopback as upgradable (#9300)", () => { + const result = resolveOllamaInstallMenuEntry({ + hasOllama: true, + ollamaRunning: true, + hasWindowsOllama: true, + windowsHostOllamaSupported: true, + ollamaHost: "127.0.0.1", + windowsDaemonOnWslLoopback: false, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + platform: "linux", + isWsl: true, + }); + expect(result.hasUpgradableOllama).toBe(true); + expect(result.entry?.label).toBe( + `Upgrade Ollama (WSL Linux) — upgrade running daemon 0.32.5 to ≥ ${MIN_OLLAMA_VERSION}`, + ); + }); + 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 d7d5048d9f..a2f314c864 100644 --- a/src/lib/onboard/ollama-install-menu.ts +++ b/src/lib/onboard/ollama-install-menu.ts @@ -28,6 +28,11 @@ export interface OllamaInstallMenuInput { * helper skips the daemon-version gate. * Null when no daemon is running locally. */ ollamaHost?: string | null; + /** Whether the daemon answering WSL loopback is the Windows host's own + * Ollama. Mirrored WSL networking shares the loopback interface, so that + * daemon resolves as `127.0.0.1` rather than `host.docker.internal` + * (#9300). Defaults to false. */ + windowsDaemonOnWslLoopback?: boolean; /** Override for tests. Defaults to a live `ollama --version` probe. */ installedOllamaVersion?: string | null; /** Override for tests. Defaults to a live `/api/version` probe on the @@ -138,7 +143,14 @@ 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); + // Mirrored WSL networking answers the Windows host's daemon on + // `127.0.0.1`, so the `host.docker.internal` check above does not recognize + // it. The Linux installer can replace neither that daemon nor the Windows + // `ollama` the WSL PATH exposes through interop, so both version gates stay + // off for this topology (#9300). + const windowsDaemonOnWslLoopback = input.windowsDaemonOnWslLoopback === true; + const daemonProbeApplies = + input.ollamaRunning && isLocalOllamaHost(input.ollamaHost) && !windowsDaemonOnWslLoopback; const runningOllamaVersion = input.runningOllamaVersion !== undefined ? input.runningOllamaVersion @@ -157,7 +169,9 @@ export function resolveOllamaInstallMenuEntry( // 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); + !windowsDaemonOnWslLoopback && + !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 0994b9858a..afa088a2b3 100644 --- a/src/lib/onboard/provider-host-state.test.ts +++ b/src/lib/onboard/provider-host-state.test.ts @@ -185,7 +185,7 @@ describe("detectInferenceProviderHostState", () => { it("detects Windows-host Ollama from Docker Desktop when WSL cannot reach it (#8127)", () => { const logs: string[] = []; const dockerCapture = vi.fn((command: string[]) => - command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "", + command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? '{"models":[]}' : "", ); const deps = buildDeps({ isWsl: vi.fn(() => true), @@ -304,12 +304,12 @@ describe("detectInferenceProviderHostState", () => { installedPath: "C:\\Ollama\\ollama.exe", loopbackOnly: false, })), - runCapture: vi.fn((command) => { - const joined = command.join(" "); - if (joined.includes("wslinfo --networking-mode")) return "mirrored\n"; - return ""; - }), - dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "", + ), + dockerCapture: vi.fn((command) => + command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? '{"models":[]}' : "", + ), }); const state = detectInferenceProviderHostState({ @@ -327,6 +327,159 @@ describe("detectInferenceProviderHostState", () => { expect(logs).toEqual([]); }); + it("keeps a stale Windows daemon on WSL mirrored loopback out of the Linux upgrade (#9300)", () => { + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + hostCommandExists: vi.fn(() => true), + 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 ? '{"models":[]}' : "", + ), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: () => {}, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(false); + expect(state.ollamaInstallMenu.binaryNeedsUpgrade).toBe(false); + expect(state.ollamaInstallMenu.entry).toBeNull(); + expect(state.windowsDaemonOnWslLoopback).toBe(true); + }); + + it("does not treat a loopback daemon as the Windows host without Docker Desktop routing (#9300)", () => { + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + hostCommandExists: vi.fn(() => true), + getContainerRuntime: vi.fn( + () => "docker", + ), + getWindowsHostOllamaDockerRequirement: vi.fn(() => + getWindowsHostOllamaDockerRequirement("docker"), + ), + 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 ? '{"models":[]}' : "", + ), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: () => {}, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + // Native Docker Engine in WSL cannot route a sandbox to Windows-host + // Ollama, so NemoClaw keeps the WSL-local upgrade rather than handing the + // sandbox a daemon it cannot reach. + expect(state.windowsDaemonOnWslLoopback).toBe(false); + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true); + }); + + it("rejects a Windows-host reachability body that is not the Ollama wire format (#9300)", () => { + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + hostCommandExists: vi.fn(() => true), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "", + ), + // A captive proxy or unrelated listener answering 2xx must not become + // evidence that the Windows daemon owns the port. + dockerCapture: vi.fn((command) => + command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "hello" : "", + ), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: () => {}, + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + expect(state.windowsOllamaReachable).toBe(false); + expect(state.windowsDaemonOnWslLoopback).toBe(false); + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true); + }); + + it("still warns about duplicate daemons under WSL NAT networking (#9300)", () => { + const logs: string[] = []; + const deps = buildDeps({ + isWsl: vi.fn(() => true), + findReachableOllamaHost: vi.fn(() => "127.0.0.1"), + hostCommandExists: vi.fn(() => true), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + runCapture: vi.fn((command) => + command.join(" ").includes("wslinfo --networking-mode") ? "nat\n" : "", + ), + dockerCapture: vi.fn((command) => + command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? '{"models":[]}' : "", + ), + }); + + const state = detectInferenceProviderHostState({ + gpu: null, + experimental: false, + platform: "linux", + env: {}, + log: (message = "") => logs.push(message), + installedOllamaVersion: "0.32.5", + runningOllamaVersion: "0.32.5", + deps, + }); + + // Under NAT the two probes reach two separate daemons, so the WSL-local + // upgrade still applies and the duplicate-daemon warning must survive the + // refactor that moved the mirrored check out of the warning helper. + expect(state.windowsDaemonOnWslLoopback).toBe(false); + expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true); + expect(logs.some((line) => line.includes("running on both WSL and the Windows host"))).toBe( + true, + ); + }); + 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(() => ""); diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts index c2a2458b51..599ea449e2 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -7,6 +7,7 @@ import { getLocalProviderAvailabilityEndpoint, getWindowsHostOllamaDockerReachabilityArgs, isLocalProviderProbeOutputHealthy, + isValidOllamaTagsResponseBody, OLLAMA_HOST_DOCKER_INTERNAL, } from "../inference/local"; import type { NvidiaPlatform } from "../inference/nim"; @@ -21,6 +22,7 @@ import { runCapture as defaultRunCapture } from "../runner"; import { getContainerRuntime as defaultGetContainerRuntime, getWindowsHostOllamaDockerRequirement, + isWindowsDaemonOnWslLoopback, type WindowsHostOllamaDockerRequirement, } from "./local-inference-topology"; import { warnAboutArm64NimImageCompatibility } from "./nim-image-compat-warning"; @@ -46,6 +48,11 @@ export interface InferenceProviderHostState { ollamaHost: string | null; ollamaRunning: boolean; isWindowsHostOllama: boolean; + /** Whether the daemon on WSL loopback is the Windows host's own Ollama, + * which mirrored networking exposes at `127.0.0.1` rather than + * `host.docker.internal`. Neither the Linux installer nor Linux service + * management applies to it. Absent means not that topology (#9300). */ + windowsDaemonOnWslLoopback?: boolean; isWsl: boolean; hasWindowsOllama: boolean; winOllamaInstalledPath: string; @@ -146,8 +153,42 @@ function probeWindowsOllamaReachable(input: { dockerCapture: DockerCapture; }): boolean { if (!input.isWsl || input.isWindowsHostOllama || !input.dockerRequirementSupported) return false; - return !!input.dockerCapture(getWindowsHostOllamaDockerReachabilityArgs(), { - ignoreError: true, + // A 2xx body alone does not prove Ollama answered: the same reasoning the + // loopback probe already applies (#4275) holds here, and this result now + // also decides whether a version gate runs (#9300). + return isValidOllamaTagsResponseBody( + input.dockerCapture(getWindowsHostOllamaDockerReachabilityArgs(), { + ignoreError: true, + }), + ); +} + +/** + * Resolve the same topology from scratch, for callers holding no provider host + * snapshot. Resume repair is one: it reads a recorded `ollama-local` route, + * which records no topology (#9300). Each probe short-circuits, so a non-WSL + * host costs one `isWsl` check. + */ +export function detectWindowsDaemonOnWslLoopback( + overrides: Partial = {}, +): boolean { + const deps = buildDeps(overrides); + if (!deps.isWsl()) return false; + const ollamaHost = deps.findReachableOllamaHost(); + if (ollamaHost !== "127.0.0.1") return false; + const windowsOllamaReachable = probeWindowsOllamaReachable({ + isWsl: true, + isWindowsHostOllama: false, + dockerRequirementSupported: deps.getWindowsHostOllamaDockerRequirement( + deps.getContainerRuntime(), + ).supported, + dockerCapture: deps.dockerCapture, + }); + return isWindowsDaemonOnWslLoopback({ + isWsl: true, + ollamaHost, + windowsOllamaReachable, + runCapture: deps.runCapture, }); } @@ -155,16 +196,11 @@ function maybeWarnAboutDuplicateOllamaDaemons(input: { isWsl: boolean; ollamaHost: string | null; windowsOllamaReachable: boolean; - runCapture: RunCapture; + windowsDaemonOnWslLoopback: boolean; 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.windowsDaemonOnWslLoopback) 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."); @@ -209,11 +245,18 @@ export function detectInferenceProviderHostState( dockerCapture: deps.dockerCapture, }); - maybeWarnAboutDuplicateOllamaDaemons({ + const windowsDaemonOnWslLoopback = isWindowsDaemonOnWslLoopback({ isWsl, ollamaHost, windowsOllamaReachable, runCapture: deps.runCapture, + }); + + maybeWarnAboutDuplicateOllamaDaemons({ + isWsl, + ollamaHost, + windowsOllamaReachable, + windowsDaemonOnWslLoopback, log, }); const gpuNimCapable = Boolean(input.gpu?.nimCapable); @@ -231,6 +274,7 @@ export function detectInferenceProviderHostState( windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported && windowsOllamaReachable, ollamaHost, + windowsDaemonOnWslLoopback, platform, isWsl, installedOllamaVersion: input.installedOllamaVersion, @@ -242,6 +286,7 @@ export function detectInferenceProviderHostState( ollamaHost, ollamaRunning, isWindowsHostOllama, + windowsDaemonOnWslLoopback, isWsl, hasWindowsOllama, winOllamaInstalledPath: winOllamaState.installedPath, diff --git a/src/lib/onboard/setup-nim-flow-ollama-topology.test.ts b/src/lib/onboard/setup-nim-flow-ollama-topology.test.ts new file mode 100644 index 0000000000..576cdb1c8f --- /dev/null +++ b/src/lib/onboard/setup-nim-flow-ollama-topology.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { makeDeps, makeHostState, unexpected } from "./__test-helpers__/setup-nim-flow"; +import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; +import { createSetupNim, type SetupNimFlowDeps } from "./setup-nim-flow"; + +describe("setupNim Ollama host topology", () => { + it("skips Linux service management for a mirrored Windows daemon on WSL loopback (#9300)", async () => { + const model = "qwen2.5:1.5b"; + const handleRunningOllamaSelection = vi.fn( + async (_gpu, requestedModel, _recoveredModel, ollamaRunning, state, isWindowsHostOllama) => { + expect(requestedModel).toBe(model); + expect(ollamaRunning).toBe(true); + // The reuse handler reads this argument to decide whether to apply the + // Linux loopback systemd override, which needs sudo and targets a + // service the Windows daemon does not have. + expect(isWindowsHostOllama).toBe(true); + state.model = model; + state.provider = "ollama-local"; + state.endpointUrl = "http://127.0.0.1:11434/v1"; + state.credentialEnv = null; + state.preferredInferenceApi = "openai-completions"; + return "selected"; + }, + ); + const handleInstallOllamaSelection = vi.fn( + async () => unexpected("Ollama install selection"), + ); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => "ollama", + getNonInteractiveModel: () => model, + detectInferenceProviderHostState: () => + makeHostState({ + hasOllama: true, + ollamaHost: "127.0.0.1", + ollamaRunning: true, + isWindowsHostOllama: false, + windowsDaemonOnWslLoopback: true, + isWsl: true, + hasWindowsOllama: true, + windowsOllamaReachable: true, + windowsHostOllamaDockerRequirement: + getWindowsHostOllamaDockerRequirement("docker-desktop"), + }), + handleRunningOllamaSelection, + handleInstallOllamaSelection, + }), + ); + + await setupNim(null, null); + + expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1); + expect(handleInstallOllamaSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 859f7dc5a2..44c1dad1f1 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -230,16 +230,14 @@ function requireSelectedProvider( } function handleSelectedOllama( - deps: Pick< - SetupNimFlowDeps, - "handleInstallOllamaSelection" | "handleRunningOllamaSelection" - >, + deps: Pick, args: { gpu: SetupNimGpu; requestedModel: string | null; recoveredModel: string | null; ollamaRunning: boolean; isWindowsHostOllama: boolean; + windowsDaemonOnWslLoopback: boolean | undefined; state: SetupNimSelectionState; ollamaInstallMenu: InferenceProviderHostState["ollamaInstallMenu"]; }, @@ -253,13 +251,17 @@ function handleSelectedOllama( args.ollamaInstallMenu, ); } + // Mirrored WSL networking answers the Windows daemon on `127.0.0.1`, so + // `isWindowsHostOllama` stays false for it. The reuse handler reads this + // argument to decide whether Linux systemd management applies, and it does + // not apply to a Windows daemon reached either way (#9300). return deps.handleRunningOllamaSelection( args.gpu, args.requestedModel, args.recoveredModel, args.ollamaRunning, args.state, - args.isWindowsHostOllama, + Boolean(args.isWindowsHostOllama || args.windowsDaemonOnWslLoopback), ); } @@ -639,6 +641,7 @@ export function createSetupNim( ollamaHost, ollamaRunning, isWindowsHostOllama, + windowsDaemonOnWslLoopback, isWsl: isWslHost, hasWindowsOllama, winOllamaInstalledPath, @@ -899,6 +902,7 @@ export function createSetupNim( recoveredModel: recoveredFromSandbox ? recoveredModel : null, ollamaRunning, isWindowsHostOllama, + windowsDaemonOnWslLoopback, state, ollamaInstallMenu, });