mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
feat(plugins): install clawhub clawpack artifacts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user