mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* feat(claws): publish hosted feed with OpenClaw proof * test(claws): prove package-local profile feed flow * fix(claws): encode scoped package artifact routes * fix(claws): enforce feed rollback and binding * test(claws): pin hosted OpenClaw contract proof * test(claws): add Convex feed runtime smoke * chore(schema): refresh experimental feed declarations --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createServer, type Server } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { EXPERIMENTAL_CLAW_FEED_ID, serializeExperimentalClawFeed } from "clawhub-schema";
|
|
import { gzipSync, strToU8, zipSync } from "fflate";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
assertSafeClawArchive,
|
|
extractSafeClawZip,
|
|
findExtractedPackageRoot,
|
|
readResponseBytesBounded,
|
|
runPublishedClawDryRun,
|
|
selectPublishedClaw,
|
|
} from "./claws-feed-openclaw-e2e";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const openclawRepo = process.env.OPENCLAW_CLAWS_CHECKOUT;
|
|
const fixtureRoot = resolve("fixtures/claws/hosted-e2e");
|
|
let tempRoot = "";
|
|
let archiveBytes = new Uint8Array();
|
|
let integrity = "";
|
|
let server: Server | undefined;
|
|
let serverPort = 0;
|
|
const TAR_BLOCK_SIZE = 512;
|
|
|
|
function writeTarString(target: Uint8Array, offset: number, width: number, value: string) {
|
|
target.set(new TextEncoder().encode(value).subarray(0, width), offset);
|
|
}
|
|
|
|
function writeTarOctal(target: Uint8Array, offset: number, width: number, value: number) {
|
|
writeTarString(target, offset, width, `${value.toString(8).padStart(width - 1, "0")}\0`);
|
|
}
|
|
|
|
function tarEntry(path: string, type: "0" | "2", content = new Uint8Array()) {
|
|
const header = new Uint8Array(TAR_BLOCK_SIZE);
|
|
writeTarString(header, 0, 100, path);
|
|
writeTarOctal(header, 100, 8, type === "0" ? 0o644 : 0o777);
|
|
writeTarOctal(header, 108, 8, 0);
|
|
writeTarOctal(header, 116, 8, 0);
|
|
writeTarOctal(header, 124, 12, content.byteLength);
|
|
writeTarOctal(header, 136, 12, 0);
|
|
header.fill(0x20, 148, 156);
|
|
header[156] = type.charCodeAt(0);
|
|
if (type === "2") writeTarString(header, 157, 100, "../../outside");
|
|
writeTarString(header, 257, 6, "ustar");
|
|
writeTarString(header, 263, 2, "00");
|
|
writeTarOctal(
|
|
header,
|
|
148,
|
|
8,
|
|
header.reduce((sum, byte) => sum + byte, 0),
|
|
);
|
|
const body = new Uint8Array(Math.ceil(content.byteLength / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE);
|
|
body.set(content);
|
|
return [header, body];
|
|
}
|
|
|
|
function deterministicLinkArchive() {
|
|
const parts = [
|
|
...tarEntry(
|
|
"package/package.json",
|
|
"0",
|
|
new TextEncoder().encode('{"name":"@openclaw/hosted-e2e","version":"1.0.0"}\n'),
|
|
),
|
|
...tarEntry("package/workspace", "2"),
|
|
new Uint8Array(TAR_BLOCK_SIZE * 2),
|
|
];
|
|
const tar = new Uint8Array(parts.reduce((size, part) => size + part.byteLength, 0));
|
|
let offset = 0;
|
|
for (const part of parts) {
|
|
tar.set(part, offset);
|
|
offset += part.byteLength;
|
|
}
|
|
return gzipSync(tar);
|
|
}
|
|
|
|
async function npmPackFixture(destination: string) {
|
|
const { stdout } = await execFileAsync(
|
|
"npm",
|
|
[
|
|
"pack",
|
|
join(fixtureRoot, "package"),
|
|
"--json",
|
|
"--ignore-scripts",
|
|
"--pack-destination",
|
|
destination,
|
|
],
|
|
{ cwd: destination },
|
|
);
|
|
const output = JSON.parse(stdout) as unknown;
|
|
const filename =
|
|
Array.isArray(output) && typeof output[0]?.filename === "string"
|
|
? output[0].filename
|
|
: undefined;
|
|
if (!filename) throw new Error("npm pack did not return a fixture filename");
|
|
return join(destination, filename);
|
|
}
|
|
|
|
function feedValue() {
|
|
const now = Date.now();
|
|
return JSON.parse(
|
|
serializeExperimentalClawFeed({
|
|
schemaVersion: 1,
|
|
id: EXPERIMENTAL_CLAW_FEED_ID,
|
|
generatedAt: new Date(now).toISOString(),
|
|
sequence: 1,
|
|
expiresAt: new Date(now + 86_400_000).toISOString(),
|
|
entries: [
|
|
{
|
|
type: "claw",
|
|
id: "@openclaw/hosted-e2e",
|
|
title: "Hosted E2E",
|
|
version: "1.0.0",
|
|
state: "available",
|
|
publisher: { id: "openclaw", trust: "official" },
|
|
clawManifestSummary: {
|
|
schemaVersion: 1,
|
|
agent: { id: "hosted-e2e", name: "Hosted E2E" },
|
|
workspace: { bootstrapFiles: ["SOUL.md"], fileCount: 0 },
|
|
packages: { skillCount: 0, pluginCount: 0 },
|
|
mcpServerCount: 0,
|
|
cronJobCount: 0,
|
|
},
|
|
install: {
|
|
candidates: [
|
|
{
|
|
sourceRef: "public-clawhub",
|
|
package: "@openclaw/hosted-e2e",
|
|
version: "1.0.0",
|
|
integrity,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("published Claw to OpenClaw dry-run proof", () => {
|
|
beforeAll(async () => {
|
|
tempRoot = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-fixture-"));
|
|
const archivePath = await npmPackFixture(tempRoot);
|
|
archiveBytes = new Uint8Array(await readFile(archivePath));
|
|
integrity = `sha256:${createHash("sha256").update(archiveBytes).digest("hex")}`;
|
|
server = createServer((request, response) => {
|
|
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
if (pathname === "/v1/feeds/claws") {
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.end(JSON.stringify(feedValue()));
|
|
return;
|
|
}
|
|
if (pathname === "/api/v1/packages/%40openclaw%2Fhosted-e2e/versions/1.0.0/artifact") {
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.end(
|
|
JSON.stringify({
|
|
artifact: {
|
|
kind: "npm-pack",
|
|
sha256: integrity.slice("sha256:".length),
|
|
downloadUrl: "/download.tgz",
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (pathname === "/download.tgz") {
|
|
response.setHeader("Content-Type", "application/gzip");
|
|
response.end(archiveBytes);
|
|
return;
|
|
}
|
|
response.statusCode = 404;
|
|
response.end("Not found");
|
|
});
|
|
await new Promise<void>((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen));
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Fixture server did not bind");
|
|
serverPort = address.port;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (server) await new Promise<void>((resolveClose) => server!.close(() => resolveClose()));
|
|
if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("selects only the exact public ClawHub candidate", () => {
|
|
const selected = selectPublishedClaw(feedValue(), "@openclaw/hosted-e2e");
|
|
expect(selected.candidate).toMatchObject({ version: "1.0.0", integrity });
|
|
expect(() => selectPublishedClaw(feedValue(), "@openclaw/missing")).toThrow("was not present");
|
|
});
|
|
|
|
it("builds a portable production-equivalent ClawPack fixture", async () => {
|
|
const archivePath = join(tempRoot, "portable-claw.tgz");
|
|
await writeFile(archivePath, archiveBytes);
|
|
await expect(assertSafeClawArchive(archivePath)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("rejects link entries before extracting a published artifact", async () => {
|
|
const archivePath = join(tempRoot, "linked.tgz");
|
|
await writeFile(archivePath, deterministicLinkArchive());
|
|
await expect(assertSafeClawArchive(archivePath)).rejects.toThrow(
|
|
"only contain regular files and directories",
|
|
);
|
|
});
|
|
|
|
it("extracts legacy ZIP artifacts without permitting traversal", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-zip-"));
|
|
try {
|
|
const archive = zipSync({
|
|
"package/package.json": strToU8("{}\n"),
|
|
"package/CLAW.md": strToU8("---\nschemaVersion: 1\n---\n"),
|
|
});
|
|
await extractSafeClawZip(archive, root);
|
|
await expect(readFile(join(root, "package", "package.json"), "utf8")).resolves.toBe("{}\n");
|
|
|
|
const unsafeArchive = zipSync({ "../outside": strToU8("unsafe") });
|
|
await expect(extractSafeClawZip(unsafeArchive, root)).rejects.toThrow("unsafe path");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("discovers legacy ZIP packages extracted directly at the archive root", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-root-zip-"));
|
|
try {
|
|
const archive = zipSync({
|
|
"package.json": strToU8("{}\n"),
|
|
"CLAW.md": strToU8("---\nschemaVersion: 1\n---\n"),
|
|
});
|
|
await extractSafeClawZip(archive, root);
|
|
await expect(findExtractedPackageRoot(root)).resolves.toBe(root);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects ZIP artifacts whose expanded content exceeds the package limit", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "clawhub-hosted-e2e-large-zip-"));
|
|
try {
|
|
const archive = zipSync({ "package/large.bin": new Uint8Array(50 * 1024 * 1024 + 1) });
|
|
await expect(extractSafeClawZip(archive, root)).rejects.toThrow("50MB unpacked limit");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects oversized downloads from metadata before buffering the body", async () => {
|
|
const response = new Response("", { headers: { "Content-Length": String(65 * 1024 * 1024) } });
|
|
await expect(readResponseBytesBounded(response)).rejects.toThrow("64MB download limit");
|
|
});
|
|
|
|
it.skipIf(!openclawRepo)(
|
|
"runs the downloaded package through OpenClaw dry-run",
|
|
async () => {
|
|
const origin = `http://127.0.0.1:${serverPort}`;
|
|
const result = await runPublishedClawDryRun({
|
|
feedUrl: `${origin}/v1/feeds/claws`,
|
|
packageName: "@openclaw/hosted-e2e",
|
|
registryUrl: origin,
|
|
openclawRepo: openclawRepo!,
|
|
});
|
|
expect(result.plan).toMatchObject({
|
|
schemaVersion: "openclaw.clawAddPlan.v1",
|
|
dryRun: true,
|
|
mutationAllowed: false,
|
|
agent: { finalId: "hosted-e2e" },
|
|
summary: { blockedActions: 0 },
|
|
});
|
|
},
|
|
30_000,
|
|
);
|
|
});
|