mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-13 18:21:27 +00:00
* docs(changelog): add clawhub archive integrity entry --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
1441 lines
45 KiB
TypeScript
1441 lines
45 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import JSZip from "jszip";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const parseClawHubPluginSpecMock = vi.fn();
|
|
const fetchClawHubPackageDetailMock = vi.fn();
|
|
const fetchClawHubPackageVersionMock = vi.fn();
|
|
const downloadClawHubPackageArchiveMock = vi.fn();
|
|
const archiveCleanupMock = vi.fn();
|
|
const resolveLatestVersionFromPackageMock = vi.fn();
|
|
const resolveCompatibilityHostVersionMock = vi.fn();
|
|
const installPluginFromArchiveMock = vi.fn();
|
|
|
|
vi.mock("../infra/clawhub.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../infra/clawhub.js")>("../infra/clawhub.js");
|
|
return {
|
|
...actual,
|
|
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args),
|
|
fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args),
|
|
fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args),
|
|
downloadClawHubPackageArchive: (...args: unknown[]) =>
|
|
downloadClawHubPackageArchiveMock(...args),
|
|
resolveLatestVersionFromPackage: (...args: unknown[]) =>
|
|
resolveLatestVersionFromPackageMock(...args),
|
|
};
|
|
});
|
|
|
|
vi.mock("../version.js", () => ({
|
|
resolveCompatibilityHostVersion: (...args: unknown[]) =>
|
|
resolveCompatibilityHostVersionMock(...args),
|
|
}));
|
|
|
|
vi.mock("./install.js", () => ({
|
|
installPluginFromArchive: (...args: unknown[]) => installPluginFromArchiveMock(...args),
|
|
}));
|
|
|
|
vi.mock("../infra/archive.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../infra/archive.js")>("../infra/archive.js");
|
|
return {
|
|
...actual,
|
|
DEFAULT_MAX_ENTRIES: 50_000,
|
|
DEFAULT_MAX_EXTRACTED_BYTES: 512 * 1024 * 1024,
|
|
DEFAULT_MAX_ENTRY_BYTES: 256 * 1024 * 1024,
|
|
};
|
|
});
|
|
|
|
const { ClawHubRequestError } = await import("../infra/clawhub.js");
|
|
const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } =
|
|
await import("./clawhub.js");
|
|
|
|
const DEMO_ARCHIVE_INTEGRITY = "sha256-qerEjGEpvES2+Tyan0j2xwDRkbcnmh4ZFfKN9vWbsa8=";
|
|
const tempDirs: string[] = [];
|
|
|
|
function sha256Hex(value: string): string {
|
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
}
|
|
|
|
async function createClawHubArchive(entries: Record<string, string>) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
const zip = new JSZip();
|
|
for (const [filePath, contents] of Object.entries(entries)) {
|
|
zip.file(filePath, contents);
|
|
}
|
|
const archiveBytes = await zip.generateAsync({ type: "nodebuffer" });
|
|
await fs.writeFile(archivePath, archiveBytes);
|
|
return {
|
|
archivePath,
|
|
integrity: `sha256-${createHash("sha256").update(archiveBytes).digest("base64")}`,
|
|
};
|
|
}
|
|
|
|
async function expectClawHubInstallError(params: {
|
|
setup?: () => void;
|
|
spec: string;
|
|
expected: {
|
|
ok: false;
|
|
code: (typeof CLAWHUB_INSTALL_ERROR_CODE)[keyof typeof CLAWHUB_INSTALL_ERROR_CODE];
|
|
error: string;
|
|
};
|
|
}) {
|
|
params.setup?.();
|
|
await expect(installPluginFromClawHub({ spec: params.spec })).resolves.toMatchObject(
|
|
params.expected,
|
|
);
|
|
}
|
|
|
|
function createLoggerSpies() {
|
|
return {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function expectClawHubInstallFlow(params: {
|
|
baseUrl: string;
|
|
version: string;
|
|
archivePath: string;
|
|
}) {
|
|
expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "demo",
|
|
baseUrl: params.baseUrl,
|
|
}),
|
|
);
|
|
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "demo",
|
|
version: params.version,
|
|
}),
|
|
);
|
|
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
archivePath: params.archivePath,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function expectSuccessfulClawHubInstall(result: unknown) {
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
version: "2026.3.22",
|
|
clawhub: {
|
|
source: "clawhub",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
integrity: DEMO_ARCHIVE_INTEGRITY,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("installPluginFromClawHub", () => {
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
parseClawHubPluginSpecMock.mockReset();
|
|
fetchClawHubPackageDetailMock.mockReset();
|
|
fetchClawHubPackageVersionMock.mockReset();
|
|
downloadClawHubPackageArchiveMock.mockReset();
|
|
archiveCleanupMock.mockReset();
|
|
resolveLatestVersionFromPackageMock.mockReset();
|
|
resolveCompatibilityHostVersionMock.mockReset();
|
|
installPluginFromArchiveMock.mockReset();
|
|
|
|
parseClawHubPluginSpecMock.mockReturnValue({ name: "demo" });
|
|
fetchClawHubPackageDetailMock.mockResolvedValue({
|
|
package: {
|
|
name: "demo",
|
|
displayName: "Demo",
|
|
family: "code-plugin",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
resolveLatestVersionFromPackageMock.mockReturnValue("2026.3.22");
|
|
fetchClawHubPackageVersionMock.mockResolvedValue({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af",
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValue({
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
integrity: DEMO_ARCHIVE_INTEGRITY,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
archiveCleanupMock.mockResolvedValue(undefined);
|
|
resolveCompatibilityHostVersionMock.mockReturnValue("2026.3.22");
|
|
installPluginFromArchiveMock.mockResolvedValue({
|
|
ok: true,
|
|
pluginId: "demo",
|
|
targetDir: "/tmp/openclaw/plugins/demo",
|
|
version: "2026.3.22",
|
|
});
|
|
});
|
|
|
|
it("formats clawhub specifiers", () => {
|
|
expect(formatClawHubSpecifier({ name: "demo" })).toBe("clawhub:demo");
|
|
expect(formatClawHubSpecifier({ name: "demo", version: "1.2.3" })).toBe("clawhub:demo@1.2.3");
|
|
});
|
|
|
|
it("installs a ClawHub code plugin through the archive installer", async () => {
|
|
const logger = createLoggerSpies();
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
baseUrl: "https://clawhub.ai",
|
|
logger,
|
|
});
|
|
|
|
expectClawHubInstallFlow({
|
|
baseUrl: "https://clawhub.ai",
|
|
version: "2026.3.22",
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
});
|
|
expectSuccessfulClawHubInstall(result);
|
|
expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official");
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
"Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0",
|
|
);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes dangerous force unsafe install through to archive installs", async () => {
|
|
await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
dangerouslyForceUnsafeInstall: true,
|
|
});
|
|
|
|
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
dangerouslyForceUnsafeInstall: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("cleans up the downloaded archive even when archive install fails", async () => {
|
|
installPluginFromArchiveMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "bad archive",
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
baseUrl: "https://clawhub.ai",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "bad archive",
|
|
});
|
|
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("accepts version-endpoint SHA-256 hashes expressed as raw hex", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: "a9eac48c6129bc44b6f93c9a9f48f6c700d191b7279a1e1915f28df6f59bb1af",
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
integrity: "sha256-qerEjGEpvES2+Tyan0j2xwDRkbcnmh4ZFfKN9vWbsa8=",
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo" });
|
|
});
|
|
|
|
it("accepts version-endpoint SHA-256 hashes expressed as unpadded SRI", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: "sha256-qerEjGEpvES2+Tyan0j2xwDRkbcnmh4ZFfKN9vWbsa8",
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
integrity: DEMO_ARCHIVE_INTEGRITY,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo" });
|
|
});
|
|
|
|
it("falls back to strict files[] verification when sha256hash is missing", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"dist/index.js": 'export const demo = "ok";',
|
|
"_meta.json": '{"slug":"demo","version":"2026.3.22"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: null,
|
|
files: [
|
|
{
|
|
path: "dist/index.js",
|
|
size: 25,
|
|
sha256: sha256Hex('export const demo = "ok";'),
|
|
},
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
const logger = createLoggerSpies();
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
logger,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo" });
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: dist/index.js, openclaw.plugin.json. Validated generated metadata files present in archive: _meta.json (JSON parse plus slug/version match only).',
|
|
);
|
|
});
|
|
|
|
it("validates _meta.json against canonical package and resolved version metadata", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"_meta.json": '{"slug":"demo","version":"2026.3.22"}',
|
|
});
|
|
parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "DemoAlias", version: "latest" });
|
|
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
|
|
package: {
|
|
name: "demo",
|
|
displayName: "Demo",
|
|
family: "code-plugin",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: null,
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
const logger = createLoggerSpies();
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:DemoAlias@latest",
|
|
logger,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo", version: "2026.3.22" });
|
|
expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "DemoAlias",
|
|
}),
|
|
);
|
|
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "demo",
|
|
version: "latest",
|
|
}),
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: openclaw.plugin.json. Validated generated metadata files present in archive: _meta.json (JSON parse plus slug/version match only).',
|
|
);
|
|
});
|
|
|
|
it("fails closed when sha256hash is present but unrecognized instead of silently falling back", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: "definitely-not-a-sha256",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid sha256hash (unrecognized value "definitely-not-a-sha256").',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects ClawHub installs when sha256hash is explicitly null and files[] is unavailable", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: null,
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" is missing sha256hash and usable files[] metadata for fallback archive verification.',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects ClawHub installs when the version metadata has no archive hash or fallback files[]", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" is missing sha256hash and usable files[] metadata for fallback archive verification.',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when files[] contains a malformed entry", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [null as unknown as { path: string; sha256: string }],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid files[0] entry (expected an object, got null).',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when files[] contains an invalid sha256", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: "not-a-digest",
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid files[0].sha256 (value "not-a-digest" is not a 64-character hexadecimal SHA-256 digest).',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when sha256hash is not a string", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: 123 as unknown as string,
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid sha256hash (non-string value of type number).',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a typed install failure when the archive download throws", async () => {
|
|
downloadClawHubPackageArchiveMock.mockRejectedValueOnce(new Error("network timeout"));
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: "network timeout",
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a typed install failure when fallback archive verification cannot read the zip", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
await fs.writeFile(archivePath, "not-a-zip", "utf8");
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath,
|
|
integrity: "sha256-not-used-in-fallback",
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error: "ClawHub archive fallback verification failed while reading the downloaded archive.",
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects ClawHub installs when the downloaded archive hash drifts from metadata", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
sha256hash: "1111111111111111111111111111111111111111111111111111111111111111",
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath: "/tmp/clawhub-demo/archive.zip",
|
|
integrity: DEMO_ARCHIVE_INTEGRITY,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error: `ClawHub archive integrity mismatch for "demo@2026.3.22": expected sha256-ERERERERERERERERERERERERERERERERERERERERERE=, got ${DEMO_ARCHIVE_INTEGRITY}.`,
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects fallback verification when an expected file is missing from the archive", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
{
|
|
path: "dist/index.js",
|
|
size: 25,
|
|
sha256: sha256Hex('export const demo = "ok";'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": missing "dist/index.js".',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when the archive includes an unexpected file", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"dist/index.js": 'export const demo = "ok";',
|
|
"extra.txt": "surprise",
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
{
|
|
path: "dist/index.js",
|
|
size: 25,
|
|
sha256: sha256Hex('export const demo = "ok";'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": unexpected file "extra.txt".',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts root-level files[] paths and allows _meta.json as an unvalidated generated file", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
const zip = new JSZip();
|
|
zip.file("scripts/search.py", "print('ok')\n");
|
|
zip.file("SKILL.md", "# Demo\n");
|
|
zip.file("_meta.json", '{"slug":"demo","version":"2026.3.22"}');
|
|
const archiveBytes = await zip.generateAsync({ type: "nodebuffer" });
|
|
await fs.writeFile(archivePath, archiveBytes);
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "scripts/search.py",
|
|
size: 12,
|
|
sha256: sha256Hex("print('ok')\n"),
|
|
},
|
|
{
|
|
path: "SKILL.md",
|
|
size: 7,
|
|
sha256: sha256Hex("# Demo\n"),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath,
|
|
integrity: `sha256-${createHash("sha256").update(archiveBytes).digest("base64")}`,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
const logger = createLoggerSpies();
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
logger,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo" });
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: SKILL.md, scripts/search.py. Validated generated metadata files present in archive: _meta.json (JSON parse plus slug/version match only).',
|
|
);
|
|
});
|
|
|
|
it("omits the skipped-files suffix when no generated extras are present", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
const logger = createLoggerSpies();
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
logger,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, pluginId: "demo" });
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'ClawHub package "demo@2026.3.22" is missing sha256hash; falling back to files[] verification. Validated files: openclaw.plugin.json.',
|
|
);
|
|
});
|
|
|
|
it("rejects fallback verification when _meta.json is not valid JSON", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"_meta.json": "{not-json",
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": _meta.json is not valid JSON.',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when _meta.json slug does not match the package name", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"_meta.json": '{"slug":"wrong","version":"2026.3.22"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": _meta.json slug does not match the package name.',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when _meta.json exceeds the per-file size limit", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
await fs.writeFile(archivePath, "placeholder", "utf8");
|
|
const oversizedMetaEntry = {
|
|
name: "_meta.json",
|
|
dir: false,
|
|
_data: { uncompressedSize: 256 * 1024 * 1024 + 1 },
|
|
nodeStream: vi.fn(),
|
|
} as unknown as JSZip.JSZipObject;
|
|
const listedFileEntry = {
|
|
name: "openclaw.plugin.json",
|
|
dir: false,
|
|
_data: { uncompressedSize: 13 },
|
|
nodeStream: () => Readable.from([Buffer.from('{"id":"demo"}')]),
|
|
} as unknown as JSZip.JSZipObject;
|
|
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValueOnce({
|
|
files: {
|
|
"_meta.json": oversizedMetaEntry,
|
|
"openclaw.plugin.json": listedFileEntry,
|
|
},
|
|
} as unknown as JSZip);
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath,
|
|
integrity: "sha256-not-used-in-fallback",
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
loadAsyncSpy.mockRestore();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive fallback verification rejected "_meta.json" because it exceeds the per-file size limit.',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when archive directories alone exceed the entry limit", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
await fs.writeFile(archivePath, "placeholder", "utf8");
|
|
const zipEntries = Object.fromEntries(
|
|
Array.from({ length: 50_001 }, (_, index) => [
|
|
`folder-${index}/`,
|
|
{
|
|
name: `folder-${index}/`,
|
|
dir: true,
|
|
},
|
|
]),
|
|
);
|
|
const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValueOnce({
|
|
files: zipEntries,
|
|
} as unknown as JSZip);
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath,
|
|
integrity: "sha256-not-used-in-fallback",
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
loadAsyncSpy.mockRestore();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error: "ClawHub archive fallback verification exceeded the archive entry limit.",
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when the downloaded archive exceeds the ZIP size limit", async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-archive-"));
|
|
tempDirs.push(dir);
|
|
const archivePath = path.join(dir, "archive.zip");
|
|
await fs.writeFile(archivePath, "placeholder", "utf8");
|
|
const realStat = fs.stat.bind(fs);
|
|
const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (filePath, options) => {
|
|
if (filePath === archivePath) {
|
|
return {
|
|
size: 256 * 1024 * 1024 + 1,
|
|
} as Awaited<ReturnType<typeof fs.stat>>;
|
|
}
|
|
return await realStat(filePath, options);
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
archivePath,
|
|
integrity: "sha256-not-used-in-fallback",
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
statSpy.mockRestore();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
"ClawHub archive fallback verification rejected the downloaded archive because it exceeds the ZIP archive size limit.",
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when a file hash drifts from files[] metadata", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: "1".repeat(64),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error: `ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": expected openclaw.plugin.json to hash to ${"1".repeat(64)}, got ${sha256Hex('{"id":"demo"}')}.`,
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback metadata with an unsafe files[] path", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "../evil.txt",
|
|
size: 4,
|
|
sha256: "1".repeat(64),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid files[0].path (path "../evil.txt" contains dot segments).',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback metadata with leading or trailing path whitespace", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json ",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has an invalid files[0].path (path "openclaw.plugin.json " has leading or trailing whitespace).',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback verification when the archive includes a whitespace-suffixed file path", async () => {
|
|
const archive = await createClawHubArchive({
|
|
"openclaw.plugin.json": '{"id":"demo"}',
|
|
"openclaw.plugin.json ": '{"id":"demo"}',
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
downloadClawHubPackageArchiveMock.mockResolvedValueOnce({
|
|
...archive,
|
|
cleanup: archiveCleanupMock,
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.ARCHIVE_INTEGRITY_MISMATCH,
|
|
error:
|
|
'ClawHub archive contents do not match files[] metadata for "demo@2026.3.22": invalid package file path "openclaw.plugin.json " (path "openclaw.plugin.json " has leading or trailing whitespace).',
|
|
});
|
|
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback metadata with duplicate files[] paths", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
{
|
|
path: "openclaw.plugin.json",
|
|
size: 13,
|
|
sha256: sha256Hex('{"id":"demo"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" has duplicate files[] path "openclaw.plugin.json".',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects fallback metadata when files[] includes generated _meta.json", async () => {
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
files: [
|
|
{
|
|
path: "_meta.json",
|
|
size: 64,
|
|
sha256: sha256Hex('{"slug":"demo","version":"2026.3.22"}'),
|
|
},
|
|
],
|
|
compatibility: {
|
|
pluginApiRange: ">=2026.3.22",
|
|
minGatewayVersion: "2026.3.0",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await installPluginFromClawHub({
|
|
spec: "clawhub:demo",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.MISSING_ARCHIVE_INTEGRITY,
|
|
error:
|
|
'ClawHub version metadata for "demo@2026.3.22" must not include generated file "_meta.json" in files[].',
|
|
});
|
|
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "rejects packages whose plugin API range exceeds the runtime version",
|
|
setup: () => {
|
|
resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.3.21");
|
|
},
|
|
spec: "clawhub:demo",
|
|
expected: {
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
|
|
error:
|
|
'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.',
|
|
},
|
|
},
|
|
{
|
|
name: "rejects skill families and redirects to skills install",
|
|
setup: () => {
|
|
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
|
|
package: {
|
|
name: "calendar",
|
|
displayName: "Calendar",
|
|
family: "skill",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
},
|
|
});
|
|
},
|
|
spec: "clawhub:calendar",
|
|
expected: {
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
|
|
error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.',
|
|
},
|
|
},
|
|
{
|
|
name: "redirects skill families before missing archive metadata checks",
|
|
setup: () => {
|
|
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
|
|
package: {
|
|
name: "calendar",
|
|
displayName: "Calendar",
|
|
family: "skill",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
},
|
|
});
|
|
fetchClawHubPackageVersionMock.mockResolvedValueOnce({
|
|
version: {
|
|
version: "2026.3.22",
|
|
createdAt: 0,
|
|
changelog: "",
|
|
},
|
|
});
|
|
},
|
|
spec: "clawhub:calendar",
|
|
expected: {
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE,
|
|
error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.',
|
|
},
|
|
},
|
|
{
|
|
name: "returns typed package-not-found failures",
|
|
setup: () => {
|
|
fetchClawHubPackageDetailMock.mockRejectedValueOnce(
|
|
new ClawHubRequestError({
|
|
path: "/api/v1/packages/demo",
|
|
status: 404,
|
|
body: "Package not found",
|
|
}),
|
|
);
|
|
},
|
|
spec: "clawhub:demo",
|
|
expected: {
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND,
|
|
error: "Package not found on ClawHub.",
|
|
},
|
|
},
|
|
{
|
|
name: "returns typed version-not-found failures",
|
|
setup: () => {
|
|
parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" });
|
|
fetchClawHubPackageVersionMock.mockRejectedValueOnce(
|
|
new ClawHubRequestError({
|
|
path: "/api/v1/packages/demo/versions/9.9.9",
|
|
status: 404,
|
|
body: "Version not found",
|
|
}),
|
|
);
|
|
},
|
|
spec: "clawhub:demo@9.9.9",
|
|
expected: {
|
|
ok: false,
|
|
code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND,
|
|
error: "Version not found on ClawHub: demo@9.9.9.",
|
|
},
|
|
},
|
|
] as const)("$name", async ({ setup, spec, expected }) => {
|
|
await expectClawHubInstallError({ setup, spec, expected });
|
|
});
|
|
});
|