From 6e479e5c1b9b3f87304d7fc2ecd428293f1f6718 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 23 May 2026 19:26:09 +0000 Subject: [PATCH 1/2] feat(onboard): add user-local Ollama install fallback for non-interactive Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `install-ollama` provider previously called `https://ollama.com/install.sh` unconditionally. The official installer is sudo-bound, so a headless `NEMOCLAW_NON_INTERACTIVE=1` run without passwordless sudo crashed at the sudo password prompt mid-install. Replicate the binary-extraction portion of `install.sh` (lines 159-187) into `src/lib/onboard/install-ollama-linux.ts`, targeting `${HOME}/.local` and running without sudo, systemd, or the `ollama` system user. Use the same auto-detect decision tree as `scripts/install-openshell.sh` (root or passwordless sudo → system; non-interactive headless → user-local; interactive → system so sudo can prompt). Add `NEMOCLAW_OLLAMA_INSTALL_MODE` for explicit override. Resolves #4114 Signed-off-by: Tinson Lai --- docs/inference/use-local-inference.mdx | 20 +- docs/reference/commands.mdx | 1 + src/lib/onboard.ts | 54 +-- src/lib/onboard/install-ollama-linux.test.ts | 370 +++++++++++++++++ src/lib/onboard/install-ollama-linux.ts | 403 +++++++++++++++++++ test/onboard-selection.test.ts | 202 ++++++++++ 6 files changed, 1000 insertions(+), 50 deletions(-) create mode 100644 src/lib/onboard/install-ollama-linux.test.ts create mode 100644 src/lib/onboard/install-ollama-linux.ts diff --git a/docs/inference/use-local-inference.mdx b/docs/inference/use-local-inference.mdx index 96341ac4865..88ed54d9dd4 100644 --- a/docs/inference/use-local-inference.mdx +++ b/docs/inference/use-local-inference.mdx @@ -32,9 +32,23 @@ The onboard wizard detects Ollama automatically when it is installed or running If Ollama is installed but not running, NemoClaw starts it for you. On macOS and Linux, the wizard can also offer to install Ollama when it is not present. On WSL, the wizard can use, start, restart, or install Ollama on the Windows host through PowerShell interop. -On Debian and Ubuntu, the native Linux install path checks for `zstd` before it runs the Ollama installer. -If `zstd` is missing, NemoClaw installs it with `apt-get` and explains the sudo prompt before continuing. -On non-apt Linux distributions, install `zstd` first, then rerun onboarding. + +#### Linux Install Modes + +On native Linux, the install path picks between a system install (under `/usr/local`, via the official `https://ollama.com/install.sh`) and a sudo-free user-local install (under `${HOME}/.local`). +The choice is made automatically: + +- Running as root or with passwordless sudo (`sudo -n true` returns 0) selects the system install. +- A non-interactive run (`NEMOCLAW_NON_INTERACTIVE=1` or no TTY on stdin) without passwordless sudo selects the user-local install. This is the path that lets headless hosts complete onboarding without prompting for a sudo password. +- An interactive shell without passwordless sudo selects the system install and lets the official installer prompt for the password as usual. + +Override the detection with `NEMOCLAW_OLLAMA_INSTALL_MODE=system` or `NEMOCLAW_OLLAMA_INSTALL_MODE=user`. + +The user-local install replicates only the binary extraction step of the official installer. It downloads the release tarball, extracts it to `${HOME}/.local`, and launches `${HOME}/.local/bin/ollama serve` once. It does not configure a systemd service, does not create the `ollama` system user, and does not install CUDA drivers, so the daemon must be relaunched manually after a reboot. +NemoClaw also prints a one-line `PATH` hint if `${HOME}/.local/bin` is not already on your `PATH`; you can add `export PATH="${HOME}/.local/bin:$PATH"` to your shell profile to invoke `ollama` directly. + +Both modes rely on `zstd` for archive extraction. On Debian and Ubuntu, the system path uses `sudo apt-get` to install `zstd` automatically and explains the prompt before continuing. +The user-local path cannot bootstrap system packages without elevation, so if `zstd` is missing it prints per-distro install hints and exits — install `zstd` manually, then rerun onboarding. Run the onboard wizard. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0aa7b0ef66f..d7808ac78a7 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1244,6 +1244,7 @@ Set them before running `nemoclaw onboard`. | `NEMOCLAW_REASONING` | `true` or `false` | Overrides the model's reasoning-mode flag in the built OpenClaw config. | | `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | duration with `s`, `m`, or `h` suffix (for example `30m`, `1h`, or `0m`) | Overrides `agents.defaults.heartbeat.every` in the built OpenClaw config. Set `0m` to disable periodic agent turns. | | `NEMOCLAW_OLLAMA_REQUIRE_TOOLS` | `0` to disable, anything else to keep the default | When set to `0`, skips the Ollama tool-calling capability check during local-inference onboarding. | +| `NEMOCLAW_OLLAMA_INSTALL_MODE` | `system`, `user`, or empty/unset | Pins the Linux Ollama install location. `system` runs the official `https://ollama.com/install.sh` (sudo, writes to `/usr/local`, configures systemd). `user` extracts the release tarball to `${HOME}/.local` without sudo and launches the daemon manually (no systemd; manual restart after reboot). Empty/unset auto-detects: root or passwordless `sudo` selects `system`; a non-interactive run without passwordless `sudo` selects `user`; an interactive shell falls back to `system` so the official installer can prompt for the password. Any other value is rejected. | | `NEMOCLAW_PROXY_HOST` | hostname or IP | Overrides the sandbox-side outbound HTTP proxy host. Defaults to `10.200.0.1`. | | `NEMOCLAW_PROXY_PORT` | integer port | Overrides the sandbox-side outbound HTTP proxy port. Defaults to `3128`. | | `NEMOCLAW_OPENSHELL_BIN` | path | Overrides the `openshell` binary the CLI invokes. Defaults to `openshell` (resolved via `PATH`). | diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 67b359821c6..ec72590497c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -22,7 +22,6 @@ const { const { cleanupTempDir }: typeof import("./onboard/temp-files") = require("./onboard/temp-files"); const { stopStaleDashboardListenersForSandbox } = require("./onboard/stale-gateway-cleanup"); const { - ensureManagedOllamaLoopbackSystemdOverride, ensureOllamaLoopbackSystemdOverride, }: typeof import("./onboard/ollama-systemd") = require("./onboard/ollama-systemd"); const { bestEffortForwardStop } = require("./onboard/forward-cleanup"); @@ -103,6 +102,9 @@ const { buildVllmMenuEntries }: typeof import("./onboard/vllm-menu") = require(" const { detectWindowsHostOllama, }: typeof import("./onboard/windows-host-ollama") = require("./onboard/windows-host-ollama"); +const { + installOllamaOnLinux, +}: typeof import("./onboard/install-ollama-linux") = require("./onboard/install-ollama-linux"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -1314,20 +1316,6 @@ function hostCommandExists(commandName: string): boolean { }); } -function ensureOllamaLinuxExtractionDependencies(): void { - if (hostCommandExists("zstd")) return; - console.log( - " The Ollama Linux installer requires zstd for archive extraction. " + - "The next step uses sudo to install zstd; you may be prompted for your password.", - ); - runShell(`if ! command -v apt-get >/dev/null 2>&1; then - echo "ERROR: Ollama requires zstd for extraction, and only apt-based Linux is supported here." >&2 - echo "Install zstd manually (for example, sudo dnf install zstd or sudo pacman -S zstd), then rerun ${cliName()} onboard." >&2 - exit 1 -fi -sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends zstd`); -} - function captureProcessArgs(pid: number): string { return runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true, @@ -5296,38 +5284,10 @@ async function setupNim( continue selectionLoop; } } else { - ensureOllamaLinuxExtractionDependencies(); - console.log( - " The Ollama installer creates a system user, a systemd service, and writes to /usr/local. " + - "It uses sudo, may ask for your password, and can take a few minutes; installer output will stream below.", - ); - runShell("set -o pipefail; curl -fsSL https://ollama.com/install.sh | sh", { stdio: "inherit" }); - // Give the just-started ollama.service a moment to bind port - // 11434 before we probe or apply the systemd drop-in override. - sleepSeconds(2); - // Linux native + systemd: force a loopback-only OLLAMA_HOST drop-in - // and let systemd own the daemon (avoids racing the installer's - // daemon with our own `ollama serve`). This also repairs older - // NemoClaw-created overrides that exposed raw Ollama on all interfaces. - // WSL and non-systemd Linux fall back to a manual loopback launch. - const overrideState = ensureManagedOllamaLoopbackSystemdOverride({ isNonInteractive }); - if (overrideState === "failed") { - console.error( - " Ollama systemd restart did not recover after applying the loopback override.", - ); - process.exit(1); - } - // Fall back to manual start only when systemd is unavailable. - if (overrideState === "not-applicable" && !findReachableOllamaHost()) { - console.log(" Starting Ollama..."); - runShell(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { - ignoreError: true, - }); - if (!waitForHttp(`http://127.0.0.1:${OLLAMA_PORT}/`, 10)) { - console.error(` Ollama did not become ready on :${OLLAMA_PORT} within timeout.`); - if (isNonInteractive()) process.exit(1); - continue selectionLoop; - } + const installResult = installOllamaOnLinux({ isNonInteractive }); + if (!installResult.ok) { + if (isNonInteractive()) process.exit(1); + continue selectionLoop; } } if (shouldFrontOllamaWithProxy()) { diff --git a/src/lib/onboard/install-ollama-linux.test.ts b/src/lib/onboard/install-ollama-linux.test.ts new file mode 100644 index 00000000000..27c0c6a5f00 --- /dev/null +++ b/src/lib/onboard/install-ollama-linux.test.ts @@ -0,0 +1,370 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + decideInstallOllamaLinuxMode, + installOllamaOnLinux, + resolveOllamaTarballArch, + type InstallOllamaLinuxOptions, +} from "../../../dist/lib/onboard/install-ollama-linux"; + +function makeOpts(overrides: Partial): InstallOllamaLinuxOptions { + return { + isNonInteractive: () => false, + getEuid: () => 1000, + isTty: () => true, + homedir: () => "/home/test", + arch: () => "arm64", + canSudoNonInteractive: () => false, + runCaptureImpl: vi.fn().mockReturnValue(""), + runCaptureExImpl: vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }), + runShellImpl: vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }), + waitForHttpImpl: vi.fn().mockReturnValue(true), + sleepSecondsImpl: vi.fn(), + findReachableOllamaHostImpl: vi.fn().mockReturnValue(null), + ensureManagedOllamaLoopbackSystemdOverrideImpl: vi.fn().mockReturnValue("ready"), + fileExistsImpl: vi.fn().mockReturnValue(false), + readFileImpl: vi.fn().mockReturnValue(""), + log: vi.fn(), + errorLog: vi.fn(), + ...overrides, + }; +} + +describe("resolveOllamaTarballArch", () => { + it("maps node arch labels to Ollama tarball architecture", () => { + expect(resolveOllamaTarballArch("x64")).toBe("amd64"); + expect(resolveOllamaTarballArch("arm64")).toBe("arm64"); + }); + + it("returns null for architectures Ollama does not publish prebuilt tarballs for", () => { + expect(resolveOllamaTarballArch("arm" as NodeJS.Architecture)).toBeNull(); + expect(resolveOllamaTarballArch("ia32" as NodeJS.Architecture)).toBeNull(); + expect(resolveOllamaTarballArch("ppc64" as NodeJS.Architecture)).toBeNull(); + }); +}); + +describe("decideInstallOllamaLinuxMode", () => { + const originalEnv = process.env.NEMOCLAW_OLLAMA_INSTALL_MODE; + + beforeEach(() => { + delete process.env.NEMOCLAW_OLLAMA_INSTALL_MODE; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.NEMOCLAW_OLLAMA_INSTALL_MODE; + else process.env.NEMOCLAW_OLLAMA_INSTALL_MODE = originalEnv; + }); + + it("honours an explicit user-mode env var even with passwordless sudo available", () => { + process.env.NEMOCLAW_OLLAMA_INSTALL_MODE = "user"; + const opts = makeOpts({ canSudoNonInteractive: () => true }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("user-local"); + }); + + it("honours an explicit system-mode env var even in headless non-interactive runs", () => { + process.env.NEMOCLAW_OLLAMA_INSTALL_MODE = "system"; + const opts = makeOpts({ isNonInteractive: () => true, isTty: () => false }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("system"); + }); + + it("rejects unknown NEMOCLAW_OLLAMA_INSTALL_MODE values", () => { + process.env.NEMOCLAW_OLLAMA_INSTALL_MODE = "garbage"; + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + const errorLog = vi.fn(); + try { + expect(() => decideInstallOllamaLinuxMode(makeOpts({ errorLog }))).toThrow( + /process\.exit\(1\)/, + ); + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("Unsupported")); + } finally { + exitSpy.mockRestore(); + } + }); + + it("returns system when running as root", () => { + const opts = makeOpts({ getEuid: () => 0, isNonInteractive: () => true, isTty: () => false }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("system"); + }); + + it("returns system when passwordless sudo is available", () => { + const opts = makeOpts({ + canSudoNonInteractive: () => true, + isNonInteractive: () => true, + isTty: () => false, + }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("system"); + }); + + it("returns user-local when non-interactive without passwordless sudo (issue #4114 repro)", () => { + const opts = makeOpts({ + canSudoNonInteractive: () => false, + isNonInteractive: () => true, + isTty: () => true, + }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("user-local"); + }); + + it("returns user-local when stdin is not a TTY even if the flag is unset", () => { + const opts = makeOpts({ + canSudoNonInteractive: () => false, + isNonInteractive: () => false, + isTty: () => false, + }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("user-local"); + }); + + it("returns system in interactive shells without passwordless sudo (lets sudo prompt)", () => { + const opts = makeOpts({ + canSudoNonInteractive: () => false, + isNonInteractive: () => false, + isTty: () => true, + }); + expect(decideInstallOllamaLinuxMode(opts)).toBe("system"); + }); +}); + +describe("installOllamaOnLinux (user-local)", () => { + function findRunShellCall( + runShellImpl: ReturnType, + fragment: string, + ): string | undefined { + for (const call of runShellImpl.mock.calls) { + const [cmd] = call as [string, unknown]; + if (typeof cmd === "string" && cmd.includes(fragment)) return cmd; + } + return undefined; + } + + it("downloads the arm64 tar.zst tarball into ~/.local without sudo when zstd is present", () => { + const runCaptureImpl = vi.fn().mockReturnValue("/usr/bin/zstd"); + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const runCaptureExImpl = vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }); + const opts = makeOpts({ + modeOverride: "user-local", + arch: () => "arm64", + runCaptureImpl, + runCaptureExImpl, + runShellImpl, + }); + const result = installOllamaOnLinux(opts); + expect(result).toEqual({ ok: true, mode: "user-local", binPath: "/home/test/.local/bin/ollama" }); + const mkdirCall = findRunShellCall(runShellImpl, "mkdir -p"); + expect(mkdirCall).toContain("/home/test/.local/bin"); + expect(mkdirCall).toContain("/home/test/.local/lib/ollama"); + const downloadCall = findRunShellCall( + runShellImpl, + "ollama-linux-arm64.tar.zst", + ); + expect(downloadCall).toBeDefined(); + expect(downloadCall).toContain("zstd -d"); + expect(downloadCall).toContain("tar -xf - -C '/home/test/.local'"); + expect(downloadCall).not.toContain("sudo"); + const startCall = findRunShellCall(runShellImpl, "nohup '/home/test/.local/bin/ollama'"); + expect(startCall).toBeDefined(); + expect(startCall).toContain(`OLLAMA_HOST=127.0.0.1:`); + expect(startCall).toContain(" serve "); + }); + + it("uses the amd64 tarball on x64 hosts", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const opts = makeOpts({ + modeOverride: "user-local", + arch: () => "x64", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runCaptureExImpl: vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }), + runShellImpl, + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + const downloadCall = findRunShellCall(runShellImpl, "ollama-linux-amd64.tar.zst"); + expect(downloadCall).toBeDefined(); + }); + + it("falls back to the .tgz tarball when the .tar.zst HEAD probe fails", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const runCaptureExImpl = vi.fn().mockReturnValue({ + stdout: "", + exitCode: 22, + timedOut: false, + }); + const opts = makeOpts({ + modeOverride: "user-local", + arch: () => "arm64", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runCaptureExImpl, + runShellImpl, + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + const tgzCall = findRunShellCall(runShellImpl, "ollama-linux-arm64.tgz"); + expect(tgzCall).toBeDefined(); + expect(tgzCall).toContain("tar -xzf - -C '/home/test/.local'"); + expect(tgzCall).not.toContain("sudo"); + }); + + it("exits with a per-distro hint when the .tar.zst asset exists but zstd is missing", () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + const errorLog = vi.fn(); + try { + const opts = makeOpts({ + modeOverride: "user-local", + runCaptureImpl: vi.fn().mockImplementation((cmd: readonly string[]) => { + if (cmd.includes("zstd")) return ""; + return ""; + }), + runCaptureExImpl: vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }), + errorLog, + }); + expect(() => installOllamaOnLinux(opts)).toThrow(/process\.exit\(1\)/); + const errorOutput = errorLog.mock.calls.flat().join("\n"); + expect(errorOutput).toContain("sudo apt-get install zstd"); + expect(errorOutput).toContain("sudo dnf install zstd"); + expect(errorOutput).toContain("sudo pacman -S zstd"); + } finally { + exitSpy.mockRestore(); + } + }); + + it("refuses to proceed on unsupported architectures and returns ok:false", () => { + const errorLog = vi.fn(); + const opts = makeOpts({ + modeOverride: "user-local", + arch: () => "arm" as NodeJS.Architecture, + errorLog, + }); + const result = installOllamaOnLinux(opts); + expect(result).toEqual({ ok: false, mode: "user-local", binPath: "" }); + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("arm")); + }); + + it("pulls the matching JetPack add-on tarball when /etc/nv_tegra_release advertises R36", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const opts = makeOpts({ + modeOverride: "user-local", + arch: () => "arm64", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runCaptureExImpl: vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }), + runShellImpl, + fileExistsImpl: (p: string) => p === "/etc/nv_tegra_release", + readFileImpl: () => "# R36 (release), REVISION: 0.0", + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + const jetpackCall = findRunShellCall(runShellImpl, "ollama-linux-arm64-jetpack6.tar.zst"); + expect(jetpackCall).toBeDefined(); + }); + + it("reports a failed daemon start as ok:false instead of crashing", () => { + const opts = makeOpts({ + modeOverride: "user-local", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + waitForHttpImpl: vi.fn().mockReturnValue(false), + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(false); + expect(result.binPath).toBe("/home/test/.local/bin/ollama"); + }); + + it("warns when ~/.local/bin is missing from PATH", () => { + const originalPath = process.env.PATH; + process.env.PATH = "/usr/local/bin:/usr/bin"; + const log = vi.fn(); + try { + const opts = makeOpts({ + modeOverride: "user-local", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + log, + }); + installOllamaOnLinux(opts); + const logOutput = log.mock.calls.flat().join("\n"); + expect(logOutput).toContain("/home/test/.local/bin"); + expect(logOutput).toContain("PATH"); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + } + }); +}); + +describe("installOllamaOnLinux (system)", () => { + function findRunShellCall( + runShellImpl: ReturnType, + fragment: string, + ): string | undefined { + for (const call of runShellImpl.mock.calls) { + const [cmd] = call as [string, unknown]; + if (typeof cmd === "string" && cmd.includes(fragment)) return cmd; + } + return undefined; + } + + it("runs the official install.sh and applies the systemd loopback override", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const ensureOverride = vi.fn().mockReturnValue("ready"); + const opts = makeOpts({ + modeOverride: "system", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runShellImpl, + ensureManagedOllamaLoopbackSystemdOverrideImpl: ensureOverride, + }); + const result = installOllamaOnLinux(opts); + expect(result).toEqual({ ok: true, mode: "system", binPath: "/usr/local/bin/ollama" }); + const installCall = findRunShellCall(runShellImpl, "ollama.com/install.sh"); + expect(installCall).toBeDefined(); + expect(installCall).toContain("curl -fsSL"); + expect(ensureOverride).toHaveBeenCalled(); + }); + + it("returns ok:false when the systemd override fails to recover", () => { + const errorLog = vi.fn(); + const opts = makeOpts({ + modeOverride: "system", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + ensureManagedOllamaLoopbackSystemdOverrideImpl: vi.fn().mockReturnValue("failed"), + errorLog, + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(false); + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("systemd restart")); + }); + + it("falls back to a manual loopback launch when systemd is not applicable and no daemon is reachable", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const opts = makeOpts({ + modeOverride: "system", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runShellImpl, + ensureManagedOllamaLoopbackSystemdOverrideImpl: vi.fn().mockReturnValue("not-applicable"), + findReachableOllamaHostImpl: vi.fn().mockReturnValue(null), + waitForHttpImpl: vi.fn().mockReturnValue(true), + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + const manualStart = findRunShellCall(runShellImpl, "ollama serve"); + expect(manualStart).toBeDefined(); + expect(manualStart).toContain("OLLAMA_HOST=127.0.0.1:"); + }); + + it("skips the manual launch when systemd is not applicable but Ollama is already reachable", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const waitForHttpImpl = vi.fn().mockReturnValue(true); + const opts = makeOpts({ + modeOverride: "system", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runShellImpl, + ensureManagedOllamaLoopbackSystemdOverrideImpl: vi.fn().mockReturnValue("not-applicable"), + findReachableOllamaHostImpl: vi.fn().mockReturnValue("127.0.0.1"), + waitForHttpImpl, + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + expect(waitForHttpImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/install-ollama-linux.ts b/src/lib/onboard/install-ollama-linux.ts new file mode 100644 index 00000000000..ba42b7ff6a8 --- /dev/null +++ b/src/lib/onboard/install-ollama-linux.ts @@ -0,0 +1,403 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import nodePath from "node:path"; + +import { OLLAMA_PORT } from "../core/ports"; +import { sleepSeconds, waitForHttp } from "../core/wait"; +import { cliName } from "./branding"; +import { + ensureManagedOllamaLoopbackSystemdOverride, + type OllamaLoopbackSystemdOverrideState, +} from "./ollama-systemd"; + +const { runCapture, runCaptureEx, runShell }: typeof import("../runner") = require("../runner"); +const { + findReachableOllamaHost, +}: typeof import("../inference/local") = require("../inference/local"); + +/** + * Install location modes. + * + * - `system` installs into `/usr/local` via the official Ollama + * installer script (`https://ollama.com/install.sh`). Requires + * sudo, configures a systemd service, and writes to a system + * user (`ollama`). This is the historical path. + * - `user-local` installs into `${HOME}/.local` by downloading the official + * release tarball directly and extracting it as the invoking + * user. No sudo, no systemd, no system user. The daemon is + * launched once via a backgrounded `ollama serve`; reboot + * persistence is the user's responsibility. + */ +export type InstallOllamaLinuxMode = "system" | "user-local"; + +export type InstallOllamaLinuxResult = { + ok: boolean; + mode: InstallOllamaLinuxMode; + binPath: string; +}; + +export type InstallOllamaLinuxOptions = { + /** Returns true when onboard is running headless (NEMOCLAW_NON_INTERACTIVE=1 + * or the `--non-interactive` flag was passed). Used to decide whether sudo + * prompts are viable and whether the systemd override may prompt. */ + isNonInteractive: () => boolean; + /** Test seam: override the auto-detected install mode. */ + modeOverride?: InstallOllamaLinuxMode; + /** Test seam: override the `sudo -n true` probe result. */ + canSudoNonInteractive?: () => boolean; + /** Test seam: override `process.getuid()`. */ + getEuid?: () => number | undefined; + /** Test seam: override stdin TTY detection. */ + isTty?: () => boolean; + /** Test seam: override `os.homedir()`. */ + homedir?: () => string; + /** Test seam: override `process.arch`. */ + arch?: () => NodeJS.Architecture; + /** Test seam: override `runCapture`. */ + runCaptureImpl?: typeof runCapture; + /** Test seam: override `runCaptureEx`. */ + runCaptureExImpl?: typeof runCaptureEx; + /** Test seam: override `runShell`. */ + runShellImpl?: typeof runShell; + /** Test seam: override systemd loopback override. */ + ensureManagedOllamaLoopbackSystemdOverrideImpl?: typeof ensureManagedOllamaLoopbackSystemdOverride; + /** Test seam: override `findReachableOllamaHost`. */ + findReachableOllamaHostImpl?: typeof findReachableOllamaHost; + /** Test seam: override `waitForHttp`. */ + waitForHttpImpl?: typeof waitForHttp; + /** Test seam: override `sleepSeconds`. */ + sleepSecondsImpl?: typeof sleepSeconds; + /** Test seam: override `fs.existsSync` (for /etc/nv_tegra_release detection). */ + fileExistsImpl?: (path: string) => boolean; + /** Test seam: override `fs.readFileSync`. */ + readFileImpl?: (path: string) => string; + /** Test seam: redirect log output. */ + log?: (message: string) => void; + /** Test seam: redirect error output. */ + errorLog?: (message: string) => void; +}; + +const INSTALL_MODE_ENV = "NEMOCLAW_OLLAMA_INSTALL_MODE"; + +/** + * Resolve the install mode. + * + * Order of precedence (highest first): + * 1. The `NEMOCLAW_OLLAMA_INSTALL_MODE` env var when set to `user` or + * `system`. Any other value is rejected. + * 2. Running as root (`euid === 0`) → `system` (no sudo required). + * 3. Passwordless sudo available (`sudo -n true` returns 0) → `system`. + * 4. Non-interactive context (either the `NEMOCLAW_NON_INTERACTIVE=1` flag + * or no TTY attached to stdin) → `user-local`. This is the path that + * fixes #4114: a headless run that cannot prompt for a sudo password + * falls back to a sudo-free user-local install instead of crashing + * mid-install. + * 5. Interactive shell → `system` (sudo can prompt the user for a password). + */ +export function decideInstallOllamaLinuxMode( + opts: InstallOllamaLinuxOptions, +): InstallOllamaLinuxMode { + if (opts.modeOverride) return opts.modeOverride; + const explicit = String(process.env[INSTALL_MODE_ENV] || "").trim().toLowerCase(); + if (explicit === "user") return "user-local"; + if (explicit === "system") return "system"; + if (explicit) { + const errorLog = opts.errorLog ?? ((m: string) => console.error(m)); + errorLog( + ` Unsupported ${INSTALL_MODE_ENV} value: ${explicit}. Use 'system', 'user', or leave it unset.`, + ); + process.exit(1); + } + const getEuid = opts.getEuid ?? (() => process.getuid?.()); + if (getEuid() === 0) return "system"; + if (canRunSudoNonInteractive(opts)) return "system"; + const isTty = opts.isTty ?? (() => Boolean(process.stdin.isTTY)); + if (opts.isNonInteractive() || !isTty()) return "user-local"; + return "system"; +} + +function canRunSudoNonInteractive(opts: InstallOllamaLinuxOptions): boolean { + if (opts.canSudoNonInteractive) return opts.canSudoNonInteractive(); + const runCaptureExImpl = opts.runCaptureExImpl ?? runCaptureEx; + if (!hostCommandExists("sudo", opts)) return false; + const result = runCaptureExImpl(["sudo", "-n", "true"], { timeout: 2_000 }); + return result.exitCode === 0; +} + +function hostCommandExists(name: string, opts: InstallOllamaLinuxOptions): boolean { + const runCaptureImpl = opts.runCaptureImpl ?? runCapture; + return !!runCaptureImpl(["sh", "-c", 'command -v "$1"', "--", name], { + ignoreError: true, + }); +} + +/** + * Map Node's `process.arch` to the architecture suffix Ollama publishes + * tarballs under (`amd64` / `arm64`). Returns `null` for anything else — + * callers must surface a clear error rather than guess. + */ +export function resolveOllamaTarballArch(arch: NodeJS.Architecture): "amd64" | "arm64" | null { + if (arch === "x64") return "amd64"; + if (arch === "arm64") return "arm64"; + return null; +} + +/** + * Detect a JetPack release line from `/etc/nv_tegra_release` to pull the + * matching CUDA add-on tarball. Mirrors the JetPack branch of the official + * `install.sh` (L178-187). + */ +function detectJetpackVariant(opts: InstallOllamaLinuxOptions): "jetpack5" | "jetpack6" | null { + const exists = opts.fileExistsImpl ?? fs.existsSync; + const read = opts.readFileImpl ?? ((p: string) => fs.readFileSync(p, "utf8")); + if (!exists("/etc/nv_tegra_release")) return null; + let body = ""; + try { + body = read("/etc/nv_tegra_release"); + } catch { + return null; + } + if (/R36/.test(body)) return "jetpack6"; + if (/R35/.test(body)) return "jetpack5"; + return null; +} + +/** + * Run the official `https://ollama.com/install.sh`. Sudo-bound. Configures + * the systemd `ollama.service`, creates the `ollama` system user, and + * installs CUDA drivers when applicable. + */ +function runOfficialInstallScript(opts: InstallOllamaLinuxOptions): void { + const log = opts.log ?? ((m: string) => console.log(m)); + const runShellImpl = opts.runShellImpl ?? runShell; + ensureOllamaLinuxExtractionDependencies(opts); + log( + " The Ollama installer creates a system user, a systemd service, and writes to /usr/local. " + + "It uses sudo, may ask for your password, and can take a few minutes; installer output will stream below.", + ); + runShellImpl("set -o pipefail; curl -fsSL https://ollama.com/install.sh | sh", { + stdio: "inherit", + }); +} + +/** + * Apt-based zstd bootstrap. Only viable in `system` mode, where sudo is + * available. The `user-local` branch hard-fails with manual install + * instructions instead (the script cannot install system packages without + * elevation). + */ +function ensureOllamaLinuxExtractionDependencies(opts: InstallOllamaLinuxOptions): void { + if (hostCommandExists("zstd", opts)) return; + const log = opts.log ?? ((m: string) => console.log(m)); + const runShellImpl = opts.runShellImpl ?? runShell; + log( + " The Ollama Linux installer requires zstd for archive extraction. " + + "The next step uses sudo to install zstd; you may be prompted for your password.", + ); + runShellImpl(`if ! command -v apt-get >/dev/null 2>&1; then + echo "ERROR: Ollama requires zstd for extraction, and only apt-based Linux is supported here." >&2 + echo "Install zstd manually (for example, sudo dnf install zstd or sudo pacman -S zstd), then rerun ${cliName()} onboard." >&2 + exit 1 +fi +sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends zstd`); +} + +/** + * Download and extract the Ollama release tarball into `installDir` without + * sudo. Mirrors `download_and_extract` from `install.sh` (L130-157) with the + * `$SUDO` invocations stripped. zstd is the primary format; falls back to + * `.tgz` for tags where the zst asset is unpublished. + * + * Hard-fails when zstd is unavailable: we cannot bootstrap it without sudo + * elevation in user-local mode, so we surface the same per-distro install + * hint that `install.sh` does. + */ +function downloadAndExtractUserLocal( + tarballName: string, + installDir: string, + opts: InstallOllamaLinuxOptions, +): void { + const runShellImpl = opts.runShellImpl ?? runShell; + const runCaptureExImpl = opts.runCaptureExImpl ?? runCaptureEx; + const errorLog = opts.errorLog ?? ((m: string) => console.error(m)); + const log = opts.log ?? ((m: string) => console.log(m)); + + const zstUrl = `https://ollama.com/download/${tarballName}.tar.zst`; + const tgzUrl = `https://ollama.com/download/${tarballName}.tgz`; + + const headProbe = runCaptureExImpl( + ["curl", "--fail", "--silent", "--head", "--location", zstUrl], + { timeout: 15_000 }, + ); + const zstExists = headProbe.exitCode === 0; + + if (zstExists) { + if (!hostCommandExists("zstd", opts)) { + errorLog( + ` ERROR: ${tarballName} ships as .tar.zst but zstd is not installed and ${cliName()} cannot bootstrap it without sudo.\n` + + ` Install zstd manually, then rerun ${cliName()} onboard:\n` + + " - Debian/Ubuntu: sudo apt-get install zstd\n" + + " - RHEL/CentOS/Fedora: sudo dnf install zstd\n" + + " - Arch: sudo pacman -S zstd", + ); + process.exit(1); + } + log(` Downloading ${tarballName}.tar.zst`); + runShellImpl( + `set -o pipefail; curl --fail --show-error --location '${zstUrl}' | zstd -d | tar -xf - -C '${installDir}'`, + { stdio: "inherit" }, + ); + return; + } + + log(` Downloading ${tarballName}.tgz`); + runShellImpl( + `set -o pipefail; curl --fail --show-error --location '${tgzUrl}' | tar -xzf - -C '${installDir}'`, + { stdio: "inherit" }, + ); +} + +/** + * Sudo-free, ~/.local-rooted install. Replicates the binary-extraction + * portion of the official `install.sh` (L159-187) but skips the parts that + * require root: the `install -o0 -g0` chown, the systemd service file, and + * the CUDA driver setup. The daemon is launched once at the end of the + * install with a backgrounded `ollama serve`. Manual re-launch is required + * after a reboot (this is documented in `docs/inference/use-local-inference.mdx`). + * + * Refuses to proceed on unsupported architectures. + */ +function installOllamaUserLocal(opts: InstallOllamaLinuxOptions): InstallOllamaLinuxResult { + const log = opts.log ?? ((m: string) => console.log(m)); + const errorLog = opts.errorLog ?? ((m: string) => console.error(m)); + const runShellImpl = opts.runShellImpl ?? runShell; + const homedir = opts.homedir ?? (() => os.homedir()); + const arch = opts.arch ?? (() => process.arch); + + const ollamaArch = resolveOllamaTarballArch(arch()); + if (!ollamaArch) { + errorLog( + ` ERROR: User-local Ollama install does not support architecture '${arch()}'. ` + + "Set NEMOCLAW_OLLAMA_INSTALL_MODE=system and run onboard interactively, " + + "or install Ollama manually before re-running onboard.", + ); + return { ok: false, mode: "user-local", binPath: "" }; + } + + const installDir = nodePath.join(homedir(), ".local"); + const binDir = nodePath.join(installDir, "bin"); + const binPath = nodePath.join(binDir, "ollama"); + + log( + ` Installing Ollama in user-local mode (${installDir}). ` + + "No sudo, no systemd; the daemon will be launched manually and must be restarted after a reboot.", + ); + + runShellImpl(`mkdir -p '${binDir}' '${installDir}/lib/ollama'`); + downloadAndExtractUserLocal(`ollama-linux-${ollamaArch}`, installDir, opts); + const jetpack = detectJetpackVariant(opts); + if (jetpack) { + log(` Detected NVIDIA JetPack (${jetpack}); pulling matching add-on.`); + downloadAndExtractUserLocal(`ollama-linux-${ollamaArch}-${jetpack}`, installDir, opts); + } + + if (!startUserLocalOllamaDaemon(binPath, opts)) { + errorLog(` Ollama did not become ready on :${OLLAMA_PORT} within timeout.`); + return { ok: false, mode: "user-local", binPath }; + } + + warnIfLocalBinNotOnPath(binDir, opts); + + return { ok: true, mode: "user-local", binPath }; +} + +function startUserLocalOllamaDaemon( + binPath: string, + opts: InstallOllamaLinuxOptions, +): boolean { + const log = opts.log ?? ((m: string) => console.log(m)); + const runShellImpl = opts.runShellImpl ?? runShell; + const waitForHttpImpl = opts.waitForHttpImpl ?? waitForHttp; + log(" Starting Ollama..."); + runShellImpl( + `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} nohup '${binPath}' serve > /dev/null 2>&1 &`, + { ignoreError: true }, + ); + return waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10); +} + +/** + * Emit a one-line PATH hint when `~/.local/bin` is missing from `PATH`. We + * intentionally do not edit shell rc files here: that is the operator's + * choice and `scripts/install.sh` already owns shell-profile rewrites for + * the NemoClaw CLI itself. + */ +function warnIfLocalBinNotOnPath(binDir: string, opts: InstallOllamaLinuxOptions): void { + const log = opts.log ?? ((m: string) => console.log(m)); + const pathEntries = String(process.env.PATH || "").split(nodePath.delimiter); + if (pathEntries.includes(binDir)) return; + log( + ` Note: ${binDir} is not on your PATH. ` + + `Add 'export PATH="${binDir}:$PATH"' to your shell profile to invoke 'ollama' directly.`, + ); +} + +/** + * Sudo-driven path. Runs the official installer, then forces the systemd + * service to bind only to loopback (so the Docker bridge cannot reach raw + * Ollama). Falls back to a manual `ollama serve` launch when systemd is + * unavailable (e.g. WSL without systemd, minimal containers). + */ +function installOllamaSystem(opts: InstallOllamaLinuxOptions): InstallOllamaLinuxResult { + const log = opts.log ?? ((m: string) => console.log(m)); + const errorLog = opts.errorLog ?? ((m: string) => console.error(m)); + const runShellImpl = opts.runShellImpl ?? runShell; + const sleepSecondsImpl = opts.sleepSecondsImpl ?? sleepSeconds; + const waitForHttpImpl = opts.waitForHttpImpl ?? waitForHttp; + const findReachableOllamaHostImpl = + opts.findReachableOllamaHostImpl ?? findReachableOllamaHost; + const ensureOverrideImpl = + opts.ensureManagedOllamaLoopbackSystemdOverrideImpl + ?? ensureManagedOllamaLoopbackSystemdOverride; + + runOfficialInstallScript(opts); + sleepSecondsImpl(2); + + const overrideState: OllamaLoopbackSystemdOverrideState = ensureOverrideImpl({ + isNonInteractive: opts.isNonInteractive, + }); + if (overrideState === "failed") { + errorLog(" Ollama systemd restart did not recover after applying the loopback override."); + return { ok: false, mode: "system", binPath: "/usr/local/bin/ollama" }; + } + + if (overrideState === "not-applicable" && !findReachableOllamaHostImpl()) { + log(" Starting Ollama..."); + runShellImpl(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { + ignoreError: true, + }); + if (!waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10)) { + errorLog(` Ollama did not become ready on :${OLLAMA_PORT} within timeout.`); + return { ok: false, mode: "system", binPath: "/usr/local/bin/ollama" }; + } + } + + return { ok: true, mode: "system", binPath: "/usr/local/bin/ollama" }; +} + +/** + * Entry point: decide an install mode (see `decideInstallOllamaLinuxMode`) + * and run the matching install path. Returns `{ ok: false, ... }` rather + * than throwing so the caller (the onboard selection loop) can continue to + * the next menu entry on interactive runs. + */ +export function installOllamaOnLinux( + opts: InstallOllamaLinuxOptions, +): InstallOllamaLinuxResult { + const mode = decideInstallOllamaLinuxMode(opts); + if (mode === "user-local") return installOllamaUserLocal(opts); + return installOllamaSystem(opts); +} diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 4158cdb7541..1d2b526a12c 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -1479,6 +1479,11 @@ const { setupNim } = require(${onboardPath}); ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, + // Force the historical system-install path so this test still + // exercises the install.sh + systemd loopback flow. Vitest spawns + // child processes without a TTY, which would otherwise route the + // install through the sudo-free user-local fallback added for #4114. + NEMOCLAW_OLLAMA_INSTALL_MODE: "system", }, }); @@ -5309,6 +5314,10 @@ const { setupNim } = require(${onboardPath}); ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, + // See #4114: Vitest spawns child processes without a TTY, which + // would otherwise route the install through the sudo-free + // user-local fallback. This case asserts the system-install path. + NEMOCLAW_OLLAMA_INSTALL_MODE: "system", }, }); @@ -5468,6 +5477,9 @@ const { setupNim } = require(${onboardPath}); env: { ...process.env, HOME: tmpDir, + // See #4114: this scenario exercises the systemd override failure + // path, which only runs under the system install mode. + NEMOCLAW_OLLAMA_INSTALL_MODE: "system", }, }); @@ -5600,6 +5612,10 @@ const { setupNim } = require(${onboardPath}); NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_PROVIDER: "ollama", NEMOCLAW_YES: "1", + // See #4114: assert the historical system-install path explicitly. + // The non-interactive default without this override now routes to + // the sudo-free user-local fallback (covered by the test below). + NEMOCLAW_OLLAMA_INSTALL_MODE: "system", }, }); @@ -5647,6 +5663,192 @@ const { setupNim } = require(${onboardPath}); ); }); + it("falls back to a user-local Ollama install when non-interactive lacks passwordless sudo (#4114)", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-userlocal-install-ollama-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "userlocal-install-ollama-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Fake curl + zstd binaries on PATH. The install module uses curl to + // probe the release tarball (HEAD) and zstd to decompress; both must + // exist on PATH for the user-local path to choose the .tar.zst asset. + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='${OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE}' +status="200" +outfile="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ -n "$outfile" ]; then + printf '%s' "$body" > "$outfile" + printf '%s' "$status" +else + printf '%s' "$body" +fi +`, + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(fakeBin, "zstd"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const platform = require(${platformPath}); +const child_process = require("child_process"); + +child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); + +const originalSpawnSync = child_process.spawnSync; +child_process.spawnSync = (cmd, args, opts) => { + const command = [cmd, ...(args || [])].join(" "); + if (cmd === "nc" && args?.includes("11435")) { + return { status: 0, stdout: "", stderr: "", signal: null }; + } + if (command.includes("ollama pull")) { + return { status: 0, stdout: "", stderr: "", signal: null }; + } + if (cmd === "ps") { + return { status: 0, stdout: "node ollama-auth-proxy.js", stderr: "", signal: null }; + } + return originalSpawnSync(cmd, args, opts); +}; + +let promptCalls = 0; +const updates = []; +const runCommands = []; +const runShellCalls = []; + +credentials.prompt = async () => { + promptCalls += 1; + return ""; +}; +credentials.ensureApiKey = async () => {}; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + // hostCommandExists() shells out as ["sh", "-c", 'command -v "$1"', "--", name], + // so match on the trailing target rather than a "command -v " substring. + if (cmd.endsWith(" -- ollama")) return ""; + if (cmd.endsWith(" -- zstd")) return "/usr/bin/zstd"; + if (cmd.endsWith(" -- sudo")) return "/usr/bin/sudo"; + if (cmd.includes("127.0.0.1:11434/api/tags")) return ""; + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("ollama list")) return "qwen3:8b abc 5 GB now"; + if (cmd.includes("ps")) return "node ollama-auth-proxy.js"; + if (cmd.includes("api/generate")) return '{"response":"hello"}'; + return ""; +}; +const originalRunCaptureEx = runner.runCaptureEx; +runner.runCaptureEx = (command, opts) => { + // Refuse passwordless sudo so the install path takes the #4114 fallback. + if (Array.isArray(command) && command[0] === "sudo" && command[1] === "-n") { + return { stdout: "", exitCode: 1, timedOut: false }; + } + // Pretend the .tar.zst asset exists so the user-local install picks the + // zstd path (instead of falling back to .tgz). + if (Array.isArray(command) && command.includes("--head")) { + return { stdout: "", exitCode: 0, timedOut: false }; + } + // Hand every other capture (curl probes, etc.) back to the real implementation + // so the fake-curl shim on PATH can answer the local-model probe. + return originalRunCaptureEx(command, opts); +}; +runner.run = (command) => { + runCommands.push(typeof command === "string" ? command : command.join(" ")); +}; +runner.runShell = (command, opts = {}) => { + runCommands.push(command); + runShellCalls.push({ command, stdio: opts.stdio || null }); +}; +registry.updateSandbox = (_name, update) => updates.push(update); + +Object.defineProperty(process, "platform", { value: "linux" }); +platform.isWsl = () => false; + +const { setupNim } = require(${onboardPath}); + +(async () => { + const originalLog = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + const result = await setupNim("userlocal-install-test", null); + originalLog(JSON.stringify({ result, promptCalls, updates, lines, runCommands, runShellCalls })); + } finally { + console.log = originalLog; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "ollama", + NEMOCLAW_YES: "1", + // No NEMOCLAW_OLLAMA_INSTALL_MODE — auto-detect routes through + // user-local because the stubbed `sudo -n true` returns exit 1. + }, + }); + + assert.equal(result.status, 0, `Process failed: ${result.stderr}`); + assert.notEqual(result.stdout.trim(), "", result.stderr); + const payload = JSON.parse(result.stdout.trim()); + + assert.equal(payload.result.provider, "ollama-local"); + assert.ok( + !payload.runCommands.some((cmd: string) => cmd.includes("ollama.com/install.sh")), + "User-local install must NOT run the official curl|sh installer", + ); + assert.ok( + payload.runCommands.some((cmd: string) => + cmd.includes("ollama-linux-") && cmd.includes(".tar.zst"), + ), + "User-local install should download the release tarball directly", + ); + assert.ok( + payload.runCommands.some( + (cmd: string) => cmd.includes("zstd -d") && cmd.includes("/.local"), + ), + "User-local install should extract under ${HOME}/.local without sudo", + ); + assert.ok( + !payload.runCommands.some((cmd: string) => cmd.includes("sudo")), + "User-local install must not invoke sudo on any extraction or start command", + ); + assert.ok( + payload.runCommands.some( + (cmd: string) => cmd.includes("nohup") && cmd.includes("/.local/bin/ollama"), + ), + "User-local install should launch the daemon from ${HOME}/.local/bin/ollama", + ); + assert.ok( + !payload.runCommands.some((cmd: string) => cmd.includes("OLLAMA_HOST=0.0.0.0:11434")), + "User-local install path must not expose raw Ollama on all interfaces", + ); + }); + it("restarts Windows-host Ollama after install when installer auto-start is not reachable", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( From 840614d8c69d3432ad0627ec8d5b17e2089c1f1c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 23 May 2026 13:26:02 -0700 Subject: [PATCH 2/2] fix(onboard): quote user-local Ollama install paths Signed-off-by: Carlos Villela --- docs/inference/use-local-inference.mdx | 9 ++++++--- src/lib/onboard/install-ollama-linux.test.ts | 18 ++++++++++++++++++ src/lib/onboard/install-ollama-linux.ts | 17 ++++++++++++----- test/onboard-selection.test.ts | 15 +++++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/inference/use-local-inference.mdx b/docs/inference/use-local-inference.mdx index c6b8b7a0ae9..b01ddd066c1 100644 --- a/docs/inference/use-local-inference.mdx +++ b/docs/inference/use-local-inference.mdx @@ -36,15 +36,18 @@ On WSL, the wizard can use, start, restart, or install Ollama on the Windows hos #### Linux Install Modes On native Linux, the install path picks between a system install (under `/usr/local`, via the official `https://ollama.com/install.sh`) and a sudo-free user-local install (under `${HOME}/.local`). -The choice is made automatically: +NemoClaw selects the mode automatically: - Running as root or with passwordless sudo (`sudo -n true` returns 0) selects the system install. -- A non-interactive run (`NEMOCLAW_NON_INTERACTIVE=1` or no TTY on stdin) without passwordless sudo selects the user-local install. This is the path that lets headless hosts complete onboarding without prompting for a sudo password. +- A non-interactive run (`NEMOCLAW_NON_INTERACTIVE=1` or no TTY on stdin) without passwordless sudo selects the user-local install. + This is the path that lets headless hosts complete onboarding without prompting for a sudo password. - An interactive shell without passwordless sudo selects the system install and lets the official installer prompt for the password as usual. Override the detection with `NEMOCLAW_OLLAMA_INSTALL_MODE=system` or `NEMOCLAW_OLLAMA_INSTALL_MODE=user`. -The user-local install replicates only the binary extraction step of the official installer. It downloads the release tarball, extracts it to `${HOME}/.local`, and launches `${HOME}/.local/bin/ollama serve` once. It does not configure a systemd service, does not create the `ollama` system user, and does not install CUDA drivers, so the daemon must be relaunched manually after a reboot. +The user-local install replicates only the binary extraction step of the official installer. +It downloads the release tarball, extracts it to `${HOME}/.local`, and launches `${HOME}/.local/bin/ollama serve` once. +It does not configure a systemd service, does not create the `ollama` system user, and does not install CUDA drivers, so the daemon must be relaunched manually after a reboot. NemoClaw also prints a one-line `PATH` hint if `${HOME}/.local/bin` is not already on your `PATH`; you can add `export PATH="${HOME}/.local/bin:$PATH"` to your shell profile to invoke `ollama` directly. Both modes rely on `zstd` for archive extraction. On Debian and Ubuntu, the system path uses `sudo apt-get` to install `zstd` automatically and explains the prompt before continuing. diff --git a/src/lib/onboard/install-ollama-linux.test.ts b/src/lib/onboard/install-ollama-linux.test.ts index 27c0c6a5f00..95183d3dd04 100644 --- a/src/lib/onboard/install-ollama-linux.test.ts +++ b/src/lib/onboard/install-ollama-linux.test.ts @@ -185,6 +185,24 @@ describe("installOllamaOnLinux (user-local)", () => { expect(downloadCall).toBeDefined(); }); + it("shell-quotes user-local paths derived from HOME", () => { + const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); + const opts = makeOpts({ + modeOverride: "user-local", + homedir: () => "/tmp/name'with-quote", + runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"), + runCaptureExImpl: vi.fn().mockReturnValue({ stdout: "", exitCode: 0, timedOut: false }), + runShellImpl, + }); + const result = installOllamaOnLinux(opts); + expect(result.ok).toBe(true); + const commandOutput = runShellImpl.mock.calls + .map(([cmd]) => (typeof cmd === "string" ? cmd : "")) + .join("\n"); + expect(commandOutput).toContain("'\\''"); + expect(commandOutput).not.toContain("'/tmp/name'with-quote"); + }); + it("falls back to the .tgz tarball when the .tar.zst HEAD probe fails", () => { const runShellImpl = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "", error: null }); const runCaptureExImpl = vi.fn().mockReturnValue({ diff --git a/src/lib/onboard/install-ollama-linux.ts b/src/lib/onboard/install-ollama-linux.ts index ba42b7ff6a8..44b1c04152c 100644 --- a/src/lib/onboard/install-ollama-linux.ts +++ b/src/lib/onboard/install-ollama-linux.ts @@ -13,7 +13,12 @@ import { type OllamaLoopbackSystemdOverrideState, } from "./ollama-systemd"; -const { runCapture, runCaptureEx, runShell }: typeof import("../runner") = require("../runner"); +const { + runCapture, + runCaptureEx, + runShell, + shellQuote, +}: typeof import("../runner") = require("../runner"); const { findReachableOllamaHost, }: typeof import("../inference/local") = require("../inference/local"); @@ -234,6 +239,7 @@ function downloadAndExtractUserLocal( ); const zstExists = headProbe.exitCode === 0; + const quotedInstallDir = shellQuote(installDir); if (zstExists) { if (!hostCommandExists("zstd", opts)) { errorLog( @@ -247,7 +253,7 @@ function downloadAndExtractUserLocal( } log(` Downloading ${tarballName}.tar.zst`); runShellImpl( - `set -o pipefail; curl --fail --show-error --location '${zstUrl}' | zstd -d | tar -xf - -C '${installDir}'`, + `set -o pipefail; curl --fail --show-error --location ${shellQuote(zstUrl)} | zstd -d | tar -xf - -C ${quotedInstallDir}`, { stdio: "inherit" }, ); return; @@ -255,7 +261,7 @@ function downloadAndExtractUserLocal( log(` Downloading ${tarballName}.tgz`); runShellImpl( - `set -o pipefail; curl --fail --show-error --location '${tgzUrl}' | tar -xzf - -C '${installDir}'`, + `set -o pipefail; curl --fail --show-error --location ${shellQuote(tgzUrl)} | tar -xzf - -C ${quotedInstallDir}`, { stdio: "inherit" }, ); } @@ -290,13 +296,14 @@ function installOllamaUserLocal(opts: InstallOllamaLinuxOptions): InstallOllamaL const installDir = nodePath.join(homedir(), ".local"); const binDir = nodePath.join(installDir, "bin"); const binPath = nodePath.join(binDir, "ollama"); + const libDir = nodePath.join(installDir, "lib", "ollama"); log( ` Installing Ollama in user-local mode (${installDir}). ` + "No sudo, no systemd; the daemon will be launched manually and must be restarted after a reboot.", ); - runShellImpl(`mkdir -p '${binDir}' '${installDir}/lib/ollama'`); + runShellImpl(`mkdir -p ${shellQuote(binDir)} ${shellQuote(libDir)}`); downloadAndExtractUserLocal(`ollama-linux-${ollamaArch}`, installDir, opts); const jetpack = detectJetpackVariant(opts); if (jetpack) { @@ -323,7 +330,7 @@ function startUserLocalOllamaDaemon( const waitForHttpImpl = opts.waitForHttpImpl ?? waitForHttp; log(" Starting Ollama..."); runShellImpl( - `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} nohup '${binPath}' serve > /dev/null 2>&1 &`, + `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} nohup ${shellQuote(binPath)} serve > /dev/null 2>&1 &`, { ignoreError: true }, ); return waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 9bea848e444..a58dce1e25c 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -1389,6 +1389,7 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync( @@ -1417,6 +1418,7 @@ fi const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const wait = require(${waitPath}); const child_process = require("child_process"); child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); @@ -1462,6 +1464,7 @@ runner.runShell = (command) => { Object.defineProperty(process, "platform", { value: "linux" }); platform.isWsl = () => false; +wait.sleepSeconds = () => {}; const { setupNim } = require(${onboardPath}); @@ -2086,10 +2089,12 @@ const { setupNim } = require(${onboardPath}); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); const script = String.raw` const runner = require(${runnerPath}); const platform = require(${platformPath}); +const wait = require(${waitPath}); let tagsProbeCount = 0; @@ -2111,6 +2116,7 @@ runner.runShell = (command) => { Object.defineProperty(process, "platform", { value: "linux" }); platform.isWsl = () => false; +wait.sleepSeconds = () => {}; const { setupNim } = require(${onboardPath}); @@ -5185,6 +5191,7 @@ const { setupNim } = require(${onboardPath}); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); // Fake curl binary that returns a successful response — needed because // runCurlProbe and validateOllamaModel spawn real curl via child_process. @@ -5220,6 +5227,7 @@ const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const registry = require(${registryPath}); const platform = require(${platformPath}); +const wait = require(${waitPath}); // Mock child_process.spawn so startOllamaAuthProxy doesn't try to spawn a real process. const child_process = require("child_process"); @@ -5293,6 +5301,7 @@ registry.updateSandbox = (_name, update) => updates.push(update); // Force platform to linux for this test Object.defineProperty(process, 'platform', { value: 'linux' }); platform.isWsl = () => false; +wait.sleepSeconds = () => {}; const { setupNim } = require(${onboardPath}); @@ -5427,11 +5436,13 @@ const { setupNim } = require(${onboardPath}); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); const script = String.raw` const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const platform = require(${platformPath}); +const wait = require(${waitPath}); const menuLines = []; const originalLog = console.log; @@ -5469,6 +5480,7 @@ runner.runShell = (command) => { Object.defineProperty(process, "platform", { value: "linux" }); platform.isWsl = () => false; +wait.sleepSeconds = () => {}; const { setupNim } = require(${onboardPath}); @@ -5513,6 +5525,7 @@ const { setupNim } = require(${onboardPath}); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync( @@ -5542,6 +5555,7 @@ const credentials = require(${credentialsPath}); const runner = require(${runnerPath}); const registry = require(${registryPath}); const platform = require(${platformPath}); +const wait = require(${waitPath}); const child_process = require("child_process"); child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); @@ -5592,6 +5606,7 @@ registry.updateSandbox = (_name, update) => updates.push(update); Object.defineProperty(process, "platform", { value: "linux" }); platform.isWsl = () => false; +wait.sleepSeconds = () => {}; const { setupNim } = require(${onboardPath});