mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 18:30:22 +00:00
Plugins: verify ClawHub archive integrity (#60517)
* docs(changelog): add clawhub archive integrity entry --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,19 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import JSZip from "jszip";
|
||||
import {
|
||||
DEFAULT_MAX_ARCHIVE_BYTES_ZIP,
|
||||
DEFAULT_MAX_ENTRIES,
|
||||
DEFAULT_MAX_EXTRACTED_BYTES,
|
||||
DEFAULT_MAX_ENTRY_BYTES,
|
||||
} from "../infra/archive.js";
|
||||
import {
|
||||
ClawHubRequestError,
|
||||
downloadClawHubPackageArchive,
|
||||
fetchClawHubPackageDetail,
|
||||
fetchClawHubPackageVersion,
|
||||
normalizeClawHubSha256Integrity,
|
||||
normalizeClawHubSha256Hex,
|
||||
parseClawHubPluginSpec,
|
||||
resolveLatestVersionFromPackage,
|
||||
satisfiesGatewayMinimum,
|
||||
@@ -11,6 +22,7 @@ import {
|
||||
type ClawHubPackageCompatibility,
|
||||
type ClawHubPackageDetail,
|
||||
type ClawHubPackageFamily,
|
||||
type ClawHubPackageVersion,
|
||||
} from "../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
@@ -27,6 +39,8 @@ export const CLAWHUB_INSTALL_ERROR_CODE = {
|
||||
PRIVATE_PACKAGE: "private_package",
|
||||
INCOMPATIBLE_PLUGIN_API: "incompatible_plugin_api",
|
||||
INCOMPATIBLE_GATEWAY: "incompatible_gateway",
|
||||
MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity",
|
||||
ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch",
|
||||
} as const;
|
||||
|
||||
export type ClawHubInstallErrorCode =
|
||||
@@ -55,6 +69,50 @@ type ClawHubInstallFailure = {
|
||||
code?: ClawHubInstallErrorCode;
|
||||
};
|
||||
|
||||
type ClawHubFileEntryLike = {
|
||||
path?: unknown;
|
||||
sha256?: unknown;
|
||||
};
|
||||
|
||||
type ClawHubFileVerificationEntry = {
|
||||
path: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
type ClawHubArchiveVerification =
|
||||
| {
|
||||
kind: "archive-integrity";
|
||||
integrity: string;
|
||||
}
|
||||
| {
|
||||
kind: "file-list";
|
||||
files: ClawHubFileVerificationEntry[];
|
||||
};
|
||||
|
||||
type ClawHubArchiveVerificationResolution =
|
||||
| {
|
||||
ok: true;
|
||||
verification: ClawHubArchiveVerification | null;
|
||||
}
|
||||
| ClawHubInstallFailure;
|
||||
|
||||
type ClawHubArchiveFileVerificationResult =
|
||||
| {
|
||||
ok: true;
|
||||
validatedGeneratedPaths: string[];
|
||||
}
|
||||
| ClawHubInstallFailure;
|
||||
|
||||
type JSZipObjectWithSize = JSZip.JSZipObject & {
|
||||
// Internal JSZip field from loadAsync() metadata. Use it only as a best-effort
|
||||
// size hint; the streaming byte checks below are the authoritative guard.
|
||||
_data?: {
|
||||
uncompressedSize?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const CLAWHUB_GENERATED_ARCHIVE_METADATA_FILE = "_meta.json";
|
||||
|
||||
export function formatClawHubSpecifier(params: { name: string; version?: string }): string {
|
||||
return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`;
|
||||
}
|
||||
@@ -66,6 +124,16 @@ function buildClawHubInstallFailure(
|
||||
return { ok: false, error, code };
|
||||
}
|
||||
|
||||
function isClawHubInstallFailure(value: unknown): value is ClawHubInstallFailure {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"ok" in value &&
|
||||
(value as { ok?: unknown }).ok === false &&
|
||||
"error" in value,
|
||||
);
|
||||
}
|
||||
|
||||
function mapClawHubRequestError(
|
||||
error: unknown,
|
||||
context: { stage: "package" | "version"; name: string; version?: string },
|
||||
@@ -95,6 +163,442 @@ function resolveRequestedVersion(params: {
|
||||
return resolveLatestVersionFromPackage(params.detail);
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeClawHubRelativePath(value: unknown): string | null {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (value.trim() !== value || value.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function describeInvalidClawHubRelativePath(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return `non-string value of type ${typeof value}`;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return "empty string";
|
||||
}
|
||||
if (value.trim() !== value) {
|
||||
return `path "${value}" has leading or trailing whitespace`;
|
||||
}
|
||||
if (value.includes("\\")) {
|
||||
return `path "${value}" contains backslashes`;
|
||||
}
|
||||
if (value.startsWith("/")) {
|
||||
return `path "${value}" is absolute`;
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment.length === 0)) {
|
||||
return `path "${value}" contains an empty segment`;
|
||||
}
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
return `path "${value}" contains dot segments`;
|
||||
}
|
||||
return `path "${value}" failed validation for an unknown reason`;
|
||||
}
|
||||
|
||||
function describeInvalidClawHubSha256(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return `non-string value of type ${typeof value}`;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return "empty string";
|
||||
}
|
||||
if (value.trim().length === 0) {
|
||||
return "whitespace-only string";
|
||||
}
|
||||
return `value "${value}" is not a 64-character hexadecimal SHA-256 digest`;
|
||||
}
|
||||
|
||||
function resolveClawHubArchiveVerification(
|
||||
versionDetail: ClawHubPackageVersion,
|
||||
packageName: string,
|
||||
version: string,
|
||||
): ClawHubArchiveVerificationResolution {
|
||||
const sha256hashValue = versionDetail.version?.sha256hash;
|
||||
const sha256hash = readTrimmedString(sha256hashValue);
|
||||
const integrity = sha256hash ? normalizeClawHubSha256Integrity(sha256hash) : null;
|
||||
if (integrity) {
|
||||
return {
|
||||
ok: true,
|
||||
verification: {
|
||||
kind: "archive-integrity",
|
||||
integrity,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sha256hashValue !== undefined && sha256hashValue !== null) {
|
||||
const detail =
|
||||
typeof sha256hashValue === "string" && sha256hashValue.trim().length === 0
|
||||
? "empty string"
|
||||
: typeof sha256hashValue === "string"
|
||||
? `unrecognized value "${sha256hashValue.trim()}"`
|
||||
: `non-string value of type ${typeof sha256hashValue}`;
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" has an invalid sha256hash (${detail}).`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
const files = versionDetail.version?.files;
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
verification: null,
|
||||
};
|
||||
}
|
||||
const normalizedFiles: ClawHubFileVerificationEntry[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (!file || typeof file !== "object") {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" has an invalid files[${index}] entry (expected an object, got ${file === null ? "null" : typeof file}).`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
const fileRecord = file as ClawHubFileEntryLike;
|
||||
const filePath = normalizeClawHubRelativePath(fileRecord.path);
|
||||
const sha256Value = readTrimmedString(fileRecord.sha256);
|
||||
const sha256 = sha256Value ? normalizeClawHubSha256Hex(sha256Value) : null;
|
||||
if (!filePath) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" has an invalid files[${index}].path (${describeInvalidClawHubRelativePath(fileRecord.path)}).`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
if (filePath === CLAWHUB_GENERATED_ARCHIVE_METADATA_FILE) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" must not include generated file "${filePath}" in files[].`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
if (!sha256) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" has an invalid files[${index}].sha256 (${describeInvalidClawHubSha256(fileRecord.sha256)}).`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
if (seenPaths.has(filePath)) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub version metadata for "${packageName}@${version}" has duplicate files[] path "${filePath}".`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
||||
);
|
||||
}
|
||||
seenPaths.add(filePath);
|
||||
normalizedFiles.push({ path: filePath, sha256 });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
verification: {
|
||||
kind: "file-list",
|
||||
files: normalizedFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readClawHubArchiveEntryBuffer(
|
||||
entry: JSZip.JSZipObject,
|
||||
limits: { maxEntryBytes: number; addArchiveBytes: (bytes: number) => boolean },
|
||||
): Promise<Buffer | ClawHubInstallFailure> {
|
||||
const hintedSize = (entry as JSZipObjectWithSize)._data?.uncompressedSize;
|
||||
if (
|
||||
typeof hintedSize === "number" &&
|
||||
Number.isFinite(hintedSize) &&
|
||||
hintedSize > limits.maxEntryBytes
|
||||
) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive fallback verification rejected "${entry.name}" because it exceeds the per-file size limit.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
let entryBytes = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
return await new Promise<Buffer | ClawHubInstallFailure>((resolve) => {
|
||||
let settled = false;
|
||||
const stream = entry.nodeStream("nodebuffer") as NodeJS.ReadableStream & {
|
||||
destroy?: (error?: Error) => void;
|
||||
};
|
||||
stream.on("data", (chunk: Buffer | Uint8Array | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const buffer =
|
||||
typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array);
|
||||
entryBytes += buffer.byteLength;
|
||||
if (entryBytes > limits.maxEntryBytes) {
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
`ClawHub archive fallback verification rejected "${entry.name}" because it exceeds the per-file size limit.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!limits.addArchiveBytes(buffer.byteLength)) {
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
"ClawHub archive fallback verification exceeded the total extracted-size limit.",
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
});
|
||||
stream.once("end", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
stream.once("error", (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function hashClawHubArchiveEntry(
|
||||
entry: JSZip.JSZipObject,
|
||||
limits: { maxEntryBytes: number; addArchiveBytes: (bytes: number) => boolean },
|
||||
): Promise<string | ClawHubInstallFailure> {
|
||||
const hintedSize = (entry as JSZipObjectWithSize)._data?.uncompressedSize;
|
||||
if (
|
||||
typeof hintedSize === "number" &&
|
||||
Number.isFinite(hintedSize) &&
|
||||
hintedSize > limits.maxEntryBytes
|
||||
) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive fallback verification rejected "${entry.name}" because it exceeds the per-file size limit.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
let entryBytes = 0;
|
||||
const digest = createHash("sha256");
|
||||
return await new Promise<string | ClawHubInstallFailure>((resolve) => {
|
||||
let settled = false;
|
||||
const stream = entry.nodeStream("nodebuffer") as NodeJS.ReadableStream & {
|
||||
destroy?: (error?: Error) => void;
|
||||
};
|
||||
stream.on("data", (chunk: Buffer | Uint8Array | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const buffer =
|
||||
typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk as Uint8Array);
|
||||
entryBytes += buffer.byteLength;
|
||||
if (entryBytes > limits.maxEntryBytes) {
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
`ClawHub archive fallback verification rejected "${entry.name}" because it exceeds the per-file size limit.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!limits.addArchiveBytes(buffer.byteLength)) {
|
||||
settled = true;
|
||||
stream.destroy?.();
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
"ClawHub archive fallback verification exceeded the total extracted-size limit.",
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
digest.update(buffer);
|
||||
});
|
||||
stream.once("end", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(digest.digest("hex"));
|
||||
});
|
||||
stream.once("error", (error: unknown) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(
|
||||
buildClawHubInstallFailure(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateClawHubArchiveMetaJson(params: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
bytes: Buffer;
|
||||
}): ClawHubInstallFailure | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(params.bytes.toString("utf8"));
|
||||
} catch {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.version}": _meta.json is not valid JSON.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.version}": _meta.json is not a JSON object.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
const record = parsed as { slug?: unknown; version?: unknown };
|
||||
if (record.slug !== params.packageName) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.version}": _meta.json slug does not match the package name.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (record.version !== params.version) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.version}": _meta.json version does not match the package version.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function verifyClawHubArchiveFiles(params: {
|
||||
archivePath: string;
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
files: ClawHubFileVerificationEntry[];
|
||||
}): Promise<ClawHubArchiveFileVerificationResult> {
|
||||
try {
|
||||
const archiveStat = await fs.stat(params.archivePath);
|
||||
if (archiveStat.size > DEFAULT_MAX_ARCHIVE_BYTES_ZIP) {
|
||||
return buildClawHubInstallFailure(
|
||||
"ClawHub archive fallback verification rejected the downloaded archive because it exceeds the ZIP archive size limit.",
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
const archiveBytes = await fs.readFile(params.archivePath);
|
||||
const zip = await JSZip.loadAsync(archiveBytes);
|
||||
const actualFiles = new Map<string, string>();
|
||||
const validatedGeneratedPaths = new Set<string>();
|
||||
let entryCount = 0;
|
||||
let extractedBytes = 0;
|
||||
const addArchiveBytes = (bytes: number): boolean => {
|
||||
extractedBytes += bytes;
|
||||
return extractedBytes <= DEFAULT_MAX_EXTRACTED_BYTES;
|
||||
};
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
entryCount += 1;
|
||||
if (entryCount > DEFAULT_MAX_ENTRIES) {
|
||||
return buildClawHubInstallFailure(
|
||||
"ClawHub archive fallback verification exceeded the archive entry limit.",
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (entry.dir) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = normalizeClawHubRelativePath(entry.name);
|
||||
if (!relativePath) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.packageVersion}": invalid package file path "${entry.name}" (${describeInvalidClawHubRelativePath(entry.name)}).`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (relativePath === CLAWHUB_GENERATED_ARCHIVE_METADATA_FILE) {
|
||||
const metaResult = await readClawHubArchiveEntryBuffer(entry, {
|
||||
maxEntryBytes: DEFAULT_MAX_ENTRY_BYTES,
|
||||
addArchiveBytes,
|
||||
});
|
||||
if (isClawHubInstallFailure(metaResult)) {
|
||||
return metaResult;
|
||||
}
|
||||
const metaFailure = validateClawHubArchiveMetaJson({
|
||||
packageName: params.packageName,
|
||||
version: params.packageVersion,
|
||||
bytes: metaResult,
|
||||
});
|
||||
if (metaFailure) {
|
||||
return metaFailure;
|
||||
}
|
||||
validatedGeneratedPaths.add(relativePath);
|
||||
continue;
|
||||
}
|
||||
const sha256 = await hashClawHubArchiveEntry(entry, {
|
||||
maxEntryBytes: DEFAULT_MAX_ENTRY_BYTES,
|
||||
addArchiveBytes,
|
||||
});
|
||||
if (typeof sha256 !== "string") {
|
||||
return sha256;
|
||||
}
|
||||
actualFiles.set(relativePath, sha256);
|
||||
}
|
||||
for (const file of params.files) {
|
||||
const actualSha256 = actualFiles.get(file.path);
|
||||
if (!actualSha256) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.packageVersion}": missing "${file.path}".`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
if (actualSha256 !== file.sha256) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.packageVersion}": expected ${file.path} to hash to ${file.sha256}, got ${actualSha256}.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
actualFiles.delete(file.path);
|
||||
}
|
||||
const unexpectedFile = [...actualFiles.keys()].toSorted()[0];
|
||||
if (unexpectedFile) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub archive contents do not match files[] metadata for "${params.packageName}@${params.packageVersion}": unexpected file "${unexpectedFile}".`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
validatedGeneratedPaths: [...validatedGeneratedPaths].toSorted(),
|
||||
};
|
||||
} catch {
|
||||
return buildClawHubInstallFailure(
|
||||
"ClawHub archive fallback verification failed while reading the downloaded archive.",
|
||||
CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCompatiblePackageVersion(params: {
|
||||
detail: ClawHubPackageDetail;
|
||||
requestedVersion?: string;
|
||||
@@ -105,11 +609,12 @@ async function resolveCompatiblePackageVersion(params: {
|
||||
ok: true;
|
||||
version: string;
|
||||
compatibility?: ClawHubPackageCompatibility | null;
|
||||
verification: ClawHubArchiveVerification | null;
|
||||
}
|
||||
| ClawHubInstallFailure
|
||||
> {
|
||||
const version = resolveRequestedVersion(params);
|
||||
if (!version) {
|
||||
const requestedVersion = resolveRequestedVersion(params);
|
||||
if (!requestedVersion) {
|
||||
return buildClawHubInstallFailure(
|
||||
`ClawHub package "${params.detail.package?.name ?? "unknown"}" has no installable version.`,
|
||||
CLAWHUB_INSTALL_ERROR_CODE.NO_INSTALLABLE_VERSION,
|
||||
@@ -119,7 +624,7 @@ async function resolveCompatiblePackageVersion(params: {
|
||||
try {
|
||||
versionDetail = await fetchClawHubPackageVersion({
|
||||
name: params.detail.package?.name ?? "",
|
||||
version,
|
||||
version: requestedVersion,
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
});
|
||||
@@ -127,14 +632,33 @@ async function resolveCompatiblePackageVersion(params: {
|
||||
return mapClawHubRequestError(error, {
|
||||
stage: "version",
|
||||
name: params.detail.package?.name ?? "unknown",
|
||||
version,
|
||||
version: requestedVersion,
|
||||
});
|
||||
}
|
||||
const resolvedVersion = versionDetail.version?.version ?? requestedVersion;
|
||||
if (params.detail.package?.family === "skill") {
|
||||
return {
|
||||
ok: true,
|
||||
version: resolvedVersion,
|
||||
compatibility:
|
||||
versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null,
|
||||
verification: null,
|
||||
};
|
||||
}
|
||||
const verificationState = resolveClawHubArchiveVerification(
|
||||
versionDetail,
|
||||
params.detail.package?.name ?? "unknown",
|
||||
resolvedVersion,
|
||||
);
|
||||
if (!verificationState.ok) {
|
||||
return verificationState;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
version,
|
||||
version: resolvedVersion,
|
||||
compatibility:
|
||||
versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null,
|
||||
verification: verificationState.verification,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,6 +809,13 @@ export async function installPluginFromClawHub(
|
||||
if (validationFailure) {
|
||||
return validationFailure;
|
||||
}
|
||||
if (!versionState.verification) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
const canonicalPackageName = detail.package?.name ?? parsed.name;
|
||||
logClawHubPackageSummary({
|
||||
detail,
|
||||
version: versionState.version,
|
||||
@@ -304,6 +835,35 @@ export async function installPluginFromClawHub(
|
||||
return buildClawHubInstallFailure(formatErrorMessage(error));
|
||||
}
|
||||
try {
|
||||
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 {
|
||||
const validatedPaths = versionState.verification.files
|
||||
.map((file) => file.path)
|
||||
.toSorted()
|
||||
.join(", ");
|
||||
const fallbackVerification = await verifyClawHubArchiveFiles({
|
||||
archivePath: archive.archivePath,
|
||||
packageName: canonicalPackageName,
|
||||
packageVersion: versionState.version,
|
||||
files: versionState.verification.files,
|
||||
});
|
||||
if (!fallbackVerification.ok) {
|
||||
return fallbackVerification;
|
||||
}
|
||||
const validatedGeneratedPaths =
|
||||
fallbackVerification.validatedGeneratedPaths.length > 0
|
||||
? ` Validated generated metadata files present in archive: ${fallbackVerification.validatedGeneratedPaths.join(", ")} (JSON parse plus slug/version match only).`
|
||||
: "";
|
||||
params.logger?.warn?.(
|
||||
`ClawHub package "${canonicalPackageName}@${versionState.version}" is missing sha256hash; falling back to files[] verification. Validated files: ${validatedPaths}.${validatedGeneratedPaths}`,
|
||||
);
|
||||
}
|
||||
params.logger?.info?.(
|
||||
`Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`,
|
||||
);
|
||||
@@ -341,6 +901,8 @@ export async function installPluginFromClawHub(
|
||||
clawhubFamily,
|
||||
clawhubChannel: pkg.channel,
|
||||
version: installResult.version ?? versionState.version,
|
||||
// For fallback installs this is the observed download digest, not a
|
||||
// server-attested sha256hash from ClawHub version metadata.
|
||||
integrity: archive.integrity,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user