diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 8e0f0396fa2..0e106279d59 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -16,21 +16,40 @@ const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; // ── Helpers ────────────────────────────────────────────────────── +/** + * Print a numbered step banner to the console. + * + * @param {number} n Current step number. + * @param {number} total Total number of steps. + * @param {string} msg Description of the step. + */ function step(n, total, msg) { console.log(""); console.log(` [${n}/${total}] ${msg}`); console.log(` ${"─".repeat(50)}`); } -function isDockerRunning() { +/** Detect which container runtime is available. Returns "docker", "podman", or null. */ +function detectContainerRuntime() { try { runCapture("docker info", { ignoreError: false }); - return true; + return "docker"; } catch { - return false; + // Podman fallback — rootless Podman can substitute for Docker + try { + runCapture("podman info", { ignoreError: false }); + return "podman"; + } catch { + return null; + } } } +/** + * Check whether the openshell CLI is available on PATH. + * + * @returns {boolean} True if `command -v openshell` succeeds. + */ function isOpenshellInstalled() { try { runCapture("command -v openshell"); @@ -40,6 +59,11 @@ function isOpenshellInstalled() { } } +/** + * Attempt to install the openshell CLI via the bundled install script. + * + * @returns {boolean} True if openshell is available after installation. + */ function installOpenshell() { console.log(" Installing openshell CLI..."); run(`bash "${path.join(SCRIPTS, "install-openshell.sh")}"`, { ignoreError: true }); @@ -48,15 +72,25 @@ function installOpenshell() { // ── Step 1: Preflight ──────────────────────────────────────────── +/** + * Step 1: Run preflight checks (container runtime, openshell CLI, cgroup, GPU). + * + * @returns {Promise} Detected GPU descriptor, or null if none found. + */ async function preflight() { step(1, 7, "Preflight checks"); - // Docker - if (!isDockerRunning()) { - console.error(" Docker is not running. Please start Docker and try again."); + // Container runtime + const runtime = detectContainerRuntime(); + if (!runtime) { + console.error(" No container runtime found. Please install Docker or Podman and try again."); process.exit(1); } - console.log(" ✓ Docker is running"); + if (runtime === "podman") { + console.log(" ✓ Podman is running (note: --add-host=host-gateway may not resolve, see issue #116)"); + } else { + console.log(" ✓ Docker is running"); + } // OpenShell CLI if (!isOpenshellInstalled()) { @@ -106,6 +140,12 @@ async function preflight() { // ── Step 2: Gateway ────────────────────────────────────────────── +/** + * Step 2: Start (or restart) the OpenShell gateway and verify health. + * + * @param {object|null} gpu GPU descriptor from preflight (unused but reserved). + * @returns {Promise} + */ async function startGateway(gpu) { step(2, 7, "Starting OpenShell gateway"); @@ -152,6 +192,12 @@ async function startGateway(gpu) { // ── Step 3: Sandbox ────────────────────────────────────────────── +/** + * Step 3: Prompt for a sandbox name, build the image, and create the sandbox. + * + * @param {object|null} gpu GPU descriptor from preflight. + * @returns {Promise} The validated sandbox name. + */ async function createSandbox(gpu) { step(3, 7, "Creating sandbox"); @@ -232,6 +278,13 @@ async function createSandbox(gpu) { // ── Step 4: NIM ────────────────────────────────────────────────── +/** + * Step 4: Detect or prompt for an inference backend (NIM, Ollama, vLLM, cloud). + * + * @param {string} sandboxName Name of the active sandbox. + * @param {object|null} gpu GPU descriptor from preflight. + * @returns {Promise<{model: string, provider: string}>} Selected model and provider. + */ async function setupNim(sandboxName, gpu) { step(4, 7, "Configuring inference (NIM)"); @@ -365,6 +418,14 @@ async function setupNim(sandboxName, gpu) { // ── Step 5: Inference provider ─────────────────────────────────── +/** + * Step 5: Register the selected inference provider with openshell. + * + * @param {string} sandboxName Name of the active sandbox. + * @param {string} model Model identifier (e.g. "nvidia/nemotron-3-super-120b-a12b"). + * @param {string} provider Provider key ("nvidia-nim", "vllm-local", "ollama-local"). + * @returns {Promise} + */ async function setupInference(sandboxName, model, provider) { step(5, 7, "Setting up inference provider"); @@ -414,6 +475,12 @@ async function setupInference(sandboxName, model, provider) { // ── Step 6: OpenClaw ───────────────────────────────────────────── +/** + * Step 6: Launch the OpenClaw agent gateway inside the sandbox. + * + * @param {string} sandboxName Name of the active sandbox. + * @returns {Promise} + */ async function setupOpenclaw(sandboxName) { step(6, 7, "Setting up OpenClaw inside sandbox"); @@ -427,6 +494,12 @@ async function setupOpenclaw(sandboxName) { // ── Step 7: Policy presets ─────────────────────────────────────── +/** + * Step 7: Suggest and apply policy presets (pypi, npm, messaging integrations). + * + * @param {string} sandboxName Name of the active sandbox. + * @returns {Promise} + */ async function setupPolicies(sandboxName) { step(7, 7, "Policy presets"); @@ -484,6 +557,13 @@ async function setupPolicies(sandboxName) { // ── Dashboard ──────────────────────────────────────────────────── +/** + * Print a summary dashboard showing sandbox, model, and NIM status. + * + * @param {string} sandboxName Name of the active sandbox. + * @param {string} model Active model identifier. + * @param {string} provider Active provider key. + */ function printDashboard(sandboxName, model, provider) { const nimStat = nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -508,6 +588,11 @@ function printDashboard(sandboxName, model, provider) { // ── Main ───────────────────────────────────────────────────────── +/** + * Run the full 7-step interactive onboarding wizard. + * + * @returns {Promise} + */ async function onboard() { console.log(""); console.log(" NemoClaw Onboarding"); diff --git a/bin/lib/runner.js b/bin/lib/runner.js index 3614dc80da2..f5a34fd0092 100644 --- a/bin/lib/runner.js +++ b/bin/lib/runner.js @@ -8,21 +8,49 @@ const fs = require("fs"); 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"; +/** + * Detect a container runtime socket (Colima first, then Docker Desktop, then Podman). + * Returns the socket path or null. + * + * @param {object} [opts] — DI overrides for testing + * @param {string} [opts.home] — HOME directory override + * @param {function} [opts.existsSync] — fs.existsSync override + * @param {number} [opts.uid] — process UID override for rootless Podman + */ +function detectContainerSocket(opts) { + const home = (opts && opts.home) || process.env.HOME || "/tmp"; + const exists = (opts && opts.existsSync) || fs.existsSync; + const uid = (opts && opts.uid !== undefined) ? opts.uid : (process.getuid ? process.getuid() : 1000); + const candidates = [ + // Colima (preferred — existing behavior) path.join(home, ".colima/default/docker.sock"), path.join(home, ".config/colima/default/docker.sock"), + // Docker Desktop (macOS) + path.join(home, ".docker/run/docker.sock"), + // Podman machine + path.join(home, ".local/share/containers/podman/machine/podman.sock"), + `/run/user/${uid}/podman/podman.sock`, + path.join(home, ".local/share/containers/podman/machine/qemu/podman.sock"), ]; + for (const sock of candidates) { - if (fs.existsSync(sock)) { - process.env.DOCKER_HOST = `unix://${sock}`; - break; + if (exists(sock)) { + return sock; } } + return null; +} + +// Auto-detect container socket if DOCKER_HOST not already set +if (!process.env.DOCKER_HOST) { + const sock = detectContainerSocket(); + if (sock) { + process.env.DOCKER_HOST = `unix://${sock}`; + } } +/** Run a shell command with inherited stdio, exiting on failure unless opts.ignoreError is set. */ function run(cmd, opts = {}) { const result = spawnSync("bash", ["-c", cmd], { stdio: "inherit", @@ -37,6 +65,7 @@ function run(cmd, opts = {}) { return result; } +/** Run a shell command and return its trimmed stdout. Returns "" on error when opts.ignoreError is set. */ function runCapture(cmd, opts = {}) { try { return execSync(cmd, { @@ -52,4 +81,4 @@ function runCapture(cmd, opts = {}) { } } -module.exports = { ROOT, SCRIPTS, run, runCapture }; +module.exports = { ROOT, SCRIPTS, run, runCapture, detectContainerSocket }; diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 07bb3d5b5a2..dacf48cbff1 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -28,11 +28,13 @@ const GLOBAL_COMMANDS = new Set([ // ── Commands ───────────────────────────────────────────────────── +/** Launch the interactive onboarding wizard. */ async function onboard() { const { onboard: runOnboard } = require("./lib/onboard"); await runOnboard(); } +/** Run the deprecated legacy setup.sh for backwards compatibility. */ async function setup() { console.log(""); console.log(" ⚠ `nemoclaw setup` is deprecated. Use `nemoclaw onboard` instead."); @@ -42,11 +44,17 @@ async function setup() { run(`bash "${SCRIPTS}/setup.sh"`); } +/** Run the DGX Spark setup script (cgroup v2 fix + Docker restart). */ async function setupSpark() { await ensureApiKey(); run(`sudo -E NVIDIA_API_KEY="${process.env.NVIDIA_API_KEY}" bash "${SCRIPTS}/setup-spark.sh"`); } +/** + * Deploy NemoClaw to a remote Brev GPU instance, sync files, and connect. + * + * @param {string} instanceName Brev instance name to create or reuse. + */ async function deploy(instanceName) { if (!instanceName) { console.error(" Usage: nemoclaw deploy "); @@ -132,15 +140,18 @@ async function deploy(instanceName) { run(`ssh -t -o StrictHostKeyChecking=no -o LogLevel=ERROR ${name} 'cd /home/ubuntu/nemoclaw && set -a && . .env && set +a && openshell sandbox connect nemoclaw'`); } +/** Start background services (Telegram bridge, tunnel). */ async function start() { await ensureApiKey(); run(`bash "${SCRIPTS}/start-services.sh"`); } +/** Stop all running NemoClaw services. */ function stop() { run(`bash "${SCRIPTS}/start-services.sh" --stop`); } +/** Display registered sandboxes and current service status. */ function showStatus() { // Show sandbox registry const { sandboxes, defaultSandbox } = registry.listSandboxes(); @@ -159,6 +170,7 @@ function showStatus() { run(`bash "${SCRIPTS}/start-services.sh" --status`); } +/** List all registered sandboxes with model, provider, and policy details. */ function listSandboxes() { const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length === 0) { @@ -186,12 +198,22 @@ function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── +/** + * Ensure the port forward is alive, then open an interactive connection. + * + * @param {string} sandboxName Target sandbox name. + */ function sandboxConnect(sandboxName) { // Ensure port forward is alive before connecting run(`openshell forward start --background 18789 "${sandboxName}" 2>/dev/null || true`, { ignoreError: true }); run(`openshell sandbox connect "${sandboxName}"`); } +/** + * Print detailed status for a sandbox (registry info, openshell state, NIM health). + * + * @param {string} sandboxName Target sandbox name. + */ function sandboxStatus(sandboxName) { const sb = registry.getSandbox(sandboxName); if (sb) { @@ -215,11 +237,22 @@ function sandboxStatus(sandboxName) { console.log(""); } +/** + * Stream sandbox logs, optionally following in real time. + * + * @param {string} sandboxName Target sandbox name. + * @param {boolean} follow If true, pass --follow to tail logs. + */ function sandboxLogs(sandboxName, follow) { const followFlag = follow ? " --follow" : ""; run(`openshell sandbox logs "${sandboxName}"${followFlag}`); } +/** + * Interactively add a policy preset to a sandbox. + * + * @param {string} sandboxName Target sandbox name. + */ async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -242,6 +275,11 @@ async function sandboxPolicyAdd(sandboxName) { policies.applyPreset(sandboxName, answer); } +/** + * List all policy presets, marking which are applied to the given sandbox. + * + * @param {string} sandboxName Target sandbox name. + */ function sandboxPolicyList(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -255,6 +293,11 @@ function sandboxPolicyList(sandboxName) { console.log(""); } +/** + * Stop the NIM container, delete the sandbox, and remove it from the registry. + * + * @param {string} sandboxName Target sandbox name. + */ function sandboxDestroy(sandboxName) { console.log(` Stopping NIM for '${sandboxName}'...`); nim.stopNimContainer(sandboxName); @@ -268,6 +311,7 @@ function sandboxDestroy(sandboxName) { // ── Help ───────────────────────────────────────────────────────── +/** Print CLI usage information. */ function help() { console.log(` nemoclaw — NemoClaw CLI diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 5d1af0be5ed..039822c9247 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -34,6 +34,13 @@ const activeSessions = new Map(); // chatId → message history // ── Telegram API helpers ────────────────────────────────────────── +/** + * Call a Telegram Bot API method via HTTPS POST. + * + * @param {string} method Telegram API method name (e.g. "sendMessage"). + * @param {object} body JSON-serializable request body. + * @returns {Promise} Parsed JSON response from Telegram. + */ function tgApi(method, body) { return new Promise((resolve, reject) => { const data = JSON.stringify(body); @@ -58,6 +65,13 @@ function tgApi(method, body) { }); } +/** + * Send a text message to a Telegram chat, splitting into chunks if needed. + * + * @param {string|number} chatId Target chat ID. + * @param {string} text Message text (auto-chunked at 4000 chars). + * @param {number} [replyTo] Message ID to reply to. + */ async function sendMessage(chatId, text, replyTo) { // Telegram max message length is 4096 const chunks = []; @@ -77,12 +91,24 @@ async function sendMessage(chatId, text, replyTo) { } } +/** + * Send a "typing" indicator to a Telegram chat. + * + * @param {string|number} chatId Target chat ID. + */ async function sendTyping(chatId) { await tgApi("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}); } // ── Run agent inside sandbox ────────────────────────────────────── +/** + * SSH into the sandbox and run the OpenClaw agent with the given message. + * + * @param {string} message User message to forward to the agent. + * @param {string} sessionId Session identifier (typically the Telegram chat ID). + * @returns {Promise} Agent response text. + */ function runAgentInSandbox(message, sessionId) { return new Promise((resolve) => { const sshConfig = execSync(`openshell sandbox ssh-config ${SANDBOX}`, { encoding: "utf-8" }); @@ -143,6 +169,7 @@ function runAgentInSandbox(message, sessionId) { // ── Poll loop ───────────────────────────────────────────────────── +/** Long-poll Telegram for updates and dispatch each message to the sandbox agent. */ async function poll() { try { const res = await tgApi("getUpdates", { offset, timeout: 30 }); @@ -212,6 +239,7 @@ async function poll() { // ── Main ────────────────────────────────────────────────────────── +/** Verify the bot token, print the startup banner, and begin polling. */ async function main() { const me = await tgApi("getMe", {}); if (!me.ok) { diff --git a/test/preflight.test.js b/test/preflight.test.js index 1700c284f42..26fd457e41f 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -9,7 +9,12 @@ const path = require("path"); const { isCgroupV2, readDaemonJson, checkCgroupConfig } = require("../bin/lib/preflight"); -// Helper: create a temp daemon.json with given content and return its path. +/** + * Create a temporary daemon.json with the given content. + * + * @param {string} content Raw file content to write. + * @returns {string} Absolute path to the created file. + */ function writeTempDaemon(content) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-preflight-")); const p = path.join(dir, "daemon.json"); diff --git a/test/runner.test.js b/test/runner.test.js new file mode 100644 index 00000000000..1214b3a64df --- /dev/null +++ b/test/runner.test.js @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); + +const { detectContainerSocket } = require("../bin/lib/runner"); + +describe("detectContainerSocket", () => { + it("returns null when no sockets exist", () => { + const result = detectContainerSocket({ + home: "/nonexistent", + existsSync: () => false, + uid: 1000, + }); + assert.equal(result, null); + }); + + it("detects Docker Desktop socket", () => { + const dockerDesktopPath = "/home/test/.docker/run/docker.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === dockerDesktopPath, + uid: 1000, + }); + assert.equal(result, dockerDesktopPath); + }); + + it("prefers Colima over Docker Desktop", () => { + const colimaPath = "/home/test/.colima/default/docker.sock"; + const dockerDesktopPath = "/home/test/.docker/run/docker.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === colimaPath || p === dockerDesktopPath, + uid: 1000, + }); + assert.equal(result, colimaPath); + }); + + it("prefers Docker Desktop over Podman", () => { + const dockerDesktopPath = "/home/test/.docker/run/docker.sock"; + const podmanPath = "/home/test/.local/share/containers/podman/machine/podman.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === dockerDesktopPath || p === podmanPath, + uid: 1000, + }); + assert.equal(result, dockerDesktopPath); + }); + + it("prefers Colima over Podman when both exist", () => { + const colimaPath = "/home/test/.colima/default/docker.sock"; + const podmanPath = "/home/test/.local/share/containers/podman/machine/podman.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === colimaPath || p === podmanPath, + uid: 1000, + }); + assert.equal(result, colimaPath); + }); + + it("falls back to Podman when Colima absent", () => { + const podmanPath = "/home/test/.local/share/containers/podman/machine/podman.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === podmanPath, + uid: 1000, + }); + assert.equal(result, podmanPath); + }); + + it("detects rootless Podman socket", () => { + const uid = 1001; + const rootlessPath = `/run/user/${uid}/podman/podman.sock`; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === rootlessPath, + uid, + }); + assert.equal(result, rootlessPath); + }); + + it("detects XDG Colima socket", () => { + const xdgPath = "/home/test/.config/colima/default/docker.sock"; + + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === xdgPath, + uid: 1000, + }); + assert.equal(result, xdgPath); + }); + + it("detects Podman QEMU socket path", () => { + const qemuPath = "/home/test/.local/share/containers/podman/machine/qemu/podman.sock"; + const result = detectContainerSocket({ + home: "/home/test", + existsSync: (p) => p === qemuPath, + uid: 1000, + }); + assert.equal(result, qemuPath); + }); +});