From 90e40c7d5682c7b4a2b82297438d39d741344b53 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 4 Aug 2026 08:49:44 +0530 Subject: [PATCH 1/2] fix(onboard): reuse a running Ollama daemon on WSL mirrored networking Express install exports NEMOCLAW_PROVIDER=install-windows-ollama for every Windows WSL host running Docker Desktop, without probing whether Ollama already answers. Under WSL mirrored networking the Windows-host daemon answers on the distro's own 127.0.0.1, so findReachableOllamaHost resolves the host as local and isWindowsHostOllama reads false. The provider menu keeps the install entry, the requested key matches it, and onboarding reinstalls through PowerShell interop. When that interop is unreachable, which is the condition that produced the false reading, the run ends with "Install did not produce ollama.exe on PATH". PR #7476 added a !isWindowsHostOllama term to the menu gate. That term is a no-op on this route because isWindowsHostOllama is already false here. Two sites answered a local question from a network address: - resolveRequestedProviderSelection now collapses an install-windows-ollama request to the running ollama entry when a daemon answers, before the key is matched. A Windows-host daemon on an unsupported container runtime still falls through to the existing rejection. - pullOllamaModel now takes the HTTP pull path whenever no local ollama binary exists, not only when the resolved host is host.docker.internal. On mirrored networking the CLI branch ran and failed with "ollama: command not found" against a daemon that answered /api/tags. install-ollama is left alone so the Ollama upgrade entry keeps working. With PowerShell reachable on the same route, onboarding previously selected start-windows-ollama, which stopped and restarted a running daemon and rewrote OLLAMA_HOST to 0.0.0.0:11434 at Windows User scope. It now reuses the daemon and leaves the process and the variable unchanged. Verified on Windows 11 with WSL2 Ubuntu 24.04, Docker Desktop 4.85 and Ollama 0.32.5. The failure reproduces on main under mirrored networking, and onboarding reaches the sandbox build after the change. Reverting the three source files turns exactly the three new tests red. Fixes #7472 Signed-off-by: Hung Le --- src/lib/inference/ollama/proxy.test.ts | 98 ++++++++++++++++++++++ src/lib/inference/ollama/proxy.ts | 12 ++- src/lib/onboard/provider-selection.test.ts | 42 ++++++++++ src/lib/onboard/provider-selection.ts | 40 +++++++++ src/lib/onboard/setup-nim-flow.test.ts | 46 ++++++++++ src/lib/onboard/setup-nim-flow.ts | 1 + 6 files changed, 237 insertions(+), 2 deletions(-) diff --git a/src/lib/inference/ollama/proxy.test.ts b/src/lib/inference/ollama/proxy.test.ts index 628e4c85c0b..c3d163e26b1 100644 --- a/src/lib/inference/ollama/proxy.test.ts +++ b/src/lib/inference/ollama/proxy.test.ts @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { EventEmitter } from "node:events"; import { createRequire } from "node:module"; +import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; const require = createRequire(import.meta.url); @@ -34,6 +36,7 @@ function loadProxyWithMocks(setup: MockSetup): { const originalPrompt = creds.prompt; const originalProbeOllamaModelCapabilities = local.probeOllamaModelCapabilities; const originalRun = runner.run; + const originalRunCapture = runner.runCapture; const originalValidateOllamaModel = local.validateOllamaModel; const spawnSync = setup.pullStatus === undefined @@ -77,6 +80,9 @@ function loadProxyWithMocks(setup: MockSetup): { runCalls.push({ command, options }); return { status: 0 }; }; + // pullOllamaModel asks whether a local `ollama` binary exists before choosing + // the CLI or the HTTP pull path (#7472). These cases exercise the CLI path. + runner.runCapture = () => "/usr/bin/ollama"; delete require.cache[PROXY_DIST]; const proxy = require(PROXY_DIST); @@ -93,6 +99,7 @@ function loadProxyWithMocks(setup: MockSetup): { creds.prompt = originalPrompt; local.probeOllamaModelCapabilities = originalProbeOllamaModelCapabilities; runner.run = originalRun; + runner.runCapture = originalRunCapture; local.validateOllamaModel = originalValidateOllamaModel; spawnSync?.mockRestore(); }, @@ -363,3 +370,94 @@ describe("prepareOllamaModel post-pull discovery", () => { expect(sleeps).toEqual([250, 500, 1_000, 2_000, 2_000, 2_000, 2_000]); }); }); + +describe("pullOllamaModel CLI-vs-HTTP dispatch", () => { + function loadProxyForDispatch(setup: { host: string; hasLocalCli: boolean }) { + const local = require(LOCAL_DIST); + const runner = require(RUNNER_DIST); + const childProcess = require(CHILD_PROCESS_DIST) as typeof import("node:child_process"); + const originalRunCapture = runner.runCapture; + const cliCommands: string[][] = []; + const httpCommands: string[][] = []; + + runner.runCapture = () => (setup.hasLocalCli ? "/usr/bin/ollama" : ""); + + const spawnSync = vi + .spyOn(childProcess, "spawnSync") + .mockImplementation((file: unknown, args: unknown) => { + cliCommands.push([String(file), ...(((args as string[]) ?? []) as string[]).map(String)]); + return { status: 0, signal: null, output: [], pid: 1, stdout: "", stderr: "" } as never; + }); + const spawn = vi.spyOn(childProcess, "spawn").mockImplementation((file: unknown, args) => { + httpCommands.push([String(file), ...(((args as string[]) ?? []) as string[]).map(String)]); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + process.nextTick(() => { + child.stdout.end('{"status":"success"}\n', () => { + setImmediate(() => child.emit("close", 0)); + }); + }); + return child as never; + }); + + local.setResolvedOllamaHost(setup.host); + delete require.cache[PROXY_DIST]; + const proxy = require(PROXY_DIST) as typeof import("./proxy"); + return { + proxy, + cliCommands, + httpCommands, + restore() { + delete require.cache[PROXY_DIST]; + runner.runCapture = originalRunCapture; + spawnSync.mockRestore(); + spawn.mockRestore(); + local.setResolvedOllamaHost(null); + }, + }; + } + + let active: ReturnType | null = null; + + afterEach(() => { + active?.restore(); + active = null; + vi.restoreAllMocks(); + }); + + it("pulls over HTTP when the daemon resolves on the Windows host", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + active = loadProxyForDispatch({ host: "host.docker.internal", hasLocalCli: false }); + + await active.proxy.pullOllamaModel("qwen3.5:9b"); + + expect(active.httpCommands.map((command) => command[0])).toContain("curl"); + expect(active.cliCommands.map((command) => command[0])).not.toContain("bash"); + }); + + it("pulls over HTTP when a loopback daemon has no local ollama binary (#7472)", async () => { + // WSL mirrored networking: the Windows daemon answers on 127.0.0.1, so the + // resolved host reads local while WSL still has no `ollama` to shell out to. + vi.spyOn(console, "log").mockImplementation(() => {}); + active = loadProxyForDispatch({ host: "127.0.0.1", hasLocalCli: false }); + + await active.proxy.pullOllamaModel("qwen3.5:9b"); + + expect(active.httpCommands.map((command) => command[0])).toContain("curl"); + expect(active.cliCommands.map((command) => command[0])).not.toContain("bash"); + }); + + it("keeps the CLI pull when a local ollama binary is installed", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + active = loadProxyForDispatch({ host: "127.0.0.1", hasLocalCli: true }); + + await active.proxy.pullOllamaModel("qwen3.5:9b"); + + expect(active.cliCommands.map((command) => command[0])).toContain("bash"); + expect(active.httpCommands.map((command) => command[0])).not.toContain("curl"); + }); +}); diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index cd206d17488..d25c57e0910 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -859,9 +859,17 @@ function pullOllamaModelViaHttp(model: string): Promise { }); } -// Dispatch to HTTP pull when Ollama was resolved on the Windows host. +function hasLocalOllamaCli(): boolean { + return !!runCapture(["sh", "-c", "command -v ollama"], { ignoreError: true }).trim(); +} + +// Dispatch to HTTP pull whenever there is no local `ollama` binary to invoke. +// Keying on the resolved host alone missed the WSL mirrored networking case, +// where the Windows-host daemon answers on 127.0.0.1 and no Linux binary +// exists — the CLI branch then failed with "ollama: command not found" against +// a daemon that answered `/api/tags` (#7472). async function pullOllamaModel(model: string): Promise { - if (getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL) { + if (getResolvedOllamaHost() === OLLAMA_HOST_DOCKER_INTERNAL || !hasLocalOllamaCli()) { return pullOllamaModelViaHttp(model); } return pullOllamaModelViaCli(model); diff --git a/src/lib/onboard/provider-selection.test.ts b/src/lib/onboard/provider-selection.test.ts index a7a1e4113f3..5deb0812d36 100644 --- a/src/lib/onboard/provider-selection.test.ts +++ b/src/lib/onboard/provider-selection.test.ts @@ -28,6 +28,7 @@ function resolve(overrides: Partial null, readRecordedNimContainer: () => null, readRecordedModel: () => null, @@ -50,6 +51,47 @@ describe("resolveRequestedProviderSelection", () => { } }); + it("reuses a running Ollama daemon instead of reinstalling on the Windows host (#7472)", () => { + // WSL mirrored networking: the Windows daemon answers on loopback first, so + // the probe reads isWindowsHostOllama false and the menu keeps the install + // entry. Express still requests it; the running daemon must win anyway. + const result = resolve({ + options: [option("build"), option("ollama"), option("install-windows-ollama")], + requestedProvider: "install-windows-ollama", + isWsl: true, + isWindowsHostOllama: false, + windowsHostOllamaSupported: true, + ollamaRunning: true, + }); + + assert.equal(selectedKey(result), "ollama"); + }); + + it("still installs on the Windows host when no daemon responds (#7472)", () => { + const result = resolve({ + options: [option("build"), option("install-windows-ollama")], + requestedProvider: "install-windows-ollama", + isWsl: true, + windowsHostOllamaSupported: true, + ollamaRunning: false, + }); + + assert.equal(selectedKey(result), "install-windows-ollama"); + }); + + it("still installs WSL-local Ollama when a daemon is already running (#7472)", () => { + // Guards the narrow scope: widening the collapse to install-ollama would + // skip the upgrade entry resolveOllamaInstallMenuEntry keeps for a + // running-but-stale daemon. + const result = resolve({ + options: [option("build"), option("ollama"), option("install-ollama")], + requestedProvider: "install-ollama", + ollamaRunning: true, + }); + + assert.equal(selectedKey(result), "install-ollama"); + }); + it("recovers the recorded provider and model when no provider was requested", () => { const result = resolve({ options: [option("build"), option("openai")], diff --git a/src/lib/onboard/provider-selection.ts b/src/lib/onboard/provider-selection.ts index 13f2addc833..1f14179db30 100644 --- a/src/lib/onboard/provider-selection.ts +++ b/src/lib/onboard/provider-selection.ts @@ -59,6 +59,12 @@ export interface ResolveRequestedProviderSelectionInput( + input: ResolveRequestedProviderSelectionInput, + providerKey: string, +): T | undefined { + if (providerKey !== "install-windows-ollama" || !input.ollamaRunning) return undefined; + // A daemon reached on the Windows host still needs Docker Desktop WSL + // integration for the sandbox to reach it. Leave that request to the + // unsupported-runtime rejection below instead of silently reusing it. + if (input.isWindowsHostOllama && !input.windowsHostOllamaSupported) return undefined; + return findOption(input.options, "ollama"); +} + export function resolveRequestedProviderSelection( input: ResolveRequestedProviderSelectionInput, ): ProviderSelectionResolution { @@ -146,6 +181,11 @@ export function resolveRequestedProviderSelection( } } + const runningDaemon = collapseWindowsInstallToRunningDaemon(input, providerKey); + if (runningDaemon) { + return { kind: "selected", selected: runningDaemon, recoveredFromSandbox, recoveredModel }; + } + const selected = findOption(input.options, providerKey); if (selected) { return { kind: "selected", selected, recoveredFromSandbox, recoveredModel }; diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts index b661a5fba5e..9b3f979e783 100644 --- a/src/lib/onboard/setup-nim-flow.test.ts +++ b/src/lib/onboard/setup-nim-flow.test.ts @@ -482,6 +482,52 @@ describe("createSetupNim", () => { expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1); }); + it("reuses the running daemon when mirrored networking exposes the Windows host on WSL loopback (#7472)", async () => { + const model = "qwen3.6:35b"; + const handleRunningOllamaSelection = vi.fn( + async (_gpu, requestedModel, _recoveredModel, ollamaRunning, state) => { + expect(requestedModel).toBe(model); + expect(ollamaRunning).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 handleWindowsHostOllamaSelection = vi.fn< + SetupNimFlowDeps["handleWindowsHostOllamaSelection"] + >(async () => unexpected("Windows-host Ollama selection")); + const setupNim = createSetupNim( + makeDeps({ + isNonInteractive: () => true, + getNonInteractiveProvider: () => "install-windows-ollama", + getNonInteractiveModel: () => model, + detectInferenceProviderHostState: () => + makeHostState({ + // Mirrored networking puts the Windows daemon on the distro's own + // loopback, so the first probe candidate answers and the host reads + // as local even though the daemon is the Windows one. + ollamaHost: "127.0.0.1", + ollamaRunning: true, + isWindowsHostOllama: false, + isWsl: true, + hasWindowsOllama: false, + windowsHostOllamaDockerRequirement: + getWindowsHostOllamaDockerRequirement("docker-desktop"), + }), + handleRunningOllamaSelection, + handleWindowsHostOllamaSelection, + }), + ); + + await setupNim(null, null); + + expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1); + expect(handleWindowsHostOllamaSelection).not.toHaveBeenCalled(); + }); + it("applies same-gateway discovery constraints before a provider probe (#6315)", async () => { const providerProbe = vi.fn(); const routeGuard = vi.fn( diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts index 541293fda5d..8f9353b642e 100644 --- a/src/lib/onboard/setup-nim-flow.ts +++ b/src/lib/onboard/setup-nim-flow.ts @@ -423,6 +423,7 @@ export function createSetupNim( remoteProviderConfig: deps.remoteProviderConfig, isWsl: isWslHost, isWindowsHostOllama, + ollamaRunning, windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, hermesProviderAvailable, preferManagedVllmDefault: gpu?.platform === "spark", From 036c92e9b3df50f7235c7bc305fe3ad4581c4b61 Mon Sep 17 00:00:00 2001 From: Hung Le Date: Tue, 4 Aug 2026 12:34:19 +0530 Subject: [PATCH 2/2] fix(onboard): isolate the Windows-host pull predicate in test Signed-off-by: Hung Le --- src/lib/inference/ollama/proxy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/inference/ollama/proxy.test.ts b/src/lib/inference/ollama/proxy.test.ts index c3d163e26b1..ba1bdd358bf 100644 --- a/src/lib/inference/ollama/proxy.test.ts +++ b/src/lib/inference/ollama/proxy.test.ts @@ -431,7 +431,7 @@ describe("pullOllamaModel CLI-vs-HTTP dispatch", () => { it("pulls over HTTP when the daemon resolves on the Windows host", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); - active = loadProxyForDispatch({ host: "host.docker.internal", hasLocalCli: false }); + active = loadProxyForDispatch({ host: "host.docker.internal", hasLocalCli: true }); await active.proxy.pullOllamaModel("qwen3.5:9b");