fix: plugin install rejects bare prerelease gateway minimums (#126020)

* fix(plugins): accept bare prerelease gateway minimums

* fix(plugins): honor prerelease host ordering
This commit is contained in:
Josh Lehman
2026-08-18 16:12:10 -07:00
committed by GitHub
parent 48c05d435e
commit ffbbb64178
8 changed files with 135 additions and 49 deletions
-16
View File
@@ -2,7 +2,6 @@
import { describe, expect, it, vi } from "vitest";
import {
assertSupportedRuntime,
isAtLeast,
isSupportedNodeVersion,
nodeVersionSatisfiesEngine,
parseSemver,
@@ -16,21 +15,6 @@ describe("runtime-guard", () => {
expect(parseSemver("invalid")).toBeNull();
});
it("compares versions correctly", () => {
expect(isAtLeast({ major: 22, minor: 16, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
true,
);
expect(isAtLeast({ major: 22, minor: 17, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
true,
);
expect(isAtLeast({ major: 22, minor: 15, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
false,
);
expect(isAtLeast({ major: 21, minor: 9, patch: 0 }, { major: 22, minor: 16, patch: 0 })).toBe(
false,
);
});
it("checks node versions against simple engine ranges", () => {
expect(nodeVersionSatisfiesEngine("22.22.3", ">=22.22.3")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.22.2", ">=22.22.3")).toBe(false);
+1 -1
View File
@@ -63,7 +63,7 @@ export function parseSemver(version: string | null): Semver | null {
}
/** Compares parsed semver triples against an inclusive minimum version. */
export function isAtLeast(version: Semver | null, minimum: Semver): boolean {
function isAtLeast(version: Semver | null, minimum: Semver): boolean {
if (!version) {
return false;
}
@@ -4,7 +4,7 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { isAtLeast, parseSemver } from "../../infra/runtime-guard.js";
import { compareOpenClawVersions } from "../../config/version.js";
import { parseMinHostVersionRequirement } from "../../plugins/min-host-version.js";
type PackageManifest = {
@@ -58,12 +58,6 @@ export function describePackageManifestContract(params: PackageManifestContractP
const minHostVersionBaseline = params.minHostVersionBaseline;
if (minHostVersionBaseline) {
it("declares a parseable minHostVersion floor at or above the baseline", () => {
const baseline = parseSemver(minHostVersionBaseline);
expect(baseline).not.toBeNull();
if (!baseline) {
return;
}
const manifest = readPackageManifest(packagePath);
const requirement = parseMinHostVersionRequirement(
manifest.openclaw?.install?.minHostVersion ?? null,
@@ -77,16 +71,19 @@ export function describePackageManifestContract(params: PackageManifestContractP
return;
}
const minimum = parseSemver(requirement.minimumLabel);
expect(minimum, `${packagePath} should use a parseable semver floor`).not.toBeNull();
if (!minimum) {
const comparison = compareOpenClawVersions(
requirement.minimumLabel,
minHostVersionBaseline,
);
expect(comparison, `${packagePath} should use a parseable semver floor`).not.toBeNull();
if (comparison === null) {
return;
}
expect(
isAtLeast(minimum, baseline),
comparison,
`${packagePath} should require at least OpenClaw ${minHostVersionBaseline}`,
).toBe(true);
).toBeGreaterThanOrEqual(0);
});
}
});
+2
View File
@@ -8,6 +8,8 @@ export const CLAWHUB_INSTALL_ERROR_CODE = {
UNSUPPORTED_FAMILY: "unsupported_family",
PRIVATE_PACKAGE: "private_package",
INCOMPATIBLE_PLUGIN_API: "incompatible_plugin_api",
INVALID_GATEWAY_VERSION: "invalid_gateway_version",
UNKNOWN_GATEWAY_VERSION: "unknown_gateway_version",
INCOMPATIBLE_GATEWAY: "incompatible_gateway",
ARTIFACT_UNAVAILABLE: "artifact_unavailable",
MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity",
+57
View File
@@ -1733,6 +1733,63 @@ describe("installPluginFromClawHub", () => {
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
it("installs when a newer host satisfies a bare prerelease gateway minimum", async () => {
resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.8.1");
mockClawHubVersionMetadata({
version: "1.0.0-beta.3",
sha256hash: DEMO_ARCHIVE_SHA256,
compatibility: {
pluginApiRange: ">=2026.7.2-beta.2",
minGatewayVersion: "2026.7.2-beta.2",
},
});
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
});
expectSuccessfulClawHubInstall(result);
expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledTimes(1);
expect(installPluginFromArchiveMock).toHaveBeenCalledTimes(1);
});
it("reports invalid gateway metadata distinctly from host incompatibility", async () => {
mockClawHubVersionMetadata({
compatibility: {
pluginApiRange: ">=2026.3.22",
minGatewayVersion: "not-semver",
},
});
const result = await installPluginFromClawHub({ spec: "clawhub:demo" });
const failure = expectInstallFailure(result);
expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.INVALID_GATEWAY_VERSION);
expect(failure.error).toBe(
'ClawHub package "demo" declares invalid minGatewayVersion metadata "not-semver"; report the package metadata to its publisher.',
);
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
});
it("reports an unknown host version distinctly from incompatibility", async () => {
resolveCompatibilityHostVersionMock.mockReturnValueOnce("unknown");
mockClawHubVersionMetadata({
compatibility: {
minGatewayVersion: "2026.3.22",
},
});
const result = await installPluginFromClawHub({ spec: "clawhub:demo" });
const failure = expectInstallFailure(result);
expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.UNKNOWN_GATEWAY_VERSION);
expect(failure.error).toContain("this host version could not be determined");
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
});
it("does not let a wildcard plugin API range hide an invalid runtime version", async () => {
resolveCompatibilityHostVersionMock.mockReturnValueOnce("invalid");
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
+21 -7
View File
@@ -1148,16 +1148,30 @@ function validateClawHubPluginPackage(params: {
);
}
if (
compatibility?.minGatewayVersion &&
!checkMinHostVersion({
const minGatewayVersion = compatibility?.minGatewayVersion;
if (minGatewayVersion) {
const minGatewayVersionCheck = checkMinHostVersion({
currentVersion: runtimeVersion,
minHostVersion: compatibility.minGatewayVersion,
minHostVersion: minGatewayVersion,
allowLegacyBareSemver: true,
}).ok
) {
});
if (minGatewayVersionCheck.ok) {
return null;
}
if (minGatewayVersionCheck.kind === "invalid") {
return buildClawHubInstallFailure(
`ClawHub package "${pkg.name}" declares invalid minGatewayVersion metadata "${sanitizeTerminalText(minGatewayVersion)}"; report the package metadata to its publisher.`,
CLAWHUB_INSTALL_ERROR_CODE.INVALID_GATEWAY_VERSION,
);
}
if (minGatewayVersionCheck.kind === "unknown_host_version") {
return buildClawHubInstallFailure(
`Plugin "${pkg.name}" requires OpenClaw >=${minGatewayVersionCheck.requirement.minimumLabel}, but this host version could not be determined. Re-run from a released build or set OPENCLAW_VERSION and retry.`,
CLAWHUB_INSTALL_ERROR_CODE.UNKNOWN_GATEWAY_VERSION,
);
}
return buildClawHubInstallFailure(
`Plugin "${pkg.name}" requires OpenClaw >=${compatibility.minGatewayVersion}, but this host is ${runtimeVersion}.`,
`Plugin "${pkg.name}" requires OpenClaw >=${minGatewayVersionCheck.requirement.minimumLabel}, but this host is ${minGatewayVersionCheck.currentVersion}.`,
CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_GATEWAY,
);
}
+36 -4
View File
@@ -48,6 +48,7 @@ describe("min-host-version", () => {
raw: ">=2026.5.1+20260501",
minimumLabel: "2026.5.1+20260501",
});
expect(parseMinHostVersionRequirement(">=2026.5.1-beta..1")).toBeNull();
});
it("can parse legacy bare semver floors for runtime upgrade compatibility", () => {
@@ -55,17 +56,25 @@ describe("min-host-version", () => {
raw: "2026.3.22",
minimumLabel: "2026.3.22",
});
expect(
parseMinHostVersionRequirement("2026.7.2-beta.2+build.7", {
allowLegacyBareSemver: true,
}),
).toEqual({
raw: "2026.7.2-beta.2+build.7",
minimumLabel: "2026.7.2-beta.2+build.7",
});
expect(
checkMinHostVersion({
currentVersion: "OpenClaw 2026.3.22",
minHostVersion: "2026.3.22",
currentVersion: "2026.8.1",
minHostVersion: "2026.7.2-beta.2",
allowLegacyBareSemver: true,
}),
).toEqual({
ok: true,
requirement: {
raw: "2026.3.22",
minimumLabel: "2026.3.22",
raw: "2026.7.2-beta.2",
minimumLabel: "2026.7.2-beta.2",
},
});
});
@@ -104,4 +113,27 @@ describe("min-host-version", () => {
expectValidHostCheck(currentVersion, ">=2026.3.22");
},
);
it.each([
{
currentVersion: "2026.7.2-beta.1",
minHostVersion: ">=2026.7.2-beta.2",
expectedOk: false,
},
{
currentVersion: "2026.7.2-beta.2",
minHostVersion: ">=2026.7.2",
expectedOk: false,
},
{
currentVersion: "2026.7.2",
minHostVersion: ">=2026.7.2-beta.2",
expectedOk: true,
},
] as const)(
"compares prerelease precedence: $currentVersion against $minHostVersion",
({ currentVersion, minHostVersion, expectedOk }) => {
expect(checkMinHostVersion({ currentVersion, minHostVersion }).ok).toBe(expectedOk);
},
);
});
+9 -9
View File
@@ -1,13 +1,14 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
// Checks plugin minimum host version compatibility.
import { isAtLeast, parseSemver } from "../infra/runtime-guard.js";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { valid as validSemver } from "semver";
import { compareOpenClawVersions } from "../config/version.js";
/** Validation message for plugin minHostVersion manifest fields. */
const MIN_HOST_VERSION_FORMAT =
'openclaw.install.minHostVersion must use a semver floor in the form ">=x.y.z[-prerelease][+build]"';
const SEMVER_LABEL_RE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
const MIN_HOST_VERSION_RE = new RegExp(`^>=(${SEMVER_LABEL_RE})$`);
const LEGACY_MIN_HOST_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const LEGACY_MIN_HOST_VERSION_RE = new RegExp(`^(${SEMVER_LABEL_RE})$`);
/** Parsed plugin minimum host version requirement. */
type MinHostVersionRequirement = {
@@ -45,8 +46,8 @@ export function parseMinHostVersionRequirement(
if (!match) {
return null;
}
const minimumLabel = match.length >= 4 ? `${match[1]}.${match[2]}.${match[3]}` : (match[1] ?? "");
if (!parseSemver(minimumLabel)) {
const minimumLabel = match[1] ?? "";
if (!validSemver(minimumLabel)) {
return null;
}
return {
@@ -71,16 +72,15 @@ export function checkMinHostVersion(params: {
return { ok: false, kind: "invalid", error: MIN_HOST_VERSION_FORMAT };
}
const currentVersion = normalizeOptionalString(params.currentVersion) || "unknown";
const currentSemver = parseSemver(currentVersion);
if (!currentSemver) {
const comparison = compareOpenClawVersions(currentVersion, requirement.minimumLabel);
if (comparison === null) {
return {
ok: false,
kind: "unknown_host_version",
requirement,
};
}
const minimumSemver = parseSemver(requirement.minimumLabel)!;
if (!isAtLeast(currentSemver, minimumSemver)) {
if (comparison < 0) {
return {
ok: false,
kind: "incompatible",