diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 139a023e11..17b8cc27cd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -187,13 +187,15 @@ const { getOllamaModelOptions, getOllamaWarmupCommand, OLLAMA_HOST_DOCKER_INTERNAL, - validateOllamaPortConfiguration, validateOllamaModel, validateLocalProvider, } = localInference; -const { resolveOllamaInstallMenuEntry, assertOllamaUpgradeApplied } = require( - "./onboard/ollama-install-menu", -); +const { + checkOllamaPortsOrWarn, + resolveOllamaInstallMenuEntry, + resolveRunningOllamaMenuEntry, + assertOllamaUpgradeApplied, +} = require("./onboard/ollama-install-menu"); const { ensureOllamaAuthProxy, getOllamaProxyToken, @@ -288,7 +290,9 @@ const platformUtils: typeof import("./platform") = require("./platform"); const { isWsl, shouldPatchCoredns } = platformUtils; const { getContainerRuntime, + getWindowsHostOllamaDockerRequirement, repairLocalInferenceSystemdOverrideOrExit, + rejectUnsupportedWindowsHostOllama, shouldFrontOllamaWithProxy, }: typeof import("./onboard/local-inference-topology") = require("./onboard/local-inference-topology"); const { resolveOpenshell } = require("./adapters/openshell/resolve"); @@ -3994,6 +3998,7 @@ async function setupNim( const hasOllama = hostCommandExists("ollama"); const ollamaHost = findReachableOllamaHost(); const ollamaRunning = ollamaHost !== null; + const isWindowsHostOllama = ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL; const vllmRunning = !!runCapture( ["curl", "-sf", ...localProbeCurlArgs, `http://127.0.0.1:${VLLM_PORT}/v1/models`], { ignoreError: true }, @@ -4007,6 +4012,9 @@ async function setupNim( vllmProfile && docker.dockerCapture(["images", "-q", vllmProfile.image], { ignoreError: true }).trim() ); + const windowsHostOllamaDockerRequirement = getWindowsHostOllamaDockerRequirement( + isWsl() ? getContainerRuntime() : null, + ); // Probed even when WSL has its own Ollama: users may prefer the Windows // instance for GPU access and a unified model cache. See // src/lib/onboard/windows-host-ollama.ts for process/path fallback details. @@ -4019,7 +4027,7 @@ async function setupNim( // on 127.0.0.1, Windows-host may also be running on 0.0.0.0 and we want // to offer a "switch" without restarting anything. let windowsOllamaReachable = false; - if (isWsl() && ollamaHost !== "host.docker.internal") { + if (isWsl() && !isWindowsHostOllama) { windowsOllamaReachable = !!runCapture( ["curl", "-sf", ...localProbeCurlArgs, `http://host.docker.internal:${OLLAMA_PORT}/api/tags`], { ignoreError: true }, @@ -4052,26 +4060,17 @@ async function setupNim( options.push({ key: "anthropic", label: "Anthropic" }); options.push({ key: "anthropicCompatible", label: "Other Anthropic-compatible endpoint" }); options.push({ key: "gemini", label: "Google Gemini" }); - if (hasOllama || ollamaRunning) { - let hostDisplay: string; - if (ollamaHost === "host.docker.internal") { - hostDisplay = `Windows host:${OLLAMA_PORT}`; - } else if (isWsl()) { - hostDisplay = `WSL:${OLLAMA_PORT}`; - } else { - hostDisplay = `localhost:${OLLAMA_PORT}`; - } - // On WSL the Windows-host entry (Use/Restart/Start, or Install) always - // carries the suggestion instead, since Windows-host is preferred. - const wslOllamaSuggested = - ollamaRunning && (ollamaHost === "host.docker.internal" || !isWsl()); - options.push({ - key: "ollama", - label: - `Local Ollama (${hostDisplay})${ollamaRunning ? " — running" : ""}` + - (wslOllamaSuggested ? " (suggested)" : ""), - }); - } + const runningOllamaMenu = resolveRunningOllamaMenuEntry({ + hasOllama, + ollamaRunning, + ollamaHost, + isWsl: isWsl(), + ollamaPort: OLLAMA_PORT, + windowsHostLabelSuffix: windowsHostOllamaDockerRequirement.supported + ? "" + : windowsHostOllamaDockerRequirement.labelSuffix, + }); + if (runningOllamaMenu) options.push(runningOllamaMenu); if (EXPERIMENTAL && gpu && gpu.nimCapable) { options.push({ key: "nim-local", label: "Local NVIDIA NIM [experimental]" }); } @@ -4086,16 +4085,14 @@ async function setupNim( ); // Skipped when Windows-host already won the cache: the running entry // above already covers that case. - if (hasWindowsOllama && ollamaHost !== "host.docker.internal") { - let windowsOllamaLabel: string; - if (windowsOllamaReachable) { - windowsOllamaLabel = "Use Ollama on Windows host - running (suggested)"; - } else if (winOllamaLoopbackOnly) { - windowsOllamaLabel = "Restart Ollama on Windows host with 0.0.0.0 binding (suggested)"; - } else { - windowsOllamaLabel = "Start Ollama on Windows host (suggested)"; - } - options.push({ key: "start-windows-ollama", label: windowsOllamaLabel }); + if (hasWindowsOllama && !isWindowsHostOllama) { + options.push({ + key: "start-windows-ollama", + label: windowsHostOllamaDockerRequirement.startLabel({ + reachable: windowsOllamaReachable, + loopbackOnly: winOllamaLoopbackOnly, + }), + }); } // On WSL, always offer to install Ollama on the Windows host when not // already installed, regardless of WSL Ollama state — users may prefer the @@ -4103,7 +4100,7 @@ async function setupNim( if (isWsl() && !hasWindowsOllama) { options.push({ key: "install-windows-ollama", - label: "Install Ollama on Windows host (recommended)", + label: windowsHostOllamaDockerRequirement.installLabel, }); } const ollamaInstallMenu = resolveOllamaInstallMenuEntry({ @@ -4127,18 +4124,14 @@ async function setupNim( options.push({ key: providerKey, label: remoteConfig.label }); } - function checkOllamaPortsOrWarn(): boolean { - const portValidation = validateOllamaPortConfiguration(); - if (!portValidation.ok) { - console.error(` ${portValidation.message}`); - if (isNonInteractive()) { - process.exit(1); - } - console.log(" Choose a different local inference provider or fix the port settings."); - console.log(""); - return false; - } - return true; + function rejectWindowsHostOllama(providerKey: string, windowsHostSelected: boolean): boolean { + return rejectUnsupportedWindowsHostOllama( + windowsHostOllamaDockerRequirement, + providerKey, + windowsHostSelected, + isNonInteractive, + abortNonInteractive, + ); } if (options.length > 1) { @@ -4165,11 +4158,7 @@ async function setupNim( // (so the menu's "ollama" key points there), the availability // check below would pass and silently swap the daemon. Detect // and fail-loud with a hint. - if ( - isWsl() && - recordedProvider === "ollama-local" && - ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL - ) { + if (isWsl() && recordedProvider === "ollama-local" && isWindowsHostOllama) { console.error( ` Recorded provider '${recordedProvider}' (WSL Ollama) is not available in this environment.`, ); @@ -4215,8 +4204,16 @@ async function setupNim( } selected = options.find((o) => o.key === providerKey); if (!selected) { + if ( + (providerKey === "start-windows-ollama" || + providerKey === "install-windows-ollama") && + rejectWindowsHostOllama(providerKey, isWindowsHostOllama) + ) { + process.exit(1); + } selected = resolveProviderKeyFallback(options, providerKey, { - isWindowsHostOllama: ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL, + canUseWindowsHostOllama: + isWindowsHostOllama && windowsHostOllamaDockerRequirement.supported, }); if (!selected) { if (providerKey === "hermesProvider" && !hermesProviderAvailable) { @@ -4848,7 +4845,10 @@ async function setupNim( } break; } else if (selected.key === "ollama") { - if (!checkOllamaPortsOrWarn()) continue selectionLoop; + if (rejectWindowsHostOllama(selected.key, isWindowsHostOllama)) { + continue selectionLoop; + } + if (!checkOllamaPortsOrWarn({ isNonInteractive })) continue selectionLoop; let ollamaReady = ollamaRunning; const overrideState = ensureOllamaLoopbackSystemdOverride({ isNonInteractive }); if (overrideState === "ready") { @@ -4900,11 +4900,11 @@ async function setupNim( preferredInferenceApi = "openai-completions"; } break; - } else if ( - selected.key === "start-windows-ollama" || - selected.key === "install-windows-ollama" - ) { - if (!checkOllamaPortsOrWarn()) continue selectionLoop; + } else if (["start-windows-ollama", "install-windows-ollama"].includes(selected.key)) { + if (rejectWindowsHostOllama(selected.key, true)) { + continue selectionLoop; + } + if (!checkOllamaPortsOrWarn({ isNonInteractive })) continue selectionLoop; const isInstall = selected.key === "install-windows-ollama"; const isSwitch = !isInstall && windowsOllamaReachable; const isRestart = !isInstall && !isSwitch && winOllamaLoopbackOnly; @@ -4987,7 +4987,7 @@ async function setupNim( } break; } else if (selected.key === "install-ollama") { - if (!checkOllamaPortsOrWarn()) continue selectionLoop; + if (!checkOllamaPortsOrWarn({ isNonInteractive })) continue selectionLoop; const isUpgrade = ollamaInstallMenu.hasUpgradableOllama; const installResult = process.platform === "darwin" ? installOllamaOnMacOS({ isNonInteractive, isUpgrade }) diff --git a/src/lib/onboard/local-inference-topology.ts b/src/lib/onboard/local-inference-topology.ts index 03320c90bb..0b9db9485a 100644 --- a/src/lib/onboard/local-inference-topology.ts +++ b/src/lib/onboard/local-inference-topology.ts @@ -17,6 +17,130 @@ export function getContainerRuntime(): ContainerRuntime { ); } +function describeContainerRuntime(runtime: ContainerRuntime): string { + switch (runtime) { + case "docker-desktop": + return "Docker Desktop"; + case "docker": + return "native Docker Engine"; + case "podman": + return "Podman"; + case "colima": + return "Colima"; + case "unknown": + return "unknown container runtime"; + default: + const _exhaustive: never = runtime; + return _exhaustive; + } +} + +type WindowsHostOllamaStartLabel = (opts: { + reachable: boolean; + loopbackOnly: boolean; +}) => string; + +type WindowsHostOllamaReject = ( + providerKey: string, + isNonInteractive: () => boolean, + abortNonInteractive: (reason: string, hint?: string) => never, +) => boolean; + +export type WindowsHostOllamaDockerRequirement = + | { + supported: true; + detectedRuntime: string; + installLabel: string; + startLabel: WindowsHostOllamaStartLabel; + } + | { + supported: false; + detectedRuntime: string; + labelSuffix: string; + installLabel: string; + startLabel: WindowsHostOllamaStartLabel; + reason: string; + hint: string; + reject: WindowsHostOllamaReject; + }; + +export function getWindowsHostOllamaDockerRequirement( + runtime: ContainerRuntime | null, +): WindowsHostOllamaDockerRequirement { + if (runtime === null) { + return { + supported: true, + detectedRuntime: "not applicable", + installLabel: "Install Ollama on Windows host (recommended)", + startLabel(opts) { + if (opts.reachable) return "Use Ollama on Windows host - running (suggested)"; + if (opts.loopbackOnly) return "Restart Ollama on Windows host with 0.0.0.0 binding (suggested)"; + return "Start Ollama on Windows host (suggested)"; + }, + }; + } + const detectedRuntime = describeContainerRuntime(runtime); + if (runtime === "docker-desktop") { + return { + supported: true, + detectedRuntime, + installLabel: "Install Ollama on Windows host (recommended)", + startLabel(opts) { + if (opts.reachable) return "Use Ollama on Windows host - running (suggested)"; + if (opts.loopbackOnly) return "Restart Ollama on Windows host with 0.0.0.0 binding (suggested)"; + return "Start Ollama on Windows host (suggested)"; + }, + }; + } + + const labelSuffix = " (requires Docker Desktop WSL integration)"; + const reason = + `Windows-host Ollama requires Docker Desktop WSL integration; ` + + `detected ${detectedRuntime} in WSL.`; + const hint = "Choose WSL-local Ollama, or enable Docker Desktop WSL integration for this distro."; + return { + supported: false, + detectedRuntime, + labelSuffix, + installLabel: `Install Ollama on Windows host${labelSuffix}`, + startLabel(opts) { + if (opts.reachable) return `Use Ollama on Windows host - running${labelSuffix}`; + if (opts.loopbackOnly) { + return `Restart Ollama on Windows host with 0.0.0.0 binding${labelSuffix}`; + } + return `Start Ollama on Windows host${labelSuffix}`; + }, + reason, + hint, + reject(providerKey, isNonInteractive, abortNonInteractive) { + if (isNonInteractive()) { + abortNonInteractive( + `${providerKey} requires Docker Desktop WSL integration; detected ${detectedRuntime} in WSL.`, + hint, + ); + } + console.error(` ${reason}`); + console.error( + " Native Docker-in-WSL cannot reliably route sandbox containers to Windows-host Ollama.", + ); + console.error(` ${hint}`); + console.log(""); + return true; + }, + }; +} + +export function rejectUnsupportedWindowsHostOllama( + requirement: WindowsHostOllamaDockerRequirement, + providerKey: string, + isWindowsHostOllama: boolean, + isNonInteractive: () => boolean, + abortNonInteractive: (reason: string, hint?: string) => never, +): boolean { + if (!isWindowsHostOllama || requirement.supported) return false; + return requirement.reject(providerKey, isNonInteractive, abortNonInteractive); +} + // True when the sandbox container needs the local Ollama auth proxy in front // of raw Ollama. False only under Docker Desktop on WSL, where the docker- // desktop VM publishes the host's 127.0.0.1 back into containers through diff --git a/src/lib/onboard/ollama-install-menu.test.ts b/src/lib/onboard/ollama-install-menu.test.ts index 224871b0b3..fe92bff90c 100644 --- a/src/lib/onboard/ollama-install-menu.test.ts +++ b/src/lib/onboard/ollama-install-menu.test.ts @@ -6,11 +6,31 @@ import { describe, expect, it } from "vitest"; import { assertOllamaUpgradeApplied, resolveOllamaInstallMenuEntry, + resolveRunningOllamaMenuEntry, } from "../../../dist/lib/onboard/ollama-install-menu"; import { MIN_OLLAMA_VERSION } from "../../../dist/lib/inference/ollama-version"; const LINUX_NON_WSL = { platform: "linux" as const, isWsl: false }; +describe("resolveRunningOllamaMenuEntry", () => { + it("labels unsupported Windows-host Ollama without suggesting it", () => { + const entry = resolveRunningOllamaMenuEntry({ + hasOllama: false, + ollamaRunning: true, + ollamaHost: "host.docker.internal", + isWsl: true, + ollamaPort: 11434, + windowsHostLabelSuffix: " (requires Docker Desktop WSL integration)", + }); + + expect(entry).toEqual({ + key: "ollama", + label: + "Local Ollama (Windows host:11434) — running (requires Docker Desktop WSL integration)", + }); + }); +}); + describe("resolveOllamaInstallMenuEntry", () => { it("offers a fresh install when no Ollama is present", () => { const result = resolveOllamaInstallMenuEntry({ diff --git a/src/lib/onboard/ollama-install-menu.ts b/src/lib/onboard/ollama-install-menu.ts index 09a3b9979c..8a68421fa3 100644 --- a/src/lib/onboard/ollama-install-menu.ts +++ b/src/lib/onboard/ollama-install-menu.ts @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { OLLAMA_HOST_DOCKER_INTERNAL } from "../inference/local"; +import { + OLLAMA_HOST_DOCKER_INTERNAL, + validateOllamaPortConfiguration, +} from "../inference/local"; import { OLLAMA_PORT } from "../core/ports"; import { getInstalledOllamaVersion, @@ -38,6 +41,54 @@ function isLocalOllamaHost(host: string | null | undefined): boolean { return Boolean(host) && host !== OLLAMA_HOST_DOCKER_INTERNAL; } +export interface RunningOllamaMenuInput { + hasOllama: boolean; + ollamaRunning: boolean; + isWsl: boolean; + ollamaPort: number; + ollamaHost?: string | null; + windowsHostLabelSuffix?: string; +} + +export function checkOllamaPortsOrWarn(input: { isNonInteractive: () => boolean }): boolean { + const portValidation = validateOllamaPortConfiguration(); + if (!portValidation.ok) { + console.error(` ${portValidation.message}`); + if (input.isNonInteractive()) { + process.exit(1); + } + console.log(" Choose a different local inference provider or fix the port settings."); + console.log(""); + return false; + } + return true; +} + +export function resolveRunningOllamaMenuEntry( + input: RunningOllamaMenuInput, +): { key: "ollama"; label: string } | null { + if (!input.hasOllama && !input.ollamaRunning) return null; + let hostDisplay: string; + if (input.ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL) { + hostDisplay = `Windows host:${input.ollamaPort}`; + } else if (input.isWsl) { + hostDisplay = `WSL:${input.ollamaPort}`; + } else { + hostDisplay = `localhost:${input.ollamaPort}`; + } + const windowsHostSuffix = + input.ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL ? input.windowsHostLabelSuffix || "" : ""; + const suggested = + input.ollamaRunning && + (input.ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL ? !windowsHostSuffix : !input.isWsl); + const runningSuffix = input.ollamaRunning ? " — running" : ""; + const suggestionSuffix = suggested ? " (suggested)" : ""; + return { + key: "ollama", + label: `Local Ollama (${hostDisplay})${runningSuffix}${windowsHostSuffix}${suggestionSuffix}`, + }; +} + export interface OllamaInstallMenuEntry { key: "install-ollama"; label: string; diff --git a/src/lib/onboard/provider-key-fallback.test.ts b/src/lib/onboard/provider-key-fallback.test.ts index 0591b07fc0..6c20cee239 100644 --- a/src/lib/onboard/provider-key-fallback.test.ts +++ b/src/lib/onboard/provider-key-fallback.test.ts @@ -13,11 +13,15 @@ describe("resolveProviderKeyFallback", () => { const options = [option("ollama"), option("vllm")]; assert.equal( - resolveProviderKeyFallback(options, "install-ollama", { isWindowsHostOllama: false })?.key, + resolveProviderKeyFallback(options, "install-ollama", { + canUseWindowsHostOllama: false, + })?.key, "ollama", ); assert.equal( - resolveProviderKeyFallback(options, "install-vllm", { isWindowsHostOllama: false })?.key, + resolveProviderKeyFallback(options, "install-vllm", { + canUseWindowsHostOllama: false, + })?.key, "vllm", ); }); @@ -27,7 +31,7 @@ describe("resolveProviderKeyFallback", () => { assert.equal( resolveProviderKeyFallback(options, "install-windows-ollama", { - isWindowsHostOllama: false, + canUseWindowsHostOllama: false, })?.key, "start-windows-ollama", ); @@ -38,13 +42,13 @@ describe("resolveProviderKeyFallback", () => { assert.equal( resolveProviderKeyFallback(options, "install-windows-ollama", { - isWindowsHostOllama: true, + canUseWindowsHostOllama: true, })?.key, "ollama", ); assert.equal( resolveProviderKeyFallback(options, "start-windows-ollama", { - isWindowsHostOllama: true, + canUseWindowsHostOllama: true, })?.key, "ollama", ); @@ -55,13 +59,13 @@ describe("resolveProviderKeyFallback", () => { assert.equal( resolveProviderKeyFallback(options, "install-windows-ollama", { - isWindowsHostOllama: false, + canUseWindowsHostOllama: false, }), undefined, ); assert.equal( resolveProviderKeyFallback(options, "start-windows-ollama", { - isWindowsHostOllama: false, + canUseWindowsHostOllama: false, }), undefined, ); diff --git a/src/lib/onboard/provider-key-fallback.ts b/src/lib/onboard/provider-key-fallback.ts index 4d5f7708ca..3f1a573cf0 100644 --- a/src/lib/onboard/provider-key-fallback.ts +++ b/src/lib/onboard/provider-key-fallback.ts @@ -7,7 +7,7 @@ export interface ProviderOption { } export interface ProviderKeyFallbackContext { - isWindowsHostOllama: boolean; + canUseWindowsHostOllama: boolean; } export function resolveProviderKeyFallback( @@ -29,10 +29,10 @@ export function resolveProviderKeyFallback( // entries that still point at the Windows host. return ( find("start-windows-ollama") || - (context.isWindowsHostOllama ? find("ollama") : undefined) + (context.canUseWindowsHostOllama ? find("ollama") : undefined) ); case "start-windows-ollama": - return context.isWindowsHostOllama ? find("ollama") : undefined; + return context.canUseWindowsHostOllama ? find("ollama") : undefined; case "ollama": return find("install-ollama"); default: diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 402e0fdaed..662c3d170a 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -6477,6 +6477,9 @@ const { setupNim } = require(${onboardPath}); 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 topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), @@ -6510,7 +6513,9 @@ const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const registry = require(${registryPath}); const platform = require(${platformPath}); +const topology = require(${topologyPath}); platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker-desktop"; const installedPath = "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; const installCalls = []; @@ -6547,6 +6552,7 @@ runner.runShell = (command) => { const local = require(${localPath}); local.resetOllamaHostCache(); +local.getOllamaModelOptions = () => ["qwen3:8b"]; const windows = require(${windowsPath}); windows.installOllamaOnWindowsHost = async () => { @@ -6625,6 +6631,309 @@ const { setupNim } = require(${onboardPath}); ); }); + it("shows Windows-host Ollama in the menu with a Docker Desktop requirement on native Docker WSL", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-windows-ollama-native-docker-menu-"), + ); + const scriptPath = path.join(tmpDir, "windows-ollama-native-docker-menu-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 platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.js"), + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const topology = require(${topologyPath}); + +platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker"; +credentials.ensureApiKey = async () => {}; +const messages = []; +credentials.prompt = async (message) => { + messages.push(message); + if (/Choose \[/.test(message)) throw new Error("STOP_AFTER_MENU"); + return ""; +}; +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:11434/api/tags")) return ""; + if (cmd.includes("host.docker.internal:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("docker images")) return ""; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Command ollama.exe")) + return "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Process ollama")) return ""; + return ""; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + try { + await setupNim(null, null); + } catch (error) { + if (!String(error && error.message).includes("STOP_AFTER_MENU")) throw error; + } + originalLog(JSON.stringify({ lines, messages })); + } 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, + NEMOCLAW_NON_INTERACTIVE: "", + NEMOCLAW_PROVIDER: "", + NEMOCLAW_MODEL: "", + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.stdout.trim(), "", result.stderr); + const payload = JSON.parse(result.stdout.trim()); + const menuOutput = payload.lines.join("\n"); + + assert.match( + menuOutput, + /Start Ollama on Windows host \(requires Docker Desktop WSL integration\)/, + ); + assert.doesNotMatch(menuOutput, /Start Ollama on Windows host \(suggested\)/); + }); + + it("rejects Windows-host Ollama providers on native Docker WSL before launching Ollama", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const scenarios = [ + { provider: "start-windows-ollama", hasWindowsOllama: true }, + { provider: "install-windows-ollama", hasWindowsOllama: false }, + ]; + + for (const scenario of scenarios) { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `nemoclaw-onboard-${scenario.provider}-native-docker-`), + ); + const scriptPath = path.join(tmpDir, `${scenario.provider}-native-docker-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 platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.js"), + ); + const windowsPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "ollama", "windows.js"), + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const topology = require(${topologyPath}); +const windows = require(${windowsPath}); +const hasWindowsOllama = ${JSON.stringify(scenario.hasWindowsOllama)}; + +platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker"; +credentials.prompt = async () => { + throw new Error("Unexpected prompt in non-interactive test"); +}; +credentials.ensureApiKey = async () => {}; +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:11434/api/tags")) return ""; + if (cmd.includes("host.docker.internal:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("docker images")) return ""; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Command ollama.exe")) { + return hasWindowsOllama + ? "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe" + : ""; + } + if (cmd.includes("powershell.exe") && cmd.includes("Get-Process ollama")) return ""; + return ""; +}; +runner.run = () => ({ status: 0 }); +runner.runShell = () => ({ status: 0 }); +windows.installOllamaOnWindowsHost = async () => { + console.error("WINDOWS_INSTALL_CALLED"); + return { + ok: true, + path: "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe", + }; +}; +windows.setupWindowsOllamaWith0000Binding = () => { + console.error("WINDOWS_SETUP_CALLED"); + return true; +}; +windows.switchToWindowsOllamaHost = () => { + console.error("WINDOWS_SWITCH_CALLED"); +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim(null, null); +})().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, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: scenario.provider, + NEMOCLAW_MODEL: "qwen3:8b", + NEMOCLAW_YES: "1", + }, + }); + + assert.equal(result.status, 1, `${scenario.provider} unexpectedly passed`); + assert.match(result.stderr, /\[non-interactive\] Aborting:/); + assert.match(result.stderr, new RegExp(`${scenario.provider} requires Docker Desktop`)); + assert.match(result.stderr, /Choose WSL-local Ollama/); + assert.doesNotMatch( + result.stderr, + /WINDOWS_INSTALL_CALLED|WINDOWS_SETUP_CALLED|WINDOWS_SWITCH_CALLED/, + ); + } + }); + + it("rejects reachable Windows-host Ollama on native Docker WSL through generic and fallback paths", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const scenarios = ["ollama", "start-windows-ollama", "install-windows-ollama"]; + + for (const provider of scenarios) { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `nemoclaw-onboard-${provider}-reachable-native-docker-`), + ); + const scriptPath = path.join(tmpDir, `${provider}-reachable-native-docker-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 platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), + ); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const topology = require(${topologyPath}); +const local = require(${localPath}); +const windows = require(${windowsPath}); + +platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker"; +credentials.prompt = async () => { + throw new Error("Unexpected prompt in non-interactive test"); +}; +credentials.ensureApiKey = async () => {}; +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("docker images")) return ""; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Command ollama.exe")) + return "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Process ollama")) return ""; + if (cmd.includes("api/tags")) return JSON.stringify({ models: [{ name: "qwen3:8b" }] }); + return ""; +}; +runner.run = () => ({ status: 0 }); +runner.runShell = () => ({ status: 0 }); +local.resetOllamaHostCache(); +local.setResolvedOllamaHost(local.OLLAMA_HOST_DOCKER_INTERNAL); +local.getOllamaModelOptions = () => { + console.error("MODEL_SELECTION_REACHED"); + return ["qwen3:8b"]; +}; +windows.installOllamaOnWindowsHost = async () => { + console.error("WINDOWS_INSTALL_CALLED"); + return { + ok: true, + path: "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe", + }; +}; +windows.setupWindowsOllamaWith0000Binding = () => { + console.error("WINDOWS_SETUP_CALLED"); + return true; +}; +windows.switchToWindowsOllamaHost = () => { + console.error("WINDOWS_SWITCH_CALLED"); +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim(null, null); +})().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, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: provider, + NEMOCLAW_MODEL: "qwen3:8b", + NEMOCLAW_YES: "1", + }, + }); + + assert.equal(result.status, 1, `${provider} unexpectedly passed`); + assert.match(result.stderr, /\[non-interactive\] Aborting:/); + assert.match(result.stderr, new RegExp(`${provider} requires Docker Desktop`)); + assert.match(result.stderr, /Choose WSL-local Ollama/); + assert.doesNotMatch( + result.stderr, + /MODEL_SELECTION_REACHED|WINDOWS_INSTALL_CALLED|WINDOWS_SETUP_CALLED|WINDOWS_SWITCH_CALLED/, + ); + } + }); + it("uses the Windows-host start path when install-windows-ollama is requested but Ollama is already installed", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( @@ -6636,6 +6945,9 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), @@ -6668,7 +6980,9 @@ fi const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const topology = require(${topologyPath}); platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker-desktop"; const installedPath = "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; const installCalls = []; @@ -6703,6 +7017,7 @@ runner.runShell = (command) => { const local = require(${localPath}); local.resetOllamaHostCache(); +local.getOllamaModelOptions = () => ["qwen3:8b"]; const windows = require(${windowsPath}); windows.installOllamaOnWindowsHost = async () => { @@ -6789,6 +7104,9 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), @@ -6821,7 +7139,9 @@ fi const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const topology = require(${topologyPath}); platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker-desktop"; const setupCalls = []; const installedPath = "C:/Program Files/Ollama/ollama.exe"; @@ -6853,6 +7173,7 @@ runner.runShell = () => ({ status: 0 }); const local = require(${localPath}); local.resetOllamaHostCache(); +local.getOllamaModelOptions = () => ["qwen3:8b"]; const windows = require(${windowsPath}); windows.installOllamaOnWindowsHost = async () => { @@ -6931,6 +7252,9 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), @@ -6963,7 +7287,9 @@ fi const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const topology = require(${topologyPath}); platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker-desktop"; const setupCalls = []; const installedPath = "C:/Users/tester/AppData/Local/Programs/Ollama/ollama.exe"; @@ -6994,6 +7320,7 @@ runner.runShell = () => ({ status: 0 }); const local = require(${localPath}); local.resetOllamaHostCache(); +local.getOllamaModelOptions = () => ["qwen3:8b"]; const windows = require(${windowsPath}); windows.installOllamaOnWindowsHost = async () => { @@ -7065,6 +7392,9 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const topologyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "local-inference-topology.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"), @@ -7074,7 +7404,9 @@ const { setupNim } = require(${onboardPath}); const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const topology = require(${topologyPath}); platform.isWsl = () => true; +topology.getContainerRuntime = () => "docker-desktop"; credentials.prompt = async () => { throw new Error("Unexpected prompt in non-interactive test");