Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 45 additions & 5 deletions scripts/install-openshell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ NC='\033[0m'
info() { echo -e "${GREEN}[install]${NC} $1"; }
warn() { echo -e "${YELLOW}[install]${NC} $1"; }
fail() {
echo -e "${RED}[install]${NC} $1"
echo -e "${RED}[install]${NC} $1" >&2
exit 1
}

Expand All @@ -39,7 +39,10 @@ MIN_VERSION="0.0.39"
# Maximum version validated for this NemoClaw release. Newer OpenShell builds
# may change sandbox semantics; upgrade NemoClaw before upgrading past this.
MAX_VERSION="0.0.39"
# Pin fresh installs to this version instead of pulling "latest".
# Pin fresh installs to this version. The TS installer normally overrides this
# via NEMOCLAW_OPENSHELL_PIN_VERSION after resolving the highest published
# OpenShell release that satisfies the blueprint's max_openshell_version
# (see #3404). The hardcoded value is the fallback for offline runs.
PIN_VERSION="$MAX_VERSION"
DEV_MIN_VERSION="0.0.39"

Expand All @@ -55,6 +58,44 @@ else
RESOLVED_CHANNEL="$CHANNEL"
fi

# Honour the TS installer's blueprint-derived env overrides only on the stable
# channel — the dev channel installs from the `dev` tag and uses DEV_MIN_VERSION
# instead, so a malformed override should not abort a dev install (#3446 review).
# The TS layer passes MIN/MAX/PIN from the blueprint so a single source of truth
# (nemoclaw-blueprint/blueprint.yaml) drives the install (#3404).
#
# Validation is inlined (rather than wrapped in a helper that returns via
# $(...)) so a `fail` triggered here is not captured into the variable
# assignment. `fail` now writes to stderr (#3446 CodeRabbit), but keeping
# the validation outside of $(...) avoids relying on that.
if [ "$RESOLVED_CHANNEL" != "dev" ]; then
if [ -n "${NEMOCLAW_OPENSHELL_MIN_VERSION:-}" ]; then
if [[ "$NEMOCLAW_OPENSHELL_MIN_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
MIN_VERSION="$NEMOCLAW_OPENSHELL_MIN_VERSION"
else
fail "NEMOCLAW_OPENSHELL_MIN_VERSION='$NEMOCLAW_OPENSHELL_MIN_VERSION' is not a valid X.Y.Z version."
fi
fi
if [ -n "${NEMOCLAW_OPENSHELL_MAX_VERSION:-}" ]; then
if [[ "$NEMOCLAW_OPENSHELL_MAX_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
MAX_VERSION="$NEMOCLAW_OPENSHELL_MAX_VERSION"
# Intentionally do NOT default PIN_VERSION to the overridden MAX here.
# If the TS resolver couldn't reach GitHub (rate-limited / offline) it
# only sets MIN/MAX, never PIN — falling through to the script's
# hardcoded PIN_VERSION is the known-good safe path (#3446 CodeRabbit).
else
fail "NEMOCLAW_OPENSHELL_MAX_VERSION='$NEMOCLAW_OPENSHELL_MAX_VERSION' is not a valid X.Y.Z version."
fi
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if [ -n "${NEMOCLAW_OPENSHELL_PIN_VERSION:-}" ]; then
if [[ "$NEMOCLAW_OPENSHELL_PIN_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
PIN_VERSION="$NEMOCLAW_OPENSHELL_PIN_VERSION"
else
fail "NEMOCLAW_OPENSHELL_PIN_VERSION='$NEMOCLAW_OPENSHELL_PIN_VERSION' is not a valid X.Y.Z version."
fi
fi
fi

if [ "$RESOLVED_CHANNEL" = "dev" ]; then
RELEASE_TAG="dev"
else
Expand Down Expand Up @@ -229,9 +270,8 @@ if command -v openshell >/dev/null 2>&1; then
else
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."
fi
if ! required_driver_bins_present; then
warn "openshell $INSTALLED_VERSION is above the maximum ($MAX_VERSION) supported by this NemoClaw release — reinstalling pinned OpenShell ${PIN_VERSION}..."
elif ! required_driver_bins_present; then
warn "openshell $INSTALLED_VERSION is missing Docker-driver binaries — reinstalling pinned OpenShell ${PIN_VERSION}..."
elif ! openshell_has_required_messaging_features; then
fail "${OPENSHELL_FEATURE_CHECK_ERROR:-openshell $INSTALLED_VERSION is missing required messaging credential rewrite support. Install an OpenShell build that includes provider aliases, WebSocket text rewrite, and request-body credential rewrite.}"
Expand Down
49 changes: 15 additions & 34 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ const validationRecovery: typeof import("./validation-recovery") = require("./va
const webSearch: typeof import("./inference/web-search") = require("./inference/web-search");
const openshellInstallFlow: typeof import("./onboard/openshell-install") =
require("./onboard/openshell-install");
const openshellPinFlow: typeof import("./onboard/openshell-pin") =
require("./onboard/openshell-pin");
const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-failure") =
require("./onboard/sandbox-create-failure");

Expand Down Expand Up @@ -343,6 +345,7 @@ import type { WebSearchConfig } from "./inference/web-search";
import type {
DockerDriverBinaryOverrides,
OpenShellInstallDeps,
OpenShellInstallResult,
} from "./onboard/openshell-install";
import type { SelectionDrift } from "./onboard/selection-drift";

Expand Down Expand Up @@ -2765,42 +2768,20 @@ function getPortConflictServiceHints(platform = process.platform): string[] {
];
}

function installOpenshell(): {
installed: boolean;
localBin: string | null;
futureShellPathHint: string | null;
} {
const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], {
function installOpenshell(): OpenShellInstallResult {
return openshellPinFlow.runOpenshellInstall({
scriptsDir: SCRIPTS,
cwd: ROOT,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
timeout: 300_000,
resolveOpenshell,
getFutureShellPathHint,
setOpenshellBin: (bin) => {
OPENSHELL_BIN = bin;
},
getBlueprintMinOpenshellVersion,
getBlueprintMaxOpenshellVersion,
versionGte,
log: console.log,
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`.trim();
if (output) {
console.error(output);
}
return { installed: false, localBin: null, futureShellPathHint: null };
}
const localBin = process.env.XDG_BIN_HOME || path.join(process.env.HOME || "", ".local", "bin");
const openshellPath = path.join(localBin, "openshell");
const futureShellPathHint = fs.existsSync(openshellPath)
? getFutureShellPathHint(localBin, process.env.PATH)
: null;
if (fs.existsSync(openshellPath) && futureShellPathHint) {
process.env.PATH = `${localBin}${path.delimiter}${process.env.PATH}`;
}
OPENSHELL_BIN = resolveOpenshell();
if (OPENSHELL_BIN) {
process.env.NEMOCLAW_OPENSHELL_BIN = OPENSHELL_BIN;
}
return {
installed: OPENSHELL_BIN !== null,
localBin,
futureShellPathHint,
};
}

function areRequiredDockerDriverBinariesPresent(
Expand Down
71 changes: 71 additions & 0 deletions src/lib/onboard/openshell-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,77 @@ export type OpenShellInstallResult = {
futureShellPathHint: string | null;
};

export type OpenshellInstallVersionResolution =
| { kind: "pin"; version: string; latest: string | null; reason: "latest" | "max-cap" }
| { kind: "no-max"; latest: string | null }
| { kind: "incompatible"; latest: string | null; max: string; message: string };

const SEMVER_TRIPLE = /^[0-9]+\.[0-9]+\.[0-9]+$/;

/**
* Sanitize an OpenShell release tag from an external source (GitHub release tag
* name, blueprint field, env var) into a plain `X.Y.Z` version string. Returns
* null for empty, leading-`-`, or non-semver-triple inputs so callers never
* pass malformed strings into version comparison logic.
*/
export function parseOpenshellReleaseTag(tag: unknown): string | null {
if (typeof tag !== "string") return null;
const trimmed = tag.trim();
if (!trimmed) return null;
if (trimmed.startsWith("-")) return null;
const stripped = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
if (!SEMVER_TRIPLE.test(stripped)) return null;
return stripped;
}

/**
* Pure resolver for which OpenShell release the installer should fetch.
*
* - If `options.max` is null, returns `kind: "no-max"` so callers leave the
* install path alone (legacy behaviour — script picks its own pin / latest).
* - Otherwise, picks the highest entry of `available` that is `<= max`.
* Returns `kind: "incompatible"` with a message naming both latest and max
* when no such release exists.
*
* Malformed entries in `available` (empty string, leading `-`, non-semver) are
* silently dropped. The shipped blueprint guarantees `max` is valid before it
* reaches this function, but a defensive parse is also applied here.
*/
export function resolveOpenshellInstallVersion(
available: readonly string[],
options: { max: string | null },
helpers: { versionGte: (a: string, b: string) => boolean },
): OpenshellInstallVersionResolution {
const sanitized = (available ?? [])
.map((entry) => parseOpenshellReleaseTag(entry))
.filter((entry): entry is string => entry !== null);
sanitized.sort((a, b) => (helpers.versionGte(a, b) ? (a === b ? 0 : -1) : 1));
const latest = sanitized[0] ?? null;

const max = parseOpenshellReleaseTag(options.max);
if (!max) {
return { kind: "no-max", latest };
}

if (latest && helpers.versionGte(max, latest)) {
return { kind: "pin", version: latest, latest, reason: "latest" };
}

const capped = sanitized.find((entry) => helpers.versionGte(max, entry));
if (capped) {
return { kind: "pin", version: capped, latest, reason: "max-cap" };
}

return {
kind: "incompatible",
latest,
max,
message:
`No OpenShell release ≤ ${max} is available (latest published: ${latest ?? "unknown"}). ` +
"Upgrade NemoClaw or raise max_openshell_version in nemoclaw-blueprint/blueprint.yaml.",
};
}

export type DockerDriverBinaryOverrides = {
gatewayBin?: string | null;
sandboxBin?: string | null;
Expand Down
Loading
Loading