Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,18 @@ The sandbox image is approximately 2.4 GB compressed. During image push, the Doc
| Linux | Ubuntu 22.04 LTS or later |
| Node.js | 20 or later |
| npm | 10 or later |
| Docker | Installed and running |
| Container runtime | Supported runtime installed and running |
| [OpenShell](https://github.com/NVIDIA/OpenShell) | Installed |

#### Container Runtime Support

| Platform | Supported runtimes | Notes |
|----------|--------------------|-------|
| Linux | Docker | Primary supported path today |
| macOS (Apple Silicon) | Colima, Docker Desktop | Recommended runtimes for supported macOS setups |
| macOS | Podman | Not supported yet. NemoClaw currently depends on OpenShell support for Podman on macOS. |
| Windows WSL | Docker Desktop (WSL backend) | Supported target path |

### Install NemoClaw and Onboard OpenClaw Agent

Download and run the installer script.
Expand Down Expand Up @@ -141,6 +150,8 @@ Inference requests from the agent never leave the sandbox directly. OpenShell in

Get an API key from [build.nvidia.com](https://build.nvidia.com). The `nemoclaw onboard` command prompts for this key during setup.

Local inference options such as Ollama and vLLM are still experimental. On macOS, they also depend on OpenShell host-routing support in addition to the local service itself being reachable on the host.

---

## Protection Layers
Expand Down
8 changes: 8 additions & 0 deletions bin/lib/credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
rl.question(question, (answer) => {
rl.close();
if (!process.stdin.isTTY) {
if (typeof process.stdin.pause === "function") {
process.stdin.pause();
}
if (typeof process.stdin.unref === "function") {
process.stdin.unref();
}
}
resolve(answer.trim());
});
});
Expand Down
60 changes: 60 additions & 0 deletions bin/lib/local-inference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const HOST_GATEWAY_URL = "http://host.openshell.internal";

function getLocalProviderBaseUrl(provider) {
switch (provider) {
case "vllm-local":
return `${HOST_GATEWAY_URL}:8000/v1`;
case "ollama-local":
return `${HOST_GATEWAY_URL}:11434/v1`;
default:
return null;
}
}

function getLocalProviderHealthCheck(provider) {
switch (provider) {
case "vllm-local":
return "curl -sf http://localhost:8000/v1/models 2>/dev/null";
case "ollama-local":
return "curl -sf http://localhost:11434/api/tags 2>/dev/null";
default:
return null;
}
}

function validateLocalProvider(provider, runCapture) {
const command = getLocalProviderHealthCheck(provider);
if (!command) {
return { ok: true };
}

const output = runCapture(command, { ignoreError: true });
if (output) {
return { ok: true };
}

switch (provider) {
case "vllm-local":
return {
ok: false,
message: "Local vLLM was selected, but nothing is responding on http://localhost:8000.",
};
case "ollama-local":
return {
ok: false,
message: "Local Ollama was selected, but nothing is responding on http://localhost:11434.",
};
default:
return { ok: false, message: "The selected local inference provider is unavailable." };
}
}

module.exports = {
HOST_GATEWAY_URL,
getLocalProviderBaseUrl,
getLocalProviderHealthCheck,
validateLocalProvider,
};
57 changes: 45 additions & 12 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@
const fs = require("fs");
const path = require("path");
const { ROOT, SCRIPTS, run, runCapture } = require("./runner");
const {
getLocalProviderBaseUrl,
validateLocalProvider,
} = require("./local-inference");
const {
inferContainerRuntime,
isUnsupportedMacosRuntime,
shouldPatchCoredns,
} = require("./platform");
const { prompt, ensureApiKey, getCredential } = require("./credentials");
const registry = require("./registry");
const nim = require("./nim");
const policies = require("./policies");
const { checkPortAvailable } = require("./preflight");
const HOST_GATEWAY_URL = "http://host.openshell.internal";
const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1";

// Non-interactive mode: set by --non-interactive flag or env var.
Expand Down Expand Up @@ -53,6 +61,11 @@ function isDockerRunning() {
}
}

function getContainerRuntime() {
const info = runCapture("docker info 2>/dev/null", { ignoreError: true });
return inferContainerRuntime(info);
}

function isOpenshellInstalled() {
try {
runCapture("command -v openshell");
Expand Down Expand Up @@ -133,6 +146,17 @@ async function preflight() {
}
console.log(" ✓ Docker is running");

const runtime = getContainerRuntime();
if (isUnsupportedMacosRuntime(runtime)) {
console.error(" Podman on macOS is not supported by NemoClaw at this time.");
console.error(" OpenShell currently depends on Docker host-gateway behavior that Podman on macOS does not provide.");
console.error(" Use Colima or Docker Desktop on macOS instead.");
process.exit(1);
}
if (runtime !== "unknown") {
console.log(` ✓ Container runtime: ${runtime}`);
}

// OpenShell CLI
if (!isOpenshellInstalled()) {
console.log(" openshell CLI not found. Attempting to install...");
Expand Down Expand Up @@ -225,14 +249,10 @@ async function startGateway(gpu) {
}

// CoreDNS fix — always run. k3s-inside-Docker has broken DNS on all platforms.
const home = process.env.HOME || "/tmp";
const colimaSocket = [
path.join(home, ".colima/default/docker.sock"),
path.join(home, ".config/colima/default/docker.sock"),
].find((s) => fs.existsSync(s));
if (colimaSocket) {
const runtime = getContainerRuntime();
if (shouldPatchCoredns(runtime)) {
console.log(" Patching CoreDNS for Colima...");
run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" 2>&1 || true`, { ignoreError: true });
run(`bash "${path.join(SCRIPTS, "fix-coredns.sh")}" nemoclaw 2>&1 || true`, { ignoreError: true });
}
// Give DNS a moment to propagate
sleep(5);
Expand Down Expand Up @@ -553,25 +573,38 @@ async function setupInference(sandboxName, model, provider) {
{ ignoreError: true }
);
} else if (provider === "vllm-local") {
const validation = validateLocalProvider(provider, runCapture);
if (!validation.ok) {
console.error(` ${validation.message}`);
process.exit(1);
}
const baseUrl = getLocalProviderBaseUrl(provider);
run(
`openshell provider create --name vllm-local --type openai ` +
`--credential "OPENAI_API_KEY=dummy" ` +
`--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:8000/v1" 2>&1 || ` +
`--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` +
`openshell provider update vllm-local --credential "OPENAI_API_KEY=dummy" ` +
`--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:8000/v1" 2>&1 || true`,
`--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`,
{ ignoreError: true }
);
run(
`openshell inference set --no-verify --provider vllm-local --model ${model} 2>/dev/null || true`,
{ ignoreError: true }
);
} else if (provider === "ollama-local") {
const validation = validateLocalProvider(provider, runCapture);
if (!validation.ok) {
console.error(` ${validation.message}`);
console.error(" On macOS, local inference also depends on OpenShell host routing support.");
process.exit(1);
}
const baseUrl = getLocalProviderBaseUrl(provider);
run(
`openshell provider create --name ollama-local --type openai ` +
`--credential "OPENAI_API_KEY=ollama" ` +
`--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:11434/v1" 2>&1 || ` +
`--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || ` +
`openshell provider update ollama-local --credential "OPENAI_API_KEY=ollama" ` +
`--config "OPENAI_BASE_URL=${HOST_GATEWAY_URL}:11434/v1" 2>&1 || true`,
`--config "OPENAI_BASE_URL=${baseUrl}" 2>&1 || true`,
{ ignoreError: true }
);
run(
Expand Down
102 changes: 102 additions & 0 deletions bin/lib/platform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const os = require("os");
const path = require("path");

function isWsl(opts = {}) {
const platform = opts.platform ?? process.platform;
if (platform !== "linux") return false;

const env = opts.env ?? process.env;
const release = opts.release ?? os.release();
const procVersion = opts.procVersion ?? "";

return (
Boolean(env.WSL_DISTRO_NAME) ||
Boolean(env.WSL_INTEROP) ||
/microsoft/i.test(release) ||
/microsoft/i.test(procVersion)
);
}

function inferContainerRuntime(info = "") {
const normalized = String(info).toLowerCase();
if (!normalized.trim()) return "unknown";
if (normalized.includes("podman")) return "podman";
if (normalized.includes("colima")) return "colima";
if (normalized.includes("docker desktop")) return "docker-desktop";
if (normalized.includes("docker")) return "docker";
return "unknown";
}

function isUnsupportedMacosRuntime(runtime, opts = {}) {
const platform = opts.platform ?? process.platform;
return platform === "darwin" && runtime === "podman";
}

function shouldPatchCoredns(runtime) {
return runtime === "colima";
}

function getColimaDockerSocketCandidates(opts = {}) {
const home = opts.home ?? process.env.HOME ?? "/tmp";
return [
path.join(home, ".colima/default/docker.sock"),
path.join(home, ".config/colima/default/docker.sock"),
];
}

function findColimaDockerSocket(opts = {}) {
const existsSync = opts.existsSync ?? require("fs").existsSync;
return getColimaDockerSocketCandidates(opts).find((socketPath) => existsSync(socketPath)) ?? null;
}

function getDockerSocketCandidates(opts = {}) {
const home = opts.home ?? process.env.HOME ?? "/tmp";
const platform = opts.platform ?? process.platform;

if (platform === "darwin") {
return [
...getColimaDockerSocketCandidates({ home }),
path.join(home, ".docker/run/docker.sock"),
];
}

return [];
}

function detectDockerHost(opts = {}) {
const env = opts.env ?? process.env;
if (env.DOCKER_HOST) {
return {
dockerHost: env.DOCKER_HOST,
source: "env",
socketPath: null,
};
}

const existsSync = opts.existsSync ?? require("fs").existsSync;
for (const socketPath of getDockerSocketCandidates(opts)) {
if (existsSync(socketPath)) {
return {
dockerHost: `unix://${socketPath}`,
source: "socket",
socketPath,
};
}
}

return null;
}

module.exports = {
detectDockerHost,
findColimaDockerSocket,
getColimaDockerSocketCandidates,
getDockerSocketCandidates,
inferContainerRuntime,
isUnsupportedMacosRuntime,
isWsl,
shouldPatchCoredns,
};
38 changes: 22 additions & 16 deletions bin/lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,35 @@

const { execSync, spawnSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const { detectDockerHost } = require("./platform");

const ROOT = path.resolve(__dirname, "..", "..");
const SCRIPTS = path.join(ROOT, "scripts");

// Auto-detect Colima Docker socket (legacy ~/.colima or XDG ~/.config/colima)
if (!process.env.DOCKER_HOST) {
const home = process.env.HOME || "/tmp";
const candidates = [
path.join(home, ".colima/default/docker.sock"),
path.join(home, ".config/colima/default/docker.sock"),
];
for (const sock of candidates) {
if (fs.existsSync(sock)) {
process.env.DOCKER_HOST = `unix://${sock}`;
break;
}
}
const dockerHost = detectDockerHost();
if (dockerHost) {
process.env.DOCKER_HOST = dockerHost.dockerHost;
}

function run(cmd, opts = {}) {
const stdio = opts.stdio ?? ["ignore", "inherit", "inherit"];
const result = spawnSync("bash", ["-c", cmd], {
stdio,
cwd: ROOT,
env: { ...process.env, ...opts.env },
...opts,
});
if (result.status !== 0 && !opts.ignoreError) {
console.error(` Command failed (exit ${result.status}): ${cmd.slice(0, 80)}`);
process.exit(result.status || 1);
}
return result;
}

function runInteractive(cmd, opts = {}) {
const stdio = opts.stdio ?? "inherit";
const result = spawnSync("bash", ["-c", cmd], {
stdio: "inherit",
stdio,
cwd: ROOT,
env: { ...process.env, ...opts.env },
...opts,
Expand All @@ -52,4 +58,4 @@ function runCapture(cmd, opts = {}) {
}
}

module.exports = { ROOT, SCRIPTS, run, runCapture };
module.exports = { ROOT, SCRIPTS, run, runCapture, runInteractive };
Loading
Loading