diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index d61a4e72e02..ae746e80377 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 version: "0.1.0" -min_openshell_version: "0.0.24" -max_openshell_version: "0.0.26" +min_openshell_version: "0.0.29" +max_openshell_version: "0.0.29" min_openclaw_version: "2026.4.2" # Mirrors the components.sandbox.image manifest digest below. Lets a # downstream consumer (or release tooling) verify the blueprint declares diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index 66f16a0f2b9..63be9e15f65 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -28,7 +28,7 @@ # curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw//scripts/brev-launchable-ci-cpu.sh | bash # # Environment overrides: -# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.20) +# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.29) # NEMOCLAW_REF — NemoClaw git ref to clone (default: main) # NEMOCLAW_CLONE_DIR — Where to clone NemoClaw (default: ~/NemoClaw) # SKIP_DOCKER_PULL — Set to 1 to skip Docker image pre-pulls @@ -40,7 +40,7 @@ set -euo pipefail # ── Configuration ──────────────────────────────────────────────────── -OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.24}" +OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.29}" NEMOCLAW_REF="${NEMOCLAW_REF:-main}" TARGET_USER="${SUDO_USER:-$(id -un)}" TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 4455855fb94..913dc2cc234 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,12 +33,13 @@ 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.24" +# Minimum version required for Landlock filesystem policy enforcement +# (NVIDIA/OpenShell#810 fixes the drop_privileges/Landlock ordering bug +# that caused /sandbox to remain writable on 0.0.26). +MIN_VERSION="0.0.29" # Maximum version validated for this NemoClaw release. Newer OpenShell builds # may change sandbox semantics; upgrade NemoClaw before upgrading past this. -MAX_VERSION="0.0.26" +MAX_VERSION="0.0.29" # Pin fresh installs to this version instead of pulling "latest". PIN_VERSION="$MAX_VERSION" @@ -57,7 +58,14 @@ 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')" + INSTALLED_VERSION="$(openshell --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + if [ -z "$INSTALLED_VERSION" ]; then + # Fallback: read the sidecar written by a previous install for builds that + # self-report an unparseable version string (e.g. openshell 0.0.29 → "m-dev"). + _SIDECAR="$(dirname "$(command -v openshell)")/.openshell-installed-version" + INSTALLED_VERSION="$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' "$_SIDECAR" 2>/dev/null | head -1 || true)" + fi + [ -n "$INSTALLED_VERSION" ] || INSTALLED_VERSION="0.0.0" if version_gte "$INSTALLED_VERSION" "$MIN_VERSION"; then if ! version_gte "$MAX_VERSION" "$INSTALLED_VERSION"; then fail "openshell $INSTALLED_VERSION is above the maximum ($MAX_VERSION) supported by this NemoClaw release. Upgrade NemoClaw first." @@ -119,17 +127,26 @@ tar xzf "$tmpdir/$ASSET" -C "$tmpdir" target_dir="/usr/local/bin" +# Record the pinned version so that binaries with unparseable --version output +# (e.g. openshell 0.0.29 self-reports "m-dev") can still pass the version gate +# on subsequent runs without triggering a redundant re-download. The sidecar +# is written inside each install branch so that the sudo path writes it with +# elevated privileges; under `set -e` any write failure aborts rather than +# silently missing the sidecar. if [ -w "$target_dir" ]; then install -m 755 "$tmpdir/openshell" "$target_dir/openshell" + printf '%s\n' "$PIN_VERSION" >"$target_dir/.openshell-installed-version" elif [ "${NEMOCLAW_NON_INTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then target_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" mkdir -p "$target_dir" install -m 755 "$tmpdir/openshell" "$target_dir/openshell" + printf '%s\n' "$PIN_VERSION" >"$target_dir/.openshell-installed-version" warn "Installed openshell to $target_dir/openshell (user-local path)" warn "For future shells, run: export PATH=\"$target_dir:\$PATH\"" warn "Add that export to your shell profile, or open a new shell before using openshell directly." else sudo install -m 755 "$tmpdir/openshell" "$target_dir/openshell" + printf '%s\n' "$PIN_VERSION" | sudo tee "$target_dir/.openshell-installed-version" >/dev/null fi info "$("$target_dir/openshell" --version 2>&1 || echo openshell) installed" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 130e6ce3cf3..a0e9f8d2f3d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -489,8 +489,31 @@ function getInstalledOpenshellVersion(versionOutput = null) { versionOutput ?? runCapture([openshellBin, "-V"], { ignoreError: true }), ).trim(); const match = output.match(/openshell\s+([0-9]+\.[0-9]+\.[0-9]+)/i); - if (!match) return null; - return match[1]; + if (match) return match[1]; + // Fallback: read the sidecar version file written by install-openshell.sh for + // binaries that self-report an unparseable string (e.g. openshell 0.0.29 → "m-dev"). + // Also applies when versionOutput is provided but unparseable (e.g. passed from + // runCaptureOpenshell at the blueprint version gate). + if (openshellBin) { + try { + const sidecar = path.join(path.dirname(openshellBin), ".openshell-installed-version"); + // Invalidate the sidecar if the binary has been replaced since the sidecar + // was written (manual `cp openshell /usr/local/bin/openshell` without + // re-running install-openshell.sh). Stale sidecar + unsupported binary would + // otherwise bypass the min/max version gate silently. + const binaryMtime = fs.statSync(openshellBin).mtimeMs; + const sidecarMtime = fs.statSync(sidecar).mtimeMs; + if (binaryMtime > sidecarMtime) return null; + const sidecarMatch = fs + .readFileSync(sidecar, "utf-8") + .trim() + .match(/^([0-9]+\.[0-9]+\.[0-9]+)$/); + if (sidecarMatch) return sidecarMatch[1]; + } catch { + // sidecar absent or unreadable — fall through + } + } + return null; } /** @@ -2557,12 +2580,11 @@ async function preflight() { process.exit(1); } } else { - const parts = currentVersion.split(".").map(Number); - const minParts = [0, 0, 24]; // must match MIN_VERSION in scripts/install-openshell.sh - const needsUpgrade = - parts[0] < minParts[0] || - (parts[0] === minParts[0] && parts[1] < minParts[1]) || - (parts[0] === minParts[0] && parts[1] === minParts[1] && parts[2] < minParts[2]); + // Source of truth: min_openshell_version in nemoclaw-blueprint/blueprint.yaml. + // Fall back to the Landlock-enforcement floor (also MIN_VERSION in + // scripts/install-openshell.sh) if the blueprint cannot be read. + const minOpenshellVersion = getBlueprintMinOpenshellVersion() ?? "0.0.29"; + const needsUpgrade = !versionGte(currentVersion, minOpenshellVersion); if (needsUpgrade) { console.log( ` openshell ${currentVersion} is below minimum required version. Upgrading...`, diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts new file mode 100644 index 00000000000..2821c06a81b --- /dev/null +++ b/test/install-openshell-version-check.test.ts @@ -0,0 +1,194 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const SCRIPT = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); + +function writeExecutable(target: string, contents: string) { + fs.writeFileSync(target, contents, { mode: 0o755 }); +} + +/** + * Run install-openshell.sh with a fake `openshell` binary that reports the + * given version. The download/install code path is never reached because we + * either exit early (version ok / too high) or hit the upgrade warn and then + * the script tries to download — so we stub curl and gh to fail fast. + */ +function runWithInstalledVersion(version: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-ver-")); + try { + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + // Fake openshell that reports the given version + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then echo "openshell ${version}"; exit 0; fi +exit 99`, + ); + + // Stub curl to fail so the install path exits without doing real network I/O + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +echo "curl stub: not available in test" >&2 +exit 1`, + ); + + // Stub gh CLI similarly + writeExecutable( + path.join(fakeBin, "gh"), + `#!/usr/bin/env bash +exit 1`, + ); + + return spawnSync("bash", [SCRIPT], { + env: { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` }, + encoding: "utf8", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe("install-openshell.sh version check", () => { + it("exits cleanly when openshell 0.0.29 is already installed", () => { + const result = runWithInstalledVersion("0.0.29"); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/already installed.*0\.0\.29/); + }); + + it("triggers upgrade when openshell 0.0.28 is installed (below MIN_VERSION)", () => { + const result = runWithInstalledVersion("0.0.28"); + // Script should warn about upgrade then fail at the download step (curl stub fails) + expect(result.status).not.toBe(0); + expect(result.stdout).toMatch(/below minimum.*upgrading/); + }); + + it("triggers upgrade when openshell 0.0.26 is installed (Landlock-vulnerable version)", () => { + const result = runWithInstalledVersion("0.0.26"); + expect(result.status).not.toBe(0); + expect(result.stdout).toMatch(/below minimum.*upgrading/); + }); + + it("triggers upgrade when openshell 0.0.24 is installed (old minimum)", () => { + const result = runWithInstalledVersion("0.0.24"); + expect(result.status).not.toBe(0); + expect(result.stdout).toMatch(/below minimum.*upgrading/); + }); + + it("fails with a clear error when openshell is above MAX_VERSION", () => { + const result = runWithInstalledVersion("0.0.30"); + expect(result.status).toBe(1); + expect(result.stdout).toMatch(/above the maximum/); + }); + + it("fails with a clear error when openshell is at a much newer version", () => { + const result = runWithInstalledVersion("0.1.0"); + expect(result.status).toBe(1); + expect(result.stdout).toMatch(/above the maximum/); + }); + + it("exits cleanly when openshell reports m-dev but sidecar records 0.0.29", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-mdev-")); + try { + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + // Fake openshell that reports "m-dev" (as openshell 0.0.29 does in practice) + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then echo "openshell m-dev"; exit 0; fi +exit 99`, + ); + + // Sidecar file written by a previous install + fs.writeFileSync(path.join(fakeBin, ".openshell-installed-version"), "0.0.29\n"); + + writeExecutable(path.join(fakeBin, "curl"), `#!/usr/bin/env bash\nexit 1`); + writeExecutable(path.join(fakeBin, "gh"), `#!/usr/bin/env bash\nexit 1`); + + const result = spawnSync("bash", [SCRIPT], { + env: { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/already installed.*0\.0\.29/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("triggers upgrade when openshell reports m-dev and sidecar records 0.0.26", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-mdev-old-")); + try { + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then echo "openshell m-dev"; exit 0; fi +exit 99`, + ); + + // Sidecar from an older install that pre-dates the Landlock fix + fs.writeFileSync(path.join(fakeBin, ".openshell-installed-version"), "0.0.26\n"); + + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash\necho "curl stub" >&2\nexit 1`, + ); + writeExecutable(path.join(fakeBin, "gh"), `#!/usr/bin/env bash\nexit 1`); + + const result = spawnSync("bash", [SCRIPT], { + env: { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` }, + encoding: "utf8", + }); + expect(result.status).not.toBe(0); + expect(result.stdout).toMatch(/below minimum.*upgrading/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("proceeds to install when openshell is not present", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-noop-")); + try { + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + + // No openshell binary — just stub curl/gh to fail fast + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +echo "curl stub: not available in test" >&2 +exit 1`, + ); + writeExecutable( + path.join(fakeBin, "gh"), + `#!/usr/bin/env bash +exit 1`, + ); + + const result = spawnSync("bash", [SCRIPT], { + env: { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` }, + encoding: "utf8", + }); + + // Should attempt install (not exit 0 early) and fail at the download step + expect(result.stdout).toMatch(/Installing openshell CLI/); + expect(result.status).not.toBe(0); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index e0e27b3725b..24c7e34e847 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -929,13 +929,13 @@ describe("onboard helpers", () => { path.join(blueprintDir, "blueprint.yaml"), [ 'version: "0.1.0"', - 'min_openshell_version: "0.0.24"', - 'max_openshell_version: "0.0.26"', + 'min_openshell_version: "0.0.29"', + 'max_openshell_version: "0.0.29"', 'min_openclaw_version: "2026.3.0"', ].join("\n"), ); try { - expect(getBlueprintMaxOpenshellVersion(tmpDir)).toBe("0.0.26"); + expect(getBlueprintMaxOpenshellVersion(tmpDir)).toBe("0.0.29"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -990,6 +990,55 @@ describe("onboard helpers", () => { expect(getStableGatewayImageRef("bogus")).toBe(null); }); + it("bypasses stale .openshell-installed-version when the binary is newer", () => { + // A manual binary swap (e.g. cp openshell /usr/local/bin/openshell without + // rerunning install-openshell.sh) must not be silently accepted via the + // previous install's sidecar. When binary mtime > sidecar mtime, return + // null so the caller re-runs the install / version gate. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sidecar-stale-")); + const binPath = path.join(tmp, "openshell"); + const sidecarPath = path.join(tmp, ".openshell-installed-version"); + const originalPath = process.env.PATH; + try { + fs.writeFileSync(binPath, "#!/bin/sh\necho 'openshell m-dev'\n", { mode: 0o755 }); + fs.writeFileSync(sidecarPath, "0.0.29\n"); + const past = new Date(Date.now() - 60_000); + const now = new Date(); + fs.utimesSync(sidecarPath, past, past); + fs.utimesSync(binPath, now, now); + // Prepend tmp so `command -v openshell` in resolveOpenshell finds our fake. + process.env.PATH = `${tmp}:${originalPath}`; + // Unparseable versionOutput → falls through to the sidecar branch. + expect(getInstalledOpenshellVersion("openshell m-dev")).toBe(null); + } finally { + process.env.PATH = originalPath; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("accepts .openshell-installed-version when the sidecar is not older than the binary", () => { + // Happy path: install-openshell.sh writes the sidecar immediately after + // installing the binary, so sidecar mtime >= binary mtime. Sidecar value + // is returned for binaries that self-report unparseable versions (m-dev). + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sidecar-fresh-")); + const binPath = path.join(tmp, "openshell"); + const sidecarPath = path.join(tmp, ".openshell-installed-version"); + const originalPath = process.env.PATH; + try { + fs.writeFileSync(binPath, "#!/bin/sh\necho 'openshell m-dev'\n", { mode: 0o755 }); + fs.writeFileSync(sidecarPath, "0.0.29\n"); + const past = new Date(Date.now() - 60_000); + const now = new Date(); + fs.utimesSync(binPath, past, past); + fs.utimesSync(sidecarPath, now, now); + process.env.PATH = `${tmp}:${originalPath}`; + expect(getInstalledOpenshellVersion("openshell m-dev")).toBe("0.0.29"); + } finally { + process.env.PATH = originalPath; + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("treats the gateway as healthy only when nemoclaw is running and connected", () => { expect( isGatewayHealthy(