Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 11 additions & 1 deletion scripts/install-openshell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,18 @@ 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"
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
DEV_MIN_VERSION="0.0.39"

CHANNEL="${NEMOCLAW_OPENSHELL_CHANNEL:-auto}"
Expand Down
101 changes: 100 additions & 1 deletion src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2767,14 +2767,111 @@ function getPortConflictServiceHints(platform = process.platform): string[] {
];
}

/**
* Query published OpenShell release tags from GitHub. Returns `null` on any
* fetch failure (gh missing, curl failure, network down) so callers can fall
* back to the legacy install behaviour instead of hard-failing. Returns
* `string[]` (possibly empty) on a successful query.
*/
function listOpenshellReleaseTags(): string[] | null {
const ghResult = spawnSync(
"gh",
[
"release",
"list",
"--repo",
"NVIDIA/OpenShell",
"--limit",
"100",
"--json",
"tagName",
],
{
env: {
...process.env,
GH_PROMPT_DISABLED: "1",
GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "",
},
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
);
if (ghResult.status === 0 && typeof ghResult.stdout === "string") {
try {
const parsed = JSON.parse(ghResult.stdout);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (entry && typeof entry.tagName === "string" ? entry.tagName : null))
.filter((tag): tag is string => tag !== null);
}
} catch {
// fall through to curl
}
}
const curlResult = spawnSync(
"curl",
[
"-fsSL",
"-H",
"Accept: application/vnd.github+json",
"https://api.github.com/repos/NVIDIA/OpenShell/releases?per_page=100",
],
{
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
);
if (curlResult.status === 0 && typeof curlResult.stdout === "string") {
try {
const parsed = JSON.parse(curlResult.stdout);
if (Array.isArray(parsed)) {
return parsed
.map((entry) => (entry && typeof entry.tag_name === "string" ? entry.tag_name : null))
.filter((tag): tag is string => tag !== null);
}
} catch {
return null;
}
}
return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

function installOpenshell(): {
installed: boolean;
localBin: string | null;
futureShellPathHint: string | null;
} {
const installEnv: NodeJS.ProcessEnv = { ...process.env };
const maxVersion = getBlueprintMaxOpenshellVersion();
if (maxVersion) {
const releases = listOpenshellReleaseTags();
if (releases !== null && releases.length > 0) {
const resolution = openshellInstallFlow.resolveOpenshellInstallVersion(
releases,
{ max: maxVersion },
{ versionGte },
);
if (resolution.kind === "incompatible") {
console.error("");
console.error(` ✗ ${resolution.message}`);
console.error("");
return { installed: false, localBin: null, futureShellPathHint: null };
}
if (resolution.kind === "pin") {
installEnv.NEMOCLAW_OPENSHELL_PIN_VERSION = resolution.version;
if (resolution.reason === "max-cap") {
console.log(
` Pinning OpenShell to ${resolution.version} (latest ${resolution.latest ?? "unknown"} exceeds blueprint max ${maxVersion})`,
);
}
}
}
}
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,
Expand Down Expand Up @@ -11010,6 +11107,8 @@ module.exports = {
getFutureShellPathHint,
areRequiredDockerDriverBinariesPresent,
ensureOpenshellForOnboard,
parseOpenshellReleaseTag: openshellInstallFlow.parseOpenshellReleaseTag,
resolveOpenshellInstallVersion: openshellInstallFlow.resolveOpenshellInstallVersion,
shouldRequireDockerDriverEnv,
getGatewayBootstrapRepairPlan,
getGatewayLocalEndpoint,
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
109 changes: 109 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2503,6 +2503,115 @@ const { loadAgent } = require(${agentDefsPath});
expect(versionGte(max, min)).toBe(true);
});

describe("resolveOpenshellInstallVersion (#3404)", () => {
const onboardModule = require("../dist/lib/onboard") as {
parseOpenshellReleaseTag: (tag: unknown) => string | null;
resolveOpenshellInstallVersion: (
available: readonly string[],
options: { max: string | null },
helpers: { versionGte: (a: string, b: string) => boolean },
) => {
kind: "pin" | "no-max" | "incompatible";
version?: string;
latest?: string | null;
max?: string;
message?: string;
reason?: "latest" | "max-cap";
};
};
const helpers = { versionGte };

it("picks the highest available release ≤ max when latest exceeds max", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.34", "0.0.35", "v0.0.38"],
{ max: "0.0.36" },
helpers,
);
expect(result.kind).toBe("pin");
expect(result.version).toBe("0.0.35");
expect(result.reason).toBe("max-cap");
expect(result.latest).toBe("0.0.38");
});

it("picks latest unchanged when latest is ≤ max", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.34", "0.0.35", "v0.0.36"],
{ max: "0.0.39" },
helpers,
);
expect(result.kind).toBe("pin");
expect(result.version).toBe("0.0.36");
expect(result.reason).toBe("latest");
expect(result.latest).toBe("0.0.36");
});

it("returns an incompatible resolution when no release ≤ max exists", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.38", "0.0.39"],
{ max: "0.0.36" },
helpers,
);
expect(result.kind).toBe("incompatible");
expect(result.latest).toBe("0.0.39");
expect(result.max).toBe("0.0.36");
expect(result.message).toContain("0.0.39");
expect(result.message).toContain("0.0.36");
});

it("falls back to legacy fetch behaviour when max is missing", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.38", "0.0.39"],
{ max: null },
helpers,
);
expect(result.kind).toBe("no-max");
expect(result.latest).toBe("0.0.39");
});

it("falls back to legacy fetch when max is malformed", () => {
for (const max of ["", "-1.0.0", "not-a-version", "v"] as const) {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.38"],
{ max },
helpers,
);
expect(result.kind).toBe("no-max");
}
});

it("silently drops malformed entries from the available list", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["", "v0.0.35", "-1.0.0", "not-a-version", "v0.0.34"],
{ max: "0.0.36" },
helpers,
);
expect(result.kind).toBe("pin");
expect(result.version).toBe("0.0.35");
});

it("parseOpenshellReleaseTag strips leading v and rejects malformed input", () => {
expect(onboardModule.parseOpenshellReleaseTag("v0.0.39")).toBe("0.0.39");
expect(onboardModule.parseOpenshellReleaseTag("0.0.39")).toBe("0.0.39");
expect(onboardModule.parseOpenshellReleaseTag("")).toBe(null);
expect(onboardModule.parseOpenshellReleaseTag(" ")).toBe(null);
expect(onboardModule.parseOpenshellReleaseTag("-1.0.0")).toBe(null);
expect(onboardModule.parseOpenshellReleaseTag("0.0")).toBe(null);
expect(onboardModule.parseOpenshellReleaseTag(42)).toBe(null);
expect(onboardModule.parseOpenshellReleaseTag(null)).toBe(null);
});

it("matches the DGX Spark repro: latest=0.0.38 max=0.0.36 picks 0.0.36", () => {
const result = onboardModule.resolveOpenshellInstallVersion(
["v0.0.36", "v0.0.37", "v0.0.38"],
{ max: "0.0.36" },
helpers,
);
expect(result.kind).toBe("pin");
expect(result.version).toBe("0.0.36");
expect(result.reason).toBe("max-cap");
});
});

it("pins the gateway image to the installed OpenShell release version", () => {
expect(getInstalledOpenshellVersion("openshell 0.0.12")).toBe("0.0.12");
expect(getInstalledOpenshellVersion("openshell 0.0.13-dev.8+gbbcaed2ea")).toBe("0.0.13");
Expand Down
Loading