diff --git a/src/lib/inference/ollama/windows.test.ts b/src/lib/inference/ollama/windows.test.ts new file mode 100644 index 00000000000..56c234a8f1d --- /dev/null +++ b/src/lib/inference/ollama/windows.test.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import { describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const WINDOWS_DIST_PATH = require.resolve("../../../../dist/lib/inference/ollama/windows"); +const RUNNER_PATH = require.resolve("../../../../dist/lib/runner"); +const childProcess = require("node:child_process"); + +function commandText(command: string | string[]): string { + return Array.isArray(command) ? command.join(" ") : String(command); +} + +function loadWindowsOllamaWithMocks(run: ReturnType, runCapture: ReturnType) { + const runner = require(RUNNER_PATH); + const originalRun = runner.run; + const originalRunCapture = runner.runCapture; + const originalSpawnSync = childProcess.spawnSync; + + delete require.cache[WINDOWS_DIST_PATH]; + runner.run = run; + runner.runCapture = runCapture; + childProcess.spawnSync = vi.fn(() => ({ status: 0 })); + + return { + windows: require(WINDOWS_DIST_PATH), + restore() { + delete require.cache[WINDOWS_DIST_PATH]; + runner.run = originalRun; + runner.runCapture = originalRunCapture; + childProcess.spawnSync = originalSpawnSync; + }, + }; +} + +describe("Windows Ollama helper", () => { + it("falls back from a stale watcher path to the verified installed executable", () => { + const watcherPath = "C:\\Users\\tester\\AppData\\Local\\Programs\\Ollama\\ollama app.exe"; + const installedPath = "C:\\Users\\tester\\AppData\\Local\\Programs\\Ollama\\ollama.exe"; + const launchScripts: string[] = []; + const stopCommands: string[] = []; + + const run = vi.fn((command: string[]) => { + const script = command[2] || ""; + launchScripts.push(script); + if (script.includes(watcherPath)) { + return { status: 1, stderr: "stale watcher path" }; + } + return { status: 0, stderr: "" }; + }); + const runCapture = vi.fn((command: string | string[]) => { + const cmd = commandText(command); + if (cmd.includes("Get-Process 'ollama app'") && cmd.includes("ExpandProperty Path")) { + return watcherPath; + } + if (cmd.includes("Stop-Process")) { + stopCommands.push(cmd); + return ""; + } + if (cmd.includes("host.docker.internal:11434/api/tags")) { + return launchScripts.some((script) => script.includes(installedPath)) + ? JSON.stringify({ models: [] }) + : ""; + } + return ""; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { windows, restore } = loadWindowsOllamaWithMocks(run, runCapture); + + try { + expect(windows.setupWindowsOllamaWith0000Binding({ installedPath })).toBe(true); + } finally { + restore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + expect(run).toHaveBeenCalledTimes(2); + expect(launchScripts[0]).toContain(watcherPath); + expect(launchScripts[1]).toContain(installedPath); + expect(launchScripts[1]).toContain("-ArgumentList 'serve'"); + expect(launchScripts.some((script) => script.includes("Start-Process -FilePath ollama.exe"))).toBe( + false, + ); + expect(stopCommands[0]).toContain("Get-Process 'ollama app'"); + expect(stopCommands[1]).toContain("Get-Process ollama"); + }); +}); diff --git a/src/lib/inference/ollama/windows.ts b/src/lib/inference/ollama/windows.ts index 9d8ad8a1c98..58e2db49b36 100644 --- a/src/lib/inference/ollama/windows.ts +++ b/src/lib/inference/ollama/windows.ts @@ -14,6 +14,10 @@ function sleep(seconds: number): void { spawnSync("sleep", [String(seconds)]); } +function psSingleQuote(value: string): string { + return `'${String(value).replace(/'/g, "''")}'`; +} + // Pre-set OLLAMA_HOST in both User scope (persists across logins) and the // current PowerShell session (inherited by the installer's auto-spawned // ollama_app + daemon) so the new daemon binds 0.0.0.0 from the start. @@ -132,30 +136,69 @@ function awaitWindowsOllamaReady(): boolean { } // Relaunch via the watcher path when available so the tray icon and the -// watcher's auto-restart survive; otherwise launch the daemon directly. -function launchAndAwaitWindowsOllama(watcherPath?: string): boolean { +// watcher's auto-restart survive; fall back through the verified installed +// path and finally refreshed PATH because stale watcher paths are possible. +function launchAndAwaitWindowsOllama( + opts: { watcherPath?: string; installedPath?: string } = {}, +): boolean { console.log(" Starting Ollama on Windows host via WSL interop..."); - const launchScript = watcherPath - ? `$env:OLLAMA_HOST='0.0.0.0:11434'; Start-Process -FilePath '${watcherPath.replace(/'/g, "''")}' -WindowStyle Hidden` - : "$env:OLLAMA_HOST='0.0.0.0:11434'; Start-Process -FilePath ollama.exe -ArgumentList serve -WindowStyle Hidden"; - const result = run(["powershell.exe", "-Command", launchScript], { - ignoreError: true, - suppressOutput: true, + const watcherPath = typeof opts.watcherPath === "string" ? opts.watcherPath.trim() : ""; + const installedPath = typeof opts.installedPath === "string" ? opts.installedPath.trim() : ""; + const launchAttempts: Array<{ label: string; script: string }> = []; + if (watcherPath) { + launchAttempts.push({ + label: "Ollama tray app", + script: + `$env:OLLAMA_HOST='0.0.0.0:11434'; Start-Process -FilePath ${psSingleQuote(watcherPath)} ` + + "-WindowStyle Hidden", + }); + } + if (installedPath) { + launchAttempts.push({ + label: "verified ollama.exe", + script: + `$env:OLLAMA_HOST='0.0.0.0:11434'; Start-Process -FilePath ${psSingleQuote(installedPath)} ` + + "-ArgumentList 'serve' -WindowStyle Hidden", + }); + } + launchAttempts.push({ + label: "refreshed Windows PATH", + script: + "$env:PATH = [Environment]::GetEnvironmentVariable('PATH','Machine') + ';' + [Environment]::GetEnvironmentVariable('PATH','User'); " + + "$env:OLLAMA_HOST='0.0.0.0:11434'; Start-Process -FilePath ollama.exe -ArgumentList serve -WindowStyle Hidden", }); - if (result.status !== 0) { + + for (let i = 0; i < launchAttempts.length; i++) { + const attempt = launchAttempts[i]; + const result = run(["powershell.exe", "-Command", attempt.script], { + ignoreError: true, + suppressOutput: true, + }); + if (result.status === 0 && awaitWindowsOllamaReady()) { + return true; + } + const stderr = String(result.stderr || "").trim(); - console.error( - ` PowerShell launch failed (exit ${result.status})${stderr ? `: ${stderr}` : ""}`, - ); - return false; + const error = result.error?.message; + const detail = + result.status === 0 + ? "Ollama did not become reachable" + : error || `exit ${result.status}${stderr ? `: ${stderr}` : ""}`; + console.error(` PowerShell launch via ${attempt.label} failed: ${detail}`); + if (i < launchAttempts.length - 1) { + killWindowsOllamaProcesses(); + sleep(1); + } } - return awaitWindowsOllamaReady(); + return false; } // Used by start and restart paths to force a 0.0.0.0 binding on an already -// installed Ollama. Install path skips this: the installer's pre-set env -// already lands on the auto-spawned daemon. -function setupWindowsOllamaWith0000Binding(opts: { announceStop?: boolean } = {}): boolean { +// installed Ollama. Fresh install fallback passes installedPath to avoid +// relying on a newly-mutated Windows PATH from this process. +function setupWindowsOllamaWith0000Binding( + opts: { announceStop?: boolean; installedPath?: string } = {}, +): boolean { const watcherPath = captureWindowsOllamaWatcherPath(); persistOllamaHostEnvVar(); if (opts.announceStop) { @@ -163,7 +206,10 @@ function setupWindowsOllamaWith0000Binding(opts: { announceStop?: boolean } = {} } killWindowsOllamaProcesses(); sleep(1); - return launchAndAwaitWindowsOllama(watcherPath || undefined); + return launchAndAwaitWindowsOllama({ + watcherPath: watcherPath || undefined, + installedPath: opts.installedPath, + }); } function switchToWindowsOllamaHost(): void { @@ -171,9 +217,22 @@ function switchToWindowsOllamaHost(): void { console.log(` ✓ Using Ollama on host.docker.internal:${OLLAMA_PORT}`); } +function printWindowsOllamaTimeoutDiagnostics(): void { + console.error(" Timed out waiting for Ollama to start on the Windows host."); + console.error(" Diagnose Windows-side Ollama state with:"); + console.error(' powershell.exe -Command "Get-Process ollama* -ErrorAction SilentlyContinue"'); + console.error( + ' powershell.exe -Command "Get-NetTCPConnection -LocalPort 11434 -State Listen -ErrorAction SilentlyContinue"', + ); + console.error( + ` curl -sS --connect-timeout 2 --max-time 5 http://host.docker.internal:${OLLAMA_PORT}/api/tags`, + ); +} + module.exports = { installOllamaOnWindowsHost, awaitWindowsOllamaReady, setupWindowsOllamaWith0000Binding, switchToWindowsOllamaHost, + printWindowsOllamaTimeoutDiagnostics, }; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2985e19dafe..1610b30ba14 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -136,6 +136,7 @@ const { awaitWindowsOllamaReady, setupWindowsOllamaWith0000Binding, switchToWindowsOllamaHost, + printWindowsOllamaTimeoutDiagnostics, } = require("./inference/ollama/windows"); const { detectVllmProfile, installVllm } = require("./inference/vllm"); const inferenceConfig: typeof import("./inference/config") = require("./inference/config"); @@ -6934,8 +6935,6 @@ async function setupNim( if (isSwitch) { switchToWindowsOllamaHost(); } else if (isInstall) { - // installOllamaOnWindowsHost pre-sets the env so the auto-spawned - // daemon already binds 0.0.0.0; no kill+relaunch needed. const installResult = await installOllamaOnWindowsHost(); if (!installResult.ok) { console.error( @@ -6945,14 +6944,21 @@ async function setupNim( continue selectionLoop; } if (!awaitWindowsOllamaReady()) { - console.error(" Timed out waiting for Ollama to start on the Windows host."); - if (isNonInteractive()) process.exit(1); - continue selectionLoop; + console.log(" Installer did not leave a reachable Ollama daemon; restarting it..."); + if ( + !setupWindowsOllamaWith0000Binding({ + installedPath: installResult.path, + }) + ) { + printWindowsOllamaTimeoutDiagnostics(); + if (isNonInteractive()) process.exit(1); + continue selectionLoop; + } } console.log(` ✓ Using Ollama on host.docker.internal:${OLLAMA_PORT}`); } else { if (!setupWindowsOllamaWith0000Binding({ announceStop: isRestart })) { - console.error(" Timed out waiting for Ollama to start on the Windows host."); + printWindowsOllamaTimeoutDiagnostics(); if (isNonInteractive()) process.exit(1); continue selectionLoop; } diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 4a206025a97..d0a0bfe3668 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -4082,6 +4082,166 @@ const { setupNim } = require(${onboardPath}); ); }); + it("restarts Windows-host Ollama after install when installer auto-start is not reachable", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-windows-ollama-install-restart-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "windows-ollama-install-restart-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const localPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "inference", "local.js")); + const windowsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "ollama", "windows.js"), + ); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='${OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ -n "$outfile" ]; then + printf '%s' "$body" > "$outfile" + printf '%s' "$status" +else + printf '%s' "$body" +fi +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const platform = require(${platformPath}); +platform.isWsl = () => true; + +const installedPath = "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; +const installCalls = []; +const awaitCalls = []; +const restartCalls = []; +const updates = []; +const runCommands = []; +credentials.prompt = async () => ""; +credentials.ensureApiKey = async () => {}; +registry.updateSandbox = (_name, update) => updates.push(update); +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("command -v ollama")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Command ollama.exe")) return ""; + if (cmd.includes("api/tags")) { + if (restartCalls.length > 0) { + return JSON.stringify({ models: [{ name: "qwen3:8b" }] }); + } + return ""; + } + if (cmd.includes("api/show")) return JSON.stringify({ capabilities: ["completion", "tools"] }); + if (cmd.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; +runner.run = (command) => { + runCommands.push(Array.isArray(command) ? command.join(" ") : String(command)); + return { status: 0 }; +}; +runner.runShell = (command) => { + runCommands.push(command); + return { status: 0 }; +}; + +const local = require(${localPath}); +local.resetOllamaHostCache(); + +const windows = require(${windowsPath}); +windows.installOllamaOnWindowsHost = async () => { + installCalls.push(true); + return { ok: true, path: installedPath }; +}; +windows.awaitWindowsOllamaReady = () => { + awaitCalls.push(true); + return false; +}; +windows.setupWindowsOllamaWith0000Binding = (opts) => { + restartCalls.push(opts || {}); + local.setResolvedOllamaHost(local.OLLAMA_HOST_DOCKER_INTERNAL); + return true; +}; +windows.switchToWindowsOllamaHost = () => { + local.setResolvedOllamaHost(local.OLLAMA_HOST_DOCKER_INTERNAL); +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim("windows-install-restart-test", null); + originalLog(JSON.stringify({ + result, + installCalls, + awaitCalls, + restartCalls, + updates, + lines, + runCommands, + })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "install-windows-ollama", + NEMOCLAW_MODEL: "qwen3:8b", + NEMOCLAW_YES: "1", + }, + }); + + assert.equal(result.status, 0, `Process failed: ${result.stderr}`); + assert.notEqual(result.stdout.trim(), "", result.stderr); + const payload = JSON.parse(result.stdout.trim()); + + assert.equal(payload.result.provider, "ollama-local"); + assert.equal(payload.result.model, "qwen3:8b"); + assert.equal(payload.installCalls.length, 1); + assert.equal(payload.awaitCalls.length, 1); + assert.deepEqual(payload.restartCalls, [ + { installedPath: "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe" }, + ]); + assert.ok( + payload.lines.some((line: string) => + line.includes("Using Ollama on host.docker.internal:11434"), + ), + ); + }); + it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(