From 1c36c73ed1e897beadefda3ab63a85ed344db1ca Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 4 Aug 2026 08:16:42 +0000 Subject: [PATCH 01/10] fix(install): defer Windows WSL provider selection until Node.js reads Docker config Express setup ran before the Node.js bootstrap, so a readable Docker configuration it could not parse resolved as non-local and pinned install-ollama on hosts whose Docker Desktop topology supports Windows-host Ollama. Selection now waits for the runtime and applies the same check. Keep the WSL-local install entry on offer when the container runtime cannot reach an installed Windows-host Ollama, so a requested install-ollama still has a provider to select. Signed-off-by: Tinson Lai --- docs/get-started/windows-preparation.mdx | 2 + scripts/install.sh | 51 +++++++++++++++++-- src/lib/onboard/ollama-install-menu.test.ts | 26 ++++++++++ src/lib/onboard/ollama-install-menu.ts | 18 +++++-- src/lib/onboard/provider-host-state.test.ts | 22 +++++++++ src/lib/onboard/provider-host-state.ts | 1 + test/install-express-wsl-ollama.test.ts | 54 ++++++++++++++++++++- 7 files changed, 164 insertions(+), 10 deletions(-) diff --git a/docs/get-started/windows-preparation.mdx b/docs/get-started/windows-preparation.mdx index 2ad8cd37e19..1a7840758b2 100644 --- a/docs/get-started/windows-preparation.mdx +++ b/docs/get-started/windows-preparation.mdx @@ -170,7 +170,9 @@ 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 a local Ollama path automatically based on the container runtime: the Windows-host Ollama path only when it detects Docker Desktop WSL integration, and WSL-local Ollama otherwise. The Windows-host Ollama path requires Docker Desktop WSL integration; the express prompt appears on WSL regardless of the container runtime. +On a host without Node.js, the installer cannot read the Docker configuration at the prompt, so it reports that it selects the local Ollama path after installing Node.js and then applies the same runtime check. With native Docker Engine inside WSL, or when the Docker runtime is unavailable or cannot be probed, express install configures WSL-local Ollama instead of aborting. +An installed Windows-host Ollama that the container runtime cannot reach leaves the WSL-local install available, in both the onboarding menu and a requested `install-ollama` provider. When containers cannot reach host loopback, onboarding fronts that WSL-local daemon with the sandbox auth proxy. You can still decline the express prompt (or set `NEMOCLAW_NO_EXPRESS=1`) to choose a provider manually; the onboarding menu labels the Windows-host actions as requiring Docker Desktop integration. 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. diff --git a/scripts/install.sh b/scripts/install.sh index 32b7074c10c..ec64b10d302 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3381,6 +3381,7 @@ STATION_ULTRA_LEGACY_VLLM_IMAGE="vllm/vllm-openai@sha256:0fec7ec5f3e6bc168e54899 STATION_DEEPSEEK_VLLM_MODEL="deepseek-v4-flash" STATION_DEEPSEEK_SERVED_MODEL="deepseek-ai/DeepSeek-V4-Flash" _SELECTED_EXPRESS_PLATFORM="" +_EXPRESS_WSL_PROVIDER_PENDING="" _STATION_EXPRESS_RESUME_REVISION="" _STATION_EXPRESS_MODEL_WAS_EXPLICIT=0 _STATION_EXPRESS_DEFERRED_MANAGED_PAIR=0 @@ -4059,6 +4060,47 @@ express_wsl_can_use_windows_host_ollama() { express_wsl_docker_operating_system | grep -qi 'docker desktop' } +# True when a readable Docker config decides the context but no Node.js can +# parse it yet. The express prompt runs before install_nodejs, so treating that +# window as non-local pinned WSL-local Ollama on hosts whose Docker Desktop +# topology supports Windows-host Ollama, and onboarding then rejected the +# preselected provider (#8199). Selection waits for the runtime instead. +express_wsl_docker_context_needs_node() { + [ -z "${DOCKER_CONTEXT:-}" ] || return 1 + local cfg="${DOCKER_CONFIG:-${HOME:-}/.docker}/config.json" + [ -e "$cfg" ] && [ -r "$cfg" ] || return 1 + ! command_exists node +} + +# Choose between Windows-host and WSL-local Ollama, or defer when only the +# missing Node.js runtime blocks the decision. +select_express_wsl_ollama_provider() { + _EXPRESS_WSL_PROVIDER_PENDING="" + if express_wsl_can_use_windows_host_ollama; then + export NEMOCLAW_PROVIDER=install-windows-ollama + return 0 + fi + if express_wsl_docker_context_needs_node; then + _EXPRESS_WSL_PROVIDER_PENDING=1 + return 0 + fi + export NEMOCLAW_PROVIDER=install-ollama +} + +# Finish a deferred Windows WSL selection once install_nodejs has provided the +# runtime that reads the Docker configuration. +resolve_pending_express_wsl_provider() { + [ "${_EXPRESS_WSL_PROVIDER_PENDING:-}" = "1" ] || return 0 + _EXPRESS_WSL_PROVIDER_PENDING="" + if express_wsl_can_use_windows_host_ollama; then + export NEMOCLAW_PROVIDER=install-windows-ollama + info "Express install will configure Windows-host Ollama through host.docker.internal." + else + export NEMOCLAW_PROVIDER=install-ollama + info "Express install will configure WSL-local Ollama." + fi +} + activate_express_install() { local platform="$1" _SELECTED_EXPRESS_PLATFORM="$platform" @@ -4091,11 +4133,7 @@ activate_express_install() { configure_station_express_model ;; "Windows WSL") - if express_wsl_can_use_windows_host_ollama; then - export NEMOCLAW_PROVIDER=install-windows-ollama - else - export NEMOCLAW_PROVIDER=install-ollama - fi + select_express_wsl_ollama_provider ;; esac } @@ -4566,6 +4604,8 @@ describe_express_install() { "Windows WSL") if express_wsl_can_use_windows_host_ollama; then inference_summary="Windows-host Ollama through host.docker.internal" + elif express_wsl_docker_context_needs_node; then + inference_summary="local Ollama, selected once the installed Node.js runtime reads the Docker configuration" else inference_summary="WSL-local Ollama, with a sandbox auth proxy when containers cannot reach host loopback" fi @@ -4847,6 +4887,7 @@ main() { step 1 "Node.js" install_nodejs ensure_supported_runtime + resolve_pending_express_wsl_provider ensure_station_express_pair step 2 "${_CLI_DISPLAY} CLI" diff --git a/src/lib/onboard/ollama-install-menu.test.ts b/src/lib/onboard/ollama-install-menu.test.ts index 4d6d2650816..62cc7ea2ce2 100644 --- a/src/lib/onboard/ollama-install-menu.test.ts +++ b/src/lib/onboard/ollama-install-menu.test.ts @@ -60,6 +60,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: false, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: null, ...LINUX_NON_WSL, }); @@ -73,6 +74,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: "0.6.2", runningOllamaVersion: "0.6.2", ...LINUX_NON_WSL, @@ -89,6 +91,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: "0.24.0", runningOllamaVersion: "0.24.0", ...LINUX_NON_WSL, @@ -102,6 +105,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, ollamaHost: "127.0.0.1", installedOllamaVersion: "0.24.0", runningOllamaVersion: "0.6.2", @@ -119,6 +123,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, ollamaHost: "127.0.0.1", installedOllamaVersion: "0.6.2", runningOllamaVersion: "0.24.0", @@ -136,6 +141,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: true, hasWindowsOllama: true, + windowsHostOllamaSupported: true, ollamaHost: "host.docker.internal", // Pretend the local-loopback probe would have returned a stale version // if it were applied. The Windows-host case must short-circuit and not @@ -153,17 +159,33 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: false, hasWindowsOllama: true, + windowsHostOllamaSupported: true, installedOllamaVersion: null, ...LINUX_NON_WSL, }); expect(result.entry).toBeNull(); }); + it("offers a WSL-local install when the sandbox cannot reach the Windows-host Ollama (#8199)", () => { + const result = resolveOllamaInstallMenuEntry({ + hasOllama: false, + ollamaRunning: false, + hasWindowsOllama: true, + windowsHostOllamaSupported: false, + installedOllamaVersion: null, + platform: "linux", + isWsl: true, + }); + expect(result.entry?.key).toBe("install-ollama"); + expect(result.entry?.label).toBe("Install Ollama (WSL Linux)"); + }); + it("treats null versions as below the minimum to recover stale installs", () => { const result = resolveOllamaInstallMenuEntry({ hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: null, ...LINUX_NON_WSL, }); @@ -178,6 +200,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: false, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: null, platform: "linux", isWsl: true, @@ -190,6 +213,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: false, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: null, platform: "darwin", isWsl: false, @@ -202,6 +226,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: true, ollamaRunning: true, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: "0.6.2", runningOllamaVersion: "0.6.2", platform: "darwin", @@ -259,6 +284,7 @@ describe("resolveOllamaInstallMenuEntry", () => { hasOllama: false, ollamaRunning: false, hasWindowsOllama: false, + windowsHostOllamaSupported: true, installedOllamaVersion: null, platform: "win32", isWsl: false, diff --git a/src/lib/onboard/ollama-install-menu.ts b/src/lib/onboard/ollama-install-menu.ts index f070485711b..a15a8081852 100644 --- a/src/lib/onboard/ollama-install-menu.ts +++ b/src/lib/onboard/ollama-install-menu.ts @@ -15,6 +15,11 @@ export interface OllamaInstallMenuInput { hasOllama: boolean; ollamaRunning: boolean; hasWindowsOllama: boolean; + /** Whether the sandbox can reach a Windows-host Ollama daemon at all. A + * Windows install behind a container runtime without `host.docker.internal` + * routing covers nothing, so the WSL-local install entry stays on offer. + * Only read when `hasWindowsOllama` is set; defaults to reachable. */ + windowsHostOllamaSupported?: boolean; platform: NodeJS.Platform; isWsl: boolean; /** Resolved host for the running Ollama daemon. `host.docker.internal` @@ -110,9 +115,9 @@ function osTagFor(platform: NodeJS.Platform, isWsl: boolean): string | null { * Decide whether the onboard provider menu should expose an `install-ollama` * entry, and which label to render. Two cases: * - * 1. No Ollama anywhere (host, running, or Windows) — offer a fresh install - * as a fallback (e.g. when the NVIDIA API server is down and cloud keys - * are unavailable). + * 1. No usable Ollama anywhere (host, running, or a Windows install the + * sandbox can reach) — offer a fresh install as a fallback (e.g. when the + * NVIDIA API server is down and cloud keys are unavailable). * 2. Host Ollama exists but its version is below `MIN_OLLAMA_VERSION` — * offer an explicit upgrade so the express setup path doesn't reuse a * daemon that crashes loading newer starter models. @@ -147,8 +152,13 @@ export function resolveOllamaInstallMenuEntry( const daemonNeedsUpgrade = daemonProbeApplies && !isOllamaVersionAtLeast(runningOllamaVersion, MIN_OLLAMA_VERSION); const hasUpgradableOllama = binaryNeedsUpgrade || daemonNeedsUpgrade; + // A Windows-host install only covers the local-inference need when the + // sandbox can route to it. Under a container runtime without that routing, + // WSL-local Ollama is the only workable local provider, and suppressing its + // entry left a requested `install-ollama` with nothing to select (#8199). + const usableWindowsOllama = input.hasWindowsOllama && (input.windowsHostOllamaSupported ?? true); const showEntry = - (!input.hasOllama && !input.ollamaRunning && !input.hasWindowsOllama) || hasUpgradableOllama; + (!input.hasOllama && !input.ollamaRunning && !usableWindowsOllama) || hasUpgradableOllama; if (!showEntry) { return { entry: null, hasUpgradableOllama }; } diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts index 82c995869ac..329d48231fa 100644 --- a/src/lib/onboard/provider-host-state.test.ts +++ b/src/lib/onboard/provider-host-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; +import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; import { type DetectInferenceProviderHostStateDeps, detectInferenceProviderHostState, @@ -197,6 +198,27 @@ describe("detectInferenceProviderHostState", () => { expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith("docker-desktop"); }); + it("keeps the WSL-local install entry when the container runtime cannot reach the Windows host (#8199)", () => { + const deps = buildDeps({ + isWsl: vi.fn(() => true), + getContainerRuntime: vi.fn( + () => "docker", + ), + getWindowsHostOllamaDockerRequirement: vi.fn(getWindowsHostOllamaDockerRequirement), + detectWindowsHostOllama: vi.fn(() => ({ + installed: true, + installedPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Ollama\\ollama.exe", + loopbackOnly: false, + })), + }); + + const state = detectWithDeps(deps); + + expect(state.hasWindowsOllama).toBe(true); + expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama"); + expect(state.ollamaInstallMenu.entry?.label).toBe("Install Ollama (WSL Linux)"); + }); + it("passes injected platform and env through WSL detection", () => { const env = { WSL_DISTRO_NAME: "Ubuntu" } as NodeJS.ProcessEnv; const isWsl = vi.fn(() => true); diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts index 4a41d5a8f8d..cdd18a97732 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -220,6 +220,7 @@ export function detectInferenceProviderHostState( hasOllama, ollamaRunning, hasWindowsOllama, + windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, ollamaHost, platform, isWsl, diff --git a/test/install-express-wsl-ollama.test.ts b/test/install-express-wsl-ollama.test.ts index b169db3f686..34f3354326e 100644 --- a/test/install-express-wsl-ollama.test.ts +++ b/test/install-express-wsl-ollama.test.ts @@ -248,6 +248,7 @@ sys.exit(exit_code) `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + `activate_express_install "Windows WSL"\n` + `printf 'PROVIDER=%s\\n' "$NEMOCLAW_PROVIDER"\n`, + { PATH: `${path.dirname(process.execPath)}:${TEST_SYSTEM_PATH}` }, ); expect(result.status, output).toBe(0); expect(output).toContain("PROVIDER=install-ollama"); @@ -285,18 +286,69 @@ sys.exit(exit_code) expect(output).toContain("PROVIDER=install-ollama"); }); - it("activate_express_install fails closed on malformed Docker config when Node is unavailable", () => { + it("activate_express_install fails closed on malformed Docker config once Node is installed", () => { const { result, output } = runInstallerSourced( `mkdir -p "$HOME/.docker"\n` + `printf '%s' 'not-json {"currentContext":"default"}' > "$HOME/.docker/config.json"\n` + `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + `activate_express_install "Windows WSL"\n` + + `printf 'DEFERRED=%s PROVIDER=%s\\n' "\${_EXPRESS_WSL_PROVIDER_PENDING:-}" "\${NEMOCLAW_PROVIDER:-}"\n` + + `PATH="$NODE_BIN_DIR:$PATH"\n` + + `resolve_pending_express_wsl_provider\n` + `printf 'PROVIDER=%s\\n' "$NEMOCLAW_PROVIDER"\n`, + { NODE_BIN_DIR: path.dirname(process.execPath) }, ); expect(result.status, output).toBe(0); + expect(output).toContain("DEFERRED=1 PROVIDER="); expect(output).toContain("PROVIDER=install-ollama"); }); + it("defers Windows WSL selection until Node.js can read the Docker configuration (#8199)", () => { + const { result, output } = runInstallerSourced( + `mkdir -p "$HOME/.docker"\n` + + `printf '%s' '{}' > "$HOME/.docker/config.json"\n` + + `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + + `activate_express_install "Windows WSL"\n` + + `printf 'DEFERRED=%s PROVIDER=%s\\n' "\${_EXPRESS_WSL_PROVIDER_PENDING:-}" "\${NEMOCLAW_PROVIDER:-}"\n` + + `PATH="$NODE_BIN_DIR:$PATH"\n` + + `resolve_pending_express_wsl_provider\n` + + `printf 'PROVIDER=%s\\n' "$NEMOCLAW_PROVIDER"\n`, + { NODE_BIN_DIR: path.dirname(process.execPath) }, + ); + expect(result.status, output).toBe(0); + expect(output).toContain("DEFERRED=1 PROVIDER="); + expect(output).toContain("PROVIDER=install-windows-ollama"); + }); + + it("describes a deferred Windows WSL selection before Node.js is installed (#8199)", () => { + const { result, output } = runInstallerSourced( + `mkdir -p "$HOME/.docker"\n` + + `printf '%s' '{}' > "$HOME/.docker/config.json"\n` + + `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + + `describe_express_install "Windows WSL"\n`, + ); + expect(result.status, output).toBe(0); + expect(output).toMatch( + /Express install will configure local Ollama, selected once the installed Node\.js runtime reads the Docker configuration/, + ); + }); + + it("keeps a resolved Windows WSL selection out of the deferred path", () => { + const dockerBin = dockerStubBin("Docker Desktop"); + const { result, output } = runInstallerSourced( + `mkdir -p "$HOME/.docker"\n` + + `printf '%s' '{"currentContext":"default"}' > "$HOME/.docker/config.json"\n` + + `activate_express_install "Windows WSL"\n` + + `printf 'DEFERRED=%s PROVIDER=%s\\n' "\${_EXPRESS_WSL_PROVIDER_PENDING:-}" "\${NEMOCLAW_PROVIDER:-}"\n` + + `resolve_pending_express_wsl_provider\n` + + `printf 'PROVIDER=%s\\n' "$NEMOCLAW_PROVIDER"\n`, + { PATH: `${dockerBin}:${path.dirname(process.execPath)}:${TEST_SYSTEM_PATH}` }, + ); + expect(result.status, output).toBe(0); + expect(output).toContain("DEFERRED= PROVIDER=install-windows-ollama"); + expect(output).toContain("PROVIDER=install-windows-ollama"); + }); + it("activate_express_install fails closed on an unreadable Docker config", () => { const { result, output } = runInstallerSourced( `mkdir -p "$HOME/.docker"\n` + From e31949ea7833a33c3b3e90b58c34025469a70ccb Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 08:32:53 -0400 Subject: [PATCH 02/10] fix(onboard): keep WSL Ollama fallback reachable Signed-off-by: Julie Yaunches --- src/lib/onboard/provider-host-state.test.ts | 8 +++--- src/lib/onboard/provider-host-state.ts | 3 +- test/install-express-wsl-ollama.test.ts | 31 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/provider-host-state.test.ts b/src/lib/onboard/provider-host-state.test.ts index 329d48231fa..01f5f0859d6 100644 --- a/src/lib/onboard/provider-host-state.test.ts +++ b/src/lib/onboard/provider-host-state.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; -import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology"; import { type DetectInferenceProviderHostStateDeps, detectInferenceProviderHostState, @@ -198,13 +197,12 @@ describe("detectInferenceProviderHostState", () => { expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith("docker-desktop"); }); - it("keeps the WSL-local install entry when the container runtime cannot reach the Windows host (#8199)", () => { + it("keeps WSL-local install available when Docker Desktop cannot reach Windows-host Ollama (#8199)", () => { const deps = buildDeps({ isWsl: vi.fn(() => true), getContainerRuntime: vi.fn( - () => "docker", + () => "docker-desktop", ), - getWindowsHostOllamaDockerRequirement: vi.fn(getWindowsHostOllamaDockerRequirement), detectWindowsHostOllama: vi.fn(() => ({ installed: true, installedPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Ollama\\ollama.exe", @@ -215,6 +213,8 @@ describe("detectInferenceProviderHostState", () => { const state = detectWithDeps(deps); expect(state.hasWindowsOllama).toBe(true); + expect(state.windowsHostOllamaDockerRequirement.supported).toBe(true); + expect(state.windowsOllamaReachable).toBe(false); expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama"); expect(state.ollamaInstallMenu.entry?.label).toBe("Install Ollama (WSL Linux)"); }); diff --git a/src/lib/onboard/provider-host-state.ts b/src/lib/onboard/provider-host-state.ts index cdd18a97732..f36198c333b 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -220,7 +220,8 @@ export function detectInferenceProviderHostState( hasOllama, ollamaRunning, hasWindowsOllama, - windowsHostOllamaSupported: windowsHostOllamaDockerRequirement.supported, + windowsHostOllamaSupported: + windowsHostOllamaDockerRequirement.supported && windowsOllamaReachable, ollamaHost, platform, isWsl, diff --git a/test/install-express-wsl-ollama.test.ts b/test/install-express-wsl-ollama.test.ts index 34f3354326e..957004b30c9 100644 --- a/test/install-express-wsl-ollama.test.ts +++ b/test/install-express-wsl-ollama.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveRequestedProviderSelection } from "../src/lib/onboard/provider-selection.js"; import { INSTALLER_PAYLOAD, TEST_SYSTEM_PATH, @@ -303,10 +304,11 @@ sys.exit(exit_code) expect(output).toContain("PROVIDER=install-ollama"); }); - it("defers Windows WSL selection until Node.js can read the Docker configuration (#8199)", () => { + it("accepts deferred Windows-host selection in onboarding provider resolution (#8199)", () => { const { result, output } = runInstallerSourced( `mkdir -p "$HOME/.docker"\n` + `printf '%s' '{}' > "$HOME/.docker/config.json"\n` + + `printf 'NODE_BEFORE=%s\\n' "$(command -v node || true)"\n` + `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + `activate_express_install "Windows WSL"\n` + `printf 'DEFERRED=%s PROVIDER=%s\\n' "\${_EXPRESS_WSL_PROVIDER_PENDING:-}" "\${NEMOCLAW_PROVIDER:-}"\n` + @@ -316,8 +318,35 @@ sys.exit(exit_code) { NODE_BIN_DIR: path.dirname(process.execPath) }, ); expect(result.status, output).toBe(0); + expect(output).toContain("NODE_BEFORE=\n"); expect(output).toContain("DEFERRED=1 PROVIDER="); expect(output).toContain("PROVIDER=install-windows-ollama"); + + const requestedProvider = output.match(/^PROVIDER=(.+)$/m)?.[1]; + expect(requestedProvider).toBe("install-windows-ollama"); + + const resolution = resolveRequestedProviderSelection({ + options: [ + { + key: "start-windows-ollama", + label: "Start Ollama on Windows host (suggested)", + }, + ], + requestedProvider: requestedProvider ?? null, + sandboxName: null, + remoteProviderConfig: {}, + isWsl: true, + isWindowsHostOllama: false, + windowsHostOllamaSupported: true, + hermesProviderAvailable: false, + readRecordedProvider: () => null, + readRecordedNimContainer: () => null, + readRecordedModel: () => null, + }); + expect(resolution).toMatchObject({ + kind: "selected", + selected: { key: "start-windows-ollama" }, + }); }); it("describes a deferred Windows WSL selection before Node.js is installed (#8199)", () => { From 6ae809d510d2e1a078d33c9e9bd6c178a409d4bb Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 11:14:03 -0400 Subject: [PATCH 03/10] fix(install): preserve DOCKER_HOST provider selection --- docs/get-started/windows-preparation.mdx | 3 ++- scripts/install.sh | 3 ++- test/install-express-wsl-ollama.test.ts | 15 ++++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/get-started/windows-preparation.mdx b/docs/get-started/windows-preparation.mdx index 1a7840758b2..bce29fff2ba 100644 --- a/docs/get-started/windows-preparation.mdx +++ b/docs/get-started/windows-preparation.mdx @@ -170,7 +170,8 @@ 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 a local Ollama path automatically based on the container runtime: the Windows-host Ollama path only when it detects Docker Desktop WSL integration, and WSL-local Ollama otherwise. The Windows-host Ollama path requires Docker Desktop WSL integration; the express prompt appears on WSL regardless of the container runtime. -On a host without Node.js, the installer cannot read the Docker configuration at the prompt, so it reports that it selects the local Ollama path after installing Node.js and then applies the same runtime check. +When the Docker target depends on a readable Docker configuration file and Node.js is unavailable, the installer defers the selection until it installs Node.js. +It then reads the configuration and applies the same runtime check. With native Docker Engine inside WSL, or when the Docker runtime is unavailable or cannot be probed, express install configures WSL-local Ollama instead of aborting. An installed Windows-host Ollama that the container runtime cannot reach leaves the WSL-local install available, in both the onboarding menu and a requested `install-ollama` provider. When containers cannot reach host loopback, onboarding fronts that WSL-local daemon with the sandbox auth proxy. diff --git a/scripts/install.sh b/scripts/install.sh index abcd263e528..802d7908b0d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4146,12 +4146,13 @@ express_wsl_can_use_windows_host_ollama() { express_wsl_docker_operating_system | grep -qi 'docker desktop' } -# True when a readable Docker config decides the context but no Node.js can +# True when a readable Docker configuration decides the context but no Node.js can # parse it yet. The express prompt runs before install_nodejs, so treating that # window as non-local pinned WSL-local Ollama on hosts whose Docker Desktop # topology supports Windows-host Ollama, and onboarding then rejected the # preselected provider (#8199). Selection waits for the runtime instead. express_wsl_docker_context_needs_node() { + [ -z "${DOCKER_HOST:-}" ] || return 1 [ -z "${DOCKER_CONTEXT:-}" ] || return 1 local cfg="${DOCKER_CONFIG:-${HOME:-}/.docker}/config.json" [ -e "$cfg" ] && [ -r "$cfg" ] || return 1 diff --git a/test/install-express-wsl-ollama.test.ts b/test/install-express-wsl-ollama.test.ts index 957004b30c9..2167ec689e4 100644 --- a/test/install-express-wsl-ollama.test.ts +++ b/test/install-express-wsl-ollama.test.ts @@ -220,6 +220,19 @@ sys.exit(exit_code) expect(output).toContain("PROVIDER=install-ollama"); }); + it("does not defer a DOCKER_HOST override when Node.js is unavailable (#8199)", () => { + const { result, output } = runInstallerSourced( + `mkdir -p "$HOME/.docker"\n` + + `printf '%s' '{}' > "$HOME/.docker/config.json"\n` + + `export DOCKER_HOST=tcp://10.0.0.5:2375\n` + + `express_wsl_docker_operating_system() { printf 'Docker Desktop\\n'; }\n` + + `activate_express_install "Windows WSL"\n` + + `printf 'DEFERRED=%s PROVIDER=%s\\n' "\${_EXPRESS_WSL_PROVIDER_PENDING:-}" "\${NEMOCLAW_PROVIDER:-}"\n`, + ); + expect(result.status, output).toBe(0); + expect(output).toContain("DEFERRED= PROVIDER=install-ollama"); + }); + it("activate_express_install rejects a remote Docker Desktop target via DOCKER_CONTEXT", () => { const { result, output } = runInstallerSourced( `export DOCKER_CONTEXT=my-remote\n` + @@ -287,7 +300,7 @@ sys.exit(exit_code) expect(output).toContain("PROVIDER=install-ollama"); }); - it("activate_express_install fails closed on malformed Docker config once Node is installed", () => { + it("activate_express_install fails closed on malformed Docker configuration after Node.js is installed", () => { const { result, output } = runInstallerSourced( `mkdir -p "$HOME/.docker"\n` + `printf '%s' 'not-json {"currentContext":"default"}' > "$HOME/.docker/config.json"\n` + From 4a93260b7f9d1ed93d80332744da180231de8a8a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 11:32:28 -0400 Subject: [PATCH 04/10] docs(install): clarify Docker context detection --- docs/get-started/windows-preparation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/get-started/windows-preparation.mdx b/docs/get-started/windows-preparation.mdx index bce29fff2ba..26eea84f945 100644 --- a/docs/get-started/windows-preparation.mdx +++ b/docs/get-started/windows-preparation.mdx @@ -170,7 +170,7 @@ 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 a local Ollama path automatically based on the container runtime: the Windows-host Ollama path only when it detects Docker Desktop WSL integration, and WSL-local Ollama otherwise. The Windows-host Ollama path requires Docker Desktop WSL integration; the express prompt appears on WSL regardless of the container runtime. -When the Docker target depends on a readable Docker configuration file and Node.js is unavailable, the installer defers the selection until it installs Node.js. +When the installer must read the Docker configuration file to determine the effective Docker context and Node.js is unavailable, it defers provider selection until after it installs Node.js. It then reads the configuration and applies the same runtime check. With native Docker Engine inside WSL, or when the Docker runtime is unavailable or cannot be probed, express install configures WSL-local Ollama instead of aborting. An installed Windows-host Ollama that the container runtime cannot reach leaves the WSL-local install available, in both the onboarding menu and a requested `install-ollama` provider. From f54c3a62238d7c058bf85737ec48d3515693e3a2 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 12:01:17 -0400 Subject: [PATCH 05/10] test(tunnel): isolate stopAll process mock Signed-off-by: Julie Yaunches --- src/lib/tunnel/services.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index c2d666e016b..b15aa6f675c 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -12,6 +12,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -467,6 +468,7 @@ describe("stopAll", () => { } return reply; }; + syncBuiltinESMExports(); // The Ollama proxy source module destructures `spawnSync` at // require time, so to make `stopAll` pick up the patched function we // bust its cache. `services.ts` requires the proxy lazily, so the @@ -476,6 +478,7 @@ describe("stopAll", () => { afterEach(() => { childProcess.spawnSync = originalSpawnSync; + syncBuiltinESMExports(); delete require.cache[require.resolve(ollamaProxySourcePath)]; rmSync(pidDir, { recursive: true, force: true }); }); From ee3bd723715c4421271642a4cd368cc65ac13c4f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 20:59:11 -0400 Subject: [PATCH 06/10] test(tunnel): inject Ollama cleanup dependency Signed-off-by: Julie Yaunches --- src/lib/tunnel/services.test.ts | 73 +++++++++++---------------------- src/lib/tunnel/services.ts | 5 ++- 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index b15aa6f675c..25ee1def93a 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import childProcess, { type SpawnSyncReturns } from "node:child_process"; import { chmodSync, existsSync, @@ -12,10 +11,9 @@ import { statSync, writeFileSync, } from "node:fs"; -import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; // Import source directly so tests cannot pass against a stale build. import { registerTunnelOrigin } from "./allowed-origins"; @@ -25,6 +23,7 @@ import { getTunnelUrl, type ProcessControl, readCloudflaredState, + type ServiceOptions, showStatus, startAll, stopAll, @@ -51,8 +50,6 @@ function seedAliveCloudflaredPid(pidDir: string): void { writeFileSync(join(pidDir, "cloudflared.pid"), String(process.pid), { mode: 0o600 }); } -const ollamaProxySourcePath = resolve(import.meta.dirname, "..", "inference", "ollama", "proxy.ts"); - describe("getTunnelUrl", () => { let pidDir: string; @@ -443,46 +440,21 @@ describe("readCloudflaredState", () => { describe("stopAll", () => { let pidDir: string; - let spawnSyncCalls: Array<{ command: string; args: readonly string[] }>; - let originalSpawnSync: typeof childProcess.spawnSync; + let unloadOllamaModels: Mock<() => void>; beforeEach(() => { pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); - spawnSyncCalls = []; - originalSpawnSync = childProcess.spawnSync; - // @ts-expect-error — partial mock signature is intentional. - childProcess.spawnSync = (command: string, args: readonly string[]) => { - spawnSyncCalls.push({ command, args }); - const reply: SpawnSyncReturns = { - pid: 0, - output: ["", "", ""], - stdout: "", - stderr: "", - status: 0, - signal: null, - }; - // Return an empty model list so the unload's for-loop is a no-op. - if (command === "curl" && args.some((a) => a.endsWith("/api/ps"))) { - reply.stdout = JSON.stringify({ models: [] }); - reply.output = ["", reply.stdout, ""]; - } - return reply; - }; - syncBuiltinESMExports(); - // The Ollama proxy source module destructures `spawnSync` at - // require time, so to make `stopAll` pick up the patched function we - // bust its cache. `services.ts` requires the proxy lazily, so the - // next call sees the freshly-loaded module. - delete require.cache[require.resolve(ollamaProxySourcePath)]; + unloadOllamaModels = vi.fn(); }); afterEach(() => { - childProcess.spawnSync = originalSpawnSync; - syncBuiltinESMExports(); - delete require.cache[require.resolve(ollamaProxySourcePath)]; rmSync(pidDir, { recursive: true, force: true }); }); + function stopAllForTest(opts: ServiceOptions = {}): void { + stopAll({ ...opts, unloadOllamaModels }); + } + // A scripted ProcessControl models PID identity/liveness/signalling without // touching the host, so the recycled-PID paths are deterministic and portable // (no real process, no /proc, no signals). `alive`/`cmdlines` are consumed in @@ -513,7 +485,7 @@ describe("stopAll", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - stopAll({ pidDir, processControl: control }); + stopAllForTest({ pidDir, processControl: control }); } finally { logSpy.mockRestore(); } @@ -534,7 +506,7 @@ describe("stopAll", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - stopAll({ pidDir, processControl: control }); + stopAllForTest({ pidDir, processControl: control }); } finally { logSpy.mockRestore(); } @@ -553,7 +525,7 @@ describe("stopAll", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(3000); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - stopAll({ pidDir, processControl: control }); + stopAllForTest({ pidDir, processControl: control }); } finally { nowSpy.mockRestore(); logSpy.mockRestore(); @@ -570,7 +542,7 @@ describe("stopAll", () => { writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); + stopAllForTest({ pidDir }); logSpy.mockRestore(); expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); @@ -578,14 +550,14 @@ describe("stopAll", () => { it("is idempotent — calling twice does not throw", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); - stopAll({ pidDir }); + stopAllForTest({ pidDir }); + stopAllForTest({ pidDir }); logSpy.mockRestore(); }); it("logs stop messages", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); + stopAllForTest({ pidDir }); const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); expect(output).toContain("All services stopped"); logSpy.mockRestore(); @@ -593,14 +565,15 @@ describe("stopAll", () => { it("unloads Ollama models before reporting services stopped", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); + stopAllForTest({ pidDir }); + const stoppedCallIndex = logSpy.mock.calls.findIndex(([message]) => + String(message).includes("All services stopped"), + ); + const stoppedCallOrder = logSpy.mock.invocationCallOrder[stoppedCallIndex]; logSpy.mockRestore(); - const psCall = spawnSyncCalls.find( - (c) => c.command === "curl" && c.args.some((a) => a.endsWith("/api/ps")), - ); - expect(psCall).toBeDefined(); - expect(psCall?.args).toContain("--max-time"); + expect(unloadOllamaModels).toHaveBeenCalledOnce(); + expect(unloadOllamaModels.mock.invocationCallOrder[0]).toBeLessThan(stoppedCallOrder ?? 0); }); }); diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index db15e923d22..ffabc6c480d 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -43,6 +43,8 @@ export interface ServiceOptions { pidDir?: string; /** Injectable process operations (identity + signalling) for tests. */ processControl?: ProcessControl; + /** Injectable Ollama model cleanup for tests. */ + unloadOllamaModels?: () => void; /** Cloudflare named tunnel token. Falls back to CLOUDFLARE_TUNNEL_TOKEN. */ cloudflareTunnelToken?: string; /** Also release the managed host gateway port (legacy full-stop only). */ @@ -520,7 +522,8 @@ export function stopAll(opts: ServiceOptions = {}): void { } try { - const { unloadOllamaModels } = require("../inference/ollama/proxy"); + const unloadOllamaModels = + opts.unloadOllamaModels ?? require("../inference/ollama/proxy").unloadOllamaModels; unloadOllamaModels(); } catch { /* best-effort */ From 8ae83a435afe7fc4dac3984f0d0940810e61f3c8 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 21:36:05 -0400 Subject: [PATCH 07/10] test(shields): isolate deadline snapshot reuse Signed-off-by: Julie Yaunches --- src/lib/shields/index.test.ts | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 82d8a23e898..9f8ed310dd2 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -592,42 +592,25 @@ describe("shields — unit logic", () => { expect(appliedPolicy).not.toContain("mcp_bridge_alpha"); }); - it("auto-restore applies a snapshot with no managed MCP entries when policy staging is unavailable (#7952)", async () => { - const sandboxName = "openclaw"; - const processToken = "d".repeat(32); + it("deadline restore reuses an unchanged snapshot without temporary storage when no managed MCP entries exist (#7952)", async () => { const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); fs.mkdirSync(stateDir(), { recursive: true }); fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"); - writeState(sandboxName, { - shieldsDown: true, - shieldsPolicySnapshotPath: snapshotPath, - shieldsManagedMcpPolicyKeys: [], - }); - writeMarker(sandboxName, { - pid: 2_147_483_647, - sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 1_000).toISOString(), - processToken, - }); - vi.spyOn(process, "kill").mockImplementation(routeProcessKill); - const { applyShieldsPolicySnapshot } = await loadShieldsModule(); - const { buildPolicySetCommand } = await import("../policy"); const createTempDirectory = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { throw Object.assign(new Error("ENOSPC: simulated temporary storage full"), { code: "ENOSPC", }); }); + const { buildDeadlineRuntimeManagedMcpPolicy } = await import("./permissive-runtime"); - const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - expiredTimerRecovery: true, + const result = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { + managedMcpPolicies: [], + snapshotManagedPolicyKeys: [], + readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), }); - expect(result.status).toBe(0); + expect(result).toEqual({ path: snapshotPath, omissions: [] }); expect(createTempDirectory).not.toHaveBeenCalled(); - expect(buildPolicySetCommand).toHaveBeenCalledWith(snapshotPath, sandboxName); }); it("shieldsStatus warns and stays DOWN when inline recovery fails", async () => { From b99632ebcc41634c24b4571f8be732b143810bb7 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 22:06:46 -0400 Subject: [PATCH 08/10] test(onboard): allow Station resume coverage budget Signed-off-by: Julie Yaunches --- .../onboard-session-station-express.test.ts | 419 +++++++++--------- 1 file changed, 212 insertions(+), 207 deletions(-) diff --git a/src/lib/state/onboard-session-station-express.test.ts b/src/lib/state/onboard-session-station-express.test.ts index d2fd6aa7e4c..922180633aa 100644 --- a/src/lib/state/onboard-session-station-express.test.ts +++ b/src/lib/state/onboard-session-station-express.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { testTimeoutOptions } from "../../../test/helpers/timeouts"; import type { OnboardSessionBootstrapDeps } from "../onboard/session-bootstrap"; @@ -465,228 +466,232 @@ describe("Station Express onboarding session state (#7048)", () => { expect(fs.existsSync(receipt)).toBe(false); }); - it("resumes a provider failure and persists its route-validated arbitrary alias", async () => { - const { prepareOnboardSession } = await import("../onboard/session-bootstrap"); - const { wrapOnboard } = await import("../onboard/station-express-resume"); - const { handleProviderInferenceState } = await import( - "../onboard/machine/handlers/provider-inference" - ); - const { baseOptions, createDeps } = await import( - "../onboard/machine/handlers/provider-inference.test-support" - ); - const { runOnboardMachine } = await import("../onboard/machine/runner"); - const { OnboardRuntime } = await import("../onboard/machine/runtime"); - const { completeOnboardMachine } = await import("../onboard/machine/result"); - const { addOnboardMachineEventListener } = await import("../onboard/machine/events"); - const { registerIncompleteOnboardExitHandlerForSession } = await import( - "../onboard/onboard-exit-handler" - ); - const bootstrapDeps = await realBootstrapDeps(); - const intent = { - version: 1 as const, - model: "nemotron-3-ultra-550b-a55b", - sandboxName: "my-assistant", - receiptGeneration, - }; - const receipt = path.join(session.SESSION_DIR, "station-express-resume"); - await prepareOnboardSession( - { - resume: false, - fresh: false, - requestedFromDockerfile: null, - requestedSandboxName: "my-assistant", - cannotPrompt: true, - nonInteractive: true, - stationExpressIntent: intent, - }, - bootstrapDeps, - ); - fs.writeFileSync(receipt, receiptText(), { mode: 0o600 }); - expect(requireLoadedSession(session.loadSession()).stationExpressIntent).toEqual(intent); - - const failingRuntime = new OnboardRuntime(); - await failingRuntime.transition("preflight"); - await failingRuntime.transition("gateway"); - await failingRuntime.transition("provider_selection"); - const exitListeners: Array<(code: number) => void> = []; - registerIncompleteOnboardExitHandlerForSession(session, () => false, { - once: (_event, listener) => exitListeners.push(listener), - }); - const injectedFailure = new Error("injected managed vLLM download failure"); - const failing = createDeps({ - setupNim: vi.fn(async () => { - throw injectedFailure; - }), - startRecordedStep: vi.fn(async (stepName: string) => { - await failingRuntime.markStepStarted(stepName); - }), - recordStepComplete: vi.fn(async (stepName, updates) => - failingRuntime.markStepComplete(stepName, updates), - ), - }); - await expect( - runOnboardMachine({ - context: {}, - runtime: failingRuntime, - handlers: { - provider_selection: async () => { - const result = await handleProviderInferenceState({ - ...baseOptions(failing.deps, requireLoadedSession(session.loadSession())), - sandboxName: "my-assistant", - }); - return result.stateResults; - }, + it( + "resumes a provider failure and persists its route-validated arbitrary alias", + testTimeoutOptions(15_000), + async () => { + const { prepareOnboardSession } = await import("../onboard/session-bootstrap"); + const { wrapOnboard } = await import("../onboard/station-express-resume"); + const { handleProviderInferenceState } = await import( + "../onboard/machine/handlers/provider-inference" + ); + const { baseOptions, createDeps } = await import( + "../onboard/machine/handlers/provider-inference.test-support" + ); + const { runOnboardMachine } = await import("../onboard/machine/runner"); + const { OnboardRuntime } = await import("../onboard/machine/runtime"); + const { completeOnboardMachine } = await import("../onboard/machine/result"); + const { addOnboardMachineEventListener } = await import("../onboard/machine/events"); + const { registerIncompleteOnboardExitHandlerForSession } = await import( + "../onboard/onboard-exit-handler" + ); + const bootstrapDeps = await realBootstrapDeps(); + const intent = { + version: 1 as const, + model: "nemotron-3-ultra-550b-a55b", + sandboxName: "my-assistant", + receiptGeneration, + }; + const receipt = path.join(session.SESSION_DIR, "station-express-resume"); + await prepareOnboardSession( + { + resume: false, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: "my-assistant", + cannotPrompt: true, + nonInteractive: true, + stationExpressIntent: intent, }, - stopStates: ["sandbox"], - }), - ).rejects.toThrow(injectedFailure.message); - expect(exitListeners).toHaveLength(1); - exitListeners[0]!(1); - - const failedSession = requireLoadedSession(session.loadSession()); - expect(failedSession).toMatchObject({ - status: "failed", - provider: null, - model: null, - stationExpressIntent: intent, - steps: { provider_selection: { status: "failed" } }, - }); - - for (const name of [ - "NEMOCLAW_STATION_EXPRESS", - "NEMOCLAW_NON_INTERACTIVE", - "NEMOCLAW_YES", - "NEMOCLAW_POLICY_MODE", - "NEMOCLAW_SANDBOX_NAME", - "NEMOCLAW_PROVIDER", - "NEMOCLAW_VLLM_MODEL", - "NEMOCLAW_MODEL", - "NEMOCLAW_STATION_EXPRESS_RECEIPT_GENERATION", - ]) { - vi.stubEnv(name, ""); - } - - const resumedSetup = vi.fn(async () => { - expect(process.env).toMatchObject({ - NEMOCLAW_STATION_EXPRESS: "1", - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_PROVIDER: "install-vllm", - NEMOCLAW_VLLM_MODEL: "nemotron-3-ultra-550b-a55b", - NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b", - NEMOCLAW_STATION_EXPRESS_RECEIPT_GENERATION: receiptGeneration, + bootstrapDeps, + ); + fs.writeFileSync(receipt, receiptText(), { mode: 0o600 }); + expect(requireLoadedSession(session.loadSession()).stationExpressIntent).toEqual(intent); + + const failingRuntime = new OnboardRuntime(); + await failingRuntime.transition("preflight"); + await failingRuntime.transition("gateway"); + await failingRuntime.transition("provider_selection"); + const exitListeners: Array<(code: number) => void> = []; + registerIncompleteOnboardExitHandlerForSession(session, () => false, { + once: (_event, listener) => exitListeners.push(listener), }); - return { - model: "nemotron-ultra", - provider: "vllm-local", - endpointUrl: null, - credentialEnv: null, - hermesAuthMethod: null, - hermesToolGateways: [], - preferredInferenceApi: "openai-responses", - compatibleEndpointReasoning: null, - compatibleEndpointReasoningEffort: null, - nimContainer: null, - vllmModelIdentity: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4", - }; - }); - const resumedRuntime = new OnboardRuntime(); - const resumed = createDeps({ - setupNim: resumedSetup, - startRecordedStep: vi.fn(async (stepName: string) => { - await resumedRuntime.markStepStarted(stepName); - }), - recordStepComplete: vi.fn(async (stepName, updates) => - resumedRuntime.markStepComplete(stepName, updates), - ), - }); - const wrapped = wrapOnboard( - async () => { - const resumedBootstrap = await prepareOnboardSession( - { - resume: true, - fresh: false, - requestedFromDockerfile: null, - requestedSandboxName: process.env.NEMOCLAW_SANDBOX_NAME || null, - cannotPrompt: true, - nonInteractive: true, - }, - bootstrapDeps, - ); - const result = await runOnboardMachine({ + const injectedFailure = new Error("injected managed vLLM download failure"); + const failing = createDeps({ + setupNim: vi.fn(async () => { + throw injectedFailure; + }), + startRecordedStep: vi.fn(async (stepName: string) => { + await failingRuntime.markStepStarted(stepName); + }), + recordStepComplete: vi.fn(async (stepName, updates) => + failingRuntime.markStepComplete(stepName, updates), + ), + }); + await expect( + runOnboardMachine({ context: {}, - runtime: resumedRuntime, + runtime: failingRuntime, handlers: { provider_selection: async () => { - const providerResult = await handleProviderInferenceState({ - ...baseOptions(resumed.deps, resumedBootstrap.session), - resume: true, - sandboxName: process.env.NEMOCLAW_SANDBOX_NAME || null, - env: process.env, + const result = await handleProviderInferenceState({ + ...baseOptions(failing.deps, requireLoadedSession(session.loadSession())), + sandboxName: "my-assistant", }); - return providerResult.stateResults; + return result.stateResults; }, }, stopStates: ["sandbox"], + }), + ).rejects.toThrow(injectedFailure.message); + expect(exitListeners).toHaveLength(1); + exitListeners[0]!(1); + + const failedSession = requireLoadedSession(session.loadSession()); + expect(failedSession).toMatchObject({ + status: "failed", + provider: null, + model: null, + stationExpressIntent: intent, + steps: { provider_selection: { status: "failed" } }, + }); + + for (const name of [ + "NEMOCLAW_STATION_EXPRESS", + "NEMOCLAW_NON_INTERACTIVE", + "NEMOCLAW_YES", + "NEMOCLAW_POLICY_MODE", + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_PROVIDER", + "NEMOCLAW_VLLM_MODEL", + "NEMOCLAW_MODEL", + "NEMOCLAW_STATION_EXPRESS_RECEIPT_GENERATION", + ]) { + vi.stubEnv(name, ""); + } + + const resumedSetup = vi.fn(async () => { + expect(process.env).toMatchObject({ + NEMOCLAW_STATION_EXPRESS: "1", + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "install-vllm", + NEMOCLAW_VLLM_MODEL: "nemotron-3-ultra-550b-a55b", + NEMOCLAW_MODEL: "nvidia/nemotron-3-ultra-550b-a55b", + NEMOCLAW_STATION_EXPRESS_RECEIPT_GENERATION: receiptGeneration, }); - expect(result.session).toMatchObject({ - provider: "vllm-local", + return { model: "nemotron-ultra", - machine: { state: "sandbox" }, - }); - }, - session.loadSession, - session.reconcileStationExpressReceiptRetirement, - ); - - await wrapped({ resume: true }); - - expect(resumedSetup).toHaveBeenCalledTimes(1); - expect(resumed.calls.promptName).not.toHaveBeenCalled(); - expect(requireLoadedSession(session.loadSession())).toMatchObject({ - provider: "vllm-local", - model: "nemotron-ultra", - stationExpressIntent: { - ...intent, - servedModel: "nemotron-ultra", - checkpointModel: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4", - }, - }); - expect(fs.existsSync(receipt)).toBe(true); - - await resumedRuntime.transition("agent_setup"); - await resumedRuntime.transition("policies"); - await resumedRuntime.transition("finalizing"); - await resumedRuntime.transition("post_verify"); - const completionEvents: string[] = []; - const removeCompletionListener = addOnboardMachineEventListener((event) => - completionEvents.push(event.type), - ); - try { - await resumedRuntime.applyResult( - completeOnboardMachine({ - sandboxName: "my-assistant", provider: "vllm-local", - model: "nemotron-ultra", + endpointUrl: null, + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai-responses", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + vllmModelIdentity: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4", + }; + }); + const resumedRuntime = new OnboardRuntime(); + const resumed = createDeps({ + setupNim: resumedSetup, + startRecordedStep: vi.fn(async (stepName: string) => { + await resumedRuntime.markStepStarted(stepName); }), + recordStepComplete: vi.fn(async (stepName, updates) => + resumedRuntime.markStepComplete(stepName, updates), + ), + }); + const wrapped = wrapOnboard( + async () => { + const resumedBootstrap = await prepareOnboardSession( + { + resume: true, + fresh: false, + requestedFromDockerfile: null, + requestedSandboxName: process.env.NEMOCLAW_SANDBOX_NAME || null, + cannotPrompt: true, + nonInteractive: true, + }, + bootstrapDeps, + ); + const result = await runOnboardMachine({ + context: {}, + runtime: resumedRuntime, + handlers: { + provider_selection: async () => { + const providerResult = await handleProviderInferenceState({ + ...baseOptions(resumed.deps, resumedBootstrap.session), + resume: true, + sandboxName: process.env.NEMOCLAW_SANDBOX_NAME || null, + env: process.env, + }); + return providerResult.stateResults; + }, + }, + stopStates: ["sandbox"], + }); + expect(result.session).toMatchObject({ + provider: "vllm-local", + model: "nemotron-ultra", + machine: { state: "sandbox" }, + }); + }, + session.loadSession, + session.reconcileStationExpressReceiptRetirement, ); - } finally { - removeCompletionListener(); - } - expect(requireLoadedSession(session.loadSession())).toMatchObject({ - status: "complete", - resumable: false, - stationExpressIntent: null, - stationExpressReceiptRetirement: null, - }); - expect(fs.existsSync(receipt)).toBe(false); - expect(completionEvents).toEqual([ - "context.updated", - "state.completed", - "state.entered", - "onboard.completed", - ]); - }); + await wrapped({ resume: true }); + + expect(resumedSetup).toHaveBeenCalledTimes(1); + expect(resumed.calls.promptName).not.toHaveBeenCalled(); + expect(requireLoadedSession(session.loadSession())).toMatchObject({ + provider: "vllm-local", + model: "nemotron-ultra", + stationExpressIntent: { + ...intent, + servedModel: "nemotron-ultra", + checkpointModel: "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-NVFP4", + }, + }); + expect(fs.existsSync(receipt)).toBe(true); + + await resumedRuntime.transition("agent_setup"); + await resumedRuntime.transition("policies"); + await resumedRuntime.transition("finalizing"); + await resumedRuntime.transition("post_verify"); + const completionEvents: string[] = []; + const removeCompletionListener = addOnboardMachineEventListener((event) => + completionEvents.push(event.type), + ); + try { + await resumedRuntime.applyResult( + completeOnboardMachine({ + sandboxName: "my-assistant", + provider: "vllm-local", + model: "nemotron-ultra", + }), + ); + } finally { + removeCompletionListener(); + } + + expect(requireLoadedSession(session.loadSession())).toMatchObject({ + status: "complete", + resumable: false, + stationExpressIntent: null, + stationExpressReceiptRetirement: null, + }); + expect(fs.existsSync(receipt)).toBe(false); + expect(completionEvents).toEqual([ + "context.updated", + "state.completed", + "state.entered", + "onboard.completed", + ]); + }, + ); it("atomically binds a route-validated arbitrary alias when provider selection completes", () => { const servedAlias = "nemotron-ultra"; From 4eed4b8f11d725202722cc73cad0fdeaa0c9d000 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 20:08:38 -0700 Subject: [PATCH 09/10] test(tunnel): cover default Ollama cleanup Signed-off-by: Apurv Kumaria --- src/lib/tunnel/services.test.ts | 14 ++++++++++++++ src/lib/tunnel/services.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index 25ee1def93a..ae1eb2d35c6 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -16,6 +16,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; // Import source directly so tests cannot pass against a stale build. +import { unloadOllamaModels as defaultUnloadOllamaModels } from "../inference/ollama/proxy"; import { registerTunnelOrigin } from "./allowed-origins"; import { resolveDefaultSandboxName } from "./service-command"; import { @@ -33,6 +34,7 @@ import { // writes; stub it so these tests exercise only the wiring (tunnel-URL and // sandbox-name discovery plus the skip/guard branches), never openshell/docker. vi.mock("./allowed-origins", () => ({ registerTunnelOrigin: vi.fn() })); +vi.mock("../inference/ollama/proxy", () => ({ unloadOllamaModels: vi.fn() })); const INTEGRATION_ENV_SANDBOX = "nc1077-env-sandbox"; const INTEGRATION_REGISTRY_SANDBOX = "nc1077-registry-sandbox"; @@ -445,6 +447,7 @@ describe("stopAll", () => { beforeEach(() => { pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-svc-test-")); unloadOllamaModels = vi.fn(); + vi.mocked(defaultUnloadOllamaModels).mockClear(); }); afterEach(() => { @@ -575,6 +578,17 @@ describe("stopAll", () => { expect(unloadOllamaModels).toHaveBeenCalledOnce(); expect(unloadOllamaModels.mock.invocationCallOrder[0]).toBeLessThan(stoppedCallOrder ?? 0); }); + + it("uses the default Ollama cleanup through the public stop path (#8199)", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + stopAll({ pidDir }); + } finally { + logSpy.mockRestore(); + } + + expect(defaultUnloadOllamaModels).toHaveBeenCalledOnce(); + }); }); // #6212: after cloudflared yields a public URL, startAll must register that diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index ffabc6c480d..461b18405be 100644 --- a/src/lib/tunnel/services.ts +++ b/src/lib/tunnel/services.ts @@ -19,6 +19,7 @@ import { renderBox } from "../cli/banner"; import { AGENT_PRODUCT_NAME, CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; import { isObjectRecord } from "../core/json-types"; import { DASHBOARD_PORT } from "../core/ports"; +import { unloadOllamaModels as unloadDefaultOllamaModels } from "../inference/ollama/proxy"; import { buildSubprocessEnv } from "../subprocess-env"; import * as agentForwardStop from "./agent-forward-stop"; import { registerTunnelOrigin } from "./allowed-origins"; @@ -522,8 +523,7 @@ export function stopAll(opts: ServiceOptions = {}): void { } try { - const unloadOllamaModels = - opts.unloadOllamaModels ?? require("../inference/ollama/proxy").unloadOllamaModels; + const unloadOllamaModels = opts.unloadOllamaModels ?? unloadDefaultOllamaModels; unloadOllamaModels(); } catch { /* best-effort */ From 6bca27be8f5793a57774dd3a6b9f90331c48e8a8 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 4 Aug 2026 22:10:50 -0700 Subject: [PATCH 10/10] test(tunnel): remove cache-sensitive cleanup assertion Signed-off-by: Apurv Kumaria --- src/lib/tunnel/services.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index 5b7f2b14efd..fcb1ca6a784 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -594,18 +594,6 @@ describe("stopAll", () => { logSpy.mockRestore(); }); - it("unloads default Ollama models before reporting services stopped", () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); - logSpy.mockRestore(); - - const psCall = spawnSyncCalls.find( - (call) => call.command === "curl" && call.args.some((arg) => arg.endsWith("/api/ps")), - ); - expect(psCall).toBeDefined(); - expect(psCall?.args).toContain("--max-time"); - }); - it("runs injected Ollama cleanup before reporting services stopped", () => { const cleanup = vi.fn(); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});