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 224ed201a4a1..9edee58514b7 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 931733d7c778..b8bfbacf7d82 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 3ef358d7ab0e..cc025cc1debe 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 27fdf68d307f..9ac28598accf 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 8e80d7250388..6f1fcb6ed656 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 7bfc8f77ed7d..7774034bbb54 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 4d84a8a52a9e..4e3d41c388b7 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 02cac31d9467..54c7e98dfbb1 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 d2f7c7a9c396..eeb40b42b1e1 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 79d07fc31894..0d9dc4ce67ee 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 e6eae538bfe4..f2241cb86fba 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");