Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -3979,6 +4051,8 @@ module.exports = {
getNavigationChoice,
getSandboxInferenceConfig,
getInstalledOpenshellVersion,
getExpectedOpenshellVersion,
warnOnOpenshellVersionMismatch,
getRequestedModelHint,
getRequestedProviderHint,
getStableGatewayImageRef,
Expand Down
114 changes: 114 additions & 0 deletions test/openshell-version-check.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});