From ffbbb6417871ec7b6ab3f2bf03bd7bfea56392a6 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 18 Aug 2026 16:12:10 -0700 Subject: [PATCH] fix: plugin install rejects bare prerelease gateway minimums (#126020) * fix(plugins): accept bare prerelease gateway minimums * fix(plugins): honor prerelease host ordering --- src/infra/runtime-guard.test.ts | 16 ------ src/infra/runtime-guard.ts | 2 +- .../test-helpers/package-manifest-contract.ts | 21 +++---- src/plugins/clawhub-error-codes.ts | 2 + src/plugins/clawhub.test.ts | 57 +++++++++++++++++++ src/plugins/clawhub.ts | 28 ++++++--- src/plugins/min-host-version.test.ts | 40 +++++++++++-- src/plugins/min-host-version.ts | 18 +++--- 8 files changed, 135 insertions(+), 49 deletions(-) diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index 74a5125091f4..0afb3ab483bb 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -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); diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index 59c482d13297..657f6618abe9 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -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; } diff --git a/src/plugin-sdk/test-helpers/package-manifest-contract.ts b/src/plugin-sdk/test-helpers/package-manifest-contract.ts index 9fb22b4ac0c1..cf744621baee 100644 --- a/src/plugin-sdk/test-helpers/package-manifest-contract.ts +++ b/src/plugin-sdk/test-helpers/package-manifest-contract.ts @@ -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); }); } }); diff --git a/src/plugins/clawhub-error-codes.ts b/src/plugins/clawhub-error-codes.ts index f39f680cb4b2..281c427600fa 100644 --- a/src/plugins/clawhub-error-codes.ts +++ b/src/plugins/clawhub-error-codes.ts @@ -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", diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index a6365b03eb9e..8cfb2ea4368b 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -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({ diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index bf84d01db321..730cbd59a9fd 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -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, ); } diff --git a/src/plugins/min-host-version.test.ts b/src/plugins/min-host-version.test.ts index ba78cac55521..2eb6053913e5 100644 --- a/src/plugins/min-host-version.test.ts +++ b/src/plugins/min-host-version.test.ts @@ -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); + }, + ); }); diff --git a/src/plugins/min-host-version.ts b/src/plugins/min-host-version.ts index c36fc5c802c1..8c000a2b0185 100644 --- a/src/plugins/min-host-version.ts +++ b/src/plugins/min-host-version.ts @@ -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",