diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f13764d84..dd0da3ffa09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc. - Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc. - Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R. - Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft. diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index b464a031b63..4d84a8a52a9 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -243,6 +244,60 @@ describe("clawhub helpers", () => { } }); + it("downloads ClawPack package artifacts from the version route and verifies response headers", async () => { + const bytes = new Uint8Array([7, 8, 9]); + const sha256Hex = createHash("sha256").update(bytes).digest("hex"); + let requestedUrl = ""; + const archive = await downloadClawHubPackageArchive({ + name: "demo", + version: "1.2.3", + artifact: "clawpack", + fetchImpl: async (input) => { + requestedUrl = input instanceof Request ? input.url : String(input); + return new Response(bytes, { + status: 200, + headers: { + "content-type": "application/zip", + "X-ClawHub-ClawPack-Sha256": sha256Hex, + "X-ClawHub-ClawPack-Spec-Version": "1", + }, + }); + }, + }); + + 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(archive.artifact).toBe("clawpack"); + expect(archive.sha256Hex).toBe(sha256Hex); + expect(archive.clawpackHeaderSha256).toBe(sha256Hex); + expect(archive.clawpackHeaderSpecVersion).toBe(1); + await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from(bytes)); + } finally { + const archiveDir = path.dirname(archive.archivePath); + await archive.cleanup(); + await expect(fs.stat(archiveDir)).rejects.toThrow(); + } + }); + + it("rejects ClawPack package artifacts when the declared digest does not match the bytes", async () => { + await expect( + downloadClawHubPackageArchive({ + name: "demo", + version: "1.2.3", + artifact: "clawpack", + fetchImpl: async () => + new Response(new Uint8Array([7, 8, 9]), { + status: 200, + headers: { + "content-type": "application/zip", + "X-ClawHub-ClawPack-Sha256": "0".repeat(64), + }, + }), + }), + ).rejects.toThrow(/declared sha256/); + }); + it("downloads skill archives to sanitized temp paths and cleans them up", async () => { const archive = await downloadClawHubSkillArchive({ slug: "agentreceipt", diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 355484cdcd6..e7c169ba6cc 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -205,6 +205,10 @@ export type ClawHubSkillListResponse = { export type ClawHubDownloadResult = { archivePath: string; integrity: string; + sha256Hex: string; + artifact: "archive" | "clawpack"; + clawpackHeaderSha256?: string; + clawpackHeaderSpecVersion?: number; cleanup: () => Promise; }; @@ -468,6 +472,10 @@ export function formatSha256Integrity(bytes: Uint8Array): string { return `sha256-${digest}`; } +function formatSha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + export function normalizeClawHubSha256Integrity(value: string): string | null { const trimmed = value.trim(); if (!trimmed) { @@ -623,11 +631,67 @@ export async function downloadClawHubPackageArchive(params: { name: string; version?: string; tag?: string; + artifact?: "archive" | "clawpack"; baseUrl?: string; token?: string; timeoutMs?: number; fetchImpl?: FetchLike; }): Promise { + if (params.artifact === "clawpack") { + if (!params.version) { + throw new Error("ClawPack package downloads require an explicit version."); + } + const { response, url } = await clawhubRequest({ + baseUrl: params.baseUrl, + path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent( + params.version, + )}/clawpack`, + token: params.token, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + }); + if (!response.ok) { + throw new ClawHubRequestError({ + path: url.pathname, + status: response.status, + body: await readErrorBody(response), + }); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + const sha256Hex = formatSha256Hex(bytes); + const headerSha256 = normalizeClawHubSha256Hex( + 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.`, + ); + } + if (headerSha256 !== sha256Hex) { + throw new Error( + `ClawHub ClawPack download for "${params.name}@${params.version}" declared sha256 ${headerSha256}, got ${sha256Hex}.`, + ); + } + 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`, + tmpDir: os.tmpdir(), + }); + await fs.writeFile(target.path, bytes); + return { + archivePath: target.path, + integrity: normalizeClawHubSha256Integrity(sha256Hex) ?? formatSha256Integrity(bytes), + sha256Hex, + artifact: "clawpack", + clawpackHeaderSha256: headerSha256, + ...(typeof specVersion === "number" && Number.isSafeInteger(specVersion) && specVersion >= 0 + ? { clawpackHeaderSpecVersion: specVersion } + : {}), + cleanup: target.cleanup, + }; + } const search = params.version ? { version: params.version } : params.tag @@ -649,6 +713,7 @@ export async function downloadClawHubPackageArchive(params: { }); } const bytes = new Uint8Array(await response.arrayBuffer()); + const sha256Hex = formatSha256Hex(bytes); const target = await createTempDownloadTarget({ prefix: "openclaw-clawhub-package", fileName: `${params.name}.zip`, @@ -658,6 +723,8 @@ export async function downloadClawHubPackageArchive(params: { return { archivePath: target.path, integrity: formatSha256Integrity(bytes), + sha256Hex, + artifact: "archive", cleanup: target.cleanup, }; } @@ -691,6 +758,7 @@ export async function downloadClawHubSkillArchive(params: { }); } const bytes = new Uint8Array(await response.arrayBuffer()); + const sha256Hex = formatSha256Hex(bytes); const target = await createTempDownloadTarget({ prefix: "openclaw-clawhub-skill", fileName: `${params.slug}.zip`, @@ -700,6 +768,8 @@ export async function downloadClawHubSkillArchive(params: { return { archivePath: target.path, integrity: formatSha256Integrity(bytes), + sha256Hex, + artifact: "archive", cleanup: target.cleanup, }; } diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 33fd2d030ef..9d7b2aecf93 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -54,6 +54,9 @@ const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromCla const DEMO_ARCHIVE_INTEGRITY = "sha256-qerEjGEpvES2+Tyan0j2xwDRkbcnmh4ZFfKN9vWbsa8="; const DEMO_CLAWPACK_SHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DEMO_CLAWPACK_INTEGRITY = `sha256-${Buffer.from(DEMO_CLAWPACK_SHA256, "hex").toString( + "base64", +)}`; const DEMO_CLAWPACK_MANIFEST_SHA256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const tempDirs: string[] = []; @@ -320,6 +323,15 @@ describe("installPluginFromClawHub", () => { }, }, }); + downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/clawhub-demo/clawpack.zip", + integrity: DEMO_CLAWPACK_INTEGRITY, + sha256Hex: DEMO_CLAWPACK_SHA256, + artifact: "clawpack", + clawpackHeaderSha256: DEMO_CLAWPACK_SHA256, + clawpackHeaderSpecVersion: 1, + cleanup: archiveCleanupMock, + }); const result = await installPluginFromClawHub({ spec: "clawhub:demo", @@ -329,12 +341,116 @@ describe("installPluginFromClawHub", () => { expect(result).toMatchObject({ ok: true, clawhub: { + integrity: DEMO_CLAWPACK_INTEGRITY, clawpackSha256: DEMO_CLAWPACK_SHA256, clawpackSpecVersion: 1, clawpackManifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, clawpackSize: 4096, }, }); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: "clawpack", + name: "demo", + version: "2026.3.22", + }), + ); + }); + + it("installs ClawPack artifacts when version metadata has no legacy archive hash", async () => { + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + clawpack: { + available: true, + specVersion: 1, + format: "clawpack.zip", + sha256: DEMO_CLAWPACK_SHA256, + size: 4096, + manifestSha256: DEMO_CLAWPACK_MANIFEST_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/clawhub-demo/clawpack.zip", + integrity: DEMO_CLAWPACK_INTEGRITY, + sha256Hex: DEMO_CLAWPACK_SHA256, + artifact: "clawpack", + clawpackHeaderSha256: DEMO_CLAWPACK_SHA256, + clawpackHeaderSpecVersion: 1, + cleanup: archiveCleanupMock, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expect(result).toMatchObject({ + ok: true, + clawhub: { + integrity: DEMO_CLAWPACK_INTEGRITY, + clawpackSha256: DEMO_CLAWPACK_SHA256, + }, + }); + expect(downloadClawHubPackageArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + artifact: "clawpack", + }), + ); + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: "/tmp/clawhub-demo/clawpack.zip", + }), + ); + }); + + it("rejects ClawPack artifacts when the download digest does not match version metadata", async () => { + const mismatchedSha256 = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + fetchClawHubPackageVersionMock.mockResolvedValueOnce({ + version: { + version: "2026.3.22", + createdAt: 0, + changelog: "", + compatibility: { + pluginApiRange: ">=2026.3.22", + minGatewayVersion: "2026.3.0", + }, + clawpack: { + available: true, + specVersion: 1, + format: "clawpack.zip", + sha256: DEMO_CLAWPACK_SHA256, + }, + }, + }); + downloadClawHubPackageArchiveMock.mockResolvedValueOnce({ + archivePath: "/tmp/clawhub-demo/clawpack.zip", + integrity: `sha256-${Buffer.from(mismatchedSha256, "hex").toString("base64")}`, + sha256Hex: mismatchedSha256, + artifact: "clawpack", + clawpackHeaderSha256: mismatchedSha256, + cleanup: archiveCleanupMock, + }); + + const result = await installPluginFromClawHub({ + spec: "clawhub:demo", + baseUrl: "https://clawhub.ai", + }); + + expect(result).toMatchObject({ + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, + error: `ClawHub ClawPack integrity mismatch for "demo@2026.3.22": expected ${DEMO_CLAWPACK_SHA256}, got ${mismatchedSha256}.`, + }); + expect(installPluginFromArchiveMock).not.toHaveBeenCalled(); + expect(archiveCleanupMock).toHaveBeenCalledTimes(1); }); it("does not persist package-level ClawPack metadata for version records without ClawPack facts", async () => { diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index 59dd5c9b843..7b6d8da36c8 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -160,6 +160,15 @@ function normalizeClawHubClawPackInstallFields( }; } +function resolveClawHubClawPackArtifactSha256( + clawpack: ClawHubPackageClawPackSummary | null | undefined, +): string | null { + if (clawpack?.available !== true || typeof clawpack.sha256 !== "string") { + return null; + } + return normalizeClawHubSha256Hex(clawpack.sha256); +} + export function formatClawHubSpecifier(params: { name: string; version?: string }): string { return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`; } @@ -677,13 +686,24 @@ async function resolveCompatiblePackageVersion(params: { clawpack: versionDetail.version?.clawpack ?? null, }; } + const clawpack = versionDetail.version?.clawpack ?? null; const verificationState = resolveClawHubArchiveVerification( versionDetail, params.detail.package?.name ?? "unknown", resolvedVersion, ); if (!verificationState.ok) { - return verificationState; + if (!resolveClawHubClawPackArtifactSha256(clawpack)) { + return verificationState; + } + return { + ok: true, + version: resolvedVersion, + compatibility: + versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, + verification: null, + clawpack, + }; } return { ok: true, @@ -691,7 +711,7 @@ async function resolveCompatiblePackageVersion(params: { compatibility: versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null, verification: verificationState.verification, - clawpack: versionDetail.version?.clawpack ?? null, + clawpack, }; } @@ -846,7 +866,8 @@ export async function installPluginFromClawHub( if (validationFailure) { return validationFailure; } - if (!versionState.verification) { + const expectedClawPackSha256 = resolveClawHubClawPackArtifactSha256(versionState.clawpack); + if (!versionState.verification && !expectedClawPackSha256) { return buildClawHubInstallFailure( `ClawHub version metadata for "${parsed.name}@${versionState.version}" is missing sha256hash and usable files[] metadata for fallback archive verification.`, CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY, @@ -865,6 +886,7 @@ export async function installPluginFromClawHub( archive = await downloadClawHubPackageArchive({ name: parsed.name, version: versionState.version, + artifact: expectedClawPackSha256 ? "clawpack" : "archive", baseUrl: params.baseUrl, token: params.token, timeoutMs: params.timeoutMs, @@ -873,14 +895,27 @@ export async function installPluginFromClawHub( return buildClawHubInstallFailure(formatErrorMessage(error)); } try { - if (versionState.verification.kind === "archive-integrity") { + if (expectedClawPackSha256) { + const expectedIntegrity = normalizeClawHubSha256Integrity(expectedClawPackSha256); + if ( + archive.artifact !== "clawpack" || + archive.clawpackHeaderSha256 !== expectedClawPackSha256 || + archive.sha256Hex !== expectedClawPackSha256 || + archive.integrity !== expectedIntegrity + ) { + return buildClawHubInstallFailure( + `ClawHub ClawPack integrity mismatch for "${parsed.name}@${versionState.version}": expected ${expectedClawPackSha256}, got ${archive.sha256Hex}.`, + CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, + ); + } + } else if (versionState.verification?.kind === "archive-integrity") { if (archive.integrity !== versionState.verification.integrity) { return buildClawHubInstallFailure( `ClawHub archive integrity mismatch for "${parsed.name}@${versionState.version}": expected ${versionState.verification.integrity}, got ${archive.integrity}.`, CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH, ); } - } else { + } else if (versionState.verification) { const validatedPaths = versionState.verification.files .map((file) => file.path) .toSorted()