Skip to content
4 changes: 4 additions & 0 deletions docs/inference/use-local-inference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ Refer to [Tool-Calling Reliability](tool-calling-reliability).

On non-WSL hosts, NemoClaw keeps Ollama bound to `127.0.0.1:11434` and starts a token-gated reverse proxy on `0.0.0.0:11435`.
The native install/start paths also reset NemoClaw-managed systemd launches to the loopback binding.
When non-interactive Linux onboarding finds an existing systemd Ollama service but cannot use passwordless sudo, it checks the service state and active TCP listeners before continuing.
It continues without rewriting the systemd drop-in only when the service is active, `ss` reports at least one listener on port `11434`, and every reported address is loopback-only.
Wildcard and non-loopback addresses, as well as missing or unreadable service or listener evidence, are treated as unverified.
In those cases, onboarding exits before configuring the proxy and asks you to rerun from a terminal with `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt` or configure passwordless sudo.
Containers and other hosts on the local network reach Ollama only through the proxy, which validates a Bearer token before forwarding requests.
On that native path, NemoClaw never exposes Ollama without authentication.

Expand Down
150 changes: 148 additions & 2 deletions src/lib/onboard/ollama-systemd.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { OLLAMA_PORT } from "../core/ports";
import { MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import { mergeOllamaLoopbackSystemdOverride } from "./ollama-systemd";
import {
ensureOllamaLoopbackSystemdOverride,
mergeOllamaLoopbackSystemdOverride,
ollamaListenersAreLoopbackOnly,
shouldSkipOllamaLoopbackForMissingSudo,
} from "./ollama-systemd";

const SUDO_MODE_ENV = "NEMOCLAW_NON_INTERACTIVE_SUDO_MODE";

// Restore a process.env entry to its saved value (or delete it when the entry
// was previously unset). Lives at top of file so test bodies stay linear per
// the repository's growth guardrail on conditional branching in test files.
function restoreEnv(key: string, previous: string | undefined): void {
previous === undefined ? delete process.env[key] : (process.env[key] = previous);
}

describe("mergeOllamaLoopbackSystemdOverride", () => {
it("writes the OLLAMA_HOST and OLLAMA_CONTEXT_LENGTH lines under [Service] when no drop-in exists", () => {
Expand Down Expand Up @@ -86,3 +100,135 @@ describe("mergeOllamaLoopbackSystemdOverride", () => {
);
});
});

describe("ollamaListenersAreLoopbackOnly", () => {
it("accepts IPv4, IPv6, and IPv4-mapped loopback listeners", () => {
const output = [
"LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:*",
"LISTEN 0 4096 [::1]:11434 [::]:*",
"LISTEN 0 4096 [::ffff:127.0.0.2]:11434 [::]:*",
].join("\n");
expect(ollamaListenersAreLoopbackOnly(output)).toBe(true);
});

it("rejects wildcard or non-loopback Ollama listeners", () => {
expect(ollamaListenersAreLoopbackOnly("LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:*")).toBe(false);
expect(ollamaListenersAreLoopbackOnly("LISTEN 0 4096 [::]:11434 [::]:*")).toBe(false);
expect(ollamaListenersAreLoopbackOnly("LISTEN 0 4096 192.168.1.8:11434 0.0.0.0:*")).toBe(false);
});

it("rejects missing or unrelated listeners because no positive proof exists", () => {
expect(ollamaListenersAreLoopbackOnly("")).toBe(false);
expect(ollamaListenersAreLoopbackOnly("LISTEN 0 4096 127.0.0.1:11435 0.0.0.0:*")).toBe(false);
});
});

// #5716: on a Linux aarch64 host running `nemoclaw onboard --non-interactive
// --yes` without passwordless sudo, the wizard previously aborted with
// "Refusing to continue with a potentially non-loopback Ollama bind" mid-flow.
// The new behaviour detects the missing `sudo -n` upfront and only skips the
// override after positively verifying the active listener is loopback-only.
describe("ensureOllamaLoopbackSystemdOverride non-interactive sudo (#5716)", () => {
// CR thread: isolate NEMOCLAW_NON_INTERACTIVE_SUDO_MODE so an outer shell
// that has set it to `prompt` cannot change which branch of getSudoPrefix
// these tests exercise. Each test in this block targets the `sudo -n`
// branch and must see the env at its default.
let savedSudoMode: string | undefined;
beforeEach(() => {
savedSudoMode = process.env[SUDO_MODE_ENV];
delete process.env[SUDO_MODE_ENV];
});
afterEach(() => {
restoreEnv(SUDO_MODE_ENV, savedSudoMode);
});

it("continues with a warning when sudo -n is unavailable but the active listener is loopback-only", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = ensureOllamaLoopbackSystemdOverride({
platformImpl: () => "linux",
hasOllamaSystemdUnitImpl: () => true,
isNonInteractive: () => true,
hasPasswordlessSudoImpl: () => false,
isOllamaLoopbackOnlyImpl: () => true,
});
expect(result).toBe("ready");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("already loopback-only"));
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt"),
);
} finally {
warn.mockRestore();
}
});

it("fails before override commands when sudo is unavailable and loopback-only binding is unverified", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const exit = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
try {
expect(() =>
ensureOllamaLoopbackSystemdOverride({
platformImpl: () => "linux",
hasOllamaSystemdUnitImpl: () => true,
isNonInteractive: () => true,
hasPasswordlessSudoImpl: () => false,
isOllamaLoopbackOnlyImpl: () => false,
}),
).toThrow("exit 1");
expect(error).toHaveBeenCalledWith(expect.stringContaining("could not be verified"));
expect(error).toHaveBeenCalledWith(expect.stringContaining("potentially exposed"));
} finally {
exit.mockRestore();
error.mockRestore();
}
});

// Ultra advisor PRA-4 / CR follow-up: pin every branch of the new gate
// via the pure `shouldSkipOllamaLoopbackForMissingSudo` decision so the
// happy path is covered without falling through into the real systemd
// override. Earlier round of this test called
// `ensureOllamaLoopbackSystemdOverride` with
// `hasPasswordlessSudoImpl: () => true`; on a Linux CI runner with
// passwordless sudo, that would exec the real `sudo install / daemon-
// reload / restart` host-boundary commands. CodeRabbit flagged that as
// a non-hermetic unit test. The pure helper covers the contract without
// any host-boundary touch.
it("skips when getSudoPrefix is 'sudo -n' AND passwordless sudo is unavailable", () => {
expect(shouldSkipOllamaLoopbackForMissingSudo("sudo -n", () => false)).toBe(true);
});

it("does NOT skip when getSudoPrefix is 'sudo -n' but passwordless sudo IS available", () => {
expect(shouldSkipOllamaLoopbackForMissingSudo("sudo -n", () => true)).toBe(false);
});

it("does NOT skip when getSudoPrefix is 'sudo' (interactive), regardless of probe", () => {
expect(shouldSkipOllamaLoopbackForMissingSudo("sudo", () => false)).toBe(false);
expect(shouldSkipOllamaLoopbackForMissingSudo("sudo", () => true)).toBe(false);
});

it("does not invoke the passwordless-sudo probe when sudoPrefix is 'sudo'", () => {
const probe = vi.fn(() => false);
shouldSkipOllamaLoopbackForMissingSudo("sudo", probe);
expect(probe).not.toHaveBeenCalled();
});

it("returns before Linux-only probes when not on Linux", () => {
// CR thread: prove the platform gate by making the Linux-only probes
// throw if reached. The platformImpl returns "darwin", so the function
// should return "not-applicable" before touching any of the Linux
// probes below.
const result = ensureOllamaLoopbackSystemdOverride({
platformImpl: () => "darwin",
hasOllamaSystemdUnitImpl: () => {
throw new Error("systemd probe should not run on non-Linux");
},
hasPasswordlessSudoImpl: () => {
throw new Error("sudo probe should not run on non-Linux");
},
isNonInteractive: () => true,
});
expect(result).toBe("not-applicable");
});
});
139 changes: 129 additions & 10 deletions src/lib/onboard/ollama-systemd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,29 @@ type OllamaLoopbackSystemdOverrideOptions = {
enableService?: boolean;
detectNvidiaPlatformImpl?: () => string;
hasOllamaCudaV13LibraryImpl?: () => boolean;
/**
* Platform override (test seam). Defaults to `process.platform`. Lets unit
* tests exercise the Linux-only branches from non-Linux dev hosts.
*/
platformImpl?: () => NodeJS.Platform;
/**
* Override the systemd-ollama-unit detection. Defaults to a `systemctl
* list-unit-files` probe. Lets unit tests skip the host check so the new
* #5716 sudo fall-through can be reached deterministically.
*/
hasOllamaSystemdUnitImpl?: () => boolean;
/**
* Optional probe for passwordless sudo. Returns true when `sudo -n true`
* succeeds. Defaults to running it via `runShell`. Exposed so tests can
* exercise the #5716 "no passwordless sudo" fall-through deterministically
* without needing a real sudoers config.
*/
hasPasswordlessSudoImpl?: () => boolean;
/**
* Positive runtime proof that the active systemd Ollama listener is bound
* only to loopback. Defaults to a non-privileged `systemctl` + `ss` probe.
*/
isOllamaLoopbackOnlyImpl?: () => boolean;
};

function isEnvNonInteractive(): boolean {
Expand All @@ -46,6 +69,72 @@ function getSudoPrefix(isNonInteractive: boolean): "sudo" | "sudo -n" {
return process.stdin.isTTY ? "sudo" : "sudo -n";
}

/**
* Pure decision for the #5716 sudo-skip gate. The override step requires
* root via `sudo -n install / daemon-reload / restart`, so a non-interactive
* run that picked the `sudo -n` prefix and has no passwordless sudo cannot
* apply it. Returns true when the override MUST be skipped.
*
* Exposed so tests can pin both branches (skip + happy path) without falling
* through into the real systemd code and triggering host-boundary side
* effects on a Linux CI runner that happens to have passwordless sudo.
*/
export function shouldSkipOllamaLoopbackForMissingSudo(
sudoPrefix: "sudo" | "sudo -n",
hasPasswordlessSudo: () => boolean,
): boolean {
return sudoPrefix === "sudo -n" && !hasPasswordlessSudo();
}

function defaultHasPasswordlessSudo(): boolean {
const probe = runShell("sudo -n true", {
ignoreError: true,
suppressOutput: true,
timeout: 5_000,
});
return !probe.error && probe.status === 0;
}

function parseListenerEndpoint(token: string): { host: string; port: number } | null {
const match = token.match(/^(?:\[([^\]]+)\]|(.+)):(\d+)$/u);
if (!match) return null;
return {
host: String(match[1] ?? match[2])
.replace(/%[^%]+$/u, "")
.toLowerCase(),
port: Number(match[3]),
};
}

/** Return true only when at least one Ollama listener exists and all are loopback-only. */
export function ollamaListenersAreLoopbackOnly(output: string): boolean {
const hosts = output.split(/\r?\n/u).flatMap((line) => {
const endpoint = parseListenerEndpoint(line.trim().split(/\s+/u)[3] ?? "");
return endpoint?.port === OLLAMA_PORT ? [endpoint.host] : [];
});
return (
hosts.length > 0 &&
hosts.every(
(host) =>
host === "::1" ||
/^127(?:\.\d{1,3}){3}$/u.test(host) ||
/^::ffff:127(?:\.\d{1,3}){3}$/u.test(host),
)
);
}

function isActiveOllamaListenerLoopbackOnly(): boolean {
const listeners = runCapture(
[
"sh",
"-c",
"command -v systemctl >/dev/null && command -v ss >/dev/null && systemctl is-active --quiet ollama.service && ss -H -ltn 2>/dev/null",
],
{ ignoreError: true },
);
return ollamaListenersAreLoopbackOnly(listeners);
}

function hasOllamaCudaV13Library(): boolean {
const ollamaPath = runCapture(["sh", "-c", "command -v ollama"], { ignoreError: true }).trim();
const candidates = [
Expand Down Expand Up @@ -91,25 +180,55 @@ export function ensureOllamaLoopbackSystemdOverride(
// now handles bridge-network reachability for both native-Docker-in-WSL and
// non-WSL Linux, so loopback binding is the right policy everywhere. See
// issues #3342 (re-onboard repair) and #3695 (WSL native Docker).
if (process.platform !== "linux") return "not-applicable";
const platform = (options.platformImpl ?? (() => process.platform))();
if (platform !== "linux") return "not-applicable";

const hasOllamaSystemdUnit = !!runCapture(
[
"sh",
"-c",
"command -v systemctl >/dev/null && [ -d /run/systemd/system ] && systemctl list-unit-files ollama.service --no-legend 2>/dev/null | head -n1",
],
{ ignoreError: true },
).trim();
const hasOllamaSystemdUnit =
options.hasOllamaSystemdUnitImpl?.() ??
!!runCapture(
[
"sh",
"-c",
"command -v systemctl >/dev/null && [ -d /run/systemd/system ] && systemctl list-unit-files ollama.service --no-legend 2>/dev/null | head -n1",
],
{ ignoreError: true },
).trim();
if (!hasOllamaSystemdUnit) return "not-applicable";

// #5716: detect missing non-interactive sudo before attempting any override
// command. Continuing is safe only when runtime listener evidence proves
// the active systemd service is already loopback-only. A loopback HTTP
// response is not enough because a wildcard bind responds there too.
const sudoPrefix = getSudoPrefix((options.isNonInteractive ?? isEnvNonInteractive)());
const hasPasswordlessSudo = options.hasPasswordlessSudoImpl ?? defaultHasPasswordlessSudo;
if (shouldSkipOllamaLoopbackForMissingSudo(sudoPrefix, hasPasswordlessSudo)) {
const loopbackOnly =
options.isOllamaLoopbackOnlyImpl?.() ?? isActiveOllamaListenerLoopbackOnly();
if (loopbackOnly) {
console.warn(
" Passwordless sudo is not available; verified that the active Ollama service " +
"is already loopback-only, so onboarding will continue without rewriting its " +
`systemd drop-in. Set ${NON_INTERACTIVE_SUDO_MODE_ENV}=prompt to apply the managed override.`,
);
return "ready";
}
console.error(
" Passwordless sudo is not available, and the active Ollama listener could not be " +
"verified as loopback-only.",
);
console.error(
` Refusing to continue with a potentially exposed Ollama bind. Set ${NON_INTERACTIVE_SUDO_MODE_ENV}=prompt ` +
"with a terminal, or configure passwordless sudo and rerun onboarding.",
);
process.exit(1);
}

console.log(" Configuring Ollama systemd loopback override...");
console.log(
` Applying an Ollama systemd override (OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT}). ` +
"The next steps use sudo to write the drop-in, reload systemd, and restart the service; " +
"you may be prompted for your password.",
);
const sudoPrefix = getSudoPrefix((options.isNonInteractive ?? isEnvNonInteractive)());
const existingDropInResult = runShell(
[
`if [ -r ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)} ]; then`,
Expand Down
Loading