diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 5d51bdf8a51..9d8d423dd41 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -64,8 +64,9 @@ $ newgrp docker $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` -On DGX Spark and DGX Station, an interactive installer can offer express install after you accept the third-party software notice. +On DGX Spark, DGX Station, and Windows WSL, an interactive installer offers express install after you accept the third-party software notice. Express install switches onboarding to non-interactive mode, allows `sudo` password prompts for required host changes, applies the suggested security policy, and selects the managed local inference path for that platform. +On WSL, express install selects the Windows-host Ollama setup path. Set `NEMOCLAW_NO_EXPRESS=1` to skip the express prompt, or set `NEMOCLAW_PROVIDER` before launching the installer when you want to choose a provider yourself. The installer auto-launches `nemoclaw onboard` when it can locate the freshly-installed binary. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index c419635e8e9..977a34383a4 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,8 +51,9 @@ $ newgrp docker $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` -On DGX Spark and DGX Station, an interactive installer can offer express install after you accept the third-party software notice. +On DGX Spark, DGX Station, and Windows WSL, an interactive installer offers express install after you accept the third-party software notice. Express install switches onboarding to non-interactive mode, allows `sudo` password prompts for required host changes, applies the suggested security policy, and selects the managed local inference path for that platform. +On WSL, express install selects the Windows-host Ollama setup path. Set `NEMOCLAW_NO_EXPRESS=1` to skip the express prompt, or set `NEMOCLAW_PROVIDER` before launching the installer when you want to choose a provider yourself. The installer auto-launches `nemoclaw onboard` when it can locate the freshly-installed binary. diff --git a/docs/get-started/windows-preparation.md b/docs/get-started/windows-preparation.md index c4afede1e8e..a385eb4a52e 100644 --- a/docs/get-started/windows-preparation.md +++ b/docs/get-started/windows-preparation.md @@ -108,6 +108,7 @@ You can also start it yourself beforehand with `ollama serve`. You can also use Ollama for Windows. During onboarding, NemoClaw can use an already-running Windows-host daemon, start or restart an installed daemon, or install Ollama on the Windows host. +If the installer offers express install on WSL, accepting it selects this Windows-host Ollama path automatically. When Ollama runs on the Windows host, NemoClaw detects it from WSL through `host.docker.internal` and pulls missing models through the Ollama HTTP API. Do not run both the Windows and WSL Ollama instances on port `11434` at the same time. Use one instance, or move one of them to a different port before running `nemoclaw onboard`. diff --git a/docs/get-started/windows-preparation.mdx b/docs/get-started/windows-preparation.mdx index c7118d28086..06b7c1eb21f 100644 --- a/docs/get-started/windows-preparation.mdx +++ b/docs/get-started/windows-preparation.mdx @@ -95,6 +95,7 @@ You can also start it yourself beforehand with `ollama serve`. You can also use Ollama for Windows. During onboarding, NemoClaw can use an already-running Windows-host daemon, start or restart an installed daemon, or install Ollama on the Windows host. +If the installer offers express install on WSL, accepting it selects this Windows-host Ollama path automatically. When Ollama runs on the Windows host, NemoClaw detects it from WSL through `host.docker.internal` and pulls missing models through the Ollama HTTP API. Do not run both the Windows and WSL Ollama instances on port `11434` at the same time. Use one instance, or move one of them to a different port before running `nemoclaw onboard`. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 133f6956581..0f0147d898b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1198,7 +1198,7 @@ These flags toggle optional behaviors during onboarding; set them before running |----------|--------|--------| | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | -| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark and DGX Station express install prompt and continues with the normal interactive onboarding flow. | +| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | | `NEMOCLAW_IGNORE_RUNTIME_RESOURCES` | `1` to enable | Suppresses the under-provisioned runtime warning during preflight. Use only when you know the sandbox host meets the minimums. | | `NEMOCLAW_DISABLE_OVERLAY_FIX` | `1` to enable | Skips the Docker overlay-fix step during sandbox build. For environments where the fix is incompatible. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 530d0de2144..79bea51c783 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1165,7 +1165,7 @@ These flags toggle optional behaviors during onboarding; set them before running |----------|--------|--------| | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | -| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark and DGX Station express install prompt and continues with the normal interactive onboarding flow. | +| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | | `NEMOCLAW_IGNORE_RUNTIME_RESOURCES` | `1` to enable | Suppresses the under-provisioned runtime warning during preflight. Use only when you know the sandbox host meets the minimums. | | `NEMOCLAW_DISABLE_OVERLAY_FIX` | `1` to enable | Skips the Docker overlay-fix step during sandbox build. For environments where the fix is incompatible. | diff --git a/install.sh b/install.sh index b58f80daf0b..df1726aa2d8 100755 --- a/install.sh +++ b/install.sh @@ -117,6 +117,7 @@ bootstrap_usage() { printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" printf " NEMOCLAW_FRESH=1 Same as --fresh\n" printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" + printf " NEMOCLAW_NO_EXPRESS=1 Skip express install prompt on supported platforms\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE=1\n" printf " Allow automatic pre-0.0.37 OpenShell gateway upgrade\n" diff --git a/scripts/install.sh b/scripts/install.sh index f6b4c4e65b8..2eff34f05bc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -550,6 +550,7 @@ usage() { printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" printf " NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt Allow sudo prompts during non-interactive onboarding\n" printf " NEMOCLAW_FRESH=1 Same as --fresh\n" + printf " NEMOCLAW_NO_EXPRESS=1 Skip express install prompt on supported platforms\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_SINGLE_SESSION=1 Abort if active sandbox sessions exist\n" printf " NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE=1\n" @@ -2037,7 +2038,7 @@ ensure_docker() { case "$(uname -s)" in Darwin | MINGW* | MSYS*) return 0 ;; esac - if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ]; then + if is_wsl_host; then return 0 fi # Fast path: docker info works → already set up (root, or already-active group). @@ -2117,11 +2118,31 @@ ensure_docker() { fi } -# Detect DGX Spark / DGX Station from firmware (DMI first, devicetree fallback). -# Echoes "DGX Spark", "DGX Station", or empty. Used to gate the express -# install prompt; only platforms with a known sensible default are offered. +is_wsl_host() { + if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ]; then + return 0 + fi + if [ -r /proc/sys/kernel/osrelease ] \ + && grep -qiE 'microsoft|wsl' /proc/sys/kernel/osrelease 2>/dev/null; then + return 0 + fi + if [ -r /proc/version ] \ + && grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then + return 0 + fi + return 1 +} + +# Detect DGX Spark / DGX Station from firmware (DMI first, devicetree fallback) +# and Windows WSL from the host environment. Echoes "DGX Spark", +# "DGX Station", "Windows WSL", or empty. Used to gate the express install +# prompt; only platforms with a known sensible default are offered. detect_express_platform() { local model="" + if is_wsl_host; then + printf "Windows WSL" + return + fi if [ -r /sys/class/dmi/id/product_name ]; then model="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)" fi @@ -2135,7 +2156,7 @@ detect_express_platform() { esac } -# Prompt the user to opt into express install on Spark/Station. Sets the +# Prompt the user to opt into express install on supported platforms. Sets the # non-interactive + provider/model env vars when accepted. Skipped when # the user already passed --non-interactive, set NEMOCLAW_PROVIDER, or has # no TTY. @@ -2198,6 +2219,9 @@ maybe_offer_express_install() { "DGX Station") export NEMOCLAW_PROVIDER=install-vllm ;; + "Windows WSL") + export NEMOCLAW_PROVIDER=install-windows-ollama + ;; esac ;; *) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index f991f2accc6..9c90924abba 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -67,6 +67,9 @@ const { const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); +const { + resolveProviderKeyFallback, +}: typeof import("./onboard/provider-key-fallback") = require("./onboard/provider-key-fallback"); const { isLinuxDockerDriverGatewayEnabled }: typeof import("./onboard/docker-driver-platform") = require("./onboard/docker-driver-platform"); const { reconcileGatewayGpuReuseForGpuIntent, @@ -6393,16 +6396,9 @@ async function setupNim( } selected = options.find((o) => o.key === providerKey); if (!selected) { - // Install action keys fall back to the equivalent running-provider - // key when the menu only emits the running entry (the install would - // have been a no-op anyway). - if (providerKey === "install-ollama") { - selected = options.find((o) => o.key === "ollama"); - } else if (providerKey === "install-vllm") { - selected = options.find((o) => o.key === "vllm"); - } else if (providerKey === "ollama") { - selected = options.find((o) => o.key === "install-ollama"); - } + selected = resolveProviderKeyFallback(options, providerKey, { + isWindowsHostOllama: ollamaHost === OLLAMA_HOST_DOCKER_INTERNAL, + }); if (!selected) { if (providerKey === "hermesProvider" && !hermesProviderAvailable) { console.error(" Hermes Provider is only available when onboarding Hermes Agent."); diff --git a/src/lib/onboard/provider-key-fallback.test.ts b/src/lib/onboard/provider-key-fallback.test.ts new file mode 100644 index 00000000000..0591b07fc0c --- /dev/null +++ b/src/lib/onboard/provider-key-fallback.test.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { resolveProviderKeyFallback } from "../../../dist/lib/onboard/provider-key-fallback"; + +const option = (key: string) => ({ key, label: key }); + +describe("resolveProviderKeyFallback", () => { + it("maps generic install action keys to already-running provider options", () => { + const options = [option("ollama"), option("vllm")]; + + assert.equal( + resolveProviderKeyFallback(options, "install-ollama", { isWindowsHostOllama: false })?.key, + "ollama", + ); + assert.equal( + resolveProviderKeyFallback(options, "install-vllm", { isWindowsHostOllama: false })?.key, + "vllm", + ); + }); + + it("prefers Windows-host start when install-windows-ollama is requested after install already exists", () => { + const options = [option("ollama"), option("start-windows-ollama")]; + + assert.equal( + resolveProviderKeyFallback(options, "install-windows-ollama", { + isWindowsHostOllama: false, + })?.key, + "start-windows-ollama", + ); + }); + + it("allows Windows-host install/start requests to collapse to ollama only for Windows-host Ollama", () => { + const options = [option("ollama")]; + + assert.equal( + resolveProviderKeyFallback(options, "install-windows-ollama", { + isWindowsHostOllama: true, + })?.key, + "ollama", + ); + assert.equal( + resolveProviderKeyFallback(options, "start-windows-ollama", { + isWindowsHostOllama: true, + })?.key, + "ollama", + ); + }); + + it("does not satisfy Windows-host requests with WSL or Linux local Ollama", () => { + const options = [option("ollama")]; + + assert.equal( + resolveProviderKeyFallback(options, "install-windows-ollama", { + isWindowsHostOllama: false, + }), + undefined, + ); + assert.equal( + resolveProviderKeyFallback(options, "start-windows-ollama", { + isWindowsHostOllama: false, + }), + undefined, + ); + }); +}); diff --git a/src/lib/onboard/provider-key-fallback.ts b/src/lib/onboard/provider-key-fallback.ts new file mode 100644 index 00000000000..4d5f7708caa --- /dev/null +++ b/src/lib/onboard/provider-key-fallback.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface ProviderOption { + key: string; + label?: string; +} + +export interface ProviderKeyFallbackContext { + isWindowsHostOllama: boolean; +} + +export function resolveProviderKeyFallback( + options: T[], + providerKey: string | null | undefined, + context: ProviderKeyFallbackContext, +): T | undefined { + const find = (key: string) => options.find((option) => option.key === key); + + switch (providerKey) { + case "install-ollama": + return find("ollama"); + case "install-vllm": + return find("vllm"); + case "install-windows-ollama": + // Windows-host Ollama requests may arrive from NEMOCLAW_PROVIDER before + // the dynamic menu knows whether Windows Ollama needs install, + // start/restart, or is already reachable. Collapse only to later-state + // entries that still point at the Windows host. + return ( + find("start-windows-ollama") || + (context.isWindowsHostOllama ? find("ollama") : undefined) + ); + case "start-windows-ollama": + return context.isWindowsHostOllama ? find("ollama") : undefined; + case "ollama": + return find("install-ollama"); + default: + return undefined; + } +} diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index d7194b20ac3..dc48433303c 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -484,6 +484,7 @@ exit 98 expect(output).toMatch(/aliases: cloud -> build, nim -> nim-local/); expect(output).toMatch(/NEMOCLAW_POLICY_MODE/); expect(output).toMatch(/NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt/); + expect(output).toMatch(/NEMOCLAW_NO_EXPRESS=1/); expect(output).toMatch(/NEMOCLAW_SANDBOX_NAME/); expect(output).toMatch(/nvidia\.com\/nemoclaw\.sh/); }); @@ -3081,7 +3082,11 @@ exit 0`, }); describe("installer express install prompt (sourced)", () => { - function runExpressPromptWithTty(answer: string, stdinMode: "pipe" | "tty") { + function runExpressPromptWithTty( + answer: string, + stdinMode: "pipe" | "tty", + platform = "DGX Spark", + ) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-express-prompt-")); const python = spawnSync("bash", ["--noprofile", "--norc", "-c", "command -v python3"], { encoding: "utf-8" }).stdout.trim() || @@ -3097,9 +3102,10 @@ import time installer = sys.argv[1] answer = sys.argv[2].encode() stdin_mode = sys.argv[3] +platform = sys.argv[4] script = r''' source "$INSTALLER_UNDER_TEST" >/dev/null -detect_express_platform() { printf "DGX Spark"; } +detect_express_platform() { printf "$EXPRESS_PLATFORM"; } NON_INTERACTIVE="" NEMOCLAW_PROVIDER="" NEMOCLAW_NO_EXPRESS="" @@ -3110,6 +3116,7 @@ printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s POLICY=%s YE ''' env = dict(os.environ) env["INSTALLER_UNDER_TEST"] = installer +env["EXPRESS_PLATFORM"] = platform pid, fd = pty.fork() if pid == 0: if stdin_mode == "pipe": @@ -3159,16 +3166,20 @@ except OSError: sys.stdout.buffer.write(output) sys.exit(exit_code) `; - return spawnSync(python, ["-c", ptyRunner, INSTALLER_PAYLOAD, answer, stdinMode], { - cwd: tmp, - encoding: "utf-8", - timeout: 15_000, - killSignal: "SIGKILL", - env: { - HOME: tmp, - PATH: TEST_SYSTEM_PATH, + return spawnSync( + python, + ["-c", ptyRunner, INSTALLER_PAYLOAD, answer, stdinMode, platform], + { + cwd: tmp, + encoding: "utf-8", + timeout: 15_000, + killSignal: "SIGKILL", + env: { + HOME: tmp, + PATH: TEST_SYSTEM_PATH, + }, }, - }); + ); } it("offers express install when curl-piped stdin still has a controlling TTY", () => { @@ -3183,6 +3194,44 @@ sys.exit(exit_code) ); }); + it("detects Windows WSL as an express install platform", () => { + const result = spawnSync( + "bash", + [ + "-c", + ` +source "$INSTALLER_UNDER_TEST" >/dev/null +detect_express_platform +`, + ], + { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-express-wsl-detect-")), + PATH: TEST_SYSTEM_PATH, + INSTALLER_UNDER_TEST: INSTALLER_PAYLOAD, + WSL_DISTRO_NAME: "Ubuntu", + }, + }, + ); + + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stdout).toBe("Windows WSL"); + }); + + it("maps Windows WSL express install to Windows-host Ollama", () => { + const result = runExpressPromptWithTty("\n", "pipe", "Windows WSL"); + const output = `${result.stdout}${result.stderr}`; + expect(result.status, output).toBe(0); + expect(output).toMatch(/Detected Windows WSL/); + expect(output).toMatch(/Run express install/); + expect(output).toMatch(/Using express install for Windows WSL/); + expect(output).toMatch( + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-windows-ollama MODEL= POLICY=suggested YES=1/, + ); + }); + it("skips express install without a controlling TTY", () => { if (process.platform === "darwin") { return; diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 14c1785bd94..76addf0b1d3 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -5119,6 +5119,295 @@ const { setupNim } = require(${onboardPath}); ); }); + 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( + path.join(os.tmpdir(), "nemoclaw-onboard-windows-ollama-install-to-start-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "windows-ollama-install-to-start-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 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 platform = require(${platformPath}); +platform.isWsl = () => true; + +const installedPath = "C:\\\\Users\\\\tester\\\\AppData\\\\Local\\\\Programs\\\\Ollama\\\\ollama.exe"; +const installCalls = []; +const setupCalls = []; +const runCommands = []; +credentials.prompt = async () => ""; +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("powershell.exe") && cmd.includes("Get-Command ollama.exe")) return installedPath; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Process ollama")) return ""; + if (cmd.includes("api/tags")) { + if (setupCalls.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: false, path: "" }; +}; +windows.setupWindowsOllamaWith0000Binding = (opts) => { + setupCalls.push(opts || {}); + local.setResolvedOllamaHost(local.OLLAMA_HOST_DOCKER_INTERNAL); + return true; +}; + +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-to-start-test", null); + originalLog(JSON.stringify({ result, installCalls, setupCalls, 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, 0); + assert.deepEqual(payload.setupCalls, [{ announceStop: false }]); + assert.ok( + payload.lines.some((line: string) => + line.includes("Using Ollama on host.docker.internal:11434"), + ), + ); + }); + + it("does not satisfy start-windows-ollama with WSL-local Ollama", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-windows-ollama-no-wsl-fallback-"), + ); + const scriptPath = path.join(tmpDir, "windows-ollama-no-wsl-fallback-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 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}); +platform.isWsl = () => true; + +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 "/usr/bin/ollama"; + if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [{ name: "qwen3:8b" }] }); + if (cmd.includes("host.docker.internal:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("powershell.exe") && cmd.includes("Get-Command ollama.exe")) return ""; + return ""; +}; + +const local = require(${localPath}); +local.resetOllamaHostCache(); + +const windows = require(${windowsPath}); +windows.setupWindowsOllamaWith0000Binding = () => { + console.error("WINDOWS_SETUP_CALLED"); + return false; +}; +windows.switchToWindowsOllamaHost = () => { + console.error("WINDOWS_SWITCH_CALLED"); +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim("windows-no-wsl-fallback-test", 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: "start-windows-ollama", + NEMOCLAW_MODEL: "qwen3:8b", + NEMOCLAW_YES: "1", + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Requested provider 'start-windows-ollama' is not available/); + assert.doesNotMatch(result.stderr, /WINDOWS_SETUP_CALLED|WINDOWS_SWITCH_CALLED/); + }); + + it("does not satisfy install-windows-ollama with non-WSL local Ollama", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-windows-ollama-no-linux-fallback-"), + ); + const scriptPath = path.join(tmpDir, "windows-ollama-no-linux-fallback-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 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}); +platform.isWsl = () => false; + +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 "/usr/bin/ollama"; + if (cmd.includes("127.0.0.1:11434/api/tags")) return JSON.stringify({ models: [{ name: "qwen3:8b" }] }); + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + return ""; +}; + +const local = require(${localPath}); +local.resetOllamaHostCache(); + +const windows = require(${windowsPath}); +windows.installOllamaOnWindowsHost = async () => { + console.error("WINDOWS_INSTALL_CALLED"); + return { ok: false, path: "" }; +}; +windows.setupWindowsOllamaWith0000Binding = () => { + console.error("WINDOWS_SETUP_CALLED"); + return false; +}; + +const { setupNim } = require(${onboardPath}); + +(async () => { + await setupNim("windows-no-linux-fallback-test", 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: "install-windows-ollama", + NEMOCLAW_MODEL: "qwen3:8b", + NEMOCLAW_YES: "1", + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Requested provider 'install-windows-ollama' is not available/); + assert.doesNotMatch(result.stderr, /WINDOWS_INSTALL_CALLED|WINDOWS_SETUP_CALLED/); + }); + it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(