diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a6fd760d6..02bd6a3d3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. +- CLI/plugins: accept ClawHub plugin API wildcard ranges such as `*` without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo. - Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex. - Browser/plugins: auto-start the bundled browser plugin when root `browser` config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist. Thanks @codex. - Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex. diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index e01962dbffa..a8ff4ef33cb 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -87,6 +87,26 @@ describe("clawhub helpers", () => { expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false); }); + it.each(["*", "x", "X", "=*", "=x", ">=*", ">=x", "<=*", "^*", "~*"] as const)( + "accepts plugin api wildcard range %s for valid runtime versions", + (range) => { + expect(satisfiesPluginApiRange("2026.3.24", range)).toBe(true); + expect(satisfiesPluginApiRange("1.0.0", range)).toBe(true); + }, + ); + + it("keeps wildcard plugin api ranges intersected with concrete comparators", () => { + expect(satisfiesPluginApiRange("2026.3.24", "* >=2026.3.22")).toBe(true); + expect(satisfiesPluginApiRange("2026.3.21", "* >=2026.3.22")).toBe(false); + expect(satisfiesPluginApiRange("2026.3.24", "x <2026.3.24")).toBe(false); + }); + + it("rejects invalid runtime versions and impossible wildcard comparators", () => { + expect(satisfiesPluginApiRange("invalid", "*")).toBe(false); + expect(satisfiesPluginApiRange("2026.3.24", ">*")).toBe(false); + expect(satisfiesPluginApiRange("2026.3.24", "<*")).toBe(false); + }); + it("checks min gateway versions with loose host labels", () => { expect(satisfiesGatewayMinimum("2026.3.22", "2026.3.0")).toBe(true); expect(satisfiesGatewayMinimum("OpenClaw 2026.3.22", "2026.3.0")).toBe(true); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index c9b1ab05fce..dea97767e3d 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -299,11 +299,24 @@ function upperBoundForCaret(version: string): string | null { return `0.0.${parsed.patch + 1}`; } +function matchWildcardComparator(token: string): "any" | "none" | null { + const match = /^(>=|<=|>|<|=|\^|~)?\s*([*xX])$/.exec(token); + if (!match) { + return null; + } + const operator = match[1]; + return operator === ">" || operator === "<" ? "none" : "any"; +} + function satisfiesComparator(version: string, token: string): boolean { const trimmed = token.trim(); if (!trimmed) { return true; } + const wildcard = matchWildcardComparator(trimmed); + if (wildcard) { + return wildcard === "any" && parseComparableSemver(version) != null; + } if (trimmed.startsWith("^")) { const base = trimmed.slice(1).trim(); const upperBound = upperBoundForCaret(base); diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 4437ed870cc..a8d72a3efa1 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -249,6 +249,64 @@ describe("installPluginFromClawHub", () => { expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); + it("installs when ClawHub advertises a wildcard plugin API range", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + compatibility: { + pluginApiRange: "*", + minGatewayVersion: "2026.3.0", + }, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expectSuccessfulClawHubInstall(result); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledTimes(1); + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/clawhub-demo/archive.zip", + }), + ); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); + }); + + it("does not let a wildcard plugin API range hide an invalid runtime version", async () => { + resolveCompatibilityHostVersionMock.mockReturnValueOnce("invalid"); + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af", + compatibility: { + pluginApiRange: "*", + minGatewayVersion: "2026.3.0", + }, + }, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + }); + + expect(result).toMatchObject({ + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API, + error: 'Plugin "demo" requires plugin API *, but this OpenClaw runtime exposes invalid.', + }); + expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled(); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + expect(archiveCleanupMock).not.toHaveBeenCalled(); + }); + it("passes dangerous force unsafe install through to archive installs", async () => { await installPluginFromClawHub({ spec: "clawhub:demo",