Skip to content
117 changes: 115 additions & 2 deletions src/lib/onboard/ollama-systemd.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
// 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,
} 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 +98,104 @@ describe("mergeOllamaLoopbackSystemdOverride", () => {
);
});
});

// #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 skips the
// loopback override with an actionable warning so the headless install can
// continue against Ollama's existing bind.
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("skips the override with a warning when sudo -n is unavailable in non-interactive mode", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = ensureOllamaLoopbackSystemdOverride({
platformImpl: () => "linux",
hasOllamaSystemdUnitImpl: () => true,
isNonInteractive: () => true,
hasPasswordlessSudoImpl: () => false,
});
expect(result).toBe("not-applicable");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("passwordless sudo is not available"),
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt"),
);
} finally {
warn.mockRestore();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

// Ultra advisor PRA-4: prove the happy path. When passwordless sudo IS
// available, the new skip-with-warning gate must NOT fire and must not
// emit the "passwordless sudo is not available" warning. Without this
// assertion, a regression that inverted the gate condition would still
// pass every other test in this block (they all hit the skip path or
// an early return). The function continues into the live override path
// afterwards, which then fails downstream because we are not on a real
// Linux+systemd+Ollama host; we tolerate that downstream failure (via
// a process.exit stub) because it is OUT OF SCOPE for this test, which
// is solely about the new gate not skipping when sudo IS available.
it("does NOT skip the override when passwordless sudo IS available", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("exit-stub");
}) as never);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
try {
ensureOllamaLoopbackSystemdOverride({
platformImpl: () => "linux",
hasOllamaSystemdUnitImpl: () => true,
isNonInteractive: () => true,
hasPasswordlessSudoImpl: () => true,
});
} catch (err) {
// Downstream override (real runShell against a non-existent
// systemd unit) throws via our process.exit stub. Anything else
// re-raises so the assertion sees the real failure.
expect((err as Error).message).toBe("exit-stub");
}
const passwordlessSudoWarnings = warn.mock.calls
.map((c) => c.join(" "))
.filter((line) => line.includes("passwordless sudo is not available"));
expect(passwordlessSudoWarnings).toHaveLength(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} finally {
warn.mockRestore();
exit.mockRestore();
error.mockRestore();
}
});

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");
});
});
85 changes: 75 additions & 10 deletions src/lib/onboard/ollama-systemd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ 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;
};

function isEnvNonInteractive(): boolean {
Expand Down Expand Up @@ -91,25 +109,72 @@ 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: a non-interactive run on a host without passwordless sudo cannot
// apply the loopback override (every `sudo -n ...` below would error). The
// installer used to march into the override and abort mid-flight with
// "Refusing to continue with a potentially non-loopback Ollama bind",
// breaking the headless install contract. Detect the unavailable sudo
// upfront, log the actionable reason, and skip the override instead of
// exiting. Ollama keeps running on its existing bind; the host operator
// can re-run with NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt or grant
// passwordless sudo to restore the loopback hardening.
//
// Trade-off (advisor PRA-2): this fall-through accepts a weaker security
// posture (Ollama may continue on a non-loopback bind) in exchange for
// not breaking the documented headless install contract. The escape
// hatches are deliberate and discoverable:
// - NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt opts back into a real
// sudo prompt when a TTY is attached, restoring the loopback override
// end-to-end.
// - The warning surfaces the exact override knob in stderr so an
// operator scanning logs (or a CI run capturing them) sees how to
// re-tighten without grepping the source.
// Aborting destroy-style with process.exit(1) here is rejected because
// headless pipelines have no recourse: they cannot answer a password
// prompt and the only signal they get is exit 1 with no actionable next
// step (#5716 reproduction).
const sudoPrefix = getSudoPrefix((options.isNonInteractive ?? isEnvNonInteractive)());
const hasPasswordlessSudo =
options.hasPasswordlessSudoImpl ??
(() => {
const probe = runShell("sudo -n true", {
ignoreError: true,
suppressOutput: true,
timeout: 5_000,
});
return !probe.error && probe.status === 0;
});
if (sudoPrefix === "sudo -n" && !hasPasswordlessSudo()) {
console.warn(
" Skipping Ollama systemd loopback override: passwordless sudo is not available " +
"on this host. Ollama will keep its current bind; set " +
`${NON_INTERACTIVE_SUDO_MODE_ENV}=prompt to allow a password prompt, ` +
"or configure passwordless sudo to restore loopback hardening.",
);
return "not-applicable";
}

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