diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 10d485c503a..4d725aaf36f 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -65,7 +65,7 @@ $ curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` On DGX Spark and DGX Station, an interactive installer can offer express install after you accept the third-party software notice. -Express install switches onboarding to non-interactive mode, applies the suggested security policy, and selects the managed local inference path for that platform. +Express install switches onboarding to non-interactive mode, allows `sudo` password prompts for required host changes, applies the suggested security policy, and selects the managed local inference path for that platform. Set `NEMOCLAW_NO_EXPRESS=1` to skip the express prompt, or set `NEMOCLAW_PROVIDER` before launching the installer when you want to choose a provider yourself. The installer auto-launches `nemoclaw onboard` when it can locate the freshly-installed binary. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0a774fec1eb..038c275265c 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1151,6 +1151,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_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 and DGX Station express install prompt and continues with the normal interactive onboarding flow. | | `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. | | `NEMOCLAW_IGNORE_RUNTIME_RESOURCES` | `1` to enable | Suppresses the under-provisioned runtime warning during preflight. Use only when you know the sandbox host meets the minimums. | diff --git a/scripts/install.sh b/scripts/install.sh index 9fd2a5b588e..cd145843822 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -548,6 +548,7 @@ usage() { printf " NVIDIA_API_KEY API key (skips credential prompt)\n" printf " NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 Same as --yes-i-accept-third-party-software\n" printf " NEMOCLAW_NON_INTERACTIVE=1 Same as --non-interactive\n" + printf " NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt Allow sudo prompts during non-interactive onboarding\n" printf " NEMOCLAW_FRESH=1 Same as --fresh\n" printf " NEMOCLAW_SANDBOX_NAME Sandbox name to create/use\n" printf " NEMOCLAW_SINGLE_SESSION=1 Abort if active sandbox sessions exist\n" @@ -2186,6 +2187,7 @@ maybe_offer_express_install() { info "Using express install for ${platform}." NON_INTERACTIVE=1 export NEMOCLAW_NON_INTERACTIVE=1 + export NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt export NEMOCLAW_YES=1 export NEMOCLAW_POLICY_MODE=suggested case "$platform" in diff --git a/src/lib/onboard/ollama-systemd.ts b/src/lib/onboard/ollama-systemd.ts index 3c410025da8..1e28ab45660 100644 --- a/src/lib/onboard/ollama-systemd.ts +++ b/src/lib/onboard/ollama-systemd.ts @@ -15,6 +15,7 @@ const { const { isWsl }: typeof import("../platform") = require("../platform"); const OLLAMA_SYSTEMD_OVERRIDE_PATH = "/etc/systemd/system/ollama.service.d/override.conf"; +const NON_INTERACTIVE_SUDO_MODE_ENV = "NEMOCLAW_NON_INTERACTIVE_SUDO_MODE"; export type OllamaLoopbackSystemdOverrideState = "not-applicable" | "ready" | "failed"; @@ -26,6 +27,20 @@ function isEnvNonInteractive(): boolean { return process.env.NEMOCLAW_NON_INTERACTIVE === "1"; } +function getSudoPrefix(isNonInteractive: boolean): "sudo" | "sudo -n" { + const rawMode = String(process.env[NON_INTERACTIVE_SUDO_MODE_ENV] || "") + .trim() + .toLowerCase(); + if (rawMode && rawMode !== "prompt") { + console.error( + ` Unsupported ${NON_INTERACTIVE_SUDO_MODE_ENV} value: ${rawMode}. Use 'prompt' or leave it unset.`, + ); + process.exit(1); + } + if (isNonInteractive) return rawMode === "prompt" ? "sudo" : "sudo -n"; + return process.stdin.isTTY ? "sudo" : "sudo -n"; +} + export function ensureOllamaLoopbackSystemdOverride( options: OllamaLoopbackSystemdOverrideOptions = {}, ): OllamaLoopbackSystemdOverrideState { @@ -47,14 +62,24 @@ export function ensureOllamaLoopbackSystemdOverride( "The next steps use sudo to write the drop-in, reload systemd, and restart the service; " + "you may be prompted for your password.", ); - const sudoPrefix = - (options.isNonInteractive ?? isEnvNonInteractive)() || !process.stdin.isTTY ? "sudo -n" : "sudo"; + const sudoPrefix = getSudoPrefix((options.isNonInteractive ?? isEnvNonInteractive)()); const existingDropInResult = runShell( - `if [ -e ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)} ]; then ${sudoPrefix} cat ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)}; fi`, + [ + `if [ -r ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)} ]; then`, + ` cat ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)}`, + `elif [ -e ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)} ]; then`, + ` ${sudoPrefix} cat ${shellQuote(OLLAMA_SYSTEMD_OVERRIDE_PATH)}`, + "fi", + ].join("\n"), { ignoreError: true, suppressOutput: true, timeout: 30_000 }, ); if (existingDropInResult.error || existingDropInResult.status !== 0) { console.error(" Failed to inspect existing Ollama systemd override."); + if (sudoPrefix === "sudo -n") { + console.error( + ` Non-interactive sudo could not read the existing drop-in. Set ${NON_INTERACTIVE_SUDO_MODE_ENV}=prompt to allow a sudo password prompt when a terminal is available.`, + ); + } console.error(" Refusing to continue because preserving existing Ollama settings is required."); process.exit(1); } diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index ee6bb0deda8..e890812a55e 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -491,6 +491,7 @@ exit 98 expect(output).toMatch(/gemini \| ollama \| custom \| nim-local \| vllm \| routed/); expect(output).toMatch(/aliases: cloud -> build, nim -> nim-local/); expect(output).toMatch(/NEMOCLAW_POLICY_MODE/); + expect(output).toMatch(/NEMOCLAW_NON_INTERACTIVE_SUDO_MODE=prompt/); expect(output).toMatch(/NEMOCLAW_SANDBOX_NAME/); expect(output).toMatch(/nvidia\.com\/nemoclaw\.sh/); }); @@ -3111,8 +3112,8 @@ NON_INTERACTIVE="" NEMOCLAW_PROVIDER="" NEMOCLAW_NO_EXPRESS="" maybe_offer_express_install -printf "RESULT NON_INTERACTIVE=%s PROVIDER=%s MODEL=%s POLICY=%s YES=%s\\n" \\ - "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ +printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s POLICY=%s YES=%s\\n" \\ + "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" ''' env = dict(os.environ) @@ -3186,7 +3187,7 @@ sys.exit(exit_code) expect(output).toMatch(/Run express install/); expect(output).toMatch(/Using express install for DGX Spark/); expect(output).toMatch( - /RESULT NON_INTERACTIVE=1 PROVIDER=install-ollama MODEL=qwen3\.6:35b POLICY=suggested YES=1/, + /RESULT NON_INTERACTIVE=1 SUDO_MODE=prompt PROVIDER=install-ollama MODEL=qwen3\.6:35b POLICY=suggested YES=1/, ); }); @@ -3207,8 +3208,8 @@ NON_INTERACTIVE="" NEMOCLAW_PROVIDER="" NEMOCLAW_NO_EXPRESS="" maybe_offer_express_install -printf "RESULT NON_INTERACTIVE=%s PROVIDER=%s MODEL=%s POLICY=%s YES=%s\\n" \\ - "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ +printf "RESULT NON_INTERACTIVE=%s SUDO_MODE=%s PROVIDER=%s MODEL=%s POLICY=%s YES=%s\\n" \\ + "\${NON_INTERACTIVE:-}" "\${NEMOCLAW_NON_INTERACTIVE_SUDO_MODE:-}" "\${NEMOCLAW_PROVIDER:-}" "\${NEMOCLAW_MODEL:-}" \\ "\${NEMOCLAW_POLICY_MODE:-}" "\${NEMOCLAW_YES:-}" `, ], @@ -3228,7 +3229,7 @@ printf "RESULT NON_INTERACTIVE=%s PROVIDER=%s MODEL=%s POLICY=%s YES=%s\\n" \\ expect(output).toMatch(/Detected DGX Spark/); expect(output).toMatch(/Skipping express prompt \(no TTY\)/); expect(output).not.toMatch(/Run express install/); - expect(output).toMatch(/RESULT NON_INTERACTIVE= PROVIDER= MODEL= POLICY= YES=/); + expect(output).toMatch(/RESULT NON_INTERACTIVE= SUDO_MODE= PROVIDER= MODEL= POLICY= YES=/); }); }); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 75f945fcd94..fd4346f3b50 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -1357,7 +1357,16 @@ const { setupNim } = require(${onboardPath}); (call: { command: string }) => call.command.includes("cat") && call.command.includes("ollama.service.d/override.conf"), ); - assert.equal(catCall?.opts?.suppressOutput, true); + assert.ok(catCall, "expected existing drop-in inspection command"); + assert.equal(catCall.opts?.suppressOutput, true); + assert.ok( + catCall.command.includes("if [ -r"), + "readable drop-ins should be inspected without sudo first", + ); + assert.ok( + catCall.command.indexOf("cat") < catCall.command.indexOf("sudo -n cat"), + "sudo cat should only be the unreadable-file fallback", + ); const repairedHost = 'Environment="OLLAMA_HOST=127.0.0.1:11434"'; const oldHost = 'Environment="OLLAMA_HOST=0.0.0.0:11434"'; @@ -1368,6 +1377,112 @@ const { setupNim } = require(${onboardPath}); ); }); + it("allows prompt-capable sudo in non-interactive Ollama systemd setup", { timeout: 10_000 }, () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ollama-systemd-sudo-mode-")); + const scriptPath = path.join(tmpDir, "ollama-systemd-sudo-mode-check.js"); + const ollamaSystemdPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "ollama-systemd.js"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + const localInferencePath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "inference", "local.js"), + ); + + const script = String.raw` +const runner = require(${runnerPath}); +const platform = require(${platformPath}); +const localInference = require(${localInferencePath}); + +const shellCommands = []; +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("systemctl list-unit-files ollama.service")) return "ollama.service enabled"; + return ""; +}; +runner.runShell = (command) => { + shellCommands.push(command); + return { status: 0, stdout: "" }; +}; +platform.isWsl = () => false; +localInference.findReachableOllamaHost = () => true; +Object.defineProperty(process, "platform", { value: "linux" }); + +const { ensureOllamaLoopbackSystemdOverride } = require(${ollamaSystemdPath}); +const result = ensureOllamaLoopbackSystemdOverride({ isNonInteractive: () => true }); +console.log(JSON.stringify({ result, shellCommands })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_NON_INTERACTIVE_SUDO_MODE: "prompt", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").at(-1) || "{}"); + assert.equal(payload.result, "ready"); + assert.ok( + payload.shellCommands.some((command: string) => command.includes("sudo install -D -m 0644")), + "prompt sudo mode should use sudo without -n", + ); + assert.ok( + !payload.shellCommands.some((command: string) => + command.includes("sudo -n install -D -m 0644"), + ), + "prompt sudo mode should not use sudo -n", + ); + }); + + it("rejects unsupported non-interactive sudo mode values", { timeout: 10_000 }, () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ollama-systemd-sudo-invalid-")); + const scriptPath = path.join(tmpDir, "ollama-systemd-sudo-invalid-check.js"); + const ollamaSystemdPath = JSON.stringify( + path.join(repoRoot, "dist", "lib", "onboard", "ollama-systemd.js"), + ); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const platformPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "platform.js")); + + const script = String.raw` +const runner = require(${runnerPath}); +const platform = require(${platformPath}); + +runner.runCapture = (command) => { + const cmd = Array.isArray(command) ? command.join(" ") : command; + if (cmd.includes("systemctl list-unit-files ollama.service")) return "ollama.service enabled"; + return ""; +}; +platform.isWsl = () => false; +Object.defineProperty(process, "platform", { value: "linux" }); + +const { ensureOllamaLoopbackSystemdOverride } = require(${ollamaSystemdPath}); +ensureOllamaLoopbackSystemdOverride({ isNonInteractive: () => true }); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_NON_INTERACTIVE_SUDO_MODE: "foo", + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unsupported NEMOCLAW_NON_INTERACTIVE_SUDO_MODE value: foo/); + }); + it("repairs already-loopback systemd Ollama without starting a duplicate daemon", { timeout: 10_000 }, () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(