diff --git a/docs/get-started/windows-preparation.mdx b/docs/get-started/windows-preparation.mdx index 2ad8cd37e19..26eea84f945 100644 --- a/docs/get-started/windows-preparation.mdx +++ b/docs/get-started/windows-preparation.mdx @@ -170,7 +170,10 @@ 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 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. 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 a91e46be69a..5f6232a4501 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3512,6 +3512,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 @@ -4190,6 +4191,48 @@ express_wsl_can_use_windows_host_ollama() { express_wsl_docker_operating_system | grep -qi 'docker desktop' } +# 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 + ! 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" @@ -4222,11 +4265,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 } @@ -4698,6 +4737,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 @@ -4979,6 +5020,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 ffbfcac4113..740730f885a 100644 --- a/src/lib/onboard/provider-host-state.test.ts +++ b/src/lib/onboard/provider-host-state.test.ts @@ -216,6 +216,28 @@ describe("detectInferenceProviderHostState", () => { expect(deps.getWindowsHostOllamaDockerRequirement).toHaveBeenCalledWith("docker-desktop"); }); + 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-desktop", + ), + 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.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)"); + }); + 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 5134774bb4e..41802f0d204 100644 --- a/src/lib/onboard/provider-host-state.ts +++ b/src/lib/onboard/provider-host-state.ts @@ -228,6 +228,8 @@ export function detectInferenceProviderHostState( hasOllama, ollamaRunning, hasWindowsOllama, + windowsHostOllamaSupported: + windowsHostOllamaDockerRequirement.supported && windowsOllamaReachable, ollamaHost, platform, isWsl, diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index d6b49278d84..a1c39c25c89 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -611,6 +611,27 @@ describe("shields — unit logic", () => { expect(writeTempPolicy).not.toHaveBeenCalled(); }); + 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"); + 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 = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { + managedMcpPolicies: [], + snapshotManagedPolicyKeys: [], + readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), + }); + + expect(result).toEqual({ path: snapshotPath, omissions: [] }); + expect(createTempDirectory).not.toHaveBeenCalled(); + }); + it("shieldsStatus warns and stays DOWN when inline recovery fails", async () => { const sandboxName = "openclaw"; const missingSnapshotPath = path.join(stateDir(), "missing-snapshot.yaml"); 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"; diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index 2d30241a52a..fcb1ca6a784 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -594,16 +594,18 @@ describe("stopAll", () => { logSpy.mockRestore(); }); - it("unloads Ollama models before reporting services stopped", () => { + it("runs injected Ollama cleanup before reporting services stopped", () => { + const cleanup = vi.fn(); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - stopAll({ pidDir }); + stopAll({ pidDir, unloadOllamaModels: cleanup }); + 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(cleanup).toHaveBeenCalledOnce(); + expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan(stoppedCallOrder ?? 0); }); }); diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts index db15e923d22..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"; @@ -43,6 +44,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 +523,7 @@ export function stopAll(opts: ServiceOptions = {}): void { } try { - const { unloadOllamaModels } = require("../inference/ollama/proxy"); + const unloadOllamaModels = opts.unloadOllamaModels ?? unloadDefaultOllamaModels; unloadOllamaModels(); } catch { /* best-effort */ diff --git a/test/install-express-wsl-ollama.test.ts b/test/install-express-wsl-ollama.test.ts index b169db3f686..2167ec689e4 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, @@ -219,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` + @@ -248,6 +262,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 +300,97 @@ 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 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` + `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("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` + + `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("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)", () => { + 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` +