diff --git a/qa/scenarios/plugins/clawhub-package-compatibility.yaml b/qa/scenarios/plugins/clawhub-package-compatibility.yaml new file mode 100644 index 000000000000..7088d84e5f0c --- /dev/null +++ b/qa/scenarios/plugins/clawhub-package-compatibility.yaml @@ -0,0 +1,25 @@ +title: ClawHub package compatibility validation + +scenario: + id: clawhub-package-compatibility + surface: clawhub + category: clawhub.compatibility-and-trust + coverage: + primary: + - clawhub.clawhub-package-compatibility-validation + objective: Verify ClawHub plugin compatibility metadata is enforced before trust checks or artifact download. + successCriteria: + - Incompatible package-level plugin API metadata fails visibly before trust checks or download. + - Incompatible version-level minimum Gateway metadata fails visibly before trust checks or download. + - Rejected installs leave no install record or telemetry. + - Compatible metadata downloads and installs the plugin, persists its install record, and reports telemetry. + docsRefs: + - docs/cli/plugins.md + - docs/plugins/manifest.md + codeRefs: + - src/cli/plugins-cli.clawhub-install.e2e.test.ts + - src/plugins/clawhub.ts + execution: + kind: vitest + path: src/cli/plugins-cli.clawhub-install.e2e.test.ts + summary: Runs real CLI installs against a local ClawHub HTTP fixture and checks compatibility rejection ordering plus compatible install side effects. diff --git a/src/cli/plugins-cli.clawhub-install.e2e.test.ts b/src/cli/plugins-cli.clawhub-install.e2e.test.ts index 0eb076853a40..bb7bd3373056 100644 --- a/src/cli/plugins-cli.clawhub-install.e2e.test.ts +++ b/src/cli/plugins-cli.clawhub-install.e2e.test.ts @@ -6,13 +6,16 @@ import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js"; const PACKAGE_NAME = "@openclaw/telemetry-demo"; const PACKAGE_VERSION = "1.0.0"; const PLUGIN_ID = "telemetry-demo"; const ENCODED_PACKAGE_NAME = encodeURIComponent(PACKAGE_NAME); +const PACKAGE_API_PATH = `/api/v1/packages/${ENCODED_PACKAGE_NAME}`; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); async function readRequestBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -71,6 +74,8 @@ async function buildPluginZip(): Promise { type TestServerOptions = { artifactSha256?: string; + artifactCompatibility?: Record | null; + packageCompatibility?: Record; telemetryStatus?: number; }; @@ -84,7 +89,7 @@ async function startClawHubServer(options: TestServerOptions = {}) { async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? "/", "http://127.0.0.1"); requestLog.push(`${req.method ?? "GET"} ${url.pathname}`); - const packagePath = `/api/v1/packages/${ENCODED_PACKAGE_NAME}`; + const packagePath = PACKAGE_API_PATH; if (req.method === "GET" && url.pathname === packagePath) { res.writeHead(200, { "Content-Type": "application/json" }); @@ -99,7 +104,7 @@ async function startClawHubServer(options: TestServerOptions = {}) { isOfficial: false, latestVersion: PACKAGE_VERSION, tags: { latest: PACKAGE_VERSION }, - compatibility: {}, + compatibility: options.packageCompatibility ?? {}, }, owner: { handle: "openclaw" }, }), @@ -124,7 +129,9 @@ async function startClawHubServer(options: TestServerOptions = {}) { createdAt: 1, changelog: "Initial release", sha256hash: artifactSha256, - compatibility: {}, + ...(options.artifactCompatibility === null + ? {} + : { compatibility: options.artifactCompatibility ?? {} }), }, }), ); @@ -257,6 +264,10 @@ describe("openclaw plugins install ClawHub E2E", () => { version: PACKAGE_VERSION, }, ]); + expect(testServer.requestLog).toContain( + `GET ${PACKAGE_API_PATH}/versions/${PACKAGE_VERSION}/security`, + ); + expect(testServer.requestLog).toContain(`GET ${PACKAGE_API_PATH}/download`); const repeat = await spawnOpenClaw( ["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`, "--force"], @@ -271,6 +282,46 @@ describe("openclaw plugins install ClawHub E2E", () => { } }, 60_000); + it.each([ + { + label: "package plugin API", + options: { + packageCompatibility: { pluginApiRange: ">=9999.0.0" }, + artifactCompatibility: null, + }, + error: "requires plugin API >=9999.0.0", + }, + { + label: "version gateway", + options: { artifactCompatibility: { minGatewayVersion: "9999.0.0" } }, + error: "requires OpenClaw >=9999.0.0", + }, + ])( + "rejects incompatible $label metadata before trust and download", + async ({ options, error }) => { + const testServer = await startClawHubServer(options); + const stateDir = tempDirs.make("openclaw-plugin-compatibility-e2e-"); + try { + const result = await spawnOpenClaw( + ["plugins", "install", `clawhub:${PACKAGE_NAME}@${PACKAGE_VERSION}`], + { cwd: process.cwd(), env: buildEnv(stateDir, testServer.registry) }, + ); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain(error); + expect(testServer.requestLog).not.toContain( + `GET ${PACKAGE_API_PATH}/versions/${PACKAGE_VERSION}/security`, + ); + expect(testServer.requestLog).not.toContain(`GET ${PACKAGE_API_PATH}/download`); + expect(testServer.telemetryBodies).toEqual([]); + await expect(readPersistedInstallRecord(stateDir)).resolves.toBeUndefined(); + } finally { + await testServer.close(); + } + }, + 30_000, + ); + it("does not report success when plugin installation fails", async () => { const testServer = await startClawHubServer({ artifactSha256: "0".repeat(64) }); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-telemetry-fail-"));