From f1eceac1901dbd90b7316199f1c656047c03b04d Mon Sep 17 00:00:00 2001 From: Tony Luo Date: Tue, 26 May 2026 12:17:42 +0800 Subject: [PATCH 1/5] fix(onboard): gate Ollama auto-start behind --no-ollama-autostart Selecting "Local Ollama" in the onboard wizard unconditionally spawned `ollama serve` even when the user had explicitly stopped the daemon, masking the documented "unreachable Ollama -> fall back to default model" QA test path. Add a `--no-ollama-autostart` flag (mirrored by `NEMOCLAW_OLLAMA_NO_AUTOSTART=1`). When set and the daemon is unreachable, the wizard now prints a warning and selects `DEFAULT_OLLAMA_MODEL` instead of resurrecting the daemon. Default behavior is unchanged for users who don't pass the flag. Fixes #3751 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tony Luo --- src/lib/onboard.ts | 25 ++ src/lib/onboard/legacy-command.test.ts | 56 +++ src/lib/onboard/legacy-command.ts | 6 +- test/onboard-ollama-autostart.test.ts | 484 +++++++++++++++++++++++++ 4 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 test/onboard-ollama-autostart.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bbf426ccc6e..6a036483071 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -173,6 +173,7 @@ const { } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); const { + DEFAULT_OLLAMA_MODEL, findReachableOllamaHost, resetOllamaHostCache, getDefaultOllamaModel, @@ -564,12 +565,14 @@ type OnboardOptions = { gpu?: boolean; noGpu?: boolean; autoYes?: boolean; + noOllamaAutostart?: boolean; }; // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. let NON_INTERACTIVE = false; let RECREATE_SANDBOX = false; let AUTO_YES = false; +let NO_OLLAMA_AUTOSTART = false; // Set by onboard() before preflight() when --control-ui-port is specified. // null means "use auto-allocation" (skip dashboard port check in preflight). let _preflightDashboardPort: number | null = null; @@ -586,6 +589,10 @@ function isAutoYes(): boolean { return AUTO_YES || process.env.NEMOCLAW_YES === "1"; } +function isOllamaAutostartDisabled(): boolean { + return NO_OLLAMA_AUTOSTART || process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART === "1"; +} + function note(message: string): void { console.log(`${DIM}${message}${RESET}`); } @@ -5140,6 +5147,23 @@ async function setupNim( process.exit(1); } if (!ollamaReady) { + if (isOllamaAutostartDisabled()) { + console.log( + " ⚠ Ollama is not running on localhost:" + + `${OLLAMA_PORT} and --no-ollama-autostart is set; ` + + "skipping auto-start and falling back to the default model.", + ); + provider = "ollama-local"; + credentialEnv = null; + endpointUrl = getLocalProviderBaseUrl(provider); + if (!endpointUrl) { + console.error(" Local Ollama base URL could not be determined."); + process.exit(1); + } + model = DEFAULT_OLLAMA_MODEL; + preferredInferenceApi = "openai-completions"; + break; + } console.log(" Starting Ollama..."); // Keep raw Ollama loopback-only; the auth proxy (or Docker Desktop // on WSL via host.docker.internal) fronts container access. @@ -6723,6 +6747,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; + NO_OLLAMA_AUTOSTART = !!opts.noOllamaAutostart; _preflightDashboardPort = opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null); onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; diff --git a/src/lib/onboard/legacy-command.test.ts b/src/lib/onboard/legacy-command.test.ts index d8d37d741fa..282b04e6a35 100644 --- a/src/lib/onboard/legacy-command.test.ts +++ b/src/lib/onboard/legacy-command.test.ts @@ -49,6 +49,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -97,6 +98,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -126,6 +128,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -184,6 +187,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -214,6 +218,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -265,6 +270,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -405,6 +411,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -577,6 +584,7 @@ describe("onboard command", () => { gpu: false, noGpu: false, autoYes: false, + noOllamaAutostart: false, }); }); @@ -647,4 +655,52 @@ describe("onboard command", () => { ).toThrow("exit:1"); expect(errors.join("\n")).toContain("--gpu and --no-gpu are mutually exclusive"); }); + + it("defaults noOllamaAutostart to false when the flag is absent", () => { + const result = parseOnboardArgs( + [], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: () => {}, + exit: exitWithCode, + }, + ); + expect(result.noOllamaAutostart).toBe(false); + }); + + it("parses --no-ollama-autostart as noOllamaAutostart=true without rejecting it as unknown", () => { + const errors: string[] = []; + const result = parseOnboardArgs( + ["--no-ollama-autostart"], + "--yes-i-accept-third-party-software", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + { + env: {}, + error: (message = "") => errors.push(message), + exit: exitWithPrefixedCode, + }, + ); + expect(result.noOllamaAutostart).toBe(true); + expect(errors.join("\n")).not.toContain("Unknown onboard option(s)"); + }); + + it("--help advertises --no-ollama-autostart in the usage output", async () => { + const lines: string[] = []; + await runOnboardCommand({ + args: ["--help"], + noticeAcceptFlag: "--yes-i-accept-third-party-software", + noticeAcceptEnv: "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", + env: {}, + runOnboard: vi.fn(async () => {}), + log: (message = "") => lines.push(message), + error: () => {}, + exit: exitWithCode, + }); + expect(lines.join("\n")).toContain("--no-ollama-autostart"); + expect(lines.join("\n")).toContain( + "disables wizard auto-start of a local Ollama daemon", + ); + }); }); diff --git a/src/lib/onboard/legacy-command.ts b/src/lib/onboard/legacy-command.ts index 84175680d2a..5744ddecbc3 100644 --- a/src/lib/onboard/legacy-command.ts +++ b/src/lib/onboard/legacy-command.ts @@ -21,6 +21,7 @@ export interface OnboardCommandOptions { gpu: boolean; noGpu: boolean; autoYes: boolean; + noOllamaAutostart: boolean; } export interface RunOnboardCommandDeps { @@ -48,14 +49,16 @@ const ONBOARD_BASE_ARGS = [ "--no-gpu", "--yes", "-y", + "--no-ollama-autostart", ]; function onboardUsageLines(noticeAcceptFlag: string): string[] { const name = CLI_NAME; return [ - ` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [${noticeAcceptFlag}]`, + ` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${noticeAcceptFlag}]`, "", " --from uses the Dockerfile's parent directory as the Docker build context.", + " --no-ollama-autostart disables wizard auto-start of a local Ollama daemon; if Ollama is not running, the wizard prints a warning and selects the default fallback model.", " --gpu enables direct NVIDIA GPU access inside the sandbox; --no-gpu forces CPU sandbox behavior.", " --sandbox-gpu enables direct NVIDIA GPU access inside the sandbox; --no-sandbox-gpu forces CPU sandbox behavior.", " --sandbox-gpu-device passes a specific OpenShell GPU device selector to sandbox create; requires --sandbox-gpu.", @@ -241,6 +244,7 @@ export function parseOnboardArgs( gpu, noGpu, autoYes: parsedArgs.includes("--yes") || parsedArgs.includes("-y"), + noOllamaAutostart: parsedArgs.includes("--no-ollama-autostart"), }; } diff --git a/test/onboard-ollama-autostart.test.ts b/test/onboard-ollama-autostart.test.ts new file mode 100644 index 00000000000..4f6155defe6 --- /dev/null +++ b/test/onboard-ollama-autostart.test.ts @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Issue #3751: `nemoclaw onboard` ignored the host's stopped Ollama and silently +// restarted it. These tests cover the new --no-ollama-autostart gate that lets +// QA (and offline reproductions) reach the "fall back to default" path without +// the wizard resurrecting the daemon. + +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { testTimeout } from "./helpers/timeouts"; + +const OLLAMA_AUTOSTART_TEST_TIMEOUT_MS = testTimeout(60_000); + +type ScenarioOptions = { + ollamaRunning: boolean; + // When set, exported as NEMOCLAW_OLLAMA_NO_AUTOSTART=1. + noAutostartEnv?: boolean; + // When true, the test forces non-interactive mode via env. The wizard would + // normally process.exit(1) on a waitForHttp timeout — Scenario D asserts the + // gate path does NOT call process.exit. + nonInteractive?: boolean; + // When true, stub waitForHttp to return false. Only used to verify that the + // gated path does not even reach waitForHttp. + waitForHttpReturnsFalse?: boolean; +}; + +type WizardResult = { + result: { + provider: string; + model: string; + preferredInferenceApi: string | null; + endpointUrl: string | null; + credentialEnv: string | null; + } | null; + lines: string[]; + shellCommands: string[]; + waitForHttpCalls: string[]; + processExitCalled: number; + selectAndValidateOllamaModelCalled: boolean; + sentinelTripped: boolean; +}; + +function runOllamaAutostartScenario(opts: ScenarioOptions): WizardResult { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-autostart-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "onboard-ollama-autostart-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 platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const waitPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "core", "wait.js")); + const localInferencePath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "local.js"), + ); + const proxyPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "ollama", "proxy.js"), + ); + + fs.mkdirSync(fakeBin, { recursive: true }); + // Curl stub: respond with an OpenAI-compatible chat-completions tool-call + // body for any request (validation in selectAndValidateOllamaModel requires + // a successful tool-call response). The /api/tags response is shaped like + // ollama's daemon and is consulted by validation helpers in inference/local. + // For the "stopped" case, the runner.runCapture stub returns "" for tags, + // which is what gates the wizard — the curl stub itself stays permissive. + const toolCallBody = + '{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"type":"function","function":{"name":"emit_ok","arguments":"{\\"ok\\":true}"}}]}}]}'; + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +body='${toolCallBody}' +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"; fi +printf '%s' "$status" +`, + { mode: 0o755 }, + ); + // ollama binary stub — only matters for hostCommandExists("ollama"). + fs.writeFileSync(path.join(fakeBin, "ollama"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755 }); + + const scenarioEnv: Record = { + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + // Pin provider selection so the test deterministically enters the Ollama + // branch of the wizard regardless of menu ordering changes elsewhere. + NEMOCLAW_PROVIDER: "ollama", + }; + if (opts.noAutostartEnv) scenarioEnv.NEMOCLAW_OLLAMA_NO_AUTOSTART = "1"; + if (opts.nonInteractive) scenarioEnv.NEMOCLAW_NON_INTERACTIVE = "1"; + + const script = String.raw` +const credentials = require(${credentialsPath}); +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const wait = require(${waitPath}); +const localInference = require(${localInferencePath}); +const child_process = require("child_process"); + +// Background process spawn: never let a real ollama serve fork off in the +// test harness (defense in depth — the spawn path uses runShell, not spawn). +child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} }); +const originalSpawnSync = child_process.spawnSync; +child_process.spawnSync = (cmd, args, opts) => { + if (cmd === "nc" && args && args.includes("11435")) { + 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); +}; + +const ollamaRunning = ${JSON.stringify(opts.ollamaRunning)}; +const shellCommands = []; +const waitForHttpCalls = []; +const lines = []; +let processExitCalled = 0; +let selectAndValidateOllamaModelCalled = false; + +// Force Linux + non-WSL to deterministically reach the "ollama" menu key +// rather than Windows-host paths. +Object.defineProperty(process, "platform", { value: "linux" }); +platform.isWsl = () => false; + +// Menu answers: "1" picks the first option whenever a prompt asks. The +// Ollama option is always offered when the binary is present (or running). +const answers = ${JSON.stringify(opts.nonInteractive ? [] : ["1"])}; +credentials.prompt = async () => { + return answers.shift() || ""; +}; +credentials.ensureApiKey = async () => {}; + +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + // Order matters: the systemd unit probe is a sh -c snippet that contains + // BOTH "command -v systemctl" and "ollama.service" — it must NOT be + // mistaken for "command -v ollama". Check the systemd probe first and + // return empty so ensureOllamaLoopbackSystemdOverride takes the + // "not-applicable" branch. + if (cmd.includes("systemctl list-unit-files ollama.service")) return ""; + if (cmd.includes("command -v") && cmd.includes("\"$1\"")) { + // hostCommandExists uses: sh -c 'command -v "$1"' -- . The argv + // contains the literal '"$1"' marker. + return cmd.includes("ollama") ? "/usr/bin/ollama" : ""; + } + if (cmd.includes("127.0.0.1:11434/api/tags")) { + return ollamaRunning ? JSON.stringify({ models: [{ name: "nemotron-3-nano:30b" }] }) : ""; + } + if (cmd.includes("127.0.0.1:8000/v1/models")) return ""; + if (cmd.includes("ollama list")) { + return ollamaRunning ? "nemotron-3-nano:30b abc 24 GB now" : ""; + } + if (cmd.includes("api/generate")) return '{"response":"hello"}'; + if (cmd.includes("ps")) return "node ollama-auth-proxy.js"; + return ""; +}; +runner.run = () => ({ status: 0 }); +runner.runShell = (command) => { + shellCommands.push(command); + return { status: 0 }; +}; + +wait.sleepSeconds = () => {}; +const originalWaitForHttp = wait.waitForHttp; +wait.waitForHttp = (url, tries) => { + waitForHttpCalls.push(String(url)); + if (${JSON.stringify(opts.waitForHttpReturnsFalse === true)}) return false; + return true; +}; + +// Pre-loaded by onboard.ts at import time — reset so the test scenario's +// runCapture stub decides reachability fresh. +localInference.resetOllamaHostCache(); +// Stub the *exported* findReachableOllamaHost. onboard.ts destructures this +// reference at its require time, so the stub MUST be installed before the +// onboard require() below. +localInference.findReachableOllamaHost = () => (ollamaRunning ? "127.0.0.1" : null); + +// Sentinel: startOllamaAuthProxy is called downstream of the Ollama branch +// (after either the spawn path or the "already running" path). Throwing a +// sentinel here bails out of the wizard once it has done everything that +// matters for the gated-vs-spawn assertions. The fallback branch breaks out +// of selectionLoop BEFORE this is reached, so Scenarios A and D never see +// the sentinel — only B and C do. +const proxy = require(${proxyPath}); +class OllamaAutostartSentinel extends Error {} +proxy.startOllamaAuthProxy = () => { + throw new OllamaAutostartSentinel("ollama-autostart-test-sentinel"); +}; + +// Wrap selectAndValidateOllamaModel to record whether the wizard reached it. +// Access via the dist module's exported function (it's local in source, but +// the local function in onboard.ts uses runCapture/localInference; we observe +// the side-effect via "Loading Ollama model" log lines). +const onboard = require(${onboardPath}); + +// Wrap process.exit to count invocations rather than terminate the test. +const originalExit = process.exit; +process.exit = (code) => { + processExitCalled++; + throw new Error("process.exit:" + (code ?? 0)); +}; + +(async () => { + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => { + const line = args.join(" "); + lines.push(line); + if (line.includes("Loading Ollama model")) { + selectAndValidateOllamaModelCalled = true; + } + }; + console.error = (...args) => lines.push(args.join(" ")); + let result = null; + let sentinelTripped = false; + try { + result = await onboard.setupNim(null); + } catch (error) { + const msg = String(error && error.message); + if (error instanceof OllamaAutostartSentinel || msg.includes("ollama-autostart-test-sentinel")) { + sentinelTripped = true; + } else if (!msg.startsWith("process.exit:")) { + console.error = originalError; + console.log = originalLog; + process.exit = originalExit; + throw error; + } + } finally { + console.error = originalError; + console.log = originalLog; + process.exit = originalExit; + } + originalLog(JSON.stringify({ + result, + lines, + shellCommands, + waitForHttpCalls, + processExitCalled, + selectAndValidateOllamaModelCalled, + sentinelTripped, + })); +})().catch((error) => { + console.error(error); + originalExit(2); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + ...scenarioEnv, + // Force loopback-only path to avoid systemd / install branches. + NEMOCLAW_OLLAMA_INSTALL_MODE: "system", + // Clear any inherited overrides from the parent test runner. NEMOCLAW_PROVIDER + // is set above in scenarioEnv to route the wizard into the Ollama branch. + NEMOCLAW_MODEL: "", + NEMOCLAW_YES: "", + }, + }); + + assert.equal(result.status, 0, `subprocess stderr:\n${result.stderr}\n\nstdout:\n${result.stdout}`); + const lastBraceLine = result.stdout + .trim() + .split("\n") + .reverse() + .find((line) => line.startsWith("{")); + if (!lastBraceLine) { + throw new Error(`no JSON payload in subprocess stdout:\n${result.stdout}`); + } + return JSON.parse(lastBraceLine); +} + +describe("nemoclaw onboard --no-ollama-autostart (issue #3751)", () => { + it( + "Scenario A: stopped Ollama + flag set → no spawn, warning, falls back to DEFAULT_OLLAMA_MODEL", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + const payload = runOllamaAutostartScenario({ + ollamaRunning: false, + noAutostartEnv: true, + }); + + // No ollama serve spawn, no waitForHttp probe to :11434. + assert.ok( + !payload.shellCommands.some((cmd) => cmd.includes("ollama serve")), + `runShell must not be invoked with 'ollama serve' when the gate is set; got: ${JSON.stringify(payload.shellCommands)}`, + ); + assert.ok( + !payload.waitForHttpCalls.some((url) => url.includes("127.0.0.1:11434")), + `waitForHttp must not probe :11434 when the gate is set; got: ${JSON.stringify(payload.waitForHttpCalls)}`, + ); + // Exact warning string from the architect contract. + assert.ok( + payload.lines.some((line) => + line.includes( + "⚠ Ollama is not running on localhost:11434 and --no-ollama-autostart is set; skipping auto-start and falling back to the default model.", + ), + ), + `expected the gated warning line; got lines:\n${payload.lines.join("\n")}`, + ); + // Should not have printed the success "Using Ollama on …" line. + assert.ok( + !payload.lines.some((line) => line.includes("✓ Using Ollama")), + "fallback branch must not log the ✓ Using Ollama line", + ); + assert.ok(payload.result, "wizard should have completed"); + assert.equal(payload.result!.provider, "ollama-local"); + // Hard-asserted against the architect contract, but the constant is the + // single source of truth. Read it from the dist module the wizard uses. + const { DEFAULT_OLLAMA_MODEL } = require( + path.join(import.meta.dirname, "..", "dist", "lib", "inference", "local.js"), + ); + assert.equal(payload.result!.model, DEFAULT_OLLAMA_MODEL); + assert.equal(payload.result!.preferredInferenceApi, "openai-completions"); + assert.equal(payload.result!.credentialEnv, null); + assert.ok( + payload.result!.endpointUrl && payload.result!.endpointUrl.length > 0, + "fallback branch must populate endpointUrl from getLocalProviderBaseUrl", + ); + // selectAndValidateOllamaModel is intentionally bypassed. + assert.equal(payload.selectAndValidateOllamaModelCalled, false); + // The fallback `break` exits selectionLoop BEFORE startOllamaAuthProxy is + // reached — sentinel must not have tripped. + assert.equal( + payload.sentinelTripped, + false, + "gated fallback must not reach startOllamaAuthProxy", + ); + }, + ); + + it( + "Scenario B: stopped Ollama + flag NOT set → existing spawn path preserved", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + const payload = runOllamaAutostartScenario({ + ollamaRunning: false, + noAutostartEnv: false, + }); + + assert.ok( + payload.shellCommands.some( + (cmd) => cmd.includes("OLLAMA_HOST=127.0.0.1:") && cmd.includes("ollama serve"), + ), + `expected the legacy spawn to fire; got: ${JSON.stringify(payload.shellCommands)}`, + ); + assert.ok( + payload.lines.some((line) => line.includes("Starting Ollama...")), + `expected the "Starting Ollama..." log; got lines:\n${payload.lines.join("\n")}`, + ); + // The gated warning string must NOT be emitted on this path. + assert.ok( + !payload.lines.some((line) => + line.includes("--no-ollama-autostart is set"), + ), + "gate warning must not fire when the flag is unset", + ); + // Sentinel tripped — proves the wizard exited the !ollamaReady block via + // the spawn-then-proxy path (i.e. moved on to startOllamaAuthProxy), NOT + // via the gated `break` that fallback uses. + assert.equal( + payload.sentinelTripped, + true, + `expected wizard to reach the post-spawn proxy step; lines:\n${payload.lines.join("\n")}`, + ); + }, + ); + + it( + "Scenario C (flag unset): Ollama already running → behavior unchanged, no spawn, no warning", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + const payload = runOllamaAutostartScenario({ + ollamaRunning: true, + noAutostartEnv: false, + }); + + assert.ok( + !payload.shellCommands.some((cmd) => cmd.includes("ollama serve")), + "no spawn expected when Ollama is already reachable", + ); + assert.ok( + !payload.waitForHttpCalls.some((url) => url.includes("127.0.0.1:11434")), + "no startup probe expected when Ollama is already reachable", + ); + assert.ok( + !payload.lines.some((line) => line.includes("Starting Ollama...")), + "no 'Starting Ollama...' line expected when daemon is already up", + ); + assert.ok( + !payload.lines.some((line) => line.includes("--no-ollama-autostart is set")), + "gate warning must not fire when daemon is already up", + ); + // Wizard should have reached the proxy step (post-readiness), not the + // gated `break` path. + assert.equal(payload.sentinelTripped, true); + }, + ); + + it( + "Scenario C (flag set): Ollama already running + flag set → no warning, no spawn", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + const payload = runOllamaAutostartScenario({ + ollamaRunning: true, + noAutostartEnv: true, + }); + + assert.ok( + !payload.shellCommands.some((cmd) => cmd.includes("ollama serve")), + "no spawn expected when Ollama is already reachable, regardless of flag", + ); + assert.ok( + !payload.lines.some((line) => line.includes("--no-ollama-autostart is set")), + "gate warning must not fire when daemon is already up — flag is orthogonal", + ); + // Flag is irrelevant here: the wizard still proceeds via the proxy path, + // not the fallback break. + assert.equal(payload.sentinelTripped, true); + }, + ); + + it( + "Scenario D: non-interactive + flag set → no process.exit, warning, model = DEFAULT_OLLAMA_MODEL", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + const payload = runOllamaAutostartScenario({ + ollamaRunning: false, + noAutostartEnv: true, + nonInteractive: true, + // Hard-fail waitForHttp so the test would observe a non-interactive + // process.exit(1) if the gate did not fire. With the gate set, this + // stub must not even be reached. + waitForHttpReturnsFalse: true, + }); + + assert.equal( + payload.processExitCalled, + 0, + `non-interactive must not exit when the gate is honored; lines:\n${payload.lines.join("\n")}`, + ); + assert.ok( + payload.lines.some((line) => + line.includes( + "⚠ Ollama is not running on localhost:11434 and --no-ollama-autostart is set; skipping auto-start and falling back to the default model.", + ), + ), + `expected gated warning in non-interactive mode; lines:\n${payload.lines.join("\n")}`, + ); + assert.ok( + !payload.shellCommands.some((cmd) => cmd.includes("ollama serve")), + "no spawn expected with the gate set, even in non-interactive mode", + ); + assert.ok(payload.result, "non-interactive wizard should still produce a result"); + const { DEFAULT_OLLAMA_MODEL } = require( + path.join(import.meta.dirname, "..", "dist", "lib", "inference", "local.js"), + ); + assert.equal(payload.result!.model, DEFAULT_OLLAMA_MODEL); + assert.equal(payload.result!.provider, "ollama-local"); + // Non-interactive gate path must not reach the proxy stage either. + assert.equal(payload.sentinelTripped, false); + }, + ); +}); From 3686f2890958bb710a420cd946cb3367a70551f6 Mon Sep 17 00:00:00 2001 From: Tony Luo Date: Tue, 26 May 2026 13:08:24 +0800 Subject: [PATCH 2/5] refactor(onboard): extract Ollama startup gate to dedicated module Move the `--no-ollama-autostart` gate plus the legacy `ollama serve` spawn block out of `src/lib/onboard.ts` into a new `src/lib/onboard/ollama-startup.ts` module. This satisfies the `onboard-entrypoint-budget` CI gate (which blocks net growth in the 12k-line entrypoint) while keeping behavior identical: the wizard now delegates to `runOllamaStartupOrGate()`, which returns either a fallback result (gate path) or a ready/continue marker (spawn path). Also document the new flag and env var so the `cli-parity` and `env-var-docs` CI gates pass: - Add `--no-ollama-autostart` to the `nemoclaw onboard`, `nemoclaw setup`, and `nemoclaw setup-spark` sections of `docs/reference/commands.mdx`. - Add `NEMOCLAW_OLLAMA_NO_AUTOSTART` to the Onboarding Behavior Flags env-var table. Refs #3751 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tony Luo --- docs/reference/commands.mdx | 7 +-- src/lib/onboard.ts | 49 ++++++--------------- src/lib/onboard/ollama-startup.ts | 71 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 39 deletions(-) create mode 100644 src/lib/onboard/ollama-startup.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 6741036b10e..8e3f1beec8f 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -63,7 +63,7 @@ The wizard creates an OpenShell gateway, registers inference providers, builds t Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```console -$ nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--yes-i-accept-third-party-software] +$ nemoclaw onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [--yes-i-accept-third-party-software] ``` @@ -1077,7 +1077,7 @@ The `nemoclaw setup` command is deprecated. Use `nemoclaw onboard` instead. -This command remains as a compatibility alias to `nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```console $ nemoclaw setup @@ -1090,7 +1090,7 @@ The `nemoclaw setup-spark` command is deprecated. Use the standard installer and run `nemoclaw onboard` instead, because current OpenShell releases handle the older DGX Spark cgroup behavior. -This command remains as a compatibility alias to `nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--yes-i-accept-third-party-software`. +This command remains as a compatibility alias to `nemoclaw onboard` and accepts the same flags: `--non-interactive`, `--resume`, `--fresh`, `--recreate-sandbox`, `--gpu` / `--no-gpu`, `--from`, `--name`, `--sandbox-gpu` / `--no-sandbox-gpu`, `--sandbox-gpu-device`, `--agent`, `--control-ui-port`, `--yes` / `-y`, `--no-ollama-autostart`, `--yes-i-accept-third-party-software`. ```console $ nemoclaw setup-spark @@ -1286,6 +1286,7 @@ These flags toggle optional behaviors during onboarding; set them before running | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. | +| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Disables the wizard's auto-start of a local Ollama daemon (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `nemoclaw onboard` Local Ollama path prints a warning and falls back to the default Ollama model instead of spawning `ollama serve`. Lets QA reproduce the documented "Ollama stopped → fallback" path. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | | `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 6a036483071..1db73d3a4de 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -173,7 +173,6 @@ const { } = require("./core/ports"); const localInference: typeof import("./inference/local") = require("./inference/local"); const { - DEFAULT_OLLAMA_MODEL, findReachableOllamaHost, resetOllamaHostCache, getDefaultOllamaModel, @@ -488,6 +487,7 @@ import { readMessagingChannelConfigFromEnv, } from "./messaging-channel-config"; import { streamGatewayStart } from "./onboard/gateway"; +import { runOllamaStartupOrGate, setOllamaAutostartDisabled } from "./onboard/ollama-startup"; import { mergeRequiredHermesToolGatewayPolicyPresets, normalizeHermesToolGatewaySelections, @@ -572,7 +572,6 @@ type OnboardOptions = { let NON_INTERACTIVE = false; let RECREATE_SANDBOX = false; let AUTO_YES = false; -let NO_OLLAMA_AUTOSTART = false; // Set by onboard() before preflight() when --control-ui-port is specified. // null means "use auto-allocation" (skip dashboard port check in preflight). let _preflightDashboardPort: number | null = null; @@ -589,10 +588,6 @@ function isAutoYes(): boolean { return AUTO_YES || process.env.NEMOCLAW_YES === "1"; } -function isOllamaAutostartDisabled(): boolean { - return NO_OLLAMA_AUTOSTART || process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART === "1"; -} - function note(message: string): void { console.log(`${DIM}${message}${RESET}`); } @@ -5146,35 +5141,17 @@ async function setupNim( ); process.exit(1); } - if (!ollamaReady) { - if (isOllamaAutostartDisabled()) { - console.log( - " ⚠ Ollama is not running on localhost:" + - `${OLLAMA_PORT} and --no-ollama-autostart is set; ` + - "skipping auto-start and falling back to the default model.", - ); - provider = "ollama-local"; - credentialEnv = null; - endpointUrl = getLocalProviderBaseUrl(provider); - if (!endpointUrl) { - console.error(" Local Ollama base URL could not be determined."); - process.exit(1); - } - model = DEFAULT_OLLAMA_MODEL; - preferredInferenceApi = "openai-completions"; - break; - } - console.log(" Starting Ollama..."); - // Keep raw Ollama loopback-only; the auth proxy (or Docker Desktop - // on WSL via host.docker.internal) fronts container access. - 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 ollamaStartup = runOllamaStartupOrGate({ + ollamaReady, + ollamaPort: OLLAMA_PORT, + getLocalProviderBaseUrl, + isNonInteractive, + }); + if (ollamaStartup.kind === "continue") continue selectionLoop; + if (ollamaStartup.kind === "fallback") { + ({ provider, credentialEnv, endpointUrl, model, preferredInferenceApi } = + ollamaStartup.result); + break; } if (shouldFrontOllamaWithProxy()) { if (!startOllamaAuthProxy()) process.exit(1); @@ -6747,7 +6724,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; - NO_OLLAMA_AUTOSTART = !!opts.noOllamaAutostart; + setOllamaAutostartDisabled(opts.noOllamaAutostart); _preflightDashboardPort = opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null); onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; diff --git a/src/lib/onboard/ollama-startup.ts b/src/lib/onboard/ollama-startup.ts new file mode 100644 index 00000000000..5ba2445612b --- /dev/null +++ b/src/lib/onboard/ollama-startup.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const runner: typeof import("../runner") = require("../runner"); +const wait: typeof import("../core/wait") = require("../core/wait"); +const localInference: typeof import("../inference/local") = require("../inference/local"); + +let NO_OLLAMA_AUTOSTART = false; + +export function setOllamaAutostartDisabled(value: boolean | undefined): void { + NO_OLLAMA_AUTOSTART = !!value; +} + +export function isOllamaAutostartDisabled(): boolean { + return NO_OLLAMA_AUTOSTART || process.env.NEMOCLAW_OLLAMA_NO_AUTOSTART === "1"; +} + +export type OllamaFallbackResult = { + provider: "ollama-local"; + credentialEnv: null; + endpointUrl: string; + model: string; + preferredInferenceApi: "openai-completions"; +}; + +export type OllamaStartupOutcome = + | { kind: "ready" } + | { kind: "continue" } + | { kind: "fallback"; result: OllamaFallbackResult }; + +export function runOllamaStartupOrGate(args: { + ollamaReady: boolean; + ollamaPort: number; + getLocalProviderBaseUrl: (provider: "ollama-local") => string | null; + isNonInteractive: () => boolean; +}): OllamaStartupOutcome { + const { ollamaReady, ollamaPort, getLocalProviderBaseUrl, isNonInteractive } = args; + if (ollamaReady) return { kind: "ready" }; + if (isOllamaAutostartDisabled()) { + console.log( + " ⚠ Ollama is not running on localhost:" + + `${ollamaPort} and --no-ollama-autostart is set; ` + + "skipping auto-start and falling back to the default model.", + ); + const endpointUrl = getLocalProviderBaseUrl("ollama-local"); + if (!endpointUrl) { + console.error(" Local Ollama base URL could not be determined."); + process.exit(1); + } + return { + kind: "fallback", + result: { + provider: "ollama-local", + credentialEnv: null, + endpointUrl, + model: localInference.DEFAULT_OLLAMA_MODEL, + preferredInferenceApi: "openai-completions", + }, + }; + } + console.log(" Starting Ollama..."); + runner.runShell(`OLLAMA_HOST=127.0.0.1:${ollamaPort} ollama serve > /dev/null 2>&1 &`, { + ignoreError: true, + }); + if (!wait.waitForHttp(`http://127.0.0.1:${ollamaPort}/`, 10)) { + console.error(` Ollama did not become ready on :${ollamaPort} within timeout.`); + if (isNonInteractive()) process.exit(1); + return { kind: "continue" }; + } + return { kind: "ready" }; +} From d5341335c935bfe383332d9bdaefeebae42a565c Mon Sep 17 00:00:00 2001 From: Tony Luo Date: Tue, 26 May 2026 13:19:50 +0800 Subject: [PATCH 3/5] fix(onboard): keep onboard.ts within entrypoint budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the prior refactor commit: 1. Revert accidental edits to unrelated lines in `src/lib/onboard.ts` (a stray comment near the local-inference detect block, and a removed `platform: gpu?.platform` field on the vLLM menu builder) so the diff against upstream/main only touches the Ollama startup path. 2. Drop the `noOllamaAutostart` field from `OnboardOptions` and stop importing `setOllamaAutostartDisabled` in `onboard.ts`. Instead, the legacy parser in `src/lib/onboard/legacy-command.ts` sets `NEMOCLAW_OLLAMA_NO_AUTOSTART=1` directly when the flag is parsed, and `ollama-startup` reads the env var. This avoids the transitive source-side `require("../runner")` load that vitest's transform pipeline can't resolve through a sibling TS source, and shrinks `onboard.ts` to 11 additions / 12 deletions vs main — inside the `onboard-entrypoint-budget` CI gate. Refs #3751 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tony Luo --- src/lib/onboard.ts | 9 +++------ src/lib/onboard/legacy-command.ts | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1db73d3a4de..9fdc3bc23fa 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -487,7 +487,7 @@ import { readMessagingChannelConfigFromEnv, } from "./messaging-channel-config"; import { streamGatewayStart } from "./onboard/gateway"; -import { runOllamaStartupOrGate, setOllamaAutostartDisabled } from "./onboard/ollama-startup"; +import { runOllamaStartupOrGate } from "./onboard/ollama-startup"; import { mergeRequiredHermesToolGatewayPolicyPresets, normalizeHermesToolGatewaySelections, @@ -565,7 +565,6 @@ type OnboardOptions = { gpu?: boolean; noGpu?: boolean; autoYes?: boolean; - noOllamaAutostart?: boolean; }; // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. @@ -4272,7 +4271,6 @@ async function setupNim( // (#2674). const localProbeCurlArgs = ["--connect-timeout", "2", "--max-time", "5"] as const; const hasOllama = hostCommandExists("ollama"); - // run and consumed by the Ollama lifecycle helpers in inference/local.ts. const ollamaHost = findReachableOllamaHost(); const ollamaRunning = ollamaHost !== null; const vllmRunning = !!runCapture( @@ -4361,6 +4359,7 @@ async function setupNim( vllmRunning, vllmProfile, experimental: EXPERIMENTAL, + platform: gpu?.platform, hasVllmImage, }), ); @@ -5149,8 +5148,7 @@ async function setupNim( }); if (ollamaStartup.kind === "continue") continue selectionLoop; if (ollamaStartup.kind === "fallback") { - ({ provider, credentialEnv, endpointUrl, model, preferredInferenceApi } = - ollamaStartup.result); + ({ provider, credentialEnv, endpointUrl, model, preferredInferenceApi } = ollamaStartup.result); break; } if (shouldFrontOllamaWithProxy()) { @@ -6724,7 +6722,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; - setOllamaAutostartDisabled(opts.noOllamaAutostart); _preflightDashboardPort = opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null); onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; diff --git a/src/lib/onboard/legacy-command.ts b/src/lib/onboard/legacy-command.ts index 5744ddecbc3..817f41ee6e6 100644 --- a/src/lib/onboard/legacy-command.ts +++ b/src/lib/onboard/legacy-command.ts @@ -256,6 +256,7 @@ export async function runOnboardCommand(deps: RunOnboardCommandDeps): Promise Date: Tue, 26 May 2026 13:32:36 +0800 Subject: [PATCH 4/5] fix(onboard): register --no-ollama-autostart on the oclif onboard command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit documented `--no-ollama-autostart` under `nemoclaw onboard` in `docs/reference/commands.mdx`, but the flag was only parsed by the legacy command wrapper (used by the deprecated `setup` and `setup-spark` aliases). The oclif `onboard` command rejected `--no-ollama-autostart` as an unknown flag, so `cli-parity` failed with the reverse drift error: documented under `nemoclaw onboard` but absent from `nemoclaw onboard --help`. Register the flag in `buildOnboardFlags()`, surface it in the `OnboardFlags` type and the canonical `onboardUsage`, and forward it to the legacy parser through `toLegacyOnboardArgs()`. With this the flag works end-to-end on `nemoclaw onboard`, `nemoclaw setup`, and `nemoclaw setup-spark`. Also drop the QA-facing wording from the `NEMOCLAW_OLLAMA_NO_AUTOSTART` env-var description — the public docs should not name internal QA test-plan scenarios. Refs #3751 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tony Luo --- docs/reference/commands.mdx | 2 +- src/lib/onboard/command-support.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8e3f1beec8f..557bb526263 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1286,7 +1286,7 @@ These flags toggle optional behaviors during onboarding; set them before running | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. | -| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Disables the wizard's auto-start of a local Ollama daemon (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `nemoclaw onboard` Local Ollama path prints a warning and falls back to the default Ollama model instead of spawning `ollama serve`. Lets QA reproduce the documented "Ollama stopped → fallback" path. | +| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Disables the wizard's auto-start of a local Ollama daemon (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `nemoclaw onboard` Local Ollama path prints a warning and falls back to the default Ollama model instead of spawning `ollama serve`. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | | `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index dff589adb73..b5955c1382f 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -8,7 +8,7 @@ import { NOTICE_ACCEPT_FLAG } from "./usage-notice"; const acceptFlagName = NOTICE_ACCEPT_FLAG.replace(/^--/, ""); export const onboardUsage = [ - `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [${NOTICE_ACCEPT_FLAG}]`, + `onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${NOTICE_ACCEPT_FLAG}]`, ]; export const onboardExamples = [ @@ -36,6 +36,7 @@ export type OnboardFlags = { agent?: string; "control-ui-port"?: number; yes?: boolean; + "no-ollama-autostart"?: boolean; [acceptFlagName]?: boolean; }; @@ -80,6 +81,10 @@ export function buildOnboardFlags(): Record { char: "y", description: "Auto-confirm prompts that are safe for unattended onboarding", }), + "no-ollama-autostart": Flags.boolean({ + description: + "Disable wizard auto-start of a local Ollama daemon; if Ollama is not running, the wizard prints a warning and selects the default fallback model", + }), [acceptFlagName]: Flags.boolean({ description: "Accept the third-party software notice" }), } as Record; } @@ -104,6 +109,7 @@ export function toLegacyOnboardArgs(flags: OnboardFlags): string[] { args.push("--control-ui-port", String(flags["control-ui-port"])); } if (flags.yes) args.push("--yes"); + if (flags["no-ollama-autostart"]) args.push("--no-ollama-autostart"); if (flags[acceptFlagName]) args.push(NOTICE_ACCEPT_FLAG); return args; } From 9fcd45a837bd0c7590cca631d7135daa5fff1ff4 Mon Sep 17 00:00:00 2001 From: Tony Luo Date: Wed, 27 May 2026 11:04:07 +0800 Subject: [PATCH 5/5] fix(onboard): narrow --no-ollama-autostart scope; abort on pinned provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback on PR #4212 (ericksoa): 1. The `--no-ollama-autostart` flag only covers the wizard's inference-provider selection step. Later setup steps (`setupInference` → auth proxy → `validateLocalProvider` → model warm) still require a reachable Ollama, and on Linux/systemd hosts `ensureOllamaLoopbackSystemdOverride()` runs before the gate and may restart the daemon. Rewrite the help text in `legacy-command.ts`, the oclif flag description in `command-support.ts`, and the env-var entry in `docs/reference/commands.mdx` so the user-facing contract no longer overpromises. 2. When `NEMOCLAW_PROVIDER=ollama` is pinned and the spawn-then-wait path times out, `runOllamaStartupOrGate()` previously returned `{ kind: "continue" }`, which made `onboard.ts` re-enter the `selectionLoop`. Because the menu is pinned to Ollama, that re-enters the same branch indefinitely. Treat a pinned provider the same as non-interactive mode: print a clear abort message and `process.exit(1)`. Test coverage: - Scenario E in `test/onboard-ollama-autostart.test.ts` exercises the pinned-provider abort: stopped Ollama, flag NOT set, `NEMOCLAW_PROVIDER=ollama`, `waitForHttp` rigged to time out. The test asserts `process.exit` is invoked, the new abort message is logged, and the sentinel never trips (no proxy stage entered). - The legacy-command `--help` parser test is updated to match the narrower text. Refs #3751 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tony Luo --- docs/reference/commands.mdx | 2 +- src/lib/onboard/command-support.ts | 2 +- src/lib/onboard/legacy-command.test.ts | 4 +-- src/lib/onboard/legacy-command.ts | 2 +- src/lib/onboard/ollama-startup.ts | 10 ++++++- test/onboard-ollama-autostart.test.ts | 37 ++++++++++++++++++++++++++ 6 files changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 557bb526263..fb93eee5281 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1286,7 +1286,7 @@ These flags toggle optional behaviors during onboarding; set them before running | Variable | Format | Effect | |----------|--------|--------| | `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup. | -| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Disables the wizard's auto-start of a local Ollama daemon (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `nemoclaw onboard` Local Ollama path prints a warning and falls back to the default Ollama model instead of spawning `ollama serve`. | +| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Skips the wizard's eager Ollama auto-start during inference-provider selection (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `nemoclaw onboard` Local Ollama path prints a warning and selects the default fallback model instead of spawning `ollama serve`. The flag covers only the provider-selection step; later setup steps (auth proxy, validation, model warm) still expect a reachable Ollama. On Linux hosts with a systemd Ollama unit, the loopback-override path may still restart the daemon before this gate runs. | | `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. | | `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index b5955c1382f..d1711e001a8 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -83,7 +83,7 @@ export function buildOnboardFlags(): Record { }), "no-ollama-autostart": Flags.boolean({ description: - "Disable wizard auto-start of a local Ollama daemon; if Ollama is not running, the wizard prints a warning and selects the default fallback model", + "Skip the wizard's eager Ollama auto-start during inference-provider selection so onboard surfaces the unreachable-Ollama warning and the default fallback model; later setup steps still expect a reachable Ollama, and on Linux/systemd hosts the loopback-override path may still restart the daemon", }), [acceptFlagName]: Flags.boolean({ description: "Accept the third-party software notice" }), } as Record; diff --git a/src/lib/onboard/legacy-command.test.ts b/src/lib/onboard/legacy-command.test.ts index 282b04e6a35..b02ee8d7ede 100644 --- a/src/lib/onboard/legacy-command.test.ts +++ b/src/lib/onboard/legacy-command.test.ts @@ -699,8 +699,6 @@ describe("onboard command", () => { exit: exitWithCode, }); expect(lines.join("\n")).toContain("--no-ollama-autostart"); - expect(lines.join("\n")).toContain( - "disables wizard auto-start of a local Ollama daemon", - ); + expect(lines.join("\n")).toContain("inference-provider selection"); }); }); diff --git a/src/lib/onboard/legacy-command.ts b/src/lib/onboard/legacy-command.ts index 817f41ee6e6..14c343aef6e 100644 --- a/src/lib/onboard/legacy-command.ts +++ b/src/lib/onboard/legacy-command.ts @@ -58,7 +58,7 @@ function onboardUsageLines(noticeAcceptFlag: string): string[] { ` Usage: ${name} onboard [--non-interactive] [--resume | --fresh] [--recreate-sandbox] [--gpu | --no-gpu] [--from ] [--name ] [--sandbox-gpu | --no-sandbox-gpu] [--sandbox-gpu-device ] [--agent ] [--control-ui-port ] [--yes | -y] [--no-ollama-autostart] [${noticeAcceptFlag}]`, "", " --from uses the Dockerfile's parent directory as the Docker build context.", - " --no-ollama-autostart disables wizard auto-start of a local Ollama daemon; if Ollama is not running, the wizard prints a warning and selects the default fallback model.", + " --no-ollama-autostart skips the wizard's eager Ollama auto-start during inference-provider selection so onboard surfaces the unreachable-Ollama warning and the default fallback model; later setup steps still expect a reachable Ollama, and on Linux hosts with a systemd Ollama unit the loopback-override path may still restart the daemon ahead of this gate.", " --gpu enables direct NVIDIA GPU access inside the sandbox; --no-gpu forces CPU sandbox behavior.", " --sandbox-gpu enables direct NVIDIA GPU access inside the sandbox; --no-sandbox-gpu forces CPU sandbox behavior.", " --sandbox-gpu-device passes a specific OpenShell GPU device selector to sandbox create; requires --sandbox-gpu.", diff --git a/src/lib/onboard/ollama-startup.ts b/src/lib/onboard/ollama-startup.ts index 5ba2445612b..33df4fe6b44 100644 --- a/src/lib/onboard/ollama-startup.ts +++ b/src/lib/onboard/ollama-startup.ts @@ -64,7 +64,15 @@ export function runOllamaStartupOrGate(args: { }); if (!wait.waitForHttp(`http://127.0.0.1:${ollamaPort}/`, 10)) { console.error(` Ollama did not become ready on :${ollamaPort} within timeout.`); - if (isNonInteractive()) process.exit(1); + const providerPinned = process.env.NEMOCLAW_PROVIDER === "ollama"; + if (isNonInteractive() || providerPinned) { + if (providerPinned) { + console.error( + " NEMOCLAW_PROVIDER=ollama is pinned but Ollama is unreachable; refusing to loop on provider selection.", + ); + } + process.exit(1); + } return { kind: "continue" }; } return { kind: "ready" }; diff --git a/test/onboard-ollama-autostart.test.ts b/test/onboard-ollama-autostart.test.ts index 4f6155defe6..95634341734 100644 --- a/test/onboard-ollama-autostart.test.ts +++ b/test/onboard-ollama-autostart.test.ts @@ -481,4 +481,41 @@ describe("nemoclaw onboard --no-ollama-autostart (issue #3751)", () => { assert.equal(payload.sentinelTripped, false); }, ); + + it( + "Scenario E: stopped Ollama + flag NOT set + NEMOCLAW_PROVIDER=ollama + waitForHttp timeout → process.exit, no selectionLoop re-entry", + { timeout: OLLAMA_AUTOSTART_TEST_TIMEOUT_MS }, + () => { + // Reporter scenario: provider pinned via env, gate not set, Ollama + // unreachable, spawn-then-wait fails. Previously `continue selectionLoop` + // would immediately re-enter the same Ollama branch because + // NEMOCLAW_PROVIDER=ollama forces the menu to keep selecting Ollama. + // The fix surfaces a failure (process.exit) instead of looping. + const payload = runOllamaAutostartScenario({ + ollamaRunning: false, + noAutostartEnv: false, + nonInteractive: false, + waitForHttpReturnsFalse: true, + }); + + assert.ok( + payload.processExitCalled >= 1, + `expected process.exit to be called when provider is pinned and Ollama is unreachable; lines:\n${payload.lines.join("\n")}`, + ); + assert.ok( + payload.lines.some((line) => + line.includes("NEMOCLAW_PROVIDER=ollama is pinned but Ollama is unreachable"), + ), + `expected pinned-provider abort message; lines:\n${payload.lines.join("\n")}`, + ); + // Sentinel guards the post-spawn proxy step. If selectionLoop had looped + // and a future iteration reached the proxy, the sentinel would have + // tripped. With the fix, we exit before that. + assert.equal( + payload.sentinelTripped, + false, + "abort must happen before reaching the proxy stage", + ); + }, + ); });