diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 6b6c1dba6..f8274b5b5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -48,6 +48,11 @@ const registry = require("./registry"); const nim = require("./nim"); const onboardSession = require("./onboard-session"); const policies = require("./policies"); +const { + DEFAULT_MIN_OPENSHELL_VERSION, + getMinimumOpenshellVersionFromFile, + parseMinimumOpenshellVersion, +} = require("../../scripts/lib/openshell-version"); const { ensureUsageNoticeConsent } = require("./usage-notice"); const { assessHost, @@ -365,6 +370,33 @@ function getInstalledOpenshellVersion(versionOutput = null) { return match[1]; } +function compareVersions(left = "0.0.0", right = "0.0.0") { + const lhs = String(left).split(".").map((part) => Number.parseInt(part, 10) || 0); + const rhs = String(right).split(".").map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] || 0; + const b = rhs[index] || 0; + if (a > b) return 1; + if (a < b) return -1; + } + return 0; +} + +function getMinimumOpenshellVersion(blueprintText = null) { + if (blueprintText == null) { + return getMinimumOpenshellVersionFromFile( + path.join(ROOT, "nemoclaw-blueprint", "blueprint.yaml"), + ); + } + return parseMinimumOpenshellVersion(String(blueprintText)); +} + +function shouldUpgradeOpenshell(installedVersion = null, minimumVersion = null) { + if (!installedVersion || !minimumVersion) return false; + return compareVersions(installedVersion, minimumVersion) < 0; +} + function getStableGatewayImageRef(versionOutput = null) { const version = getInstalledOpenshellVersion(versionOutput); if (!version) return null; @@ -1361,10 +1393,14 @@ function getPortConflictServiceHints(platform = process.platform) { ]; } -function installOpenshell() { +function installOpenshell(opts = {}) { + const env = { ...process.env }; + if (opts.minimumVersion) { + env.NEMOCLAW_MIN_OPENSHELL_VERSION = opts.minimumVersion; + } const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { cwd: ROOT, - env: process.env, + env, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", timeout: 300_000, @@ -1522,14 +1558,29 @@ async function preflight() { // OpenShell CLI let openshellInstall = { localBin: null, futureShellPathHint: null }; - if (!isOpenshellInstalled()) { + const openshellInstalled = isOpenshellInstalled(); + const requiredOpenshellVersion = getMinimumOpenshellVersion(); + const installedOpenshellVersion = openshellInstalled ? getInstalledOpenshellVersion() : null; + if (!openshellInstalled) { console.log(" openshell CLI not found. Installing..."); - openshellInstall = installOpenshell(); + openshellInstall = installOpenshell({ minimumVersion: requiredOpenshellVersion }); if (!openshellInstall.installed) { console.error(" Failed to install openshell CLI."); console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); process.exit(1); } + } else if (shouldUpgradeOpenshell(installedOpenshellVersion, requiredOpenshellVersion)) { + console.log( + ` openshell CLI ${installedOpenshellVersion} is below required ${requiredOpenshellVersion}. Upgrading...`, + ); + openshellInstall = installOpenshell({ minimumVersion: requiredOpenshellVersion }); + if (!openshellInstall.installed) { + console.error( + ` Failed to upgrade openshell CLI to meet NemoClaw's minimum ${requiredOpenshellVersion}.`, + ); + console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); + process.exit(1); + } } console.log( ` ✓ openshell CLI: ${runCaptureOpenshell(["--version"], { ignoreError: true }) || "unknown"}`, @@ -3937,6 +3988,7 @@ module.exports = { copyBuildContextDir, classifySandboxCreateFailure, createSandbox, + DEFAULT_MIN_OPENSHELL_VERSION, formatEnvAssignment, getFutureShellPathHint, getGatewayStartEnv, @@ -3944,6 +3996,7 @@ module.exports = { getNavigationChoice, getSandboxInferenceConfig, getInstalledOpenshellVersion, + getMinimumOpenshellVersion, getRequestedModelHint, getRequestedProviderHint, getStableGatewayImageRef, @@ -3968,6 +4021,7 @@ module.exports = { repairRecordedSandbox, recoverGatewayRuntime, resolveDashboardForwardTarget, + shouldUpgradeOpenshell, startGatewayForRecovery, runCaptureOpenshell, setupInference, diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index e0118436f..28fe88f95 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -4,6 +4,9 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -34,8 +37,39 @@ esac info "Detected $OS_LABEL ($ARCH_LABEL)" # Minimum version required for sandbox persistence across gateway restarts -# (deterministic k3s node name + workspace PVC: NVIDIA/OpenShell#739, #488) -MIN_VERSION="0.0.22" +# (deterministic k3s node name + workspace PVC: NVIDIA/OpenShell#739, #488). +# When a blueprint declares a higher minimum, prefer that newer requirement. +resolve_min_version() { + if [[ -n "${NEMOCLAW_MIN_OPENSHELL_VERSION:-}" ]]; then + printf "%s" "${NEMOCLAW_MIN_OPENSHELL_VERSION}" + return + fi + + local blueprint="${REPO_ROOT}/nemoclaw-blueprint/blueprint.yaml" + local parser="${SCRIPT_DIR}/lib/openshell-version.js" + if [[ -f "$blueprint" && -f "$parser" && -n "$(command -v node || true)" ]]; then + local version + version="$(node "$parser" "$blueprint" 2>/dev/null || true)" + if [[ -n "$version" ]]; then + printf "%s" "$version" + return + fi + fi + + if [[ -f "$blueprint" ]]; then + local version + version="$(sed -nE 's/^[[:space:]]*min_openshell_version:[[:space:]]*"([^"]+)".*/\1/p' "$blueprint" | head -1)" + if [[ -n "$version" ]]; then + printf "%s" "$version" + return + fi + fi + + # Keep a safe fallback for contexts that only have this script. + printf "%s" "0.1.0" +} + +MIN_VERSION="$(resolve_min_version)" version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) diff --git a/scripts/lib/openshell-version.js b/scripts/lib/openshell-version.js new file mode 100644 index 000000000..5dce54126 --- /dev/null +++ b/scripts/lib/openshell-version.js @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_MIN_OPENSHELL_VERSION = "0.1.0"; +const MIN_OPENSHELL_VERSION_PATTERN = + /^[\t ]*min_openshell_version:[\t ]*"([^"]+)".*$/m; + +function parseMinimumOpenshellVersion(blueprintText = "") { + const match = String(blueprintText ?? "").match(MIN_OPENSHELL_VERSION_PATTERN); + return match?.[1] || DEFAULT_MIN_OPENSHELL_VERSION; +} + +function getMinimumOpenshellVersionFromFile( + blueprintPath = path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"), +) { + if (!fs.existsSync(blueprintPath)) { + return DEFAULT_MIN_OPENSHELL_VERSION; + } + return parseMinimumOpenshellVersion(fs.readFileSync(blueprintPath, "utf-8")); +} + +if (require.main === module) { + process.stdout.write(getMinimumOpenshellVersionFromFile(process.argv[2])); +} + +module.exports = { + DEFAULT_MIN_OPENSHELL_VERSION, + getMinimumOpenshellVersionFromFile, + parseMinimumOpenshellVersion, +}; diff --git a/test/install-openshell.test.js b/test/install-openshell.test.js new file mode 100644 index 000000000..c8606952b --- /dev/null +++ b/test/install-openshell.test.js @@ -0,0 +1,78 @@ +// 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 { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const INSTALL_OPEN_SHELL = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); +const TEST_SYSTEM_PATH = "/usr/bin:/bin"; + +function writeExecutable(target, contents) { + fs.writeFileSync(target, contents, { mode: 0o755 }); +} + +describe("install-openshell.sh", () => { + it("uses the blueprint minimum version when no override is provided", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-openshell-blueprint-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.1.0" + exit 0 +fi +exit 0 +`, + ); + + const result = spawnSync("bash", [INSTALL_OPEN_SHELL], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + }, + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/openshell already installed: 0\.1\.0 \(>= 0\.1\.0\)/); + }); + + it("honors an explicit minimum-version override from the caller", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-openshell-override-")); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "$1" = "--version" ]; then + echo "openshell 0.0.21" + exit 0 +fi +exit 0 +`, + ); + + const result = spawnSync("bash", [INSTALL_OPEN_SHELL], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { + ...process.env, + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + NEMOCLAW_MIN_OPENSHELL_VERSION: "0.0.20", + }, + }); + + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toMatch(/openshell already installed: 0\.0\.21 \(>= 0\.0\.20\)/); + }); +}); diff --git a/test/onboard.test.js b/test/onboard.test.js index 62f37d6af..ff9287e54 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -19,7 +19,9 @@ import { getPortConflictServiceHints, getFutureShellPathHint, getSandboxInferenceConfig, + DEFAULT_MIN_OPENSHELL_VERSION, getInstalledOpenshellVersion, + getMinimumOpenshellVersion, getRequestedModelHint, getRequestedProviderHint, getRequestedSandboxNameHint, @@ -37,6 +39,7 @@ import { resolveDashboardForwardTarget, summarizeCurlFailure, summarizeProbeFailure, + shouldUpgradeOpenshell, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; @@ -305,6 +308,16 @@ describe("onboard helpers", () => { expect(getInstalledOpenshellVersion("openshell 0.0.12")).toBe("0.0.12"); expect(getInstalledOpenshellVersion("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("0.0.13"); expect(getInstalledOpenshellVersion("bogus")).toBe(null); + expect(getMinimumOpenshellVersion('min_openshell_version: "0.1.0"\n')).toBe("0.1.0"); + expect(getMinimumOpenshellVersion("min_openshell_version: 0.2.0\n")).toBe( + DEFAULT_MIN_OPENSHELL_VERSION, + ); + expect(getMinimumOpenshellVersion("name: no-version-here\n")).toBe( + DEFAULT_MIN_OPENSHELL_VERSION, + ); + expect(shouldUpgradeOpenshell("0.0.21", "0.1.0")).toBe(true); + expect(shouldUpgradeOpenshell("0.1.0", "0.1.0")).toBe(false); + expect(shouldUpgradeOpenshell("0.1.2", "0.1.0")).toBe(false); expect(getStableGatewayImageRef("openshell 0.0.12")).toBe( "ghcr.io/nvidia/openshell/cluster:0.0.12", );