Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion docs/inference/use-local-inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ If both WSL and Windows-host Ollama are running, pick the intended menu entry du
### Authenticated Reverse Proxy

On non-WSL hosts, NemoClaw keeps Ollama bound to `127.0.0.1:11434` and starts a token-gated reverse proxy on `0.0.0.0:11435`.
The native install/start paths also reset NemoClaw-managed systemd launches to the loopback binding.
Containers and other hosts on the local network reach Ollama only through the
proxy, which validates a Bearer token before forwarding requests.
Ollama itself is never exposed without authentication.
On that native path, NemoClaw never exposes Ollama without authentication.

WSL Ollama paths do not use this proxy.
Windows-host Ollama uses the Windows daemon through `host.docker.internal`.
Expand Down
4 changes: 2 additions & 2 deletions src/lib/local-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export type RunCaptureFn = (cmd: string | string[], opts?: { ignoreError?: boole
// Hosts that the WSL-side onboard CLI tries when probing Ollama. Native Linux
// and macOS only ever reach Ollama on the local loopback. WSL with Docker
// Desktop can also reach a Windows-host Ollama through the docker-desktop
// integration's `host.docker.internal` alias when Ollama is bound to a
// non-loopback interface (typically OLLAMA_HOST=0.0.0.0).
// integration's `host.docker.internal` alias when that host explicitly exposes
// Ollama outside Windows loopback.
export const OLLAMA_LOCALHOST = "127.0.0.1";
export const OLLAMA_HOST_DOCKER_INTERNAL = "host.docker.internal";

Expand Down
33 changes: 18 additions & 15 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6116,14 +6116,12 @@ async function setupNim(
if (!checkOllamaPortsOrWarn()) continue selectionLoop;
if (!ollamaRunning) {
console.log(" Starting Ollama...");
// On WSL2, binding to 0.0.0.0 creates a dual-stack socket that Docker
// cannot reach via host-gateway. The default 127.0.0.1 binding works
// because WSL2 relays IPv4-only sockets to the Windows host.
// Keep raw Ollama loopback-only. Non-WSL containers reach it through
// the authenticated proxy on OLLAMA_PROXY_PORT.
// Shell required: backgrounding (&), env var prefix, output redirection.
const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} `;
const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} `;
runShell(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true });
sleep(2);
if (!isWsl()) printOllamaExposureWarning();
}
if (isWsl()) {
// WSL2 doesn't need the proxy — Docker can reach the host directly.
Expand Down Expand Up @@ -6242,7 +6240,7 @@ async function setupNim(
// brew install doesn't auto-start a service; launch directly.
// Shell required: backgrounding (&), env var prefix, output redirection.
console.log(" Starting Ollama...");
runShell(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, {
runShell(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, {
ignoreError: true,
});
sleep(2);
Expand All @@ -6264,21 +6262,27 @@ async function setupNim(
],
{ ignoreError: true },
).trim();
// Linux native + systemd: override OLLAMA_HOST=0.0.0.0 via a drop-in
// Linux native + systemd: force a loopback-only OLLAMA_HOST drop-in
// and let systemd own the daemon (avoids racing the installer's
// daemon with our own `ollama serve`). WSL keeps the default
// 127.0.0.1 binding (wslrelay forwards it). No-systemd / daemon
// failed to start: manual launch with the right binding.
// daemon with our own `ollama serve`). This also repairs older
// NemoClaw-created overrides that exposed raw Ollama on all interfaces.
// WSL keeps Ollama's default binding. No-systemd / daemon failed to
// start: manual launch with the loopback binding.
if (!isWsl() && hasOllamaSystemdUnit) {
console.log(" Configuring Ollama systemd override...");
const dropInBody = `[Service]\nEnvironment="OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT}"\n`;
console.log(" Configuring Ollama systemd loopback override...");
const dropInBody = `[Service]\nEnvironment="OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT}"\n`;
const tmpDropIn = secureTempFile("nemoclaw-ollama-override", ".conf");
fs.writeFileSync(tmpDropIn, dropInBody, { mode: 0o644 });
runShell(
const overrideResult = runShell(
`sudo install -D -m 0644 ${shellQuote(tmpDropIn)} ${shellQuote("/etc/systemd/system/ollama.service.d/override.conf")} && sudo systemctl daemon-reload && sudo systemctl restart ollama`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid clobbering an existing override.conf.

Writing directly to /etc/systemd/system/ollama.service.d/override.conf will replace any user-managed drop-in already living at that path. That can silently discard unrelated Ollama service customizations. A NemoClaw-specific drop-in filename is safer, with legacy cleanup limited to the old NemoClaw-managed override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/onboard.ts` at line 6277, The current install command writes
tmpDropIn to /etc/systemd/system/ollama.service.d/override.conf which can
overwrite user-managed drop-ins; change it to install to a NemoClaw-specific
filename (e.g. /etc/systemd/system/ollama.service.d/nemoclaw.conf) instead of
override.conf, update the string that builds the shell command where tmpDropIn
is used, and add a one-time cleanup step that removes the old NemoClaw-managed
override (if present) rather than unconditionally clobbering override.conf;
ensure you still run systemctl daemon-reload and systemctl restart ollama after
the install.

{ ignoreError: true },
);
cleanupTempDir(tmpDropIn, "nemoclaw-ollama-override");
if (overrideResult.error || overrideResult.status !== 0) {
console.error(" Failed to apply Ollama systemd loopback override.");
console.error(" Refusing to continue with a potentially non-loopback Ollama bind.");
process.exit(1);
}
// Retry the probe for a few seconds before giving up — systemd's
// daemon may still be binding the port; a single probe could falsely
// conclude it's down and spawn a duplicate `ollama serve`.
Expand All @@ -6290,7 +6294,7 @@ async function setupNim(
// Fall back to manual start if systemd path failed or isn't present.
if (!findReachableOllamaHost()) {
console.log(" Starting Ollama...");
const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} `;
const ollamaEnv = isWsl() ? "" : `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} `;
runShell(`${ollamaEnv}ollama serve > /dev/null 2>&1 &`, { ignoreError: true });
sleep(2);
}
Expand All @@ -6299,7 +6303,6 @@ async function setupNim(
// WSL2 doesn't need the proxy — Docker reaches the host directly.
console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT}`);
} else {
printOllamaExposureWarning();
if (!startOllamaAuthProxy()) {
process.exit(1);
}
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/test-gpu-double-onboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ else
fi

# If the Ollama installer started a system service, stop it so onboard
# can start Ollama with OLLAMA_HOST=0.0.0.0:11434 (required for containers).
# can restart Ollama on loopback and expose only the authenticated proxy to containers.
if curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
info "Ollama service is running — attempting to stop for clean onboard..."
systemctl --user stop ollama 2>/dev/null || true
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/test-gpu-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ else
fi

# If the Ollama installer started a system service, stop it so onboard
# can start Ollama with OLLAMA_HOST=0.0.0.0:11434 (required for containers).
# can restart Ollama on loopback and expose only the authenticated proxy to containers.
# This needs the ollama process to be owned by our user, or systemctl access.
if curl -sf http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
info "Ollama service is running — attempting to stop for clean onboard..."
Expand Down
215 changes: 215 additions & 0 deletions test/onboard-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,129 @@ const { setupNim } = require(${onboardPath});
);
});

it("starts managed Ollama on loopback before exposing the auth proxy", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-loopback-"));
const fakeBin = path.join(tmpDir, "bin");
const scriptPath = path.join(tmpDir, "ollama-loopback-check.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));

fs.mkdirSync(fakeBin, { recursive: true });
fs.writeFileSync(
path.join(fakeBin, "curl"),
`#!/usr/bin/env bash
body='{"id":"ok"}'
status="200"
outfile=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) outfile="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -n "$outfile" ]; then
printf '%s' "$body" > "$outfile"
printf '%s' "$status"
else
printf '%s' "$body"
fi
`,
{ mode: 0o755 },
);

const script = String.raw`
const credentials = require(${credentialsPath});
const runner = require(${runnerPath});
const child_process = require("child_process");

child_process.spawn = () => ({ pid: 99999, unref() {}, on() {} });
const originalSpawnSync = child_process.spawnSync;
child_process.spawnSync = (cmd, args, opts) => {
if (cmd === "ps") {
return { status: 0, stdout: "node ollama-auth-proxy.js", stderr: "", signal: null };
}
return originalSpawnSync(cmd, args, opts);
};

const messages = [];
const runCommands = [];
const shellCommands = [];
const answers = ["7", "1"];

credentials.prompt = async (message) => {
messages.push(message);
return answers.shift() || "";
};
credentials.ensureApiKey = async () => {};
runner.runCapture = (command) => {
const cmd = Array.isArray(command) ? command.join(" ") : command;
if (cmd.includes("command -v ollama")) return "/usr/bin/ollama";
if (cmd.includes("127.0.0.1:11434/api/tags")) return "";
if (cmd.includes("127.0.0.1:8000/v1/models")) return "";
if (cmd.includes("ollama list")) return "qwen3:8b abc 5 GB now";
if (cmd.includes("ps")) return "node ollama-auth-proxy.js";
if (cmd.includes("api/generate")) return '{"response":"hello"}';
return "";
};
runner.run = (command) => {
runCommands.push(Array.isArray(command) ? command.join(" ") : command);
return { status: 0 };
};
runner.runShell = (command) => {
shellCommands.push(command);
return { status: 0 };
};

Object.defineProperty(process, "platform", { value: "linux" });

const { setupNim } = require(${onboardPath});

(async () => {
const originalLog = console.log;
const lines = [];
console.log = (...args) => lines.push(args.join(" "));
try {
const result = await setupNim(null);
originalLog(JSON.stringify({ result, messages, lines, runCommands, shellCommands }));
} finally {
console.log = originalLog;
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
fs.writeFileSync(scriptPath, script);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
PATH: `${fakeBin}:${process.env.PATH || ""}`,
},
});

assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout.trim());
assert.equal(payload.result.provider, "ollama-local");
assert.ok(
payload.shellCommands.some((command: string) =>
command.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"),
),
"managed Ollama launch should be loopback-only",
);
assert.ok(
!payload.shellCommands.some((command: string) =>
command.includes("OLLAMA_HOST=0.0.0.0:11434"),
),
"managed Ollama launch must not expose raw Ollama on all interfaces",
);
});

it("returns to provider selection when Ollama manual entry chooses back", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-ollama-back-"));
Expand Down Expand Up @@ -3729,6 +3852,88 @@ const { setupNim } = require(${onboardPath});
!payload.runCommands.some((cmd: string) => cmd.includes("brew install")),
"Should NOT use brew on Linux",
);
assert.ok(
payload.runCommands.some((cmd: string) =>
cmd.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"),
),
"Linux install fallback should start Ollama on loopback",
);
assert.ok(
!payload.runCommands.some((cmd: string) => cmd.includes("OLLAMA_HOST=0.0.0.0:11434")),
"Linux install path must not expose raw Ollama on all interfaces",
);
});

it("fails closed when the Linux systemd loopback override cannot be applied", () => {
const repoRoot = path.join(import.meta.dirname, "..");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-systemd-fail-"));
const scriptPath = path.join(tmpDir, "systemd-fail-check.js");
const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js"));
const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials.js"));
const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js"));

const script = String.raw`
const credentials = require(${credentialsPath});
const runner = require(${runnerPath});

const menuLines = [];
const originalLog = console.log;
console.log = (...args) => {
const line = args.join(" ");
menuLines.push(line);
originalLog(...args);
};

function findInstallOllamaChoice() {
const option = menuLines.find((line) => /Install Ollama \((WSL )?Linux\)/.test(line));
const match = option && option.match(/^\s*(\d+)\)/);
if (!match) {
throw new Error("Could not find Linux Ollama install option in menu:\\n" + menuLines.join("\\n"));
}
return match[1];
}

credentials.prompt = async () => findInstallOllamaChoice();
credentials.ensureApiKey = async () => {};
runner.runCapture = (command) => {
const cmd = Array.isArray(command) ? command.join(" ") : command;
if (cmd.includes("command -v ollama")) return "";
if (cmd.includes("127.0.0.1:11434/api/tags")) return "";
if (cmd.includes("127.0.0.1:8000/v1/models")) return "";
if (cmd.includes("systemctl list-unit-files ollama.service")) return "ollama.service enabled";
return "";
};
runner.runShell = (command) => {
if (command.includes("ollama.com/install.sh")) return { status: 0 };
if (command.includes("sudo install -D -m 0644")) return { status: 1 };
return { status: 0 };
};

Object.defineProperty(process, "platform", { value: "linux" });

const { setupNim } = require(${onboardPath});

(async () => {
await setupNim("systemd-fail-test", null);
})().catch((error) => {
console.error(error);
process.exit(1);
});
`;
fs.writeFileSync(scriptPath, script);

const result = spawnSync(process.execPath, [scriptPath], {
cwd: repoRoot,
encoding: "utf-8",
env: {
...process.env,
HOME: tmpDir,
},
});

assert.equal(result.status, 1);
assert.match(result.stderr, /Failed to apply Ollama systemd loopback override/);
assert.match(result.stderr, /Refusing to continue/);
});

it("uses install-ollama for non-interactive NEMOCLAW_PROVIDER=ollama on fresh Linux", () => {
Expand Down Expand Up @@ -3857,6 +4062,16 @@ const { setupNim } = require(${onboardPath});
payload.runCommands.some((cmd: string) => cmd.includes("ollama.com/install.sh")),
"Should use the Ollama installer when requested non-interactively on a fresh host",
);
assert.ok(
payload.runCommands.some((cmd: string) =>
cmd.includes("OLLAMA_HOST=127.0.0.1:11434 ollama serve"),
),
"non-interactive install fallback should start Ollama on loopback",
);
assert.ok(
!payload.runCommands.some((cmd: string) => cmd.includes("OLLAMA_HOST=0.0.0.0:11434")),
"non-interactive install path must not expose raw Ollama on all interfaces",
);
});

it("honours NEMOCLAW_LOCAL_INFERENCE_TIMEOUT for compatible-endpoint during inference setup (#2403)", () => {
Expand Down
Loading