feat(plugins): install clawhub clawpack artifacts

This commit is contained in:
Vincent Koc
2026-05-01 17:24:33 -07:00
parent 0aa8022e88
commit 0a3a89810b
5 changed files with 282 additions and 5 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<void>;
};
@@ -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<ClawHubDownloadResult> {
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,
};
}

View File

@@ -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 () => {

View File

@@ -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()