From 2314c513fb8f7be3665bf5fe7e5c8f55939424e4 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 20 Apr 2026 16:44:16 -0700 Subject: [PATCH 1/7] fix(install): bump OpenShell max version to 0.0.29 for Landlock enforcement OpenShell 0.0.26 does not enforce Landlock filesystem policy on the sandbox process because its Landlock setup ran after drop_privileges(), causing path fd opens to fail as the unprivileged user and the best_effort handler to silently return success. As a result, /sandbox remained writable on every platform even though NemoClaw delivered the correct policy. Upstream fixed the ordering in NVIDIA/OpenShell#810, released in openshell 0.0.29. Bump MAX_VERSION / PIN_VERSION in the installer and the max_openshell_version pin in the blueprint (plus its test fixture) so fresh installs pull 0.0.29 and existing installs on 0.0.26 are flagged as below the supported range. Closes #1739 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Prekshi Vyas --- nemoclaw-blueprint/blueprint.yaml | 2 +- scripts/install-openshell.sh | 5 ++++- test/onboard.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index d61a4e72e02..bb4a949331b 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -3,7 +3,7 @@ version: "0.1.0" min_openshell_version: "0.0.24" -max_openshell_version: "0.0.26" +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/install-openshell.sh b/scripts/install-openshell.sh index 4455855fb94..ac93f2a11e1 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -38,7 +38,10 @@ info "Detected $OS_LABEL ($ARCH_LABEL)" MIN_VERSION="0.0.24" # 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" +# Landlock filesystem policy enforcement requires >= 0.0.29 +# (NVIDIA/OpenShell#810 fixes the drop_privileges/Landlock ordering bug +# that caused /sandbox to remain writable on 0.0.26). +MAX_VERSION="0.0.29" # Pin fresh installs to this version instead of pulling "latest". PIN_VERSION="$MAX_VERSION" diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 0cced605456..a92f3b255c3 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -922,12 +922,12 @@ describe("onboard helpers", () => { [ 'version: "0.1.0"', 'min_openshell_version: "0.0.24"', - 'max_openshell_version: "0.0.26"', + '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 }); } From ed2dee585fa7c6397cccc2d2116d42ba69b5d7dc Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Apr 2026 00:22:35 +0000 Subject: [PATCH 2/7] fix(install): enforce openshell 0.0.29 as hard minimum for Landlock fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise MIN_VERSION from 0.0.24 to 0.0.29 so existing installs on 0.0.26–0.0.28 are forced to upgrade. Previously those versions passed the range check and skipped the upgrade despite carrying the drop_privileges/Landlock ordering bug (NVIDIA/OpenShell#810). Add unit tests covering the version-check branch: clean exit at 0.0.29, upgrade triggered for 0.0.24–0.0.28, and hard failure above MAX_VERSION. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Prekshi Vyas --- scripts/install-openshell.sh | 10 +- test/install-openshell-version-check.test.ts | 122 +++++++++++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 test/install-openshell-version-check.test.ts diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index ac93f2a11e1..a3f28e95e56 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,14 +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.24" -# Maximum version validated for this NemoClaw release. Newer OpenShell builds -# may change sandbox semantics; upgrade NemoClaw before upgrading past this. -# Landlock filesystem policy enforcement requires >= 0.0.29 +# 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.29" # Pin fresh installs to this version instead of pulling "latest". PIN_VERSION="$MAX_VERSION" diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts new file mode 100644 index 00000000000..89a4d839946 --- /dev/null +++ b/test/install-openshell-version-check.test.ts @@ -0,0 +1,122 @@ +// @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-")); + 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", + }); +} + +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("proceeds to install when openshell is not present", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-noop-")); + 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); + }); +}); From 863ed4743757f70421cb2bab5c4e8e7e60a4ad70 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Apr 2026 00:55:15 +0000 Subject: [PATCH 3/7] fix(install): handle m-dev version string and sync min-version constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openshell 0.0.29 self-reports "m-dev" rather than a semver string, causing three independent failures: 1. install-openshell.sh parsed the version as 0.0.0 and re-downloaded the binary on every run even though 0.0.29 was already present. 2. onboard.ts getInstalledOpenshellVersion returned null, triggering an unnecessary reinstall and silently skipping the blueprint version gate. 3. onboard.ts minParts and blueprint.yaml min_openshell_version were both still set to 0.0.24 — not updated when MIN_VERSION was raised to 0.0.29. Fix: after a successful install write PIN_VERSION to a sidecar file (.openshell-installed-version alongside the binary). Both the bash version check and the TypeScript getInstalledOpenshellVersion fall back to that file when --version is unparseable. Update minParts to [0,0,29] and min_openshell_version to "0.0.29" to match MIN_VERSION. Add two unit tests covering m-dev + sidecar: clean exit when sidecar records 0.0.29, upgrade triggered when sidecar records 0.0.26. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Prekshi Vyas --- nemoclaw-blueprint/blueprint.yaml | 2 +- scripts/install-openshell.sh | 14 ++++- src/lib/onboard.ts | 17 ++++-- test/install-openshell-version-check.test.ts | 56 ++++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index bb4a949331b..ae746e80377 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 version: "0.1.0" -min_openshell_version: "0.0.24" +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 diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index a3f28e95e56..cce693d3564 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -58,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." @@ -133,4 +140,9 @@ else sudo install -m 755 "$tmpdir/openshell" "$target_dir/openshell" fi +# 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. +echo "$PIN_VERSION" >"$target_dir/.openshell-installed-version" 2>/dev/null || true + info "$("$target_dir/openshell" --version 2>&1 || echo openshell) installed" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 133d6aab096..d52ec7d06c0 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -462,8 +462,19 @@ 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"). + if (!versionOutput && openshellBin) { + try { + const sidecar = path.join(path.dirname(openshellBin), ".openshell-installed-version"); + 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; } /** @@ -2350,7 +2361,7 @@ async function preflight() { } } else { const parts = currentVersion.split(".").map(Number); - const minParts = [0, 0, 24]; // must match MIN_VERSION in scripts/install-openshell.sh + const minParts = [0, 0, 29]; // must match MIN_VERSION in scripts/install-openshell.sh const needsUpgrade = parts[0] < minParts[0] || (parts[0] === minParts[0] && parts[1] < minParts[1]) || diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index 89a4d839946..9c573ba1ae1 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -92,6 +92,62 @@ describe("install-openshell.sh version check", () => { 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-")); + 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/); + }); + + 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-")); + 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/); + }); + it("proceeds to install when openshell is not present", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-noop-")); const fakeBin = path.join(tmp, "bin"); From 57fb502b8024b89da42c345c6f47d54dc89685e0 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Apr 2026 01:04:43 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix(install):=20address=20CodeRabbit=20revi?= =?UTF-8?q?ew=20=E2=80=94=20sidecar=20guard=20and=20tmp=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboard.ts: broaden sidecar fallback guard from (!versionOutput && openshellBin) to (openshellBin) so the fallback also fires when versionOutput is provided but unparseable (e.g. "openshell m-dev" passed from the blueprint version gate). - test: wrap all inline temp dirs in try/finally + fs.rmSync to prevent /tmp accumulation across repeated test runs. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Prekshi Vyas --- src/lib/onboard.ts | 4 +- test/install-openshell-version-check.test.ts | 186 ++++++++++--------- 2 files changed, 104 insertions(+), 86 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d52ec7d06c0..4ec180d64fd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -465,7 +465,9 @@ function getInstalledOpenshellVersion(versionOutput = null) { 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"). - if (!versionOutput && openshellBin) { + // 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"); const sidecarMatch = fs.readFileSync(sidecar, "utf-8").trim().match(/([0-9]+\.[0-9]+\.[0-9]+)/); diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index 9c573ba1ae1..2821c06a81b 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -22,36 +22,40 @@ function writeExecutable(target: string, contents: string) { */ function runWithInstalledVersion(version: string) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-ver-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + 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 + // 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 + // 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 + // 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", - }); + 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", () => { @@ -94,85 +98,97 @@ describe("install-openshell.sh version check", () => { 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-")); - 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 + 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/); + ); + + // 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-")); - const fakeBin = path.join(tmp, "bin"); - fs.mkdirSync(fakeBin); + try { + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); - writeExecutable( - path.join(fakeBin, "openshell"), - `#!/usr/bin/env bash + 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/); + ); + + // 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-")); - 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 + 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 + ); + 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); + ); + + 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 }); + } }); }); From 9fe6edb6604af134c910924502caf51184b02f92 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 20 Apr 2026 22:12:49 -0700 Subject: [PATCH 5/7] fix(install): address CodeRabbit review on #2141 - scripts/brev-launchable-ci-cpu.sh: bump OPENSHELL_VERSION default from v0.0.24 to v0.0.29 (and sync the comment) so the launchable CI path cannot provision the pre-Landlock build after this change. - scripts/install-openshell.sh: persist the .openshell-installed-version sidecar inside each install branch. The sudo path now uses `sudo tee` so system-wide installs always create the sidecar alongside the binary. Removed the blanket `2>/dev/null || true` so write failures surface under `set -e` instead of silently leaving m-dev builds unrecognized on the next run. - src/lib/onboard.ts: derive the minimum OpenShell version in the reinstall-gate branch from getBlueprintMinOpenshellVersion() instead of hardcoding [0, 0, 29], with a fallback to "0.0.29" (the Landlock floor matching MIN_VERSION in install-openshell.sh) if the blueprint is unreadable. Removes one of the three duplicates of the version constant. - test/onboard.test.ts: align the min_openshell_version fixture in the getBlueprintMaxOpenshellVersion test with the current pinned range (0.0.29) for readability. Test asserts max only, so no behavior change. --- scripts/brev-launchable-ci-cpu.sh | 4 ++-- scripts/install-openshell.sh | 14 +++++++++----- src/lib/onboard.ts | 6 +++++- test/onboard.test.ts | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index b8b1340e006..59956520f22 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 cce693d3564..913dc2cc234 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -127,22 +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 -# 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. -echo "$PIN_VERSION" >"$target_dir/.openshell-installed-version" 2>/dev/null || true - info "$("$target_dir/openshell" --version 2>&1 || echo openshell) installed" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7dbae1b2db2..f1abb9723d4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2366,8 +2366,12 @@ async function preflight() { process.exit(1); } } else { + // 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 parts = currentVersion.split(".").map(Number); - const minParts = [0, 0, 29]; // must match MIN_VERSION in scripts/install-openshell.sh + const minParts = minOpenshellVersion.split(".").map(Number); const needsUpgrade = parts[0] < minParts[0] || (parts[0] === minParts[0] && parts[1] < minParts[1]) || diff --git a/test/onboard.test.ts b/test/onboard.test.ts index a92f3b255c3..32e62ab5e09 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -921,7 +921,7 @@ describe("onboard helpers", () => { path.join(blueprintDir, "blueprint.yaml"), [ 'version: "0.1.0"', - 'min_openshell_version: "0.0.24"', + 'min_openshell_version: "0.0.29"', 'max_openshell_version: "0.0.29"', 'min_openclaw_version: "2026.3.0"', ].join("\n"), From 519b43eb630ccfa12b11be32aeac1e64537d0b10 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Apr 2026 11:19:05 -0700 Subject: [PATCH 6/7] fix(install): invalidate stale .openshell-installed-version sidecar Addresses the last CodeRabbit comment on #2141. Previously the m-dev fallback trusted the sidecar unconditionally, so a manual binary swap (cp /some/openshell /usr/local/bin/openshell) that reports an unparseable version string would silently pass the min/max version gate by re-reading the previous install's sidecar value. Now we compare the binary's mtime to the sidecar's mtime: if the binary is newer, the sidecar is stale and we return null instead of trusting it, forcing a reinstall / re-check. Also tightens the sidecar version regex with ^...$ anchors so junk-wrapped values can't partial-match. No new unit test: exercising this branch requires mocking the module-local resolveOpenshell() call + fs.statSync, which is more plumbing than the defensive one-liner warrants. Covered by: - Existing 138 tests still pass (fresh-install happy path unchanged). - The behavior matches CodeRabbit's suggested diff verbatim. --- src/lib/onboard.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8b379dbca2c..87a693ea58a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -478,7 +478,17 @@ function getInstalledOpenshellVersion(versionOutput = null) { if (openshellBin) { try { const sidecar = path.join(path.dirname(openshellBin), ".openshell-installed-version"); - const sidecarMatch = fs.readFileSync(sidecar, "utf-8").trim().match(/([0-9]+\.[0-9]+\.[0-9]+)/); + // 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 From 28699ced1486e2b81d220408db2a3045be0c49c3 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 21 Apr 2026 11:48:08 -0700 Subject: [PATCH 7/7] refactor(onboard): consolidate min-version gate + test stale sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two CodeRabbit nitpicks on #2141: 1. Reuse versionGte() for the min-version upgrade gate in preflight. The open-coded three-way numeric comparison duplicated logic already centralized in versionGte() (and used by the max-version check a few lines below). Collapses 6 lines to 1. 2. Add focused tests for the stale-sidecar branch introduced in the previous commit. Two cases: - binary newer than sidecar -> return null (stale, bypass prevented) - binary not newer than sidecar -> return parsed version (happy path) Both write a fake openshell binary + sidecar into a tmp dir, prepend it to PATH so resolveOpenshell's `command -v` lookup finds it, and control mtimes via fs.utimesSync. PATH is restored in a finally. No behavior change from nitpick 1 (versionGte produces the same result as the open-coded comparison — confirmed by existing tests that pass both before and after). Nitpick 2 locks in the stale-sidecar guard so a future regression can't silently bypass the min/max version gate. --- src/lib/onboard.ts | 7 +------ test/onboard.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 87a693ea58a..af0f1083f84 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2564,12 +2564,7 @@ async function preflight() { // 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 parts = currentVersion.split(".").map(Number); - const minParts = minOpenshellVersion.split(".").map(Number); - 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]); + const needsUpgrade = !versionGte(currentVersion, minOpenshellVersion); if (needsUpgrade) { console.log( ` openshell ${currentVersion} is below minimum required version. Upgrading...`, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 32e62ab5e09..3d4913ebd57 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -982,6 +982,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(