Skip to content

Commit 8da7378

Browse files
committed
fix(clawhub): use semver for plugin API ranges
1 parent cf8b57e commit 8da7378

3 files changed

Lines changed: 22 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
3434

3535
### Fixes
3636

37+
- **ClawHub plugin API ranges:** delegate each supported comparator to `semver` so tilde, partial-wildcard, and prerelease caret bounds are correct while preserving OpenClaw version normalization and the existing restricted range grammar. (#106877)
3738
- **Web Readability relative links:** seed parsed documents with the request URL so article links resolve correctly while removing the plugin's duplicate lazy-loader facade. (#106860)
3839
- **Browser auto-routing:** fall back to the Gateway host when an implicitly selected browser node reports that its control host is unreachable, while preserving explicit node pins and ambiguous action failures.
3940
- **Discord voice participant context:** maintain the live Gateway voice-state roster and include current channel participants in authorized voice agent turns so agents can answer who is present.

src/infra/clawhub.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,12 @@ describe("clawhub helpers", () => {
236236

237237
it("checks plugin api ranges with semver precedence", () => {
238238
expect(satisfiesPluginApiRange("1.2.3", "^1.2.0")).toBe(true);
239+
expect(satisfiesPluginApiRange("1.2.3", "~1.2.0")).toBe(true);
240+
expect(satisfiesPluginApiRange("1.2.3", "1.2.x")).toBe(true);
239241
expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true);
242+
expect(satisfiesPluginApiRange("1.3.0", "~1.2.0")).toBe(false);
240243
expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false);
244+
expect(satisfiesPluginApiRange("2.0.0-beta.1", "^1.2.0")).toBe(false);
241245
expect(satisfiesPluginApiRange("1.1.9", ">=1.2.0")).toBe(false);
242246
expect(satisfiesPluginApiRange("2026.3.22", ">=2026.3.22")).toBe(true);
243247
expect(satisfiesPluginApiRange("2026.3.21", ">=2026.3.22")).toBe(false);
@@ -266,6 +270,7 @@ describe("clawhub helpers", () => {
266270
expect(satisfiesPluginApiRange("2026.5.2", "2026.4")).toBe(true);
267271
expect(satisfiesPluginApiRange("2026.4.0", "2026.4")).toBe(true);
268272
expect(satisfiesPluginApiRange("2026.3.99", "2026.4")).toBe(false);
273+
expect(satisfiesPluginApiRange("2026.4.1", "=2026.4")).toBe(false);
269274
expect(satisfiesPluginApiRange("2026.5.2", "=2026.4")).toBe(false);
270275
expect(satisfiesPluginApiRange("invalid", "2026.4")).toBe(false);
271276
});
@@ -288,6 +293,10 @@ describe("clawhub helpers", () => {
288293
expect(satisfiesPluginApiRange("invalid", "*")).toBe(false);
289294
expect(satisfiesPluginApiRange("2026.3.24", ">*")).toBe(false);
290295
expect(satisfiesPluginApiRange("2026.3.24", "<*")).toBe(false);
296+
expect(satisfiesPluginApiRange("1.5.0", ">=1.0.0 || >=2.0.0")).toBe(false);
297+
expect(satisfiesPluginApiRange("1.2.3", "1.2.3||2.0.0")).toBe(false);
298+
expect(satisfiesPluginApiRange("1.5.0", "1.0.0 - 2.0.0")).toBe(false);
299+
expect(satisfiesPluginApiRange("1.2.3", "~>1.2.3")).toBe(false);
291300
});
292301

293302
it("checks min gateway versions with loose host labels", () => {

src/infra/clawhub.ts

Lines changed: 12 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
normalizeOptionalString,
1010
} from "@openclaw/normalization-core/string-coerce";
1111
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
12-
import { parse as parseSemverVersion, prerelease as parseSemverPrerelease } from "semver";
12+
import { prerelease as parseSemverPrerelease, satisfies as satisfiesSemver } from "semver";
1313
import { retryClawHubRead } from "./clawhub-retry.js";
1414
import { sha256Base64, sha256Hex as digestSha256Hex } from "./crypto-digest.js";
1515
import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js";
@@ -19,7 +19,6 @@ import {
1919
parseStrictPositiveInteger,
2020
} from "./parse-finite-number.js";
2121
import { isAtLeast, parseSemver } from "./runtime-guard.js";
22-
import { compareValidSemver } from "./semver.js";
2322
import { createTempDownloadTarget } from "./temp-download.js";
2423
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
2524

@@ -546,35 +545,6 @@ function normalizePartialComparableVersion(version: string): {
546545
: { version: trimmed, isPartial: false };
547546
}
548547

549-
function compareSemver(left: string, right: string): number | null {
550-
const normalizedLeft = normalizePartialComparableVersion(left).version;
551-
const normalizedRight = normalizePartialComparableVersion(right).version;
552-
return compareValidSemver(normalizedLeft, normalizedRight);
553-
}
554-
555-
function upperBoundForCaret(version: string): string | null {
556-
const parsed = parseSemverVersion(normalizePartialComparableVersion(version).version);
557-
if (!parsed) {
558-
return null;
559-
}
560-
if (parsed.major > 0) {
561-
return `${parsed.major + 1}.0.0`;
562-
}
563-
if (parsed.minor > 0) {
564-
return `0.${parsed.minor + 1}.0`;
565-
}
566-
return `0.0.${parsed.patch + 1}`;
567-
}
568-
569-
function matchWildcardComparator(token: string): "any" | "none" | null {
570-
const match = /^(>=|<=|>|<|=|\^|~)?\s*([*xX])$/.exec(token);
571-
if (!match) {
572-
return null;
573-
}
574-
const operator = match[1];
575-
return operator === ">" || operator === "<" ? "none" : "any";
576-
}
577-
578548
function shouldPreservePluginApiPrereleaseFloor(target: string): boolean {
579549
return Boolean(parseSemverPrerelease(normalizePartialComparableVersion(target).version));
580550
}
@@ -594,49 +564,28 @@ function satisfiesComparator(version: string, token: string): boolean {
594564
if (!trimmed) {
595565
return true;
596566
}
597-
const wildcard = matchWildcardComparator(trimmed);
598-
if (wildcard) {
599-
return wildcard === "any" && parseSemverVersion(version) != null;
600-
}
601-
if (trimmed.startsWith("^")) {
602-
const base = trimmed.slice(1).trim();
603-
const upperBound = upperBoundForCaret(base);
604-
const comparableVersion = normalizePluginApiVersionForComparator(version, base);
605-
const lowerCmp = compareSemver(comparableVersion, base);
606-
const upperCmp = upperBound ? compareSemver(comparableVersion, upperBound) : null;
607-
return lowerCmp != null && upperCmp != null && lowerCmp >= 0 && upperCmp < 0;
608-
}
609-
610-
const match = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(trimmed);
567+
const match = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed);
611568
if (!match) {
612569
return false;
613570
}
614-
const operator = match[1];
571+
const operator = match[1] ?? "";
615572
const target = match[2]?.trim();
616-
if (!target) {
573+
if (!target || /^[<>=^~]/.test(target)) {
617574
return false;
618575
}
619576
const comparableVersion = normalizePluginApiVersionForComparator(version, target);
620577
const normalizedTarget = normalizePartialComparableVersion(target);
621-
const cmp = compareSemver(comparableVersion, normalizedTarget.version);
622-
if (cmp == null) {
623-
return false;
624-
}
625-
switch (operator) {
626-
case ">=":
627-
return cmp >= 0;
628-
case "<=":
629-
return cmp <= 0;
630-
case ">":
631-
return cmp > 0;
632-
case "<":
633-
return cmp < 0;
634-
default:
635-
return normalizedTarget.isPartial && !operator ? cmp >= 0 : cmp === 0;
636-
}
578+
const comparator =
579+
normalizedTarget.isPartial && !operator
580+
? `>=${normalizedTarget.version}`
581+
: `${operator}${normalizedTarget.version}`;
582+
return satisfiesSemver(comparableVersion, comparator, { includePrerelease: true });
637583
}
638584

639585
function satisfiesSemverRange(version: string, range: string): boolean {
586+
if (range.includes("||")) {
587+
return false;
588+
}
640589
const tokens = normalizeStringEntries(range.trim().split(/\s+/));
641590
if (tokens.length === 0) {
642591
return false;

0 commit comments

Comments
 (0)