diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..52eb78ee5 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# NemoClaw port configuration — copy to .env and edit as needed. +# Ports must be integers in range 1024–65535. +# Run scripts/check-ports.sh to find port conflicts + +NEMOCLAW_DASHBOARD_PORT=18789 +NEMOCLAW_GATEWAY_PORT=8080 +NEMOCLAW_VLLM_PORT=8000 +NEMOCLAW_OLLAMA_PORT=11434 diff --git a/.gitignore b/.gitignore index 5e68edf79..dde97ea46 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ docs/_build/ coverage/ vdr-notes/ draft_newsletter_* +tmp/ +.env +.env.local +.venv/ +uv.lock diff --git a/README.md b/README.md index 287ff0d82..b9d1f68c7 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,36 @@ curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uni --- +## Port Configuration + +NemoClaw uses four network ports. All are configurable via environment variables or a `.env` file at the project root (copy `.env.example` to get started). + +| Port | Default | Env var | Purpose | Conflict risk | +|------|---------|---------|---------|---------------| +| Dashboard | 18789 | `NEMOCLAW_DASHBOARD_PORT` | OpenClaw web UI, forwarded from sandbox to host | Low | +| Gateway | 8080 | `NEMOCLAW_GATEWAY_PORT` | OpenShell gateway signal channel | **High** — Jenkins, Tomcat, K8s dashboard | +| vLLM/NIM | 8000 | `NEMOCLAW_VLLM_PORT` | Local vLLM or NIM inference endpoint | **High** — Django, PHP dev server | +| Ollama | 11434 | `NEMOCLAW_OLLAMA_PORT` | Local Ollama inference endpoint | Low | + +To use non-default ports, set the environment variables before running `nemoclaw onboard`: + +```bash +export NEMOCLAW_GATEWAY_PORT=9080 +export NEMOCLAW_VLLM_PORT=9000 +nemoclaw onboard +``` + +Or create a `.env` file at the project root (see `.env.example`). + +> **ℹ️ Note** +> +> Changing the dashboard port requires rebuilding the sandbox image because the CORS origin is baked in at build time. Re-run `nemoclaw onboard` after changing `NEMOCLAW_DASHBOARD_PORT`. + +> **⚠️ Network exposure** +> +> When using local inference (Ollama or vLLM), the inference service binds to `0.0.0.0` so that containers can reach it via `host.openshell.internal`. This means the service is reachable from your local network, not just localhost. This is required for the sandbox architecture but should be considered in shared or untrusted network environments. +--- + ## How It Works NemoClaw installs the NVIDIA OpenShell runtime and Nemotron models, then uses a versioned blueprint to create a sandboxed environment where every network request, file access, and inference call is governed by declarative policy. The `nemoclaw` CLI orchestrates the full stack: OpenShell gateway, sandbox, inference provider, and network policy. diff --git a/bin/lib/env.js b/bin/lib/env.js new file mode 100644 index 000000000..c0eb54ce7 --- /dev/null +++ b/bin/lib/env.js @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Lightweight .env loader — reads .env files from the project root and populates +// process.env. Existing environment variables are never overwritten, so shell +// exports always take precedence over file values. +// +// Supports: +// - Multiple files (loaded in order; first file's values win over later files) +// - Comments (#) and blank lines +// - KEY=VALUE, KEY="VALUE", KEY='VALUE' +// - Inline comments after unquoted values + +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const CWD = process.cwd(); + +// Walk up from a directory looking for a .git marker to find the repo root. +function findGitRoot(start) { + let dir = start; + while (true) { + try { + fs.statSync(path.join(dir, ".git")); + return dir; + } catch { + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } +} + +const GIT_ROOT = findGitRoot(CWD); + +function parseEnvFile(filePath) { + let content; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return; // file doesn't exist or isn't readable — skip silently + } + + for (const raw of content.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + + const eqIndex = line.indexOf("="); + if (eqIndex === -1) continue; + + const key = line.slice(0, eqIndex).trim(); + if (!key) continue; + + let value = line.slice(eqIndex + 1).trim(); + + // Remove inline comments for unquoted values first, then strip quotes. + // This handles cases like KEY='value' # comment correctly. + const hashIndex = value.indexOf(" #"); + if (hashIndex !== -1) { + value = value.slice(0, hashIndex).trim(); + } + + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Never overwrite existing env vars + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Collect unique directories to search for .env files. The git repo root and +// CWD are checked in addition to the __dirname-relative ROOT so that a user's +// .env.local (which is gitignored and therefore not synced into the sandbox +// source directory) is still picked up on a fresh install. +const SEARCH_DIRS = [...new Set([ROOT, GIT_ROOT, CWD].filter(Boolean))]; + +// Load .env files in priority order — first file wins for any given key +// because we never overwrite once set. +const ENV_FILES = [".env.local", ".env"]; + +for (const file of ENV_FILES) { + for (const dir of SEARCH_DIRS) { + parseEnvFile(path.join(dir, file)); + } +} diff --git a/bin/lib/local-inference.js b/bin/lib/local-inference.js index 1065a70e3..07f266d19 100644 --- a/bin/lib/local-inference.js +++ b/bin/lib/local-inference.js @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +const { VLLM_PORT, OLLAMA_PORT } = require("./ports"); const { shellQuote } = require("./runner"); const HOST_GATEWAY_URL = "http://host.openshell.internal"; @@ -10,9 +11,9 @@ const DEFAULT_OLLAMA_MODEL = "nemotron-3-nano:30b"; function getLocalProviderBaseUrl(provider) { switch (provider) { case "vllm-local": - return `${HOST_GATEWAY_URL}:8000/v1`; + return `${HOST_GATEWAY_URL}:${VLLM_PORT}/v1`; case "ollama-local": - return `${HOST_GATEWAY_URL}:11434/v1`; + return `${HOST_GATEWAY_URL}:${OLLAMA_PORT}/v1`; default: return null; } @@ -21,9 +22,9 @@ function getLocalProviderBaseUrl(provider) { function getLocalProviderHealthCheck(provider) { switch (provider) { case "vllm-local": - return "curl -sf http://localhost:8000/v1/models 2>/dev/null"; + return `curl -sf http://localhost:${VLLM_PORT}/v1/models 2>/dev/null`; case "ollama-local": - return "curl -sf http://localhost:11434/api/tags 2>/dev/null"; + return `curl -sf http://localhost:${OLLAMA_PORT}/api/tags 2>/dev/null`; default: return null; } @@ -32,9 +33,9 @@ function getLocalProviderHealthCheck(provider) { function getLocalProviderContainerReachabilityCheck(provider) { switch (provider) { case "vllm-local": - return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null`; + return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:${VLLM_PORT}/v1/models 2>/dev/null`; case "ollama-local": - return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null`; + return `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:${OLLAMA_PORT}/api/tags 2>/dev/null`; default: return null; } @@ -52,12 +53,12 @@ function validateLocalProvider(provider, runCapture) { case "vllm-local": return { ok: false, - message: "Local vLLM was selected, but nothing is responding on http://localhost:8000.", + message: `Local vLLM was selected, but nothing is responding on http://localhost:${VLLM_PORT}.`, }; case "ollama-local": return { ok: false, - message: "Local Ollama was selected, but nothing is responding on http://localhost:11434.", + message: `Local Ollama was selected, but nothing is responding on http://localhost:${OLLAMA_PORT}.`, }; default: return { ok: false, message: "The selected local inference provider is unavailable." }; @@ -79,13 +80,13 @@ function validateLocalProvider(provider, runCapture) { return { ok: false, message: - "Local vLLM is responding on localhost, but containers cannot reach http://host.openshell.internal:8000. Ensure the server is reachable from containers, not only from the host shell.", + `Local vLLM is responding on localhost, but containers cannot reach http://host.openshell.internal:${VLLM_PORT}. Ensure the server is reachable from containers, not only from the host shell.`, }; case "ollama-local": return { ok: false, message: - "Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:11434. Ensure Ollama listens on 0.0.0.0:11434 instead of 127.0.0.1 so sandboxes can reach it.", + `Local Ollama is responding on localhost, but containers cannot reach http://host.openshell.internal:${OLLAMA_PORT}. Ensure Ollama listens on 0.0.0.0:${OLLAMA_PORT} instead of 127.0.0.1 so sandboxes can reach it.`, }; default: return { ok: false, message: "The selected local inference provider is unavailable from containers." }; @@ -123,7 +124,7 @@ function getOllamaWarmupCommand(model, keepAlive = "15m") { stream: false, keep_alive: keepAlive, }); - return `nohup curl -s http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} >/dev/null 2>&1 &`; + return `nohup curl -s http://localhost:${OLLAMA_PORT}/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} >/dev/null 2>&1 &`; } function getOllamaProbeCommand(model, timeoutSeconds = 120, keepAlive = "15m") { @@ -133,7 +134,7 @@ function getOllamaProbeCommand(model, timeoutSeconds = 120, keepAlive = "15m") { stream: false, keep_alive: keepAlive, }); - return `curl -sS --max-time ${timeoutSeconds} http://localhost:11434/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} 2>/dev/null`; + return `curl -sS --max-time ${timeoutSeconds} http://localhost:${OLLAMA_PORT}/api/generate -H 'Content-Type: application/json' -d ${shellQuote(payload)} 2>/dev/null`; } function validateOllamaModel(model, runCapture) { diff --git a/bin/lib/nim.js b/bin/lib/nim.js index 548b2db23..ec0e4bd27 100644 --- a/bin/lib/nim.js +++ b/bin/lib/nim.js @@ -4,6 +4,7 @@ // NIM container management — pull, start, stop, health-check NIM images. const { run, runCapture, shellQuote } = require("./runner"); +const { VLLM_PORT } = require("./ports"); const nimImages = require("./nim-images.json"); function containerName(sandboxName) { @@ -125,7 +126,7 @@ function pullNimImage(model) { return image; } -function startNimContainer(sandboxName, model, port = 8000) { +function startNimContainer(sandboxName, model, port = VLLM_PORT) { const name = containerName(sandboxName); const image = getImageForModel(model); if (!image) { @@ -139,12 +140,13 @@ function startNimContainer(sandboxName, model, port = 8000) { console.log(` Starting NIM container: ${name}`); run( + // Right-hand :8000 is the NIM image's internal port — fixed by the image, not configurable. `docker run -d --gpus all -p ${Number(port)}:8000 --name ${qn} --shm-size 16g ${shellQuote(image)}` ); return name; } -function waitForNimHealth(port = 8000, timeout = 300) { +function waitForNimHealth(port = VLLM_PORT, timeout = 300) { const start = Date.now(); const interval = 5000; const safePort = Number(port); @@ -175,7 +177,7 @@ function stopNimContainer(sandboxName) { run(`docker rm ${qn} 2>/dev/null || true`, { ignoreError: true }); } -function nimStatus(sandboxName) { +function nimStatus(sandboxName, port = VLLM_PORT) { const name = containerName(sandboxName); try { const state = runCapture( @@ -186,7 +188,7 @@ function nimStatus(sandboxName) { let healthy = false; if (state === "running") { - const health = runCapture(`curl -sf http://localhost:8000/v1/models 2>/dev/null`, { + const health = runCapture(`curl -sf http://localhost:${port}/v1/models 2>/dev/null`, { ignoreError: true, }); healthy = !!health; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 2bbbda577..e58f91a8b 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -9,6 +9,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("./runner"); +const { DASHBOARD_PORT, GATEWAY_PORT, VLLM_PORT, OLLAMA_PORT } = require("./ports"); const { getDefaultOllamaModel, getLocalProviderBaseUrl, @@ -235,6 +236,16 @@ function getNonInteractiveModel(providerKey) { async function preflight() { step(1, 7, "Preflight checks"); + // Bootstrap .env from .env.example on first run so port defaults are + // discoverable and editable. This doesn't affect the current process + // (ports.js is already loaded), but ensures .env exists for future runs. + const envFile = path.join(ROOT, ".env"); + const envExample = path.join(ROOT, ".env.example"); + if (!fs.existsSync(envFile) && fs.existsSync(envExample)) { + fs.copyFileSync(envExample, envFile); + console.log(" ✓ Created .env from .env.example"); + } + // Docker if (!isDockerRunning()) { console.error(" Docker is not running. Please start Docker and try again."); @@ -271,17 +282,17 @@ async function preflight() { const gwInfo = runCapture("openshell gateway info -g nemoclaw 2>/dev/null", { ignoreError: true }); if (hasStaleGateway(gwInfo)) { console.log(" Cleaning up previous NemoClaw session..."); - run("openshell forward stop 18789 2>/dev/null || true", { ignoreError: true }); + run(`openshell forward stop ${DASHBOARD_PORT} 2>/dev/null || true`, { ignoreError: true }); run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); console.log(" ✓ Previous session cleaned up"); } - // Required ports — gateway (8080) and dashboard (18789) + // Required ports — gateway and dashboard const requiredPorts = [ - { port: 8080, label: "OpenShell gateway" }, - { port: 18789, label: "NemoClaw dashboard" }, + { port: GATEWAY_PORT, label: "OpenShell gateway", envVar: "NEMOCLAW_GATEWAY_PORT" }, + { port: DASHBOARD_PORT, label: "NemoClaw dashboard", envVar: "NEMOCLAW_DASHBOARD_PORT" }, ]; - for (const { port, label } of requiredPorts) { + for (const { port, label, envVar } of requiredPorts) { const portCheck = await checkPortAvailable(port); if (!portCheck.ok) { console.error(""); @@ -291,7 +302,7 @@ async function preflight() { if (portCheck.process && portCheck.process !== "unknown") { console.error(` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`); console.error(""); - console.error(" To fix, stop the conflicting process:"); + console.error(" To fix, either stop the conflicting process:"); console.error(""); if (portCheck.pid) { console.error(` sudo kill ${portCheck.pid}`); @@ -305,6 +316,9 @@ async function preflight() { console.error(` Run: lsof -i :${port} -sTCP:LISTEN`); } console.error(""); + console.error(` Or use a different port by adding to .env.local:`); + console.error(` echo '${envVar}=' >> .env.local`); + console.error(""); console.error(` Detail: ${portCheck.reason}`); process.exit(1); } @@ -333,7 +347,7 @@ async function startGateway(gpu) { // Destroy old gateway run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); - const gwArgs = ["--name", "nemoclaw"]; + const gwArgs = ["--name", "nemoclaw", "--port", String(GATEWAY_PORT)]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is // routed through a host-side provider (Ollama, vLLM, or cloud API) — the // sandbox itself does not need direct GPU access. Passing --gpu causes @@ -434,16 +448,23 @@ async function createSandbox(gpu) { // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const basePolicyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${DASHBOARD_PORT}`; const createArgs = [ `--from "${buildCtx}/Dockerfile"`, `--name "${sandboxName}"`, `--policy "${basePolicyPath}"`, ]; + // CHAT_UI_URL is passed as an env arg below; the Dockerfile ARG default + // covers the standard port at build time. --build-arg is not supported by + // the current openshell sandbox create CLI. // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - const chatUiUrl = process.env.CHAT_UI_URL || 'http://127.0.0.1:18789'; - const envArgs = [`CHAT_UI_URL=${shellQuote(chatUiUrl)}`]; + const envArgs = [ + `CHAT_UI_URL=${shellQuote(chatUiUrl)}`, + `NEMOCLAW_DASHBOARD_PORT=${DASHBOARD_PORT}`, + `PUBLIC_PORT=${DASHBOARD_PORT}`, + ]; if (process.env.NVIDIA_API_KEY) { envArgs.push(`NVIDIA_API_KEY=${shellQuote(process.env.NVIDIA_API_KEY)}`); } @@ -507,12 +528,12 @@ async function createSandbox(gpu) { process.exit(1); } - // Release any stale forward on port 18789 before claiming it for the new sandbox. + // Release any stale forward on the dashboard port before claiming it for the new sandbox. // A previous onboard run may have left the port forwarded to a different sandbox, // which would silently prevent the new sandbox's dashboard from being reachable. - run(`openshell forward stop 18789 2>/dev/null || true`, { ignoreError: true }); + run(`openshell forward stop ${DASHBOARD_PORT} 2>/dev/null || true`, { ignoreError: true }); // Forward dashboard port to the new sandbox - run(`openshell forward start --background 18789 "${sandboxName}"`, { ignoreError: true }); + run(`openshell forward start --background ${DASHBOARD_PORT} "${sandboxName}"`, { ignoreError: true }); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -535,8 +556,8 @@ async function setupNim(sandboxName, gpu) { // Detect local inference options const hasOllama = !!runCapture("command -v ollama", { ignoreError: true }); - const ollamaRunning = !!runCapture("curl -sf http://localhost:11434/api/tags 2>/dev/null", { ignoreError: true }); - const vllmRunning = !!runCapture("curl -sf http://localhost:8000/v1/models 2>/dev/null", { ignoreError: true }); + const ollamaRunning = !!runCapture(`curl -sf http://localhost:${OLLAMA_PORT}/api/tags 2>/dev/null`, { ignoreError: true }); + const vllmRunning = !!runCapture(`curl -sf http://localhost:${VLLM_PORT}/v1/models 2>/dev/null`, { ignoreError: true }); const requestedProvider = isNonInteractive() ? getNonInteractiveProvider() : null; const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "cloud") : null; // Build options list — only show local options with NEMOCLAW_EXPERIMENTAL=1 @@ -554,14 +575,14 @@ async function setupNim(sandboxName, gpu) { options.push({ key: "ollama", label: - `Local Ollama (localhost:11434)${ollamaRunning ? " — running" : ""}` + + `Local Ollama (localhost:${OLLAMA_PORT})${ollamaRunning ? " — running" : ""}` + (ollamaRunning ? " (suggested)" : ""), }); } if (EXPERIMENTAL && vllmRunning) { options.push({ key: "vllm", - label: "Existing vLLM instance (localhost:8000) — running [experimental] (suggested)", + label: `Existing vLLM instance (localhost:${VLLM_PORT}) — running [experimental] (suggested)`, }); } @@ -654,10 +675,10 @@ async function setupNim(sandboxName, gpu) { } else if (selected.key === "ollama") { if (!ollamaRunning) { console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + run(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); sleep(2); } - console.log(" ✓ Using Ollama on localhost:11434"); + console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT}`); provider = "ollama-local"; if (isNonInteractive()) { model = requestedModel || getDefaultOllamaModel(runCapture); @@ -668,9 +689,9 @@ async function setupNim(sandboxName, gpu) { console.log(" Installing Ollama via Homebrew..."); run("brew install ollama", { ignoreError: true }); console.log(" Starting Ollama..."); - run("OLLAMA_HOST=0.0.0.0:11434 ollama serve > /dev/null 2>&1 &", { ignoreError: true }); + run(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); sleep(2); - console.log(" ✓ Using Ollama on localhost:11434"); + console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT}`); provider = "ollama-local"; if (isNonInteractive()) { model = requestedModel || getDefaultOllamaModel(runCapture); @@ -678,7 +699,7 @@ async function setupNim(sandboxName, gpu) { model = await promptOllamaModel(); } } else if (selected.key === "vllm") { - console.log(" ✓ Using existing vLLM on localhost:8000"); + console.log(` ✓ Using existing vLLM on localhost:${VLLM_PORT}`); provider = "vllm-local"; model = "vllm-local"; } @@ -701,6 +722,23 @@ async function setupNim(sandboxName, gpu) { console.log(` Using NVIDIA Endpoint API with model: ${model}`); } + // Warn (don't block) if the selected inference port is occupied by another process. + // Only check for vllm-local when we did NOT start NIM ourselves (nimContainer is + // set when we launched a NIM container). For ollama-local we always start or + // adopt the service, so the port being in use is expected — skip the warning. + if (provider === "vllm-local" && !nimContainer) { + const vllmCheck = await checkPortAvailable(VLLM_PORT); + if (!vllmCheck.ok) { + const who = vllmCheck.process !== "unknown" + ? ` by ${vllmCheck.process}${vllmCheck.pid ? ` (PID ${vllmCheck.pid})` : ""}` + : ""; + console.log(""); + console.log(` ⚠ Port ${VLLM_PORT} is in use${who}.`); + console.log(` vLLM/NIM inference needs this port. If this is your inference server, you're fine.`); + console.log(` Otherwise, stop the conflicting process or set NEMOCLAW_VLLM_PORT to a different port.`); + } + } + registry.updateSandbox(sandboxName, { model, provider, nimContainer }); return { model, provider }; diff --git a/bin/lib/ports.js b/bin/lib/ports.js new file mode 100644 index 000000000..42fcb7241 --- /dev/null +++ b/bin/lib/ports.js @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Central port configuration — single source of truth for all configurable ports. +// Override via environment variables or a .env file at the project root. + +function parsePort(envVar, fallback) { + const raw = process.env[envVar]; + if (raw === undefined || raw === "") return fallback; + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 1024 || parsed > 65535) { + throw new Error( + `Invalid port: ${envVar}="${raw}" — must be an integer between 1024 and 65535` + ); + } + return parsed; +} + +// Dashboard port supports legacy env var fallback chain: +// NEMOCLAW_DASHBOARD_PORT → DASHBOARD_PORT → PUBLIC_PORT → 18789 +const DASHBOARD_PORT = parsePort( + "NEMOCLAW_DASHBOARD_PORT", + parsePort("DASHBOARD_PORT", parsePort("PUBLIC_PORT", 18789)) +); + +module.exports = { + DASHBOARD_PORT, + GATEWAY_PORT: parsePort("NEMOCLAW_GATEWAY_PORT", 8080), + VLLM_PORT: parsePort("NEMOCLAW_VLLM_PORT", 8000), + OLLAMA_PORT: parsePort("NEMOCLAW_OLLAMA_PORT", 11434), +}; diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 7f191413d..a77768a2a 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -5,6 +5,7 @@ const net = require("net"); const { runCapture } = require("./runner"); +const { DASHBOARD_PORT } = require("./ports"); /** * Check whether a TCP port is available for listening. @@ -21,7 +22,7 @@ const { runCapture } = require("./runner"); * { ok: false, process: string, pid: number|null, reason: string } */ async function checkPortAvailable(port, opts) { - const p = port || 18789; + const p = port || DASHBOARD_PORT; const o = opts || {}; // ── lsof path ────────────────────────────────────────────────── diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 832a8d3cc..a31645218 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -7,6 +7,10 @@ const path = require("path"); const fs = require("fs"); const os = require("os"); +// Load .env files before any module reads process.env (e.g. ports.js) +require("./lib/env"); + + const { ROOT, SCRIPTS, run, runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); const { ensureApiKey, @@ -17,6 +21,7 @@ const { const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +const { DASHBOARD_PORT } = require("./lib/ports"); // ── Global commands ────────────────────────────────────────────── @@ -283,7 +288,7 @@ function listSandboxes() { function sandboxConnect(sandboxName) { const qn = shellQuote(sandboxName); // Ensure port forward is alive before connecting - run(`openshell forward start --background 18789 ${qn} 2>/dev/null || true`, { ignoreError: true }); + run(`openshell forward start --background ${DASHBOARD_PORT} ${qn} 2>/dev/null || true`, { ignoreError: true }); runInteractive(`openshell sandbox connect ${qn}`); } diff --git a/docs/reference/port-configuration.md b/docs/reference/port-configuration.md new file mode 100644 index 000000000..b6540c81c --- /dev/null +++ b/docs/reference/port-configuration.md @@ -0,0 +1,118 @@ +--- +title: + page: "NemoClaw Port Configuration" + nav: "Port Configuration" +description: "Configure NemoClaw network ports using environment variables or a .env file." +keywords: ["nemoclaw ports", "nemoclaw port configuration", "nemoclaw port conflict"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "configuration", "nemoclaw"] +content: + type: reference + difficulty: technical_beginner + audience: ["developer", "engineer"] +status: published +--- + + + +# NemoClaw Port Configuration + +NemoClaw uses four network ports. +All ports are configurable through environment variables or a `.env` file at the project root. + +## Default Ports + +| Port | Default | Environment variable | Purpose | +|------|---------|----------------------|---------| +| Dashboard | 18789 | `NEMOCLAW_DASHBOARD_PORT` | OpenClaw web UI, forwarded from sandbox to host | +| Gateway | 8080 | `NEMOCLAW_GATEWAY_PORT` | OpenShell gateway API | +| vLLM/NIM | 8000 | `NEMOCLAW_VLLM_PORT` | Local vLLM or NIM inference server | +| Ollama | 11434 | `NEMOCLAW_OLLAMA_PORT` | Local Ollama inference server | + +## Configure Ports with a .env File + +Copy the example file and edit it to set your preferred ports. + +```console +$ cp .env.example .env +``` + +The `.env.example` file contains all four port variables with their defaults: + +```bash +NEMOCLAW_DASHBOARD_PORT=18789 +NEMOCLAW_GATEWAY_PORT=8080 +NEMOCLAW_VLLM_PORT=8000 +NEMOCLAW_OLLAMA_PORT=11434 +``` + +Edit `.env` to change any port. +Ports must be integers in the range 1024 to 65535. +The `.env` file is gitignored and not committed to the repository. + +## Configure Ports with Environment Variables + +Export the variables directly in your shell instead of using a `.env` file. + +```console +$ export NEMOCLAW_DASHBOARD_PORT=28789 +$ export NEMOCLAW_VLLM_PORT=9000 +$ nemoclaw onboard +``` + +Shell exports take precedence over `.env` file values. + +## Dashboard Port Fallback Chain + +The dashboard port checks multiple variables for backward compatibility. +The first defined value wins: + +1. `NEMOCLAW_DASHBOARD_PORT` +2. `DASHBOARD_PORT` +3. `PUBLIC_PORT` +4. `18789` (default) + +## Check for Port Conflicts + +Run the port checker script before onboarding to detect conflicts. + +```console +$ scripts/check-ports.sh +``` + +The script reads your `.env` and `.env.local` files (if present) to resolve the configured ports, then checks each one. +If a port is in use, the output shows the process name and PID holding it. + +```text +Checking NemoClaw ports... + + ok 18789 (dashboard) + CONFLICT 8080 (gateway) — in use by nginx (PID 1234) + ok 8000 (vllm/nim) + ok 11434 (ollama) + +1 port conflict(s) found. +Set NEMOCLAW_*_PORT env vars or edit .env to use different ports. +``` + +You can also pass custom ports as arguments to check additional ports. + +```console +$ scripts/check-ports.sh 9000 9080 +``` + +The onboarding preflight also checks for port conflicts automatically. + +:::{note} +Ports 8080 and 8000 are common conflict sources. +Port 8080 is used by many web servers and proxies. +Port 8000 is used by development servers and other inference tools. +::: + +## Next Steps + +- [Troubleshooting](troubleshooting.md) for resolving port and onboarding issues. +- [CLI Commands](commands.md) for the full command reference. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 16f345423..67a47b4a9 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -90,17 +90,29 @@ Add the `export` line to your `~/.bashrc` or `~/.zshrc` to make it permanent, th ### Port already in use -The NemoClaw gateway uses port `18789` by default. -If another process is already bound to this port, onboarding fails. -Identify the conflicting process, verify it is safe to stop, and terminate it: +NemoClaw uses four ports (see the [Port Configuration](../../README.md#port-configuration) section in the README). +If another process is bound to one of these ports, onboarding fails with a message identifying the conflicting process. + +To resolve, either stop the conflicting process: ```console -$ lsof -i :18789 +$ lsof -i :8080 $ kill ``` -If the process does not exit, use `kill -9 ` to force-terminate it. -Then retry onboarding. +Or use a different port by setting the corresponding environment variable before onboarding: + +```console +$ export NEMOCLAW_GATEWAY_PORT=9080 +$ nemoclaw onboard +``` + +| Default port | Env var | Common conflicts | +|-------------|---------|-----------------| +| 8080 | `NEMOCLAW_GATEWAY_PORT` | Jenkins, Tomcat, K8s dashboard | +| 8000 | `NEMOCLAW_VLLM_PORT` | Django, PHP dev server | +| 18789 | `NEMOCLAW_DASHBOARD_PORT` | Uncommon | +| 11434 | `NEMOCLAW_OLLAMA_PORT` | Uncommon | ## Onboarding diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index f55f9f651..7d9e0d176 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -20,7 +20,7 @@ components: sandbox: image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest" name: "openclaw" - forward_ports: + forward_ports: # Override at runtime via NEMOCLAW_DASHBOARD_PORT - 18789 inference: @@ -42,14 +42,14 @@ components: nim-local: provider_type: "openai" provider_name: "nim-local" - endpoint: "http://nim-service.local:8000/v1" + endpoint: "http://nim-service.local:8000/v1" # Port overridden by NEMOCLAW_VLLM_PORT via runner.py model: "nvidia/nemotron-3-super-120b-a12b" credential_env: "NIM_API_KEY" vllm: provider_type: "openai" provider_name: "vllm-local" - endpoint: "http://localhost:8000/v1" + endpoint: "http://localhost:8000/v1" # Port overridden by NEMOCLAW_VLLM_PORT via runner.py model: "nvidia/nemotron-3-nano-30b-a3b" credential_env: "OPENAI_API_KEY" credential_default: "dummy" @@ -61,5 +61,5 @@ components: name: nim_service endpoints: - host: "nim-service.local" - port: 8000 + port: 8000 # Overridden by NEMOCLAW_VLLM_PORT via runner.py protocol: rest diff --git a/nemoclaw-blueprint/orchestrator/runner.py b/nemoclaw-blueprint/orchestrator/runner.py index 432c228c3..31f2bc327 100644 --- a/nemoclaw-blueprint/orchestrator/runner.py +++ b/nemoclaw-blueprint/orchestrator/runner.py @@ -17,6 +17,7 @@ import argparse import json import os +import re import shutil import subprocess import sys @@ -29,27 +30,84 @@ def log(msg: str) -> None: + """Print a message to stdout with immediate flush.""" print(msg, flush=True) def progress(pct: int, label: str) -> None: + """Emit a PROGRESS::