Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/get-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions src/lib/onboard/ollama-systemd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand All @@ -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);
}
Expand Down
13 changes: 7 additions & 6 deletions test/install-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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/,
);
});

Expand All @@ -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:-}"
`,
],
Expand All @@ -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=/);
});
});

Expand Down
117 changes: 116 additions & 1 deletion test/onboard-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"';
Expand All @@ -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(
Expand Down
Loading