Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/inference/set-up-ollama.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ When neither side can be read, the failure asks you to check that Ollama is inst
Fresh installs skip this second probe because the bundled installers provide a daemon at or above the minimum.

The version gate does not apply to Windows-host Ollama reached from Docker Desktop through `host.docker.internal`.
With WSL mirrored networking, the same daemon can answer on `127.0.0.1`; NemoClaw treats it as Windows-host Ollama only when Windows installation and Docker reachability checks match and Linux procfs shows no WSL-local listener on the Ollama port.
Ambiguous evidence or a separate WSL-local listener stays on the Linux install and upgrade path.
The Windows-host menu entries perform their own actions on the Windows side.

## Choose a Linux Install Mode
Expand Down
1 change: 1 addition & 0 deletions src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function resetOllamaContainerPortCache(): void {
export const HOST_GATEWAY_URL = "http://host.openshell.internal";
export const LOCAL_INFERENCE_SANDBOX_HOST_URL_ENV = "NEMOCLAW_LOCAL_INFERENCE_SANDBOX_HOST_URL";
export { CONTAINER_REACHABILITY_IMAGE } from "../adapters/http/container-curl-probe";
export { OLLAMA_PORT };

// These tags are convenience aliases for callers that want to refer to a
// specific bootstrap model by role rather than by string. The canonical
Expand Down
21 changes: 21 additions & 0 deletions src/lib/onboard/ollama-install-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,27 @@ describe("resolveOllamaInstallMenuEntry", () => {
expect(result.entry).toBeNull();
});

it("does not route mirrored Windows-host Ollama through the WSL installer (#9300)", () => {
const result = resolveOllamaInstallMenuEntry({
hasOllama: true,
ollamaRunning: true,
hasWindowsOllama: true,
windowsHostOllamaSupported: true,
isWindowsHostOllama: true,
ollamaHost: "127.0.0.1",
installedOllamaVersion: "0.32.5",
runningOllamaVersion: "0.32.5",
platform: "linux",
isWsl: true,
});

expect(result).toEqual({
entry: null,
hasUpgradableOllama: false,
binaryNeedsUpgrade: false,
});
});

it("omits the entry when only Windows-host Ollama is present", () => {
const result = resolveOllamaInstallMenuEntry({
hasOllama: false,
Expand Down
15 changes: 12 additions & 3 deletions src/lib/onboard/ollama-install-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface OllamaInstallMenuInput {
* routing covers nothing, so the WSL-local install entry stays on offer.
* Only read when `hasWindowsOllama` is set; defaults to reachable. */
windowsHostOllamaSupported?: boolean;
/** True when the responding daemon is known to run on Windows, including
* WSL mirrored networking where it is observed through distro loopback. */
isWindowsHostOllama?: boolean;
platform: NodeJS.Platform;
isWsl: boolean;
/** Resolved host for the running Ollama daemon. `host.docker.internal`
Expand Down Expand Up @@ -138,7 +141,9 @@ export function resolveOllamaInstallMenuEntry(
// 127.0.0.1/localhost. A Windows-host daemon reached via
// `host.docker.internal` is handled by separate menu entries
// (`install-windows-ollama` / `start-windows-ollama`).
const daemonProbeApplies = input.ollamaRunning && isLocalOllamaHost(input.ollamaHost);
const localUpgradeApplies = input.isWindowsHostOllama !== true;
const daemonProbeApplies =
localUpgradeApplies && input.ollamaRunning && isLocalOllamaHost(input.ollamaHost);
const runningOllamaVersion =
input.runningOllamaVersion !== undefined
? input.runningOllamaVersion
Expand All @@ -150,14 +155,18 @@ export function resolveOllamaInstallMenuEntry(
// on the old version (and vice versa). Upgrade when either source is below
// the minimum.
const installedBinaryMeetsMinimum =
input.hasOllama && isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION);
localUpgradeApplies &&
input.hasOllama &&
isOllamaVersionAtLeast(installedOllamaVersion, MIN_OLLAMA_VERSION);
const daemonNeedsUpgrade =
daemonProbeApplies && !isOllamaVersionAtLeast(runningOllamaVersion, MIN_OLLAMA_VERSION);
// Restart-only recovery is safe only with positive evidence that the
// installed binary meets the floor. A stale daemon without a local binary
// still needs the installer to provide one.
const binaryNeedsUpgrade =
!installedBinaryMeetsMinimum && (input.hasOllama || daemonNeedsUpgrade);
localUpgradeApplies &&
!installedBinaryMeetsMinimum &&
(input.hasOllama || daemonNeedsUpgrade);
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,
Expand Down
120 changes: 119 additions & 1 deletion src/lib/onboard/provider-host-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MIN_OLLAMA_VERSION } from "../inference/ollama-version";
import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology";
import {
type DetectInferenceProviderHostStateDeps,
detectLocalTcpListener,
detectInferenceProviderHostState,
type InferenceProviderHostGpu,
} from "./provider-host-state";
Expand Down Expand Up @@ -40,6 +41,7 @@ function buildDeps(
getWindowsHostOllamaDockerRequirement: vi.fn(() => SUPPORTED_WINDOWS_OLLAMA),
detectVllmProfile: vi.fn(() => null),
getLocalProviderAvailabilityEndpoint: vi.fn(() => "http://127.0.0.1:8000/v1/models"),
detectLocalTcpListener: vi.fn(() => null),
...overrides,
};
}
Expand Down Expand Up @@ -294,7 +296,7 @@ describe("detectInferenceProviderHostState", () => {
expect(isWsl).toHaveBeenCalledWith({ platform: "linux", env });
});

it("suppresses the duplicate-daemon warning when WSL mirrored networking makes the probes equivalent", () => {
it("classifies a mirrored loopback daemon as Windows-host Ollama (#9300)", () => {
const logs: string[] = [];
const deps = buildDeps({
isWsl: vi.fn(() => true),
Expand All @@ -310,6 +312,7 @@ describe("detectInferenceProviderHostState", () => {
return "";
}),
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
detectLocalTcpListener: vi.fn(() => false),
});

const state = detectInferenceProviderHostState({
Expand All @@ -324,9 +327,103 @@ describe("detectInferenceProviderHostState", () => {
});

expect(state.windowsOllamaReachable).toBe(true);
expect(state.isWindowsHostOllama).toBe(true);
expect(state.ollamaInstallMenu.entry).toBeNull();
expect(logs).toEqual([]);
});

it("keeps a mirrored WSL-local daemon on the Linux upgrade path (#9300)", () => {
const logs: string[] = [];
const deps = buildDeps({
isWsl: vi.fn(() => true),
hostCommandExists: vi.fn((command) => command === "ollama"),
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
detectWindowsHostOllama: vi.fn(() => ({
installed: true,
installedPath: "C:\\Ollama\\ollama.exe",
loopbackOnly: false,
})),
runCapture: vi.fn((command) =>
command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "",
),
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
detectLocalTcpListener: vi.fn(() => true),
});

const state = detectInferenceProviderHostState({
gpu: null,
experimental: false,
platform: "linux",
env: {},
log: (message = "") => logs.push(message),
installedOllamaVersion: "0.32.5",
runningOllamaVersion: "0.32.5",
deps,
});

expect(state.windowsOllamaReachable).toBe(true);
expect(state.isWindowsHostOllama).toBe(false);
expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama");
expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true);
expect(logs.join("\n")).toContain("Ollama is running on both WSL and the Windows host");
});

it("fails closed when mirrored listener identity is unavailable (#9300)", () => {
const deps = buildDeps({
isWsl: vi.fn(() => true),
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
detectWindowsHostOllama: vi.fn(() => ({
installed: true,
installedPath: "C:\\Ollama\\ollama.exe",
loopbackOnly: false,
})),
runCapture: vi.fn((command) =>
command.join(" ").includes("wslinfo --networking-mode") ? "mirrored\n" : "",
),
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
detectLocalTcpListener: vi.fn(() => null),
});

const state = detectWithDeps(deps);

expect(state.isWindowsHostOllama).toBe(false);
});

it("keeps an unrecognized WSL networking mode on the Linux upgrade path (#9300)", () => {
const detectLocalTcpListener = vi.fn(() => false);
const deps = buildDeps({
isWsl: vi.fn(() => true),
hostCommandExists: vi.fn((command) => command === "ollama"),
findReachableOllamaHost: vi.fn(() => "127.0.0.1"),
detectWindowsHostOllama: vi.fn(() => ({
installed: true,
installedPath: "C:\\Ollama\\ollama.exe",
loopbackOnly: false,
})),
runCapture: vi.fn((command) =>
command.join(" ").includes("wslinfo --networking-mode") ? "future-mode\n" : "",
),
dockerCapture: vi.fn((command) => (command.at(-1) === WINDOWS_OLLAMA_TAGS_URL ? "{}" : "")),
detectLocalTcpListener,
});

const state = detectInferenceProviderHostState({
gpu: null,
experimental: false,
platform: "linux",
env: {},
log: () => undefined,
installedOllamaVersion: "0.32.5",
runningOllamaVersion: "0.32.5",
deps,
});

expect(state.isWindowsHostOllama).toBe(false);
expect(state.ollamaInstallMenu.entry?.key).toBe("install-ollama");
expect(state.ollamaInstallMenu.hasUpgradableOllama).toBe(true);
expect(detectLocalTcpListener).not.toHaveBeenCalled();
});

it("does not probe the Windows-host switch path when running Ollama already resolves to the Windows host", () => {
const runCapture = vi.fn<DetectInferenceProviderHostStateDeps["runCapture"]>(() => "");
const dockerCapture = vi.fn<DetectInferenceProviderHostStateDeps["dockerCapture"]>(() => "");
Expand All @@ -349,3 +446,24 @@ describe("detectInferenceProviderHostState", () => {
expect(dockerCapture).not.toHaveBeenCalled();
});
});

describe("detectLocalTcpListener", () => {
it("distinguishes Linux listeners from an empty procfs socket table (#9300)", () => {
const header = " sl local_address rem_address st\n";
const listener = `${header} 0: 0100007F:2CAA 00000000:0000 0A\n`;

expect(detectLocalTcpListener(11434, () => listener)).toBe(true);
expect(detectLocalTcpListener(11434, () => header)).toBe(false);
});

it("fails closed when procfs is unavailable or malformed (#9300)", () => {
expect(detectLocalTcpListener(11434, () => null)).toBeNull();
expect(detectLocalTcpListener(11434, () => "header\nmalformed\n")).toBeNull();
expect(
detectLocalTcpListener(11434, (filePath) =>
filePath.endsWith("tcp") ? " sl local_address rem_address st\n" : null,
),
).toBeNull();
expect(detectLocalTcpListener(0, () => "unused")).toBeNull();
});
});
Loading
Loading