diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 65d839bab89..a451ee550a5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1510,6 +1510,70 @@ function getNonInteractiveModel(providerKey) { // ── Step 1: Preflight ──────────────────────────────────────────── // eslint-disable-next-line complexity +const OPENSHELL_INSTALL_URL = "https://www.nvidia.com/nemoclaw.sh"; + +/** + * Read the minimum required OpenShell version from scripts/install-openshell.sh. + * Returns null if the file is missing or the MIN_VERSION line can't be parsed. + */ +function getExpectedOpenshellVersion() { + const installScript = path.join(SCRIPTS, "install-openshell.sh"); + try { + const scriptContent = fs.readFileSync(installScript, "utf-8"); + const match = scriptContent.match(/^MIN_VERSION="([^"]+)"/m); + if (match) return match[1]; + note( + "[preflight] install-openshell.sh found but MIN_VERSION line not parseable — skipping version check", + ); + return null; + } catch { + return null; // file missing — skip version check silently + } +} + +/** + * Emit a warning if the installed OpenShell version differs from the tested baseline. + * Silently skips if the installed version or expected version can't be determined. + */ +function warnOnOpenshellVersionMismatch(installedVersion) { + if (!installedVersion) return; + const expectedVersion = getExpectedOpenshellVersion(); + if (!expectedVersion || installedVersion === expectedVersion) return; + + const [iMaj, iMin, iPatch] = installedVersion.split(".").map(Number); + const [eMaj, eMin, ePatch] = expectedVersion.split(".").map(Number); + const isNewer = + iMaj > eMaj || + (iMaj === eMaj && iMin > eMin) || + (iMaj === eMaj && iMin === eMin && iPatch > ePatch); + const isOlder = + iMaj < eMaj || + (iMaj === eMaj && iMin < eMin) || + (iMaj === eMaj && iMin === eMin && iPatch < ePatch); + + if (isNewer) { + console.error(""); + console.error( + ` ⚠️ OpenShell ${installedVersion} is newer than the tested version ${expectedVersion}.`, + ); + console.error( + " Upgrading OpenShell independently (e.g. 'openshell self-update') can break NemoClaw sandbox compatibility.", + ); + console.error( + ` If you experience issues, reinstall the tested version: curl -fsSL ${OPENSHELL_INSTALL_URL} | bash`, + ); + console.error(""); + } else if (isOlder) { + console.error(""); + console.error( + ` ⚠️ OpenShell ${installedVersion} is older than the tested version ${expectedVersion}.`, + ); + console.error(" This version may cause onboarding or runtime failures. Please upgrade:"); + console.error(` curl -fsSL ${OPENSHELL_INSTALL_URL} | bash`); + console.error(""); + } +} + async function preflight() { step(1, 8, "Preflight checks"); @@ -1547,9 +1611,17 @@ async function preflight() { process.exit(1); } } - console.log( - ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, - ); + const openshellVersionRaw = runCaptureOpenshell(["--version"], { ignoreError: true }); + const installedVersion = getInstalledOpenshellVersion(openshellVersionRaw); + if (!openshellVersionRaw) { + console.error(" Could not determine the installed OpenShell version."); + } else { + console.log(` ✓ openshell CLI: ${openshellVersionRaw}`); + } + + // Warn if the installed OpenShell version differs from the version NemoClaw + // was tested with. Running a newer OpenShell can break sandbox compatibility. + warnOnOpenshellVersionMismatch(installedVersion); if (openshellInstall.futureShellPathHint) { console.log( ` Note: openshell was installed to ${openshellInstall.localBin} for this onboarding run.`, @@ -3979,6 +4051,8 @@ module.exports = { getNavigationChoice, getSandboxInferenceConfig, getInstalledOpenshellVersion, + getExpectedOpenshellVersion, + warnOnOpenshellVersionMismatch, getRequestedModelHint, getRequestedProviderHint, getStableGatewayImageRef, diff --git a/test/openshell-version-check.test.js b/test/openshell-version-check.test.js new file mode 100644 index 00000000000..5d07970fc7a --- /dev/null +++ b/test/openshell-version-check.test.js @@ -0,0 +1,114 @@ +// 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"; +import path from "node:path"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getExpectedOpenshellVersion, + warnOnOpenshellVersionMismatch, + getInstalledOpenshellVersion, +} from "../bin/lib/onboard"; + +// Helper: create a temp install-openshell.sh with a given MIN_VERSION +function makeTmpInstallScript(minVersion) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-test-")); + const scriptPath = path.join(dir, "install-openshell.sh"); + fs.writeFileSync(scriptPath, `#!/usr/bin/env bash\nMIN_VERSION="${minVersion}"\necho "done"\n`); + return { dir, scriptPath }; +} + +describe("getExpectedOpenshellVersion", () => { + it("reads MIN_VERSION from install-openshell.sh", () => { + const { scriptPath } = makeTmpInstallScript("0.0.22"); + // Temporarily override SCRIPTS path by spying — use vi.spyOn on fs + const origReadFileSync = fs.readFileSync; + vi.spyOn(fs, "readFileSync").mockImplementation((p, ...args) => { + if (String(p).endsWith("install-openshell.sh")) { + return origReadFileSync(scriptPath, ...args); + } + return origReadFileSync(p, ...args); + }); + + const version = getExpectedOpenshellVersion(); + expect(version).toBe("0.0.22"); + + vi.restoreAllMocks(); + }); + + it("returns null when install script is missing", () => { + vi.spyOn(fs, "readFileSync").mockImplementation((p) => { + if (String(p).endsWith("install-openshell.sh")) throw new Error("ENOENT"); + throw new Error("unexpected"); + }); + + const version = getExpectedOpenshellVersion(); + expect(version).toBeNull(); + + vi.restoreAllMocks(); + }); + + it("returns null when MIN_VERSION line is missing from script", () => { + vi.spyOn(fs, "readFileSync").mockReturnValueOnce( + "#!/usr/bin/env bash\n# no MIN_VERSION here\n", + ); + + const version = getExpectedOpenshellVersion(); + expect(version).toBeNull(); + + vi.restoreAllMocks(); + }); +}); + +describe("warnOnOpenshellVersionMismatch", () => { + let stderrOutput; + + beforeEach(() => { + stderrOutput = []; + vi.spyOn(console, "error").mockImplementation((...args) => stderrOutput.push(args.join(" "))); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits no warning when versions match", () => { + vi.spyOn(fs, "readFileSync").mockReturnValueOnce('MIN_VERSION="0.0.22"\n'); + warnOnOpenshellVersionMismatch("0.0.22"); + expect(stderrOutput).toHaveLength(0); + }); + + it("warns when installed version is newer", () => { + vi.spyOn(fs, "readFileSync").mockReturnValueOnce('MIN_VERSION="0.0.22"\n'); + warnOnOpenshellVersionMismatch("0.0.23"); + expect(stderrOutput.some((l) => l.includes("newer"))).toBe(true); + expect(stderrOutput.some((l) => l.includes("0.0.23"))).toBe(true); + expect(stderrOutput.some((l) => l.includes("0.0.22"))).toBe(true); + }); + + it("warns when installed version is older", () => { + vi.spyOn(fs, "readFileSync").mockReturnValueOnce('MIN_VERSION="0.0.22"\n'); + warnOnOpenshellVersionMismatch("0.0.7"); + expect(stderrOutput.some((l) => l.includes("older"))).toBe(true); + expect(stderrOutput.some((l) => l.includes("0.0.7"))).toBe(true); + expect(stderrOutput.some((l) => l.includes("0.0.22"))).toBe(true); + }); + + it("skips gracefully when install script is missing (no crash)", () => { + vi.spyOn(fs, "readFileSync").mockImplementation((p) => { + if (String(p).endsWith("install-openshell.sh")) throw new Error("ENOENT"); + throw new Error("unexpected"); + }); + // Should not throw even if expectedVersion is null + expect(() => warnOnOpenshellVersionMismatch("0.0.7")).not.toThrow(); + expect(stderrOutput).toHaveLength(0); + }); + + it("skips gracefully when installed version is empty/null", () => { + vi.spyOn(fs, "readFileSync").mockReturnValueOnce('MIN_VERSION="0.0.22"\n'); + expect(() => warnOnOpenshellVersionMismatch(null)).not.toThrow(); + expect(() => warnOnOpenshellVersionMismatch("")).not.toThrow(); + expect(stderrOutput).toHaveLength(0); + }); +});