Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2314c51
fix(install): bump OpenShell max version to 0.0.29 for Landlock enfor…
prekshivyas Apr 20, 2026
ed2dee5
fix(install): enforce openshell 0.0.29 as hard minimum for Landlock fix
prekshivyas Apr 21, 2026
1b9da8a
Merge branch 'main' into fix/1739-openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
fbc2cb0
Merge branch 'main' into fix/1739-openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
863ed47
fix(install): handle m-dev version string and sync min-version constants
prekshivyas Apr 21, 2026
57fb502
fix(install): address CodeRabbit review — sidecar guard and tmp cleanup
prekshivyas Apr 21, 2026
bb00e3b
Merge branch 'main' into fix/1739-openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
778cd43
Merge branch 'main' into fix/1739-openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
08a5b00
Merge branch 'main' into fix/1739-openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
9fe6edb
fix(install): address CodeRabbit review on #2141
prekshivyas Apr 21, 2026
f720a83
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
d956ff9
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
771ee87
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
adcb80e
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
972c0e0
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
519b43e
fix(install): invalidate stale .openshell-installed-version sidecar
prekshivyas Apr 21, 2026
28699ce
refactor(onboard): consolidate min-version gate + test stale sidecar
prekshivyas Apr 21, 2026
553c2d0
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
5034333
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
eccb325
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
9e3360c
Merge branch 'main' into prekshi/openshell-0.0.29-landlock
prekshivyas Apr 21, 2026
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
4 changes: 2 additions & 2 deletions nemoclaw-blueprint/blueprint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions scripts/brev-launchable-ci-cpu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
# curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/<ref>/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
Expand All @@ -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)"
Expand Down
27 changes: 22 additions & 5 deletions scripts/install-openshell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Pin fresh installs to this version instead of pulling "latest".
PIN_VERSION="$MAX_VERSION"

Expand All @@ -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."
Expand Down Expand Up @@ -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"
38 changes: 30 additions & 8 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a log?

}
}
return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -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...`,
Expand Down
194 changes: 194 additions & 0 deletions test/install-openshell-version-check.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading
Loading