From 03be4bfac57a886fe795b3007aa33db59307a475 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 09:30:49 -0700 Subject: [PATCH] fix(plugins): align clawhub clawpack downloads --- CHANGELOG.md | 1 + docs/cli/plugins.md | 2 +- docs/tools/clawhub.md | 2 +- scripts/e2e/lib/clawhub-fixture-server.cjs | 104 +++++++++++++++--- .../lib/kitchen-sink-plugin/assertions.mjs | 7 +- scripts/e2e/lib/plugins/assertions.mjs | 7 +- src/infra/clawhub.test.ts | 17 +-- src/infra/clawhub.ts | 68 +++++++++++- src/plugins/clawhub.test.ts | 61 ++++------ src/plugins/clawhub.ts | 49 +++++++-- .../plugin-prerelease-test-plan.test.ts | 2 +- 11 files changed, 229 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224ed201a4a..9edee58514b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Plugins/source checkout: discover source-only plugins such as Codex from the `extensions/*` workspace while using npm package excludes as the packaged-core boundary, removing the stale core-bundle metadata path. +- Plugins/ClawHub: install ClawPack artifacts from the explicit npm-pack `.tgz` resolver path instead of the legacy ZIP-shaped placeholder route. Thanks @vincentkoc. - Control UI: allow deployments to configure grouped chat message max-width with a validated `gateway.controlUi.chatMessageMaxWidth` setting instead of patching bundled CSS after upgrades. Fixes #67935. Thanks @xiew4589-lang. - Control UI/Cron: ignore malformed persisted cron rows without valid payloads before they enter UI state and guard stale cron render paths, preventing blank Control UI sections after a bad cron snapshot. Fixes #55047 and #54439; supersedes #54550 and #54552. - Control UI/sessions: bound the default Sessions tab query to recent activity and fewer rows, avoiding expensive full-history loads while keeping filters editable. Fixes #76050. (#76051) Thanks @Neomail2. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 931733d7c77..b8bfbacf7d8 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -172,7 +172,7 @@ openclaw plugins install npm:openclaw-codex-app-server openclaw plugins install npm:@scope/plugin-name@1.0.1 ``` -OpenClaw checks the advertised plugin API / minimum gateway compatibility before install. When the selected ClawHub version publishes a ClawPack artifact, OpenClaw downloads the versioned ClawPack, verifies the ClawHub digest header and the artifact digest, then installs it through the normal archive path. Older ClawHub versions without ClawPack metadata still install through the legacy package archive verification path. Recorded installs keep their ClawHub source metadata and ClawPack digest facts for later updates. +OpenClaw checks the advertised plugin API / minimum gateway compatibility before install. When the selected ClawHub version publishes a ClawPack artifact, OpenClaw downloads the versioned npm-pack `.tgz`, verifies the ClawHub digest header and the artifact digest, then installs it through the normal archive path. Older ClawHub versions without ClawPack metadata still install through the legacy package archive verification path. Recorded installs keep their ClawHub source metadata and ClawPack digest facts for later updates. Unversioned ClawHub installs keep an unversioned recorded spec so `openclaw plugins update` can follow newer ClawHub releases; explicit version or tag selectors such as `clawhub:pkg@1.2.3` and `clawhub:pkg@beta` remain pinned to that selector. #### Marketplace shorthand diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md index 3ef358d7ab0..cc025cc1deb 100644 --- a/docs/tools/clawhub.md +++ b/docs/tools/clawhub.md @@ -84,7 +84,7 @@ Site: [clawhub.ai](https://clawhub.ai) `minGatewayVersion` compatibility before archive install runs, so incompatible hosts fail closed early instead of partially installing the package. When a package version publishes a ClawPack artifact, - OpenClaw prefers that artifact, verifies the ClawHub digest header and + OpenClaw prefers the exact uploaded npm-pack `.tgz`, verifies the ClawHub digest header and downloaded bytes, and records the ClawPack digest metadata for later updates. Older package versions without ClawPack metadata still use the legacy package archive verification path. diff --git a/scripts/e2e/lib/clawhub-fixture-server.cjs b/scripts/e2e/lib/clawhub-fixture-server.cjs index 27fdf68d307..9ac28598acc 100644 --- a/scripts/e2e/lib/clawhub-fixture-server.cjs +++ b/scripts/e2e/lib/clawhub-fixture-server.cjs @@ -1,6 +1,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); +const os = require("node:os"); const path = require("node:path"); const { createRequire } = require("node:module"); @@ -8,18 +9,82 @@ const profile = process.argv[2]; const portFile = process.argv[3]; const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); const JSZip = requireFromApp("jszip"); +const tar = requireFromApp("tar"); const packageName = "@openclaw/kitchen-sink"; const pluginId = "openclaw-kitchen-sink-fixture"; -const buildClawPackSummary = ({ sha256hash, manifestSha256, size }) => ({ - available: true, - specVersion: 1, - format: "clawpack.zip", - sha256: sha256hash, - size, - manifestSha256, +const buildArtifactSummary = ({ + clawpackSha256, + clawpackSize, + npmIntegrity, + npmShasum, + npmTarballName, +}) => ({ + kind: "npm-pack", + format: "tgz", + sha256: clawpackSha256, + size: clawpackSize, + npmIntegrity, + npmShasum, + npmTarballName, }); +const buildClawPackSummary = ({ + clawpackSha256, + clawpackSize, + npmIntegrity, + npmShasum, + npmTarballName, +}) => ({ + available: true, + format: "tgz", + sha256: clawpackSha256, + size: clawpackSize, + npmIntegrity, + npmShasum, + npmTarballName, +}); + +async function buildNpmPackArtifact(fixture) { + const packRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-fixture-")); + try { + const packageDir = path.join(packRoot, "package"); + await fs.promises.mkdir(packageDir, { recursive: true }); + await fs.promises.writeFile( + path.join(packageDir, "package.json"), + `${JSON.stringify(fixture.packageJson, null, 2)}\n`, + ); + await fs.promises.writeFile(path.join(packageDir, "index.js"), fixture.indexJs); + await fs.promises.writeFile( + path.join(packageDir, "openclaw.plugin.json"), + `${JSON.stringify(fixture.manifest, null, 2)}\n`, + ); + const npmTarballName = `${packageName.replace(/^@/, "").replace("/", "-")}-${fixture.version}.tgz`; + const archivePath = path.join(packRoot, npmTarballName); + await tar.c( + { + cwd: packRoot, + file: archivePath, + gzip: true, + portable: true, + noMtime: true, + }, + ["package"], + ); + const archive = await fs.promises.readFile(archivePath); + return { + archive, + clawpackSha256: crypto.createHash("sha256").update(archive).digest("hex"), + clawpackSize: archive.length, + npmIntegrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + npmShasum: crypto.createHash("sha1").update(archive).digest("hex"), + npmTarballName, + }; + } finally { + await fs.promises.rm(packRoot, { recursive: true, force: true }).catch(() => undefined); + } +} + const profiles = { "kitchen-sink-plugin": { version: "0.1.3", @@ -98,6 +163,7 @@ export default definePluginEntry({ }, packageDetail(artifact) { const clawpack = buildClawPackSummary(artifact); + const packageArtifact = buildArtifactSummary(artifact); const packageDetail = { package: { name: packageName, @@ -131,6 +197,7 @@ export default definePluginEntry({ hasProvenance: false, scanStatus: "passed", }, + artifact: packageArtifact, clawpack, }, }; @@ -151,6 +218,7 @@ export default definePluginEntry({ compatibility: packageDetail.package.compatibility, capabilities: packageDetail.package.capabilities, verification: packageDetail.package.verification, + artifact: packageArtifact, clawpack, }, }, @@ -209,6 +277,7 @@ export default definePluginEntry({ minGatewayVersion: "2026.4.26", }; const clawpack = buildClawPackSummary(artifact); + const packageArtifact = buildArtifactSummary(artifact); return { packageDetail: { package: { @@ -222,6 +291,7 @@ export default definePluginEntry({ createdAt: 0, updatedAt: 0, compatibility, + artifact: packageArtifact, clawpack, }, }, @@ -232,6 +302,7 @@ export default definePluginEntry({ changelog: "Kitchen-sink fixture package for Docker plugin E2E.", sha256hash: artifact.sha256hash, compatibility, + artifact: packageArtifact, clawpack, }, }, @@ -257,11 +328,10 @@ async function main() { const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - const manifestSha256 = crypto.createHash("sha256").update(manifestJson).digest("hex"); + const clawpack = await buildNpmPackArtifact(fixture); const { packageDetail, versionDetail, betaStatus } = fixture.packageDetail({ sha256hash, - manifestSha256, - size: archive.length, + ...clawpack, }); const json = (response, value, status = 200) => { @@ -304,15 +374,17 @@ async function main() { } if ( url.pathname === - `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/clawpack` + `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${fixture.version}/artifact/download` ) { response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - "X-ClawHub-ClawPack-Sha256": sha256hash, - "X-ClawHub-ClawPack-Spec-Version": "1", + "content-type": "application/octet-stream", + "content-length": String(clawpack.archive.length), + "X-ClawHub-Artifact-Type": "npm-pack-tarball", + "X-ClawHub-Artifact-Sha256": clawpack.clawpackSha256, + "X-ClawHub-Npm-Integrity": clawpack.npmIntegrity, + "X-ClawHub-Npm-Shasum": clawpack.npmShasum, }); - response.end(archive); + response.end(clawpack.archive); return; } response.writeHead(404, { "content-type": "text/plain" }); diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs index 8e80d725038..6f1fcb6ed65 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -389,12 +389,7 @@ function assertInstalled() { if (!record.version || !record.integrity || !record.resolvedAt) { throw new Error(`missing ClawHub resolution metadata: ${JSON.stringify(record)}`); } - if ( - !record.clawpackSha256 || - record.clawpackSpecVersion !== 1 || - !record.clawpackManifestSha256 || - typeof record.clawpackSize !== "number" - ) { + if (!record.clawpackSha256 || typeof record.clawpackSize !== "number") { throw new Error(`missing kitchen-sink ClawPack metadata: ${JSON.stringify(record)}`); } } diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index 7bfc8f77ed7..7774034bbb5 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -534,12 +534,7 @@ function assertClawHubInstalled() { if (typeof record.installPath !== "string" || record.installPath.length === 0) { throw new Error(`missing ClawHub install path for ${pluginId}`); } - if ( - !record.clawpackSha256 || - record.clawpackSpecVersion !== 1 || - !record.clawpackManifestSha256 || - typeof record.clawpackSize !== "number" - ) { + if (!record.clawpackSha256 || typeof record.clawpackSize !== "number") { throw new Error(`missing ClawHub ClawPack metadata for ${pluginId}: ${JSON.stringify(record)}`); } diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index 4d84a8a52a9..4e3d41c388b 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -257,21 +257,22 @@ describe("clawhub helpers", () => { return new Response(bytes, { status: 200, headers: { - "content-type": "application/zip", - "X-ClawHub-ClawPack-Sha256": sha256Hex, - "X-ClawHub-ClawPack-Spec-Version": "1", + "content-type": "application/octet-stream", + "X-ClawHub-Artifact-Sha256": sha256Hex, }, }); }, }); try { - expect(new URL(requestedUrl).pathname).toBe("/api/v1/packages/demo/versions/1.2.3/clawpack"); - expect(path.basename(archive.archivePath)).toBe("demo.clawpack.zip"); + expect(new URL(requestedUrl).pathname).toBe( + "/api/v1/packages/demo/versions/1.2.3/artifact/download", + ); + expect(path.basename(archive.archivePath)).toBe("demo-1.2.3.tgz"); expect(archive.artifact).toBe("clawpack"); expect(archive.sha256Hex).toBe(sha256Hex); expect(archive.clawpackHeaderSha256).toBe(sha256Hex); - expect(archive.clawpackHeaderSpecVersion).toBe(1); + expect(archive.npmIntegrity).toMatch(/^sha512-/); await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from(bytes)); } finally { const archiveDir = path.dirname(archive.archivePath); @@ -290,8 +291,8 @@ describe("clawhub helpers", () => { new Response(new Uint8Array([7, 8, 9]), { status: 200, headers: { - "content-type": "application/zip", - "X-ClawHub-ClawPack-Sha256": "0".repeat(64), + "content-type": "application/octet-stream", + "X-ClawHub-Artifact-Sha256": "0".repeat(64), }, }), }), diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 02cac31d946..54c7e98dfbb 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -39,6 +39,20 @@ export type ClawHubPackageEnvironmentSummary = { supportsRemoteHost?: boolean; knownUnsupported?: string[]; }; +export type ClawHubPackageArtifactSummary = { + kind?: "legacy-zip" | "npm-pack" | null; + sha256?: string | null; + size?: number | null; + format?: "zip" | "tgz" | null; + npmIntegrity?: string | null; + npmShasum?: string | null; + npmTarballName?: string | null; + npmUnpackedSize?: number | null; + npmFileCount?: number | null; + downloadUrl?: string | null; + tarballUrl?: string | null; + legacyDownloadUrl?: string | null; +}; export type ClawHubPackageClawPackSummary = { available: boolean; specVersion?: number | null; @@ -47,6 +61,9 @@ export type ClawHubPackageClawPackSummary = { size?: number | null; fileCount?: number | null; manifestSha256?: string | null; + npmIntegrity?: string | null; + npmShasum?: string | null; + npmTarballName?: string | null; builtAt?: number | null; buildVersion?: string | null; hostTargets?: ClawHubPackageHostTarget[]; @@ -71,6 +88,7 @@ export type ClawHubPackageListItem = { clawpackAvailable?: boolean; hostTargetKeys?: string[]; environmentFlags?: string[]; + artifact?: ClawHubPackageArtifactSummary | null; clawpack?: ClawHubPackageClawPackSummary; }; export type ClawHubPackageDetail = { @@ -99,6 +117,7 @@ export type ClawHubPackageDetail = { hasProvenance?: boolean; scanStatus?: string; } | null; + artifact?: ClawHubPackageArtifactSummary | null; clawpack?: ClawHubPackageClawPackSummary; }) | null; @@ -138,6 +157,7 @@ export type ClawHubPackageVersion = { ? C : never : never; + artifact?: ClawHubPackageArtifactSummary | null; clawpack?: ClawHubPackageClawPackSummary; } | null; }; @@ -209,6 +229,9 @@ export type ClawHubDownloadResult = { artifact: "archive" | "clawpack"; clawpackHeaderSha256?: string; clawpackHeaderSpecVersion?: number; + npmIntegrity?: string; + npmShasum?: string; + npmTarballName?: string; cleanup: () => Promise; }; @@ -476,6 +499,24 @@ function formatSha256Hex(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } +function formatSha512Integrity(bytes: Uint8Array): string { + const digest = createHash("sha512").update(bytes).digest("base64"); + return `sha512-${digest}`; +} + +function normalizeHeaderValue(value: string | null): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function safePackageTarballName(name: string, version: string): string { + const base = name + .replace(/^@/, "") + .replace(/[\\/]+/g, "-") + .replace(/[^A-Za-z0-9._-]/g, "-"); + return `${base || "package"}-${version}.tgz`; +} + export function normalizeClawHubSha256Integrity(value: string): string | null { const trimmed = value.trim(); if (!trimmed) { @@ -645,7 +686,7 @@ export async function downloadClawHubPackageArchive(params: { baseUrl: params.baseUrl, path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( params.version, - )}/clawpack`, + )}/artifact/download`, token: params.token, timeoutMs: params.timeoutMs, fetchImpl: params.fetchImpl, @@ -659,12 +700,15 @@ export async function downloadClawHubPackageArchive(params: { } const bytes = new Uint8Array(await response.arrayBuffer()); const sha256Hex = formatSha256Hex(bytes); + const npmIntegrity = formatSha512Integrity(bytes); const headerSha256 = normalizeClawHubSha256Hex( - response.headers.get("X-ClawHub-ClawPack-Sha256") ?? "", + response.headers.get("X-ClawHub-Artifact-Sha256") ?? + response.headers.get("X-ClawHub-ClawPack-Sha256") ?? + "", ); if (!headerSha256) { throw new Error( - `ClawHub ClawPack download for "${params.name}@${params.version}" is missing X-ClawHub-ClawPack-Sha256.`, + `ClawHub ClawPack download for "${params.name}@${params.version}" is missing X-ClawHub-Artifact-Sha256.`, ); } if (headerSha256 !== sha256Hex) { @@ -672,11 +716,22 @@ export async function downloadClawHubPackageArchive(params: { `ClawHub ClawPack download for "${params.name}@${params.version}" declared sha256 ${headerSha256}, got ${sha256Hex}.`, ); } + const headerNpmIntegrity = normalizeHeaderValue( + response.headers.get("X-ClawHub-Npm-Integrity"), + ); + if (headerNpmIntegrity && headerNpmIntegrity !== npmIntegrity) { + throw new Error( + `ClawHub ClawPack download for "${params.name}@${params.version}" declared npm integrity ${headerNpmIntegrity}, got ${npmIntegrity}.`, + ); + } + const npmTarballName = + normalizeHeaderValue(response.headers.get("X-ClawHub-Npm-Tarball-Name")) ?? + safePackageTarballName(params.name, params.version); const rawSpecVersion = response.headers.get("X-ClawHub-ClawPack-Spec-Version"); const specVersion = rawSpecVersion ? Number.parseInt(rawSpecVersion, 10) : undefined; const target = await createTempDownloadTarget({ prefix: "openclaw-clawhub-clawpack", - fileName: `${params.name}.clawpack.zip`, + fileName: npmTarballName, tmpDir: os.tmpdir(), }); await fs.writeFile(target.path, bytes); @@ -689,6 +744,11 @@ export async function downloadClawHubPackageArchive(params: { ...(typeof specVersion === "number" && Number.isSafeInteger(specVersion) && specVersion >= 0 ? { clawpackHeaderSpecVersion: specVersion } : {}), + npmIntegrity, + ...(normalizeHeaderValue(response.headers.get("X-ClawHub-Npm-Shasum")) + ? { npmShasum: normalizeHeaderValue(response.headers.get("X-ClawHub-Npm-Shasum")) } + : {}), + npmTarballName, cleanup: target.cleanup, }; } diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index d2f7c7a9c39..eeb40b42b1e 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -57,8 +57,6 @@ const DEMO_CLAWPACK_SHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const DEMO_CLAWPACK_INTEGRITY = `sha256-${Buffer.from(DEMO_CLAWPACK_SHA256, "hex").toString( "base64", )}`; -const DEMO_CLAWPACK_MANIFEST_SHA256 = - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const tempDirs: string[] = []; function sha256Hex(value: string): string { @@ -336,29 +334,24 @@ describe("installPluginFromClawHub", () => { pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, - clawpack: { - available: true, - specVersion: 1, - format: "clawpack.zip", + artifact: { + kind: "npm-pack", + format: "tgz", sha256: DEMO_CLAWPACK_SHA256, size: 4096, - fileCount: 7, - manifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, - builtAt: 1774200000000, - buildVersion: "2026.3.22", - hostTargets: [], - environment: null, - runtimeBundles: [], + npmIntegrity: "sha512-clawpack", + npmShasum: "1".repeat(40), + npmTarballName: "demo-2026.3.22.tgz", }, }, }); downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ - archivePath: "/tmp/clawhub-demo/clawpack.zip", + archivePath: "/tmp/clawhub-demo/demo-2026.3.22.tgz", integrity: DEMO_CLAWPACK_INTEGRITY, sha256Hex: DEMO_CLAWPACK_SHA256, artifact: "clawpack", clawpackHeaderSha256: DEMO_CLAWPACK_SHA256, - clawpackHeaderSpecVersion: 1, + npmIntegrity: "sha512-clawpack", cleanup: archiveCleanupMock, }); @@ -372,8 +365,6 @@ describe("installPluginFromClawHub", () => { clawhub: { integrity: DEMO_CLAWPACK_INTEGRITY, clawpackSha256: DEMO_CLAWPACK_SHA256, - clawpackSpecVersion: 1, - clawpackManifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, clawpackSize: 4096, }, }); @@ -396,23 +387,20 @@ describe("installPluginFromClawHub", () => { pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, - clawpack: { - available: true, - specVersion: 1, - format: "clawpack.zip", + artifact: { + kind: "npm-pack", + format: "tgz", sha256: DEMO_CLAWPACK_SHA256, size: 4096, - manifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, }, }, }); downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ - archivePath: "/tmp/clawhub-demo/clawpack.zip", + archivePath: "/tmp/clawhub-demo/demo-2026.3.22.tgz", integrity: DEMO_CLAWPACK_INTEGRITY, sha256Hex: DEMO_CLAWPACK_SHA256, artifact: "clawpack", clawpackHeaderSha256: DEMO_CLAWPACK_SHA256, - clawpackHeaderSpecVersion: 1, cleanup: archiveCleanupMock, }); @@ -435,7 +423,7 @@ describe("installPluginFromClawHub", () => { ); expect(installPluginFromArchiveMock).toHaveBeenCalledWith( expect.objectContaining({ - archivePath: "/tmp/clawhub-demo/clawpack.zip", + archivePath: "/tmp/clawhub-demo/demo-2026.3.22.tgz", }), ); }); @@ -451,16 +439,15 @@ describe("installPluginFromClawHub", () => { pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, - clawpack: { - available: true, - specVersion: 1, - format: "clawpack.zip", + artifact: { + kind: "npm-pack", + format: "tgz", sha256: DEMO_CLAWPACK_SHA256, }, }, }); downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ - archivePath: "/tmp/clawhub-demo/clawpack.zip", + archivePath: "/tmp/clawhub-demo/demo-2026.3.22.tgz", integrity: `sha256-${Buffer.from(mismatchedSha256, "hex").toString("base64")}`, sha256Hex: mismatchedSha256, artifact: "clawpack", @@ -497,19 +484,11 @@ describe("installPluginFromClawHub", () => { pluginApiRange: ">=2026.3.22", minGatewayVersion: "2026.3.0", }, - clawpack: { - available: true, - specVersion: 1, - format: "clawpack.zip", + artifact: { + kind: "npm-pack", + format: "tgz", sha256: DEMO_CLAWPACK_SHA256, size: 4096, - fileCount: 7, - manifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, - builtAt: 1774200000000, - buildVersion: "2026.3.22", - hostTargets: [], - environment: null, - runtimeBundles: [], }, }, }); diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 79d07fc3189..0d9dc4ce67e 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -22,6 +22,7 @@ import { satisfiesGatewayMinimum, satisfiesPluginApiRange, type ClawHubPackageChannel, + type ClawHubPackageArtifactSummary, type ClawHubPackageCompatibility, type ClawHubPackageDetail, type ClawHubPackageFamily, @@ -128,22 +129,26 @@ type ClawHubArchiveEntryLimits = { }; function normalizeClawHubClawPackInstallFields( - clawpack: ClawHubPackageClawPackSummary | null | undefined, + clawpack: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null | undefined, ): Pick< ClawHubPluginInstallRecordFields, "clawpackSha256" | "clawpackSpecVersion" | "clawpackManifestSha256" | "clawpackSize" > { - if (clawpack?.available !== true) { + const isNpmPackArtifact = + clawpack && "kind" in clawpack && normalizeOptionalString(clawpack.kind) === "npm-pack"; + const isLegacyClawPack = clawpack && "available" in clawpack && clawpack.available; + if (!isNpmPackArtifact && !isLegacyClawPack) { return {}; } const clawpackSha256 = typeof clawpack.sha256 === "string" ? normalizeClawHubSha256Hex(clawpack.sha256) : null; const clawpackManifestSha256 = - typeof clawpack.manifestSha256 === "string" + "manifestSha256" in clawpack && typeof clawpack.manifestSha256 === "string" ? normalizeClawHubSha256Hex(clawpack.manifestSha256) : null; const clawpackSpecVersion = + "specVersion" in clawpack && typeof clawpack.specVersion === "number" && Number.isSafeInteger(clawpack.specVersion) && clawpack.specVersion >= 0 @@ -174,14 +179,35 @@ function isTrustedSourceLinkedOfficialPackage(pkg: NonNullable, +): ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null { + if (version.artifact?.kind === "npm-pack") { + return version.artifact; + } + if (version.clawpack?.available === true) { + return version.clawpack; + } + return null; +} + export function formatClawHubSpecifier(params: { name: string; version?: string }): string { return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`; } @@ -661,7 +687,7 @@ async function resolveCompatiblePackageVersion(params: { version: string; compatibility?: ClawHubPackageCompatibility | null; verification: ClawHubArchiveVerification | null; - clawpack?: ClawHubPackageClawPackSummary | null; + clawpack?: ClawHubPackageArtifactSummary | ClawHubPackageClawPackSummary | null; } | ClawHubInstallFailure > { @@ -699,7 +725,9 @@ async function resolveCompatiblePackageVersion(params: { clawpack: versionDetail.version?.clawpack ?? null, }; } - const clawpack = versionDetail.version?.clawpack ?? null; + const clawpack = versionDetail.version + ? resolveClawHubNpmPackArtifact(versionDetail.version) + : null; const verificationState = resolveClawHubArchiveVerification( versionDetail, params.detail.package?.name ?? "unknown", @@ -910,6 +938,7 @@ export async function installPluginFromClawHub( try { if (expectedClawPackSha256) { const expectedIntegrity = normalizeClawHubSha256Integrity(expectedClawPackSha256); + const expectedNpmIntegrity = resolveClawHubNpmIntegrity(versionState.clawpack); if ( archive.artifact !== "clawpack" || archive.clawpackHeaderSha256 !== expectedClawPackSha256 || @@ -921,6 +950,12 @@ export async function installPluginFromClawHub( CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } + if (expectedNpmIntegrity && archive.npmIntegrity !== expectedNpmIntegrity) { + return buildClawHubInstallFailure( + `ClawHub ClawPack npm integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedNpmIntegrity}, got ${archive.npmIntegrity ?? "unknown"}.`, + CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, + ); + } } else if (versionState.verification?.kind === "archive-integrity") { if (archive.integrity !== versionState.verification.integrity) { return buildClawHubInstallFailure( diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index e6eae538bfe..f2241cb86fb 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -142,7 +142,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { 'from "openclaw/plugin-sdk/plugin-entry"', ); expect(readFileSync("scripts/e2e/lib/clawhub-fixture-server.cjs", "utf8")).toContain( - "X-ClawHub-ClawPack-Sha256", + "X-ClawHub-Artifact-Sha256", ); expect(script).toContain("docker stats --no-stream"); expect(sweepScript).toContain("scan_logs_for_unexpected_errors");