diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 4dd8a7045..69ade8086 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -1,357 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Preflight checks for NemoClaw onboarding. +// Thin re-export shim — the implementation lives in src/lib/preflight.ts, +// compiled to dist/lib/preflight.js. -const fs = require("fs"); -const net = require("net"); -const os = require("os"); -const path = require("path"); -const { runCapture } = require("./runner"); - -async function probePortAvailability(port, opts = {}) { - if (typeof opts.probeImpl === "function") { - return opts.probeImpl(port); - } - - return new Promise((resolve) => { - const srv = net.createServer(); - srv.once("error", (/** @type {NodeJS.ErrnoException} */ err) => { - if (err.code === "EADDRINUSE") { - resolve({ - ok: false, - process: "unknown", - pid: null, - reason: `port ${port} is in use (EADDRINUSE)`, - }); - return; - } - - if (err.code === "EPERM" || err.code === "EACCES") { - resolve({ - ok: true, - warning: `port probe skipped: ${err.message}`, - }); - return; - } - - // Unexpected probe failure: do not report a false conflict. - resolve({ - ok: true, - warning: `port probe inconclusive: ${err.message}`, - }); - }); - srv.listen(port, "127.0.0.1", () => { - srv.close(() => resolve({ ok: true })); - }); - }); -} - -/** - * Check whether a TCP port is available for listening. - * - * Detection chain: - * 1. lsof (primary) — identifies the blocking process name + PID - * 2. Node.js net probe (fallback) — cross-platform, detects EADDRINUSE - * - * opts.lsofOutput — inject fake lsof output for testing (skips shell) - * opts.skipLsof — force the net-probe fallback path - * opts.probeImpl — async (port) => probe result for testing - * - * Returns: - * { ok: true } - * { ok: true, warning: string } - * { ok: false, process: string, pid: number|null, reason: string } - */ -async function checkPortAvailable(port, opts) { - const p = port || 18789; - const o = opts || {}; - - // ── lsof path ────────────────────────────────────────────────── - if (!o.skipLsof) { - let lsofOut; - if (typeof o.lsofOutput === "string") { - lsofOut = o.lsofOutput; - } else { - const hasLsof = runCapture("command -v lsof", { ignoreError: true }); - if (hasLsof) { - lsofOut = runCapture(`lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { ignoreError: true }); - } - } - - if (typeof lsofOut === "string") { - const lines = lsofOut.split("\n").filter((l) => l.trim()); - // Skip the header line (starts with COMMAND) - const dataLines = lines.filter((l) => !l.startsWith("COMMAND")); - if (dataLines.length > 0) { - // Parse first data line: COMMAND PID USER ... - const parts = dataLines[0].split(/\s+/); - const proc = parts[0] || "unknown"; - const pid = parseInt(parts[1], 10) || null; - return { - ok: false, - process: proc, - pid, - reason: `lsof reports ${proc} (PID ${pid}) listening on port ${p}`, - }; - } - // Empty lsof output is not authoritative — non-root users cannot - // see listeners owned by root (e.g., docker-proxy, leftover gateway). - // Retry with sudo to identify root-owned listeners before falling - // through to the net probe (which can only detect EADDRINUSE but not - // the owning process). - if (dataLines.length === 0 && !o.lsofOutput) { - const sudoOut = runCapture(`sudo -n lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { - ignoreError: true, - }); - if (typeof sudoOut === "string") { - const sudoLines = sudoOut.split("\n").filter((l) => l.trim()); - const sudoData = sudoLines.filter((l) => !l.startsWith("COMMAND")); - if (sudoData.length > 0) { - const parts = sudoData[0].split(/\s+/); - const proc = parts[0] || "unknown"; - const pid = parseInt(parts[1], 10) || null; - return { - ok: false, - process: proc, - pid, - reason: `sudo lsof reports ${proc} (PID ${pid}) listening on port ${p}`, - }; - } - } - } - } - } - - // ── net probe fallback ───────────────────────────────────────── - return probePortAvailability(p, o); -} - -/** - * Read system memory info (RAM + swap). - * - * On Linux, parses /proc/meminfo. On macOS, uses sysctl. - * Returns null on unsupported platforms or read errors. - * - * opts.meminfoContent — inject fake /proc/meminfo for testing - * opts.platform — override process.platform for testing - * - * Returns: - * { totalRamMB: number, totalSwapMB: number, totalMB: number } - */ -function getMemoryInfo(opts) { - const o = opts || {}; - const platform = o.platform || process.platform; - - if (platform === "linux") { - let content; - if (typeof o.meminfoContent === "string") { - content = o.meminfoContent; - } else { - try { - content = fs.readFileSync("/proc/meminfo", "utf-8"); - } catch { - return null; - } - } - - const parseKB = (key) => { - const match = content.match(new RegExp(`^${key}:\\s+(\\d+)`, "m")); - return match ? parseInt(match[1], 10) : 0; - }; - - const totalRamKB = parseKB("MemTotal"); - const totalSwapKB = parseKB("SwapTotal"); - const totalRamMB = Math.floor(totalRamKB / 1024); - const totalSwapMB = Math.floor(totalSwapKB / 1024); - return { totalRamMB, totalSwapMB, totalMB: totalRamMB + totalSwapMB }; - } - - if (platform === "darwin") { - try { - const memBytes = parseInt(runCapture("sysctl -n hw.memsize", { ignoreError: true }), 10); - if (!memBytes || isNaN(memBytes)) return null; - const totalRamMB = Math.floor(memBytes / 1024 / 1024); - // macOS does not use traditional swap files in the same way - return { totalRamMB, totalSwapMB: 0, totalMB: totalRamMB }; - } catch { - return null; - } - } - - return null; -} - -function hasSwapfile() { - try { - fs.accessSync("/swapfile"); - return true; - } catch { - return false; - } -} - -function getExistingSwapResult(mem) { - if (!hasSwapfile()) { - return null; - } - - const swaps = (() => { - try { - return fs.readFileSync("/proc/swaps", "utf-8"); - } catch { - return ""; - } - })(); - - if (swaps.includes("/swapfile")) { - return { - ok: true, - totalMB: mem.totalMB, - swapCreated: false, - reason: "/swapfile already exists", - }; - } - - try { - runCapture("sudo swapon /swapfile", { ignoreError: false }); - return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; - } catch (err) { - return { - ok: false, - reason: `found orphaned /swapfile but could not activate it: ${err.message}`, - }; - } -} - -function checkSwapDiskSpace() { - try { - const dfOut = runCapture("df / --output=avail -k 2>/dev/null | tail -1", { ignoreError: true }); - const freeKB = parseInt((dfOut || "").trim(), 10); - if (!isNaN(freeKB) && freeKB < 5000000) { - return { - ok: false, - reason: `insufficient disk space (${Math.floor(freeKB / 1024)} MB free, need ~5 GB) to create swap file`, - }; - } - } catch { - // df unavailable — let dd fail naturally if out of space - } - - return null; -} - -function writeManagedSwapMarker() { - const nemoclawDir = path.join(os.homedir(), ".nemoclaw"); - if (!fs.existsSync(nemoclawDir)) { - runCapture(`mkdir -p ${nemoclawDir}`, { ignoreError: true }); - } - - try { - fs.writeFileSync(path.join(nemoclawDir, "managed_swap"), "/swapfile"); - } catch { - // Best effort marker write. - } -} - -function cleanupPartialSwap() { - try { - runCapture("sudo swapoff /swapfile 2>/dev/null || true", { ignoreError: true }); - runCapture("sudo rm -f /swapfile", { ignoreError: true }); - } catch { - // Best effort cleanup - } -} - -function createSwapfile(mem) { - try { - runCapture("sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none", { - ignoreError: false, - }); - runCapture("sudo chmod 600 /swapfile", { ignoreError: false }); - runCapture("sudo mkswap /swapfile", { ignoreError: false }); - runCapture("sudo swapon /swapfile", { ignoreError: false }); - runCapture( - "grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab", - { ignoreError: false }, - ); - writeManagedSwapMarker(); - - return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; - } catch (err) { - cleanupPartialSwap(); - return { - ok: false, - reason: - `swap creation failed: ${err.message}. Create swap manually:\n` + - " sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none && sudo chmod 600 /swapfile && " + - "sudo mkswap /swapfile && sudo swapon /swapfile", - }; - } -} - -/** - * Ensure the system has enough memory (RAM + swap) for sandbox operations. - * - * If total memory is below minTotalMB and no swap file exists, attempts to - * create a 4 GB swap file via sudo to prevent OOM kills during sandbox image push. - * - * opts.memoryInfo — inject mock getMemoryInfo() result for testing - * opts.platform — override process.platform for testing - * opts.dryRun — if true, skip actual swap creation (for testing) - * - * Returns: - * { ok: true, totalMB, swapCreated: boolean } - * { ok: false, reason: string } - */ -function ensureSwap(minTotalMB, opts = {}) { - const o = { - platform: process.platform, - memoryInfo: null, - swapfileExists: fs.existsSync("/swapfile"), - dryRun: false, - interactive: process.stdout.isTTY && !process.env.NEMOCLAW_NON_INTERACTIVE, - getMemoryInfoImpl: getMemoryInfo, - ...opts, - }; - const threshold = minTotalMB ?? 12000; - - if (o.platform !== "linux") { - return { ok: true, totalMB: 0, swapCreated: false }; - } - - const mem = o.memoryInfo ?? o.getMemoryInfoImpl({ platform: o.platform }); - if (!mem) { - return { ok: false, reason: "could not read memory info" }; - } - - if (mem.totalMB >= threshold) { - return { ok: true, totalMB: mem.totalMB, swapCreated: false }; - } - - if (o.dryRun) { - if (o.swapfileExists) { - return { - ok: true, - totalMB: mem.totalMB, - swapCreated: false, - reason: "/swapfile already exists", - }; - } - return { ok: true, totalMB: mem.totalMB, swapCreated: true }; - } - - const existingSwapResult = getExistingSwapResult(mem); - if (existingSwapResult) { - return existingSwapResult; - } - - const diskSpaceResult = checkSwapDiskSpace(); - if (diskSpaceResult) { - return diskSpaceResult; - } - - return createSwapfile(mem); -} - -module.exports = { checkPortAvailable, probePortAvailability, getMemoryInfo, ensureSwap }; +module.exports = require("../../dist/lib/preflight"); diff --git a/src/lib/dashboard.test.ts b/src/lib/dashboard.test.ts index cadb8b26b..8f812b3b6 100644 --- a/src/lib/dashboard.test.ts +++ b/src/lib/dashboard.test.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { resolveDashboardForwardTarget, buildControlUiUrls } from "./dashboard"; +// Import from compiled dist/ so coverage is attributed correctly. +import { resolveDashboardForwardTarget, buildControlUiUrls } from "../../dist/lib/dashboard"; describe("resolveDashboardForwardTarget", () => { it("returns port-only for localhost URL", () => { @@ -31,12 +32,25 @@ describe("resolveDashboardForwardTarget", () => { expect(resolveDashboardForwardTarget("remote-host:18789")).toBe("0.0.0.0:18789"); }); - it("handles invalid URL with localhost", () => { + it("handles invalid URL containing localhost in catch path", () => { + // This triggers the catch branch since ://localhost is not a valid URL expect(resolveDashboardForwardTarget("://localhost:bad")).toBe("18789"); }); - it("handles invalid URL with non-localhost", () => { - expect(resolveDashboardForwardTarget("://remote:bad")).toBe("0.0.0.0:18789"); + it("handles invalid URL containing 127.0.0.1 in catch path", () => { + expect(resolveDashboardForwardTarget("://127.0.0.1:bad")).toBe("18789"); + }); + + it("handles invalid URL containing ::1 in catch path", () => { + expect(resolveDashboardForwardTarget("://::1:bad")).toBe("18789"); + }); + + it("handles invalid URL with non-loopback in catch path", () => { + expect(resolveDashboardForwardTarget("://remote-host:bad")).toBe("0.0.0.0:18789"); + }); + + it("handles IPv6 loopback URL", () => { + expect(resolveDashboardForwardTarget("http://[::1]:18789")).toBe("18789"); }); }); @@ -77,4 +91,16 @@ describe("buildControlUiUrls", () => { const urls = buildControlUiUrls(null); expect(urls).toHaveLength(1); }); + + it("ignores non-http CHAT_UI_URL", () => { + process.env.CHAT_UI_URL = "ftp://example.com"; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(1); + }); + + it("ignores empty CHAT_UI_URL", () => { + process.env.CHAT_UI_URL = " "; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(1); + }); }); diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts new file mode 100644 index 000000000..a14102c5e --- /dev/null +++ b/src/lib/preflight.test.ts @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +// Import through the compiled dist/ output (via the bin/lib shim) so +// coverage is attributed to dist/lib/preflight.js, which is what the +// ratchet measures. +import { + checkPortAvailable, + getMemoryInfo, + ensureSwap, +} from "../../dist/lib/preflight"; + +describe("checkPortAvailable", () => { + it("falls through to the probe when lsof output is empty", async () => { + let probedPort: number | null = null; + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, + }); + + expect(probedPort).toBe(18789); + expect(result).toEqual({ ok: true }); + }); + + it("probe catches occupied port even when lsof returns empty", async () => { + const result = await checkPortAvailable(18789, { + lsofOutput: "", + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 18789 is in use (EADDRINUSE)", + }), + }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); + }); + + it("parses process and PID from lsof output", async () => { + const lsofOutput = [ + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", + "openclaw 12345 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", + ].join("\n"); + const result = await checkPortAvailable(18789, { lsofOutput }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("openclaw"); + expect(result.pid).toBe(12345); + expect(result.reason).toContain("openclaw"); + }); + + it("picks first listener when lsof shows multiple", async () => { + const lsofOutput = [ + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", + "gateway 111 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", + "node 222 root 8u IPv4 54322 0t0 TCP *:18789 (LISTEN)", + ].join("\n"); + const result = await checkPortAvailable(18789, { lsofOutput }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("gateway"); + expect(result.pid).toBe(111); + }); + + it("returns ok for a free port probe", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ ok: true }), + }); + + expect(result).toEqual({ ok: true }); + }); + + it("returns occupied for EADDRINUSE probe results", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: false, + process: "unknown", + pid: null, + reason: "port 8080 is in use (EADDRINUSE)", + }), + }); + + expect(result.ok).toBe(false); + expect(result.process).toBe("unknown"); + expect(result.reason).toContain("EADDRINUSE"); + }); + + it("treats restricted probe environments as inconclusive instead of occupied", async () => { + const result = await checkPortAvailable(8080, { + skipLsof: true, + probeImpl: async () => ({ + ok: true as const, + warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", + }), + }); + + expect(result.ok).toBe(true); + expect(result.warning).toContain("EPERM"); + }); + + it("defaults to port 18789 when no port is given", async () => { + let probedPort: number | null = null; + const result = await checkPortAvailable(undefined, { + skipLsof: true, + probeImpl: async (port) => { + probedPort = port; + return { ok: true }; + }, + }); + + expect(probedPort).toBe(18789); + expect(result.ok).toBe(true); + }); +}); + +describe("probePortAvailability", () => { + // Import probePortAvailability directly for targeted testing + const { probePortAvailability } = require("../../dist/lib/preflight"); + + it("returns ok when port is free (real net probe)", async () => { + // Use a high ephemeral port unlikely to be in use + const result = await probePortAvailability(0, {}); + // Port 0 lets the OS pick a free port, so it should always succeed + expect(result.ok).toBe(true); + }); + + it("detects EADDRINUSE on an occupied port (real net probe)", async () => { + // Start a server on a random port, then probe it + const net = require("node:net"); + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = srv.address().port; + try { + const result = await probePortAvailability(port, {}); + expect(result.ok).toBe(false); + expect(result.reason).toContain("EADDRINUSE"); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } + }); + + it("delegates to probeImpl when provided", async () => { + let called = false; + const result = await probePortAvailability(9999, { + probeImpl: async (port: number) => { + called = true; + expect(port).toBe(9999); + return { ok: true as const }; + }, + }); + expect(called).toBe(true); + expect(result.ok).toBe(true); + }); +}); + +describe("checkPortAvailable — real probe fallback", () => { + it("returns ok for a free port via full detection chain", async () => { + // skipLsof forces the net probe path; use port 0 which is always free + const result = await checkPortAvailable(0, { skipLsof: true }); + expect(result.ok).toBe(true); + }); + + it("detects a real occupied port", async () => { + const net = require("node:net"); + const srv = net.createServer(); + await new Promise((resolve) => srv.listen(0, "127.0.0.1", resolve)); + const port = srv.address().port; + try { + const result = await checkPortAvailable(port, { skipLsof: true }); + expect(result.ok).toBe(false); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } + }); +}); + +describe("checkPortAvailable — sudo -n lsof retry", () => { + it("uses sudo -n (non-interactive) for the lsof retry path", async () => { + // When lsof returns empty (non-root can't see root-owned listeners), + // checkPortAvailable retries with sudo -n. We can't easily test this + // without mocking runCapture, but we can verify the lsofOutput injection + // path handles header-only output correctly (falls through to probe). + let probed = false; + const result = await checkPortAvailable(18789, { + lsofOutput: "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n", + probeImpl: async () => { + probed = true; + return { ok: true }; + }, + }); + expect(probed).toBe(true); + expect(result.ok).toBe(true); + }); +}); + +describe("getMemoryInfo", () => { + it("parses valid /proc/meminfo content", () => { + const meminfoContent = [ + "MemTotal: 8152056 kB", + "MemFree: 1234567 kB", + "MemAvailable: 4567890 kB", + "SwapTotal: 4194300 kB", + "SwapFree: 4194300 kB", + ].join("\n"); + + const result = getMemoryInfo({ meminfoContent, platform: "linux" }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result!.totalSwapMB).toBe(Math.floor(4194300 / 1024)); + expect(result!.totalMB).toBe(result!.totalRamMB + result!.totalSwapMB); + }); + + it("returns correct values when swap is zero", () => { + const meminfoContent = [ + "MemTotal: 8152056 kB", + "MemFree: 1234567 kB", + "SwapTotal: 0 kB", + "SwapFree: 0 kB", + ].join("\n"); + + const result = getMemoryInfo({ meminfoContent, platform: "linux" }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result!.totalSwapMB).toBe(0); + expect(result!.totalMB).toBe(result!.totalRamMB); + }); + + it("returns null on unsupported platforms", () => { + const result = getMemoryInfo({ platform: "win32" }); + expect(result).toBeNull(); + }); + + it("returns null on darwin when sysctl returns empty", () => { + // When runCapture("sysctl -n hw.memsize") returns empty/falsy, + // getMemoryInfo should return null rather than crash. + // This exercises the darwin branch without requiring a real sysctl binary. + const result = getMemoryInfo({ platform: "darwin" }); + // On macOS with sysctl available, returns info; otherwise null — both are valid + if (result !== null) { + expect(result.totalRamMB).toBeGreaterThan(0); + expect(result.totalSwapMB).toBe(0); + } + }); + + it("handles malformed /proc/meminfo gracefully", () => { + const result = getMemoryInfo({ + meminfoContent: "garbage data\nno fields here", + platform: "linux", + }); + expect(result).not.toBeNull(); + expect(result!.totalRamMB).toBe(0); + expect(result!.totalSwapMB).toBe(0); + expect(result!.totalMB).toBe(0); + }); +}); + +describe("ensureSwap", () => { + it("returns ok when total memory already exceeds threshold", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 8000, totalSwapMB: 0, totalMB: 8000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.totalMB).toBe(8000); + }); + + it("reports swap would be created in dry-run mode when below threshold", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + dryRun: true, + swapfileExists: false, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(true); + }); + + it("skips swap creation when /swapfile already exists (dry-run)", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + dryRun: true, + swapfileExists: true, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.reason).toMatch(/swapfile already exists/); + }); + + it("skips on non-Linux platforms", () => { + const result = ensureSwap(6144, { + platform: "darwin", + memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + }); + + it("returns error when memory info is unavailable", () => { + const result = ensureSwap(6144, { + platform: "linux", + memoryInfo: null, + getMemoryInfoImpl: () => null, + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/could not read memory info/); + }); + + it("uses default 12000 MB threshold when minTotalMB is undefined", () => { + const result = ensureSwap(undefined, { + platform: "linux", + memoryInfo: { totalRamMB: 16000, totalSwapMB: 0, totalMB: 16000 }, + }); + expect(result.ok).toBe(true); + expect(result.swapCreated).toBe(false); + expect(result.totalMB).toBe(16000); + }); + + it("uses getMemoryInfoImpl when memoryInfo is not provided", () => { + let called = false; + const result = ensureSwap(6144, { + platform: "linux", + getMemoryInfoImpl: () => { + called = true; + return { totalRamMB: 8000, totalSwapMB: 0, totalMB: 8000 }; + }, + }); + expect(called).toBe(true); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts new file mode 100644 index 000000000..c59723f8c --- /dev/null +++ b/src/lib/preflight.ts @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Preflight checks for NemoClaw onboarding: port availability, memory + * info, and swap management. + * + * Every function accepts an opts object for dependency injection so + * tests can run without real I/O. + */ + +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +// runner.js is CJS — use require so we don't pull it into the TS build. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { runCapture } = require("../../bin/lib/runner"); + +// ── Types ──────────────────────────────────────────────────────── + +export interface PortProbeResult { + ok: boolean; + warning?: string; + process?: string; + pid?: number | null; + reason?: string; +} + +export interface CheckPortOpts { + /** Inject fake lsof output (skips shell). */ + lsofOutput?: string; + /** Force the net-probe fallback path. */ + skipLsof?: boolean; + /** Async probe implementation for testing. */ + probeImpl?: (port: number) => Promise; +} + +export interface MemoryInfo { + totalRamMB: number; + totalSwapMB: number; + totalMB: number; +} + +export interface GetMemoryInfoOpts { + /** Inject fake /proc/meminfo content. */ + meminfoContent?: string; + /** Override process.platform. */ + platform?: NodeJS.Platform; +} + +export interface SwapResult { + ok: boolean; + totalMB?: number; + swapCreated?: boolean; + reason?: string; +} + +export interface EnsureSwapOpts { + /** Override process.platform. */ + platform?: NodeJS.Platform; + /** Inject mock getMemoryInfo() result. */ + memoryInfo?: MemoryInfo | null; + /** Whether /swapfile exists (override for testing). */ + swapfileExists?: boolean; + /** Skip actual swap creation. */ + dryRun?: boolean; + /** Whether the session is interactive. */ + interactive?: boolean; + /** Override getMemoryInfo implementation. */ + getMemoryInfoImpl?: (opts: GetMemoryInfoOpts) => MemoryInfo | null; +} + +// ── Port availability ──────────────────────────────────────────── + +export async function probePortAvailability( + port: number, + opts: Pick = {}, +): Promise { + if (typeof opts.probeImpl === "function") { + return opts.probeImpl(port); + } + + return new Promise((resolve) => { + const srv = net.createServer(); + srv.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve({ + ok: false, + process: "unknown", + pid: null, + reason: `port ${port} is in use (EADDRINUSE)`, + }); + return; + } + + if (err.code === "EPERM" || err.code === "EACCES") { + resolve({ + ok: true, + warning: `port probe skipped: ${err.message}`, + }); + return; + } + + // Unexpected probe failure: do not report a false conflict. + resolve({ + ok: true, + warning: `port probe inconclusive: ${err.message}`, + }); + }); + srv.listen(port, "127.0.0.1", () => { + srv.close(() => resolve({ ok: true })); + }); + }); +} + +function parseLsofLines(output: string): PortProbeResult | null { + const lines = output.split("\n").filter((l) => l.trim()); + const dataLines = lines.filter((l) => !l.startsWith("COMMAND")); + if (dataLines.length === 0) return null; + + const parts = dataLines[0].split(/\s+/); + const proc = parts[0] || "unknown"; + const pid = parseInt(parts[1], 10) || null; + return { ok: false, process: proc, pid, reason: "" }; +} + +/** + * Check whether a TCP port is available for listening. + * + * Detection chain: + * 1. lsof (primary) — identifies the blocking process name + PID + * 2. Node.js net probe (fallback) — cross-platform, detects EADDRINUSE + */ +export async function checkPortAvailable( + port?: number, + opts?: CheckPortOpts, +): Promise { + const p = port || 18789; + const o = opts || {}; + + // ── lsof path ────────────────────────────────────────────────── + if (!o.skipLsof) { + let lsofOut: string | undefined; + if (typeof o.lsofOutput === "string") { + lsofOut = o.lsofOutput; + } else { + const hasLsof = runCapture("command -v lsof", { ignoreError: true }); + if (hasLsof) { + lsofOut = runCapture(`lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, { + ignoreError: true, + }); + } + } + + if (typeof lsofOut === "string") { + const conflict = parseLsofLines(lsofOut); + if (conflict) { + return { + ...conflict, + reason: `lsof reports ${conflict.process} (PID ${conflict.pid}) listening on port ${p}`, + }; + } + + // Empty lsof output is not authoritative — non-root users cannot + // see listeners owned by root (e.g., docker-proxy, leftover gateway). + // Retry with sudo -n to identify root-owned listeners before falling + // through to the net probe (which can only detect EADDRINUSE but not + // the owning process). + if (!o.lsofOutput) { + const sudoOut: string | undefined = runCapture( + `sudo -n lsof -i :${p} -sTCP:LISTEN -P -n 2>/dev/null`, + { ignoreError: true }, + ); + if (typeof sudoOut === "string") { + const sudoConflict = parseLsofLines(sudoOut); + if (sudoConflict) { + return { + ...sudoConflict, + reason: `sudo lsof reports ${sudoConflict.process} (PID ${sudoConflict.pid}) listening on port ${p}`, + }; + } + } + } + } + } + + // ── net probe fallback ───────────────────────────────────────── + return probePortAvailability(p, o); +} + +// ── Memory info ────────────────────────────────────────────────── + +export function getMemoryInfo(opts?: GetMemoryInfoOpts): MemoryInfo | null { + const o = opts || {}; + const platform = o.platform || process.platform; + + if (platform === "linux") { + let content: string; + if (typeof o.meminfoContent === "string") { + content = o.meminfoContent; + } else { + try { + content = fs.readFileSync("/proc/meminfo", "utf-8"); + } catch { + return null; + } + } + + const parseKB = (key: string): number => { + const match = content.match(new RegExp(`^${key}:\\s+(\\d+)`, "m")); + return match ? parseInt(match[1], 10) : 0; + }; + + const totalRamKB = parseKB("MemTotal"); + const totalSwapKB = parseKB("SwapTotal"); + const totalRamMB = Math.floor(totalRamKB / 1024); + const totalSwapMB = Math.floor(totalSwapKB / 1024); + return { totalRamMB, totalSwapMB, totalMB: totalRamMB + totalSwapMB }; + } + + if (platform === "darwin") { + try { + const memBytes = parseInt(runCapture("sysctl -n hw.memsize", { ignoreError: true }), 10); + if (!memBytes || isNaN(memBytes)) return null; + const totalRamMB = Math.floor(memBytes / 1024 / 1024); + // macOS does not use traditional swap files in the same way + return { totalRamMB, totalSwapMB: 0, totalMB: totalRamMB }; + } catch { + return null; + } + } + + return null; +} + +// ── Swap management (Linux only) ───────────────────────────────── + +function hasSwapfile(): boolean { + try { + fs.accessSync("/swapfile"); + return true; + } catch { + return false; + } +} + +function getExistingSwapResult(mem: MemoryInfo): SwapResult | null { + if (!hasSwapfile()) { + return null; + } + + const swaps = (() => { + try { + return fs.readFileSync("/proc/swaps", "utf-8"); + } catch { + return ""; + } + })(); + + if (swaps.includes("/swapfile")) { + return { + ok: true, + totalMB: mem.totalMB, + swapCreated: false, + reason: "/swapfile already exists", + }; + } + + try { + runCapture("sudo swapon /swapfile", { ignoreError: false }); + return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + reason: `found orphaned /swapfile but could not activate it: ${message}`, + }; + } +} + +function checkSwapDiskSpace(): SwapResult | null { + try { + const dfOut = runCapture("df / --output=avail -k 2>/dev/null | tail -1", { + ignoreError: true, + }); + const freeKB = parseInt((dfOut || "").trim(), 10); + if (!isNaN(freeKB) && freeKB < 5000000) { + return { + ok: false, + reason: `insufficient disk space (${Math.floor(freeKB / 1024)} MB free, need ~5 GB) to create swap file`, + }; + } + } catch { + // df unavailable — let dd fail naturally if out of space + } + + return null; +} + +function writeManagedSwapMarker(): void { + const nemoclawDir = path.join(os.homedir(), ".nemoclaw"); + if (!fs.existsSync(nemoclawDir)) { + runCapture(`mkdir -p ${nemoclawDir}`, { ignoreError: true }); + } + + try { + fs.writeFileSync(path.join(nemoclawDir, "managed_swap"), "/swapfile"); + } catch { + // Best effort marker write. + } +} + +function cleanupPartialSwap(): void { + try { + runCapture("sudo swapoff /swapfile 2>/dev/null || true", { ignoreError: true }); + runCapture("sudo rm -f /swapfile", { ignoreError: true }); + } catch { + // Best effort cleanup + } +} + +function createSwapfile(mem: MemoryInfo): SwapResult { + try { + runCapture("sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none", { + ignoreError: false, + }); + runCapture("sudo chmod 600 /swapfile", { ignoreError: false }); + runCapture("sudo mkswap /swapfile", { ignoreError: false }); + runCapture("sudo swapon /swapfile", { ignoreError: false }); + runCapture( + "grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab", + { ignoreError: false }, + ); + writeManagedSwapMarker(); + + return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; + } catch (err: unknown) { + cleanupPartialSwap(); + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + reason: + `swap creation failed: ${message}. Create swap manually:\n` + + " sudo dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none && sudo chmod 600 /swapfile && " + + "sudo mkswap /swapfile && sudo swapon /swapfile", + }; + } +} + +/** + * Ensure the system has enough memory (RAM + swap) for sandbox operations. + * + * If total memory is below minTotalMB and no swap file exists, attempts to + * create a 4 GB swap file via sudo to prevent OOM kills during sandbox + * image push. + */ +export function ensureSwap(minTotalMB?: number, opts: EnsureSwapOpts = {}): SwapResult { + const o = { + platform: process.platform as NodeJS.Platform, + memoryInfo: null as MemoryInfo | null, + swapfileExists: fs.existsSync("/swapfile"), + dryRun: false, + interactive: process.stdout.isTTY && !process.env.NEMOCLAW_NON_INTERACTIVE, + getMemoryInfoImpl: getMemoryInfo, + ...opts, + }; + const threshold = minTotalMB ?? 12000; + + if (o.platform !== "linux") { + return { ok: true, totalMB: 0, swapCreated: false }; + } + + const mem = o.memoryInfo ?? o.getMemoryInfoImpl({ platform: o.platform }); + if (!mem) { + return { ok: false, reason: "could not read memory info" }; + } + + if (mem.totalMB >= threshold) { + return { ok: true, totalMB: mem.totalMB, swapCreated: false }; + } + + if (o.dryRun) { + if (o.swapfileExists) { + return { + ok: true, + totalMB: mem.totalMB, + swapCreated: false, + reason: "/swapfile already exists", + }; + } + return { ok: true, totalMB: mem.totalMB, swapCreated: true }; + } + + const existingSwapResult = getExistingSwapResult(mem); + if (existingSwapResult) { + return existingSwapResult; + } + + const diskSpaceResult = checkSwapDiskSpace(); + if (diskSpaceResult) { + return diskSpaceResult; + } + + return createSwapfile(mem); +} diff --git a/src/lib/url-utils.test.ts b/src/lib/url-utils.test.ts index 8bc2c73cf..69def4b84 100644 --- a/src/lib/url-utils.test.ts +++ b/src/lib/url-utils.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. import { compactText, stripEndpointSuffix, @@ -9,7 +10,7 @@ import { isLoopbackHostname, formatEnvAssignment, parsePolicyPresetEnv, -} from "./url-utils"; +} from "../../dist/lib/url-utils"; describe("compactText", () => { it("collapses whitespace", () => { diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts index 5eab7981b..eeceeffe2 100644 --- a/src/lib/validation.test.ts +++ b/src/lib/validation.test.ts @@ -2,13 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. import { classifyValidationFailure, classifyApplyFailure, classifySandboxCreateFailure, validateNvidiaApiKeyValue, isSafeModelId, -} from "./validation"; +} from "../../dist/lib/validation"; describe("classifyValidationFailure", () => { it("classifies curl failures as transport", () => { diff --git a/test/preflight.test.js b/test/preflight.test.js deleted file mode 100644 index 5f9a738d8..000000000 --- a/test/preflight.test.js +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { assert, describe, expect, it } from "vitest"; - -import { checkPortAvailable } from "../bin/lib/preflight"; - -describe("checkPortAvailable", () => { - it("falls through to the probe when lsof output is empty", async () => { - let probedPort = null; - const result = await checkPortAvailable(18789, { - lsofOutput: "", - probeImpl: async (port) => { - probedPort = port; - return { ok: true }; - }, - }); - - expect(probedPort).toBe(18789); - expect(result).toEqual({ ok: true }); - }); - - it("probe catches occupied port even when lsof returns empty", async () => { - const result = await checkPortAvailable(18789, { - lsofOutput: "", - probeImpl: async () => ({ - ok: false, - process: "unknown", - pid: null, - reason: "port 18789 is in use (EADDRINUSE)", - }), - }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason).toContain("EADDRINUSE"); - }); - - it("parses process and PID from lsof output", async () => { - const lsofOutput = [ - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", - "openclaw 12345 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", - ].join("\n"); - const result = await checkPortAvailable(18789, { lsofOutput }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("openclaw"); - expect(result.pid).toBe(12345); - expect(result.reason).toContain("openclaw"); - }); - - it("picks first listener when lsof shows multiple", async () => { - const lsofOutput = [ - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME", - "gateway 111 root 7u IPv4 54321 0t0 TCP *:18789 (LISTEN)", - "node 222 root 8u IPv4 54322 0t0 TCP *:18789 (LISTEN)", - ].join("\n"); - const result = await checkPortAvailable(18789, { lsofOutput }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("gateway"); - expect(result.pid).toBe(111); - }); - - it("returns ok for a free port probe", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ ok: true }), - }); - - expect(result).toEqual({ ok: true }); - }); - - it("returns occupied for EADDRINUSE probe results", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ - ok: false, - process: "unknown", - pid: null, - reason: "port 8080 is in use (EADDRINUSE)", - }), - }); - - expect(result.ok).toBe(false); - expect(result.process).toBe("unknown"); - expect(result.reason).toContain("EADDRINUSE"); - }); - - it("treats restricted probe environments as inconclusive instead of occupied", async () => { - const result = await checkPortAvailable(8080, { - skipLsof: true, - probeImpl: async () => ({ - ok: true, - warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", - }), - }); - - expect(result.ok).toBe(true); - expect(result.warning).toContain("EPERM"); - }); - - it("defaults to port 18789 when no port is given", async () => { - let probedPort = null; - const result = await checkPortAvailable(undefined, { - skipLsof: true, - probeImpl: async (port) => { - probedPort = port; - return { ok: true }; - }, - }); - - expect(probedPort).toBe(18789); - expect(result.ok).toBe(true); - }); -}); - -describe("getMemoryInfo", () => { - const { getMemoryInfo } = require("../bin/lib/preflight"); - - it("parses valid /proc/meminfo content", () => { - const meminfoContent = [ - "MemTotal: 8152056 kB", - "MemFree: 1234567 kB", - "MemAvailable: 4567890 kB", - "SwapTotal: 4194300 kB", - "SwapFree: 4194300 kB", - ].join("\n"); - - const result = getMemoryInfo({ meminfoContent, platform: "linux" }); - assert.equal(result.totalRamMB, Math.floor(8152056 / 1024)); - assert.equal(result.totalSwapMB, Math.floor(4194300 / 1024)); - assert.equal(result.totalMB, result.totalRamMB + result.totalSwapMB); - }); - - it("returns correct values when swap is zero", () => { - const meminfoContent = [ - "MemTotal: 8152056 kB", - "MemFree: 1234567 kB", - "SwapTotal: 0 kB", - "SwapFree: 0 kB", - ].join("\n"); - - const result = getMemoryInfo({ meminfoContent, platform: "linux" }); - assert.equal(result.totalRamMB, Math.floor(8152056 / 1024)); - assert.equal(result.totalSwapMB, 0); - assert.equal(result.totalMB, result.totalRamMB); - }); - - it("returns null on unsupported platforms", () => { - const result = getMemoryInfo({ platform: "win32" }); - assert.equal(result, null); - }); - - it("handles malformed /proc/meminfo gracefully", () => { - const result = getMemoryInfo({ - meminfoContent: "garbage data\nno fields here", - platform: "linux", - }); - assert.equal(result.totalRamMB, 0); - assert.equal(result.totalSwapMB, 0); - assert.equal(result.totalMB, 0); - }); -}); - -describe("ensureSwap", () => { - const { ensureSwap } = require("../bin/lib/preflight"); - - it("returns ok when total memory already exceeds threshold", () => { - const result = ensureSwap(6144, { - platform: "linux", - memoryInfo: { totalRamMB: 8000, totalSwapMB: 0, totalMB: 8000 }, - }); - assert.equal(result.ok, true); - assert.equal(result.swapCreated, false); - assert.equal(result.totalMB, 8000); - }); - - it("reports swap would be created in dry-run mode when below threshold", () => { - const result = ensureSwap(6144, { - platform: "linux", - memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, - dryRun: true, - swapfileExists: false, - }); - assert.equal(result.ok, true); - assert.equal(result.swapCreated, true); - }); - - it("skips swap creation when /swapfile already exists (dry-run)", () => { - const result = ensureSwap(6144, { - platform: "linux", - memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, - dryRun: true, - swapfileExists: true, - }); - assert.equal(result.ok, true); - assert.equal(result.swapCreated, false); - assert.match(result.reason, /swapfile already exists/); - }); - - it("skips on non-Linux platforms", () => { - const result = ensureSwap(6144, { - platform: "darwin", - memoryInfo: { totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }, - }); - assert.equal(result.ok, true); - assert.equal(result.swapCreated, false); - }); - - it("returns error when memory info is unavailable", () => { - const result = ensureSwap(6144, { - platform: "linux", - memoryInfo: null, - getMemoryInfoImpl: () => null, - }); - assert.equal(result.ok, false); - assert.match(result.reason, /could not read memory info/); - }); -});