diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ccfb60085..49b9ef96a 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -576,6 +576,23 @@ function getInstalledOpenshellVersion(versionOutput = null) { return match[1]; } +/** True when `left` >= `right` (semver, major.minor.patch). */ +function versionGte(left = "0.0.0", right = "0.0.0") { + const lhs = String(left) + .split(".") + .map((p) => Number.parseInt(p, 10) || 0); + const rhs = String(right) + .split(".") + .map((p) => Number.parseInt(p, 10) || 0); + for (let i = 0; i < Math.max(lhs.length, rhs.length); i++) { + const a = lhs[i] || 0; + const b = rhs[i] || 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + function getStableGatewayImageRef(versionOutput = null) { const version = getInstalledOpenshellVersion(versionOutput); if (!version) return null; @@ -1890,9 +1907,14 @@ function getPortConflictServiceHints(platform = process.platform) { } function installOpenshell() { + const pinnedVersion = require("../../package.json").openshellVersion; + const installEnv = { ...process.env }; + if (pinnedVersion) { + installEnv.OPENSHELL_PIN_VERSION = pinnedVersion; + } const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { cwd: ROOT, - env: process.env, + env: installEnv, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", timeout: 300_000, @@ -2045,10 +2067,21 @@ async function preflight() { console.log(` ✓ Container runtime: ${runtime}`); } - // OpenShell CLI + // OpenShell CLI — install if missing or upgrade if below pinned version + const pinnedOpenshellVersion = require("../../package.json").openshellVersion; let openshellInstall = { localBin: null, futureShellPathHint: null }; - if (!isOpenshellInstalled()) { - console.log(" openshell CLI not found. Installing..."); + const installedOpenshellVersion = isOpenshellInstalled() ? getInstalledOpenshellVersion() : null; + const needsInstall = + !installedOpenshellVersion || + (pinnedOpenshellVersion && !versionGte(installedOpenshellVersion, pinnedOpenshellVersion)); + if (needsInstall) { + if (!installedOpenshellVersion) { + console.log(" openshell CLI not found. Installing..."); + } else { + console.log( + ` openshell ${installedOpenshellVersion} is below required ${pinnedOpenshellVersion} — upgrading...`, + ); + } openshellInstall = installOpenshell(); if (!openshellInstall.installed) { console.error(" Failed to install openshell CLI."); @@ -4198,4 +4231,5 @@ module.exports = { shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, patchStagedDockerfile, + versionGte, }; diff --git a/package.json b/package.json index a5ba9db5e..119c613e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "nemoclaw", "version": "0.1.0", + "openshellVersion": "0.0.22", "description": "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference", "license": "Apache-2.0", "bin": { diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index e0118436f..0429c7d66 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,9 +33,12 @@ 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" +# OPENSHELL_PIN_VERSION (set by onboard.js from package.json) is the version +# NemoClaw was tested against. When set, download that exact release and treat +# it as the minimum required version. When unset, fall back to the static +# floor for sandbox persistence across gateway restarts +# (deterministic k3s node name + workspace PVC: NVIDIA/OpenShell#739, #488). +REQUIRED_VERSION="${OPENSHELL_PIN_VERSION:-0.0.22}" version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) @@ -53,11 +56,11 @@ version_gte() { if command -v openshell >/dev/null 2>&1; then INSTALLED_VERSION="$(openshell --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo '0.0.0')" - if version_gte "$INSTALLED_VERSION" "$MIN_VERSION"; then - info "openshell already installed: $INSTALLED_VERSION (>= $MIN_VERSION)" + if version_gte "$INSTALLED_VERSION" "$REQUIRED_VERSION"; then + info "openshell already installed: $INSTALLED_VERSION (>= $REQUIRED_VERSION)" exit 0 fi - warn "openshell $INSTALLED_VERSION is below minimum $MIN_VERSION — upgrading..." + warn "openshell $INSTALLED_VERSION is below required $REQUIRED_VERSION — upgrading..." fi info "Installing openshell CLI..." @@ -81,18 +84,28 @@ tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT CHECKSUM_FILE="openshell-checksums-sha256.txt" + +# When a pin is set, download that exact tag; otherwise download latest. +GH_TAG_FLAG=() +CURL_TAG_PATH="latest/download" +if [[ -n "${OPENSHELL_PIN_VERSION:-}" ]]; then + GH_TAG_FLAG=(--tag "v${OPENSHELL_PIN_VERSION}") + CURL_TAG_PATH="download/v${OPENSHELL_PIN_VERSION}" + info "Pinned to OpenShell v${OPENSHELL_PIN_VERSION}" +fi + download_with_curl() { - curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$ASSET" \ + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/${CURL_TAG_PATH}/$ASSET" \ -o "$tmpdir/$ASSET" - curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$CHECKSUM_FILE" \ + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/${CURL_TAG_PATH}/$CHECKSUM_FILE" \ -o "$tmpdir/$CHECKSUM_FILE" } if command -v gh >/dev/null 2>&1; then if GH_PROMPT_DISABLED=1 GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" gh release download --repo NVIDIA/OpenShell \ - --pattern "$ASSET" --dir "$tmpdir" 2>/dev/null \ + "${GH_TAG_FLAG[@]}" --pattern "$ASSET" --dir "$tmpdir" 2>/dev/null \ && GH_PROMPT_DISABLED=1 GH_TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" gh release download --repo NVIDIA/OpenShell \ - --pattern "$CHECKSUM_FILE" --dir "$tmpdir" 2>/dev/null; then + "${GH_TAG_FLAG[@]}" --pattern "$CHECKSUM_FILE" --dir "$tmpdir" 2>/dev/null; then : # gh succeeded else warn "gh CLI download failed (auth may not be configured) — falling back to curl" diff --git a/test/onboard.test.js b/test/onboard.test.js index 7add59454..87e5d214c 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -31,6 +31,7 @@ import { printSandboxCreateRecoveryHints, resolveDashboardForwardTarget, shouldIncludeBuildContextPath, + versionGte, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; @@ -306,6 +307,14 @@ describe("onboard helpers", () => { expect(getStableGatewayImageRef("bogus")).toBe(null); }); + it("versionGte compares semver correctly for OpenShell pin checks", () => { + expect(versionGte("0.0.22", "0.0.22")).toBe(true); + expect(versionGte("0.0.23", "0.0.22")).toBe(true); + expect(versionGte("0.1.0", "0.0.22")).toBe(true); + expect(versionGte("0.0.21", "0.0.22")).toBe(false); + expect(versionGte("0.0.7", "0.0.22")).toBe(false); + }); + it("treats the gateway as healthy only when nemoclaw is running and connected", () => { expect( isGatewayHealthy( diff --git a/test/runner.test.js b/test/runner.test.js index 53885210f..bd387467c 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -531,6 +531,20 @@ describe("regression guards", () => { expect(src).toContain("shasum -a 256 -c"); }); + it("install-openshell.sh respects OPENSHELL_PIN_VERSION for tagged downloads", () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"), + "utf-8", + ); + // Env-var driven pin replaces hardcoded MIN_VERSION + expect(src).toContain("OPENSHELL_PIN_VERSION"); + expect(src).toContain("REQUIRED_VERSION"); + // gh path uses --tag flag when pin is set + expect(src).toContain('--tag "v${OPENSHELL_PIN_VERSION}"'); + // curl path uses versioned download URL when pin is set + expect(src).toContain("download/v${OPENSHELL_PIN_VERSION}"); + }); + it("install-openshell.sh falls back to curl when gh fails (#1318)", () => { const src = fs.readFileSync( path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"),