diff --git a/.github/actions/basic-checks/action.yaml b/.github/actions/basic-checks/action.yaml index 79fcf2602..efe15f965 100644 --- a/.github/actions/basic-checks/action.yaml +++ b/.github/actions/basic-checks/action.yaml @@ -34,6 +34,10 @@ runs: shell: bash run: cd nemoclaw && npm run build + - name: Build CLI TypeScript modules + shell: bash + run: npm run build:cli + - name: Run checks shell: bash run: npx prek run --all-files --stage pre-push diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b2ee25e5..850ebd272 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -196,7 +196,7 @@ repos: - id: tsc-js name: TypeScript (JS config) - entry: npx tsc -p jsconfig.json + entry: bash -c 'npm run build:cli && npx tsc -p jsconfig.json' language: system pass_filenames: false files: ^(bin|test|scripts)/.*\.js$ @@ -227,10 +227,10 @@ repos: hooks: - id: test-cli name: Test (CLI) - entry: bash -c 'npx vitest run --project cli --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/cli --coverage.include="bin/**/*.js" --coverage.exclude="test/**/*.js" --coverage.exclude="test/**/*.ts" && npx tsx scripts/check-coverage-ratchet.ts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json "CLI coverage"' + entry: bash -c 'npm run build:cli && npx vitest run --project cli --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/cli --coverage.include="bin/**/*.js" --coverage.include="dist/lib/**/*.js" --coverage.exclude="test/**/*.js" --coverage.exclude="test/**/*.ts" && npx tsx scripts/check-coverage-ratchet.ts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json "CLI coverage"' language: system pass_filenames: false - files: ^(bin/|test/) + files: ^(bin/|src/|test/) priority: 20 - id: test-plugin diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index e6e2c0925..cc853fc10 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -55,6 +55,13 @@ const onboardSession = require("./onboard-session"); const policies = require("./policies"); const { checkPortAvailable, ensureSwap, getMemoryInfo } = require("./preflight"); +// Typed modules (compiled from src/lib/*.ts → dist/lib/*.js) +const gatewayState = require("../../dist/lib/gateway-state"); +const validation = require("../../dist/lib/validation"); +const urlUtils = require("../../dist/lib/url-utils"); +const buildContext = require("../../dist/lib/build-context"); +const dashboard = require("../../dist/lib/dashboard"); + /** * Create a temp file inside a directory with a cryptographically random name. * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that @@ -197,98 +204,15 @@ async function promptOrDefault(question, envVar, defaultValue) { // ── Helpers ────────────────────────────────────────────────────── -/** - * Check if a sandbox is in Ready state from `openshell sandbox list` output. - * Strips ANSI codes and exact-matches the sandbox name in the first column. - */ -function isSandboxReady(output, sandboxName) { - // eslint-disable-next-line no-control-regex - const clean = output.replace(/\x1b\[[0-9;]*m/g, ""); - return clean.split("\n").some((l) => { - const cols = l.trim().split(/\s+/); - return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); - }); -} - -/** - * Determine whether stale NemoClaw gateway output indicates a previous - * session that should be cleaned up before the port preflight check. - * @param {string} gwInfoOutput - Raw output from `openshell gateway info -g nemoclaw`. - * @returns {boolean} - */ -function hasStaleGateway(gwInfoOutput) { - const cleanOutput = - typeof gwInfoOutput === "string" - ? // eslint-disable-next-line no-control-regex - gwInfoOutput.replace(/\x1b\[[0-9;]*m/g, "") - : ""; - return ( - cleanOutput.length > 0 && - cleanOutput.includes(`Gateway: ${GATEWAY_NAME}`) && - !cleanOutput.includes("No gateway metadata found") - ); -} - -function getReportedGatewayName(output = "") { - if (typeof output !== "string") return null; - // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ""); - const match = cleanOutput.match(/^\s*Gateway:\s+([^\s]+)/m); - return match ? match[1] : null; -} - -function isGatewayConnected(statusOutput = "") { - return typeof statusOutput === "string" && statusOutput.includes("Connected"); -} - -function hasActiveGatewayInfo(activeGatewayInfoOutput = "") { - return ( - typeof activeGatewayInfoOutput === "string" && - activeGatewayInfoOutput.includes("Gateway endpoint:") && - !activeGatewayInfoOutput.includes("No gateway metadata found") - ); -} - -function isSelectedGateway(statusOutput = "", gatewayName = GATEWAY_NAME) { - return getReportedGatewayName(statusOutput) === gatewayName; -} - -function isGatewayHealthy(statusOutput = "", gwInfoOutput = "", activeGatewayInfoOutput = "") { - const namedGatewayKnown = hasStaleGateway(gwInfoOutput); - if (!namedGatewayKnown || !isGatewayConnected(statusOutput)) return false; - - const activeGatewayName = - getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); - return activeGatewayName === GATEWAY_NAME; -} - -function getGatewayReuseState(statusOutput = "", gwInfoOutput = "", activeGatewayInfoOutput = "") { - if (isGatewayHealthy(statusOutput, gwInfoOutput, activeGatewayInfoOutput)) { - return "healthy"; - } - const connected = isGatewayConnected(statusOutput); - const activeGatewayName = - getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); - if (connected && activeGatewayName === GATEWAY_NAME) { - return "active-unnamed"; - } - if (connected && activeGatewayName && activeGatewayName !== GATEWAY_NAME) { - return "foreign-active"; - } - if (hasStaleGateway(gwInfoOutput)) { - return "stale"; - } - if (hasActiveGatewayInfo(activeGatewayInfoOutput)) { - return "active-unnamed"; - } - return "missing"; -} - -function getSandboxStateFromOutputs(sandboxName, getOutput = "", listOutput = "") { - if (!sandboxName) return "missing"; - if (!getOutput) return "missing"; - return isSandboxReady(listOutput, sandboxName) ? "ready" : "not_ready"; -} +// Gateway state functions — delegated to src/lib/gateway-state.ts +const { + isSandboxReady, + hasStaleGateway, + isSelectedGateway, + isGatewayHealthy, + getGatewayReuseState, + getSandboxStateFromOutputs, +} = gatewayState; function getSandboxReuseState(sandboxName) { if (!sandboxName) return "missing"; @@ -543,9 +467,14 @@ function runCaptureOpenshell(args, opts = {}) { return runCapture(openshellShellCommand(args), opts); } -function formatEnvAssignment(name, value) { - return `${name}=${value}`; -} +// URL/string utilities — delegated to src/lib/url-utils.ts +const { + compactText, + normalizeProviderBaseUrl, + isLoopbackHostname, + formatEnvAssignment, + parsePolicyPresetEnv, +} = urlUtils; function hydrateCredentialEnv(envName) { if (!envName) return null; @@ -560,41 +489,6 @@ function getCurlTimingArgs() { return ["--connect-timeout", "10", "--max-time", "60"]; } -function compactText(value = "") { - return String(value).replace(/\s+/g, " ").trim(); -} - -function stripEndpointSuffix(pathname = "", suffixes = []) { - for (const suffix of suffixes) { - if (pathname === suffix) return ""; - if (pathname.endsWith(suffix)) { - return pathname.slice(0, -suffix.length); - } - } - return pathname; -} - -function normalizeProviderBaseUrl(value, flavor) { - const raw = String(value || "").trim(); - if (!raw) return ""; - - try { - const url = new URL(raw); - url.search = ""; - url.hash = ""; - const suffixes = - flavor === "anthropic" - ? ["/v1/messages", "/v1/models", "/v1", "/messages", "/models"] - : ["/responses", "/chat/completions", "/completions", "/models"]; - let pathname = stripEndpointSuffix(url.pathname.replace(/\/+$/, ""), suffixes); - pathname = pathname.replace(/\/+$/, ""); - url.pathname = pathname || "/"; - return url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`; - } catch { - return raw.replace(/[?#].*$/, "").replace(/\/+$/, ""); - } -} - function summarizeCurlFailure(curlStatus = 0, stderr = "", body = "") { const detail = compactText(stderr || body); return detail @@ -652,35 +546,14 @@ function getTransportRecoveryMessage(failure = {}) { return " Validation hit a network or transport error."; } -function classifyValidationFailure({ httpStatus = 0, curlStatus = 0, message = "" } = {}) { - const normalized = compactText(message).toLowerCase(); - if (curlStatus) { - return { kind: "transport", retry: "retry" }; - } - if (httpStatus === 429 || (httpStatus >= 500 && httpStatus < 600)) { - return { kind: "transport", retry: "retry" }; - } - if (httpStatus === 401 || httpStatus === 403) { - return { kind: "credential", retry: "credential" }; - } - if (httpStatus === 400) { - return { kind: "model", retry: "model" }; - } - if (/model.+not found|unknown model|unsupported model|bad model/i.test(normalized)) { - return { kind: "model", retry: "model" }; - } - if (httpStatus === 404 || httpStatus === 405) { - return { kind: "endpoint", retry: "selection" }; - } - if (/unauthorized|forbidden|invalid api key|invalid_auth|permission/i.test(normalized)) { - return { kind: "credential", retry: "credential" }; - } - return { kind: "unknown", retry: "selection" }; -} - -function classifyApplyFailure(message = "") { - return classifyValidationFailure({ message }); -} +// Validation functions — delegated to src/lib/validation.ts +const { + classifyValidationFailure, + classifyApplyFailure, + classifySandboxCreateFailure, + validateNvidiaApiKeyValue, + isSafeModelId, +} = validation; function getProbeRecovery(probe, options = {}) { const allowModelRetry = options.allowModelRetry === true; @@ -772,15 +645,7 @@ function runCurlProbe(argv) { } } -function validateNvidiaApiKeyValue(key) { - if (!key) { - return " NVIDIA API Key is required."; - } - if (!key.startsWith("nvapi-")) { - return " Invalid key. Must start with nvapi-"; - } - return null; -} +// validateNvidiaApiKeyValue — see validation import above async function replaceNamedCredential(envName, label, helpUrl = null, validator = null) { if (helpUrl) { @@ -1450,102 +1315,10 @@ async function promptManualModelId(promptLabel, errorLabel, validator = null) { return trimmed; } } -function shouldIncludeBuildContextPath(sourceRoot, candidatePath) { - const relative = path.relative(sourceRoot, candidatePath); - if (!relative || relative === "") return true; - - const segments = relative.split(path.sep); - const basename = path.basename(candidatePath); - const excludedSegments = new Set([ - ".venv", - ".ruff_cache", - ".pytest_cache", - ".mypy_cache", - "__pycache__", - "node_modules", - ".git", - ]); - - if (basename === ".DS_Store" || basename.startsWith("._")) { - return false; - } - - return !segments.some((segment) => excludedSegments.has(segment)); -} - -function copyBuildContextDir(sourceDir, destinationDir) { - fs.cpSync(sourceDir, destinationDir, { - recursive: true, - filter: (candidatePath) => shouldIncludeBuildContextPath(sourceDir, candidatePath), - }); -} - -function classifySandboxCreateFailure(output = "") { - const text = String(output || ""); - const uploadedToGateway = - /\[progress\]\s+Uploaded to gateway/i.test(text) || - /Image .*available in the gateway/i.test(text); - - if (/failed to read image export stream|Timeout error/i.test(text)) { - return { - kind: "image_transfer_timeout", - uploadedToGateway, - }; - } - - if (/Connection reset by peer/i.test(text)) { - return { - kind: "image_transfer_reset", - uploadedToGateway, - }; - } - - if (/Created sandbox:/i.test(text)) { - return { - kind: "sandbox_create_incomplete", - uploadedToGateway: true, - }; - } - - return { - kind: "unknown", - uploadedToGateway, - }; -} - -function printSandboxCreateRecoveryHints(output = "") { - const failure = classifySandboxCreateFailure(output); - if (failure.kind === "image_transfer_timeout") { - console.error(" Hint: image upload into the OpenShell gateway timed out."); - console.error(" Recovery: nemoclaw onboard --resume"); - if (failure.uploadedToGateway) { - console.error( - " Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state.", - ); - } - console.error(" If this repeats, check Docker memory and retry on a host with more RAM."); - return; - } - if (failure.kind === "image_transfer_reset") { - console.error(" Hint: the image push/import stream was interrupted."); - console.error(" Recovery: nemoclaw onboard --resume"); - if (failure.uploadedToGateway) { - console.error(" The image appears to have reached the gateway before the stream failed."); - } - console.error(" If this repeats, restart Docker or the gateway and retry."); - return; - } - if (failure.kind === "sandbox_create_incomplete") { - console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); - console.error(" Recovery: nemoclaw onboard --resume"); - console.error( - " Check: openshell sandbox list # verify whether the sandbox became ready", - ); - return; - } - console.error(" Recovery: nemoclaw onboard --resume"); - console.error(" Or: nemoclaw onboard"); -} +// Build context helpers — delegated to src/lib/build-context.ts +const { shouldIncludeBuildContextPath, copyBuildContextDir, printSandboxCreateRecoveryHints } = + buildContext; +// classifySandboxCreateFailure — see validation import above async function promptCloudModel() { console.log(""); @@ -1892,16 +1665,8 @@ function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { return false; } -function parsePolicyPresetEnv(value) { - return (value || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -function isSafeModelId(value) { - return /^[A-Za-z0-9._:/-]+$/.test(value); -} +// parsePolicyPresetEnv — see urlUtils import above +// isSafeModelId — see validation import above function getNonInteractiveProvider() { const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); @@ -3526,32 +3291,10 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { // ── Dashboard ──────────────────────────────────────────────────── const CONTROL_UI_PORT = 18789; -const CONTROL_UI_PATH = "/"; - -function isLoopbackHostname(hostname = "") { - const normalized = String(hostname || "") - .trim() - .toLowerCase() - .replace(/^\[|\]$/g, ""); - return ( - normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) - ); -} -function resolveDashboardForwardTarget(chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { - const raw = String(chatUiUrl || "").trim(); - if (!raw) return String(CONTROL_UI_PORT); - try { - const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); - return isLoopbackHostname(parsed.hostname) - ? String(CONTROL_UI_PORT) - : `0.0.0.0:${CONTROL_UI_PORT}`; - } catch { - return /localhost|::1|127(?:\.\d{1,3}){3}/i.test(raw) - ? String(CONTROL_UI_PORT) - : `0.0.0.0:${CONTROL_UI_PORT}`; - } -} +// Dashboard helpers — delegated to src/lib/dashboard.ts +// isLoopbackHostname — see urlUtils import above +const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); @@ -3605,16 +3348,7 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { } } -function buildControlUiUrls(token) { - const hash = token ? `#token=${token}` : ""; - const baseUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`; - const urls = [`${baseUrl}${CONTROL_UI_PATH}${hash}`]; - const chatUi = (process.env.CHAT_UI_URL || "").trim().replace(/\/$/, ""); - if (chatUi && /^https?:\/\//i.test(chatUi) && chatUi !== baseUrl) { - urls.push(`${chatUi}${CONTROL_UI_PATH}${hash}`); - } - return [...new Set(urls)]; -} +// buildControlUiUrls — see dashboard import above function printDashboard(sandboxName, model, provider, nimContainer = null) { const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); 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/package.json b/package.json index 7b4658e47..60e1ce9a3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format": "prettier --write 'bin/**/*.js' 'test/**/*.js'", "format:check": "prettier --check 'bin/**/*.js' 'test/**/*.js'", "typecheck": "tsc -p jsconfig.json", + "build:cli": "tsc -p tsconfig.src.json", "typecheck:cli": "tsc -p tsconfig.cli.json", "prepare": "npm install --omit=dev --ignore-scripts 2>/dev/null || true && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts new file mode 100644 index 000000000..2407f2ebb --- /dev/null +++ b/src/lib/build-context.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Helpers for staging a Docker build context and classifying sandbox + * creation failures. + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { classifySandboxCreateFailure } from "./validation"; + +const EXCLUDED_SEGMENTS = new Set([ + ".venv", + ".ruff_cache", + ".pytest_cache", + ".mypy_cache", + "__pycache__", + "node_modules", + ".git", +]); + +export function shouldIncludeBuildContextPath(sourceRoot: string, candidatePath: string): boolean { + const relative = path.relative(sourceRoot, candidatePath); + if (!relative || relative === "") return true; + + const segments = relative.split(path.sep); + const basename = path.basename(candidatePath); + + if (basename === ".DS_Store" || basename.startsWith("._")) { + return false; + } + + return !segments.some((segment) => EXCLUDED_SEGMENTS.has(segment)); +} + +export function copyBuildContextDir(sourceDir: string, destinationDir: string): void { + fs.cpSync(sourceDir, destinationDir, { + recursive: true, + filter: (candidatePath) => shouldIncludeBuildContextPath(sourceDir, candidatePath), + }); +} + +export function printSandboxCreateRecoveryHints(output = ""): void { + const failure = classifySandboxCreateFailure(output); + if (failure.kind === "image_transfer_timeout") { + console.error(" Hint: image upload into the OpenShell gateway timed out."); + console.error(" Recovery: nemoclaw onboard --resume"); + if (failure.uploadedToGateway) { + console.error( + " Progress reached the gateway upload stage, so resume may be able to reuse existing gateway state.", + ); + } + console.error(" If this repeats, check Docker memory and retry on a host with more RAM."); + return; + } + if (failure.kind === "image_transfer_reset") { + console.error(" Hint: the image push/import stream was interrupted."); + console.error(" Recovery: nemoclaw onboard --resume"); + if (failure.uploadedToGateway) { + console.error(" The image appears to have reached the gateway before the stream failed."); + } + console.error(" If this repeats, restart Docker or the gateway and retry."); + return; + } + if (failure.kind === "sandbox_create_incomplete") { + console.error(" Hint: sandbox creation started but the create stream did not finish cleanly."); + console.error(" Recovery: nemoclaw onboard --resume"); + console.error( + " Check: openshell sandbox list # verify whether the sandbox became ready", + ); + return; + } + console.error(" Recovery: nemoclaw onboard --resume"); + console.error(" Or: nemoclaw onboard"); +} diff --git a/src/lib/dashboard.test.ts b/src/lib/dashboard.test.ts new file mode 100644 index 000000000..8f812b3b6 --- /dev/null +++ b/src/lib/dashboard.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +// 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", () => { + expect(resolveDashboardForwardTarget("http://127.0.0.1:18789")).toBe("18789"); + }); + + it("returns port-only for localhost hostname", () => { + expect(resolveDashboardForwardTarget("http://localhost:18789")).toBe("18789"); + }); + + it("binds to 0.0.0.0 for non-loopback URL", () => { + expect(resolveDashboardForwardTarget("http://my-server.example.com:18789")).toBe( + "0.0.0.0:18789", + ); + }); + + it("returns port-only for empty input", () => { + expect(resolveDashboardForwardTarget("")).toBe("18789"); + }); + + it("returns port-only for default", () => { + expect(resolveDashboardForwardTarget()).toBe("18789"); + }); + + it("handles URL without scheme", () => { + expect(resolveDashboardForwardTarget("remote-host:18789")).toBe("0.0.0.0:18789"); + }); + + 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 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"); + }); +}); + +describe("buildControlUiUrls", () => { + const originalEnv = process.env.CHAT_UI_URL; + + beforeEach(() => { + delete process.env.CHAT_UI_URL; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CHAT_UI_URL = originalEnv; + } else { + delete process.env.CHAT_UI_URL; + } + }); + + it("builds URL with token hash", () => { + const urls = buildControlUiUrls("my-token"); + expect(urls).toEqual(["http://127.0.0.1:18789/#token=my-token"]); + }); + + it("builds URL without token", () => { + const urls = buildControlUiUrls(null); + expect(urls).toEqual(["http://127.0.0.1:18789/"]); + }); + + it("includes CHAT_UI_URL when set", () => { + process.env.CHAT_UI_URL = "https://my-dashboard.example.com"; + const urls = buildControlUiUrls("tok"); + expect(urls).toHaveLength(2); + expect(urls[1]).toBe("https://my-dashboard.example.com/#token=tok"); + }); + + it("deduplicates when CHAT_UI_URL matches local", () => { + process.env.CHAT_UI_URL = "http://127.0.0.1:18789"; + 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/dashboard.ts b/src/lib/dashboard.ts new file mode 100644 index 000000000..45af5c59c --- /dev/null +++ b/src/lib/dashboard.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Dashboard URL resolution and construction. + */ + +import { isLoopbackHostname } from "./url-utils"; + +const CONTROL_UI_PORT = 18789; +const CONTROL_UI_PATH = "/"; + +export function resolveDashboardForwardTarget( + chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`, +): string { + const raw = String(chatUiUrl || "").trim(); + if (!raw) return String(CONTROL_UI_PORT); + try { + const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `http://${raw}`); + return isLoopbackHostname(parsed.hostname) + ? String(CONTROL_UI_PORT) + : `0.0.0.0:${CONTROL_UI_PORT}`; + } catch { + return /localhost|::1|127(?:\.\d{1,3}){3}/i.test(raw) + ? String(CONTROL_UI_PORT) + : `0.0.0.0:${CONTROL_UI_PORT}`; + } +} + +export function buildControlUiUrls(token: string | null = null): string[] { + const hash = token ? `#token=${token}` : ""; + const baseUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`; + const urls = [`${baseUrl}${CONTROL_UI_PATH}${hash}`]; + const chatUi = (process.env.CHAT_UI_URL || "").trim().replace(/\/$/, ""); + if (chatUi && /^https?:\/\//i.test(chatUi) && chatUi !== baseUrl) { + urls.push(`${chatUi}${CONTROL_UI_PATH}${hash}`); + } + return [...new Set(urls)]; +} diff --git a/src/lib/gateway-state.ts b/src/lib/gateway-state.ts new file mode 100644 index 000000000..1b78a02d2 --- /dev/null +++ b/src/lib/gateway-state.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure classifiers for OpenShell gateway and sandbox state. + * + * Every function here takes string output from openshell CLI commands and + * returns a typed result — no I/O, no side effects. + */ + +const GATEWAY_NAME = "nemoclaw"; + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function stripAnsi(value: string): string { + return value.replace(ANSI_RE, ""); +} + +export type GatewayReuseState = + | "healthy" + | "active-unnamed" + | "foreign-active" + | "stale" + | "missing"; + +export type SandboxState = "ready" | "not_ready" | "missing"; + +/** + * Check if a sandbox is in Ready state from `openshell sandbox list` output. + * Strips ANSI codes and exact-matches the sandbox name in the first column. + */ +export function isSandboxReady(output: string, sandboxName: string): boolean { + const clean = stripAnsi(output); + return clean.split("\n").some((l) => { + const cols = l.trim().split(/\s+/); + return cols[0] === sandboxName && cols.includes("Ready") && !cols.includes("NotReady"); + }); +} + +/** + * Determine whether stale NemoClaw gateway output indicates a previous + * session that should be cleaned up before the port preflight check. + */ +export function hasStaleGateway(gwInfoOutput: string): boolean { + const clean = typeof gwInfoOutput === "string" ? stripAnsi(gwInfoOutput) : ""; + return ( + clean.length > 0 && + clean.includes(`Gateway: ${GATEWAY_NAME}`) && + !clean.includes("No gateway metadata found") + ); +} + +export function getReportedGatewayName(output = ""): string | null { + if (typeof output !== "string") return null; + const clean = stripAnsi(output); + const match = clean.match(/^\s*Gateway:\s+([^\s]+)/m); + return match ? match[1] : null; +} + +export function isGatewayConnected(statusOutput = ""): boolean { + return typeof statusOutput === "string" && statusOutput.includes("Connected"); +} + +export function hasActiveGatewayInfo(activeGatewayInfoOutput = ""): boolean { + return ( + typeof activeGatewayInfoOutput === "string" && + activeGatewayInfoOutput.includes("Gateway endpoint:") && + !activeGatewayInfoOutput.includes("No gateway metadata found") + ); +} + +export function isSelectedGateway(statusOutput = "", gatewayName = GATEWAY_NAME): boolean { + return getReportedGatewayName(statusOutput) === gatewayName; +} + +export function isGatewayHealthy( + statusOutput = "", + gwInfoOutput = "", + activeGatewayInfoOutput = "", +): boolean { + const namedGatewayKnown = hasStaleGateway(gwInfoOutput); + if (!namedGatewayKnown || !isGatewayConnected(statusOutput)) return false; + + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + return activeGatewayName === GATEWAY_NAME; +} + +export function getGatewayReuseState( + statusOutput = "", + gwInfoOutput = "", + activeGatewayInfoOutput = "", +): GatewayReuseState { + if (isGatewayHealthy(statusOutput, gwInfoOutput, activeGatewayInfoOutput)) { + return "healthy"; + } + const connected = isGatewayConnected(statusOutput); + const activeGatewayName = + getReportedGatewayName(statusOutput) || getReportedGatewayName(activeGatewayInfoOutput); + if (connected && activeGatewayName === GATEWAY_NAME) { + return "active-unnamed"; + } + if (connected && activeGatewayName && activeGatewayName !== GATEWAY_NAME) { + return "foreign-active"; + } + if (hasStaleGateway(gwInfoOutput)) { + return "stale"; + } + if (hasActiveGatewayInfo(activeGatewayInfoOutput)) { + return "active-unnamed"; + } + return "missing"; +} + +export function getSandboxStateFromOutputs( + sandboxName: string, + getOutput = "", + listOutput = "", +): SandboxState { + if (!sandboxName) return "missing"; + if (!getOutput) return "missing"; + return isSandboxReady(listOutput, sandboxName) ? "ready" : "not_ready"; +} 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 new file mode 100644 index 000000000..69def4b84 --- /dev/null +++ b/src/lib/url-utils.test.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +// Import from compiled dist/ so coverage is attributed correctly. +import { + compactText, + stripEndpointSuffix, + normalizeProviderBaseUrl, + isLoopbackHostname, + formatEnvAssignment, + parsePolicyPresetEnv, +} from "../../dist/lib/url-utils"; + +describe("compactText", () => { + it("collapses whitespace", () => { + expect(compactText(" hello world ")).toBe("hello world"); + }); + + it("handles empty string", () => { + expect(compactText("")).toBe(""); + }); +}); + +describe("stripEndpointSuffix", () => { + it("strips matching suffix", () => { + expect(stripEndpointSuffix("/v1/chat/completions", ["/chat/completions"])).toBe("/v1"); + }); + + it("returns empty for exact match", () => { + expect(stripEndpointSuffix("/v1", ["/v1"])).toBe(""); + }); + + it("returns pathname when no suffix matches", () => { + expect(stripEndpointSuffix("/api/foo", ["/v1"])).toBe("/api/foo"); + }); +}); + +describe("normalizeProviderBaseUrl", () => { + it("strips OpenAI suffixes", () => { + expect(normalizeProviderBaseUrl("https://api.openai.com/v1/chat/completions", "openai")).toBe( + "https://api.openai.com/v1", + ); + }); + + it("strips Anthropic suffixes", () => { + expect(normalizeProviderBaseUrl("https://api.anthropic.com/v1/messages", "anthropic")).toBe( + "https://api.anthropic.com", + ); + }); + + it("strips trailing slashes", () => { + expect(normalizeProviderBaseUrl("https://example.com/v1/", "openai")).toBe( + "https://example.com/v1", + ); + }); + + it("returns origin for root path", () => { + expect(normalizeProviderBaseUrl("https://example.com/", "openai")).toBe( + "https://example.com", + ); + }); + + it("handles empty input", () => { + expect(normalizeProviderBaseUrl("", "openai")).toBe(""); + }); + + it("handles invalid URL gracefully", () => { + expect(normalizeProviderBaseUrl("not-a-url", "openai")).toBe("not-a-url"); + }); +}); + +describe("isLoopbackHostname", () => { + it("matches localhost", () => { + expect(isLoopbackHostname("localhost")).toBe(true); + }); + + it("matches 127.0.0.1", () => { + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + }); + + it("matches ::1", () => { + expect(isLoopbackHostname("::1")).toBe(true); + }); + + it("matches bracketed IPv6", () => { + expect(isLoopbackHostname("[::1]")).toBe(true); + }); + + it("rejects external hostname", () => { + expect(isLoopbackHostname("example.com")).toBe(false); + }); + + it("handles empty input", () => { + expect(isLoopbackHostname("")).toBe(false); + }); +}); + +describe("formatEnvAssignment", () => { + it("formats name=value", () => { + expect(formatEnvAssignment("FOO", "bar")).toBe("FOO=bar"); + }); +}); + +describe("parsePolicyPresetEnv", () => { + it("parses comma-separated values", () => { + expect(parsePolicyPresetEnv("web,local-inference")).toEqual(["web", "local-inference"]); + }); + + it("trims whitespace", () => { + expect(parsePolicyPresetEnv(" web , local ")).toEqual(["web", "local"]); + }); + + it("filters empty segments", () => { + expect(parsePolicyPresetEnv("web,,local")).toEqual(["web", "local"]); + }); + + it("handles empty string", () => { + expect(parsePolicyPresetEnv("")).toEqual([]); + }); +}); diff --git a/src/lib/url-utils.ts b/src/lib/url-utils.ts new file mode 100644 index 000000000..4f34013c0 --- /dev/null +++ b/src/lib/url-utils.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure string utilities for URL normalization, text compaction, and + * formatting helpers used across the CLI. + */ + +export function compactText(value = ""): string { + return String(value).replace(/\s+/g, " ").trim(); +} + +export function stripEndpointSuffix(pathname = "", suffixes: string[] = []): string { + for (const suffix of suffixes) { + if (pathname === suffix) return ""; + if (pathname.endsWith(suffix)) { + return pathname.slice(0, -suffix.length); + } + } + return pathname; +} + +export type EndpointFlavor = "anthropic" | "openai"; + +export function normalizeProviderBaseUrl(value: unknown, flavor: EndpointFlavor): string { + const raw = String(value || "").trim(); + if (!raw) return ""; + + try { + const url = new URL(raw); + url.search = ""; + url.hash = ""; + const suffixes = + flavor === "anthropic" + ? ["/v1/messages", "/v1/models", "/v1", "/messages", "/models"] + : ["/responses", "/chat/completions", "/completions", "/models"]; + let pathname = stripEndpointSuffix(url.pathname.replace(/\/+$/, ""), suffixes); + pathname = pathname.replace(/\/+$/, ""); + url.pathname = pathname || "/"; + return url.pathname === "/" ? url.origin : `${url.origin}${url.pathname}`; + } catch { + return raw.replace(/[?#].*$/, "").replace(/\/+$/, ""); + } +} + +export function isLoopbackHostname(hostname = ""): boolean { + const normalized = String(hostname || "") + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return ( + normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); +} + +export function formatEnvAssignment(name: string, value: string): string { + return `${name}=${value}`; +} + +export function parsePolicyPresetEnv(value: string): string[] { + return (value || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 000000000..eeceeffe2 --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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 "../../dist/lib/validation"; + +describe("classifyValidationFailure", () => { + it("classifies curl failures as transport", () => { + expect(classifyValidationFailure({ curlStatus: 7 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 429 as transport", () => { + expect(classifyValidationFailure({ httpStatus: 429 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 5xx as transport", () => { + expect(classifyValidationFailure({ httpStatus: 502 })).toEqual({ + kind: "transport", + retry: "retry", + }); + }); + + it("classifies 401 as credential", () => { + expect(classifyValidationFailure({ httpStatus: 401 })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("classifies 403 as credential", () => { + expect(classifyValidationFailure({ httpStatus: 403 })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("classifies 400 as model", () => { + expect(classifyValidationFailure({ httpStatus: 400 })).toEqual({ + kind: "model", + retry: "model", + }); + }); + + it("classifies model-not-found message as model", () => { + expect(classifyValidationFailure({ message: "model xyz not found" })).toEqual({ + kind: "model", + retry: "model", + }); + }); + + it("classifies 404 as endpoint", () => { + expect(classifyValidationFailure({ httpStatus: 404 })).toEqual({ + kind: "endpoint", + retry: "selection", + }); + }); + + it("classifies unauthorized message as credential", () => { + expect(classifyValidationFailure({ message: "Unauthorized access" })).toEqual({ + kind: "credential", + retry: "credential", + }); + }); + + it("returns unknown for unrecognized failures", () => { + expect(classifyValidationFailure({ httpStatus: 418 })).toEqual({ + kind: "unknown", + retry: "selection", + }); + }); + + it("handles no arguments", () => { + expect(classifyValidationFailure()).toEqual({ kind: "unknown", retry: "selection" }); + }); +}); + +describe("classifyApplyFailure", () => { + it("delegates to classifyValidationFailure", () => { + expect(classifyApplyFailure("unauthorized")).toEqual({ + kind: "credential", + retry: "credential", + }); + }); +}); + +describe("classifySandboxCreateFailure", () => { + it("detects image transfer timeout", () => { + const result = classifySandboxCreateFailure("failed to read image export stream"); + expect(result.kind).toBe("image_transfer_timeout"); + }); + + it("detects connection reset", () => { + const result = classifySandboxCreateFailure("Connection reset by peer"); + expect(result.kind).toBe("image_transfer_reset"); + }); + + it("detects incomplete sandbox creation", () => { + const result = classifySandboxCreateFailure("Created sandbox: test"); + expect(result.kind).toBe("sandbox_create_incomplete"); + expect(result.uploadedToGateway).toBe(true); + }); + + it("detects upload progress", () => { + const result = classifySandboxCreateFailure( + "[progress] Uploaded to gateway\nfailed to read image export stream", + ); + expect(result.uploadedToGateway).toBe(true); + }); + + it("returns unknown for unrecognized output", () => { + const result = classifySandboxCreateFailure("something else happened"); + expect(result.kind).toBe("unknown"); + }); +}); + +describe("validateNvidiaApiKeyValue", () => { + it("returns null for valid key", () => { + expect(validateNvidiaApiKeyValue("nvapi-abc123")).toBeNull(); + }); + + it("rejects empty key", () => { + expect(validateNvidiaApiKeyValue("")).toBeTruthy(); + }); + + it("rejects key without nvapi- prefix", () => { + expect(validateNvidiaApiKeyValue("sk-abc123")).toBeTruthy(); + }); +}); + +describe("isSafeModelId", () => { + it("accepts valid model IDs", () => { + expect(isSafeModelId("nvidia/nemotron-3-super-120b-a12b")).toBe(true); + expect(isSafeModelId("gpt-5.4")).toBe(true); + expect(isSafeModelId("claude-sonnet-4-6")).toBe(true); + }); + + it("rejects IDs with spaces or special chars", () => { + expect(isSafeModelId("model name")).toBe(false); + expect(isSafeModelId("model;rm -rf /")).toBe(false); + expect(isSafeModelId("")).toBe(false); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 000000000..ead13b9d7 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure validation and failure-classification functions. + * + * No I/O, no side effects — takes strings/numbers in, returns typed results. + */ + +export interface ValidationClassification { + kind: "transport" | "credential" | "model" | "endpoint" | "unknown"; + retry: "retry" | "credential" | "model" | "selection"; +} + +export interface SandboxCreateFailure { + kind: "image_transfer_timeout" | "image_transfer_reset" | "sandbox_create_incomplete" | "unknown"; + uploadedToGateway: boolean; +} + +export function classifyValidationFailure({ + httpStatus = 0, + curlStatus = 0, + message = "", +} = {}): ValidationClassification { + const normalized = String(message).replace(/\s+/g, " ").trim().toLowerCase(); + if (curlStatus) { + return { kind: "transport", retry: "retry" }; + } + if (httpStatus === 429 || (httpStatus >= 500 && httpStatus < 600)) { + return { kind: "transport", retry: "retry" }; + } + if (httpStatus === 401 || httpStatus === 403) { + return { kind: "credential", retry: "credential" }; + } + if (httpStatus === 400) { + return { kind: "model", retry: "model" }; + } + if (/model.+not found|unknown model|unsupported model|bad model/i.test(normalized)) { + return { kind: "model", retry: "model" }; + } + if (httpStatus === 404 || httpStatus === 405) { + return { kind: "endpoint", retry: "selection" }; + } + if (/unauthorized|forbidden|invalid api key|invalid_auth|permission/i.test(normalized)) { + return { kind: "credential", retry: "credential" }; + } + return { kind: "unknown", retry: "selection" }; +} + +export function classifyApplyFailure(message = ""): ValidationClassification { + return classifyValidationFailure({ message }); +} + +export function classifySandboxCreateFailure(output = ""): SandboxCreateFailure { + const text = String(output || ""); + const uploadedToGateway = + /\[progress\]\s+Uploaded to gateway/i.test(text) || + /Image .*available in the gateway/i.test(text); + + if (/failed to read image export stream|Timeout error/i.test(text)) { + return { kind: "image_transfer_timeout", uploadedToGateway }; + } + if (/Connection reset by peer/i.test(text)) { + return { kind: "image_transfer_reset", uploadedToGateway }; + } + if (/Created sandbox:/i.test(text)) { + return { kind: "sandbox_create_incomplete", uploadedToGateway: true }; + } + return { kind: "unknown", uploadedToGateway }; +} + +export function validateNvidiaApiKeyValue(key: string): string | null { + if (!key) { + return " NVIDIA API Key is required."; + } + if (!key.startsWith("nvapi-")) { + return " Invalid key. Must start with nvapi-"; + } + return null; +} + +export function isSafeModelId(value: string): boolean { + return /^[A-Za-z0-9._:/-]+$/.test(value); +} 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/); - }); -}); diff --git a/tsconfig.cli.json b/tsconfig.cli.json index 620839cf5..052cf455b 100644 --- a/tsconfig.cli.json +++ b/tsconfig.cli.json @@ -15,6 +15,6 @@ "moduleDetection": "force", "types": ["node"] }, - "include": ["bin/**/*.ts", "scripts/**/*.ts"], + "include": ["bin/**/*.ts", "scripts/**/*.ts", "src/**/*.ts"], "exclude": ["node_modules", "nemoclaw"] } diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 000000000..da1287ae4 --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "nemoclaw", "src/**/*.test.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 5b7727919..5296f9efb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ { test: { name: "cli", - include: ["test/**/*.test.{js,ts}"], + include: ["test/**/*.test.{js,ts}", "src/**/*.test.ts"], exclude: ["**/node_modules/**", "**/.claude/**", "test/e2e/**"], }, },