diff --git a/bin/lib/host-support.js b/bin/lib/host-support.js new file mode 100644 index 000000000..fee42b6d3 --- /dev/null +++ b/bin/lib/host-support.js @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Thin CJS shim — implementation lives in src/lib/host-support.ts +module.exports = require("../../dist/lib/host-support"); diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 6b6c1dba6..374885df3 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -56,6 +56,7 @@ const { getMemoryInfo, planHostRemediation, } = require("./preflight"); +const { checkHostSupport } = require("./host-support"); // Typed modules (compiled from src/lib/*.ts → dist/lib/*.js) const gatewayState = require("../../dist/lib/gateway-state"); @@ -1497,6 +1498,16 @@ function getNonInteractiveModel(providerKey) { async function preflight() { step(1, 8, "Preflight checks"); + const hostSupport = checkHostSupport(); + if (hostSupport.status === "ok") { + console.log(` ✓ ${hostSupport.message}`); + } else if (hostSupport.status === "warning") { + console.log(` ⚠ ${hostSupport.message}`); + } else { + console.error(` ${hostSupport.message}`); + process.exit(1); + } + const host = assessHost(); // Docker / runtime diff --git a/src/lib/host-support.test.ts b/src/lib/host-support.test.ts new file mode 100644 index 000000000..9eb5897ba --- /dev/null +++ b/src/lib/host-support.test.ts @@ -0,0 +1,110 @@ +// 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 compiled dist output for consistent coverage attribution. +import { + checkHostSupport, + classifyLinuxHost, + parseOsRelease, +} from "../../dist/lib/host-support"; + +describe("parseOsRelease", () => { + it("parses quoted and unquoted values", () => { + const parsed = parseOsRelease([ + 'NAME="Ubuntu"', + "ID=ubuntu", + 'VERSION_ID="24.04"', + ].join("\n")); + + expect(parsed).toEqual({ id: "ubuntu", versionId: "24.04" }); + }); + + it("returns empty fields when ID/VERSION_ID are missing", () => { + const parsed = parseOsRelease("NAME=Ubuntu\nPRETTY_NAME=Ubuntu Linux"); + expect(parsed).toEqual({ id: "", versionId: "" }); + }); +}); + +describe("classifyLinuxHost", () => { + it("marks Ubuntu 24.04 as supported", () => { + const result = classifyLinuxHost("ubuntu", "24.04"); + expect(result.status).toBe("ok"); + expect(result.code).toBe("SUPPORTED"); + }); + + it("marks Ubuntu 22.04 as supported", () => { + const result = classifyLinuxHost("ubuntu", "22.04"); + expect(result.status).toBe("ok"); + expect(result.code).toBe("SUPPORTED"); + }); + + it("marks Ubuntu 20.04 as near EOL warning", () => { + const result = classifyLinuxHost("ubuntu", "20.04"); + expect(result.status).toBe("warning"); + expect(result.code).toBe("NEAR_EOL"); + }); + + it("marks Ubuntu 18.04 as EOL error", () => { + const result = classifyLinuxHost("ubuntu", "18.04"); + expect(result.status).toBe("error"); + expect(result.code).toBe("EOL"); + }); + + it("marks unknown Linux distro as warning", () => { + const result = classifyLinuxHost("debian", "12"); + expect(result.status).toBe("warning"); + expect(result.code).toBe("UNSUPPORTED_OS"); + }); +}); + +describe("checkHostSupport", () => { + it("classifies unsupported platform as error", () => { + const result = checkHostSupport({ platform: "win32" }); + expect(result.status).toBe("error"); + expect(result.code).toBe("UNSUPPORTED_OS"); + }); + + it("classifies linux from os-release data", () => { + const result = checkHostSupport({ + platform: "linux", + readFileSyncImpl: () => 'ID=ubuntu\nVERSION_ID="24.04"\n', + }); + + expect(result.status).toBe("ok"); + expect(result.code).toBe("SUPPORTED"); + }); + + it("returns warning when /etc/os-release cannot be read", () => { + const result = checkHostSupport({ + platform: "linux", + readFileSyncImpl: () => { + throw new Error("missing"); + }, + }); + + expect(result.status).toBe("warning"); + expect(result.code).toBe("UNKNOWN_VERSION"); + }); + + it("uses mocked macOS version detection", () => { + const result = checkHostSupport({ + platform: "darwin", + getMacosVersionImpl: () => "14.5", + }); + + expect(result.status).toBe("warning"); + expect(result.code).toBe("UNSUPPORTED_OS"); + expect(result.message).toContain("macOS 14"); + }); + + it("returns unknown-version warning when macOS version is unavailable", () => { + const result = checkHostSupport({ + platform: "darwin", + getMacosVersionImpl: () => "", + }); + + expect(result.status).toBe("warning"); + expect(result.code).toBe("UNKNOWN_VERSION"); + }); +}); diff --git a/src/lib/host-support.ts b/src/lib/host-support.ts new file mode 100644 index 000000000..86d451267 --- /dev/null +++ b/src/lib/host-support.ts @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; + +// 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"); + +export type HostSupportStatus = "ok" | "warning" | "error"; +export type HostSupportCode = + | "SUPPORTED" + | "NEAR_EOL" + | "EOL" + | "UNSUPPORTED_OS" + | "UNKNOWN_VERSION"; + +export type HostSupportResult = { + os: string; + version: string; + status: HostSupportStatus; + code: HostSupportCode; + message: string; +}; + +export interface OsReleaseInfo { + id: string; + versionId: string; +} + +export interface CheckHostSupportOpts { + platform?: NodeJS.Platform; + osReleasePath?: string; + readFileSyncImpl?: (path: string, encoding: BufferEncoding) => string; + getMacosVersionImpl?: () => string; +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function parseOsRelease(content: string): OsReleaseInfo { + const lines = content.split("\n"); + let id = ""; + let versionId = ""; + + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + + const key = line.slice(0, eq).trim(); + const value = stripQuotes(line.slice(eq + 1)); + + if (key === "ID") id = value.toLowerCase(); + if (key === "VERSION_ID") versionId = value; + } + + return { id, versionId }; +} + +function parseUbuntuVersion(version: string): { major: number; minor: number } | null { + const match = String(version).trim().match(/^(\d+)\.(\d+)/); + if (!match) return null; + + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (isNaN(major) || isNaN(minor)) return null; + + return { major, minor }; +} + +export function classifyLinuxHost(id: string, versionId: string): HostSupportResult { + if (!id) { + return { + os: "linux", + version: versionId || "unknown", + status: "warning", + code: "UNKNOWN_VERSION", + message: "Linux detected but distro/version could not be determined from /etc/os-release.", + }; + } + + if (id !== "ubuntu") { + return { + os: id, + version: versionId || "unknown", + status: "warning", + code: "UNSUPPORTED_OS", + message: `${id} ${versionId || "unknown"} detected: recognized Linux distro, but explicit host support policy is currently defined for Ubuntu only.`, + }; + } + + if (!versionId) { + return { + os: "ubuntu", + version: "unknown", + status: "warning", + code: "UNKNOWN_VERSION", + message: "Ubuntu detected but VERSION_ID is missing; support level could not be determined.", + }; + } + + const parsed = parseUbuntuVersion(versionId); + if (!parsed) { + return { + os: "ubuntu", + version: versionId, + status: "warning", + code: "UNKNOWN_VERSION", + message: `Ubuntu ${versionId} detected, but version format is unrecognized; support level could not be determined.`, + }; + } + + const { major, minor } = parsed; + const normalized = `${major}.${minor.toString().padStart(2, "0")}`; + + if ((major === 24 && minor === 4) || (major === 22 && minor === 4)) { + return { + os: "ubuntu", + version: normalized, + status: "ok", + code: "SUPPORTED", + message: `Ubuntu ${normalized} detected: supported.`, + }; + } + + if (major === 20 && minor === 4) { + return { + os: "ubuntu", + version: normalized, + status: "warning", + code: "NEAR_EOL", + message: `Ubuntu ${normalized} detected: older host OS; upgrade is recommended.`, + }; + } + + if (major < 20) { + return { + os: "ubuntu", + version: normalized, + status: "error", + code: "EOL", + message: `Ubuntu ${normalized} detected: unsupported or end-of-life host OS.`, + }; + } + + return { + os: "ubuntu", + version: normalized, + status: "warning", + code: "UNSUPPORTED_OS", + message: `Ubuntu ${normalized} detected: recognized host OS, but this version is outside the current explicit support policy.`, + }; +} + +export function classifyMacosHost(version: string): HostSupportResult { + if (!version || version === "unknown") { + return { + os: "macos", + version: "unknown", + status: "warning", + code: "UNKNOWN_VERSION", + message: "macOS detected but product version could not be determined; explicit support policy is not yet fully defined.", + }; + } + + const major = version.split(".")[0] || version; + return { + os: "macos", + version, + status: "warning", + code: "UNSUPPORTED_OS", + message: `macOS ${major} detected: recognized host OS; explicit support policy not yet fully defined.`, + }; +} + +function defaultGetMacosVersion(): string { + const out = runCapture("sw_vers -productVersion", { ignoreError: true }); + return String(out || "").trim(); +} + +export function checkHostSupport(opts: CheckHostSupportOpts = {}): HostSupportResult { + const platform = opts.platform || os.platform(); + const osReleasePath = opts.osReleasePath || "/etc/os-release"; + const readFileSyncImpl = opts.readFileSyncImpl || fs.readFileSync; + const getMacosVersionImpl = opts.getMacosVersionImpl || defaultGetMacosVersion; + + if (platform === "linux") { + try { + const content = readFileSyncImpl(osReleasePath, "utf-8"); + const { id, versionId } = parseOsRelease(content); + return classifyLinuxHost(id, versionId); + } catch { + return { + os: "linux", + version: "unknown", + status: "warning", + code: "UNKNOWN_VERSION", + message: "Linux detected but /etc/os-release is unavailable; support level could not be determined.", + }; + } + } + + if (platform === "darwin") { + const version = getMacosVersionImpl() || "unknown"; + return classifyMacosHost(version); + } + + return { + os: platform, + version: "unknown", + status: "error", + code: "UNSUPPORTED_OS", + message: `${platform} detected: unsupported host operating system for NemoClaw onboarding.`, + }; +} diff --git a/test/onboard.test.js b/test/onboard.test.js index 62f37d6af..cd3379d5c 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -3051,4 +3051,87 @@ const { setupMessagingChannels } = require(${onboardPath}); assert.match(fnBody, /isNonInteractive\(\)/); assert.match(fnBody, /process\.exit\(1\)/); }); + + it("fails fast before Docker checks when host support is unsupported", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-host-support-")); + const fakeBin = path.join(tmpDir, "bin"); + const markerPath = path.join(tmpDir, "docker-touched"); + const scriptPath = path.join(tmpDir, "host-support-fail-fast-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const hostSupportPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "host-support.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +touch "${markerPath}" +exit 0 +`, + { mode: 0o755 }, + ); + + const script = String.raw` +const fs = require("node:fs"); +const Module = require("node:module"); + +const hostSupportModulePath = ${hostSupportPath}; +const fakeHostSupport = new Module(hostSupportModulePath); +fakeHostSupport.exports = { + checkHostSupport: () => ({ + os: "win32", + version: "unknown", + status: "error", + code: "UNSUPPORTED_OS", + message: "win32 detected: unsupported host operating system for NemoClaw onboarding.", + }), +}; +require.cache[hostSupportModulePath] = fakeHostSupport; + +const { onboard } = require(${onboardPath}); + +const originalExit = process.exit; +process.exit = (code) => { + const err = new Error("EXIT"); + err.exitCode = code; + throw err; +}; + +(async () => { + try { + await onboard({ nonInteractive: true, acceptThirdPartySoftware: true }); + console.log(JSON.stringify({ exited: false, dockerTouched: fs.existsSync(${JSON.stringify(markerPath)}) })); + } catch (error) { + console.log( + JSON.stringify({ + exited: true, + exitCode: typeof error.exitCode === "number" ? error.exitCode : null, + dockerTouched: fs.existsSync(${JSON.stringify(markerPath)}), + }), + ); + } finally { + process.exit = originalExit; + } +})(); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + expect(payload).toEqual({ + exited: true, + exitCode: 1, + dockerTouched: false, + }); + }); });