Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -63,7 +63,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 @@ -1135,6 +1135,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` | Allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation. By default, non-interactive onboarding uses `sudo -n` and fails instead of asking for a password. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
| `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 @@ -2107,6 +2108,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 @@ -2944,8 +2945,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 @@ -3019,7 +3020,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 @@ -3040,8 +3041,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 @@ -3061,7 +3062,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