mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user