Compare commits

...
Author SHA1 Message Date
Vincent Koc ca33ea3839 fix(static): trim unused clawpack exports 2026-05-02 07:44:02 -07:00
Vincent Koc 4019c727b2 merge main into clawpack branch 2026-05-02 07:37:11 -07:00
Vincent Koc 66d1814ee5 fix(plugins): keep clawpack release pages private 2026-05-02 07:27:06 -07:00
Vincent Koc 3f31e5bb03 test(plugin): normalize github fetch mock urls 2026-05-01 18:33:09 -07:00
Vincent Koc 879f4d95d4 feat(plugin): link published clawpack releases 2026-05-01 18:30:22 -07:00
Vincent Koc b89f7d95e3 feat(plugin): show clawpack upload progress 2026-05-01 18:27:56 -07:00
Vincent Koc 6ff0c9e560 fix(plugin): resolve clawpack download hrefs 2026-05-01 18:25:14 -07:00
Vincent Koc aad6ff1912 refactor(plugin): rename clawpack surfaces 2026-05-01 18:21:04 -07:00
Vincent Koc b8b449f6f5 chore(plugin): format github source importer 2026-05-01 18:15:38 -07:00
Vincent Koc e538e7396c feat(plugin): import publish files from github urls 2026-05-01 18:15:14 -07:00
Vincent Koc d13387d1bb feat(plugin): fetch package files from github urls 2026-05-01 18:12:10 -07:00
Vincent Koc d89b9d1075 refactor(ui): use clawpack helper names 2026-05-01 18:07:00 -07:00
Vincent Koc 3df5102259 chore: refresh generated route tree 2026-05-01 17:49:22 -07:00
Vincent Koc 9d397472fc fix(ui): use clawpack list badge field 2026-05-01 17:47:22 -07:00
Vincent Koc 3ed917f973 fix(api): drop unused clawpack path variable 2026-05-01 17:45:58 -07:00
Vincent Koc 24a0fc10f6 chore: format clawpack changes 2026-05-01 17:44:34 -07:00
Vincent Koc 8a19cd0a3e fix(api): expose clawpack migration contracts 2026-05-01 17:42:57 -07:00
Vincent Koc bb69b7c4df feat(management): rename storepack surfaces to clawpack 2026-05-01 17:35:05 -07:00
Vincent Koc 9b3904fa08 feat(plugin): move upload flow to clawpack 2026-05-01 17:23:28 -07:00
Vincent Koc 8506c3cbb3 merge main into storepack branch 2026-05-01 16:07:43 -07:00
Vincent Koc 6de67497e1 docs(storepack): document migration run operations 2026-05-01 16:03:08 -07:00
Vincent Koc 56953f5a2a feat(moderation): show plugin queue counts 2026-05-01 15:55:39 -07:00
Vincent Koc b9d3620ca7 feat(cli): add storepack migration runs 2026-05-01 15:45:49 -07:00
Vincent Koc f71890dab3 feat(api): add storepack migration run routes 2026-05-01 15:41:47 -07:00
Vincent Koc ebaa5ed270 feat(storepack): expose internal migration run APIs 2026-05-01 15:38:25 -07:00
Vincent Koc 0d36f5e153 feat(management): add storepack run controls 2026-05-01 15:34:34 -07:00
Vincent Koc 79f4dded4c feat(storepack): track migration runs 2026-05-01 15:30:38 -07:00
Vincent Koc 14ce0288d7 feat(management): add user role operations 2026-05-01 15:20:36 -07:00
Vincent Koc ec6e960b32 fix(management): render nested detail routes 2026-05-01 15:15:04 -07:00
Vincent Koc 98e3e663f3 feat(management): add storepack release details 2026-05-01 15:12:57 -07:00
Vincent Koc d14eb821bc feat(management): add plugin operations routes 2026-05-01 15:03:26 -07:00
Vincent Koc 5e05b0e29a fix(routes): reserve asset paths from skill pages 2026-05-01 14:55:55 -07:00
Vincent Koc a11ee6245c feat(management): add migration candidate details 2026-05-01 14:44:49 -07:00
Vincent Koc 9bbd9c2f53 docs(storepack): add platform operations guides 2026-05-01 14:38:38 -07:00
Vincent Koc 343deb5611 fix(app): skip analytics outside production 2026-05-01 14:34:10 -07:00
Vincent Koc a610f0d812 test(site): align local canonical URLs 2026-05-01 14:21:04 -07:00
Vincent Koc 92c620b2e8 test(storepack): cover publish artifact storage 2026-05-01 14:17:40 -07:00
Vincent Koc 3b87bfcf0b feat(moderation): show plugin release evidence 2026-05-01 14:14:55 -07:00
Vincent Koc c30b5f4ab0 feat(publish): add storepack intake review 2026-05-01 14:11:56 -07:00
Vincent Koc b0d3ac36a2 feat(management): map plugin operations 2026-05-01 14:09:22 -07:00
Vincent Koc 3aa2d2da3a feat(dashboard): expose plugin release publishing 2026-05-01 14:06:37 -07:00
Vincent Koc 6516bce5ca feat(dashboard): show plugin package health 2026-05-01 14:03:52 -07:00
Vincent Koc 9b1c727de6 feat(cli): report storepack migration readiness 2026-05-01 13:55:26 -07:00
Vincent Koc 2f3852f857 feat(publish): import storepack archives 2026-05-01 13:44:26 -07:00
Vincent Koc 5bc359f3c7 feat(storepack): surface release lifecycle 2026-05-01 13:39:30 -07:00
Vincent Koc d993daa2d6 test(e2e): cover plugin storepack workflows 2026-05-01 13:28:21 -07:00
Vincent Koc a0fa36f57f feat(management): add migration readiness dashboard 2026-05-01 13:28:09 -07:00
Vincent Koc 6c4a8cb1c6 feat(storepack): report plugin migration readiness 2026-05-01 13:27:55 -07:00
Vincent Koc 83f7b52805 fix(publish): gate package upload until hydration 2026-05-01 13:27:35 -07:00
Vincent Koc 336c4741cb fix(management): clarify access and local navigation 2026-05-01 13:05:53 -07:00
Vincent Koc 3fe68a0c13 fix(publish): surface package import errors 2026-05-01 13:00:42 -07:00
Vincent Koc 675b9324de feat(storepack): retry failed artifact builds 2026-05-01 12:50:36 -07:00
Vincent Koc 5aee884618 feat(cli): publish package archives 2026-05-01 12:42:06 -07:00
Vincent Koc 8d44391c51 feat(storepack): track backfill failures 2026-05-01 12:34:13 -07:00
Vincent Koc 65dd5f2a52 feat(publish): make archive upload explicit 2026-05-01 12:27:32 -07:00
Vincent Koc f26abe7976 feat(management): add plugin moderation queue 2026-05-01 12:25:39 -07:00
Vincent Koc 5d56bca4bb feat(management): add storepack operations page 2026-05-01 12:20:04 -07:00
Vincent Koc a43970b1ec feat(publish): preview generated storepacks 2026-05-01 12:17:02 -07:00
Vincent Koc afcb2b1150 feat(cli): inspect remote storepack manifests 2026-05-01 12:08:50 -07:00
Vincent Koc f85914e8c2 feat(storepack): strengthen management controls 2026-05-01 12:02:02 -07:00
Vincent Koc f75061e492 feat(storepack): add release artifact page 2026-05-01 11:59:26 -07:00
Vincent Koc af881532bb feat(storepack): expose release inspection api 2026-05-01 11:59:13 -07:00
Vincent Koc fc1aa31328 fix(ui): keep local preview links same-origin 2026-05-01 10:23:29 -07:00
Vincent Koc 36ba9a679b docs(storepack): document admin operations 2026-05-01 09:52:24 -07:00
Vincent Koc 92ca2c04cc feat(storepack): backfill filter indexes 2026-05-01 09:49:19 -07:00
Vincent Koc ae659c042a feat(storepack): index host environment filters 2026-05-01 09:43:33 -07:00
Vincent Koc 10afffc712 feat(ui): expose storepack revocation 2026-05-01 09:35:58 -07:00
Vincent Koc fdecff9c9c feat(cli): revoke storepack artifacts 2026-05-01 09:32:40 -07:00
Vincent Koc 48f94cdd57 feat(storepack): revoke compromised artifacts 2026-05-01 09:28:27 -07:00
Vincent Koc df95443da5 fix(storepack): reject unsafe archive paths 2026-05-01 09:20:10 -07:00
Vincent Koc 76450a57ea feat(storepack): serve artifacts by digest 2026-05-01 09:16:23 -07:00
Vincent Koc de58458210 fix(storepack): satisfy lint for admin helpers 2026-05-01 08:44:41 -07:00
Vincent Koc 86af5c07df docs: document storepack package workflows 2026-05-01 08:41:13 -07:00
Vincent Koc 938caf8ed5 feat(cli): filter package browse by storepack signals 2026-05-01 08:39:46 -07:00
Vincent Koc 2262f00bf5 test(storepack): cover kitchen sink plugin archive 2026-05-01 08:36:21 -07:00
Vincent Koc 692db1da9a test(fixtures): add kitchen sink storepack input 2026-05-01 08:34:40 -07:00
Vincent Koc 0e0510f7ea feat(plugins): add storepack browse filters 2026-05-01 08:33:01 -07:00
Vincent Koc 58b619708d feat(packages): filter catalog by storepack metadata 2026-05-01 08:31:27 -07:00
Vincent Koc 1d38dc5592 feat(packages): index storepack digest metadata 2026-05-01 08:22:27 -07:00
Vincent Koc b4c443f08f test(api): cover storepack migration endpoints 2026-05-01 08:20:28 -07:00
Vincent Koc 86d3c2a29a test(cli): cover storepack admin commands 2026-05-01 08:19:34 -07:00
Vincent Koc efdaf8381b feat(publish): preview storepack readiness 2026-05-01 08:18:30 -07:00
Vincent Koc 41cebd32bb feat(plugins): show storepack artifact details 2026-05-01 08:17:27 -07:00
Vincent Koc fae573f534 feat(management): add storepack plugin controls 2026-05-01 08:15:13 -07:00
Vincent Koc b468fd32f8 feat(cli): expose storepack migration controls 2026-05-01 08:14:59 -07:00
Vincent Koc 1f985013e1 feat(packages): add storepack migration backend 2026-05-01 08:14:42 -07:00
Vincent Koc 97a8ad8f93 style(storepack): format artifact helpers 2026-05-01 08:14:08 -07:00
Vincent Koc 023d0cb863 feat(packages): add storepack artifacts 2026-05-01 07:53:29 -07:00
74 changed files with 15194 additions and 128 deletions
+20 -1
View File
@@ -17,6 +17,7 @@ import type { MutationCtx } from "./_generated/server";
import {
deletePackageSearchDigests,
extractPackageDigestFields,
extractPackageClawPackDigestFields,
upsertPackageSearchDigest,
} from "./lib/packageSearchDigest";
import { getOwnerPublisher } from "./lib/publishers";
@@ -130,6 +131,7 @@ async function syncPackageSearchDigest(
});
await upsertPackageSearchDigest(ctx, {
...fields,
...extractPackageClawPackDigestFields(latestRelease),
latestVersion:
latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
ownerHandle: owner?.handle ?? "",
@@ -147,6 +149,23 @@ export async function syncPackageSearchDigestForPackageId(
await syncPackageSearchDigest(ctx, pkg);
}
function packageReleaseDigestFieldsChanged(
oldDoc: Doc<"packageReleases">,
newDoc: Doc<"packageReleases">,
) {
return (
oldDoc.softDeletedAt !== newDoc.softDeletedAt ||
oldDoc.clawpackStorageId !== newDoc.clawpackStorageId ||
oldDoc.clawpackSha256 !== newDoc.clawpackSha256 ||
oldDoc.clawpackBuiltAt !== newDoc.clawpackBuiltAt ||
oldDoc.clawpackRevokedAt !== newDoc.clawpackRevokedAt ||
JSON.stringify(oldDoc.hostTargetsSummary ?? []) !==
JSON.stringify(newDoc.hostTargetsSummary ?? []) ||
JSON.stringify(oldDoc.environmentSummary ?? null) !==
JSON.stringify(newDoc.environmentSummary ?? null)
);
}
export async function syncPackageSearchDigestsForOwnerUserId(
ctx: PackageDigestSyncCtx,
ownerUserId: Id<"users"> | null | undefined,
@@ -402,7 +421,7 @@ triggers.register("packageReleases", async (ctx, change) => {
if (change.operation === "insert") return;
if (
change.operation === "update" &&
change.oldDoc.softDeletedAt === change.newDoc.softDeletedAt
!packageReleaseDigestFieldsChanged(change.oldDoc, change.newDoc)
) {
return;
}
+7
View File
@@ -38,6 +38,7 @@ import {
soulsPostRouterV1Http,
starsDeleteRouterV1Http,
starsPostRouterV1Http,
clawpacksGetRouterV1Http,
transfersGetRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
@@ -115,6 +116,12 @@ http.route({
handler: pluginsGetRouterV1Http,
});
http.route({
pathPrefix: "/api/v1/clawpacks/",
method: "GET",
handler: clawpacksGetRouterV1Http,
});
http.route({
path: ApiRoutes.skills,
method: "POST",
+736 -1
View File
@@ -1,5 +1,5 @@
/* @vitest-environment node */
import { unzipSync } from "fflate";
import { unzipSync, zipSync } from "fflate";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import { RATE_LIMITS } from "./lib/httpRateLimit";
@@ -3795,6 +3795,741 @@ describe("httpApiV1 handlers", () => {
});
});
it("package download serves the stored Claw Pack artifact when present", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "official",
isOfficial: true,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: { _id: "publishers:openclaw", handle: "openclaw" },
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
files: [
{
path: "package.json",
size: 2,
sha256: "a".repeat(64),
storageId: "storage:file",
contentType: "application/json",
},
],
clawpackStorageId: "storage:clawpack",
clawpackSha256: "ab".repeat(32),
clawpackSize: 13,
clawpackSpecVersion: 1,
};
}
return null;
});
const storageGet = vi.fn(async (storageId: string) => {
if (storageId === "storage:clawpack") {
return new Blob(["clawpack zip"], { type: "application/zip" });
}
throw new Error(`unexpected storage read: ${storageId}`);
});
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: {
get: storageGet,
},
}),
new Request("https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/download"),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("clawpack zip");
expect(storageGet).toHaveBeenCalledTimes(1);
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
expect(response.headers.get("Content-Disposition")).toBe(
'attachment; filename="@openclaw-kitchen-sink-1.0.0.clawpack.zip"',
);
expect(response.headers.get("ETag")).toBe(`"sha256:${"ab".repeat(32)}"`);
expect(response.headers.get("Digest")).toBe(
`sha-256=${Buffer.from("ab".repeat(32), "hex").toString("base64")}`,
);
expect(response.headers.get("X-ClawHub-ClawPack-Sha256")).toBe("ab".repeat(32));
expect(response.headers.get("X-ClawHub-ClawPack-Spec-Version")).toBe("1");
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
packageId: "packages:1",
});
});
it("returns release Claw Pack metadata without reading the artifact blob", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("version" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
},
version: {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
files: [],
clawpackStorageId: "storage:clawpack",
clawpackSha256: "ab".repeat(32),
clawpackSize: 13,
clawpackSpecVersion: 1,
clawpackFileCount: 3,
clawpackManifestSha256: "cd".repeat(32),
hostTargetsSummary: [{ os: "darwin", arch: "arm64", supportState: "supported" }],
environmentSummary: { requiresLocalDesktop: true },
},
};
}
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "official",
isOfficial: true,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: { _id: "publishers:openclaw", handle: "openclaw" },
};
}
return null;
});
const storageGet = vi.fn();
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: {
get: storageGet,
},
}),
new Request(
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack",
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.clawpack).toMatchObject({
available: true,
sha256: "ab".repeat(32),
size: 13,
fileCount: 3,
manifestSha256: "cd".repeat(32),
});
expect(body.links).toEqual({
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
immutable: `/api/v1/clawpacks/${"ab".repeat(32)}`,
manifest: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
});
expect(storageGet).not.toHaveBeenCalled();
});
it("returns a release Claw Pack manifest from the stored artifact", async () => {
const manifest = {
kind: "openclaw.clawpack",
specVersion: 1,
package: { name: "@openclaw/kitchen-sink", version: "1.0.0" },
files: [{ path: "package.json", sha256: "a".repeat(64), size: 2 }],
};
const zip = zipSync({
"package/CLAWPACK.json": new TextEncoder().encode(JSON.stringify(manifest)),
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("version" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
},
version: {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
files: [],
clawpackStorageId: "storage:clawpack",
clawpackSha256: "ab".repeat(32),
clawpackSize: zip.byteLength,
clawpackSpecVersion: 1,
clawpackFileCount: 2,
},
};
}
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "official",
isOfficial: true,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: { _id: "publishers:openclaw", handle: "openclaw" },
};
}
return null;
});
const storageGet = vi.fn(async (storageId: string) => {
if (storageId === "storage:clawpack") {
const zipBlobPart = zip.buffer.slice(
zip.byteOffset,
zip.byteOffset + zip.byteLength,
) as ArrayBuffer;
return new Blob([zipBlobPart], {
type: "application/zip",
});
}
throw new Error(`unexpected storage read: ${storageId}`);
});
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: {
get: storageGet,
},
}),
new Request(
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.package).toEqual({
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
});
expect(body.version).toBe("1.0.0");
expect(body.clawpack.sha256).toBe("ab".repeat(32));
expect(body.manifest).toEqual(manifest);
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
});
it("package download refuses revoked Claw Pack artifacts", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) {
return {
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
tags: {},
latestReleaseId: "packageReleases:1",
channel: "official",
isOfficial: true,
createdAt: 1,
updatedAt: 1,
},
latestRelease: null,
owner: { _id: "publishers:openclaw", handle: "openclaw" },
};
}
if ("releaseId" in args) {
return {
_id: "packageReleases:1",
version: "1.0.0",
createdAt: 1,
changelog: "init",
files: [],
clawpackStorageId: "storage:clawpack",
clawpackSha256: "ab".repeat(32),
clawpackSize: 13,
clawpackSpecVersion: 1,
clawpackRevokedAt: 1_763_000_000_000,
};
}
return null;
});
const storageGet = vi.fn();
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: {
get: storageGet,
},
}),
new Request("https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/download"),
);
expect(response.status).toBe(410);
expect(await response.text()).toBe("Claw Pack revoked");
expect(storageGet).not.toHaveBeenCalled();
expect(
runMutation.mock.calls.some(
([ref]) => ref === internal.packages.recordPackageDownloadInternal,
),
).toBe(false);
});
it("serves Claw Pack artifacts by digest with immutable cache headers", async () => {
const sha256 = "ab".repeat(32);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (args.sha256 === sha256) {
return {
status: "ok",
artifact: {
storageId: "storage:clawpack",
sha256,
size: 13,
format: "zip",
},
package: {
_id: "packages:1",
name: "@openclaw/kitchen-sink",
},
release: {
_id: "packageReleases:1",
version: "1.0.0",
clawpackSpecVersion: 1,
},
};
}
return null;
});
const storageGet = vi.fn(async () => new Blob(["clawpack zip"], { type: "application/zip" }));
const response = await __handlers.clawpacksGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: { get: storageGet },
}),
new Request(`https://example.com/api/v1/clawpacks/${sha256}`),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("clawpack zip");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("ETag")).toBe(`"sha256:${sha256}"`);
expect(response.headers.get("Digest")).toBe(
`sha-256=${Buffer.from(sha256, "hex").toString("base64")}`,
);
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
packageId: "packages:1",
});
});
it("supports HEAD for digest-addressed Claw Pack artifacts without recording a download", async () => {
const sha256 = "cd".repeat(32);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (args.sha256 === sha256) {
return {
status: "ok",
artifact: {
storageId: "storage:clawpack",
sha256,
size: 13,
format: "zip",
},
package: {
_id: "packages:1",
name: "demo",
},
release: {
_id: "packageReleases:1",
version: "1.0.0",
clawpackSpecVersion: 1,
},
};
}
return null;
});
const storageGet = vi.fn(async () => new Blob(["clawpack zip"], { type: "application/zip" }));
const response = await __handlers.clawpacksGetRouterV1Handler(
makeCtx({
runQuery,
runMutation,
storage: { get: storageGet },
}),
new Request(`https://example.com/api/v1/clawpacks/${sha256}`, { method: "HEAD" }),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("");
expect(response.headers.get("Content-Length")).toBe("13");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
expect(
runMutation.mock.calls.some(
([ref]) => ref === internal.packages.recordPackageDownloadInternal,
),
).toBe(false);
});
it("returns gone for revoked digest-addressed Claw Pack artifacts", async () => {
const sha256 = "ef".repeat(32);
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (args.sha256 === sha256) return { status: "revoked" };
return null;
});
const response = await __handlers.clawpacksGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
new Request(`https://example.com/api/v1/clawpacks/${sha256}`),
);
expect(response.status).toBe(410);
});
it("clawpack migration status requires an admin API token", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
missingSample: [],
missingSampleSize: 0,
generatedClawPackSampleSize: 1,
generatedClawPackBytes: 1024,
sampleLimit: args.limit,
};
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/migration-status?limit=7"),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
generatedClawPackSampleSize: 1,
sampleLimit: 7,
});
});
it("clawpack migration readiness requires an admin API token", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
items: [
{
bundledPluginId: "opik",
readinessState: "clawpack-missing",
blockers: ["clawpack-missing"],
},
],
readyCount: 1,
blockedCount: 0,
generatedAt: 1,
};
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/migration-readiness"),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
readyCount: 1,
items: [{ bundledPluginId: "opik", readinessState: "clawpack-missing" }],
});
});
it("clawpack migration run dry-run and list routes require an admin API token", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if (args.operation) {
return {
operation: args.operation,
limit: args.limit,
candidates: [{ name: "demo-plugin", version: "1.0.0" }],
candidateCount: 1,
};
}
return {
items: [{ _id: "clawPackMigrationRuns:1", status: "pending" }],
limit: args.limit,
status: args.status ?? null,
};
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const dryRunResponse = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/packages/clawpack/migration-runs/dry-run?operation=artifact-backfill&limit=5",
),
);
const listResponse = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
"https://example.com/api/v1/packages/clawpack/migration-runs?status=pending&limit=10",
),
);
expect(dryRunResponse.status).toBe(200);
await expect(dryRunResponse.json()).resolves.toMatchObject({
operation: "artifact-backfill",
candidateCount: 1,
});
expect(listResponse.status).toBe(200);
await expect(listResponse.json()).resolves.toMatchObject({
items: [{ _id: "clawPackMigrationRuns:1" }],
status: "pending",
});
});
it("clawpack migration run create and continue routes dispatch admin operations", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
_id: "clawPackMigrationRuns:1",
actorUserId: args.actorUserId,
operation: args.operation,
status: "pending",
limit: args.limit,
};
});
const runAction = vi.fn().mockResolvedValue({
run: { _id: "clawPackMigrationRuns:1", status: "completed" },
result: { processed: 1, succeeded: 1, failed: 0 },
});
const createResponse = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runAction, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/migration-runs", {
method: "POST",
body: JSON.stringify({ operation: "failure-retry", limit: 3 }),
}),
);
const continueResponse = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runAction, runMutation }),
new Request(
"https://example.com/api/v1/packages/clawpack/migration-runs/clawPackMigrationRuns:1/continue",
{ method: "POST" },
),
);
expect(createResponse.status).toBe(200);
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
operation: "failure-retry",
limit: 3,
});
await expect(createResponse.json()).resolves.toMatchObject({
_id: "clawPackMigrationRuns:1",
operation: "failure-retry",
});
expect(continueResponse.status).toBe(200);
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
runId: "clawPackMigrationRuns:1",
});
await expect(continueResponse.json()).resolves.toMatchObject({
result: { processed: 1 },
});
});
it("clawpack backfill dispatches the admin action", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runAction = vi.fn().mockResolvedValue({
processed: 2,
succeeded: 2,
failed: 0,
results: [],
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runAction, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/backfill", {
method: "POST",
body: JSON.stringify({ limit: 2 }),
}),
);
expect(response.status).toBe(200);
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
limit: 2,
});
await expect(response.json()).resolves.toMatchObject({
processed: 2,
succeeded: 2,
});
});
it("clawpack index backfill dispatches the admin action", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runAction = vi.fn().mockResolvedValue({
processed: 2,
succeeded: 2,
failed: 0,
results: [],
continueCursor: "cursor:2",
isDone: false,
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runAction, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/index-backfill", {
method: "POST",
body: JSON.stringify({ limit: 2, cursor: "cursor:1" }),
}),
);
expect(response.status).toBe(200);
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
limit: 2,
cursor: "cursor:1",
});
await expect(response.json()).resolves.toMatchObject({
processed: 2,
continueCursor: "cursor:2",
});
});
it("clawpack failure retry dispatches the admin action", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runAction = vi.fn().mockResolvedValue({
processed: 1,
succeeded: 1,
failed: 0,
results: [],
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runAction, runMutation }),
new Request("https://example.com/api/v1/packages/clawpack/retry-failures", {
method: "POST",
body: JSON.stringify({ limit: 1 }),
}),
);
expect(response.status).toBe(200);
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:admin",
limit: 1,
});
await expect(response.json()).resolves.toMatchObject({
processed: 1,
succeeded: 1,
});
});
it("clawpack revoke dispatches the moderator mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
ok: true,
packageId: "packages:1",
releaseId: "packageReleases:1",
version: "1.0.0",
sha256: "ab".repeat(32),
revokedArtifactCount: 1,
};
});
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runMutation }),
new Request(
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/revoke",
{
method: "POST",
body: JSON.stringify({ reason: "malware confirmed" }),
},
),
);
if (response.status !== 200) throw new Error(await response.text());
expect(await response.json()).toMatchObject({
ok: true,
releaseId: "packageReleases:1",
revokedArtifactCount: 1,
});
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:moderator",
name: "@openclaw/kitchen-sink",
version: "1.0.0",
reason: "malware confirmed",
});
});
it("package download fails when any stored file is missing", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
+3
View File
@@ -10,6 +10,7 @@ import {
packagesPostRouterV1Handler,
pluginsGetRouterV1Handler,
publishPackageV1Handler,
clawpacksGetRouterV1Handler,
} from "./httpApiV1/packagesV1";
import {
listSkillsV1Handler,
@@ -38,6 +39,7 @@ export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
export const packagesPostRouterV1Http = httpAction(packagesPostRouterV1Handler);
export const packagesDeleteRouterV1Http = httpAction(packagesDeleteRouterV1Handler);
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
export const clawpacksGetRouterV1Http = httpAction(clawpacksGetRouterV1Handler);
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
export const mintPublishTokenV1Http = httpAction(mintPublishTokenV1Handler);
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
@@ -72,6 +74,7 @@ export const __handlers = {
packagesPostRouterV1Handler,
packagesDeleteRouterV1Handler,
pluginsGetRouterV1Handler,
clawpacksGetRouterV1Handler,
publishPackageV1Handler,
mintPublishTokenV1Handler,
listCodePluginsV1Handler,
+710 -7
View File
@@ -4,6 +4,8 @@ import {
PublishTokenMintRequestSchema,
parseArk,
} from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema/routes";
import { unzipSync } from "fflate";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
@@ -26,6 +28,7 @@ import {
json,
resolveTagsBatch,
requireApiTokenUserOrResponse,
requireAdminOrResponse,
requirePackagePublishAuthOrResponse,
safeTextFileResponse,
softDeleteErrorToResponse,
@@ -53,11 +56,23 @@ const internalRefs = internal as unknown as {
listVersionsForViewerInternal: unknown;
getPackageByNameInternal: unknown;
getTrustedPublisherByPackageIdInternal: unknown;
getClawPackArtifactByShaForViewerInternal: unknown;
getVersionByNameForViewerInternal: unknown;
publishPackageForUserInternal: unknown;
publishPackageForTrustedPublisherInternal: unknown;
setTrustedPublisherForUserInternal: unknown;
deleteTrustedPublisherForUserInternal: unknown;
backfillClawPackArtifactsInternal: unknown;
backfillClawPackSearchIndexInternal: unknown;
retryClawPackBackfillFailuresInternal: unknown;
getClawPackMigrationStatusInternal: unknown;
dryRunClawPackMigrationRunForStaffInternal: unknown;
listClawPackMigrationRunsForStaffInternal: unknown;
getClawPackMigrationRunInternal: unknown;
startClawPackMigrationRunInternal: unknown;
continueClawPackMigrationRunInternal: unknown;
listOfficialMigrationReadinessForStaffInternal: unknown;
revokeClawPackArtifactForStaffInternal: unknown;
getReleasesByIdsInternal: unknown;
getReleaseByPackageAndVersionInternal: unknown;
getReleaseByIdInternal: unknown;
@@ -108,10 +123,29 @@ type PackageListQueryArgs = {
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
hostTarget?: string;
environment?: string;
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
};
type ClawPackMigrationOperation = "artifact-backfill" | "failure-retry" | "search-index-backfill";
type ClawPackMigrationStatus = "pending" | "running" | "completed" | "failed";
function parseClawPackMigrationOperation(raw: unknown): ClawPackMigrationOperation | null {
if (raw === "artifact-backfill" || raw === "failure-retry" || raw === "search-index-backfill") {
return raw;
}
return null;
}
function parseClawPackMigrationStatus(raw: unknown): ClawPackMigrationStatus | undefined {
if (raw === "pending" || raw === "running" || raw === "completed" || raw === "failed") {
return raw;
}
return undefined;
}
type SkillPackageDocLike = {
_id: Id<"skills">;
slug: string;
@@ -157,6 +191,20 @@ type ReleaseLike = {
compatibility?: Doc<"packageReleases">["compatibility"];
capabilities?: Doc<"packageReleases">["capabilities"];
verification?: Doc<"packageReleases">["verification"];
clawpackStorageId?: Id<"_storage">;
clawpackSha256?: string;
clawpackSize?: number;
clawpackSpecVersion?: number;
clawpackFormat?: "zip";
clawpackFileCount?: number;
clawpackManifestSha256?: string;
clawpackBuiltAt?: number;
clawpackBuildVersion?: string;
clawpackRevokedAt?: number;
clawpackRevokedByUserId?: Id<"users">;
clawpackRevocationReason?: string;
hostTargetsSummary?: Doc<"packageReleases">["hostTargetsSummary"];
environmentSummary?: Doc<"packageReleases">["environmentSummary"];
sha256hash?: string;
vtAnalysis?: Doc<"packageReleases">["vtAnalysis"];
llmAnalysis?: Doc<"packageReleases">["llmAnalysis"];
@@ -201,6 +249,89 @@ function getReleaseSecurityBlock(release: ReleaseLike) {
return getPackageDownloadSecurityBlock(release);
}
function toPublicClawPack(release: ReleaseLike | null | undefined) {
if (
!release?.clawpackStorageId ||
!release.clawpackSha256 ||
!release.clawpackSize ||
release.clawpackRevokedAt
) {
return {
available: false,
specVersion: null,
format: null,
sha256: null,
size: null,
fileCount: null,
manifestSha256: null,
builtAt: null,
buildVersion: null,
hostTargets: release?.hostTargetsSummary ?? [],
environment: release?.environmentSummary ?? null,
runtimeBundles: [],
};
}
return {
available: true,
specVersion: release.clawpackSpecVersion ?? 1,
format: release.clawpackFormat ?? "zip",
sha256: release.clawpackSha256,
size: release.clawpackSize,
fileCount: release.clawpackFileCount ?? null,
manifestSha256: release.clawpackManifestSha256 ?? null,
builtAt: release.clawpackBuiltAt ?? null,
buildVersion: release.clawpackBuildVersion ?? null,
hostTargets: release.hostTargetsSummary ?? [],
environment: release.environmentSummary ?? null,
runtimeBundles: [],
};
}
async function readClawPackManifest(blob: Blob) {
const entries = unzipSync(new Uint8Array(await blob.arrayBuffer()));
const manifestBytes = entries["package/CLAWPACK.json"];
if (!manifestBytes) throw new Error("Missing Claw Pack manifest");
return JSON.parse(new TextDecoder().decode(manifestBytes)) as Record<string, unknown>;
}
function requireModeratorOrResponse(
user: { role?: string | null | undefined },
headers: HeadersInit,
) {
if (user.role === "admin" || user.role === "moderator") return { ok: true as const };
return { ok: false as const, response: text("Forbidden", 403, headers) };
}
function sha256DigestHeader(hex: string) {
const bytes = hex.match(/.{1,2}/g)?.map((part) => Number.parseInt(part, 16)) ?? [];
return `sha-256=${btoa(String.fromCharCode(...bytes))}`;
}
function normalizeClawPackSha256(raw: string | undefined) {
const sha256 = raw?.trim().toLowerCase();
return sha256 && /^[a-f0-9]{64}$/.test(sha256) ? sha256 : null;
}
function clawPackArtifactHeaders(input: {
packageName: string;
version: string;
sha256: string;
size: number;
specVersion?: number;
immutable?: boolean;
}) {
return {
"Content-Type": "application/zip",
"Content-Length": String(input.size),
"Content-Disposition": `attachment; filename="${input.packageName.replaceAll("/", "-")}-${input.version}.clawpack.zip"`,
ETag: `"sha256:${input.sha256}"`,
Digest: sha256DigestHeader(input.sha256),
...(input.immutable ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
"X-ClawHub-ClawPack-Sha256": input.sha256,
"X-ClawHub-ClawPack-Spec-Version": String(input.specVersion ?? 1),
};
}
async function resolvePackageTags(
ctx: ActionCtx,
tags: Record<string, Id<"packageReleases">>,
@@ -237,10 +368,82 @@ type CatalogListItem = {
capabilityTags?: string[];
executesCode?: boolean;
verificationTier?: string | null;
clawpackAvailable?: boolean;
hostTargetKeys?: string[];
environmentFlags?: string[];
};
type CatalogSearchEntry = { score: number; package: CatalogListItem };
function toPublicCatalogItem(item: CatalogListItem & Record<string, unknown>): CatalogListItem {
const { clawpackAvailable, clawpack, ...rest } = item;
return {
...rest,
...(typeof clawpackAvailable === "boolean" ? { clawpackAvailable: clawpackAvailable } : {}),
...(clawpack ? { clawpack: clawpack } : {}),
} as CatalogListItem;
}
function toPublicCatalogSearchEntry(entry: CatalogSearchEntry): CatalogSearchEntry {
return {
...entry,
package: toPublicCatalogItem(entry.package as CatalogListItem & Record<string, unknown>),
};
}
function toPublicClawPackMigrationStatus(result: Record<string, unknown>) {
const { generatedClawPackSampleSize, generatedClawPackBytes, ...rest } = result;
return {
...rest,
generatedClawPackSampleSize: generatedClawPackSampleSize,
generatedClawPackBytes: generatedClawPackBytes,
};
}
function toPublicClawPackReadinessLabel(value: unknown) {
return value === "clawpack-missing" ? "clawpack-missing" : value;
}
function toPublicClawPackReadinessResult(result: Record<string, unknown>) {
const items = Array.isArray(result.items)
? result.items.map((item) => {
if (!item || typeof item !== "object") return item;
const record = item as Record<string, unknown>;
return {
...record,
readinessState: toPublicClawPackReadinessLabel(record.readinessState),
blockers: Array.isArray(record.blockers)
? record.blockers.map(toPublicClawPackReadinessLabel)
: record.blockers,
};
})
: result.items;
return { ...result, items };
}
type ClawPackArtifactLookup =
| {
status: "ok";
artifact: {
storageId: Id<"_storage">;
sha256: string;
size: number;
format: string;
};
package: {
_id: Id<"packages">;
name: string;
};
release: {
_id: Id<"packageReleases">;
version: string;
clawpackSpecVersion?: number;
};
}
| {
status: "revoked";
};
type CatalogSourceCursorState = {
cursor: string | null;
offset: number;
@@ -463,6 +666,8 @@ async function searchPackageCatalogByListing(
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
hostTarget?: string;
environment?: string;
viewerUserId?: Id<"users">;
},
): Promise<CatalogSearchEntry[]> {
@@ -488,6 +693,8 @@ async function searchPackageCatalogByListing(
highlightedOnly: args.highlightedOnly,
executesCode: args.executesCode,
capabilityTag: args.capabilityTag,
hostTarget: args.hostTarget,
environment: args.environment,
viewerUserId: args.viewerUserId,
paginationOpts: { cursor, numItems: HTTP_PACKAGE_SEARCH_PAGE_SIZE },
});
@@ -653,6 +860,8 @@ async function listPackages(
const familyRaw = url.searchParams.get("family");
const channelRaw = url.searchParams.get("channel")?.trim();
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const hostTarget = url.searchParams.get("hostTarget")?.trim() || undefined;
const environment = url.searchParams.get("environment")?.trim() || undefined;
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
@@ -665,7 +874,10 @@ async function listPackages(
(familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
? familyRaw
: undefined);
const includeSkills = options?.includeSkills ?? effectiveFamily === undefined;
const packageOnlyFilters = Boolean(hostTarget || environment);
const includeSkills = packageOnlyFilters
? false
: (options?.includeSkills ?? effectiveFamily === undefined);
const channel =
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
? channelRaw
@@ -676,6 +888,9 @@ async function listPackages(
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
if (effectiveFamily === "skill") {
if (packageOnlyFilters) {
return json({ items: [], nextCursor: null }, 200, rate.headers);
}
const result = await runQueryRef<{
page: CatalogListItem[];
isDone: boolean;
@@ -689,7 +904,12 @@ async function listPackages(
paginationOpts: { cursor, numItems: limit },
});
return json(
{ items: result.page, nextCursor: result.isDone ? null : result.continueCursor },
{
items: result.page.map((item) =>
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
),
nextCursor: result.isDone ? null : result.continueCursor,
},
200,
rate.headers,
);
@@ -714,6 +934,8 @@ async function listPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: pageCursor, numItems },
});
@@ -768,7 +990,9 @@ async function listPackages(
nextState.skills.offset === 0;
return json(
{
items,
items: items.map((item) =>
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
),
nextCursor: isDoneAll ? null : encodeUnifiedCatalogCursor(nextState),
},
200,
@@ -798,6 +1022,8 @@ async function listPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: pageCursor, numItems },
});
@@ -847,7 +1073,9 @@ async function listPackages(
nextState.bundlePlugins.offset === 0;
return json(
{
items,
items: items.map((item) =>
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
),
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
},
200,
@@ -866,11 +1094,18 @@ async function listPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor, numItems: limit },
} satisfies PackageListQueryArgs);
return json(
{ items: result.page, nextCursor: result.isDone ? null : result.continueCursor },
{
items: result.page.map((item) =>
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
),
nextCursor: result.isDone ? null : result.continueCursor,
},
200,
rate.headers,
);
@@ -1051,6 +1286,209 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const body = await request.json().catch(() => ({}));
const operation = parseClawPackMigrationOperation(
body && typeof body === "object" ? (body as { operation?: unknown }).operation : undefined,
);
if (!operation) return text("Invalid Claw Pack migration operation", 400, rate.headers);
const rawLimit =
body && typeof body === "object" && "limit" in body
? Number((body as { limit?: unknown }).limit)
: undefined;
const cursor =
body && typeof body === "object" && typeof (body as { cursor?: unknown }).cursor === "string"
? (body as { cursor: string }).cursor
: undefined;
const result = await runMutationRef(
ctx,
internalRefs.packages.startClawPackMigrationRunInternal,
{
actorUserId: auth.userId,
operation,
...(Number.isFinite(rawLimit) ? { limit: rawLimit } : {}),
...(cursor ? { cursor } : {}),
},
);
return json(result, 200, rate.headers);
}
if (
segments[0] === "clawpack" &&
segments[1] === "migration-runs" &&
segments[3] === "continue" &&
segments.length === 4
) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
try {
const result = await runActionRef(
ctx,
internalRefs.packages.continueClawPackMigrationRunInternal,
{
actorUserId: auth.userId,
runId: segments[2] as Id<"clawPackMigrationRuns">,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Claw Pack migration run failed",
400,
rate.headers,
);
}
}
if (segments[0] === "clawpack" && segments[1] === "backfill" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const body = await request.json().catch(() => ({}));
const rawLimit =
body && typeof body === "object" && "limit" in body
? Number((body as { limit?: unknown }).limit)
: undefined;
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
try {
const result = await runActionRef(
ctx,
internalRefs.packages.backfillClawPackArtifactsInternal,
{
actorUserId: auth.userId,
...(limit ? { limit } : {}),
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Claw Pack backfill failed",
400,
rate.headers,
);
}
}
if (segments[0] === "clawpack" && segments[1] === "index-backfill" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const body = await request.json().catch(() => ({}));
const rawLimit =
body && typeof body === "object" && "limit" in body
? Number((body as { limit?: unknown }).limit)
: undefined;
const cursor =
body && typeof body === "object" && typeof (body as { cursor?: unknown }).cursor === "string"
? (body as { cursor: string }).cursor
: undefined;
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
try {
const result = await runActionRef(
ctx,
internalRefs.packages.backfillClawPackSearchIndexInternal,
{
actorUserId: auth.userId,
...(limit ? { limit } : {}),
...(cursor ? { cursor } : {}),
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Claw Pack index backfill failed",
400,
rate.headers,
);
}
}
if (segments[0] === "clawpack" && segments[1] === "retry-failures" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const body = await request.json().catch(() => ({}));
const rawLimit =
body && typeof body === "object" && "limit" in body
? Number((body as { limit?: unknown }).limit)
: undefined;
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
try {
const result = await runActionRef(
ctx,
internalRefs.packages.retryClawPackBackfillFailuresInternal,
{
actorUserId: auth.userId,
...(limit ? { limit } : {}),
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Claw Pack failure retry failed",
400,
rate.headers,
);
}
}
if (
segments[1] === "versions" &&
segments[3] === "clawpack" &&
segments[4] === "revoke" &&
segments.length === 5
) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const moderator = requireModeratorOrResponse(auth.user, rate.headers);
if (!moderator.ok) return moderator.response;
const body = await request.json().catch(() => ({}));
const reason =
body && typeof body === "object" && typeof (body as { reason?: unknown }).reason === "string"
? (body as { reason: string }).reason.trim()
: undefined;
try {
const result = await runMutationRef(
ctx,
internalRefs.packages.revokeClawPackArtifactForStaffInternal,
{
actorUserId: auth.userId,
name: segments[0]!,
version: segments[2]!,
...(reason ? { reason } : {}),
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Claw Pack revoke failed",
400,
rate.headers,
);
}
}
if (segments[1] === "rescan" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
@@ -1300,11 +1738,16 @@ async function searchPackages(
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const hostTarget = url.searchParams.get("hostTarget")?.trim() || undefined;
const environment = url.searchParams.get("environment")?.trim() || undefined;
const family =
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
? familyRaw
: undefined;
const includeSkills = options?.includeSkills ?? family === undefined;
const packageOnlyFilters = Boolean(hostTarget || environment);
const includeSkills = packageOnlyFilters
? false
: (options?.includeSkills ?? family === undefined);
const channel =
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
? channelRaw
@@ -1316,6 +1759,9 @@ async function searchPackages(
let results: CatalogSearchEntry[];
if (family === "skill") {
if (packageOnlyFilters) {
return json({ results: [] }, 200, rate.headers);
}
results = await runQueryRef<CatalogSearchEntry[]>(
ctx,
apiRefs.skills.searchPackageCatalogPublic,
@@ -1342,6 +1788,8 @@ async function searchPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
}),
),
@@ -1367,6 +1815,8 @@ async function searchPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
});
}
@@ -1380,6 +1830,8 @@ async function searchPackages(
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
hostTarget,
environment,
viewerUserId: viewerUserId ?? undefined,
}),
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
@@ -1403,7 +1855,7 @@ async function searchPackages(
.sort(compareCatalogSearchEntries)
.slice(0, limit);
}
return json({ results }, 200, rate.headers);
return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers);
}
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
@@ -1412,6 +1864,96 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
return await searchPackages(ctx, request, { includeSkills: true });
}
if (segments[0] === "clawpack" && segments[1] === "migration-status" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const limit = toOptionalNumber(new URL(request.url).searchParams.get("limit")) ?? undefined;
const result = (await runQueryRef(
ctx,
internalRefs.packages.getClawPackMigrationStatusInternal,
{ limit },
)) as Record<string, unknown>;
return json(toPublicClawPackMigrationStatus(result), 200, rate.headers);
}
if (
segments[0] === "clawpack" &&
segments[1] === "migration-runs" &&
segments[2] === "dry-run" &&
segments.length === 3
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const search = new URL(request.url).searchParams;
const operation = parseClawPackMigrationOperation(search.get("operation"));
if (!operation) return text("Invalid Claw Pack migration operation", 400, rate.headers);
const result = await runQueryRef(
ctx,
internalRefs.packages.dryRunClawPackMigrationRunForStaffInternal,
{
operation,
limit: toOptionalNumber(search.get("limit")) ?? undefined,
cursor: search.get("cursor") || undefined,
},
);
return json(result, 200, rate.headers);
}
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const search = new URL(request.url).searchParams;
const result = await runQueryRef(
ctx,
internalRefs.packages.listClawPackMigrationRunsForStaffInternal,
{
status: parseClawPackMigrationStatus(search.get("status")),
limit: toOptionalNumber(search.get("limit")) ?? undefined,
},
);
return json(result, 200, rate.headers);
}
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 3) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const result = await runQueryRef(ctx, internalRefs.packages.getClawPackMigrationRunInternal, {
runId: segments[2] as Id<"clawPackMigrationRuns">,
});
if (!result) return text("Claw Pack migration run not found", 404, rate.headers);
return json(result, 200, rate.headers);
}
if (
segments[0] === "clawpack" &&
segments[1] === "migration-readiness" &&
segments.length === 2
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
const result = (await runQueryRef(
ctx,
internalRefs.packages.listOfficialMigrationReadinessForStaffInternal,
{},
)) as Record<string, unknown>;
return json(toPublicClawPackReadinessResult(result), 200, rate.headers);
}
const rateKind = segments[1] === "download" ? "download" : "read";
const rate = await applyRateLimit(ctx, request, rateKind);
@@ -1451,6 +1993,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
package: {
...publicPackage!,
tags: await resolvePackageTags(ctx, publicPackage!.tags),
clawpack: toPublicClawPack(packageDetail?.latestRelease),
},
owner: packageOwner
? {
@@ -1533,6 +2076,92 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
);
}
const artifactRoute = segments[3];
if (
segments[1] === "versions" &&
segments[2] &&
artifactRoute === "clawpack" &&
(segments.length === 4 || (segments[4] === "manifest" && segments.length === 5))
) {
if (!publicPackage) return text("Claw Pack not available", 404, rate.headers);
const result = (await runQueryRef(
ctx,
internalRefs.packages.getVersionByNameForViewerInternal,
{
name: packageName,
version: segments[2],
viewerUserId: viewerUserId ?? undefined,
},
)) as { package: PublicPackageDocLike; version: ReleaseLike } | null;
if (!result) return text("Version not found", 404, rate.headers);
const clawpack = toPublicClawPack(result.version);
if (result.version.clawpackRevokedAt) return text("Claw Pack revoked", 410, rate.headers);
if (!clawpack.available) return text("Claw Pack not available", 404, rate.headers);
const securityBlock = getReleaseSecurityBlock(result.version);
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
if (segments[4] === "manifest") {
if (!result.version.clawpackStorageId) {
return text("Claw Pack not available", 404, rate.headers);
}
const blob = await ctx.storage.get(result.version.clawpackStorageId);
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
try {
const manifest = await readClawPackManifest(blob);
return json(
{
package: {
name: result.package.name,
displayName: result.package.displayName,
family: result.package.family,
},
version: result.version.version,
clawpack,
manifest,
},
200,
rate.headers,
);
} catch (error) {
return text(
error instanceof Error ? error.message : "Invalid Claw Pack manifest",
500,
rate.headers,
);
}
}
return json(
{
package: {
name: result.package.name,
displayName: result.package.displayName,
family: result.package.family,
},
version: {
version: result.version.version,
createdAt: result.version.createdAt,
distTags: result.version.distTags ?? [],
verification: result.version.verification ?? null,
sha256hash: result.version.sha256hash ?? null,
vtAnalysis: result.version.vtAnalysis ?? null,
llmAnalysis: result.version.llmAnalysis ?? null,
staticScan: result.version.staticScan ?? null,
},
clawpack,
links: {
download: `${ApiRoutes.packages}/${encodeURIComponent(result.package.name)}/download?version=${encodeURIComponent(result.version.version)}`,
immutable: clawpack.sha256 ? `/api/v1/clawpacks/${clawpack.sha256}` : null,
manifest: `${ApiRoutes.packages}/${encodeURIComponent(result.package.name)}/versions/${encodeURIComponent(result.version.version)}/clawpack/manifest`,
},
},
200,
rate.headers,
);
}
if (segments[1] === "versions" && segments[2]) {
if (skillDetail?.skill) {
const version = (await runQueryRef(
@@ -1607,6 +2236,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
vtAnalysis: result.version.vtAnalysis ?? null,
llmAnalysis: result.version.llmAnalysis ?? null,
staticScan: result.version.staticScan ?? null,
clawpack: toPublicClawPack(result.version),
},
},
200,
@@ -1680,6 +2310,32 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
if (!release) return text("Version not found", 404, rate.headers);
const securityBlock = getReleaseSecurityBlock(release);
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
if (release.clawpackRevokedAt) return text("Claw Pack revoked", 410, rate.headers);
if (release.clawpackStorageId && release.clawpackSha256 && release.clawpackSize) {
const blob = await ctx.storage.get(release.clawpackStorageId);
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
try {
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
packageId: publicPackage!._id,
});
} catch {
// Best-effort metric path; never fail package downloads.
}
return new Response(blob, {
status: 200,
headers: mergeHeaders(
rate.headers,
clawPackArtifactHeaders({
packageName: publicPackage!.name,
version: release.version,
sha256: release.clawpackSha256,
size: release.clawpackSize,
specVersion: release.clawpackSpecVersion,
}),
corsHeaders(),
),
});
}
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of release.files) {
const blob = await ctx.storage.get(file.storageId);
@@ -1713,6 +2369,53 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
return text("Not found", 404, rate.headers);
}
export async function clawpacksGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const prefix = "/api/v1/clawpacks/";
const segments = getPathSegments(request, prefix);
if (segments.length !== 1) return text("Not found", 404);
const sha256 = normalizeClawPackSha256(segments[0]);
if (!sha256) return text("Invalid Claw Pack digest", 400);
const rate = await applyRateLimit(ctx, request, "download");
if (!rate.ok) return rate.response;
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
const lookup = await runQueryRef<ClawPackArtifactLookup | null>(
ctx,
internalRefs.packages.getClawPackArtifactByShaForViewerInternal,
{
sha256,
viewerUserId: viewerUserId ?? undefined,
},
);
if (!lookup) return text("Claw Pack not found", 404, rate.headers);
if (lookup.status === "revoked") return text("Claw Pack revoked", 410, rate.headers);
const blob = await ctx.storage.get(lookup.artifact.storageId);
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
if (request.method !== "HEAD") {
try {
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
packageId: lookup.package._id,
});
} catch {
// Best-effort metric path; never fail Claw Pack downloads.
}
}
return new Response(request.method === "HEAD" ? null : blob, {
status: 200,
headers: mergeHeaders(
rate.headers,
clawPackArtifactHeaders({
packageName: lookup.package.name,
version: lookup.release.version,
sha256: lookup.artifact.sha256,
size: lookup.artifact.size,
specVersion: lookup.release.clawpackSpecVersion,
immutable: true,
}),
corsHeaders(),
),
});
}
export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/plugins/");
if (segments.length === 0) return text("Not found", 404);
+412
View File
@@ -0,0 +1,412 @@
/* @vitest-environment node */
import { unzipSync } from "fflate";
import { describe, expect, it } from "vitest";
import {
buildClawPack,
CLAWPACK_MANIFEST_PATH,
deriveClawPackEnvironment,
deriveClawPackHostTargets,
type ClawPackFile,
type ClawPackInput,
sha256Hex,
} from "./clawpack";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function makeClawPack(overrides: Partial<Parameters<typeof buildClawPack>[0]> = {}) {
return await buildClawPack({
packageId: "pkg_123",
releaseId: "rel_123",
name: "@openclaw/kitchen-sink",
owner: "openclaw",
slug: "openclaw-kitchen-sink",
version: "1.0.0",
family: "code-plugin",
channel: "official",
publishedAt: 1_763_000_000_000,
compatibility: {
minGatewayVersion: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
capabilities: {
executesCode: true,
hostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
capabilityTags: ["browser", "desktop", "service:github"],
},
verification: {
tier: "source-linked",
scope: "artifact-only",
},
files: [
{
path: "package.json",
size: 2,
sha256: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
bytes: encoder.encode("{}"),
contentType: "application/json",
},
{
path: "dist/index.js",
size: 17,
sha256: "index-sha",
bytes: encoder.encode("export default {};"),
contentType: "text/javascript",
},
],
...overrides,
});
}
async function fixtureFile(
path: string,
source: string,
contentType?: string,
): Promise<ClawPackFile> {
const bytes = encoder.encode(source);
return {
path,
size: bytes.byteLength,
sha256: await sha256Hex(bytes),
bytes,
...(contentType ? { contentType } : {}),
};
}
async function makeKitchenSinkClawPackInput(): Promise<ClawPackInput> {
return {
packageId: "pkg_kitchen_sink",
releaseId: "rel_kitchen_sink",
name: "@openclaw/kitchen-sink-plugin",
owner: "openclaw",
slug: "openclaw-kitchen-sink-plugin",
version: "9.9.9",
family: "code-plugin",
channel: "community",
publishedAt: 1_767_225_600_000,
source: {
kind: "github",
repository: "openclaw/kitchen-sink-plugin",
commit: "abc123fixture",
},
compatibility: {
builtWithOpenClawVersion: "2026.5.0",
pluginApiRange: "^1.0.0",
minGatewayVersion: ">=2026.5.0",
},
capabilities: {
executesCode: true,
runtimeId: "openclaw.kitchen-sink",
pluginKind: "runtime",
hooks: ["chat:before", "chat:after", "app:startup"],
providers: ["openai", "openrouter"],
toolNames: ["browser.open", "desktop.capture", "github.search"],
serviceNames: ["playwright", "github"],
bundledSkills: ["prompt-reviewer", "workflow-runner"],
setupEntry: true,
configSchema: true,
configUiHints: true,
materializesDependencies: true,
hostTargets: ["darwin-arm64", "darwin-x64", "linux-x64-glibc", "win32-x64"],
capabilityTags: [
"browser",
"desktop",
"audio",
"service:github",
"service:openai",
"permission:screen-recording",
],
},
verification: {
tier: "source-linked",
scope: "dependency-graph-aware",
sourceRepo: "openclaw/kitchen-sink-plugin",
sourceCommit: "abc123fixture",
scanStatus: "clean",
},
files: [
await fixtureFile(
"package.json",
JSON.stringify(
{
name: "@openclaw/kitchen-sink-plugin",
version: "9.9.9",
type: "module",
openclaw: {
plugin: "./openclaw.plugin.json",
extensions: ["./dist/index.js"],
},
dependencies: {
"@playwright/test": "^1.52.0",
ws: "^8.18.0",
},
},
null,
2,
),
"application/json",
),
await fixtureFile(
"openclaw.plugin.json",
JSON.stringify(
{
id: "openclaw.kitchen-sink",
entry: "./dist/index.js",
setup: "./dist/setup.js",
hostTargets: ["darwin-arm64", "darwin-x64", "linux-x64-glibc", "win32-x64"],
permissions: ["network", "screen-recording", "audio-input"],
},
null,
2,
),
"application/json",
),
await fixtureFile(
"dist/index.js",
"export const plugin = { activate() { return 'kitchen-sink'; } };\n",
"text/javascript",
),
await fixtureFile(
"dist/setup.js",
"export function setup() { return { schema: true, uiHints: true }; }\n",
"text/javascript",
),
await fixtureFile(
"browser/playwright-smoke.ts",
"export async function smoke(page) { await page.goto('https://example.com'); }\n",
"text/typescript",
),
],
};
}
describe("clawpack", () => {
it("builds a deterministic archive with a generated CLAWPACK manifest", async () => {
const first = await makeClawPack();
const second = await makeClawPack();
const unzipped = unzipSync(first.bytes);
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
expect(Array.from(first.bytes)).toEqual(Array.from(second.bytes));
expect(first.sha256).toBe(second.sha256);
expect(Object.keys(unzipped).sort()).toEqual([
"package/CLAWPACK.json",
"package/dist/index.js",
"package/package.json",
]);
expect(manifest).toMatchObject({
specVersion: 1,
kind: "openclaw.clawpack",
package: {
name: "@openclaw/kitchen-sink",
owner: "openclaw",
slug: "openclaw-kitchen-sink",
version: "1.0.0",
family: "code-plugin",
channel: "official",
},
artifact: {
format: "zip",
root: "package/",
fileCount: 2,
},
});
expect(manifest.files.map((file: { path: string }) => file.path)).toEqual([
"dist/index.js",
"package.json",
]);
});
it("ignores publisher supplied CLAWPACK.json files", async () => {
const built = await makeClawPack({
files: [
{
path: "CLAWPACK.json",
size: 22,
sha256: "attacker-sha",
bytes: encoder.encode('{"forged": true}\n'),
},
{
path: "package.json",
size: 2,
sha256: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
bytes: encoder.encode("{}"),
},
],
});
const unzipped = unzipSync(built.bytes);
const manifest = JSON.parse(decoder.decode(unzipped["package/CLAWPACK.json"]));
expect(Object.keys(unzipped).sort()).toEqual(["package/CLAWPACK.json", "package/package.json"]);
expect(manifest.forged).toBeUndefined();
expect(manifest.files).toHaveLength(1);
expect(built.fileCount).toBe(2);
});
it("normalizes archive separators before packing files", async () => {
const built = await makeClawPack({
files: [
{
path: "dist\\index.js",
size: 17,
sha256: "index-sha",
bytes: encoder.encode("export default {};"),
},
],
});
const unzipped = unzipSync(built.bytes);
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
expect(Object.keys(unzipped).sort()).toEqual([
"package/CLAWPACK.json",
"package/dist/index.js",
]);
expect(manifest.files.map((file: { path: string }) => file.path)).toEqual(["dist/index.js"]);
});
it("rejects archive paths that can escape the package root", async () => {
for (const path of ["../evil.js", "dist/../../evil.js", "/tmp/evil.js", "C:\\tmp\\evil.js"]) {
await expect(
makeClawPack({
files: [
{
path,
size: 4,
sha256: "evil-sha",
bytes: encoder.encode("evil"),
},
],
}),
).rejects.toThrow("Invalid Claw Pack file path");
}
});
it("rejects case-insensitive duplicate archive paths", async () => {
await expect(
makeClawPack({
files: [
{
path: "dist/index.js",
size: 17,
sha256: "index-sha",
bytes: encoder.encode("export default {};"),
},
{
path: "dist/INDEX.js",
size: 17,
sha256: "index-upper-sha",
bytes: encoder.encode("export default {};"),
},
],
}),
).rejects.toThrow("Duplicate Claw Pack file path");
});
it("packs a kitchen-sink OpenClaw plugin with cross-platform signals", async () => {
const input = await makeKitchenSinkClawPackInput();
const built = await buildClawPack(input);
const unzipped = unzipSync(built.bytes);
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
const packageJson = JSON.parse(decoder.decode(unzipped["package/package.json"]));
expect(Object.keys(unzipped).sort()).toEqual([
"package/CLAWPACK.json",
"package/browser/playwright-smoke.ts",
"package/dist/index.js",
"package/dist/setup.js",
"package/openclaw.plugin.json",
"package/package.json",
]);
expect(packageJson.openclaw.extensions).toEqual(["./dist/index.js"]);
expect(manifest.hostTargets).toEqual([
{
os: "darwin",
arch: "arm64",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
{
os: "darwin",
arch: "x64",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
{
os: "linux",
arch: "x64",
libc: "glibc",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
{
os: "win32",
arch: "x64",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
]);
expect(manifest.environment).toEqual({
requiresNetwork: true,
requiresBrowser: true,
requiresLocalDesktop: true,
requiresAudioDevice: true,
requiresExternalServices: ["github", "openai"],
});
expect(built.hostTargets.map((target) => [target.os, target.arch, target.libc])).toEqual([
["darwin", "arm64", undefined],
["darwin", "x64", undefined],
["linux", "x64", "glibc"],
["win32", "x64", undefined],
]);
});
it("derives host targets and environment cues from package capabilities", () => {
expect(
deriveClawPackHostTargets({
capabilities: {
hostTargets: ["Darwin/ARM64", "linux-x64-musl", "bad-target", "linux-x64-musl"],
},
compatibility: {
minGatewayVersion: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
}),
).toEqual([
{
os: "darwin",
arch: "arm64",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
{
os: "linux",
arch: "x64",
libc: "musl",
supportState: "supported",
openclawRange: ">=2026.5.0",
pluginApiRange: "^1.0.0",
},
]);
expect(
deriveClawPackEnvironment({
capabilities: {
capabilityTags: ["browser", "desktop", "audio", "service:slack"],
},
files: [{ path: "dist/index.js" }],
}),
).toEqual({
requiresNetwork: true,
requiresBrowser: true,
requiresLocalDesktop: true,
requiresAudioDevice: true,
requiresExternalServices: ["slack"],
});
});
});
+321
View File
@@ -0,0 +1,321 @@
import { buildDeterministicPackageZip } from "./skillZip";
const CLAWPACK_SPEC_VERSION = 1;
export const CLAWPACK_MANIFEST_PATH = "CLAWPACK.json";
type ClawPackHostTarget = {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl";
nodeRange?: string;
openclawRange?: string;
pluginApiRange?: string;
supportState?: "supported" | "setup-required" | "unsupported";
unsupportedReason?: string;
};
type ClawPackEnvironmentSummary = {
requiresLocalDesktop?: boolean;
requiresBrowser?: boolean;
requiresAudioDevice?: boolean;
requiresNetwork?: boolean;
requiresExternalServices?: string[];
requiresOsPermissions?: string[];
supportsRemoteHost?: boolean;
knownUnsupported?: string[];
};
export type ClawPackFile = {
path: string;
size: number;
sha256: string;
bytes: Uint8Array;
contentType?: string;
};
export type ClawPackInput = {
packageId: string;
releaseId: string;
name: string;
owner?: string | null;
slug: string;
version: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
publishedAt: number;
source?: unknown;
compatibility?: unknown;
capabilities?: unknown;
verification?: unknown;
files: ClawPackFile[];
};
type BuiltClawPack = {
bytes: Uint8Array;
sha256: string;
size: number;
fileCount: number;
manifestSha256: string;
manifest: Record<string, unknown>;
hostTargets: ClawPackHostTarget[];
environment: ClawPackEnvironmentSummary;
};
const textEncoder = new TextEncoder();
export async function sha256Hex(bytes: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function stableJson(value: unknown) {
return `${JSON.stringify(sortJson(value), null, 2)}\n`;
}
function sortJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortJson);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, entry]) => [key, sortJson(entry)]),
);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function stringArray(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && Boolean(entry.trim()))
: [];
}
function normalizeHostTarget(raw: string): ClawPackHostTarget | null {
const parts = raw.trim().toLowerCase().split(/[-_/]/).filter(Boolean);
const os = parts.find((part) => part === "darwin" || part === "linux" || part === "win32");
const arch = parts.find((part) => part === "arm64" || part === "x64");
const libc = parts.find((part) => part === "glibc" || part === "musl");
if (!os || !arch) return null;
return {
os,
arch,
...(libc ? { libc } : {}),
supportState: "supported",
};
}
function uniqueTargets(targets: ClawPackHostTarget[]) {
const seen = new Set<string>();
const result: ClawPackHostTarget[] = [];
for (const target of targets) {
const key = [target.os, target.arch, target.libc ?? ""].join("-");
if (seen.has(key)) continue;
seen.add(key);
result.push(target);
}
return result;
}
function normalizeClawPackFilePath(path: string) {
const normalizedSeparators = path.trim().replaceAll("\\", "/");
if (!normalizedSeparators) return null;
if (
Array.from(normalizedSeparators).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
})
) {
return null;
}
if (normalizedSeparators.startsWith("/") || normalizedSeparators.startsWith("//")) return null;
if (/^[a-zA-Z]:($|\/)/.test(normalizedSeparators)) return null;
if (normalizedSeparators.endsWith("/")) return null;
const segments = normalizedSeparators.split("/").filter(Boolean);
if (segments.length === 0) return null;
if (segments.some((segment) => segment === "." || segment === "..")) return null;
return segments.join("/");
}
function normalizeClawPackFiles(files: ClawPackFile[]) {
const seen = new Map<string, string>();
const publishFiles: ClawPackFile[] = [];
for (const file of files) {
const path = normalizeClawPackFilePath(file.path);
if (!path) {
throw new Error(`Invalid Claw Pack file path: ${file.path}`);
}
const lowerPath = path.toLowerCase();
if (lowerPath === CLAWPACK_MANIFEST_PATH.toLowerCase()) {
continue;
}
const collisionKey = path.toLowerCase();
const existingPath = seen.get(collisionKey);
if (existingPath) {
throw new Error(`Duplicate Claw Pack file path: ${existingPath} and ${path}`);
}
seen.set(collisionKey, path);
publishFiles.push({ ...file, path });
}
return publishFiles;
}
export function deriveClawPackHostTargets(input: {
capabilities?: unknown;
compatibility?: unknown;
}): ClawPackHostTarget[] {
const capabilities = asRecord(input.capabilities);
const compatibility = asRecord(input.compatibility);
const targetStrings = stringArray(capabilities.hostTargets);
const fromCapabilities = targetStrings
.map(normalizeHostTarget)
.filter((target): target is ClawPackHostTarget => Boolean(target));
if (fromCapabilities.length > 0) {
return uniqueTargets(
fromCapabilities.map((target) => ({
...target,
openclawRange: stringValue(compatibility.minGatewayVersion),
pluginApiRange: stringValue(compatibility.pluginApiRange),
})),
);
}
return [
{
os: "darwin",
arch: "arm64",
supportState: "supported",
openclawRange: stringValue(compatibility.minGatewayVersion),
pluginApiRange: stringValue(compatibility.pluginApiRange),
},
{
os: "linux",
arch: "x64",
libc: "glibc",
supportState: "supported",
openclawRange: stringValue(compatibility.minGatewayVersion),
pluginApiRange: stringValue(compatibility.pluginApiRange),
},
{
os: "win32",
arch: "x64",
supportState: "supported",
openclawRange: stringValue(compatibility.minGatewayVersion),
pluginApiRange: stringValue(compatibility.pluginApiRange),
},
];
}
export function deriveClawPackEnvironment(input: {
capabilities?: unknown;
files: Array<{ path: string }>;
}): ClawPackEnvironmentSummary {
const capabilities = asRecord(input.capabilities);
const capabilityTags = stringArray(capabilities.capabilityTags).map((tag) => tag.toLowerCase());
const fileNames = input.files.map((file) => file.path.toLowerCase());
const requiresBrowser =
capabilityTags.some((tag) => tag.includes("browser") || tag.includes("playwright")) ||
fileNames.some((path) => path.includes("playwright") || path.includes("browser"));
const requiresLocalDesktop = capabilityTags.some(
(tag) => tag.includes("desktop") || tag.includes("imessage") || tag.includes("bluebubbles"),
);
const requiresAudioDevice = capabilityTags.some(
(tag) => tag.includes("audio") || tag.includes("meet"),
);
const externalServices = capabilityTags
.filter((tag) => tag.startsWith("service:"))
.map((tag) => tag.slice("service:".length))
.filter(Boolean);
return {
requiresNetwork: true,
...(requiresBrowser ? { requiresBrowser } : {}),
...(requiresLocalDesktop ? { requiresLocalDesktop } : {}),
...(requiresAudioDevice ? { requiresAudioDevice } : {}),
...(externalServices.length > 0 ? { requiresExternalServices: externalServices } : {}),
};
}
export async function buildClawPack(input: ClawPackInput): Promise<BuiltClawPack> {
const publishFiles = normalizeClawPackFiles(input.files);
const hostTargets = deriveClawPackHostTargets({
capabilities: input.capabilities,
compatibility: input.compatibility,
});
const environment = deriveClawPackEnvironment({
capabilities: input.capabilities,
files: publishFiles,
});
const fileManifest = publishFiles
.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
...(file.contentType ? { contentType: file.contentType } : {}),
}))
.sort((a, b) => a.path.localeCompare(b.path));
const manifest: Record<string, unknown> = {
specVersion: CLAWPACK_SPEC_VERSION,
kind: "openclaw.clawpack",
package: {
name: input.name,
owner: input.owner ?? null,
slug: input.slug,
version: input.version,
family: input.family,
channel: input.channel,
},
release: {
packageId: input.packageId,
releaseId: input.releaseId,
publishedAt: input.publishedAt,
...(input.source !== undefined ? { source: input.source } : {}),
},
artifact: {
format: "zip",
root: "package/",
specVersion: CLAWPACK_SPEC_VERSION,
contentSha256: await sha256Hex(
textEncoder.encode(
stableJson(fileManifest.map((file) => ({ path: file.path, sha256: file.sha256 }))),
),
),
fileCount: publishFiles.length,
},
files: fileManifest,
compatibility: input.compatibility ?? null,
capabilities: input.capabilities ?? null,
verification: input.verification ?? null,
hostTargets,
environment,
runtimeBundles: [],
};
const manifestBytes = textEncoder.encode(stableJson(manifest));
const manifestSha256 = await sha256Hex(manifestBytes);
const bytes = buildDeterministicPackageZip([
{ path: CLAWPACK_MANIFEST_PATH, bytes: manifestBytes },
...publishFiles.map((file) => ({ path: file.path, bytes: file.bytes })),
]);
const sha256 = await sha256Hex(bytes);
return {
bytes,
sha256,
size: bytes.byteLength,
fileCount: publishFiles.length + 1,
manifestSha256,
manifest,
hostTargets,
environment,
};
}
+45
View File
@@ -56,6 +56,9 @@ export type PackageSearchDigestFields = Pick<Doc<"packages">, (typeof SHARED_KEY
ownerHandle?: string;
ownerKind?: "user" | "org";
verificationTier?: Doc<"packageSearchDigest">["verificationTier"];
clawpackAvailable?: boolean;
hostTargetKeys?: string[];
environmentFlags?: string[];
};
type PackageCapabilitySearchDigestFields = Pick<
@@ -74,6 +77,48 @@ export function extractPackageDigestFields(pkg: Doc<"packages">): PackageSearchD
};
}
export function extractPackageClawPackDigestFields(
release: Doc<"packageReleases"> | null | undefined,
): Pick<PackageSearchDigestFields, "clawpackAvailable" | "hostTargetKeys" | "environmentFlags"> {
if (!release || release.softDeletedAt || release.clawpackRevokedAt) {
return {
clawpackAvailable: false,
hostTargetKeys: [],
environmentFlags: [],
};
}
return {
clawpackAvailable: Boolean(release.clawpackStorageId),
hostTargetKeys: getPackageClawPackHostTargetKeys(release),
environmentFlags: getPackageClawPackEnvironmentFlags(release),
};
}
export function getPackageClawPackHostTargetKeys(release: Doc<"packageReleases">) {
return [
...new Set(
(release.hostTargetsSummary ?? []).map((target) =>
[target.os, target.arch, target.libc].filter(Boolean).join("-"),
),
),
];
}
export function getPackageClawPackEnvironmentFlags(release: Doc<"packageReleases">) {
const environment = release.environmentSummary;
const flags = [
environment?.requiresLocalDesktop ? "desktop" : null,
environment?.requiresBrowser ? "browser" : null,
environment?.requiresAudioDevice ? "audio" : null,
environment?.requiresNetwork ? "network" : null,
environment?.supportsRemoteHost ? "remote-host" : null,
...(environment?.requiresExternalServices ?? []).map((service) => `service:${service}`),
...(environment?.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
...(environment?.knownUnsupported ?? []).map((target) => `unsupported:${target}`),
].filter((flag): flag is string => Boolean(flag));
return [...new Set(flags)];
}
export async function upsertPackageSearchDigest(
ctx: Pick<MutationCtx, "db">,
fields: PackageSearchDigestFields,
+80 -3
View File
@@ -73,6 +73,8 @@ const listPublicPageHandler = (
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
hostTarget?: string;
environment?: string;
paginationOpts: { cursor: string | null; numItems: number };
},
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
@@ -384,6 +386,11 @@ function makeDigestCtx(options: {
isDone: boolean;
continueCursor: string;
}>;
clawPackPages?: Array<{
page: Array<Record<string, unknown>>;
isDone: boolean;
continueCursor: string;
}>;
exactPackages?: Array<Record<string, unknown>>;
exactDigests?: Array<Record<string, unknown>>;
publisherMemberships?: Record<string, "owner" | "admin" | "publisher">;
@@ -428,6 +435,7 @@ function makeDigestCtx(options: {
setPages("packageSearchDigest", options.pages ?? []);
setPages("packageCapabilitySearchDigest", options.capabilityPages ?? []);
setPages("packageClawPackSearchIndex", options.clawPackPages ?? []);
const paginate = vi.fn();
const paginateForTable = (table: string) =>
@@ -610,6 +618,12 @@ function makeDigestCtx(options: {
},
};
}
if (table === "packageClawPackSearchIndex") {
tableNames.push(table);
return {
withIndex: (indexName: string) => withIndex(table, indexName),
};
}
if (table !== "packageCapabilitySearchDigest") {
throw new Error(`Unexpected table ${table}`);
}
@@ -1318,6 +1332,43 @@ describe("packages public queries", () => {
expect(indexNames).toEqual(["by_active_executes_updated"]);
});
it("uses the ClawPack index for host-target public listings", async () => {
const digest = makeDigest("darwin-demo", {
packageId: "packages:darwin-demo",
clawpackAvailable: true,
hostTargetKeys: ["darwin-arm64"],
});
const { ctx, indexNames, tableNames } = makeDigestCtx({
clawPackPages: [
{
page: [
{
_id: "packageClawPackSearchIndex:1",
packageId: "packages:darwin-demo",
releaseId: "packageReleases:darwin-demo",
kind: "host-target",
key: "darwin-arm64",
updatedAt: 10,
createdAt: 10,
},
],
isDone: true,
continueCursor: "",
},
],
exactDigests: [digest],
});
const result = await listPublicPageHandler(ctx, {
hostTarget: "darwin-arm64",
paginationOpts: { cursor: null, numItems: 10 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["darwin-demo"]);
expect(tableNames).toEqual(["packageClawPackSearchIndex", "packageSearchDigest"]);
expect(indexNames).toEqual(["by_kind_key_updated"]);
});
it("uses capability digests for capability-tagged package search", async () => {
const { ctx, indexNames, tableNames } = makeDigestCtx({
capabilityPages: [
@@ -2728,6 +2779,7 @@ describe("packages public queries", () => {
},
storage: {
get: vi.fn(),
store: vi.fn().mockResolvedValue("storage:clawpack"),
},
};
@@ -2810,6 +2862,7 @@ describe("packages public queries", () => {
},
storage: {
get: vi.fn(),
store: vi.fn().mockResolvedValue("storage:clawpack"),
},
};
@@ -2935,6 +2988,7 @@ describe("packages public queries", () => {
"storage:package",
JSON.stringify({
name: "demo-plugin",
$schema: "https://json.schemastore.org/package",
openclaw: {
extensions: ["./dist/index.js"],
compat: { pluginApi: "^1.0.0" },
@@ -2947,6 +3001,11 @@ describe("packages public queries", () => {
"storage:manifest",
JSON.stringify({
id: "demo.plugin",
configSchema: {
$defs: {
secret: { $ref: "#/$defs/secret" },
},
},
tools: [{ name: "demoTool" }],
}),
],
@@ -2958,6 +3017,7 @@ describe("packages public queries", () => {
const content = files.get(storageId);
return content ? new Blob([content]) : null;
}),
store: vi.fn().mockResolvedValue("storage:clawpack"),
},
};
@@ -2983,21 +3043,21 @@ describe("packages public queries", () => {
path: "package.json",
size: 1,
storageId: "storage:package",
sha256: "package",
sha256: "6eb6f88411091ea48eb66a990a5d83c45edb34b5a7d4db7b64ff618a82c951ef",
contentType: "application/json",
},
{
path: "openclaw.plugin.json",
size: 1,
storageId: "storage:manifest",
sha256: "manifest",
sha256: "765ff752ed0b735b69860133a82c088479f2ecd84abb7db0ee3edf412239ec9e",
contentType: "application/json",
},
{
path: "dist/index.js",
size: 1,
storageId: "storage:code",
sha256: "code",
sha256: "42d6cead6d2a563483e07881281dabe3a21c964e5522eb690ab0406528943ab3",
contentType: "application/javascript",
},
],
@@ -3005,6 +3065,23 @@ describe("packages public queries", () => {
})) as Record<string, unknown>;
expect(runMutation).toHaveBeenCalled();
const insertReleaseArgs = runMutation.mock.calls.find(
([, args]) => typeof args === "object" && args !== null && "extractedPackageJson" in args,
)?.[1];
expect(insertReleaseArgs).toEqual(
expect.objectContaining({
extractedPackageJson: expect.objectContaining({
dollar_schema: "https://json.schemastore.org/package",
}),
extractedPluginManifest: expect.objectContaining({
configSchema: {
dollar_defs: {
secret: { dollar_ref: "#/$defs/secret" },
},
},
}),
}),
);
expect(result.verification).toEqual(expect.objectContaining({ scanStatus: "pending" }));
expect(result.staticScan).toEqual(
expect.objectContaining({
+2082 -3
View File
File diff suppressed because it is too large Load Diff
+144 -1
View File
@@ -29,6 +29,30 @@ const vtAnalysisValidator = v.object({
checkedAt: v.number(),
});
const packageHostTargetValidator = v.object({
os: v.union(v.literal("darwin"), v.literal("linux"), v.literal("win32")),
arch: v.union(v.literal("arm64"), v.literal("x64")),
libc: v.optional(v.union(v.literal("glibc"), v.literal("musl"))),
nodeRange: v.optional(v.string()),
openclawRange: v.optional(v.string()),
pluginApiRange: v.optional(v.string()),
supportState: v.optional(
v.union(v.literal("supported"), v.literal("setup-required"), v.literal("unsupported")),
),
unsupportedReason: v.optional(v.string()),
});
const packageEnvironmentSummaryValidator = v.object({
requiresLocalDesktop: v.optional(v.boolean()),
requiresBrowser: v.optional(v.boolean()),
requiresAudioDevice: v.optional(v.boolean()),
requiresNetwork: v.optional(v.boolean()),
requiresExternalServices: v.optional(v.array(v.string())),
requiresOsPermissions: v.optional(v.array(v.string())),
supportsRemoteHost: v.optional(v.boolean()),
knownUnsupported: v.optional(v.array(v.string())),
});
const depRegistryStatusValidator = v.union(
v.literal("clean"),
v.literal("suspicious"),
@@ -822,6 +846,20 @@ const packageReleases = defineTable({
compatibility: packageCompatibilityValidator,
capabilities: packageCapabilitiesValidator,
verification: packageVerificationValidator,
clawpackStorageId: v.optional(v.id("_storage")),
clawpackSha256: v.optional(v.string()),
clawpackSize: v.optional(v.number()),
clawpackSpecVersion: v.optional(v.number()),
clawpackFormat: v.optional(v.literal("zip")),
clawpackFileCount: v.optional(v.number()),
clawpackManifestSha256: v.optional(v.string()),
clawpackBuiltAt: v.optional(v.number()),
clawpackBuildVersion: v.optional(v.string()),
clawpackRevokedAt: v.optional(v.number()),
clawpackRevokedByUserId: v.optional(v.id("users")),
clawpackRevocationReason: v.optional(v.string()),
hostTargetsSummary: v.optional(v.array(packageHostTargetValidator)),
environmentSummary: v.optional(packageEnvironmentSummaryValidator),
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(vtAnalysisValidator),
llmAnalysis: v.optional(
@@ -875,7 +913,96 @@ const packageReleases = defineTable({
.index("by_package_active_created", ["packageId", "softDeletedAt", "createdAt"])
.index("by_active_created", ["softDeletedAt", "createdAt"])
.index("by_package_version", ["packageId", "version"])
.index("by_sha256hash", ["sha256hash"]);
.index("by_sha256hash", ["sha256hash"])
.index("by_clawpack_built_at", ["clawpackBuiltAt"]);
const packageReleaseArtifacts = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
kind: v.union(
v.literal("clawpack"),
v.literal("runtime-bundle"),
v.literal("scan-report"),
v.literal("sbom"),
),
targetKey: v.optional(v.string()),
storageId: v.id("_storage"),
sha256: v.string(),
size: v.number(),
format: v.string(),
createdAt: v.number(),
status: v.union(v.literal("active"), v.literal("superseded"), v.literal("revoked")),
revokedAt: v.optional(v.number()),
revokedByUserId: v.optional(v.id("users")),
revocationReason: v.optional(v.string()),
})
.index("by_release", ["releaseId"])
.index("by_package_kind", ["packageId", "kind"])
.index("by_sha256", ["sha256"])
.index("by_target_key", ["targetKey"])
.index("by_status", ["status"]);
const packageClawPackBackfillFailures = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
name: v.string(),
version: v.string(),
error: v.string(),
attemptCount: v.number(),
firstFailedAt: v.number(),
lastAttemptAt: v.number(),
lastFailedAt: v.number(),
resolvedAt: v.optional(v.number()),
})
.index("by_release", ["releaseId"])
.index("by_package_failed_at", ["packageId", "lastFailedAt"])
.index("by_open_failed_at", ["resolvedAt", "lastFailedAt"]);
const packageClawPackSearchIndex = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
kind: v.union(v.literal("host-target"), v.literal("environment")),
key: v.string(),
updatedAt: v.number(),
createdAt: v.number(),
})
.index("by_release", ["releaseId"])
.index("by_package", ["packageId"])
.index("by_package_kind_key", ["packageId", "kind", "key"])
.index("by_kind_key_updated", ["kind", "key", "updatedAt"]);
const clawPackMigrationRuns = defineTable({
actorUserId: v.id("users"),
operation: v.union(
v.literal("artifact-backfill"),
v.literal("failure-retry"),
v.literal("search-index-backfill"),
),
status: v.union(
v.literal("pending"),
v.literal("running"),
v.literal("completed"),
v.literal("failed"),
),
limit: v.number(),
cursor: v.optional(v.string()),
continueCursor: v.optional(v.string()),
isDone: v.optional(v.boolean()),
processed: v.number(),
generated: v.number(),
skipped: v.number(),
failed: v.number(),
bytesGenerated: v.number(),
failureCounts: v.record(v.string(), v.number()),
lastError: v.optional(v.string()),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_created_at", ["createdAt"])
.index("by_status_created_at", ["status", "createdAt"])
.index("by_actor_created_at", ["actorUserId", "createdAt"]);
const packageTrustedPublishers = defineTable({
packageId: v.id("packages"),
@@ -939,6 +1066,9 @@ const packageSearchDigest = defineTable({
capabilityTags: v.optional(v.array(v.string())),
executesCode: v.optional(v.boolean()),
verificationTier: v.optional(packageVerificationTierValidator),
clawpackAvailable: v.optional(v.boolean()),
hostTargetKeys: v.optional(v.array(v.string())),
environmentFlags: v.optional(v.array(v.string())),
scanStatus: packageScanStatusValidator,
softDeletedAt: v.optional(v.number()),
createdAt: v.number(),
@@ -983,6 +1113,12 @@ const packageSearchDigest = defineTable({
"executesCode",
"updatedAt",
])
.index("by_active_family_scan_status_updated", [
"softDeletedAt",
"family",
"scanStatus",
"updatedAt",
])
.index("by_active_channel_executes_updated", [
"softDeletedAt",
"channel",
@@ -1025,6 +1161,9 @@ const packageCapabilitySearchDigest = defineTable({
capabilityTag: v.string(),
executesCode: v.optional(v.boolean()),
verificationTier: v.optional(packageVerificationTierValidator),
clawpackAvailable: v.optional(v.boolean()),
hostTargetKeys: v.optional(v.array(v.string())),
environmentFlags: v.optional(v.array(v.string())),
scanStatus: packageScanStatusValidator,
softDeletedAt: v.optional(v.number()),
createdAt: v.number(),
@@ -1466,6 +1605,10 @@ export default defineSchema({
skillSlugAliases,
packages,
packageReleases,
packageReleaseArtifacts,
packageClawPackBackfillFailures,
packageClawPackSearchIndex,
clawPackMigrationRuns,
packageTrustedPublishers,
packagePublishTokens,
packageBadges,
+13 -5
View File
@@ -13,11 +13,15 @@ Reading order (new contributor):
2. `docs/quickstart.md`: end-to-end: search → install → publish → sync.
3. `docs/architecture.md`: how the pieces fit (TanStack Start + Convex + CLI).
4. `docs/skill-format.md`: what a “skill” is on disk + on the registry.
5. `docs/cli.md`: CLI reference (flags, config, lockfiles, sync rules).
6. `docs/http-api.md`: HTTP endpoints used by the CLI + public API.
7. `docs/auth.md`: GitHub OAuth + API tokens + CLI loopback login.
8. `docs/deploy.md`: Convex + Vercel deployment + rewrites.
9. `docs/troubleshooting.md`: common failure modes.
5. `docs/plugin-publishing.md`: publish plugin packages and preview ClawPack output.
6. `docs/clawpack.md`: ClawPack artifact contract, download, and verification.
7. `docs/clawpack-operations.md`: ClawPack moderation, backfill, retry, and revocation.
8. `docs/official-plugin-migration-readiness.md`: readiness tracking for future OpenClaw externalization.
9. `docs/cli.md`: CLI reference (flags, config, lockfiles, sync rules).
10. `docs/http-api.md`: HTTP endpoints used by the CLI + public API.
11. `docs/auth.md`: GitHub OAuth + API tokens + CLI loopback login.
12. `docs/deploy.md`: Convex + Vercel deployment + rewrites.
13. `docs/troubleshooting.md`: common failure modes.
Feature/ops docs (already present):
@@ -27,6 +31,10 @@ Feature/ops docs (already present):
- `docs/webhook.md`: Discord webhook events/payload.
- `docs/diffing.md`: version-to-version diff UI spec.
- `docs/manual-testing.md`: CLI smoke scripts.
- `docs/clawpack.md`: ClawPack artifact model and integrity checks.
- `docs/clawpack-operations.md`: staff operation runbook for ClawPack artifacts.
- `docs/plugin-publishing.md`: publisher workflow for code and bundle plugins.
- `docs/official-plugin-migration-readiness.md`: ClawHub-only readiness tracker for bundled OpenClaw plugin migration planning.
Docs tooling:
+205
View File
@@ -0,0 +1,205 @@
---
summary: "Staff runbook for ClawPack migration, moderation, retry, and revocation."
read_when:
- Operating ClawPack backfills
- Moderating plugin artifacts
- Debugging failed package artifact builds
---
# ClawPack Operations
ClawPack operations are staff-only surfaces for migration, moderation,
artifact recovery, and revocation. They exist so operators do not have to edit
Convex documents manually.
Current management entry points:
```text
/management
/management/clawpacks
/management/moderation
/management/migrations
```
Unauthorized users should see the required role and their current auth state,
not a generic broken page.
## Roles
- moderators can review plugin risk and revoke ClawPack artifacts
- admins can run migration and backfill operations
- normal publishers can publish their own plugins but cannot mutate staff state
Live Convex mutations and deploys should be confirmed before running in a
shared or production deployment.
## ClawPack Ops Dashboard
Use:
```text
/management/clawpacks
```
The dashboard should answer:
- how many plugin releases exist
- how many have ClawPack artifacts
- how many are missing artifacts
- how many artifacts are revoked
- how many builds failed
- how many search index rows exist
- which sample rows need attention
Admin actions:
- preview migration candidates without writing
- create persistent migration runs
- execute one bounded batch at a time
- build missing ClawPack artifacts in bounded repair batches
- rebuild ClawPack host/environment index rows
- retry failed builds
- inspect failed release ids and reason codes
Every batch must be bounded and tied to a visible run record when the operation
is part of a coordinated migration. Avoid unbounded table scans and avoid any
operation that makes a partial migration silently look complete.
## CLI Admin Commands
Status:
```bash
clawhub package clawpack-admin status --json
```
Preview a coordinated migration:
```bash
clawhub package clawpack-admin dry-run --operation artifact-backfill --limit 25
```
Create and continue a durable run:
```bash
clawhub package clawpack-admin create-run --operation artifact-backfill --limit 25
clawhub package clawpack-admin continue-run <run-id>
```
List run history:
```bash
clawhub package clawpack-admin runs --status failed --json
```
Direct repair for missing artifacts:
```bash
clawhub package clawpack-admin backfill --limit 25
```
Direct search-index repair:
```bash
clawhub package clawpack-admin index-backfill --limit 100
```
Direct failure retry:
```bash
clawhub package clawpack-admin retry-failures --limit 25
```
Revoke an artifact:
```bash
clawhub package clawpack-admin revoke <name> <version> --reason "reason code or note"
```
Use `--json` for automation and audit capture. For production-sized work, prefer
`dry-run` -> `create-run` -> repeated `continue-run` over the direct repair
commands.
## Moderation Console
Use:
```text
/management/moderation
```
Moderators should see plugin releases by risk and operational state:
- pending review
- suspicious scan
- malicious scan
- missing ClawPack
- failed ClawPack build
- revoked
- official review
- metadata incomplete
The queue should show source facts, ClawPack digest, scan summaries, LLM/static
verdicts, VirusTotal status where present, and latest release state.
Destructive actions require a reason. Revocation reason should be visible to
staff and exposed safely through API responses where useful.
## Revocation
Revocation makes the stored artifact non-downloadable. It is separate from
package deletion and separate from hiding a package.
Revocation must update:
- artifact status
- release summary fields
- revocation timestamp
- revoking user id
- reason text
All ClawPack download paths must block revoked artifacts.
## Retry and Recovery
Retry is safe for transient storage/build failures and search index failures.
Retry is not a substitute for fixing publisher metadata. If validation failed
because metadata is incomplete or unsafe, ask the publisher for a corrected
release.
Operators should record:
- failed release id
- package name
- version
- failure code
- failure message
- retry count
- last attempted time
## Integrity Sampling
Integrity checks should compare:
- stored archive digest
- release summary digest
- artifact row digest
- generated manifest digest
- archive availability in Convex storage
Digest mismatch is a serious incident. Revoke first if public downloads could
serve corrupted or substituted artifacts, then rebuild from trusted source if
available.
## Production Safety
Before production operations:
1. Check current deployment health.
2. Dry-run or status-read first.
3. Use small bounded limits.
4. Capture command output.
5. Confirm before any write action.
6. Recheck status after the batch.
Do not run ClawPack migrations as a single unbounded backfill.
+142
View File
@@ -0,0 +1,142 @@
---
summary: "ClawPack artifact contract, integrity model, and download behavior."
read_when:
- Working on plugin artifact storage
- Changing package download APIs
- Debugging ClawPack verification
---
# ClawPack
ClawPack is ClawHub's stored artifact format for plugin releases. A ClawPack
is a deterministic ZIP archive built by ClawHub from publisher-provided package
source. Publishers may upload `CLAWPACK.json`, but ClawHub ignores it and
generates the canonical manifest itself.
ClawPack is not OpenClaw install support by itself. OpenClaw consumption is a
future downstream step. This repository owns artifact creation, storage,
moderation, API, CLI, and operator readiness surfaces.
## Contract
Every active ClawPack has:
- a canonical package name
- a release version
- `package/CLAWPACK.json`
- normalized package files under `package/`
- a SHA-256 digest of the final ZIP bytes
- a manifest SHA-256 digest
- a file count and byte size
- a spec version
- a build timestamp
- a storage id in Convex file storage
- artifact status: `active`, `superseded`, or `revoked`
The ZIP digest is the immutable artifact identity. The release row stores a hot
summary for UI/API reads, while the artifact row owns detailed storage identity
and status.
## Manifest
`package/CLAWPACK.json` describes the archive ClawHub actually produced. It
includes package identity, source attribution, compatibility, host targets,
environment requirements, and file summaries.
Required properties for plugin confidence:
- package family: `code-plugin` or `bundle-plugin`
- package name and version
- source repository, path, ref, or commit where known
- OpenClaw compatibility range for code plugins
- plugin API compatibility range for code plugins
- host target matrix where declared
- environment flags such as browser, desktop, network, native dependencies, or external services
Missing host or environment facts do not always block publish, but they lower
readiness and should be visible in UI, API, and moderation tools.
## Build Rules
The artifact builder must:
- reject unsafe archive paths, absolute paths, and traversal paths
- normalize path separators
- ignore local junk such as dependency folders and build cache files
- ignore publisher-provided `CLAWPACK.json`
- sort manifest entries deterministically
- build deterministic ZIP bytes
- hash the final archive bytes
- store the artifact in Convex storage
- write release summary fields and artifact records together
- avoid making a failed artifact publicly installable
## Download Paths
Public download routes return stored artifacts, not regenerated archives.
- `GET /api/v1/packages/{name}/download`
- `GET /api/v1/packages/{name}/versions/{version}/clawpack`
- `GET /api/v1/clawpacks/{sha256}`
Expected headers:
```http
ETag: "sha256:<hex>"
Digest: sha-256=<base64>
X-ClawHub-ClawPack-Sha256: <hex>
X-ClawHub-ClawPack-Spec-Version: 1
X-ClawHub-Artifact-Status: active
```
Revoked artifacts must not be served from any path.
## CLI Verification
Download:
```bash
clawhub package download <name> --version <version>
```
Inspect:
```bash
clawhub package inspect <name> --version <version>
clawhub package clawpack <name> --version <version> --json
```
Verify a downloaded artifact:
```bash
clawhub package verify <file>.clawpack.zip --sha256 <digest>
```
The verifier checks the archive digest when `--sha256` is provided and confirms
that `package/CLAWPACK.json` exists.
## Storage
V1 source of truth is Convex file storage. The database stores the Convex
storage id, artifact digest, status, and release summary.
S3 is intentionally not required for the first platform release. A later mirror
can add provider, bucket/key, mirror digest, status, and repair metadata, but
the mirror must never be trusted until digest verification passes against the
Convex source artifact.
## Failure Model
Common failure states:
- metadata validation blocked publish
- archive expansion failed
- unsafe path rejected
- ClawPack build failed
- Convex storage write failed
- artifact row write failed
- search index backfill failed
- artifact revoked after publish
Admin and moderator tooling should show the failed step, reason code, release
identity, and retry path where retry is safe.
+116 -1
View File
@@ -237,6 +237,8 @@ Stores your API token + cached registry URL.
- `--family skill|code-plugin|bundle-plugin`
- `--official`
- `--executes-code`
- `--host-target <target>` (for example `darwin-arm64`, `linux-x64-glibc`, `win32-x64`)
- `--environment <flag>` (for example `browser`, `desktop`, `network`)
- `--limit <n>` (1-100, default: 25)
- `--json`
@@ -244,13 +246,15 @@ Examples:
```bash
clawhub package explore --family code-plugin
clawhub package explore --family code-plugin --host-target darwin-arm64
clawhub package explore browser --family code-plugin --environment browser
clawhub package explore episodic-claw --family code-plugin
```
### `package inspect <name>`
- Fetches package metadata without installing.
- Use this for plugin metadata, compatibility, verification, source, and version/file inspection.
- Use this for plugin metadata, ClawPack availability, compatibility, verification, source, and version/file inspection.
- `--version <version>`: inspect a specific version (default: latest).
- `--tag <tag>`: inspect a tagged version (e.g. `latest`).
- `--versions`: list version history (first page).
@@ -259,11 +263,122 @@ clawhub package explore episodic-claw --family code-plugin
- `--file <path>`: fetch raw file content (text files only; 200KB limit).
- `--json`: machine-readable output.
### `package download <name>`
- Downloads the selected package release as a ClawPack archive.
- Calls `GET /api/v1/packages/{name}/download`.
- Defaults to the latest release.
- `--version <version>`: download a specific version.
- `--tag <tag>`: download a tagged version.
- `-o, --output <path>`: output path. Defaults to `<name>.clawpack.zip`.
- `--json`: print the output path, ClawPack SHA-256 header, and spec version.
### `package verify <file>`
- Verifies a downloaded ClawPack ZIP.
- Requires `package/CLAWPACK.json` inside the archive.
- `--sha256 <digest>`: also compare the full archive SHA-256.
- `--json`: machine-readable output.
### `package clawpack <name>`
- Alias-style ClawPack download command for operators who want the artifact noun first.
- Accepts the same `--version`, `--tag`, `--output`, and `--json` flags as `package download`.
### `package clawpack-admin status`
- Admin-only migration status check.
- Calls `GET /api/v1/packages/clawpack/migration-status`.
- Requires an API token for an admin user.
- `--limit <n>` controls sample size.
- `--json` emits the raw response.
### `package clawpack-admin readiness`
- Admin-only readiness check for the official OpenClaw bundled plugin migration targets.
- Calls `GET /api/v1/packages/clawpack/migration-readiness`.
- Requires an API token for an admin user.
- Prints each target package, readiness state, and blocker list.
- `--json` emits the raw response.
### `package clawpack-admin dry-run`
- Admin-only preview for a persistent ClawPack migration run.
- Calls `GET /api/v1/packages/clawpack/migration-runs/dry-run`.
- Requires an API token for an admin user.
- `--operation <operation>` accepts `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
- `--limit <n>` controls candidate sample or batch size.
- `--cursor <cursor>` previews a later search-index batch.
- `--json` emits the raw response.
### `package clawpack-admin runs`
- Admin-only run ledger for ClawPack migration operations.
- Calls `GET /api/v1/packages/clawpack/migration-runs`.
- Requires an API token for an admin user.
- `--status <status>` filters by `pending`, `running`, `completed`, or `failed`.
- `--limit <n>` controls run count.
- `--json` emits the raw response.
### `package clawpack-admin create-run`
- Admin-only creation path for a durable ClawPack migration run.
- Calls `POST /api/v1/packages/clawpack/migration-runs`.
- Requires an API token for an admin user.
- `--operation <operation>` accepts `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
- `--limit <n>` controls batch size for each continuation.
- `--cursor <cursor>` sets the initial search-index cursor.
- `--json` emits the raw response.
### `package clawpack-admin continue-run <run-id>`
- Admin-only execution path for the next bounded batch of a migration run.
- Calls `POST /api/v1/packages/clawpack/migration-runs/{runId}/continue`.
- Requires an API token for an admin user.
- `--json` emits the raw response.
### `package clawpack-admin backfill`
- Admin-only direct batch builder for legacy plugin releases missing stored ClawPack artifacts.
- Calls `POST /api/v1/packages/clawpack/backfill`.
- Requires an API token for an admin user.
- `--limit <n>` controls batch size.
- `--json` emits the raw response.
- Prefer `dry-run`, `create-run`, and `continue-run` for coordinated migrations; use direct backfill for focused repair.
### `package clawpack-admin index-backfill`
- Admin-only direct batch builder for ClawPack host-target and environment lookup indexes.
- Calls `POST /api/v1/packages/clawpack/index-backfill`.
- Requires an API token for an admin user.
- `--limit <n>` controls batch size.
- `--cursor <cursor>` continues from the previous batch response.
- `--json` emits the raw response.
- Prefer the `search-index-backfill` migration-run operation for coordinated index migrations.
### `package clawpack-admin retry-failures`
- Admin-only direct batch retry for failed ClawPack artifact builds.
- Calls `POST /api/v1/packages/clawpack/retry-failures`.
- Requires an API token for an admin user.
- `--limit <n>` controls batch size.
- `--json` emits the raw response.
- Prefer the `failure-retry` migration-run operation for coordinated retries.
### `package clawpack-admin revoke <name> <version>`
- Moderator/admin revocation path for a published ClawPack artifact.
- Calls `POST /api/v1/packages/{name}/versions/{version}/clawpack/revoke`.
- Requires an API token for an admin or moderator user.
- `--reason <text>` records the moderation reason.
- `--json` emits the raw response.
### `package publish <source>`
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
- `<source>` accepts:
- Local folder path: `./my-plugin`
- Local package archive: `./my-plugin.zip`, `./my-plugin.tgz`, or `./my-plugin.tar.gz`
- GitHub repo: `owner/repo` or `owner/repo@ref`
- GitHub URL: `https://github.com/owner/repo`
- Metadata is auto-detected from `package.json`, `openclaw.plugin.json`, and `openclaw.bundle.json`.
+110 -2
View File
@@ -293,15 +293,22 @@ Query params:
- `isOfficial` (optional): `true` or `false`
- `executesCode` (optional): `true` or `false`
- `capabilityTag` (optional): capability filter for plugin packages
- `hostTarget` (optional): ClawPack host target key, e.g. `darwin-arm64`, `linux-x64-glibc`, `win32-x64`
- `environment` (optional): ClawPack environment flag, e.g. `browser`, `desktop`, `network`
Notes:
- `GET /api/v1/code-plugins` and `GET /api/v1/bundle-plugins` remain fixed-family aliases.
- Skill entries stay backed by the skill registry and can still be published only through `POST /api/v1/skills`.
- `POST /api/v1/packages` is still only for code-plugin and bundle-plugin releases.
- ClawPack-only filters return plugin package entries and exclude skill-backed catalog entries.
- Anonymous callers only see public package channels.
- Authenticated callers can see private packages for publishers they belong to in list/search results.
- `channel=private` only returns packages the authenticated caller can read.
- Package list items include ClawPack summary signals when available:
- `clawpackAvailable`
- `hostTargetKeys`
- `environmentFlags`
### `GET /api/v1/packages/search`
@@ -316,9 +323,12 @@ Query params:
- `isOfficial` (optional): `true` or `false`
- `executesCode` (optional): `true` or `false`
- `capabilityTag` (optional): capability filter for plugin packages
- `hostTarget` (optional): ClawPack host target key
- `environment` (optional): ClawPack environment flag
Notes:
- ClawPack-only filters return plugin package entries and exclude skill-backed catalog entries.
- Anonymous callers only see public package channels.
- Authenticated callers can search private packages for publishers they belong to.
- `channel=private` only returns packages the authenticated caller can read.
@@ -386,8 +396,10 @@ Notes:
- Defaults to the latest release.
- Skills redirect to `GET /api/v1/download`.
- Plugin/package archives are zip files with a `package/` root so they install directly in OpenClaw without repacking.
- Registry-only metadata is not injected into the downloaded archive.
- Plugin/package archives are ClawPack zip files with a `package/` root and a generated `package/CLAWPACK.json` manifest.
- Stored ClawPack artifacts are served when available; legacy releases fall back to deterministic package ZIP assembly.
- Response headers include `X-ClawHub-ClawPack-Sha256` and `X-ClawHub-ClawPack-Spec-Version` when a stored ClawPack is served.
- Publisher-supplied `CLAWPACK.json` files are ignored during ClawPack generation.
- Pending VirusTotal scans do not block downloads; malicious releases return `403`.
- Private packages return `404` unless the caller is the owner.
@@ -455,9 +467,105 @@ Validation highlights:
- `family` must be `code-plugin` or `bundle-plugin`.
- Code plugins require `package.json`, `openclaw.plugin.json`, source repo metadata, source commit metadata, and config schema metadata.
- Bundle plugins require at least one host target.
- Successful publishes generate and store a ClawPack artifact for the release.
- Release detail responses include `version.clawpack` with digest, size, file count, host targets, environment summary, and runtime bundle placeholders.
- Only trusted publishers may publish to the `official` channel.
- On-behalf publishes still validate official-channel eligibility against the target owner account.
### `GET /api/v1/packages/clawpack/migration-status`
Admin-only ClawPack migration status.
- Requires Bearer token auth.
- Caller must be an admin.
- `limit` (optional): sample size for generated ClawPack artifact statistics.
### `GET /api/v1/packages/clawpack/migration-runs/dry-run`
Admin-only preview for a persistent ClawPack migration run.
- Requires Bearer token auth.
- Caller must be an admin.
- Query params:
- `operation`: `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
- `limit` (optional): sample size.
- `cursor` (optional): search-index continuation cursor.
- Returns candidate rows and the cursor state without mutating data.
### `GET /api/v1/packages/clawpack/migration-runs`
Admin-only ClawPack migration run ledger.
- Requires Bearer token auth.
- Caller must be an admin.
- Query params:
- `status` (optional): `pending`, `running`, `completed`, or `failed`.
- `limit` (optional): max run records.
### `GET /api/v1/packages/clawpack/migration-runs/{runId}`
Admin-only ClawPack migration run detail.
- Requires Bearer token auth.
- Caller must be an admin.
- Returns `404` when the run id does not exist.
### `POST /api/v1/packages/clawpack/migration-runs`
Admin-only ClawPack migration run creation.
- Requires Bearer token auth.
- Caller must be an admin.
- JSON body: `{ "operation": "artifact-backfill", "limit": 10, "cursor": "optional" }`.
- Creates a durable `pending` run. It does not execute the batch until `continue` is called.
### `POST /api/v1/packages/clawpack/migration-runs/{runId}/continue`
Admin-only execution path for one bounded ClawPack migration batch.
- Requires Bearer token auth.
- Caller must be an admin.
- Runs one batch for the selected migration run and updates processed/generated/skipped/failed counters.
- Returns the updated run and the batch result. Failed runs store `lastError`.
### `POST /api/v1/packages/clawpack/backfill`
Admin-only ClawPack backfill batch for legacy plugin releases.
- Requires Bearer token auth.
- Caller must be an admin.
- JSON body: `{ "limit": 10 }`.
- Builds missing ClawPack artifacts for eligible code-plugin and bundle-plugin releases.
- Prefer migration runs for coordinated production work; use this direct endpoint for focused repair.
### `POST /api/v1/packages/clawpack/index-backfill`
Admin-only ClawPack lookup-index backfill batch.
- Requires Bearer token auth.
- Caller must be an admin.
- JSON body: `{ "limit": 25, "cursor": "<previous continueCursor>" }`.
- Rebuilds host-target and environment lookup rows for releases with stored, non-revoked ClawPack artifacts.
- Prefer the `search-index-backfill` migration-run operation for coordinated production work.
### `POST /api/v1/packages/clawpack/retry-failures`
Admin-only retry path for failed ClawPack artifact builds.
- Requires Bearer token auth.
- Caller must be an admin.
- JSON body: `{ "limit": 10 }`.
- Prefer the `failure-retry` migration-run operation for coordinated production work.
### `POST /api/v1/packages/{name}/versions/{version}/clawpack/revoke`
Moderator/admin ClawPack revocation for a specific package release.
- Requires Bearer token auth.
- Caller must be an admin or moderator.
- JSON body: `{ "reason": "Malware confirmed" }`.
- Marks the active ClawPack artifact revoked and blocks package download and digest-addressed ClawPack download paths.
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
Soft-delete / restore a skill (owner, moderator, or admin).
+122
View File
@@ -0,0 +1,122 @@
---
summary: "ClawHub-only readiness tracking for future OpenClaw bundled plugin externalization."
read_when:
- Planning OpenClaw plugin externalization
- Reviewing ClawPack migration readiness
- Exporting operator status reports
---
# Official Plugin Migration Readiness
ClawHub can track whether bundled OpenClaw plugins are ready to become external
ClawHub-hosted packages. This tracker is informational and operational. It does
not mutate `openclaw/openclaw`, remove bundled plugins, or claim install support
before the downstream OpenClaw work exists.
Use:
```text
/management/migrations
```
CLI:
```bash
clawhub package clawpack-admin readiness --json
```
## Readiness Object
Each candidate should track:
- bundled plugin id
- desired ClawHub package name
- publisher or owner
- source repository
- source path
- source commit or ref
- current ClawHub package id
- latest release id and version
- ClawPack digest
- host matrix completeness
- environment metadata completeness
- scan state
- moderation state
- docs status
- runtime bundle decision
- API visibility
- blockers
- readiness decision
## Gates
A candidate is not ready until all gates are green:
- package exists
- latest release exists
- active ClawPack exists
- digest-addressed download works
- source repo, path, and commit are recorded
- host targets are complete
- environment metadata is complete
- scan is clean or manually approved
- moderation is approved
- docs link exists where required
- runtime bundle decision is recorded
Readiness should be conservative. Unknown is blocked.
## States
Use explicit states:
```text
planned
package-missing
release-missing
clawpack-missing
metadata-incomplete
scan-blocked
moderation-blocked
runtime-bundle-blocked
docs-blocked
ready-for-openclaw
```
Do not show `ready-for-openclaw` unless every required gate is satisfied.
## Operator Workflow
1. Open `/management/migrations`.
2. Review each candidate state.
3. Open the package or release links where available.
4. Fix ClawHub-side metadata, publishing, ClawPack, moderation, or docs gaps.
5. Export readiness for planning.
6. Use the export as input to future OpenClaw work.
The export is a planning artifact, not an OpenClaw change request by itself.
## What This Tracker Must Not Do
- edit `openclaw/openclaw`
- open OpenClaw pull requests
- remove bundled plugin code
- auto-publish packages without human-owned source attribution
- mark a candidate ready while ClawPack or moderation is missing
- hide blockers behind a single percentage score
## Suggested Blocker Codes
- `package-missing`
- `release-missing`
- `clawpack-missing`
- `digest-download-failed`
- `source-metadata-missing`
- `host-matrix-incomplete`
- `environment-metadata-incomplete`
- `scan-blocked`
- `moderation-blocked`
- `docs-missing`
- `runtime-decision-missing`
Blockers should include an owner or next action when known.
+152
View File
@@ -0,0 +1,152 @@
---
summary: "Publisher workflow for code-plugin and bundle-plugin ClawPack releases."
read_when:
- Publishing plugin packages
- Updating the publish UI
- Debugging package publish validation
---
# Plugin Publishing
ClawHub supports plugin package publishing for `code-plugin` and
`bundle-plugin` families. Publishing creates a package release and, when source
validation passes, a stored ClawPack artifact.
This is ClawHub-only. It does not remove bundled plugins from OpenClaw and does
not mean OpenClaw can install the artifact yet.
## Web Flow
Use:
```text
/publish-plugin
```
The publish page accepts:
- `.zip`
- `.tgz`
- `.tar.gz`
- folder upload
The page expands package source in the browser, normalizes paths, ignores local
junk, extracts package metadata, and previews the ClawPack manifest ClawHub
will generate.
Publisher checks should make these facts obvious before publish:
- package name
- display name
- version
- package family
- source repository and path where known
- source ref or commit where known
- OpenClaw compatibility range
- plugin API compatibility range
- host target matrix
- environment requirements
- files that will be included
- ignored files
- blocking errors
- non-blocking warnings
The metadata form stays locked until package source is selected because source
inspection is the trust boundary. The upload panel is the primary next action.
## CLI Flow
Preview first:
```bash
clawhub package publish ./my-plugin --family code-plugin --dry-run
```
Publish:
```bash
clawhub package publish ./my-plugin --family code-plugin
```
Supported source locators:
- local folder
- local archive
- `owner/repo`
- `owner/repo@ref`
- GitHub URL
Private GitHub imports require `GITHUB_TOKEN` in the publisher environment.
## Code Plugin Minimum Metadata
Code plugins must declare OpenClaw compatibility explicitly. Do not rely on the
package version as a fallback for runtime compatibility.
```json
{
"name": "@example/openclaw-plugin",
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./dist/index.js"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2"
},
"build": {
"openclawVersion": "2026.3.24-beta.2"
}
}
}
```
Required:
- `openclaw.extensions`
- `openclaw.compat.pluginApi`
- `openclaw.build.openclawVersion`
Optional but useful:
- `openclaw.compat.minGatewayVersion`
- `openclaw.build.pluginSdkVersion`
- host target declarations
- environment requirement declarations
## Bundle Plugin Metadata
Bundle plugins should ship a bundle manifest such as `openclaw.bundle.json`.
They do not execute native code, but they still need source attribution,
versioning, family labels, and moderation.
The UI and API must never blur bundle plugins with code plugins. Cards, detail
pages, and CLI output should explicitly label the family.
## Publish Result
After publish, ClawHub should expose:
- package URL
- release URL
- ClawPack digest when available
- moderation state
- scan state
- next action for the publisher
New releases may remain pending or limited until scans and moderation complete.
Published is not the same thing as publicly installable.
## Common Blockers
- missing `package.json`
- missing plugin or bundle manifest
- unsafe archive path
- missing code-plugin compatibility fields
- invalid version
- unsupported package family
- unknown source attribution
- empty ClawPack file list
- storage failure after validation
Errors should include file or field context. Vague "invalid package" messages
are not acceptable for plugin publishing.
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from "@playwright/test";
import { zipSync } from "fflate";
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
const encoder = new TextEncoder();
function makePluginZip() {
return Buffer.from(
zipSync({
"demo-plugin/package.json": encoder.encode(
JSON.stringify({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
repository: "https://github.com/openclaw/demo-plugin.git",
openclaw: {
extensions: ["./dist/index.js"],
compat: {
pluginApi: ">=2026.3.24-beta.2",
},
build: {
openclawVersion: "2026.3.24-beta.2",
pluginSdkVersion: "2026.3.24-beta.2",
},
},
}),
),
"demo-plugin/openclaw.plugin.json": encoder.encode(
JSON.stringify({
id: "demo.plugin",
name: "Demo Plugin",
setupEntry: "./dist/setup.js",
}),
),
"demo-plugin/dist/index.js": encoder.encode("export const demo = true;\n"),
"demo-plugin/CLAWPACK.json": encoder.encode('{"forged": true}\n'),
}),
);
}
test("publisher can upload an archive and inspect the ClawPack preview", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/publish-plugin", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Publish Plugin" })).toBeVisible();
await expect(page.locator('[data-upload-ready="true"]')).toBeVisible();
await page.locator('input[aria-label="Package archive input"]').setInputFiles({
name: "demo-plugin.zip",
mimeType: "application/zip",
buffer: makePluginZip(),
});
await expect(page.getByText("Package detected")).toBeVisible();
await expect(page.getByPlaceholder("Plugin name")).toHaveValue("demo-plugin");
await expect(page.getByPlaceholder("Display name")).toHaveValue("Demo Plugin");
await expect(page.getByPlaceholder("Version")).toHaveValue("1.2.3");
await expect(page.getByPlaceholder("Source repo (owner/repo)")).toHaveValue(
"openclaw/demo-plugin",
);
await expect(page.getByRole("heading", { name: "ClawPack preview" })).toBeVisible();
await expect(page.getByText('"kind": "openclaw.clawpack"')).toBeVisible();
await expect(
page.getByText("CLAWPACK.json supplied by package will be replaced by ClawHub."),
).toBeVisible();
await expect(page.getByRole("main").getByRole("button", { name: "Publish" })).toBeDisabled();
await expectHealthyPage(page, errors);
});
test("management child routes stay on the management URL and show access diagnostics", async ({
page,
}) => {
const errors = trackRuntimeErrors(page);
await page.goto("/management/clawpacks", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/management\/clawpacks$/);
await expect(page.getByText("Management access required")).toBeVisible();
await page.goto("/management/moderation", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/management\/moderation$/);
await expect(page.getByText("Management access required")).toBeVisible();
await page.goto("/management/migrations", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/management\/migrations$/);
await expect(page.getByText("Management access required")).toBeVisible();
await expectHealthyPage(page, errors);
});
+186
View File
@@ -18,10 +18,23 @@ import { cmdMergeSkill, cmdRenameSkill } from "./cli/commands/ownership.js";
import {
cmdExplorePackages,
cmdGetPackageTrustedPublisher,
cmdDownloadPackage,
cmdInspectPackage,
cmdInspectPackageClawPack,
cmdDeletePackageTrustedPublisher,
cmdPackageClawPackBackfill,
cmdPackageClawPackIndexBackfill,
cmdPackageClawPackMigrationDryRun,
cmdPackageClawPackMigrationRunContinue,
cmdPackageClawPackMigrationRunCreate,
cmdPackageClawPackMigrationRuns,
cmdPackageClawPackMigrationReadiness,
cmdPackageClawPackMigrationStatus,
cmdPackageClawPackRetryFailures,
cmdPackageClawPackRevoke,
cmdPublishPackage,
cmdSetPackageTrustedPublisher,
cmdVerifyPackageClawPack,
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
@@ -370,6 +383,8 @@ packageCmd
.option("--family <family>", "skill|code-plugin|bundle-plugin")
.option("--official", "Only official packages")
.option("--executes-code", "Only packages that execute code")
.option("--host-target <target>", "Filter by Claw Pack host target, e.g. darwin-arm64")
.option("--environment <flag>", "Filter by Claw Pack environment flag, e.g. browser")
.option(
"--limit <n>",
"Number of packages to show (max 100)",
@@ -399,6 +414,177 @@ packageCmd
await cmdInspectPackage(opts, name, options);
});
packageCmd
.command("download")
.description("Download a package Claw Pack artifact")
.argument("<name>", "Package name")
.option("--version <version>", "Version to download")
.option("--tag <tag>", "Tag to download")
.option("-o, --output <path>", "Output path")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdDownloadPackage(opts, name, options);
});
packageCmd
.command("verify")
.description("Verify a downloaded Claw Pack artifact")
.argument("<file>", "Claw Pack ZIP path")
.option("--sha256 <digest>", "Expected Claw Pack SHA-256")
.option("--json", "Output JSON")
.action(async (file, options) => {
await cmdVerifyPackageClawPack(file, options);
});
packageCmd
.command("clawpack")
.description("Download Claw Pack artifacts")
.argument("[name]", "Package name")
.option("--version <version>", "Version to download")
.option("--tag <tag>", "Tag to download")
.option("-o, --output <path>", "Output path")
.option("--json", "Output JSON")
.action(async (name, options) => {
if (!name) {
packageCmd.commands.find((command) => command.name() === "clawpack")?.help();
return;
}
const opts = await resolveGlobalOpts();
await cmdDownloadPackage(opts, name, options);
});
packageCmd
.command("clawpack-inspect")
.description("Inspect a remote Claw Pack artifact")
.argument("<name>", "Package name")
.option("--version <version>", "Version to inspect (default: latest)")
.option("--manifest", "Print the generated CLAWPACK.json")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdInspectPackageClawPack(opts, name, options);
});
const packageClawPackCmd = packageCmd
.command("clawpack-admin")
.description("Admin Claw Pack migration controls");
packageClawPackCmd
.command("status")
.description("Show Claw Pack migration status")
.option("--limit <n>", "Sample limit", (value) => Number.parseInt(value, 10), 25)
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationStatus(opts, options);
});
packageClawPackCmd
.command("readiness")
.description("Show official OpenClaw plugin migration readiness")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationReadiness(opts, options);
});
packageClawPackCmd
.command("dry-run")
.description("Preview Claw Pack migration run candidates")
.option(
"--operation <operation>",
"artifact-backfill, failure-retry, or search-index-backfill",
"artifact-backfill",
)
.option("--limit <n>", "Sample size", (value) => Number.parseInt(value, 10), 10)
.option("--cursor <cursor>", "Continue cursor for search-index-backfill")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationDryRun(opts, options);
});
packageClawPackCmd
.command("runs")
.description("List Claw Pack migration run records")
.option("--status <status>", "pending, running, completed, or failed")
.option("--limit <n>", "Run limit", (value) => Number.parseInt(value, 10), 20)
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationRuns(opts, options);
});
packageClawPackCmd
.command("create-run")
.description("Create a persistent Claw Pack migration run")
.option(
"--operation <operation>",
"artifact-backfill, failure-retry, or search-index-backfill",
"artifact-backfill",
)
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
.option("--cursor <cursor>", "Initial cursor for search-index-backfill")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationRunCreate(opts, options);
});
packageClawPackCmd
.command("continue-run")
.description("Execute the next batch for a Claw Pack migration run")
.argument("<run-id>", "Migration run id")
.option("--json", "Output JSON")
.action(async (runId, options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackMigrationRunContinue(opts, runId, options);
});
packageClawPackCmd
.command("backfill")
.description("Build Claw Pack artifacts for plugin releases")
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackBackfill(opts, options);
});
packageClawPackCmd
.command("retry-failures")
.description("Retry failed Claw Pack artifact builds")
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackRetryFailures(opts, options);
});
packageClawPackCmd
.command("index-backfill")
.description("Backfill Claw Pack host and environment lookup indexes")
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 25)
.option("--cursor <cursor>", "Continue cursor from the previous batch")
.option("--json", "Output JSON")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackIndexBackfill(opts, options);
});
packageClawPackCmd
.command("revoke")
.description("Revoke a published Claw Pack artifact")
.argument("<name>", "Package name")
.argument("<version>", "Package version")
.option("--reason <text>", "Moderation reason")
.option("--json", "Output JSON")
.action(async (name, version, options) => {
const opts = await resolveGlobalOpts();
await cmdPackageClawPackRevoke(opts, name, version, options);
});
packageCmd
.command("publish")
.description("Publish a code plugin or bundle plugin from a folder or GitHub source")
@@ -1,7 +1,8 @@
/* @vitest-environment node */
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { zipSync } from "fflate";
@@ -28,11 +29,24 @@ vi.mock("../ui.js", () => uiMocks.moduleFactory());
const {
cmdDeletePackageTrustedPublisher,
cmdDownloadPackage,
cmdExplorePackages,
cmdGetPackageTrustedPublisher,
cmdInspectPackage,
cmdPackageClawPackBackfill,
cmdPackageClawPackIndexBackfill,
cmdPackageClawPackMigrationDryRun,
cmdPackageClawPackMigrationReadiness,
cmdPackageClawPackMigrationRunContinue,
cmdPackageClawPackMigrationRunCreate,
cmdPackageClawPackMigrationRuns,
cmdPackageClawPackMigrationStatus,
cmdPackageClawPackRetryFailures,
cmdPackageClawPackRevoke,
cmdInspectPackageClawPack,
cmdPublishPackage,
cmdSetPackageTrustedPublisher,
cmdVerifyPackageClawPack,
} = await import("./packages");
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -137,6 +151,8 @@ describe("package commands", () => {
await cmdExplorePackages(makeOpts(), "demo plugin", {
family: "code-plugin",
executesCode: true,
hostTarget: "darwin-arm64",
environment: "browser",
});
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
@@ -145,6 +161,8 @@ describe("package commands", () => {
expect(url.searchParams.get("q")).toBe("demo plugin");
expect(url.searchParams.get("family")).toBe("code-plugin");
expect(url.searchParams.get("executesCode")).toBe("true");
expect(url.searchParams.get("hostTarget")).toBe("darwin-arm64");
expect(url.searchParams.get("environment")).toBe("browser");
});
it("supports skill family package browse requests", async () => {
@@ -153,12 +171,19 @@ describe("package commands", () => {
nextCursor: null,
});
await cmdExplorePackages(makeOpts(), "", { family: "skill", limit: 7 });
await cmdExplorePackages(makeOpts(), "", {
family: "skill",
hostTarget: "linux-x64-glibc",
environment: "desktop",
limit: 7,
});
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
const url = new URL(String(request?.url));
expect(url.pathname).toBe("/api/v1/packages");
expect(url.searchParams.get("family")).toBe("skill");
expect(url.searchParams.get("hostTarget")).toBe("linux-x64-glibc");
expect(url.searchParams.get("environment")).toBe("desktop");
expect(url.searchParams.get("limit")).toBe("7");
});
@@ -207,6 +232,483 @@ describe("package commands", () => {
expect(url.searchParams.get("version")).toBeNull();
});
it("prints Claw Pack metadata while inspecting a package", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
runtimeId: "demo.plugin",
channel: "community",
isOfficial: false,
summary: null,
latestVersion: "2.0.0",
createdAt: 1,
updatedAt: 2,
tags: { latest: "2.0.0" },
compatibility: null,
capabilities: { executesCode: true },
verification: {
tier: "structural",
scope: "artifact-only",
},
clawpack: {
available: true,
specVersion: 1,
format: "zip",
sha256: "a".repeat(64),
size: 123,
fileCount: 3,
manifestSha256: "b".repeat(64),
builtAt: 1_763_000_000_000,
buildVersion: "clawhub-clawpack-v1",
hostTargets: [
{ os: "darwin", arch: "arm64", supportState: "supported" },
{ os: "linux", arch: "x64", libc: "glibc", supportState: "supported" },
],
environment: { requiresNetwork: true },
runtimeBundles: [],
},
},
owner: null,
});
await cmdInspectPackage(makeOpts(), "demo", {});
expect(mockLog).toHaveBeenCalledWith("Claw Pack: available");
expect(mockLog).toHaveBeenCalledWith(`Claw Pack SHA-256: ${"a".repeat(64)}`);
expect(mockLog).toHaveBeenCalledWith("Claw Pack Targets: darwin-arm64, linux-x64-glibc");
});
it("inspects remote Claw Pack metadata by package version", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
package: {
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
},
version: { version: "1.0.0", createdAt: 1 },
clawpack: {
available: true,
specVersion: 1,
format: "zip",
sha256: "a".repeat(64),
size: 123,
fileCount: 3,
manifestSha256: "b".repeat(64),
builtAt: 1_763_000_000_000,
buildVersion: "clawhub-clawpack-v1",
hostTargets: [{ os: "darwin", arch: "arm64", supportState: "supported" }],
environment: { requiresNetwork: true },
runtimeBundles: [],
},
links: {
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
immutable: `/api/v1/clawpacks/${"a".repeat(64)}`,
manifest: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
},
});
await cmdInspectPackageClawPack(makeOpts(), "@openclaw/kitchen-sink", { version: "1.0.0" });
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "GET",
path: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack",
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("@openclaw/kitchen-sink@1.0.0");
expect(mockLog).toHaveBeenCalledWith(`Claw Pack SHA-256: ${"a".repeat(64)}`);
});
it("prints remote Claw Pack manifests and resolves latest versions", async () => {
httpMocks.apiRequest
.mockResolvedValueOnce({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
runtimeId: "demo.plugin",
channel: "community",
isOfficial: false,
summary: null,
latestVersion: "2.0.0",
createdAt: 1,
updatedAt: 2,
tags: { latest: "2.0.0" },
compatibility: null,
capabilities: { executesCode: true },
verification: {
tier: "structural",
scope: "artifact-only",
},
},
owner: null,
})
.mockResolvedValueOnce({
package: { name: "demo", displayName: "Demo", family: "code-plugin" },
version: "2.0.0",
clawpack: {
available: true,
specVersion: 1,
format: "zip",
sha256: "c".repeat(64),
size: 123,
fileCount: 3,
manifestSha256: "d".repeat(64),
builtAt: 1_763_000_000_000,
buildVersion: "clawhub-clawpack-v1",
hostTargets: [],
environment: null,
runtimeBundles: [],
},
manifest: { kind: "openclaw.clawpack", specVersion: 1 },
});
await cmdInspectPackageClawPack(makeOpts(), "demo", { manifest: true });
const manifestCall = httpMocks.apiRequest.mock.calls[1];
if (!manifestCall) throw new Error("Missing Claw Pack manifest request");
const manifestRequest = manifestCall[1] as { path?: string };
expect(manifestRequest.path).toBe("/api/v1/packages/demo/versions/2.0.0/clawpack/manifest");
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
`"kind": "openclaw.clawpack"`,
);
});
it("downloads a Claw Pack package archive", async () => {
const workdir = await makeTmpWorkdir();
const bytes = new Uint8Array([1, 2, 3, 4]);
const fetchMock = vi.fn(async () => {
return new Response(bytes, {
headers: {
"content-disposition": 'attachment; filename="demo.clawpack.zip"',
"x-clawhub-clawpack-sha256": "c".repeat(64),
"x-clawhub-clawpack-spec-version": "1",
},
});
});
vi.stubGlobal("fetch", fetchMock);
try {
await cmdDownloadPackage(makeOpts(workdir), "demo", { json: true });
expect(fetchMock).toHaveBeenCalledWith(
new URL("https://clawhub.ai/api/v1/packages/demo/download"),
expect.objectContaining({
headers: expect.objectContaining({ Accept: "application/zip" }),
}),
);
expect(await readFile(join(workdir, "demo.clawpack.zip"))).toEqual(Buffer.from(bytes));
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
`"clawpackSha256": "${"c".repeat(64)}"`,
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("verifies Claw Pack archives and rejects digest mismatches", async () => {
const workdir = await makeTmpWorkdir();
try {
const zip = zipSync({
"package/CLAWPACK.json": new TextEncoder().encode(
JSON.stringify({
specVersion: 1,
package: { name: "demo", version: "1.0.0" },
}),
),
"package/package.json": new TextEncoder().encode("{}"),
});
const file = join(workdir, "demo.clawpack.zip");
await writeFile(file, zip);
const sha256 = createHash("sha256").update(zip).digest("hex");
await cmdVerifyPackageClawPack(file, { sha256, json: true });
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(`"ok": true`);
await expect(cmdVerifyPackageClawPack(file, { sha256: "0".repeat(64) })).rejects.toThrow(
"Claw Pack digest mismatch",
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("fetches Claw Pack migration status for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
missingSample: [],
missingSampleSize: 0,
generatedClawPackSampleSize: 2,
generatedClawPackBytes: 2048,
sampleLimit: 25,
});
await cmdPackageClawPackMigrationStatus(makeOpts(), { limit: 25, json: true });
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
| { method?: string; url?: string; token?: string }
| undefined;
expect(request?.method).toBe("GET");
expect(request?.token).toBe("tkn");
const url = new URL(String(request?.url));
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-status");
expect(url.searchParams.get("limit")).toBe("25");
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
"generatedClawPackSampleSize",
);
});
it("fetches official plugin migration readiness for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
items: [
{
bundledPluginId: "opik",
desiredPackageName: "@opik/opik-openclaw",
readinessState: "clawpack-missing",
blockers: ["clawpack-missing"],
},
],
readyCount: 0,
blockedCount: 1,
generatedAt: 1,
});
await cmdPackageClawPackMigrationReadiness(makeOpts());
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "GET",
path: "/api/v1/packages/clawpack/migration-readiness",
token: "tkn",
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration readiness");
expect(mockLog).toHaveBeenCalledWith("Ready: 0");
expect(mockLog).toHaveBeenCalledWith(
"opik: clawpack-missing -> @opik/opik-openclaw [clawpack-missing]",
);
});
it("fetches Claw Pack migration dry-run candidates for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
operation: "artifact-backfill",
candidateCount: 1,
failureCount: 0,
isDone: false,
candidates: [{ name: "demo-plugin", version: "1.0.0" }],
});
await cmdPackageClawPackMigrationDryRun(makeOpts(), {
operation: "artifact-backfill",
limit: 5,
json: true,
});
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
| { method?: string; url?: string; token?: string }
| undefined;
expect(request?.method).toBe("GET");
expect(request?.token).toBe("tkn");
const url = new URL(String(request?.url));
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-runs/dry-run");
expect(url.searchParams.get("operation")).toBe("artifact-backfill");
expect(url.searchParams.get("limit")).toBe("5");
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
"candidateCount",
);
});
it("lists Claw Pack migration runs for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
items: [
{
_id: "clawPackMigrationRuns:1",
operation: "failure-retry",
status: "pending",
processed: 0,
failed: 0,
},
],
});
await cmdPackageClawPackMigrationRuns(makeOpts(), { status: "pending", limit: 10 });
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
| { method?: string; url?: string; token?: string }
| undefined;
expect(request?.method).toBe("GET");
const url = new URL(String(request?.url));
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-runs");
expect(url.searchParams.get("status")).toBe("pending");
expect(url.searchParams.get("limit")).toBe("10");
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration runs");
expect(mockLog).toHaveBeenCalledWith(
"clawPackMigrationRuns:1 failure-retry pending processed=0 failed=0 cursor=unknown",
);
});
it("creates and continues Claw Pack migration runs for admins", async () => {
httpMocks.apiRequest
.mockResolvedValueOnce({
_id: "clawPackMigrationRuns:1",
operation: "search-index-backfill",
status: "pending",
processed: 0,
failed: 0,
cursor: "cursor:1",
})
.mockResolvedValueOnce({
run: {
_id: "clawPackMigrationRuns:1",
operation: "search-index-backfill",
status: "completed",
processed: 2,
failed: 0,
},
result: { processed: 2, succeeded: 2, failed: 0 },
});
await cmdPackageClawPackMigrationRunCreate(makeOpts(), {
operation: "search-index-backfill",
limit: 2,
cursor: "cursor:1",
});
await cmdPackageClawPackMigrationRunContinue(makeOpts(), "clawPackMigrationRuns:1");
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
1,
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/clawpack/migration-runs",
token: "tkn",
body: { operation: "search-index-backfill", limit: 2, cursor: "cursor:1" },
},
undefined,
);
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
2,
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/clawpack/migration-runs/clawPackMigrationRuns%3A1/continue",
token: "tkn",
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration run created");
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration run continued");
expect(mockLog).toHaveBeenCalledWith("Processed: 2");
});
it("runs Claw Pack backfill batches for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
processed: 2,
succeeded: 2,
failed: 0,
results: [],
});
await cmdPackageClawPackBackfill(makeOpts(), { limit: 2 });
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/clawpack/backfill",
token: "tkn",
body: { limit: 2 },
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack backfill");
expect(mockLog).toHaveBeenCalledWith("Succeeded: 2");
});
it("runs Claw Pack index backfill batches for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
processed: 2,
succeeded: 2,
failed: 0,
results: [],
continueCursor: "cursor:2",
isDone: false,
});
await cmdPackageClawPackIndexBackfill(makeOpts(), { limit: 2, cursor: "cursor:1" });
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/clawpack/index-backfill",
token: "tkn",
body: { limit: 2, cursor: "cursor:1" },
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack index backfill");
expect(mockLog).toHaveBeenCalledWith("Next cursor: cursor:2");
});
it("retries failed Claw Pack backfill batches for admins", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
processed: 1,
succeeded: 1,
failed: 0,
results: [],
});
await cmdPackageClawPackRetryFailures(makeOpts(), { limit: 1 });
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/clawpack/retry-failures",
token: "tkn",
body: { limit: 1 },
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack failure retry");
expect(mockLog).toHaveBeenCalledWith("Processed: 1");
expect(mockLog).toHaveBeenCalledWith("Succeeded: 1");
expect(mockLog).toHaveBeenCalledWith("Failed: 0");
});
it("revokes Claw Pack artifacts for moderators", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
packageId: "packages:1",
releaseId: "packageReleases:1",
version: "1.0.0",
sha256: "d".repeat(64),
revokedArtifactCount: 1,
});
await cmdPackageClawPackRevoke(makeOpts(), "@openclaw/kitchen-sink", "1.0.0", {
reason: "malware confirmed",
});
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
{
method: "POST",
path: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/revoke",
token: "tkn",
body: { reason: "malware confirmed" },
},
undefined,
);
expect(mockLog).toHaveBeenCalledWith("Claw Pack revoked");
expect(mockLog).toHaveBeenCalledWith(`SHA-256: ${"d".repeat(64)}`);
expect(mockLog).toHaveBeenCalledWith("Revoked artifacts: 1");
});
it("publishes a code plugin package with an exact explicit payload", async () => {
const workdir = await makeTmpWorkdir();
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
@@ -279,6 +781,66 @@ describe("package commands", () => {
}
});
it("publishes a code plugin package from a zip archive", async () => {
const workdir = await makeTmpWorkdir();
let dateSpy: { mockRestore: () => void } | undefined;
try {
const archive = join(workdir, "demo-plugin.zip");
const archiveBytes = zipSync({
"demo-plugin/package.json": new TextEncoder().encode(
makeCodePluginPackageJson({
name: "@scope/demo-plugin",
displayName: "Demo Plugin",
version: "1.0.0",
}),
),
"demo-plugin/openclaw.plugin.json": new TextEncoder().encode(
JSON.stringify({ id: "demo.plugin" }),
),
"demo-plugin/dist/index.js": new TextEncoder().encode("export const demo = true;\n"),
});
await writeFile(archive, archiveBytes);
dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_archive",
releaseId: "rel_archive",
});
await cmdPublishPackage(makeOpts(workdir), "demo-plugin.zip", {
sourceRepo: "openclaw/demo-plugin",
sourceCommit: "abc123",
});
expect(getPublishPayload()).toMatchObject({
name: "@scope/demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
source: {
kind: "github",
url: "https://github.com/openclaw/demo-plugin",
repo: "openclaw/demo-plugin",
commit: "abc123",
path: ".",
importedAt: 123_456_789,
},
});
expect(getUploadedFileNames()).toEqual([
"dist/index.js",
"openclaw.plugin.json",
"package.json",
]);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Published @scope/demo-plugin@1.0.0 (rel_archive)",
);
} finally {
dateSpy?.mockRestore();
await rm(workdir, { recursive: true, force: true });
}
});
it("mints a short-lived publish token from GitHub Actions OIDC in CI", async () => {
const workdir = await makeTmpWorkdir();
try {
+647 -15
View File
@@ -1,5 +1,8 @@
import { readFile, readdir, stat } from "node:fs/promises";
import { basename, join, relative, resolve, sep } from "node:path";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { gunzipSync, unzipSync } from "fflate";
import ignore from "ignore";
import mime from "mime";
import semver from "semver";
@@ -18,6 +21,7 @@ import {
type PackageCapabilitySummary,
type PackageCompatibility,
type PackageFamily,
type PackageClawPackSummary,
type PackageTrustedPublisher,
type PackageVerificationSummary,
validateOpenClawExternalCodePluginPackageJson,
@@ -53,6 +57,8 @@ type PackageExploreOptions = {
family?: PackageFamily;
official?: boolean;
executesCode?: boolean;
hostTarget?: string;
environment?: string;
limit?: number;
json?: boolean;
};
@@ -76,6 +82,40 @@ type PackagePublishOptions = {
json?: boolean;
};
type PackageDownloadOptions = {
version?: string;
tag?: string;
output?: string;
json?: boolean;
};
type PackageClawPackInspectOptions = {
version?: string;
manifest?: boolean;
json?: boolean;
};
type PackageVerifyOptions = {
sha256?: string;
json?: boolean;
};
type PackageClawPackMigrationOptions = {
limit?: number;
cursor?: string;
json?: boolean;
};
type PackageClawPackMigrationRunOptions = PackageClawPackMigrationOptions & {
operation?: string;
status?: string;
};
type PackageClawPackRevokeOptions = {
reason?: string;
json?: boolean;
};
type PackageTrustedPublisherGetOptions = {
json?: boolean;
};
@@ -97,6 +137,18 @@ type PackageFile = {
contentType?: string;
};
type PackageClawPackInspectResponse = {
package: { name: string; displayName: string; family: PackageFamily };
version: string | { version: string; createdAt?: number };
clawpack: PackageClawPackSummary;
links?: {
download?: string;
immutable?: string | null;
manifest?: string;
};
manifest?: Record<string, unknown>;
};
type InferredPublishSource = {
repo?: string;
commit?: string;
@@ -172,6 +224,8 @@ export async function cmdExplorePackages(
if (typeof options.executesCode === "boolean") {
url.searchParams.set("executesCode", String(options.executesCode));
}
if (options.hostTarget) url.searchParams.set("hostTarget", options.hostTarget);
if (options.environment) url.searchParams.set("environment", options.environment);
const result = await apiRequest(
registry,
{ method: "GET", url: url.toString(), token },
@@ -205,6 +259,8 @@ export async function cmdExplorePackages(
if (typeof options.executesCode === "boolean") {
url.searchParams.set("executesCode", String(options.executesCode));
}
if (options.hostTarget) url.searchParams.set("hostTarget", options.hostTarget);
if (options.environment) url.searchParams.set("environment", options.environment);
const result = await apiRequest(
registry,
{ method: "GET", url: url.toString(), token },
@@ -310,12 +366,14 @@ export async function cmdInspectPackage(
if (shouldPrintMeta && versionResult?.version) {
printVersionSummary(versionResult.version);
printClawPack(versionResult.version.clawpack);
printCompatibility(
versionResult.version.compatibility ?? detail.package.compatibility ?? null,
);
printCapabilities(versionResult.version.capabilities ?? detail.package.capabilities ?? null);
printVerification(versionResult.version.verification ?? detail.package.verification ?? null);
} else if (shouldPrintMeta) {
printClawPack(detail.package.clawpack);
printCompatibility(detail.package.compatibility ?? null);
printCapabilities(detail.package.capabilities ?? null);
printVerification(detail.package.verification ?? null);
@@ -536,6 +594,436 @@ export async function cmdPublishPackage(
}
}
export async function cmdDownloadPackage(
opts: GlobalOpts,
packageName: string,
options: PackageDownloadOptions = {},
) {
const trimmed = normalizePackageNameOrFail(packageName);
if (options.version && options.tag) fail("Use either --version or --tag");
const token = await getOptionalAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(
`${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/download`,
registry,
);
if (options.version) url.searchParams.set("version", options.version);
if (options.tag) url.searchParams.set("tag", options.tag);
const spinner = options.json ? null : createSpinner("Downloading Claw Pack");
try {
const response = await fetch(url, {
headers: {
Accept: "application/zip",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!response.ok) {
throw new Error((await response.text()) || `Download failed (${response.status})`);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const sha256 = sha256Hex(bytes);
const filename =
options.output?.trim() ||
filenameFromContentDisposition(response.headers.get("content-disposition")) ||
`${trimmed.replaceAll("/", "-")}.clawpack.zip`;
const outputPath = resolve(opts.workdir, filename);
await mkdir(resolve(outputPath, ".."), { recursive: true }).catch(() => undefined);
await writeFile(outputPath, bytes);
spinner?.succeed(`Downloaded ${outputPath}`);
const result = {
path: outputPath,
bytes: bytes.byteLength,
sha256,
clawpackSha256: response.headers.get("x-clawhub-clawpack-sha256"),
specVersion: response.headers.get("x-clawhub-clawpack-spec-version"),
};
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
async function resolvePackageClawPackVersion(
registry: string,
packageName: string,
token: string | null,
options: PackageClawPackInspectOptions,
) {
if (options.version?.trim()) return options.version.trim();
const detail = await apiRequest(
registry,
{
method: "GET",
path: `${ApiRoutes.packages}/${encodeURIComponent(packageName)}`,
...(token ? { token } : {}),
},
ApiV1PackageResponseSchema,
);
if (!detail.package) fail(`Package not found: ${packageName}`);
const version = detail.package.latestVersion;
if (!version) fail(`Package has no published versions: ${packageName}`);
return version;
}
export async function cmdInspectPackageClawPack(
opts: GlobalOpts,
packageName: string,
options: PackageClawPackInspectOptions = {},
) {
const trimmed = normalizePackageNameOrFail(packageName);
const token = (await getOptionalAuthToken()) ?? null;
const registry = await getRegistry(opts, { cache: true });
const version = await resolvePackageClawPackVersion(registry, trimmed, token, options);
const response = await apiRequest<PackageClawPackInspectResponse>(registry, {
method: "GET",
path: `${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(version)}/clawpack${
options.manifest ? "/manifest" : ""
}`,
...(token ? { token } : {}),
});
if (options.json) {
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
return;
}
if (options.manifest) {
process.stdout.write(`${JSON.stringify(response.manifest ?? {}, null, 2)}\n`);
return;
}
const responseVersion =
typeof response.version === "string" ? response.version : response.version.version;
console.log(`${response.package.name}@${responseVersion}`);
printClawPack(response.clawpack);
if (response.links?.download) console.log(`Claw Pack Download: ${response.links.download}`);
if (response.links?.immutable)
console.log(`Claw Pack Immutable URL: ${response.links.immutable}`);
if (response.links?.manifest) console.log(`Claw Pack Manifest URL: ${response.links.manifest}`);
}
export async function cmdVerifyPackageClawPack(
filePath: string,
options: PackageVerifyOptions = {},
) {
const bytes = new Uint8Array(await readFile(resolve(filePath)));
const sha256 = sha256Hex(bytes);
const zipEntries = unzipSync(bytes);
const manifestBytes = zipEntries["package/CLAWPACK.json"];
if (!manifestBytes) fail("Missing package/CLAWPACK.json");
const manifestText = new TextDecoder().decode(manifestBytes);
const manifest = JSON.parse(manifestText) as Record<string, unknown>;
const expected = options.sha256?.trim();
const ok = expected ? sha256 === expected : true;
const result = {
ok,
sha256,
expectedSha256: expected || null,
specVersion: manifest.specVersion ?? null,
package: manifest.package ?? null,
fileCount: Object.keys(zipEntries).length,
};
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log(`Claw Pack: ${ok ? "ok" : "mismatch"}`);
console.log(`SHA-256: ${sha256}`);
if (expected) console.log(`Expected: ${expected}`);
console.log(`Spec: ${formatUnknownScalar(manifest.specVersion)}`);
if (!ok) fail("Claw Pack digest mismatch");
}
export async function cmdPackageClawPackMigrationStatus(
opts: GlobalOpts,
options: PackageClawPackMigrationOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-status`, registry);
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
url.searchParams.set("limit", String(options.limit));
}
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "GET",
url: url.toString(),
token,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration");
console.log(`Missing sample: ${formatUnknownScalar(result.missingSampleSize)}`);
console.log(`Open failures: ${formatUnknownScalar(result.failureSampleSize)}`);
console.log(`Generated sample: ${formatUnknownScalar(result.generatedClawPackSampleSize)}`);
console.log(`Generated bytes: ${formatUnknownScalar(result.generatedClawPackBytes)}`);
}
export async function cmdPackageClawPackMigrationReadiness(
opts: GlobalOpts,
options: Pick<PackageClawPackMigrationOptions, "json"> = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<{
items?: Array<{
bundledPluginId?: string;
displayName?: string;
desiredPackageName?: string;
readinessState?: string;
blockers?: string[];
}>;
readyCount?: number;
blockedCount?: number;
generatedAt?: number;
}>(registry, {
method: "GET",
path: `${ApiRoutes.packages}/clawpack/migration-readiness`,
token,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration readiness");
console.log(`Ready: ${formatUnknownScalar(result.readyCount)}`);
console.log(`Blocked: ${formatUnknownScalar(result.blockedCount)}`);
for (const item of result.items ?? []) {
const blockers = item.blockers?.length ? ` [${item.blockers.join(", ")}]` : "";
console.log(
`${item.bundledPluginId ?? "unknown"}: ${item.readinessState ?? "unknown"} -> ${
item.desiredPackageName ?? item.displayName ?? "unknown"
}${blockers}`,
);
}
}
export async function cmdPackageClawPackMigrationDryRun(
opts: GlobalOpts,
options: PackageClawPackMigrationRunOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-runs/dry-run`, registry);
url.searchParams.set("operation", options.operation?.trim() || "artifact-backfill");
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
url.searchParams.set("limit", String(options.limit));
}
if (options.cursor?.trim()) url.searchParams.set("cursor", options.cursor.trim());
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "GET",
url: url.toString(),
token,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration dry-run");
console.log(`Operation: ${formatUnknownScalar(result.operation)}`);
console.log(`Candidates: ${formatUnknownScalar(result.candidateCount)}`);
console.log(`Open failures: ${formatUnknownScalar(result.failureCount)}`);
console.log(`Next cursor: ${formatUnknownScalar(result.continueCursor)}`);
console.log(`Done: ${formatUnknownScalar(result.isDone)}`);
}
export async function cmdPackageClawPackMigrationRuns(
opts: GlobalOpts,
options: PackageClawPackMigrationRunOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-runs`, registry);
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
url.searchParams.set("limit", String(options.limit));
}
if (options.status?.trim()) url.searchParams.set("status", options.status.trim());
const result = await apiRequest<
{ items?: Array<Record<string, unknown>> } & Record<string, unknown>
>(registry, {
method: "GET",
url: url.toString(),
token,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration runs");
for (const run of result.items ?? []) {
console.log(formatClawPackMigrationRunLine(run));
}
}
export async function cmdPackageClawPackMigrationRunCreate(
opts: GlobalOpts,
options: PackageClawPackMigrationRunOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/clawpack/migration-runs`,
token,
body: {
operation: options.operation?.trim() || "artifact-backfill",
...(typeof options.limit === "number" && Number.isFinite(options.limit)
? { limit: options.limit }
: {}),
...(options.cursor?.trim() ? { cursor: options.cursor.trim() } : {}),
},
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration run created");
console.log(formatClawPackMigrationRunLine(result));
}
export async function cmdPackageClawPackMigrationRunContinue(
opts: GlobalOpts,
runId: string,
options: Pick<PackageClawPackMigrationRunOptions, "json"> = {},
) {
const trimmedRunId = runId.trim();
if (!trimmedRunId) fail("Run id required");
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<{
run?: Record<string, unknown> | null;
result?: Record<string, unknown> | null;
error?: string;
}>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/clawpack/migration-runs/${encodeURIComponent(trimmedRunId)}/continue`,
token,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack migration run continued");
if (result.run) console.log(formatClawPackMigrationRunLine(result.run));
if (result.result) {
console.log(`Processed: ${formatUnknownScalar(result.result.processed)}`);
console.log(`Succeeded: ${formatUnknownScalar(result.result.succeeded)}`);
console.log(`Failed: ${formatUnknownScalar(result.result.failed)}`);
console.log(`Next cursor: ${formatUnknownScalar(result.result.continueCursor)}`);
}
if (result.error) console.log(`Error: ${result.error}`);
}
export async function cmdPackageClawPackBackfill(
opts: GlobalOpts,
options: PackageClawPackMigrationOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/clawpack/backfill`,
token,
body:
typeof options.limit === "number" && Number.isFinite(options.limit)
? { limit: options.limit }
: {},
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack backfill");
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
}
export async function cmdPackageClawPackRetryFailures(
opts: GlobalOpts,
options: PackageClawPackMigrationOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/clawpack/retry-failures`,
token,
body:
typeof options.limit === "number" && Number.isFinite(options.limit)
? { limit: options.limit }
: {},
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack failure retry");
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
}
export async function cmdPackageClawPackIndexBackfill(
opts: GlobalOpts,
options: PackageClawPackMigrationOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/clawpack/index-backfill`,
token,
body: {
...(typeof options.limit === "number" && Number.isFinite(options.limit)
? { limit: options.limit }
: {}),
...(options.cursor?.trim() ? { cursor: options.cursor.trim() } : {}),
},
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack index backfill");
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
console.log(`Next cursor: ${formatUnknownScalar(result.continueCursor)}`);
console.log(`Done: ${formatUnknownScalar(result.isDone)}`);
}
export async function cmdPackageClawPackRevoke(
opts: GlobalOpts,
packageName: string,
version: string,
options: PackageClawPackRevokeOptions = {},
) {
const trimmed = normalizePackageNameOrFail(packageName);
const trimmedVersion = version.trim();
if (!trimmedVersion) fail("Version required");
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const reason = options.reason?.trim();
const result = await apiRequest<Record<string, unknown>>(registry, {
method: "POST",
path: `${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(trimmedVersion)}/clawpack/revoke`,
token,
body: reason ? { reason } : {},
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log("Claw Pack revoked");
console.log(`Package: ${trimmed}`);
console.log(`Version: ${formatUnknownScalar(result.version ?? trimmedVersion)}`);
console.log(`SHA-256: ${formatUnknownScalar(result.sha256)}`);
console.log(`Revoked artifacts: ${formatUnknownScalar(result.revokedArtifactCount)}`);
}
async function apiRequestPackageDetail(registry: string, name: string, token?: string) {
return await apiRequest(
registry,
@@ -599,6 +1087,23 @@ function clampLimit(value: number, max: number) {
return Math.max(1, Math.min(Math.round(value), max));
}
function formatUnknownScalar(value: unknown) {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
if (typeof value === "boolean") return String(value);
return "unknown";
}
function formatClawPackMigrationRunLine(run: Record<string, unknown>) {
const id = formatUnknownScalar(run._id);
const operation = formatUnknownScalar(run.operation);
const status = formatUnknownScalar(run.status);
const processed = formatUnknownScalar(run.processed);
const failed = formatUnknownScalar(run.failed);
const cursor = formatUnknownScalar(run.continueCursor ?? run.cursor);
return `${id} ${operation} ${status} processed=${processed} failed=${failed} cursor=${cursor}`;
}
function formatPackageLine(item: {
name: string;
displayName: string;
@@ -702,6 +1207,21 @@ function printVerification(verification: PackageVerificationSummary | null | und
if (verification.scanStatus) console.log(`Scan: ${verification.scanStatus}`);
}
function printClawPack(clawpack: PackageClawPackSummary | null | undefined) {
if (!clawpack) return;
console.log(`Claw Pack: ${clawpack.available ? "available" : "unavailable"}`);
if (!clawpack.available) return;
if (clawpack.sha256) console.log(`Claw Pack SHA-256: ${clawpack.sha256}`);
if (typeof clawpack.size === "number") console.log(`Claw Pack Size: ${clawpack.size}B`);
if (clawpack.specVersion) console.log(`Claw Pack Spec: v${clawpack.specVersion}`);
if (clawpack.hostTargets?.length) {
const targets = clawpack.hostTargets
.map((target) => [target.os, target.arch, target.libc].filter(Boolean).join("-"))
.join(", ");
console.log(`Claw Pack Targets: ${targets}`);
}
}
function normalizeTags(tags: unknown): Record<string, string> {
if (!tags || typeof tags !== "object") return {};
const resolved: Record<string, string> = {};
@@ -758,6 +1278,15 @@ function formatTimestamp(value: number) {
return new Date(value).toISOString();
}
function sha256Hex(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex");
}
function filenameFromContentDisposition(value: string | null) {
const match = value?.match(/filename="([^"]+)"/i);
return match?.[1] ? basename(match[1]) : null;
}
async function readJsonFile(path: string) {
try {
const raw = await readFile(path, "utf8");
@@ -835,18 +1364,25 @@ async function preparePackagePublishPlan(
throw error;
}
} else {
const folderStat = await stat(folder).catch(() => null);
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
const localGitInfo = resolveLocalGitInfo(folder);
if (localGitInfo) {
inferredSource = {
repo: localGitInfo.repo,
commit: localGitInfo.commit,
ref: localGitInfo.ref,
path: localGitInfo.path,
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
};
const sourceStat = await stat(folder).catch(() => null);
if (!sourceStat) fail("Path must be a folder or package archive");
if (sourceStat.isFile()) {
const extracted = await extractPackageArchive(folder);
folder = extracted.folder;
cleanup = extracted.cleanup;
} else if (sourceStat.isDirectory()) {
const localGitInfo = resolveLocalGitInfo(folder);
if (localGitInfo) {
inferredSource = {
repo: localGitInfo.repo,
commit: localGitInfo.commit,
ref: localGitInfo.ref,
path: localGitInfo.path,
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
};
}
} else {
fail("Path must be a folder or package archive");
}
}
@@ -1100,7 +1636,7 @@ function describePublishSource(
sourceInput.path !== "." ? `:${sourceInput.path}` : ""
}`;
}
return `local:${folder}`;
return `local:${sourceInput.path || folder}`;
}
function printPackageDryRun(params: {
@@ -1144,6 +1680,43 @@ function formatByteCount(value: number) {
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
async function extractPackageArchive(archivePath: string) {
const archiveName = basename(archivePath);
const lower = archiveName.toLowerCase();
const bytes = new Uint8Array(await readFile(archivePath));
const entries = lower.endsWith(".zip")
? Object.entries(unzipSync(bytes)).map(([path, data]) => ({ path, data }))
: lower.endsWith(".tgz") || lower.endsWith(".tar.gz")
? untar(gunzipSync(bytes))
: fail("Path must be a folder or .zip/.tgz package archive");
const normalizedEntries = stripSingleArchiveRoot(entries);
if (normalizedEntries.length === 0) fail("Package archive does not contain any files");
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-package-publish-"));
try {
for (const entry of normalizedEntries) {
const relPath = normalizeArchivePath(entry.path);
if (!relPath) continue;
const destination = resolve(tempDir, relPath);
if (!destination.startsWith(`${tempDir}${sep}`)) {
throw new Error(`Unsafe archive path: ${entry.path}`);
}
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, entry.data);
}
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
return {
folder: tempDir,
cleanup: async () => {
await rm(tempDir, { recursive: true, force: true });
},
};
}
async function listPackageFiles(root: string) {
const files: PackageFile[] = [];
const absRoot = resolve(root);
@@ -1164,6 +1737,35 @@ async function listPackageFiles(root: string) {
return files;
}
function stripSingleArchiveRoot(entries: Array<{ path: string; data: Uint8Array }>) {
const normalized = entries
.map((entry) => ({ path: normalizeArchivePath(entry.path), data: entry.data }))
.filter((entry) => entry.path && !entry.path.endsWith("/"));
const partsList = normalized.map((entry) => entry.path.split("/").filter(Boolean));
if (partsList.length === 0 || partsList.some((parts) => parts.length < 2)) return normalized;
const first = partsList[0]?.[0];
if (!first || !partsList.every((parts) => parts[0] === first)) return normalized;
return normalized.map((entry) => ({
path: entry.path.split("/").slice(1).join("/"),
data: entry.data,
}));
}
function normalizeArchivePath(path: string) {
const normalized = path
.replaceAll("\u0000", "")
.replaceAll("\\", "/")
.trim()
.replace(/^\.\/+/, "")
.replace(/^\/+/, "");
const parts = normalized.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) {
throw new Error(`Unsafe archive path: ${path}`);
}
return parts.join("/");
}
function normalizePath(path: string) {
return path
.split(sep)
@@ -1193,3 +1795,33 @@ async function addIgnoreFile(ig: ReturnType<typeof ignore>, path: string) {
// optional
}
}
function untar(bytes: Uint8Array) {
const entries: Array<{ path: string; data: Uint8Array }> = [];
let offset = 0;
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const name = readTarString(header.subarray(0, 100));
const size = readTarOctal(header.subarray(124, 136));
const typeflag = header[156];
offset += 512;
const data = bytes.subarray(offset, offset + size);
offset += Math.ceil(size / 512) * 512;
if (!name || typeflag === 53) continue;
entries.push({ path: name, data });
}
return entries;
}
function readTarString(bytes: Uint8Array) {
const end = bytes.indexOf(0);
const slice = end === -1 ? bytes : bytes.subarray(0, end);
return new TextDecoder().decode(slice).trim();
}
function readTarOctal(bytes: Uint8Array) {
const raw = readTarString(bytes).replaceAll("\u0000", "").trim();
if (!raw) return 0;
return Number.parseInt(raw, 8);
}
+46
View File
@@ -115,6 +115,46 @@ export const PackageStaticScanSchema = type({
});
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
export const PackageHostTargetSchema = type({
os: '"darwin"|"linux"|"win32"',
arch: '"arm64"|"x64"',
libc: '"glibc"|"musl"?',
nodeRange: "string?",
openclawRange: "string?",
pluginApiRange: "string?",
supportState: '"supported"|"setup-required"|"unsupported"?',
unsupportedReason: "string?",
});
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
export const PackageEnvironmentSummarySchema = type({
requiresLocalDesktop: "boolean?",
requiresBrowser: "boolean?",
requiresAudioDevice: "boolean?",
requiresNetwork: "boolean?",
requiresExternalServices: "string[]?",
requiresOsPermissions: "string[]?",
supportsRemoteHost: "boolean?",
knownUnsupported: "string[]?",
});
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
export const PackageClawPackSummarySchema = type({
available: "boolean",
specVersion: "number|null",
format: "string|null",
sha256: "string|null",
size: "number|null",
fileCount: "number|null",
manifestSha256: "string|null",
builtAt: "number|null",
buildVersion: "string|null",
hostTargets: PackageHostTargetSchema.array(),
environment: PackageEnvironmentSummarySchema.or("null"),
runtimeBundles: "unknown[]",
});
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
export const BundlePublishMetadataSchema = type({
id: "string?",
format: "string?",
@@ -164,6 +204,10 @@ export const PackageListItemSchema = type({
capabilityTags: "string[]?",
executesCode: "boolean?",
verificationTier: PackageVerificationTierSchema.or("null").optional(),
clawpackAvailable: "boolean?",
hostTargetKeys: "string[]?",
environmentFlags: "string[]?",
clawpack: PackageClawPackSummarySchema.optional(),
});
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
@@ -196,6 +240,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
@@ -234,6 +279,7 @@ export const ApiV1PackageVersionResponseSchema = type({
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
staticScan: PackageStaticScanSchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
}).or("null"),
});
+1
View File
@@ -18,6 +18,7 @@ export const ApiRoutes = {
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
packages: "/api/v1/packages",
clawpacks: "/api/v1/clawpacks",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
stars: "/api/v1/stars",
+224
View File
@@ -110,6 +110,61 @@ export declare const PackageStaticScanSchema: import("arktype/internal/variants/
checkedAt: number;
}, {}>;
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
export declare const PackageHostTargetSchema: import("arktype/internal/variants/object.ts").ObjectType<{
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}, {}>;
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
export declare const PackageEnvironmentSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
}, {}>;
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
export declare const PackageClawPackSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
}, {}>;
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
export declare const BundlePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
id?: string | undefined;
format?: string | undefined;
@@ -174,6 +229,41 @@ export declare const PackageListItemSchema: import("arktype/internal/variants/ob
capabilityTags?: string[] | undefined;
executesCode?: boolean | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
clawpackAvailable?: boolean | undefined;
hostTargetKeys?: string[] | undefined;
environmentFlags?: string[] | undefined;
clawpack?: {
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
} | undefined;
}, {}>;
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
export declare const ApiV1PackageListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -192,6 +282,41 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
capabilityTags?: string[] | undefined;
executesCode?: boolean | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
clawpackAvailable?: boolean | undefined;
hostTargetKeys?: string[] | undefined;
environmentFlags?: string[] | undefined;
clawpack?: {
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
} | undefined;
}[];
nextCursor: string | null;
}, {}>;
@@ -214,6 +339,41 @@ export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/
capabilityTags?: string[] | undefined;
executesCode?: boolean | undefined;
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
clawpackAvailable?: boolean | undefined;
hostTargetKeys?: string[] | undefined;
environmentFlags?: string[] | undefined;
clawpack?: {
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
} | undefined;
};
}[];
}, {}>;
@@ -268,6 +428,38 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
hasProvenance?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
} | null | undefined;
clawpack?: {
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
} | undefined;
stats?: {
downloads: number;
installs: number;
@@ -379,6 +571,38 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
engineVersion: string;
checkedAt: number;
} | null | undefined;
clawpack?: {
available: boolean;
specVersion: number | null;
format: string | null;
sha256: string | null;
size: number | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets: {
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl" | undefined;
nodeRange?: string | undefined;
openclawRange?: string | undefined;
pluginApiRange?: string | undefined;
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
unsupportedReason?: string | undefined;
}[];
environment: {
requiresLocalDesktop?: boolean | undefined;
requiresBrowser?: boolean | undefined;
requiresAudioDevice?: boolean | undefined;
requiresNetwork?: boolean | undefined;
requiresExternalServices?: string[] | undefined;
requiresOsPermissions?: string[] | undefined;
supportsRemoteHost?: boolean | undefined;
knownUnsupported?: string[] | undefined;
} | null;
runtimeBundles: unknown[];
} | undefined;
} | null;
}, {}>;
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
+40
View File
@@ -86,6 +86,40 @@ export const PackageStaticScanSchema = type({
engineVersion: "string",
checkedAt: "number",
});
export const PackageHostTargetSchema = type({
os: '"darwin"|"linux"|"win32"',
arch: '"arm64"|"x64"',
libc: '"glibc"|"musl"?',
nodeRange: "string?",
openclawRange: "string?",
pluginApiRange: "string?",
supportState: '"supported"|"setup-required"|"unsupported"?',
unsupportedReason: "string?",
});
export const PackageEnvironmentSummarySchema = type({
requiresLocalDesktop: "boolean?",
requiresBrowser: "boolean?",
requiresAudioDevice: "boolean?",
requiresNetwork: "boolean?",
requiresExternalServices: "string[]?",
requiresOsPermissions: "string[]?",
supportsRemoteHost: "boolean?",
knownUnsupported: "string[]?",
});
export const PackageClawPackSummarySchema = type({
available: "boolean",
specVersion: "number|null",
format: "string|null",
sha256: "string|null",
size: "number|null",
fileCount: "number|null",
manifestSha256: "string|null",
builtAt: "number|null",
buildVersion: "string|null",
hostTargets: PackageHostTargetSchema.array(),
environment: PackageEnvironmentSummarySchema.or("null"),
runtimeBundles: "unknown[]",
});
export const BundlePublishMetadataSchema = type({
id: "string?",
format: "string?",
@@ -129,6 +163,10 @@ export const PackageListItemSchema = type({
capabilityTags: "string[]?",
executesCode: "boolean?",
verificationTier: PackageVerificationTierSchema.or("null").optional(),
clawpackAvailable: "boolean?",
hostTargetKeys: "string[]?",
environmentFlags: "string[]?",
clawpack: PackageClawPackSummarySchema.optional(),
});
export const ApiV1PackageListResponseSchema = type({
items: PackageListItemSchema.array(),
@@ -157,6 +195,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
@@ -193,6 +232,7 @@ export const ApiV1PackageVersionResponseSchema = type({
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
staticScan: PackageStaticScanSchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
}).or("null"),
});
export const ApiV1PackagePublishResponseSchema = type({
File diff suppressed because one or more lines are too long
+1
View File
@@ -18,6 +18,7 @@ export declare const ApiRoutes: {
readonly skills: "/api/v1/skills";
readonly plugins: "/api/v1/plugins";
readonly packages: "/api/v1/packages";
readonly clawpacks: "/api/v1/clawpacks";
readonly codePlugins: "/api/v1/code-plugins";
readonly bundlePlugins: "/api/v1/bundle-plugins";
readonly stars: "/api/v1/stars";
+1
View File
@@ -18,6 +18,7 @@ export const ApiRoutes = {
skills: "/api/v1/skills",
plugins: "/api/v1/plugins",
packages: "/api/v1/packages",
clawpacks: "/api/v1/clawpacks",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
stars: "/api/v1/stars",
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
+46
View File
@@ -115,6 +115,46 @@ export const PackageStaticScanSchema = type({
});
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
export const PackageHostTargetSchema = type({
os: '"darwin"|"linux"|"win32"',
arch: '"arm64"|"x64"',
libc: '"glibc"|"musl"?',
nodeRange: "string?",
openclawRange: "string?",
pluginApiRange: "string?",
supportState: '"supported"|"setup-required"|"unsupported"?',
unsupportedReason: "string?",
});
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
export const PackageEnvironmentSummarySchema = type({
requiresLocalDesktop: "boolean?",
requiresBrowser: "boolean?",
requiresAudioDevice: "boolean?",
requiresNetwork: "boolean?",
requiresExternalServices: "string[]?",
requiresOsPermissions: "string[]?",
supportsRemoteHost: "boolean?",
knownUnsupported: "string[]?",
});
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
export const PackageClawPackSummarySchema = type({
available: "boolean",
specVersion: "number|null",
format: "string|null",
sha256: "string|null",
size: "number|null",
fileCount: "number|null",
manifestSha256: "string|null",
builtAt: "number|null",
buildVersion: "string|null",
hostTargets: PackageHostTargetSchema.array(),
environment: PackageEnvironmentSummarySchema.or("null"),
runtimeBundles: "unknown[]",
});
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
export const BundlePublishMetadataSchema = type({
id: "string?",
format: "string?",
@@ -164,6 +204,10 @@ export const PackageListItemSchema = type({
capabilityTags: "string[]?",
executesCode: "boolean?",
verificationTier: PackageVerificationTierSchema.or("null").optional(),
clawpackAvailable: "boolean?",
hostTargetKeys: "string[]?",
environmentFlags: "string[]?",
clawpack: PackageClawPackSummarySchema.optional(),
});
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
@@ -198,6 +242,7 @@ export const ApiV1PackageResponseSchema = type({
compatibility: PackageCompatibilitySchema.or("null").optional(),
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
verification: PackageVerificationSummarySchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
stats: PackageStatsSchema.optional(),
}).or("null"),
owner: type({
@@ -239,6 +284,7 @@ export const ApiV1PackageVersionResponseSchema = type({
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
staticScan: PackageStaticScanSchema.or("null").optional(),
clawpack: PackageClawPackSummarySchema.optional(),
}).or("null"),
});
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
+1
View File
@@ -19,6 +19,7 @@ export const ApiRoutes = {
skills: "/api/v1/skills",
plugins: "/api/v1/plugins",
packages: "/api/v1/packages",
clawpacks: "/api/v1/clawpacks",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
stars: "/api/v1/stars",
@@ -0,0 +1,182 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
useParams: () => ({ releaseId: "packageReleases:1" }),
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
}));
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import {
Route,
ClawPackReleaseDetailPage,
} from "../routes/management/clawpacks/releases/$releaseId";
function renderRoute() {
render(
createElement(ClawPackReleaseDetailPage as never, {
releaseId: "packageReleases:1",
}),
);
}
describe("Claw Pack release detail route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useQueryMock.mockReturnValue({
package: {
packageId: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: true,
scanStatus: "clean",
updatedAt: Date.UTC(2026, 0, 2),
},
release: {
releaseId: "packageReleases:1",
version: "1.2.3",
createdAt: Date.UTC(2026, 0, 1),
fileCount: 2,
fileSample: [
{ path: "SKILL.md", size: 1200, sha256: "c".repeat(64) },
{ path: "plugin.json", size: 400, sha256: "d".repeat(64) },
],
clawpackStorageId: "storage:1",
clawpackSha256: "a".repeat(64),
clawpackSize: 4096,
clawpackSpecVersion: 1,
clawpackFormat: "zip",
clawpackFileCount: 2,
clawpackManifestSha256: "b".repeat(64),
clawpackBuiltAt: Date.UTC(2026, 0, 3),
clawpackBuildVersion: "clawhub-clawpack-v1",
clawpackRevokedAt: null,
clawpackRevocationReason: null,
hostTargetsSummary: [{ os: "darwin", arch: "arm64" }],
environmentSummary: { requiresNetwork: true, requiresExternalServices: ["opik"] },
source: {
kind: "git",
repo: "openclaw/demo-plugin",
url: null,
ref: "refs/tags/v1.2.3",
commit: "abcdef1234567890",
path: ".",
},
verificationScanStatus: "clean",
vtStatus: "clean",
vtVerdict: "clean",
llmStatus: "clean",
llmVerdict: "clean",
staticScanStatus: "clean",
staticScanSummary: "No findings",
staticScanReasonCodes: [],
},
artifacts: [
{
artifactId: "packageReleaseArtifacts:1",
kind: "clawpack",
targetKey: null,
storageId: "storage:1",
sha256: "a".repeat(64),
size: 4096,
format: "zip",
status: "active",
createdAt: Date.UTC(2026, 0, 3),
revokedAt: null,
revocationReason: null,
},
],
failures: [
{
failureId: "packageClawPackBackfillFailures:1",
error: "previous zip build failed",
attemptCount: 2,
firstFailedAt: Date.UTC(2026, 0, 1),
lastAttemptAt: Date.UTC(2026, 0, 2),
lastFailedAt: Date.UTC(2026, 0, 2),
resolvedAt: Date.UTC(2026, 0, 3),
},
],
searchIndexRows: [
{
rowId: "packageClawPackSearchIndex:1",
kind: "host-target",
key: "darwin-arm64",
updatedAt: Date.UTC(2026, 0, 3),
createdAt: Date.UTC(2026, 0, 3),
},
],
});
});
it("registers the release drilldown route", () => {
expect(Route).toBeTruthy();
});
it("renders release artifact, failure, index, and provenance evidence", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "Claw Pack release detail" })).toBeTruthy();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(screen.getByText("demo-plugin")).toBeTruthy();
expect(screen.getByText("Artifact rows")).toBeTruthy();
expect(screen.getByText("Failure ledger")).toBeTruthy();
expect(screen.getByText("Lookup index")).toBeTruthy();
expect(screen.getAllByText("previous zip build failed").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("darwin-arm64").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("service:opik")).toBeTruthy();
expect(
screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / . / abcdef123456"),
).toBeTruthy();
expect(screen.getByText("SKILL.md")).toBeTruthy();
});
it("blocks non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,233 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
}),
Outlet: () => <div data-testid="outlet" />,
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
useRouterState: () => "/management/clawpacks",
}));
const useQueryMock = vi.fn();
const useActionMock = vi.fn();
const useMutationMock = vi.fn();
const useAuthStatusMock = vi.fn();
const startMigrationRun = vi.fn();
const continueMigrationRun = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => useActionMock(),
useMutation: () => useMutationMock(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { Route, ClawPackManagementRoute } from "../routes/management/clawpacks";
function renderRoute() {
render(createElement(ClawPackManagementRoute as never));
}
describe("Claw Pack management route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useActionMock.mockReset();
useMutationMock.mockReset();
useAuthStatusMock.mockReset();
startMigrationRun.mockReset();
continueMigrationRun.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:admin", role: "admin" },
});
useActionMock.mockReturnValue(continueMigrationRun);
useMutationMock.mockReturnValue(startMigrationRun);
const migrationStatus = {
missingSample: [
{
releaseId: "packageReleases:1",
packageId: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
createdAt: Date.UTC(2026, 0, 1),
fileCount: 3,
},
],
failureSample: [
{
failureId: "packageClawPackBackfillFailures:1",
releaseId: "packageReleases:2",
packageId: "packages:2",
name: "broken-plugin",
version: "0.1.0",
error: "Invalid Claw Pack file path",
attemptCount: 2,
firstFailedAt: Date.UTC(2026, 0, 1),
lastAttemptAt: Date.UTC(2026, 0, 2),
lastFailedAt: Date.UTC(2026, 0, 2),
},
],
missingSampleSize: 1,
failureSampleSize: 1,
generatedClawPackSampleSize: 3,
generatedClawPackBytes: 4096,
sampleLimit: 25,
};
const migrationRunList = {
items: [
{
_id: "clawPackMigrationRuns:1",
actorUserId: "users:admin",
operation: "failure-retry",
status: "pending",
limit: 10,
processed: 0,
generated: 0,
skipped: 0,
failed: 0,
bytesGenerated: 0,
failureCounts: {},
createdAt: Date.UTC(2026, 0, 3),
updatedAt: Date.UTC(2026, 0, 3),
actor: { userId: "users:admin", handle: "admin", name: "Admin", role: "admin" },
},
],
limit: 12,
status: null,
hasMore: false,
};
const dryRunResult = {
operation: "artifact-backfill",
limit: 10,
cursor: null,
continueCursor: null,
isDone: false,
candidates: migrationStatus.missingSample,
candidateCount: 1,
failureCount: 1,
};
useQueryMock.mockImplementation((_ref: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "operation" in args) return dryRunResult;
if (args && typeof args === "object" && "limit" in args) return migrationRunList;
return migrationStatus;
});
});
it("registers the dedicated management route", () => {
expect(Route).toBeTruthy();
});
it("renders migration status and dry-run sample rows", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "Claw Pack operations" })).toBeTruthy();
expect(screen.getByText("Plugin operations")).toBeTruthy();
expect(screen.getByText("/publish-plugin")).toBeTruthy();
expect(screen.getByText("/management/moderation")).toBeTruthy();
expect(screen.getByText("/management/migrations")).toBeTruthy();
expect(screen.getByText("75%")).toBeTruthy();
expect(screen.getByText("4.0KB")).toBeTruthy();
expect(screen.getByText("Failed artifact builds")).toBeTruthy();
expect(screen.getByText(/Invalid Claw Pack file path/)).toBeTruthy();
expect(screen.getByText("Create migration run")).toBeTruthy();
expect(screen.getByText("Run next batch")).toBeTruthy();
expect(screen.getByText("Migration runs")).toBeTruthy();
expect(screen.getAllByText("Details").length).toBeGreaterThanOrEqual(2);
fireEvent.click(screen.getByRole("button", { name: "Dry-run operation" }));
expect(screen.getAllByText("Demo Plugin").length).toBeGreaterThan(0);
expect(screen.getAllByText(/demo-plugin@1\.2\.3/i).length).toBeGreaterThan(0);
});
it("hides write buttons for moderators without admin role", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
renderRoute();
expect(screen.getByText("read only")).toBeTruthy();
expect(screen.queryByText("Create migration run")).toBeNull();
expect(screen.queryByText("Run next batch")).toBeNull();
});
it("explains missing management role for non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(screen.getByText(/role user/i)).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
it("confirms and creates migration runs for admins", async () => {
startMigrationRun.mockResolvedValueOnce({
_id: "clawPackMigrationRuns:2",
operation: "artifact-backfill",
status: "pending",
});
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
renderRoute();
fireEvent.click(screen.getByRole("button", { name: "Create migration run" }));
expect(window.confirm).toHaveBeenCalledWith(
expect.stringContaining("Create a artifact backfill migration run"),
);
expect(startMigrationRun).toHaveBeenCalledWith({ operation: "artifact-backfill", limit: 10 });
expect(await screen.findByText(/created clawPackMigrationRuns:2/i)).toBeTruthy();
});
it("confirms and continues migration run batches for admins", async () => {
continueMigrationRun.mockResolvedValueOnce({
run: {
_id: "clawPackMigrationRuns:1",
operation: "failure-retry",
status: "completed",
},
result: { processed: 1, succeeded: 1, failed: 0 },
});
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
renderRoute();
fireEvent.click(screen.getByRole("button", { name: "Run next batch" }));
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("Run next failure retry"));
expect(continueMigrationRun).toHaveBeenCalledWith({ runId: "clawPackMigrationRuns:1" });
expect(await screen.findByText(/processed 1 - succeeded 1 - failed 0/i)).toBeTruthy();
});
});
@@ -0,0 +1,145 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
useParams: () => ({ bundledPluginId: "opik" }),
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
}));
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import {
OfficialMigrationDetailPage,
Route,
} from "../routes/management/migrations/$bundledPluginId";
function renderDetail(bundledPluginId = "opik") {
render(createElement(OfficialMigrationDetailPage as never, { bundledPluginId }));
}
describe("official migration readiness detail route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useQueryMock.mockReturnValue({
readyCount: 0,
blockedCount: 1,
generatedAt: Date.UTC(2026, 4, 1),
items: [
{
bundledPluginId: "opik",
displayName: "Opik",
desiredPackageName: "@opik/opik-openclaw",
publisherHandle: "opik",
sourceRepo: "comet-ml/opik-openclaw",
sourcePath: "packages/openclaw",
sourceCommit: "abc1234567890",
sourceRef: null,
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
readinessState: "metadata-incomplete",
blockers: ["environment-metadata-incomplete"],
gates: {
packageExists: true,
releaseExists: true,
clawpackAvailable: true,
hostMatrixComplete: true,
environmentComplete: false,
sourceLinked: true,
scanClear: true,
runtimeBundleStatus: "not-required",
},
package: {
packageId: "packages:1",
name: "@opik/opik-openclaw",
displayName: "Opik",
family: "code-plugin",
runtimeId: "opik",
channel: "official",
isOfficial: true,
scanStatus: "clean",
updatedAt: Date.UTC(2026, 4, 1),
},
latestRelease: {
releaseId: "packageReleases:1",
version: "1.0.0",
createdAt: Date.UTC(2026, 4, 1),
clawpackSha256: "b".repeat(64),
clawpackFileCount: 8,
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
environmentFlags: [],
scanStatus: "clean",
},
},
],
});
});
it("registers the migration candidate route", () => {
expect(Route).toBeTruthy();
});
it("renders one candidate with gate evidence and blockers", () => {
renderDetail();
expect(screen.getByRole("heading", { name: "Migration candidate" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Opik" })).toBeTruthy();
expect(screen.getByText("metadata incomplete")).toBeTruthy();
expect(screen.getByText("8 files / bbbbbbbbbbbb")).toBeTruthy();
expect(screen.getByText("packages/openclaw")).toBeTruthy();
expect(screen.getByText("Environment complete")).toBeTruthy();
expect(screen.getAllByText("blocked").length).toBeGreaterThan(0);
expect(screen.getByText("environment metadata incomplete")).toBeTruthy();
});
it("shows a not found state for unknown candidates", () => {
renderDetail("missing");
expect(screen.getByText("not found")).toBeTruthy();
expect(screen.getByText("missing")).toBeTruthy();
});
it("explains missing management role for non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderDetail();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,190 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
}),
Outlet: () => <div data-testid="outlet" />,
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
useRouterState: () => "/management/migrations",
}));
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { OfficialMigrationRoute, Route } from "../routes/management/migrations";
function renderRoute() {
render(createElement(OfficialMigrationRoute as never));
}
describe("official migration readiness route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useQueryMock.mockReturnValue({
readyCount: 1,
blockedCount: 1,
generatedAt: Date.UTC(2026, 4, 1),
items: [
{
bundledPluginId: "opik",
displayName: "Opik",
desiredPackageName: "@opik/opik-openclaw",
publisherHandle: "opik",
sourceRepo: "comet-ml/opik-openclaw",
sourcePath: ".",
sourceCommit: "abc1234567890",
sourceRef: null,
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
readinessState: "ready-for-openclaw",
blockers: [],
gates: {
packageExists: true,
releaseExists: true,
clawpackAvailable: true,
hostMatrixComplete: true,
environmentComplete: true,
sourceLinked: true,
scanClear: true,
runtimeBundleStatus: "not-required",
},
package: {
packageId: "packages:1",
name: "@opik/opik-openclaw",
displayName: "Opik",
family: "code-plugin",
runtimeId: "opik",
channel: "official",
isOfficial: true,
scanStatus: "clean",
updatedAt: Date.UTC(2026, 4, 1),
},
latestRelease: {
releaseId: "packageReleases:1",
version: "1.0.0",
createdAt: Date.UTC(2026, 4, 1),
clawpackSha256: "a".repeat(64),
clawpackFileCount: 5,
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
environmentFlags: ["network"],
scanStatus: "clean",
},
},
{
bundledPluginId: "qqbot",
displayName: "QQbot",
desiredPackageName: "@tencent-connect/openclaw-qqbot",
publisherHandle: "tencent-connect",
sourceRepo: "tencent-connect/openclaw-qqbot",
sourcePath: ".",
sourceCommit: null,
sourceRef: null,
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
readinessState: "clawpack-missing",
blockers: ["clawpack-missing", "source-ref-missing"],
gates: {
packageExists: true,
releaseExists: true,
clawpackAvailable: false,
hostMatrixComplete: false,
environmentComplete: false,
sourceLinked: false,
scanClear: false,
runtimeBundleStatus: "not-required",
},
package: {
packageId: "packages:2",
name: "@tencent-connect/openclaw-qqbot",
displayName: "QQbot",
family: "code-plugin",
runtimeId: "qqbot",
channel: "community",
isOfficial: false,
scanStatus: "pending",
updatedAt: Date.UTC(2026, 4, 1),
},
latestRelease: {
releaseId: "packageReleases:2",
version: "1.0.0",
createdAt: Date.UTC(2026, 4, 1),
clawpackSha256: null,
clawpackFileCount: null,
hostTargetKeys: [],
environmentFlags: [],
scanStatus: "pending",
},
},
],
});
});
it("registers the dedicated migrations route", () => {
expect(Route).toBeTruthy();
});
it("renders readiness counts, gates, blockers, and deep links", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "OpenClaw migration readiness" })).toBeTruthy();
expect(screen.getByText("Plugin operations")).toBeTruthy();
expect(screen.getByText("/publish-plugin")).toBeTruthy();
expect(screen.getByText("/management/moderation")).toBeTruthy();
expect(screen.getByText("/management/clawpacks")).toBeTruthy();
expect(screen.getByText("Opik")).toBeTruthy();
expect(screen.getByText("QQbot")).toBeTruthy();
expect(screen.getByText("ready for openclaw")).toBeTruthy();
expect(screen.getByText("claw pack missing")).toBeTruthy();
expect(screen.getByText("5 files / aaaaaaaaaaaa")).toBeTruthy();
expect(screen.getByText(/claw pack missing, source ref missing/i)).toBeTruthy();
expect(screen.getAllByRole("link", { name: "Plugin page" })[0]?.getAttribute("href")).toBe(
"/plugins/$name",
);
expect(screen.getAllByRole("link", { name: "Details" })[0]?.getAttribute("href")).toBe(
"/management/migrations/$bundledPluginId",
);
});
it("explains missing management role for non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(screen.getByText(/role user/i)).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,187 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
}));
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const useAuthStatusMock = vi.fn();
const setVerdict = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => useMutationMock(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { PluginModerationRoute, Route } from "../routes/management/moderation";
function renderRoute() {
render(createElement(PluginModerationRoute as never));
}
describe("plugin moderation route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useMutationMock.mockReset();
useAuthStatusMock.mockReset();
setVerdict.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useMutationMock.mockReturnValue(setVerdict);
setVerdict.mockResolvedValue({ ok: true });
useQueryMock.mockReturnValue({
status: "needs-review",
limit: 30,
hasMore: false,
counts: {
"needs-review": { value: 3, capped: false },
pending: { value: 1, capped: false },
suspicious: { value: 2, capped: false },
malicious: { value: 0, capped: false },
"not-run": { value: 0, capped: false },
clean: { value: 100, capped: true },
},
items: [
{
packageId: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
ownerHandle: "openclaw",
ownerKind: "org",
summary: "A demo plugin.",
latestVersion: "1.2.3",
runtimeId: "demo.plugin",
executesCode: true,
verificationTier: "structural",
clawpackAvailable: true,
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc"],
environmentFlags: ["network", "desktop"],
scanStatus: "suspicious",
updatedAt: Date.UTC(2026, 0, 2),
latestRelease: {
releaseId: "packageReleases:1",
version: "1.2.3",
createdAt: Date.UTC(2026, 0, 1),
clawpackAvailable: true,
clawpackSha256: "a".repeat(64),
clawpackFileCount: 4,
clawpackSize: 2048,
clawpackManifestSha256: "b".repeat(64),
source: {
kind: "github",
repo: "openclaw/demo-plugin",
url: "https://github.com/openclaw/demo-plugin",
ref: "refs/tags/v1.2.3",
commit: "abc1234567890",
path: ".",
},
verificationScanStatus: "suspicious",
vtStatus: "clean",
vtVerdict: "clean",
llmStatus: "clean",
llmVerdict: "clean",
llmSummary: "No policy findings.",
staticScanStatus: "suspicious",
staticScanSummary: "Dynamic execution found.",
staticScanReasonCodes: ["suspicious.dynamic_code_execution"],
},
},
],
});
});
it("registers the dedicated moderation route", () => {
expect(Route).toBeTruthy();
});
it("renders plugin risk, Claw Pack, and compatibility context", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "Plugin moderation" })).toBeTruthy();
expect(screen.getByText("Plugin operations")).toBeTruthy();
expect(screen.getByText("/publish-plugin")).toBeTruthy();
expect(screen.getByText("/management/clawpacks")).toBeTruthy();
expect(screen.getByText("/management/migrations")).toBeTruthy();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(screen.getAllByText("suspicious").length).toBeGreaterThan(0);
expect(screen.getByText("Code Plugin")).toBeTruthy();
expect(screen.getByText("4 files / aaaaaaaaaaaa")).toBeTruthy();
expect(screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / .")).toBeTruthy();
expect(screen.getByText("suspicious / suspicious.dynamic_code_execution")).toBeTruthy();
expect(screen.getByText("clean / clean / No policy findings.")).toBeTruthy();
expect(screen.getByText("zip aaaaaaaaaaaa / manifest bbbbbbbbbbbb")).toBeTruthy();
expect(screen.getByText("darwin-arm64")).toBeTruthy();
expect(screen.getByText("linux-x64-glibc")).toBeTruthy();
expect(screen.getByText("100+")).toBeTruthy();
expect(screen.getByText("Needs review")).toBeTruthy();
expect(screen.getByRole("link", { name: "Manage" }).getAttribute("href")).toBe("/management");
});
it("requires an audit note and confirms before writing a verdict", async () => {
const prompt = vi.spyOn(window, "prompt").mockReturnValue("reviewed source and artifact");
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderRoute();
fireEvent.click(screen.getByRole("button", { name: "Approve clean" }));
await waitFor(() => {
expect(setVerdict).toHaveBeenCalledWith({
packageId: "packages:1",
verdict: "clean",
note: "reviewed source and artifact",
});
});
expect(prompt).toHaveBeenCalledWith("Audit note for demo-plugin -> clean");
expect(confirm.mock.calls[0]?.[0]).toContain(
"writes a package moderation verdict and audit log in Convex",
);
prompt.mockRestore();
confirm.mockRestore();
});
it("blocks non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(screen.getByText(/role user/i)).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,199 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
useParams: () => ({ name: "demo-plugin" }),
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
}));
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const useAuthStatusMock = vi.fn();
const setBatch = vi.fn();
const setVerdict = vi.fn();
const revokeClawPack = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => useMutationMock(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { PluginManagementDetailPage, Route } from "../routes/management/plugins/$name";
function renderRoute() {
render(createElement(PluginManagementDetailPage as never, { name: "demo-plugin" }));
}
describe("plugin management detail route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useMutationMock.mockReset();
useAuthStatusMock.mockReset();
setBatch.mockReset();
setVerdict.mockReset();
revokeClawPack.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useMutationMock.mockReturnValue((args: Record<string, unknown>) => {
if ("releaseId" in args) return revokeClawPack(args);
if ("verdict" in args) return setVerdict(args);
return setBatch(args);
});
setBatch.mockResolvedValue({ ok: true });
setVerdict.mockResolvedValue({ ok: true });
revokeClawPack.mockResolvedValue({ ok: true });
useQueryMock.mockReturnValue({
package: {
_id: "packages:1",
_creationTime: 1,
name: "demo-plugin",
normalizedName: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
ownerUserId: "users:owner",
summary: "A demo plugin.",
tags: {},
latestReleaseId: "packageReleases:1",
latestVersion: "1.2.3",
executesCode: true,
runtimeId: "demo.plugin",
verification: { tier: "structural" },
scanStatus: "suspicious",
stats: {},
createdAt: Date.UTC(2026, 0, 1),
updatedAt: Date.UTC(2026, 0, 2),
},
latestRelease: {
_id: "packageReleases:1",
_creationTime: 1,
packageId: "packages:1",
version: "1.2.3",
files: [],
tags: ["latest"],
createdAt: Date.UTC(2026, 0, 1),
clawpackStorageId: "storage:1",
clawpackSha256: "a".repeat(64),
clawpackManifestSha256: "b".repeat(64),
clawpackFileCount: 4,
clawpackSize: 4096,
clawpackBuiltAt: Date.UTC(2026, 0, 3),
hostTargetsSummary: [{ os: "darwin", arch: "arm64" }],
environmentSummary: { requiresNetwork: true, requiresExternalServices: ["opik"] },
source: {
repo: "openclaw/demo-plugin",
ref: "refs/tags/v1.2.3",
path: ".",
commit: "abcdef1234567890",
},
verification: { scanStatus: "suspicious" },
staticScan: { status: "suspicious" },
vtAnalysis: { status: "clean" },
llmAnalysis: { status: "clean" },
},
owner: { handle: "openclaw" },
highlighted: null,
});
});
it("registers the dedicated plugin detail route", () => {
expect(Route).toBeTruthy();
});
it("renders package, Claw Pack, and release provenance details", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "Plugin package detail" })).toBeTruthy();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(screen.getByText("demo.plugin")).toBeTruthy();
expect(screen.getByText(/active /)).toBeTruthy();
expect(
screen.getByText("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
).toBeTruthy();
expect(
screen.getByText("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
).toBeTruthy();
expect(screen.getByText("darwin-arm64")).toBeTruthy();
expect(screen.getByText("network, service:opik")).toBeTruthy();
expect(
screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / . / abcdef123456"),
).toBeTruthy();
});
it("confirms and writes moderation verdicts", async () => {
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
renderRoute();
fireEvent.change(screen.getByPlaceholderText("Required"), {
target: { value: "reviewed clawpack and source" },
});
fireEvent.click(screen.getByRole("button", { name: "Save verdict" }));
await waitFor(() => {
expect(setVerdict).toHaveBeenCalledWith({
packageId: "packages:1",
verdict: "suspicious",
note: "reviewed clawpack and source",
});
});
expect(window.confirm).toHaveBeenCalledWith(
expect.stringContaining("writes a package moderation verdict and audit log in Convex"),
);
});
it("requires a revocation reason before Claw Pack writes", async () => {
vi.spyOn(window, "prompt").mockReturnValueOnce("bad artifact");
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
renderRoute();
fireEvent.click(screen.getByRole("button", { name: "Revoke Claw Pack" }));
await waitFor(() => {
expect(revokeClawPack).toHaveBeenCalledWith({
releaseId: "packageReleases:1",
reason: "bad artifact",
});
});
});
it("blocks non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,140 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
}),
Outlet: () => <div data-testid="outlet" />,
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
params?: Record<string, string>;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
useRouterState: () => "/management/plugins",
}));
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { PluginManagementRoute, Route } from "../routes/management/plugins";
function renderRoute() {
render(createElement(PluginManagementRoute as never));
}
describe("plugin management route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
useQueryMock.mockReturnValue({
status: "needs-review",
limit: 30,
hasMore: false,
items: [
{
packageId: "packages:1",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
ownerHandle: "openclaw",
summary: "A demo plugin.",
latestVersion: "1.2.3",
runtimeId: "demo.plugin",
executesCode: true,
verificationTier: "structural",
clawpackAvailable: true,
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc"],
environmentFlags: ["network", "desktop"],
scanStatus: "suspicious",
updatedAt: Date.UTC(2026, 0, 2),
latestRelease: {
releaseId: "packageReleases:1",
version: "1.2.3",
createdAt: Date.UTC(2026, 0, 1),
clawpackAvailable: true,
clawpackSha256: "a".repeat(64),
clawpackFileCount: 4,
source: {
repo: "openclaw/demo-plugin",
ref: "refs/tags/v1.2.3",
path: ".",
},
verificationScanStatus: "suspicious",
},
},
],
});
});
it("registers the dedicated plugin management route", () => {
expect(Route).toBeTruthy();
});
it("renders plugin queue rows with management drilldown links", () => {
renderRoute();
expect(screen.getByRole("heading", { name: "Plugin management" })).toBeTruthy();
expect(screen.getByText("Plugin operations")).toBeTruthy();
expect(screen.getByText("/management/plugins")).toBeTruthy();
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(screen.getByText("suspicious")).toBeTruthy();
expect(screen.getByText("4 files / aaaaaaaaaaaa")).toBeTruthy();
expect(screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / .")).toBeTruthy();
expect(screen.getByText("darwin-arm64, linux-x64-glibc")).toBeTruthy();
expect(screen.getByText("network, desktop")).toBeTruthy();
expect(screen.getByRole("link", { name: "Manage" }).getAttribute("href")).toBe(
"/management/plugins/$name",
);
});
it("updates the query status filter", () => {
renderRoute();
fireEvent.change(screen.getByDisplayValue("Needs review"), { target: { value: "clean" } });
expect(useQueryMock).toHaveBeenLastCalledWith(expect.anything(), {
status: "clean",
limit: 30,
});
});
it("blocks non-staff users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:member", role: "user" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
@@ -0,0 +1,150 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
__path: path,
}),
Link: ({
children,
to,
}: {
children: ReactNode;
to: string;
search?: Record<string, unknown>;
}) => <a href={to}>{children}</a>,
}));
const useQueryMock = vi.fn();
const useMutationMock = vi.fn();
const useAuthStatusMock = vi.fn();
const setRole = vi.fn();
const banUser = vi.fn();
const unbanUser = vi.fn();
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => useMutationMock(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { Route, UserManagementRoute } from "../routes/management/users";
function renderRoute() {
render(createElement(UserManagementRoute as never));
}
describe("user management route", () => {
beforeEach(() => {
useQueryMock.mockReset();
useMutationMock.mockReset();
useAuthStatusMock.mockReset();
setRole.mockReset();
banUser.mockReset();
unbanUser.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:admin", role: "admin" },
});
useMutationMock.mockReturnValue((args: Record<string, unknown>) => {
if ("role" in args) return setRole(args);
if ("reason" in args && args.userId === "users:banned") return unbanUser(args);
return banUser(args);
});
setRole.mockResolvedValue({ ok: true });
banUser.mockResolvedValue({ ok: true });
unbanUser.mockResolvedValue({ ok: true });
useQueryMock.mockReturnValue({
total: 2,
items: [
{
_id: "users:target",
_creationTime: Date.UTC(2026, 0, 1),
handle: "target",
name: "target",
email: "target@example.com",
role: "user",
createdAt: Date.UTC(2026, 0, 1),
},
{
_id: "users:banned",
_creationTime: Date.UTC(2026, 0, 2),
handle: "banned",
name: "banned",
email: "banned@example.com",
role: "moderator",
createdAt: Date.UTC(2026, 0, 2),
deletedAt: Date.UTC(2026, 0, 3),
banReason: "malware",
},
],
});
});
it("registers the dedicated user management route", () => {
expect(Route).toBeTruthy();
});
it("renders role management and confirms moderator writes", async () => {
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
renderRoute();
expect(screen.getByRole("heading", { name: "User roles" })).toBeTruthy();
expect(screen.getByText("/management/users")).toBeTruthy();
expect(screen.getByText("@target")).toBeTruthy();
expect(screen.getByText("malware")).toBeTruthy();
fireEvent.change(screen.getAllByRole("combobox")[0]!, { target: { value: "moderator" } });
await waitFor(() => {
expect(setRole).toHaveBeenCalledWith({
userId: "users:target",
role: "moderator",
});
});
expect(window.confirm).toHaveBeenCalledWith(
expect.stringContaining("Set @target role from user to moderator"),
);
});
it("requires a reason for ban writes", async () => {
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
vi.spyOn(window, "prompt").mockReturnValueOnce("security abuse");
renderRoute();
fireEvent.click(screen.getAllByRole("button", { name: "Ban" })[0]!);
await waitFor(() => {
expect(banUser).toHaveBeenCalledWith({
userId: "users:target",
reason: "security abuse",
});
});
});
it("blocks non-admin users", () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:mod", role: "moderator" },
});
renderRoute();
expect(screen.getByText("Management access required")).toBeTruthy();
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
});
});
+317 -10
View File
@@ -65,6 +65,30 @@ function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
});
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function textResponse(body: string, contentType = "text/plain") {
return new Response(body, {
status: 200,
headers: { "content-type": contentType },
});
}
function byteLength(value: string) {
return new TextEncoder().encode(value).byteLength;
}
function fetchInputUrl(input: RequestInfo | URL) {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function getFileInput() {
const input = document.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
@@ -140,7 +164,7 @@ describe("plugins publish route", () => {
expect(archiveClick).not.toHaveBeenCalled();
});
it("opens only the archive picker when clicking Browse files", () => {
it("opens only the archive picker when clicking Upload ZIP/TGZ", () => {
renderPublishRoute();
const [archiveInput, directoryInput] = getFileInputs();
@@ -149,7 +173,10 @@ describe("plugins publish route", () => {
archiveInput.click = archiveClick;
directoryInput.click = directoryClick;
fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
expect(archiveInput.getAttribute("accept")).toContain(".zip");
expect(archiveInput.getAttribute("accept")).toContain(".tgz");
fireEvent.click(screen.getByRole("button", { name: "Upload ZIP/TGZ" }));
expect(archiveClick).toHaveBeenCalledTimes(1);
expect(directoryClick).not.toHaveBeenCalled();
@@ -232,6 +259,281 @@ describe("plugins publish route", () => {
]),
}),
});
expect(screen.getByText("demo-plugin@1.2.3")).toBeTruthy();
expect(screen.queryByRole("link", { name: "View release" })).toBeNull();
expect(screen.queryByRole("link", { name: "Download Claw Pack" })).toBeNull();
expect(screen.getByRole("link", { name: "Open dashboard" }).getAttribute("href")).toBe(
"/dashboard",
);
});
it("fetches a GitHub URL, fills the form, and publishes the resolved package files", async () => {
const commit = "a".repeat(40);
const tree = "b".repeat(40);
const blob = "c".repeat(40);
const packageJsonBody = makeCodePluginPackageJson({
name: "github-plugin",
displayName: "GitHub Plugin",
version: "2.0.0",
repository: "https://github.com/owner/repo.git",
});
const pluginManifestBody = '{"id":"github.plugin","name":"GitHub Plugin"}';
const sourceBody = "export const githubPlugin = true;\n";
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = fetchInputUrl(input);
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
return jsonResponse({ sha: commit, commit: { tree: { sha: tree } } });
}
if (url.startsWith("https://api.github.com/repos/owner/repo/commits/")) {
return jsonResponse({}, 404);
}
if (url === `https://api.github.com/repos/owner/repo/git/trees/${tree}?recursive=1`) {
return jsonResponse({
tree: [
{
path: "packages/demo/package.json",
type: "blob",
sha: blob,
size: byteLength(packageJsonBody),
},
{
path: "packages/demo/openclaw.plugin.json",
type: "blob",
sha: blob,
size: byteLength(pluginManifestBody),
},
{
path: "packages/demo/index.ts",
type: "blob",
sha: blob,
size: byteLength(sourceBody),
},
{ path: "other/package.json", type: "blob", sha: blob, size: 2 },
],
});
}
if (
url === `https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/package.json`
) {
return textResponse(packageJsonBody, "application/json");
}
if (
url ===
`https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/openclaw.plugin.json`
) {
return textResponse(pluginManifestBody, "application/json");
}
if (url === `https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/index.ts`) {
return textResponse(sourceBody, "text/plain");
}
if (url === "https://upload.local") {
return {
ok: true,
json: async () => ({
storageId: `storage:${((init?.body as File | undefined)?.name ?? "unknown").replaceAll("/", "_")}`,
}),
};
}
throw new Error(`unexpected fetch ${url}`);
});
renderPublishRoute();
fireEvent.change(screen.getByLabelText("GitHub plugin source URL"), {
target: { value: "https://github.com/owner/repo/tree/main/packages/demo" },
});
fireEvent.click(screen.getByRole("button", { name: "Use URL" }));
await waitFor(() => {
expect(screen.getByText(/Fetched 3 files from owner\/repo/i)).toBeTruthy();
expect(screen.getByDisplayValue("github-plugin")).toBeTruthy();
expect(screen.getByDisplayValue("GitHub Plugin")).toBeTruthy();
expect(screen.getByDisplayValue("2.0.0")).toBeTruthy();
expect(screen.getByDisplayValue(commit)).toBeTruthy();
expect(screen.getByDisplayValue("packages/demo")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
target: { value: "Fetched from GitHub" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
await waitFor(() => {
expect(publishRelease).toHaveBeenCalledTimes(1);
});
expect(generateUploadUrl).toHaveBeenCalledTimes(3);
expect(publishRelease).toHaveBeenCalledWith({
payload: expect.objectContaining({
name: "github-plugin",
displayName: "GitHub Plugin",
family: "code-plugin",
version: "2.0.0",
changelog: "Fetched from GitHub",
source: expect.objectContaining({
repo: "owner/repo",
url: "https://github.com/owner/repo",
ref: "main",
commit,
path: "packages/demo",
}),
files: expect.arrayContaining([
expect.objectContaining({ path: "package.json" }),
expect.objectContaining({ path: "openclaw.plugin.json" }),
expect.objectContaining({ path: "index.ts" }),
]),
}),
});
});
it("previews the generated Claw Pack manifest before publish", async () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo-plugin/openclaw.plugin.json",
);
const dist = withRelativePath(
new File(["export const demo = true;\n"], "index.js", { type: "text/javascript" }),
"demo-plugin/dist/index.js",
);
const suppliedClawPack = withRelativePath(
new File(['{"kind":"user.supplied"}'], "CLAWPACK.json", { type: "application/json" }),
"demo-plugin/CLAWPACK.json",
);
fireEvent.change(getFileInput(), {
target: { files: [packageJson, manifest, dist, suppliedClawPack] },
});
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Claw Pack preview" })).toBeTruthy();
});
expect(screen.getByRole("heading", { name: "Claw Pack checks" })).toBeTruthy();
expect(screen.getByText("Archive contract")).toBeTruthy();
expect(screen.getByText("Source provenance")).toBeTruthy();
expect(screen.getByText("Platform matrix")).toBeTruthy();
expect(screen.getByText("Security review")).toBeTruthy();
expect(screen.getByText("4 files including CLAWPACK.json")).toBeTruthy();
expect(screen.getAllByText("darwin-arm64").length).toBeGreaterThan(0);
expect(screen.getAllByText("linux-x64-glibc").length).toBeGreaterThan(0);
expect(screen.getAllByText("win32-x64").length).toBeGreaterThan(0);
expect(screen.getByText("Existing pack manifest will be replaced by ClawHub.")).toBeTruthy();
expect(screen.getByText(/"kind": "openclaw\.clawpack"/i)).toBeTruthy();
expect(screen.getByText(/"sha256": "computed-on-publish"/i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Copy Claw Pack preview manifest" })).toBeTruthy();
});
it("imports a Claw Pack archive as package source before publish", async () => {
renderPublishRoute();
const clawpackManifest = withRelativePath(
new File(
[
JSON.stringify({
kind: "openclaw.clawpack",
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
family: "code-plugin",
},
release: {
source: {
repo: "openclaw/demo-plugin",
commit: "abc123",
ref: "refs/tags/v1.2.3",
path: ".",
},
},
hostTargets: [
{ os: "darwin", arch: "arm64" },
{ os: "linux", arch: "x64", libc: "glibc" },
],
}),
],
"CLAWPACK.json",
{ type: "application/json" },
),
"demo.clawpack/CLAWPACK.json",
);
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo.clawpack/package/package.json",
);
const pluginManifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo.clawpack/package/openclaw.plugin.json",
);
fireEvent.change(getFileInput(), {
target: { files: [clawpackManifest, packageJson, pluginManifest] },
});
await waitFor(() => {
expect(screen.getByText("Claw Pack import")).toBeTruthy();
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
expect(screen.getByDisplayValue("Demo Plugin")).toBeTruthy();
expect(screen.getByDisplayValue("1.2.3")).toBeTruthy();
expect(screen.getByDisplayValue("abc123")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
target: { value: "Imported Claw Pack" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
await waitFor(() => {
expect(publishRelease).toHaveBeenCalledTimes(1);
});
expect(publishRelease).toHaveBeenCalledWith({
payload: expect.objectContaining({
name: "demo-plugin",
version: "1.2.3",
source: expect.objectContaining({
repo: "openclaw/demo-plugin",
commit: "abc123",
ref: "refs/tags/v1.2.3",
}),
files: expect.arrayContaining([
expect.objectContaining({ path: "package.json" }),
expect.objectContaining({ path: "openclaw.plugin.json" }),
]),
}),
});
const payload = publishRelease.mock.calls[0]?.[0]?.payload as {
files: Array<{ path: string }>;
};
expect(payload.files.some((file) => file.path.toLowerCase() === "clawpack.json")).toBe(false);
expect(payload.files.some((file) => file.path.startsWith("package/"))).toBe(false);
});
it("surfaces missing OpenClaw compatibility metadata before publish", async () => {
@@ -275,8 +577,8 @@ describe("plugins publish route", () => {
expect(screen.getByText(/Missing required OpenClaw package metadata:/i)).toBeTruthy();
});
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
expect(screen.getAllByText(/openclaw\.compat\.pluginApi/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/openclaw\.build\.openclawVersion/i).length).toBeGreaterThan(0);
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
expect(docsLink.getAttribute("href")).toBe(
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
@@ -297,7 +599,7 @@ describe("plugins publish route", () => {
fireEvent.change(getFileInput(), { target: { files: [bigFile] } });
await waitFor(() => {
expect(screen.getByText(/Each file must be 10MB or smaller/i)).toBeTruthy();
expect(screen.getAllByText(/Each file must be 10MB or smaller/i).length).toBeGreaterThan(0);
});
const summaryBorders = document.querySelectorAll(".border-emerald-300\\/40");
@@ -343,7 +645,7 @@ describe("plugins publish route", () => {
expect((screen.getAllByRole("combobox")[0] as HTMLSelectElement).value).toBe("bundle-plugin");
expect(screen.getByDisplayValue("openclaw-bundle")).toBeTruthy();
expect(screen.getByDisplayValue("desktop, mobile")).toBeTruthy();
expect(screen.getByText(/Browse files/i)).toBeTruthy();
expect(screen.getByText(/Upload ZIP\/TGZ/i)).toBeTruthy();
expect(screen.getByText(/Choose folder/i)).toBeTruthy();
});
@@ -533,7 +835,9 @@ describe("plugins publish route", () => {
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, huge] } });
await waitFor(() => {
expect(screen.getByText(/Each file must be 10MB or smaller: plugin\.wasm/i)).toBeTruthy();
expect(
screen.getAllByText(/Each file must be 10MB or smaller: plugin\.wasm/i).length,
).toBeGreaterThan(0);
});
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
@@ -577,8 +881,11 @@ describe("plugins publish route", () => {
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
expect(
await screen.findByText(/Pending security checks and verification before public listing\./i),
).toBeTruthy();
await waitFor(() => {
expect(
screen.getAllByText(/Pending security checks and verification before public listing\./i)
.length,
).toBeGreaterThan(0);
});
});
});
+13 -6
View File
@@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", () => ({
component?: unknown;
head?: unknown;
}) => ({ __config: config }),
notFound: () => ({ notFound: true }),
redirect: (options: unknown) => ({ redirect: options }),
}));
@@ -93,6 +94,12 @@ describe("skill route loader", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
it("blocks static asset paths from the skill route", async () => {
await expect(runBeforeLoad({ owner: "assets", slug: "index-old.js" })).rejects.toEqual({
notFound: true,
});
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
});
@@ -268,7 +275,7 @@ describe("skill route loader", () => {
links: [
{
rel: "canonical",
href: "https://clawhub.ai/steipete/weather",
href: "http://localhost/steipete/weather",
},
],
}),
@@ -277,14 +284,14 @@ describe("skill route loader", () => {
expect.arrayContaining([
{ title: "Weather — ClawHub" },
{ name: "description", content: "Get current weather." },
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
{ property: "og:url", content: "http://localhost/steipete/weather" },
{
property: "og:image",
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
content: "http://localhost/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
},
{
name: "twitter:image",
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
content: "http://localhost/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
},
]),
);
@@ -295,12 +302,12 @@ describe("skill route loader", () => {
links: [
{
rel: "canonical",
href: "https://clawhub.ai/steipete/weather",
href: "http://localhost/steipete/weather",
},
],
meta: expect.arrayContaining([
{ title: "weather — ClawHub" },
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
{ property: "og:url", content: "http://localhost/steipete/weather" },
]),
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { Doc } from "../../convex/_generated/dataModel";
import { Card } from "./ui/card";
type Props = {
me: Doc<"users"> | null | undefined;
requiredRole?: "admin" | "moderator";
};
export function ManagementAccessNotice({ me, requiredRole = "moderator" }: Props) {
const role = me?.role ?? "none";
const requirement = requiredRole === "admin" ? "admin" : "admin or moderator";
const identity = me?.handle || me?.displayName || me?.name || me?._id || "not signed in";
return (
<main className="section">
<Card>
<div className="management-report-item">
<span className="management-report-meta">access</span>
<strong className="text-[color:var(--ink)]">Management access required</strong>
</div>
<p className="section-subtitle m-0 mt-2">
Signed in as {identity} with role {role}. This page requires {requirement} access.
</p>
<p className="section-subtitle m-0 mt-2">
Use the Users panel on the root management page, or an admin CLI token, to grant the
correct role before returning here.
</p>
</Card>
</main>
);
}
+165 -9
View File
@@ -1,6 +1,7 @@
import type { PackageCompatibility } from "clawhub-schema";
import { Package } from "lucide-react";
import { useRef, useState } from "react";
import { GitBranch, Package, UploadCloud } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ClawPackImportSummary } from "../lib/clawpackImport";
import { formatPackageCompatibility } from "../lib/pluginPublishPrefill";
import { expandDroppedItems } from "../lib/uploadFiles";
import { formatBytes } from "../routes/upload/-utils";
@@ -17,17 +18,37 @@ export function PackageSourceChooser(props: {
normalizedPaths: string[];
normalizedPathSet: Set<string>;
ignoredPaths: string[];
sourceUrl: string;
sourceUrlError: string | null;
sourceUrlStatus: string | null;
sourceUrlBusy: boolean;
intakeStatus: string | null;
detectedPrefillFields: string[];
family: "code-plugin" | "bundle-plugin";
validationError: string | null;
codePluginFieldIssues: string[];
codePluginCompatibility: PackageCompatibility | null;
clawPackImport: ClawPackImportSummary | null;
hostTargets?: string;
onSourceUrlChange: (value: string) => void;
onApplySourceUrl: () => void | Promise<void>;
onPickFiles: (selected: File[]) => Promise<void>;
}) {
const [isDragging, setIsDragging] = useState(false);
const [isClientReady, setIsClientReady] = useState(false);
const archiveInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const isMetadataLocked = props.files.length === 0 || Boolean(props.validationError);
const hostTargetLabels =
props.hostTargets
?.split(",")
.map((target) => target.trim())
.filter(Boolean) ?? [];
const effectiveHostTargets =
hostTargetLabels.length > 0
? hostTargetLabels
: ["darwin-arm64", "linux-x64-glibc", "win32-x64"];
const environmentSignals = deriveEnvironmentSignals(props.normalizedPaths);
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
directoryInputRef.current = node;
@@ -37,13 +58,18 @@ export function PackageSourceChooser(props: {
}
};
useEffect(() => {
setIsClientReady(true);
}, []);
return (
<Card className="mb-5">
<Card className="mb-5" data-upload-ready={isClientReady ? "true" : "false"}>
<input
ref={archiveInputRef}
className="hidden"
type="file"
multiple
aria-label="Package archive input"
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
@@ -60,6 +86,65 @@ export function PackageSourceChooser(props: {
void props.onPickFiles(selected);
}}
/>
<div className="mb-5 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4">
<div className="mb-3">
<h2 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Plugin onboarding changed
</h2>
<p className="m-0 mt-1 text-sm text-[color:var(--ink-soft)]">
Start with files or a GitHub URL. ClawHub fills what it can and builds the Claw Pack
when you publish.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4">
<div className="mb-3 flex items-center gap-2">
<UploadCloud size={18} className="text-[color:var(--accent)]" />
<strong className="text-[color:var(--ink)]">Give us your files</strong>
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
Drop a folder, zip, tgz, or tarball and we will expand it, ignore local junk, and
prefill the form.
</p>
</div>
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4">
<div className="mb-3 flex items-center gap-2">
<GitBranch size={18} className="text-[color:var(--accent)]" />
<strong className="text-[color:var(--ink)]">Paste a GitHub URL</strong>
</div>
<div className="flex gap-2">
<input
className="min-h-[38px] min-w-0 flex-1 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
value={props.sourceUrl}
onChange={(event) => props.onSourceUrlChange(event.target.value)}
placeholder="https://github.com/owner/repo"
aria-label="GitHub plugin source URL"
/>
<Button
variant="outline"
size="sm"
disabled={props.sourceUrlBusy}
onClick={() => {
void props.onApplySourceUrl();
}}
>
{props.sourceUrlBusy ? "Fetching" : "Use URL"}
</Button>
</div>
{props.sourceUrlStatus ? (
<p className="m-0 mt-2 text-xs text-[color:var(--ink-soft)]">
{props.sourceUrlStatus}
</p>
) : null}
{props.sourceUrlError ? (
<p className="m-0 mt-2 text-xs text-red-700 dark:text-red-200">
{props.sourceUrlError}
</p>
) : null}
</div>
</div>
</div>
<div
className={`flex flex-col items-center gap-4 rounded-[var(--radius-md)] border-2 border-dashed p-8 text-center transition-colors ${
isDragging
@@ -96,20 +181,46 @@ export function PackageSourceChooser(props: {
</span>
</div>
<span className="max-w-md text-sm text-[color:var(--ink-soft)]">
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
of the form.
Drag a package archive, folder, zip, or tgz here. ClawHub expands the source package,
ignores local junk, then generates the Claw Pack itself.
</span>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => archiveInputRef.current?.click()}>
Browse files
<div className="flex flex-wrap justify-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isClientReady}
onClick={() => archiveInputRef.current?.click()}
>
Upload ZIP/TGZ
</Button>
<Button variant="ghost" size="sm" onClick={() => directoryInputRef.current?.click()}>
<Button
variant="ghost"
size="sm"
disabled={!isClientReady}
onClick={() => directoryInputRef.current?.click()}
>
Choose folder
</Button>
</div>
<div className="grid max-w-xl gap-2 pt-2 text-left text-xs text-[color:var(--ink-soft)] sm:grid-cols-2">
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
<strong className="block text-[color:var(--ink)]">archive upload</strong>
<span>.zip, .tgz, and .tar.gz are expanded before publish.</span>
</div>
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
<strong className="block text-[color:var(--ink)]">folder upload</strong>
<span>directory picks preserve paths for manifest and package detection.</span>
</div>
</div>
</div>
</div>
{props.intakeStatus ? (
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-3 text-sm text-[color:var(--ink)]">
{props.intakeStatus}
</div>
) : null}
<div
className={`rounded-[var(--radius-sm)] border px-4 py-3 transition-colors ${
isMetadataLocked
@@ -149,6 +260,7 @@ export function PackageSourceChooser(props: {
{props.ignoredPaths.length > 0 ? (
<Badge>Ignored {props.ignoredPaths.length} files</Badge>
) : null}
{props.clawPackImport ? <Badge>Claw Pack import</Badge> : null}
</div>
</>
)}
@@ -174,6 +286,50 @@ export function PackageSourceChooser(props: {
Compatibility: {formatPackageCompatibility(props.codePluginCompatibility)}
</p>
) : null}
{props.normalizedPaths.length > 0 ? (
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<strong className="text-sm text-[color:var(--ink)]">Claw Pack readiness</strong>
<span className="text-xs text-[color:var(--ink-soft)]">generated on publish</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge>Deterministic archive</Badge>
<Badge>Generated manifest</Badge>
{effectiveHostTargets.map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
{environmentSignals.map((signal) => (
<Badge key={signal} variant="compact">
{signal}
</Badge>
))}
</div>
<p className="mt-2 text-sm text-[color:var(--ink-soft)]">
{props.clawPackImport
? `Imported ${props.clawPackImport.packageFileCount} package files from a Claw Pack archive. ClawHub will rebuild the canonical manifest and digests on publish.`
: "ClawHub will package these files with a Claw Pack manifest, host target summary, file digests, and environment hints for OpenClaw clients."}
</p>
</div>
) : null}
</Card>
);
}
function deriveEnvironmentSignals(paths: string[]) {
const lowerPaths = paths.map((path) => path.toLowerCase());
const signals = [
lowerPaths.some((path) => path.includes("playwright") || path.includes("browser"))
? "browser"
: null,
lowerPaths.some((path) => path.includes("desktop") || path.includes("imessage"))
? "desktop"
: null,
lowerPaths.some((path) => path.includes("audio") || path.includes("microphone"))
? "audio"
: null,
"network",
].filter((signal): signal is string => Boolean(signal));
return [...new Set(signals)];
}
+129
View File
@@ -0,0 +1,129 @@
import { Link } from "@tanstack/react-router";
import { DatabaseZap, Gauge, ListChecks, ShieldCheck, UploadCloud, UserCog } from "lucide-react";
import type React from "react";
import { Badge } from "./ui/badge";
type OperationKey = "publish" | "plugins" | "moderation" | "clawpacks" | "migrations" | "users";
const pluginPublishSearch = {
ownerHandle: undefined,
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
};
export function PluginOperationsNav({ current }: { current?: OperationKey }) {
return (
<section className="mb-5">
<div className="mb-3 flex flex-col gap-1">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Plugin operations
</h2>
<p className="section-subtitle m-0">
Publisher, moderation, artifact, and migration surfaces for the ClawHub plugin platform.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<OperationLink
current={current === "publish"}
icon={<UploadCloud className="h-4 w-4" aria-hidden="true" />}
title="Publish plugin"
path="/publish-plugin"
description="Upload a folder, zip, or Claw Pack archive and review the generated contract."
>
<Link to="/publish-plugin" search={pluginPublishSearch}>
Open publish
</Link>
</OperationLink>
<OperationLink
current={current === "plugins"}
icon={<ListChecks className="h-4 w-4" aria-hidden="true" />}
title="Plugin management"
path="/management/plugins"
description="Open package drilldowns with release, Claw Pack, badge, and verdict controls."
>
<Link to="/management/plugins" search={{ skill: undefined, plugin: undefined }}>
Open plugins
</Link>
</OperationLink>
<OperationLink
current={current === "moderation"}
icon={<ShieldCheck className="h-4 w-4" aria-hidden="true" />}
title="Plugin moderation"
path="/management/moderation"
description="Review code and bundle plugins by scan state, Claw Pack status, and release risk."
>
<Link to="/management/moderation" search={{ skill: undefined, plugin: undefined }}>
Open queue
</Link>
</OperationLink>
<OperationLink
current={current === "clawpacks"}
icon={<DatabaseZap className="h-4 w-4" aria-hidden="true" />}
title="Claw Pack ops"
path="/management/clawpacks"
description="Dry-run migration samples, build artifacts, retry failures, and rebuild lookup rows."
>
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
Open Claw Pack ops
</Link>
</OperationLink>
<OperationLink
current={current === "migrations"}
icon={<Gauge className="h-4 w-4" aria-hidden="true" />}
title="Migration readiness"
path="/management/migrations"
description="Track ClawHub gates for future OpenClaw bundled-plugin externalization."
>
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
Open readiness
</Link>
</OperationLink>
<OperationLink
current={current === "users"}
icon={<UserCog className="h-4 w-4" aria-hidden="true" />}
title="User roles"
path="/management/users"
description="Find users, grant moderator/admin roles, and manage account bans."
>
<Link to="/management/users" search={{ skill: undefined, plugin: undefined }}>
Open users
</Link>
</OperationLink>
</div>
</section>
);
}
function OperationLink({
children,
current,
description,
icon,
path,
title,
}: {
children: React.ReactNode;
current: boolean;
description: string;
icon: React.ReactNode;
path: string;
title: string;
}) {
return (
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
<div className="mb-2 flex items-start justify-between gap-3">
<div className="flex items-center gap-2 font-semibold text-[color:var(--ink)]">
{icon}
{title}
</div>
{current ? <Badge variant="compact">current</Badge> : null}
</div>
<div className="mono mb-2 text-xs text-[color:var(--ink-soft)]">{path}</div>
<p className="section-subtitle m-0 mb-3">{description}</p>
<div className="text-sm font-semibold text-[color:var(--accent)]">{children}</div>
</div>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ describe("skill detail install helpers", () => {
});
expect(prompt).toContain("steipete/weather");
expect(prompt).toContain("https://clawhub.ai/steipete/weather");
expect(prompt).toContain("http://localhost/steipete/weather");
expect(prompt).toContain("WEATHER_API_KEY");
expect(prompt).toContain("curl");
expect(prompt).toContain("~/.weatherrc");
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { normalizeClawPackImport } from "./clawpackImport";
function withRelativePath(file: File, path: string) {
Object.defineProperty(file, "webkitRelativePath", {
value: path,
configurable: true,
});
return file;
}
describe("clawpack import", () => {
it("unwraps package files and prefill metadata from a Claw Pack archive", async () => {
const manifest = withRelativePath(
new File(
[
JSON.stringify({
kind: "openclaw.clawpack",
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
family: "code-plugin",
},
release: {
source: {
repo: "openclaw/demo-plugin",
commit: "abc123",
ref: "refs/tags/v1.2.3",
path: ".",
},
},
hostTargets: [
{ os: "darwin", arch: "arm64" },
{ os: "linux", arch: "x64", libc: "glibc" },
],
}),
],
"CLAWPACK.json",
{ type: "application/json" },
),
"demo.clawpack/CLAWPACK.json",
);
const packageJson = withRelativePath(
new File(['{"name":"demo-plugin"}'], "package.json", { type: "application/json" }),
"demo.clawpack/package/package.json",
);
const pluginManifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo.clawpack/package/openclaw.plugin.json",
);
const imported = await normalizeClawPackImport([manifest, packageJson, pluginManifest]);
expect(imported.summary).toMatchObject({
packageName: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
family: "code-plugin",
sourceRepo: "openclaw/demo-plugin",
sourceCommit: "abc123",
sourceRef: "refs/tags/v1.2.3",
sourcePath: ".",
hostTargets: ["darwin-arm64", "linux-x64-glibc"],
packageFileCount: 2,
});
expect(imported.files.map((file) => file.name)).toEqual([
"package.json",
"openclaw.plugin.json",
]);
});
it("rejects malformed Claw Pack manifests", async () => {
const manifest = new File(['{"kind":"other"}'], "CLAWPACK.json", {
type: "application/json",
});
const packageJson = new File(["{}"], "package/package.json", { type: "application/json" });
await expect(normalizeClawPackImport([manifest, packageJson])).rejects.toThrow(
/not an OpenClaw Claw Pack/i,
);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { normalizePackageUploadFiles } from "./packageUpload";
type JsonRecord = Record<string, unknown>;
export type ClawPackImportSummary = {
packageName?: string;
displayName?: string;
version?: string;
family?: "code-plugin" | "bundle-plugin";
sourceRepo?: string;
sourceCommit?: string;
sourceRef?: string;
sourcePath?: string;
hostTargets: string[];
packageFileCount: number;
};
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getClawPackTarget(value: unknown) {
if (!isRecord(value)) return null;
const os = getString(value.os);
const arch = getString(value.arch);
const libc = getString(value.libc);
if (!os || !arch) return null;
return [os, arch, libc].filter(Boolean).join("-");
}
function createPathFile(source: File, path: string) {
return new File([source], path, {
type: source.type,
lastModified: source.lastModified,
});
}
function hasGenericPackageRoot(files: Array<{ path: string }>) {
return files.some((entry) => {
const path = entry.path.toLowerCase();
return (
path === "package.json" || path === "openclaw.plugin.json" || path === "openclaw.bundle.json"
);
});
}
export async function normalizeClawPackImport(files: File[]) {
const normalized = normalizePackageUploadFiles(files);
const manifestEntry = normalized.find((entry) => {
const fileName = entry.path.toLowerCase().split("/").at(-1);
return fileName === "clawpack.json";
});
if (!manifestEntry) return { files, summary: null };
let manifest: JsonRecord;
try {
const parsed = JSON.parse((await manifestEntry.file.text()).replace(/^\uFEFF/, "")) as unknown;
if (!isRecord(parsed)) throw new Error("Invalid manifest");
manifest = parsed;
} catch {
if (hasGenericPackageRoot(normalized)) return { files, summary: null };
throw new Error("Claw Pack manifest is not valid JSON.");
}
if (manifest.kind !== "openclaw.clawpack") {
if (hasGenericPackageRoot(normalized)) return { files, summary: null };
throw new Error("Manifest is not an OpenClaw Claw Pack.");
}
const manifestPath = manifestEntry.path;
const manifestDir = manifestPath.includes("/")
? `${manifestPath.split("/").slice(0, -1).join("/")}/`
: "";
const packageRoot = `${manifestDir}package/`;
const packageFiles = normalized
.filter((entry) => entry.path !== manifestPath && entry.path.startsWith(packageRoot))
.map((entry) => createPathFile(entry.file, entry.path.slice(packageRoot.length)));
if (packageFiles.length === 0) {
throw new Error("Claw Pack archive does not contain package files under package/.");
}
const packageInfo = isRecord(manifest.package) ? manifest.package : {};
const releaseInfo = isRecord(manifest.release) ? manifest.release : {};
const sourceInfo = isRecord(releaseInfo.source) ? releaseInfo.source : {};
const hostTargets = Array.isArray(manifest.hostTargets)
? manifest.hostTargets
.map(getClawPackTarget)
.filter((target): target is string => Boolean(target))
: [];
const family = getString(packageInfo.family);
return {
files: packageFiles,
summary: {
packageName: getString(packageInfo.name) ?? getString(packageInfo.slug),
displayName: getString(packageInfo.displayName) ?? getString(packageInfo.name),
version: getString(packageInfo.version),
family: family === "code-plugin" || family === "bundle-plugin" ? family : undefined,
sourceRepo: getString(sourceInfo.repo),
sourceCommit: getString(sourceInfo.commit),
sourceRef: getString(sourceInfo.ref),
sourcePath: getString(sourceInfo.path),
hostTargets,
packageFileCount: packageFiles.length,
} satisfies ClawPackImportSummary,
};
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import { fetchGitHubPackageSource } from "./githubPackageSource";
const COMMIT = "a".repeat(40);
const TREE = "b".repeat(40);
const BLOB = "c".repeat(40);
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function textResponse(body: string, contentType = "text/plain") {
return new Response(body, {
status: 200,
headers: { "content-type": contentType },
});
}
function fetchInputUrl(input: RequestInfo | URL) {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
describe("fetchGitHubPackageSource", () => {
it("resolves a repo URL, downloads package files, and reports source metadata", async () => {
const progress: string[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url = fetchInputUrl(input);
if (url === "https://api.github.com/repos/owner/repo") {
return jsonResponse({ default_branch: "main" });
}
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
}
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
return jsonResponse({
tree: [
{ path: "package.json", type: "blob", sha: BLOB, size: 37 },
{ path: "src/index.ts", type: "blob", sha: BLOB, size: 17 },
],
});
}
if (url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/package.json`) {
return textResponse('{"name":"demo","version":"1.0.0"}', "application/json");
}
if (url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/src/index.ts`) {
return textResponse("export const x=1;\n");
}
throw new Error(`unexpected ${url}`);
}) as typeof fetch;
const result = await fetchGitHubPackageSource("https://github.com/owner/repo", {
fetcher,
onProgress: (entry) => progress.push(entry.phase),
});
expect(result.source).toEqual({
repo: "owner/repo",
url: "https://github.com/owner/repo",
ref: "main",
commit: COMMIT,
path: ".",
});
expect(result.files.map((file) => file.name)).toEqual(["package.json", "src/index.ts"]);
expect(await result.files[0]?.text()).toBe('{"name":"demo","version":"1.0.0"}');
expect(progress).toContain("resolving");
expect(progress).toContain("listing");
expect(progress.filter((phase) => phase === "downloading")).toHaveLength(2);
});
it("handles branch names with slashes and trims tree URL paths", async () => {
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url = fetchInputUrl(input);
if (url === "https://api.github.com/repos/owner/repo/commits/feature%2Fnew-ui") {
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
}
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
return jsonResponse({
tree: [
{ path: "plugins/demo/package.json", type: "blob", sha: BLOB, size: 15 },
{ path: "plugins/demo/openclaw.plugin.json", type: "blob", sha: BLOB, size: 11 },
{ path: "other/package.json", type: "blob", sha: BLOB, size: 2 },
],
});
}
if (
url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/plugins/demo/package.json`
) {
return textResponse('{"name":"demo"}', "application/json");
}
if (
url ===
`https://raw.githubusercontent.com/owner/repo/${COMMIT}/plugins/demo/openclaw.plugin.json`
) {
return textResponse('{"id":"demo"}', "application/json");
}
return jsonResponse({}, 404);
}) as typeof fetch;
const result = await fetchGitHubPackageSource(
"https://github.com/owner/repo/tree/feature/new-ui/plugins/demo",
{ fetcher },
);
expect(result.source.ref).toBe("feature/new-ui");
expect(result.source.path).toBe("plugins/demo");
expect(result.files.map((file) => file.name)).toEqual(["openclaw.plugin.json", "package.json"]);
});
it("rejects oversized GitHub packages before downloading files", async () => {
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const url = fetchInputUrl(input);
if (url === "https://api.github.com/repos/owner/repo") {
return jsonResponse({ default_branch: "main" });
}
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
}
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
return jsonResponse({
tree: [{ path: "package.json", type: "blob", sha: BLOB, size: 11 * 1024 * 1024 }],
});
}
throw new Error(`unexpected ${url}`);
}) as typeof fetch;
await expect(
fetchGitHubPackageSource("https://github.com/owner/repo", { fetcher }),
).rejects.toThrow(/10MB/);
expect(fetcher).toHaveBeenCalledTimes(3);
});
});
+311
View File
@@ -0,0 +1,311 @@
type GitHubPackageSource = {
repo: string;
url: string;
ref: string;
commit: string;
path: string;
};
type GitHubPackageSourceResult = {
files: File[];
source: GitHubPackageSource;
};
type Fetcher = typeof fetch;
type GitHubRepoResponse = {
default_branch?: unknown;
};
type GitHubCommitResponse = {
sha?: unknown;
commit?: { tree?: { sha?: unknown } };
};
type GitHubTreeEntry = {
path?: unknown;
type?: unknown;
sha?: unknown;
size?: unknown;
};
type GitHubTreeResponse = {
tree?: unknown;
truncated?: unknown;
};
export type GitHubPackageSourceProgress = {
phase: "resolving" | "listing" | "downloading";
current?: number;
total?: number;
path?: string;
};
const GITHUB_API = "https://api.github.com";
const GITHUB_RAW = "https://raw.githubusercontent.com";
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
const DEFAULT_MAX_FILES = 500;
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
type ParsedGitHubUrl = {
owner: string;
repo: string;
kind: "repo" | "tree" | "blob";
segments: string[];
url: string;
};
export async function fetchGitHubPackageSource(
input: string,
options: {
fetcher?: Fetcher;
maxFiles?: number;
maxFileBytes?: number;
maxTotalBytes?: number;
onProgress?: (progress: GitHubPackageSourceProgress) => void;
} = {},
): Promise<GitHubPackageSourceResult> {
const fetcher = options.fetcher ?? fetch;
const parsed = parseGitHubPackageUrl(input);
options.onProgress?.({ phase: "resolving" });
const resolved = await resolveGitHubSource(parsed, fetcher);
options.onProgress?.({ phase: "listing" });
const entries = await listGitHubTreeFiles(parsed.owner, parsed.repo, resolved.treeSha, fetcher);
const selected = filterGitHubTreeEntries(entries, resolved.path);
if (selected.length === 0) {
throw new Error(`GitHub path "${resolved.path}" does not contain package files.`);
}
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
if (selected.length > maxFiles) {
throw new Error(
`GitHub path has too many files (${selected.length}). Upload an archive instead.`,
);
}
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
const totalBytes = selected.reduce((sum, entry) => sum + entry.size, 0);
if (selected.some((entry) => entry.size > maxFileBytes)) {
throw new Error("One or more GitHub files exceeds the 10MB per-file limit.");
}
if (totalBytes > maxTotalBytes) {
throw new Error("GitHub package exceeds the 50MB publish limit.");
}
const files: File[] = [];
for (const [index, entry] of selected.entries()) {
options.onProgress?.({
phase: "downloading",
current: index + 1,
total: selected.length,
path: entry.path,
});
const response = await fetcher(
`${GITHUB_RAW}/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/${encodeURIComponent(
resolved.commit,
)}/${entry.path.split("/").map(encodeURIComponent).join("/")}`,
);
if (!response.ok) throw new Error(`Could not download ${entry.path} from GitHub.`);
const bytes = await response.arrayBuffer();
if (bytes.byteLength !== entry.size && bytes.byteLength > maxFileBytes) {
throw new Error(`GitHub file ${entry.path} exceeds the 10MB per-file limit.`);
}
files.push(
new File([bytes], entry.relativePath, {
type: response.headers.get("content-type")?.split(";")[0] ?? "",
}),
);
}
return {
files,
source: {
repo: `${parsed.owner}/${parsed.repo}`,
url: `https://github.com/${parsed.owner}/${parsed.repo}`,
ref: resolved.ref,
commit: resolved.commit,
path: resolved.path,
},
};
}
function parseGitHubPackageUrl(input: string): ParsedGitHubUrl {
const value = input.trim().replace(/^git@github\.com:/i, "https://github.com/");
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("Paste a GitHub repo, tree, or blob URL.");
}
if (url.protocol !== "https:" || !GITHUB_HOSTS.has(url.hostname)) {
throw new Error("Paste a GitHub repo, tree, or blob URL.");
}
const segments = url.pathname
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean)
.map(decodePathSegment);
const [owner, repo, kind, ...rest] = segments;
if (!owner || !repo) throw new Error("GitHub URL must include owner and repo.");
if (kind && kind !== "tree" && kind !== "blob") {
return { owner, repo, kind: "repo", segments: [], url: `https://github.com/${owner}/${repo}` };
}
return {
owner,
repo,
kind: kind === "tree" || kind === "blob" ? kind : "repo",
segments: rest,
url: `https://github.com/${owner}/${repo}`,
};
}
async function resolveGitHubSource(parsed: ParsedGitHubUrl, fetcher: Fetcher) {
if (parsed.kind === "repo") {
const defaultBranch = await fetchDefaultBranch(parsed.owner, parsed.repo, fetcher);
const commit = await fetchCommit(parsed.owner, parsed.repo, defaultBranch, fetcher);
return {
ref: defaultBranch,
commit: commit.sha,
treeSha: commit.treeSha,
path: ".",
};
}
if (parsed.segments.length === 0) throw new Error("GitHub URL is missing a ref.");
const minPathSegments = parsed.kind === "blob" ? 1 : 0;
const maxRefSegments = parsed.segments.length - minPathSegments;
for (let refSegmentCount = maxRefSegments; refSegmentCount >= 1; refSegmentCount -= 1) {
const ref = parsed.segments.slice(0, refSegmentCount).join("/");
const pathSegments = parsed.segments.slice(refSegmentCount);
const candidate = await tryFetchCommit(parsed.owner, parsed.repo, ref, fetcher);
if (!candidate) continue;
const rawPath =
parsed.kind === "blob" ? pathSegments.slice(0, -1).join("/") : pathSegments.join("/");
return {
ref,
commit: candidate.sha,
treeSha: candidate.treeSha,
path: normalizeRepoPath(rawPath) || ".",
};
}
throw new Error("GitHub ref not found.");
}
async function fetchDefaultBranch(owner: string, repo: string, fetcher: Fetcher) {
const response = await githubJson(`${GITHUB_API}/repos/${owner}/${repo}`, fetcher);
const parsed = (await response.json()) as GitHubRepoResponse;
const branch = typeof parsed.default_branch === "string" ? parsed.default_branch.trim() : "";
if (!branch) throw new Error("GitHub repo default branch missing.");
return branch;
}
async function tryFetchCommit(owner: string, repo: string, ref: string, fetcher: Fetcher) {
const response = await fetcher(
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
{
headers: githubHeaders(),
},
);
if (!response.ok) return null;
return parseCommitResponse((await response.json()) as GitHubCommitResponse);
}
async function fetchCommit(owner: string, repo: string, ref: string, fetcher: Fetcher) {
const response = await githubJson(
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
fetcher,
);
return parseCommitResponse((await response.json()) as GitHubCommitResponse);
}
function parseCommitResponse(parsed: GitHubCommitResponse) {
const sha = typeof parsed.sha === "string" ? parsed.sha.trim().toLowerCase() : "";
const treeSha =
typeof parsed.commit?.tree?.sha === "string" ? parsed.commit.tree.sha.trim().toLowerCase() : "";
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[a-f0-9]{40}$/.test(treeSha)) {
throw new Error("GitHub commit metadata missing.");
}
return { sha, treeSha };
}
async function listGitHubTreeFiles(owner: string, repo: string, treeSha: string, fetcher: Fetcher) {
const response = await githubJson(
`${GITHUB_API}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`,
fetcher,
);
const parsed = (await response.json()) as GitHubTreeResponse;
if (parsed.truncated) throw new Error("GitHub tree is too large. Upload an archive instead.");
if (!Array.isArray(parsed.tree)) throw new Error("GitHub tree metadata missing.");
return parsed.tree
.map(normalizeTreeEntry)
.filter((entry): entry is { path: string; sha: string; size: number } => Boolean(entry));
}
function normalizeTreeEntry(entry: GitHubTreeEntry) {
if (entry.type !== "blob") return null;
const path = typeof entry.path === "string" ? normalizeRepoPath(entry.path) : "";
const sha = typeof entry.sha === "string" ? entry.sha.trim() : "";
const size = typeof entry.size === "number" ? entry.size : Number.NaN;
if (!path || !/^[a-f0-9]{40}$/i.test(sha) || !Number.isFinite(size) || size < 0) return null;
return { path, sha: sha.toLowerCase(), size };
}
function filterGitHubTreeEntries(
entries: Array<{ path: string; sha: string; size: number }>,
rootPath: string,
) {
const root = normalizeRepoPath(rootPath);
const prefix = root && root !== "." ? `${root}/` : "";
return entries
.filter((entry) => !prefix || entry.path.startsWith(prefix))
.map((entry) => ({
...entry,
relativePath: prefix ? entry.path.slice(prefix.length) : entry.path,
}))
.filter((entry) => entry.relativePath && !entry.relativePath.endsWith("/"))
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
async function githubJson(url: string, fetcher: Fetcher) {
const response = await fetcher(url, { headers: githubHeaders() });
if (response.ok) return response;
if (response.status === 403 || response.status === 429) {
throw new Error("GitHub rate limit hit. Try again shortly or upload an archive.");
}
if (response.status === 404) throw new Error("GitHub repo or ref not found.");
throw new Error("GitHub request failed.");
}
function githubHeaders() {
return {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
}
function normalizeRepoPath(value: string) {
if (value.trim() === ".") return ".";
const parts = value
.replaceAll("\u0000", "")
.replaceAll("\\", "/")
.split("/")
.map((part) => part.trim())
.filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) {
throw new Error("Invalid GitHub path.");
}
return parts.join("/");
}
function decodePathSegment(segment: string) {
try {
return decodeURIComponent(segment);
} catch {
throw new Error("Invalid GitHub URL.");
}
}
+87
View File
@@ -0,0 +1,87 @@
import type { Id } from "../../convex/_generated/dataModel";
type MigrationReadinessState =
| "package-missing"
| "release-missing"
| "clawpack-missing"
| "metadata-incomplete"
| "scan-blocked"
| "ready-for-openclaw";
export type MigrationReadinessItem = {
bundledPluginId: string;
displayName: string;
desiredPackageName: string;
publisherHandle: string;
sourceRepo: string | null;
sourcePath: string;
sourceCommit: string | null;
sourceRef: string | null;
requiredHostTargets: string[];
readinessState: MigrationReadinessState;
blockers: string[];
gates: {
packageExists: boolean;
releaseExists: boolean;
clawpackAvailable: boolean;
hostMatrixComplete: boolean;
environmentComplete: boolean;
sourceLinked: boolean;
scanClear: boolean;
runtimeBundleStatus: string;
};
package: {
packageId: Id<"packages">;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
runtimeId: string | null;
channel: "official" | "community" | "private";
isOfficial: boolean;
scanStatus: string;
updatedAt: number;
} | null;
latestRelease: {
releaseId: Id<"packageReleases">;
version: string;
createdAt: number;
clawpackSha256: string | null;
clawpackFileCount: number | null;
clawpackRevokedAt?: number;
hostTargetKeys: string[];
environmentFlags: string[];
scanStatus: string;
} | null;
};
export type MigrationReadinessResult = {
items: MigrationReadinessItem[];
readyCount: number;
blockedCount: number;
generatedAt: number;
};
export function readinessStateLabel(state: MigrationReadinessState) {
if (state === "clawpack-missing") return "claw pack missing";
return state.replaceAll("-", " ");
}
export function readinessBlockerLabel(blocker: string) {
if (blocker === "clawpack-missing") {
return "claw pack missing";
}
return blocker.replaceAll("-", " ");
}
export function formatReadinessClawPack(item: MigrationReadinessItem) {
if (item.latestRelease?.clawpackRevokedAt) return "revoked";
if (!item.latestRelease?.clawpackSha256) return "missing";
const digest = item.latestRelease.clawpackSha256.slice(0, 12);
const count = item.latestRelease.clawpackFileCount;
return [count ? `${count} files` : null, digest].filter(Boolean).join(" / ");
}
export function formatReadinessSource(item: MigrationReadinessItem) {
const ref = item.sourceCommit ?? item.sourceRef;
return `${item.sourceRepo ?? "missing"}${ref ? ` @ ${ref.slice(0, 12)}` : ""}`;
}
+79
View File
@@ -11,10 +11,16 @@ vi.mock("@tanstack/react-start/server", () => ({
import {
fetchPackageDetail,
fetchPackageReadme,
fetchPackageClawPack,
fetchPackageClawPackManifest,
fetchPackageVersion,
fetchPluginCatalog,
fetchPackages,
getPackageApiHref,
getPackageClawPackHref,
getPackageDownloadPath,
getPackageDownloadHref,
getPackageClawPackPath,
PackageApiError,
} from "./packageApi";
@@ -374,6 +380,59 @@ describe("fetchPackages", () => {
);
});
it("fetches package Claw Pack details from the encoded release route", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
package: {
name: "@openclaw/kitchen-sink",
displayName: "Kitchen Sink",
family: "code-plugin",
},
version: { version: "1.0.0", createdAt: 1 },
clawpack: { available: true, specVersion: 1, sha256: "a".repeat(64), size: 12 },
links: {
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
immutable: `/api/v1/clawpacks/${"a".repeat(64)}`,
manifest:
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
},
}),
{ status: 200 },
),
);
const result = await fetchPackageClawPack("@openclaw/kitchen-sink", "1.0.0+build/meta");
expect(result?.clawpack.sha256).toBe("a".repeat(64));
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://registry.example/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0%2Bbuild%2Fmeta/clawpack",
);
});
it("fetches package Claw Pack manifests from the encoded release route", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
package: { name: "demo-plugin", displayName: "Demo Plugin", family: "code-plugin" },
version: { version: "1.0.0", createdAt: 1 },
clawpack: { available: true, specVersion: 1, sha256: "b".repeat(64), size: 12 },
manifest: { kind: "openclaw.clawpack", specVersion: 1 },
}),
{ status: 200 },
),
);
const result = await fetchPackageClawPackManifest("demo-plugin", "1.0.0");
expect(result?.manifest).toEqual({ kind: "openclaw.clawpack", specVersion: 1 });
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://registry.example/api/v1/packages/demo-plugin/versions/1.0.0/clawpack/manifest",
);
});
it("returns null when no supported README variant exists", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi
@@ -419,6 +478,26 @@ describe("fetchPackages", () => {
expect(getPackageDownloadPath("private-plugin")).toBe(
"/api/v1/packages/private-plugin/download",
);
expect(getPackageClawPackPath("@openclaw/kitchen-sink", "1.0.0+build/meta")).toBe(
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0%2Bbuild%2Fmeta/clawpack",
);
expect(getPackageClawPackPath("@openclaw/kitchen-sink", "1.0.0", "manifest")).toBe(
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
);
});
it("builds absolute package asset hrefs from the Convex site URL", () => {
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://registry.example");
expect(getPackageApiHref("/api/v1/packages/private-plugin/download")).toBe(
"https://registry.example/api/v1/packages/private-plugin/download",
);
expect(getPackageDownloadHref("private-plugin", "1.0.0")).toBe(
"https://registry.example/api/v1/packages/private-plugin/download?version=1.0.0",
);
expect(getPackageClawPackHref("@openclaw/kitchen-sink", "1.0.0", "manifest")).toBe(
"https://registry.example/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
);
});
});
+136
View File
@@ -8,6 +8,39 @@ import { ApiRoutes } from "clawhub-schema/routes";
import { hasOwnProperty } from "./hasOwnProperty";
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
type PackageClawPackSummary = {
available: boolean;
specVersion: number | null;
sha256: string | null;
size: number | null;
format: string | null;
fileCount: number | null;
manifestSha256: string | null;
builtAt: number | null;
buildVersion: string | null;
hostTargets?: Array<{
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl";
nodeRange?: string;
openclawRange?: string;
pluginApiRange?: string;
supportState?: "supported" | "setup-required" | "unsupported";
unsupportedReason?: string;
}>;
environment: {
requiresLocalDesktop?: boolean;
requiresBrowser?: boolean;
requiresAudioDevice?: boolean;
requiresNetwork?: boolean;
requiresExternalServices?: string[];
requiresOsPermissions?: string[];
supportsRemoteHost?: boolean;
knownUnsupported?: string[];
} | null;
runtimeBundles: unknown[];
};
export type PackageListItem = {
name: string;
displayName: string;
@@ -23,6 +56,10 @@ export type PackageListItem = {
capabilityTags?: string[];
executesCode?: boolean;
verificationTier?: string | null;
clawpackAvailable?: boolean;
hostTargetKeys?: string[];
environmentFlags?: string[];
clawpack?: PackageClawPackSummary;
};
export type PackageDetailResponse = ApiV1PackageResponse;
@@ -86,9 +123,38 @@ export type PackageVersionDetail = {
engineVersion: string;
checkedAt: number;
} | null;
clawpack?: PackageClawPackSummary;
} | null;
};
type PackageClawPackReleaseDetail = {
package: {
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
};
version: {
version: string;
createdAt: number;
distTags?: string[];
verification?: PackageVerificationSummary | null;
sha256hash?: string | null;
vtAnalysis?: NonNullable<PackageVersionDetail["version"]>["vtAnalysis"];
llmAnalysis?: NonNullable<PackageVersionDetail["version"]>["llmAnalysis"];
staticScan?: NonNullable<PackageVersionDetail["version"]>["staticScan"];
};
clawpack: PackageClawPackSummary;
links: {
download: string;
immutable: string | null;
manifest: string;
};
};
type PackageClawPackManifestDetail = Omit<PackageClawPackReleaseDetail, "links"> & {
manifest: Record<string, unknown>;
};
type PluginFamily = "code-plugin" | "bundle-plugin";
type PluginCatalogResult = {
@@ -179,6 +245,38 @@ export function getPackageDownloadPath(name: string, version?: string | null) {
return `${path}?version=${encodeURIComponent(version)}`;
}
export function getPackageApiHref(path: string) {
try {
return new URL(path).toString();
} catch {
// Relative API links are expected here.
}
const normalizedPath = normalizeApiPath(path);
const base = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
);
if (base) return new URL(normalizedPath, base).toString();
if (typeof window !== "undefined")
return new URL(normalizedPath, window.location.origin).toString();
return normalizedPath;
}
export function getPackageDownloadHref(name: string, version?: string | null) {
return getPackageApiHref(getPackageDownloadPath(name, version));
}
export function getPackageClawPackPath(name: string, version: string, suffix?: "manifest") {
const path = normalizeApiPath(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}/clawpack`,
);
return suffix ? `${path}/${suffix}` : path;
}
export function getPackageClawPackHref(name: string, version: string, suffix?: "manifest") {
return getPackageApiHref(getPackageClawPackPath(name, version, suffix));
}
async function getForwardedHeaders() {
if (typeof window !== "undefined" || !import.meta.env.SSR) return {};
try {
@@ -258,6 +356,8 @@ export async function fetchPackages(params: {
featured?: boolean;
executesCode?: boolean;
capabilityTag?: string;
hostTarget?: string;
environment?: string;
limit?: number;
}) {
if (params.q?.trim()) {
@@ -273,6 +373,8 @@ export async function fetchPackages(params: {
url.searchParams.set("executesCode", String(params.executesCode));
}
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
if (params.environment) url.searchParams.set("environment", params.environment);
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(url);
}
@@ -294,6 +396,8 @@ export async function fetchPackages(params: {
url.searchParams.set("executesCode", String(params.executesCode));
}
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
if (params.environment) url.searchParams.set("environment", params.environment);
return await fetchJson<{ items: PackageListItem[]; nextCursor: string | null }>(url);
}
@@ -304,6 +408,8 @@ export async function fetchPluginCatalog(params: {
isOfficial?: boolean;
featured?: boolean;
executesCode?: boolean;
hostTarget?: string;
environment?: string;
limit?: number;
}): Promise<PluginCatalogResult> {
if (params.family) {
@@ -314,6 +420,8 @@ export async function fetchPluginCatalog(params: {
isOfficial: params.isOfficial,
featured: params.featured,
executesCode: params.executesCode,
hostTarget: params.hostTarget,
environment: params.environment,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
@@ -341,6 +449,8 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
if (params.environment) url.searchParams.set("environment", params.environment);
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
@@ -362,6 +472,8 @@ export async function fetchPluginCatalog(params: {
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
if (params.environment) url.searchParams.set("environment", params.environment);
const result = await fetchJson<PluginCatalogResult>(url);
return {
items: result?.items ?? [],
@@ -394,6 +506,30 @@ export async function fetchPackageVersion(
}
}
export async function fetchPackageClawPack(
name: string,
version: string,
): Promise<PackageClawPackReleaseDetail | null> {
try {
const url = await packageApiUrl(getPackageClawPackPath(name, version));
return await fetchJson<PackageClawPackReleaseDetail>(url);
} catch {
return null;
}
}
export async function fetchPackageClawPackManifest(
name: string,
version: string,
): Promise<PackageClawPackManifestDetail | null> {
try {
const url = await packageApiUrl(getPackageClawPackPath(name, version, "manifest"));
return await fetchJson<PackageClawPackManifestDetail>(url);
} catch {
return null;
}
}
export async function fetchPackageReadme(
name: string,
version?: string | null,
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { derivePublishLifecycle, deriveClawPackLifecycle } from "./packageLifecycle";
describe("package lifecycle", () => {
it("marks publish as blocked when Claw Pack preview has blockers", () => {
const lifecycle = derivePublishLifecycle({
hasFiles: true,
isAuthenticated: true,
blockers: ["Source commit is required."],
status: null,
});
expect(lifecycle.state).toBe("metadata-blocked");
expect(lifecycle.label).toBe("Blocked before publish");
expect(lifecycle.steps.find((step) => step.key === "manifest")?.status).toBe("blocked");
});
it("tracks the post-publish scan pending state", () => {
const lifecycle = derivePublishLifecycle({
hasFiles: true,
isAuthenticated: true,
blockers: [],
status: "Published. Pending security checks and verification before public listing.",
});
expect(lifecycle.state).toBe("scan-pending");
expect(lifecycle.steps.find((step) => step.key === "scan")?.status).toBe("active");
});
it("keeps built Claw Packs pending until scans are clean", () => {
const lifecycle = deriveClawPackLifecycle({
available: true,
verificationScanStatus: "clean",
vtStatus: "not-run",
staticScanStatus: "clean",
});
expect(lifecycle.state).toBe("scan-pending");
expect(lifecycle.action).toMatch(/Wait for scans/i);
});
it("marks clean built Claw Packs as ready", () => {
const lifecycle = deriveClawPackLifecycle({
available: true,
verificationScanStatus: "clean",
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
});
expect(lifecycle.state).toBe("ready");
expect(lifecycle.steps.every((step) => step.status === "done")).toBe(true);
});
it("keeps suspicious Claw Packs blocked even when the artifact exists", () => {
const lifecycle = deriveClawPackLifecycle({
available: true,
verificationScanStatus: "suspicious",
vtStatus: "clean",
});
expect(lifecycle.state).toBe("blocked");
expect(lifecycle.severity).toBe("danger");
});
});
+318
View File
@@ -0,0 +1,318 @@
type PackageLifecycleState =
| "needs-input"
| "metadata-blocked"
| "ready-to-submit"
| "uploading"
| "publishing"
| "scan-pending"
| "clawpack-missing"
| "ready"
| "revoked"
| "blocked"
| "failed";
type PackageLifecycleSeverity = "neutral" | "info" | "success" | "warning" | "danger";
type PackageLifecycleStepStatus = "waiting" | "active" | "done" | "blocked";
type PackageLifecycleStep = {
key: string;
label: string;
status: PackageLifecycleStepStatus;
};
type PackageLifecycle = {
state: PackageLifecycleState;
label: string;
description: string;
severity: PackageLifecycleSeverity;
action: string | null;
steps: PackageLifecycleStep[];
};
type ScanStatus = string | null | undefined;
const DONE_STEPS = {
upload: { key: "upload", label: "Upload", status: "done" },
manifest: { key: "manifest", label: "Manifest", status: "done" },
build: { key: "build", label: "Build", status: "done" },
scan: { key: "scan", label: "Scan", status: "done" },
available: { key: "available", label: "Available", status: "done" },
} satisfies Record<string, PackageLifecycleStep>;
function step(
key: string,
label: string,
status: PackageLifecycleStepStatus,
): PackageLifecycleStep {
return { key, label, status };
}
function normalizeScanStatus(...statuses: ScanStatus[]) {
const normalized = statuses
.map((status) => status?.trim().toLowerCase())
.filter((status): status is string => Boolean(status));
if (normalized.some((status) => status === "malicious" || status === "blocked")) {
return "malicious";
}
if (normalized.some((status) => status === "suspicious")) return "suspicious";
if (normalized.some((status) => status === "pending" || status === "queued")) return "pending";
if (normalized.some((status) => status === "error" || status === "failed")) return "failed";
if (normalized.length === 0 || normalized.some((status) => status === "not-run")) {
return "not-run";
}
if (normalized.every((status) => status === "clean" || status === "harmless")) return "clean";
return "pending";
}
function lifecycle(input: Omit<PackageLifecycle, "steps"> & { steps: PackageLifecycleStep[] }) {
return input;
}
export function derivePublishLifecycle(input: {
hasFiles: boolean;
isAuthenticated: boolean;
blockers: string[];
status: string | null;
}): PackageLifecycle {
if (!input.hasFiles) {
return lifecycle({
state: "needs-input",
label: "Waiting for package",
description:
"Upload a plugin folder, archive, or package source before metadata is editable.",
severity: "neutral",
action: "Choose a package source.",
steps: [
step("upload", "Upload", "active"),
step("manifest", "Manifest", "waiting"),
step("build", "Build", "waiting"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
if (input.status?.toLowerCase().includes("uploading")) {
return lifecycle({
state: "uploading",
label: "Uploading files",
description: "Package files are being written to ClawHub storage.",
severity: "info",
action: null,
steps: [
step("upload", "Upload", "active"),
step("manifest", "Manifest", "done"),
step("build", "Build", "waiting"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
if (input.status?.toLowerCase().includes("publishing")) {
return lifecycle({
state: "publishing",
label: "Building Claw Pack",
description: "ClawHub is creating the canonical Claw Pack artifact and release record.",
severity: "info",
action: null,
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
step("build", "Build", "active"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
if (input.status?.toLowerCase().includes("pending security")) {
return lifecycle({
state: "scan-pending",
label: "Published, scan pending",
description:
"The release exists, but public confidence depends on security checks finishing.",
severity: "warning",
action: "Watch the release until scans clear.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
DONE_STEPS.build,
step("scan", "Scan", "active"),
step("available", "Available", "waiting"),
],
});
}
if (!input.isAuthenticated) {
return lifecycle({
state: "metadata-blocked",
label: "Login required",
description: "The package is parsed, but publishing requires an authenticated ClawHub user.",
severity: "warning",
action: "Log in before publishing.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
step("build", "Build", "blocked"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
if (input.blockers.length > 0) {
return lifecycle({
state: "metadata-blocked",
label: "Blocked before publish",
description: "ClawHub can preview the Claw Pack, but required metadata is incomplete.",
severity: "danger",
action: input.blockers[0] ?? "Resolve the blocking metadata.",
steps: [
DONE_STEPS.upload,
step("manifest", "Manifest", "blocked"),
step("build", "Build", "waiting"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
return lifecycle({
state: "ready-to-submit",
label: "Ready to publish",
description: "Metadata and package files are ready for the canonical Claw Pack build.",
severity: "success",
action: "Publish to start build and security checks.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
step("build", "Build", "waiting"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
export function deriveClawPackLifecycle(input: {
available?: boolean | null;
revokedAt?: number | null;
buildError?: string | null;
verificationScanStatus?: ScanStatus;
vtStatus?: ScanStatus;
vtVerdict?: ScanStatus;
llmStatus?: ScanStatus;
llmVerdict?: ScanStatus;
staticScanStatus?: ScanStatus;
}): PackageLifecycle {
if (input.revokedAt) {
return lifecycle({
state: "revoked",
label: "Revoked",
description: "This Claw Pack has been revoked and should not be installed.",
severity: "danger",
action: "Publish a replacement release or keep the artifact unavailable.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
DONE_STEPS.build,
step("scan", "Scan", "blocked"),
step("available", "Available", "blocked"),
],
});
}
if (input.buildError) {
return lifecycle({
state: "failed",
label: "Build failed",
description: input.buildError,
severity: "danger",
action: "Retry the Claw Pack build after fixing the source package.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
step("build", "Build", "blocked"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
if (!input.available) {
return lifecycle({
state: "clawpack-missing",
label: "Claw Pack missing",
description: "The release exists but does not have a generated Claw Pack artifact yet.",
severity: "warning",
action: "Run or retry Claw Pack artifact backfill.",
steps: [
DONE_STEPS.upload,
step("manifest", "Manifest", "waiting"),
step("build", "Build", "active"),
step("scan", "Scan", "waiting"),
step("available", "Available", "waiting"),
],
});
}
const scanStatus = normalizeScanStatus(
input.verificationScanStatus,
input.vtStatus,
input.vtVerdict,
input.llmStatus,
input.llmVerdict,
input.staticScanStatus,
);
if (scanStatus === "malicious" || scanStatus === "suspicious") {
return lifecycle({
state: "blocked",
label: scanStatus === "malicious" ? "Blocked as malicious" : "Needs review",
description: "The Claw Pack exists, but security signals prevent a clean install decision.",
severity: "danger",
action: "Open moderation evidence and resolve or revoke the artifact.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
DONE_STEPS.build,
step("scan", "Scan", "blocked"),
step("available", "Available", "blocked"),
],
});
}
if (scanStatus === "failed" || scanStatus === "pending" || scanStatus === "not-run") {
return lifecycle({
state: "scan-pending",
label: "Scan pending",
description:
"The Claw Pack is built, but all security checks have not reached a clean state.",
severity: "warning",
action: "Wait for scans or request a rescan if this is stale.",
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
DONE_STEPS.build,
step("scan", "Scan", "active"),
step("available", "Available", "waiting"),
],
});
}
return lifecycle({
state: "ready",
label: "Ready",
description: "The Claw Pack is built and current security signals are clean.",
severity: "success",
action: null,
steps: [
DONE_STEPS.upload,
DONE_STEPS.manifest,
DONE_STEPS.build,
DONE_STEPS.scan,
DONE_STEPS.available,
],
});
}
+12
View File
@@ -92,16 +92,28 @@ describe("buildPackageUploadEntries", () => {
.fn()
.mockResolvedValueOnce("storage:1")
.mockResolvedValueOnce("storage:2");
const onProgress = vi.fn();
const uploaded = await buildPackageUploadEntries(files, {
generateUploadUrl,
hashFile,
uploadFile,
onProgress,
});
expect(generateUploadUrl).toHaveBeenCalledTimes(2);
expect(uploadFile).toHaveBeenNthCalledWith(1, "upload-1", files[0]);
expect(uploadFile).toHaveBeenNthCalledWith(2, "upload-2", files[1]);
expect(onProgress).toHaveBeenNthCalledWith(1, {
current: 1,
total: 2,
path: "package.json",
});
expect(onProgress).toHaveBeenNthCalledWith(2, {
current: 2,
total: 2,
path: "dist/index.js",
});
expect(uploaded.map((entry) => entry.path)).toEqual(["package.json", "dist/index.js"]);
});
+4 -1
View File
@@ -119,6 +119,7 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
generateUploadUrl: () => Promise<string>;
hashFile: (file: TFile) => Promise<string>;
uploadFile: (uploadUrl: string, file: TFile) => Promise<string>;
onProgress?: (progress: { current: number; total: number; path: string }) => void;
},
) {
const uploaded: Array<{
@@ -129,7 +130,9 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
contentType?: string;
}> = [];
for (const { file, path } of normalizePackageUploadFiles(files)) {
const normalizedFiles = normalizePackageUploadFiles(files);
for (const [index, { file, path }] of normalizedFiles.entries()) {
options.onProgress?.({ current: index + 1, total: normalizedFiles.length, path });
const sha256 = await options.hashFile(file);
const uploadUrl = await options.generateUploadUrl();
const storageId = await options.uploadFile(uploadUrl, file);
+16
View File
@@ -49,6 +49,22 @@ describe("site helpers", () => {
});
});
it("uses the current browser origin for local ClawHub previews", () => {
vi.stubGlobal("window", {
location: { hostname: "127.0.0.1", origin: "http://127.0.0.1:3000" },
} as unknown as Window);
expect(getClawHubSiteUrl()).toBe("http://127.0.0.1:3000");
});
it("keeps local navigation on the current origin even when env points at production", () => {
withServerEnv({ VITE_SITE_URL: "https://clawhub.ai" }, () => {
vi.stubGlobal("window", {
location: { hostname: "localhost", origin: "http://localhost:3000" },
} as unknown as Window);
expect(getClawHubSiteUrl()).toBe("http://localhost:3000");
});
});
it("picks SoulHub URL from explicit env", () => {
withServerEnv({ VITE_SOULHUB_SITE_URL: "https://souls.example.com" }, () => {
expect(getOnlyCrabsSiteUrl()).toBe("https://souls.example.com");
+13 -6
View File
@@ -6,6 +6,7 @@ const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
const DEFAULT_ONLYCRABS_SITE_URL = "https://onlycrabs.ai";
const DEFAULT_ONLYCRABS_HOST = "onlycrabs.ai";
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
export function normalizeClawHubSiteOrigin(value?: string | null) {
if (!value) return null;
@@ -21,7 +22,11 @@ export function normalizeClawHubSiteOrigin(value?: string | null) {
}
export function getClawHubSiteUrl() {
return normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ?? DEFAULT_CLAWHUB_SITE_URL;
return (
getLocalBrowserOrigin() ??
normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ??
DEFAULT_CLAWHUB_SITE_URL
);
}
export function getOnlyCrabsSiteUrl() {
@@ -32,11 +37,7 @@ export function getOnlyCrabsSiteUrl() {
if (siteUrl) {
try {
const url = new URL(siteUrl);
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "0.0.0.0"
) {
if (LOCAL_HOSTS.has(url.hostname)) {
return url.origin;
}
} catch {
@@ -98,3 +99,9 @@ export function getSiteDescription(mode: SiteMode = getSiteMode()) {
export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
return mode === "souls" ? getOnlyCrabsSiteUrl() : getClawHubSiteUrl();
}
function getLocalBrowserOrigin() {
if (typeof window === "undefined") return null;
const { hostname, origin } = window.location;
return LOCAL_HOSTS.has(hostname) ? origin : null;
}
+214 -5
View File
@@ -33,10 +33,18 @@ import { Route as PluginsNameRouteImport } from './routes/plugins/$name'
import { Route as PackagesNewRouteImport } from './routes/packages/new'
import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as ManagementUsersRouteImport } from './routes/management/users'
import { Route as ManagementPluginsRouteImport } from './routes/management/plugins'
import { Route as ManagementModerationRouteImport } from './routes/management/moderation'
import { Route as ManagementMigrationsRouteImport } from './routes/management/migrations'
import { Route as ManagementClawpacksRouteImport } from './routes/management/clawpacks'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as ManagementPluginsNameRouteImport } from './routes/management/plugins/$name'
import { Route as ManagementMigrationsBundledPluginIdRouteImport } from './routes/management/migrations/$bundledPluginId'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as ManagementClawpacksReleasesReleaseIdRouteImport } from './routes/management/clawpacks/releases/$releaseId'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
const UploadRoute = UploadRouteImport.update({
@@ -159,6 +167,31 @@ const OrgsHandleRoute = OrgsHandleRouteImport.update({
path: '/orgs/$handle',
getParentRoute: () => rootRouteImport,
} as any)
const ManagementUsersRoute = ManagementUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => ManagementRoute,
} as any)
const ManagementPluginsRoute = ManagementPluginsRouteImport.update({
id: '/plugins',
path: '/plugins',
getParentRoute: () => ManagementRoute,
} as any)
const ManagementModerationRoute = ManagementModerationRouteImport.update({
id: '/moderation',
path: '/moderation',
getParentRoute: () => ManagementRoute,
} as any)
const ManagementMigrationsRoute = ManagementMigrationsRouteImport.update({
id: '/migrations',
path: '/migrations',
getParentRoute: () => ManagementRoute,
} as any)
const ManagementClawpacksRoute = ManagementClawpacksRouteImport.update({
id: '/clawpacks',
path: '/clawpacks',
getParentRoute: () => ManagementRoute,
} as any)
const CliAuthRoute = CliAuthRouteImport.update({
id: '/cli/auth',
path: '/cli/auth',
@@ -169,6 +202,17 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
path: '/$owner/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const ManagementPluginsNameRoute = ManagementPluginsNameRouteImport.update({
id: '/$name',
path: '/$name',
getParentRoute: () => ManagementPluginsRoute,
} as any)
const ManagementMigrationsBundledPluginIdRoute =
ManagementMigrationsBundledPluginIdRouteImport.update({
id: '/$bundledPluginId',
path: '/$bundledPluginId',
getParentRoute: () => ManagementMigrationsRoute,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -180,6 +224,12 @@ const PluginsNameSecurityScannerRoute =
path: '/security/$scanner',
getParentRoute: () => PluginsNameRoute,
} as any)
const ManagementClawpacksReleasesReleaseIdRoute =
ManagementClawpacksReleasesReleaseIdRouteImport.update({
id: '/releases/$releaseId',
path: '/releases/$releaseId',
getParentRoute: () => ManagementClawpacksRoute,
} as any)
const OwnerSlugSecurityScannerRoute =
OwnerSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
@@ -193,7 +243,7 @@ export interface FileRoutesByFullPath {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/management': typeof ManagementRouteWithChildren
'/publish-plugin': typeof PublishPluginRoute
'/publish-skill': typeof PublishSkillRoute
'/search': typeof SearchRoute
@@ -202,6 +252,11 @@ export interface FileRoutesByFullPath {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
'/management/moderation': typeof ManagementModerationRoute
'/management/plugins': typeof ManagementPluginsRouteWithChildren
'/management/users': typeof ManagementUsersRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -215,7 +270,10 @@ export interface FileRoutesByFullPath {
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
'/management/plugins/$name': typeof ManagementPluginsNameRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesByTo {
@@ -224,7 +282,7 @@ export interface FileRoutesByTo {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/management': typeof ManagementRouteWithChildren
'/publish-plugin': typeof PublishPluginRoute
'/publish-skill': typeof PublishSkillRoute
'/search': typeof SearchRoute
@@ -233,6 +291,11 @@ export interface FileRoutesByTo {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
'/management/moderation': typeof ManagementModerationRoute
'/management/plugins': typeof ManagementPluginsRouteWithChildren
'/management/users': typeof ManagementUsersRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -246,7 +309,10 @@ export interface FileRoutesByTo {
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
'/management/plugins/$name': typeof ManagementPluginsNameRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesById {
@@ -256,7 +322,7 @@ export interface FileRoutesById {
'/admin': typeof AdminRoute
'/dashboard': typeof DashboardRoute
'/import': typeof ImportRoute
'/management': typeof ManagementRoute
'/management': typeof ManagementRouteWithChildren
'/publish-plugin': typeof PublishPluginRoute
'/publish-skill': typeof PublishSkillRoute
'/search': typeof SearchRoute
@@ -265,6 +331,11 @@ export interface FileRoutesById {
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
'/management/moderation': typeof ManagementModerationRoute
'/management/plugins': typeof ManagementPluginsRouteWithChildren
'/management/users': typeof ManagementUsersRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
@@ -278,7 +349,10 @@ export interface FileRoutesById {
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
'/management/plugins/$name': typeof ManagementPluginsNameRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRouteTypes {
@@ -298,6 +372,11 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/management/clawpacks'
| '/management/migrations'
| '/management/moderation'
| '/management/plugins'
| '/management/users'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -311,7 +390,10 @@ export interface FileRouteTypes {
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/management/migrations/$bundledPluginId'
| '/management/plugins/$name'
| '/$owner/$slug/security/$scanner'
| '/management/clawpacks/releases/$releaseId'
| '/plugins/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
@@ -329,6 +411,11 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/management/clawpacks'
| '/management/migrations'
| '/management/moderation'
| '/management/plugins'
| '/management/users'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -342,7 +429,10 @@ export interface FileRouteTypes {
| '/souls'
| '/users'
| '/$owner/$slug/settings'
| '/management/migrations/$bundledPluginId'
| '/management/plugins/$name'
| '/$owner/$slug/security/$scanner'
| '/management/clawpacks/releases/$releaseId'
| '/plugins/$name/security/$scanner'
id:
| '__root__'
@@ -360,6 +450,11 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/management/clawpacks'
| '/management/migrations'
| '/management/moderation'
| '/management/plugins'
| '/management/users'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
@@ -373,7 +468,10 @@ export interface FileRouteTypes {
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/management/migrations/$bundledPluginId'
| '/management/plugins/$name'
| '/$owner/$slug/security/$scanner'
| '/management/clawpacks/releases/$releaseId'
| '/plugins/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
@@ -383,7 +481,7 @@ export interface RootRouteChildren {
AdminRoute: typeof AdminRoute
DashboardRoute: typeof DashboardRoute
ImportRoute: typeof ImportRoute
ManagementRoute: typeof ManagementRoute
ManagementRoute: typeof ManagementRouteWithChildren
PublishPluginRoute: typeof PublishPluginRoute
PublishSkillRoute: typeof PublishSkillRoute
SearchRoute: typeof SearchRoute
@@ -576,6 +674,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OrgsHandleRouteImport
parentRoute: typeof rootRouteImport
}
'/management/users': {
id: '/management/users'
path: '/users'
fullPath: '/management/users'
preLoaderRoute: typeof ManagementUsersRouteImport
parentRoute: typeof ManagementRoute
}
'/management/plugins': {
id: '/management/plugins'
path: '/plugins'
fullPath: '/management/plugins'
preLoaderRoute: typeof ManagementPluginsRouteImport
parentRoute: typeof ManagementRoute
}
'/management/moderation': {
id: '/management/moderation'
path: '/moderation'
fullPath: '/management/moderation'
preLoaderRoute: typeof ManagementModerationRouteImport
parentRoute: typeof ManagementRoute
}
'/management/migrations': {
id: '/management/migrations'
path: '/migrations'
fullPath: '/management/migrations'
preLoaderRoute: typeof ManagementMigrationsRouteImport
parentRoute: typeof ManagementRoute
}
'/management/clawpacks': {
id: '/management/clawpacks'
path: '/clawpacks'
fullPath: '/management/clawpacks'
preLoaderRoute: typeof ManagementClawpacksRouteImport
parentRoute: typeof ManagementRoute
}
'/cli/auth': {
id: '/cli/auth'
path: '/cli/auth'
@@ -590,6 +723,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/management/plugins/$name': {
id: '/management/plugins/$name'
path: '/$name'
fullPath: '/management/plugins/$name'
preLoaderRoute: typeof ManagementPluginsNameRouteImport
parentRoute: typeof ManagementPluginsRoute
}
'/management/migrations/$bundledPluginId': {
id: '/management/migrations/$bundledPluginId'
path: '/$bundledPluginId'
fullPath: '/management/migrations/$bundledPluginId'
preLoaderRoute: typeof ManagementMigrationsBundledPluginIdRouteImport
parentRoute: typeof ManagementMigrationsRoute
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
@@ -604,6 +751,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
parentRoute: typeof PluginsNameRoute
}
'/management/clawpacks/releases/$releaseId': {
id: '/management/clawpacks/releases/$releaseId'
path: '/releases/$releaseId'
fullPath: '/management/clawpacks/releases/$releaseId'
preLoaderRoute: typeof ManagementClawpacksReleasesReleaseIdRouteImport
parentRoute: typeof ManagementClawpacksRoute
}
'/$owner/$slug/security/$scanner': {
id: '/$owner/$slug/security/$scanner'
path: '/security/$scanner'
@@ -614,6 +768,61 @@ declare module '@tanstack/react-router' {
}
}
interface ManagementClawpacksRouteChildren {
ManagementClawpacksReleasesReleaseIdRoute: typeof ManagementClawpacksReleasesReleaseIdRoute
}
const ManagementClawpacksRouteChildren: ManagementClawpacksRouteChildren = {
ManagementClawpacksReleasesReleaseIdRoute:
ManagementClawpacksReleasesReleaseIdRoute,
}
const ManagementClawpacksRouteWithChildren =
ManagementClawpacksRoute._addFileChildren(ManagementClawpacksRouteChildren)
interface ManagementMigrationsRouteChildren {
ManagementMigrationsBundledPluginIdRoute: typeof ManagementMigrationsBundledPluginIdRoute
}
const ManagementMigrationsRouteChildren: ManagementMigrationsRouteChildren = {
ManagementMigrationsBundledPluginIdRoute:
ManagementMigrationsBundledPluginIdRoute,
}
const ManagementMigrationsRouteWithChildren =
ManagementMigrationsRoute._addFileChildren(ManagementMigrationsRouteChildren)
interface ManagementPluginsRouteChildren {
ManagementPluginsNameRoute: typeof ManagementPluginsNameRoute
}
const ManagementPluginsRouteChildren: ManagementPluginsRouteChildren = {
ManagementPluginsNameRoute: ManagementPluginsNameRoute,
}
const ManagementPluginsRouteWithChildren =
ManagementPluginsRoute._addFileChildren(ManagementPluginsRouteChildren)
interface ManagementRouteChildren {
ManagementClawpacksRoute: typeof ManagementClawpacksRouteWithChildren
ManagementMigrationsRoute: typeof ManagementMigrationsRouteWithChildren
ManagementModerationRoute: typeof ManagementModerationRoute
ManagementPluginsRoute: typeof ManagementPluginsRouteWithChildren
ManagementUsersRoute: typeof ManagementUsersRoute
}
const ManagementRouteChildren: ManagementRouteChildren = {
ManagementClawpacksRoute: ManagementClawpacksRouteWithChildren,
ManagementMigrationsRoute: ManagementMigrationsRouteWithChildren,
ManagementModerationRoute: ManagementModerationRoute,
ManagementPluginsRoute: ManagementPluginsRouteWithChildren,
ManagementUsersRoute: ManagementUsersRoute,
}
const ManagementRouteWithChildren = ManagementRoute._addFileChildren(
ManagementRouteChildren,
)
interface OwnerSlugRouteChildren {
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
@@ -646,7 +855,7 @@ const rootRouteChildren: RootRouteChildren = {
AdminRoute: AdminRoute,
DashboardRoute: DashboardRoute,
ImportRoute: ImportRoute,
ManagementRoute: ManagementRoute,
ManagementRoute: ManagementRouteWithChildren,
PublishPluginRoute: PublishPluginRoute,
PublishSkillRoute: PublishSkillRoute,
SearchRoute: SearchRoute,
+6 -1
View File
@@ -9,11 +9,16 @@ import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
const RESERVED_OWNER_ROUTE_SEGMENTS = new Set(["api", "assets", "og"]);
export const Route = createFileRoute("/$owner/$slug")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
const isReservedInfrastructurePath = RESERVED_OWNER_ROUTE_SEGMENTS.has(
params.owner.toLowerCase(),
);
if ((!isHandle && !isOwnerId) || isReservedInfrastructurePath) {
throw notFound();
}
},
+43 -1
View File
@@ -92,13 +92,17 @@ type TestPackage = {
versions: number;
};
verification: null;
scanStatus: "suspicious" | "malicious";
scanStatus: "clean" | "suspicious" | "malicious";
latestRelease: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
clawpackAvailable: boolean;
clawpackSha256: string | null;
hostTargets: null;
environment: null;
};
rescanState: TestSkill["rescanState"];
};
@@ -179,6 +183,10 @@ function createPackage(overrides?: Partial<TestPackage>): TestPackage {
vtStatus: "malicious",
llmStatus: "malicious",
staticScanStatus: "malicious",
clawpackAvailable: true,
clawpackSha256: "a".repeat(64),
hostTargets: null,
environment: null,
},
rescanState: {
maxRequests: 3,
@@ -260,6 +268,14 @@ describe("Dashboard minimal rows", () => {
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("links new publishers to plugin publishing from the empty state", () => {
arrangeDashboard({ skills: [], packages: [] });
renderDashboard();
expect(screen.getByRole("link", { name: "Publish a Plugin" })).toBeTruthy();
});
it("does not render row-level actions", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
@@ -271,6 +287,32 @@ describe("Dashboard minimal rows", () => {
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
});
it("surfaces missing Claw Pack artifacts in plugin dashboard status", () => {
arrangeDashboard({
packages: [
createPackage({
scanStatus: "clean",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
clawpackAvailable: false,
clawpackSha256: null,
hostTargets: null,
environment: null,
},
}),
],
});
renderDashboard();
expect(screen.getByText("Claw Pack missing")).toBeTruthy();
expect(screen.getByRole("button", { name: "Claw Pack missing status reason" })).toBeTruthy();
});
it("does not render column titles, scanner details, or plugin metadata chips", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
+8 -1
View File
@@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}}
/>
<ClientOnly>
<Analytics />
<ProductionAnalytics />
</ClientOnly>
</AppProviders>
<Scripts />
@@ -157,6 +157,13 @@ function RootDocument({ children }: { children: React.ReactNode }) {
);
}
function ProductionAnalytics() {
if (typeof window === "undefined") return null;
const hostname = window.location.hostname.toLowerCase();
if (hostname !== "clawhub.ai" && hostname !== "www.clawhub.ai") return null;
return <Analytics />;
}
/** Resets the error boundary whenever the route pathname changes. */
function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
const location = useLocation();
+91 -3
View File
@@ -17,6 +17,16 @@ import {
} from "../components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
import { getUserFacingConvexError } from "../lib/convexError";
import { deriveClawPackLifecycle } from "../lib/packageLifecycle";
type PluginPublishSearch = {
ownerHandle: string | undefined;
name: string | undefined;
displayName: string | undefined;
family: "code-plugin" | "bundle-plugin" | undefined;
nextVersion: string | undefined;
sourceRepo: string | undefined;
};
const emptyPluginPublishSearch = {
ownerHandle: undefined,
@@ -25,7 +35,7 @@ const emptyPluginPublishSearch = {
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
} as const;
} satisfies PluginPublishSearch;
type DashboardSkill = Pick<
Doc<"skills">,
@@ -91,6 +101,19 @@ type DashboardPackage = {
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
clawpackAvailable: boolean;
clawpackSha256: string | null;
hostTargets: Array<{
os: "darwin" | "linux" | "win32";
arch: "arm64" | "x64";
libc?: "glibc" | "musl";
}> | null;
environment: {
requiresLocalDesktop?: boolean;
requiresBrowser?: boolean;
requiresAudioDevice?: boolean;
requiresNetwork?: boolean;
} | null;
} | null;
rescanState?: DashboardRescanState | null;
};
@@ -201,6 +224,11 @@ export function Dashboard() {
Publish a Skill
</Link>
</Button>
<Button asChild>
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
Publish a Plugin
</Link>
</Button>
<Button asChild>
<Link
to="/skills"
@@ -320,6 +348,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
settingsHref={settingsHref}
statusLabel={status.label}
rescanState={skill.rescanState ?? null}
releaseLink={null}
/>
</div>
);
@@ -366,6 +395,34 @@ function packageDashboardStatus(pkg: DashboardPackage): {
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const lifecycle = deriveClawPackLifecycle({
available: pkg.latestRelease?.clawpackAvailable ?? false,
verificationScanStatus: pkg.scanStatus,
vtStatus: pkg.latestRelease?.vtStatus,
llmStatus: pkg.latestRelease?.llmStatus,
staticScanStatus: pkg.latestRelease?.staticScanStatus,
});
if (lifecycle.state === "clawpack-missing") {
return {
label: lifecycle.label,
description: lifecycle.description,
variant: "warning",
};
}
if (lifecycle.state === "scan-pending") {
return {
label: lifecycle.label,
description: lifecycle.description,
variant: "pending",
};
}
if (lifecycle.state === "ready") {
return {
label: lifecycle.label,
description: lifecycle.description,
variant: "success",
};
}
const releaseStatuses = new Set([
pkg.latestRelease?.vtStatus,
pkg.latestRelease?.llmStatus,
@@ -406,7 +463,7 @@ function packageDashboardStatus(pkg: DashboardPackage): {
};
}
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const status = packageDashboardStatus(pkg);
return (
@@ -429,13 +486,25 @@ function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
statusLabel={status.label}
rescanState={pkg.rescanState ?? null}
releaseLink={{
to: "/publish-plugin",
search: {
...emptyPluginPublishSearch,
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
},
}}
/>
</div>
);
}
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
if (statusLabel === "Visible") return false;
if (statusLabel === "Visible" || statusLabel === "Ready") return false;
if (!state) return true;
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
}
@@ -447,6 +516,7 @@ function RowMenu({
settingsHref,
statusLabel,
rescanState,
releaseLink,
}: {
kind: "skill" | "plugin";
targetId: string;
@@ -454,6 +524,16 @@ function RowMenu({
settingsHref: string;
statusLabel: string;
rescanState: DashboardRescanState | null;
releaseLink: {
to: "/publish-plugin";
search: PluginPublishSearch & {
ownerHandle: string;
name: string;
displayName: string;
family: "code-plugin" | "bundle-plugin";
sourceRepo?: string;
};
} | null;
}) {
const requestSkillRescan = useMutation(api.skills.requestRescan);
const requestPluginRescan = useMutation(api.packages.requestRescan);
@@ -504,6 +584,14 @@ function RowMenu({
Settings
</a>
</DropdownMenuItem>
{releaseLink ? (
<DropdownMenuItem asChild>
<Link to={releaseLink.to} search={releaseLink.search}>
<Plus className="h-4 w-4" aria-hidden="true" />
New release
</Link>
</DropdownMenuItem>
) : null}
{showRescanItem ? (
<DropdownMenuItem
disabled={isRequesting || isScanInProgress}
+403 -12
View File
@@ -1,8 +1,10 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../components/PluginOperationsNav";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
@@ -19,6 +21,16 @@ import { useAuthStatus } from "../lib/useAuthStatus";
const SKILL_AUDIT_LOG_LIMIT = 10;
const packageApiRefs = api as unknown as {
packages: {
setModerationVerdict: unknown;
getClawPackMigrationStatus: unknown;
backfillClawPackArtifacts: unknown;
backfillClawPackSearchIndex: unknown;
revokeClawPackArtifact: unknown;
};
};
type ManagementUserSummary = {
_id: Id<"users">;
handle?: string | null;
@@ -84,6 +96,33 @@ type PluginByNameResult = {
highlighted: { byUserId: Id<"users">; at: number } | null;
} | null;
type ClawPackMigrationStatus = {
missingSample: Array<{
releaseId: Id<"packageReleases">;
packageId: Id<"packages">;
name: string;
displayName: string;
version: string;
createdAt: number;
fileCount: number;
}>;
missingSampleSize: number;
generatedClawPackSampleSize: number;
generatedClawPackBytes: number;
sampleLimit: number;
};
type ClawPackBackfillResult = {
processed?: number;
succeeded?: number;
failed?: number;
skipped?: number;
isDone?: boolean;
continueCursor?: string | null;
};
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
function resolveOwnerParam(
handle: string | null | undefined,
ownerId?: Id<"users"> | Id<"publishers">,
@@ -105,15 +144,32 @@ function promptUnbanReason(label: string) {
return trimmed.length > 0 ? trimmed : undefined;
}
function promptClawPackRevocationReason(label: string) {
const result = window.prompt(`Revoke Claw Pack for ${label}. Reason required.`);
if (result === null) return null;
const trimmed = result.trim();
return trimmed.length > 0 ? trimmed : null;
}
function promptClawPackBackfill(label: string) {
return window.confirm(`${label}\n\nThis writes Claw Pack metadata in Convex. Continue?`);
}
export const Route = createFileRoute("/management")({
validateSearch: (search) => ({
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
}),
component: Management,
component: ManagementRouteComponent,
});
function Management() {
function ManagementRouteComponent() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname !== "/management") return <Outlet />;
return <ManagementConsole />;
}
function ManagementConsole() {
const { me } = useAuthStatus();
const search = Route.useSearch();
const navigate = useNavigate();
@@ -147,6 +203,22 @@ function Management() {
const unbanUser = useMutation(api.users.unbanUser);
const setBatch = useMutation(api.skills.setBatch);
const setPackageBatch = useMutation(api.packages.setBatch);
const setPackageModerationVerdict = useMutation(
packageApiRefs.packages.setModerationVerdict as never,
) as unknown as (args: {
packageId: Id<"packages">;
verdict: PackageScanStatus;
note?: string;
}) => Promise<unknown>;
const revokeClawPackArtifact = useMutation(
packageApiRefs.packages.revokeClawPackArtifact as never,
) as unknown as (args: { releaseId: Id<"packageReleases">; reason?: string }) => Promise<unknown>;
const backfillClawPackArtifacts = useAction(
packageApiRefs.packages.backfillClawPackArtifacts as never,
) as unknown as (args: { limit?: number }) => Promise<unknown>;
const backfillClawPackSearchIndex = useAction(
packageApiRefs.packages.backfillClawPackSearchIndex as never,
) as unknown as (args: { limit?: number; cursor?: string }) => Promise<unknown>;
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
const hardDelete = useMutation(api.skills.hardDelete);
const changeOwner = useMutation(api.skills.changeOwner);
@@ -163,6 +235,15 @@ function Management() {
const [userSearch, setUserSearch] = useState("");
const [userSearchDebounced, setUserSearchDebounced] = useState("");
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
const [pluginModerationVerdict, setPluginModerationVerdict] =
useState<PackageScanStatus>("clean");
const [pluginModerationNote, setPluginModerationNote] = useState("");
const [clawPackBackfillLimit, setClawPackBackfillLimit] = useState(10);
const [clawPackIndexCursor, setClawPackIndexCursor] = useState("");
const [clawPackLastResult, setClawPackLastResult] = useState<{
kind: "artifact-backfill" | "index-backfill";
result: ClawPackBackfillResult;
} | null>(null);
const [skillOverrideNote, setSkillOverrideNote] = useState("");
const userQuery = userSearchDebounced.trim();
@@ -170,6 +251,10 @@ function Management() {
api.users.list,
admin ? { limit: 200, search: userQuery || undefined } : "skip",
) as { items: Doc<"users">[]; total: number } | undefined;
const clawPackMigration = useQuery(
packageApiRefs.packages.getClawPackMigrationStatus as never,
staff ? {} : "skip",
) as ClawPackMigrationStatus | undefined;
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null;
const selectedCanonicalSlug = selectedSkill?.canonical?.skill?.slug ?? "";
@@ -188,6 +273,13 @@ function Management() {
setPluginSearch(selectedPluginName ?? "");
}, [selectedPluginName]);
useEffect(() => {
setPluginModerationVerdict(
(selectedPlugin?.package?.scanStatus ?? "clean") as PackageScanStatus,
);
setPluginModerationNote("");
}, [selectedPlugin?.package?._id, selectedPlugin?.package?.scanStatus]);
useEffect(() => {
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
return () => clearTimeout(handle);
@@ -199,11 +291,7 @@ function Management() {
}, [userSearch]);
if (!staff) {
return (
<main className="section">
<Card>Management only.</Card>
</main>
);
return <ManagementAccessNotice me={me} />;
}
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
@@ -254,7 +342,6 @@ function Management() {
: "No users yet."
: ""
: "Loading users…";
const applySkillOverride = () => {
if (!selectedSkill?.skill) return;
void setSkillManualOverride({
@@ -288,10 +375,186 @@ function Management() {
});
};
const clawPackSampleTotal = clawPackMigration
? clawPackMigration.missingSampleSize + clawPackMigration.generatedClawPackSampleSize
: 0;
const clawPackSampleCoverage =
clawPackSampleTotal > 0 && clawPackMigration
? Math.round((clawPackMigration.generatedClawPackSampleSize / clawPackSampleTotal) * 100)
: null;
const runClawPackArtifactBackfill = () => {
const limit = Math.max(1, Math.min(clawPackBackfillLimit, 100));
if (!promptClawPackBackfill(`Build Claw Pack artifacts for up to ${limit} legacy releases?`)) {
return;
}
void backfillClawPackArtifacts({ limit })
.then((result) => {
setClawPackLastResult({
kind: "artifact-backfill",
result: result as ClawPackBackfillResult,
});
})
.catch((error) => window.alert(formatMutationError(error)));
};
const runClawPackIndexBackfill = () => {
const limit = Math.max(1, Math.min(clawPackBackfillLimit, 100));
if (
!promptClawPackBackfill(`Rebuild Claw Pack search index rows for up to ${limit} releases?`)
) {
return;
}
void backfillClawPackSearchIndex({
limit,
...(clawPackIndexCursor.trim() ? { cursor: clawPackIndexCursor.trim() } : {}),
})
.then((result) => {
const typedResult = result as ClawPackBackfillResult;
setClawPackLastResult({
kind: "index-backfill",
result: typedResult,
});
if (typedResult.continueCursor) setClawPackIndexCursor(typedResult.continueCursor);
})
.catch((error) => window.alert(formatMutationError(error)));
};
return (
<main className="section">
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to="/management/moderation" search={{ skill: undefined, plugin: undefined }}>
Open moderation queue
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
Open migration readiness
</Link>
</Button>
</div>
</div>
<PluginOperationsNav />
<Card className="mb-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="section-title text-[1.2rem] m-0">Claw Pack migration</h2>
<p className="section-subtitle m-0">
Quick status snapshot. Use the dedicated Claw Pack page for dry-run and cursor
controls.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
Open Claw Pack ops
</Link>
</Button>
</div>
<div className="management-sublist">
<div className="management-report-item">
<span className="management-report-meta">Sample coverage</span>
<span>
{clawPackMigration
? clawPackSampleCoverage === null
? "No sampled releases yet"
: `${clawPackSampleCoverage}% generated across ${clawPackSampleTotal} sampled releases`
: "Loading..."}
</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Missing sample</span>
<span>
{clawPackMigration
? `${clawPackMigration.missingSampleSize} releases in the current sample`
: "Loading..."}
</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Generated sample</span>
<span>
{clawPackMigration
? `${clawPackMigration.generatedClawPackSampleSize} artifacts / ${formatBytesCompact(
clawPackMigration.generatedClawPackBytes,
)}`
: "Loading..."}
</span>
</div>
{clawPackMigration?.missingSample?.length ? (
<div className="management-sublist">
{clawPackMigration.missingSample.slice(0, 5).map((entry) => (
<div key={entry.releaseId} className="management-report-item">
<span className="management-report-meta">
{entry.name}@{entry.version}
</span>
<span>
{entry.fileCount} files · published {formatTimestamp(entry.createdAt)} ·{" "}
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
manage
</Link>
</span>
</div>
))}
</div>
) : null}
</div>
{admin ? (
<div className="mt-3 flex flex-col gap-3">
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">batch limit</span>
<input
className="management-field"
type="number"
min={1}
max={100}
value={clawPackBackfillLimit}
onChange={(event) =>
setClawPackBackfillLimit(Number.parseInt(event.target.value, 10) || 1)
}
/>
</label>
<label className="management-control management-control-stack">
<span className="mono">index cursor</span>
<input
className="management-field"
value={clawPackIndexCursor}
onChange={(event) => setClawPackIndexCursor(event.target.value)}
placeholder="optional continue cursor"
/>
</label>
</div>
<div className="management-actions management-actions-start">
<Button type="button" onClick={runClawPackArtifactBackfill}>
Build missing artifacts
</Button>
<Button type="button" onClick={runClawPackIndexBackfill}>
Rebuild lookup index
</Button>
</div>
{clawPackLastResult ? (
<div className="management-report-item">
<span className="management-report-meta">
Last {clawPackLastResult.kind.replace("-", " ")}
</span>
<span>
processed {clawPackLastResult.result.processed ?? "?"} · succeeded{" "}
{clawPackLastResult.result.succeeded ?? "?"} · failed{" "}
{clawPackLastResult.result.failed ?? "?"}
{clawPackLastResult.result.isDone === false ? " · more available" : ""}
</span>
</div>
) : null}
</div>
) : null}
</Card>
<Card>
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
@@ -786,6 +1049,82 @@ function Management() {
: "Not highlighted"}
</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Claw Pack</span>
<span>
{latestRelease?.clawpackRevokedAt
? `Revoked ${formatTimestamp(latestRelease.clawpackRevokedAt)}`
: latestRelease?.clawpackStorageId
? `${latestRelease.clawpackFileCount ?? 0} files · ${latestRelease.clawpackSha256?.slice(0, 12) ?? "no digest"}`
: "Missing Claw Pack artifact"}
</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Host targets</span>
<span>
{latestRelease?.hostTargetsSummary?.length
? latestRelease.hostTargetsSummary
.map((target) =>
[target.os, target.arch, target.libc].filter(Boolean).join("-"),
)
.join(", ")
: "No target summary yet"}
</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Environment</span>
<span>{formatEnvironmentSummary(latestRelease?.environmentSummary)}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Moderation</span>
<span>{plugin.scanStatus}</span>
</div>
<section className="management-override-panel">
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">verdict</span>
<select
className="management-field"
value={pluginModerationVerdict}
onChange={(event) =>
setPluginModerationVerdict(event.target.value as PackageScanStatus)
}
>
<option value="clean">clean</option>
<option value="suspicious">suspicious</option>
<option value="malicious">malicious</option>
<option value="pending">pending</option>
<option value="not-run">not-run</option>
</select>
</label>
<label className="management-control management-control-stack">
<span className="mono">note</span>
<input
className="management-field"
value={pluginModerationNote}
onChange={(event) => setPluginModerationNote(event.target.value)}
placeholder="Audit note"
/>
</label>
</div>
<div className="management-actions management-actions-start">
<Button
type="button"
disabled={!pluginModerationNote.trim()}
onClick={() =>
void setPackageModerationVerdict({
packageId: plugin._id,
verdict: pluginModerationVerdict,
note: pluginModerationNote.trim(),
})
.then(() => setPluginModerationNote(""))
.catch((error) => window.alert(formatMutationError(error)))
}
>
Save verdict
</Button>
</div>
</section>
</div>
</div>
<div className="management-actions management-action-grid">
@@ -794,6 +1133,17 @@ function Management() {
View
</Link>
</Button>
{latestRelease?.version ? (
<Button asChild className="management-action-btn">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: latestRelease._id }}
search={{ skill: undefined, plugin: undefined }}
>
Inspect Claw Pack
</Link>
</Button>
) : null}
<Button
className="management-action-btn"
type="button"
@@ -806,6 +1156,27 @@ function Management() {
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
<Button
className="management-action-btn"
type="button"
disabled={
!latestRelease?.clawpackStorageId ||
Boolean(latestRelease.clawpackRevokedAt)
}
onClick={() => {
if (!latestRelease?._id) return;
const reason = promptClawPackRevocationReason(
`${plugin.name}@${latestRelease.version}`,
);
if (!reason) return;
void revokeClawPackArtifact({
releaseId: latestRelease._id,
reason,
}).catch((error) => window.alert(formatMutationError(error)));
}}
>
Revoke Claw Pack
</Button>
</div>
</div>
);
@@ -1047,6 +1418,26 @@ function Management() {
);
}
function formatBytesCompact(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0B";
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function formatEnvironmentSummary(environment: Doc<"packageReleases">["environmentSummary"]) {
if (!environment) return "No environment summary yet";
const labels = [
environment.requiresLocalDesktop ? "desktop" : null,
environment.requiresBrowser ? "browser" : null,
environment.requiresAudioDevice ? "audio" : null,
environment.requiresNetwork ? "network" : null,
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
].filter(Boolean);
return labels.length > 0 ? labels.join(", ") : "No special environment requirements";
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
+741
View File
@@ -0,0 +1,741 @@
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { deriveClawPackLifecycle } from "../../lib/packageLifecycle";
import { isAdmin, isModerator } from "../../lib/roles";
import { useAuthStatus } from "../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
getClawPackMigrationStatus: unknown;
dryRunClawPackMigrationRunForStaff: unknown;
listClawPackMigrationRunsForStaff: unknown;
startClawPackMigrationRun: unknown;
continueClawPackMigrationRun: unknown;
};
};
type ClawPackMigrationOperation = "artifact-backfill" | "failure-retry" | "search-index-backfill";
type ClawPackMigrationRunStatus = "pending" | "running" | "completed" | "failed";
type ClawPackMigrationStatus = {
missingSample: Array<{
releaseId: Id<"packageReleases">;
packageId: Id<"packages">;
name: string;
displayName: string;
version: string;
createdAt: number;
fileCount: number;
clawpackAvailable?: boolean;
clawpackBuiltAt?: number | null;
clawpackSha256?: string | null;
clawpackRevokedAt?: number | null;
}>;
failureSample: Array<{
failureId: Id<"packageClawPackBackfillFailures">;
releaseId: Id<"packageReleases">;
packageId: Id<"packages">;
name: string;
version: string;
error: string;
attemptCount: number;
firstFailedAt: number;
lastAttemptAt: number;
lastFailedAt: number;
}>;
missingSampleSize: number;
failureSampleSize: number;
generatedClawPackSampleSize: number;
generatedClawPackBytes: number;
sampleLimit: number;
};
type ClawPackBackfillResult = {
processed?: number;
succeeded?: number;
failed?: number;
skipped?: number;
isDone?: boolean;
continueCursor?: string | null;
results?: Array<{
ok?: boolean;
name?: string;
version?: string;
error?: string;
sha256?: string;
}>;
};
type ClawPackMigrationDryRunCandidate = {
failureId?: Id<"packageClawPackBackfillFailures">;
releaseId?: Id<"packageReleases">;
packageId?: Id<"packages">;
name?: string;
displayName?: string;
version?: string;
error?: string;
attemptCount?: number;
lastFailedAt?: number;
clawpackSha256?: string | null;
clawpackBuiltAt?: number | null;
};
type ClawPackMigrationDryRun = {
operation: ClawPackMigrationOperation;
limit: number;
cursor: string | null;
continueCursor: string | null;
isDone: boolean;
candidates: ClawPackMigrationDryRunCandidate[];
candidateCount: number;
failureCount: number;
};
type ClawPackMigrationRun = {
_id: Id<"clawPackMigrationRuns">;
actorUserId: Id<"users">;
operation: ClawPackMigrationOperation;
status: ClawPackMigrationRunStatus;
limit: number;
cursor?: string;
continueCursor?: string;
isDone?: boolean;
processed: number;
generated: number;
skipped: number;
failed: number;
bytesGenerated: number;
failureCounts: Record<string, number>;
lastError?: string;
startedAt?: number;
completedAt?: number;
createdAt: number;
updatedAt: number;
actor?: {
userId: Id<"users">;
handle?: string | null;
name?: string | null;
role?: string | null;
} | null;
};
type ClawPackMigrationRunList = {
items: ClawPackMigrationRun[];
limit: number;
status: ClawPackMigrationRunStatus | null;
hasMore: boolean;
};
type ClawPackMigrationRunResult = {
run: ClawPackMigrationRun | null;
result: ClawPackBackfillResult | null;
error?: string;
};
export const Route = createFileRoute("/management/clawpacks")({
component: ClawPackManagementRoute,
});
export function ClawPackManagementRoute() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname !== "/management/clawpacks") return <Outlet />;
return <ClawPackManagementConsole />;
}
function ClawPackManagementConsole() {
const { me } = useAuthStatus();
const staff = isModerator(me);
const admin = isAdmin(me);
const migration = useQuery(
packageApiRefs.packages.getClawPackMigrationStatus as never,
staff ? {} : "skip",
) as ClawPackMigrationStatus | undefined;
const migrationRuns = useQuery(
packageApiRefs.packages.listClawPackMigrationRunsForStaff as never,
staff ? ({ limit: 12 } as never) : "skip",
) as ClawPackMigrationRunList | undefined;
const [operation, setOperation] = useState<ClawPackMigrationOperation>("artifact-backfill");
const [batchLimit, setBatchLimit] = useState(10);
const [indexCursor, setIndexCursor] = useState("");
const [dryRunArgs, setDryRunArgs] = useState<{
operation: ClawPackMigrationOperation;
limit: number;
cursor?: string;
} | null>(null);
const dryRun = useQuery(
packageApiRefs.packages.dryRunClawPackMigrationRunForStaff as never,
staff && dryRunArgs ? (dryRunArgs as never) : "skip",
) as ClawPackMigrationDryRun | undefined;
const startMigrationRun = useMutation(
packageApiRefs.packages.startClawPackMigrationRun as never,
) as unknown as (args: {
operation: ClawPackMigrationOperation;
limit?: number;
cursor?: string;
}) => Promise<ClawPackMigrationRun | null>;
const continueMigrationRun = useAction(
packageApiRefs.packages.continueClawPackMigrationRun as never,
) as unknown as (args: {
runId: Id<"clawPackMigrationRuns">;
}) => Promise<ClawPackMigrationRunResult>;
const [lastResult, setLastResult] = useState<{
kind: ClawPackMigrationOperation;
result: ClawPackBackfillResult | null;
run: ClawPackMigrationRun | null;
} | null>(null);
const [activeRunId, setActiveRunId] = useState<Id<"clawPackMigrationRuns"> | null>(null);
const [error, setError] = useState<string | null>(null);
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
const limit = Math.max(1, Math.min(batchLimit, 100));
const sampleTotal = migration
? migration.missingSampleSize + migration.generatedClawPackSampleSize
: 0;
const coverage =
migration && sampleTotal > 0
? Math.round((migration.generatedClawPackSampleSize / sampleTotal) * 100)
: null;
const runDryRun = () => {
setError(null);
setDryRunArgs({
operation,
limit,
...(operation === "search-index-backfill" && indexCursor.trim()
? { cursor: indexCursor.trim() }
: {}),
});
};
const createMigrationRun = () => {
if (
!window.confirm(
`Create a ${formatOperation(operation)} migration run for up to ${limit} releases?\n\nThis writes an operator run record in Convex but does not execute the batch yet.`,
)
) {
return;
}
setError(null);
void startMigrationRun({
operation,
limit,
...(operation === "search-index-backfill" && indexCursor.trim()
? { cursor: indexCursor.trim() }
: {}),
})
.then((run) => {
setLastResult({ kind: operation, result: null, run });
})
.catch((requestError) => setError(formatMutationError(requestError)));
};
const runMigrationBatch = (run: ClawPackMigrationRun) => {
if (
!window.confirm(
`Run next ${formatOperation(run.operation)} batch?\n\nRun: ${run._id}\nLimit: ${run.limit}\nCursor: ${run.continueCursor ?? run.cursor ?? "start"}\n\nThis writes Claw Pack migration data in Convex.`,
)
) {
return;
}
setError(null);
setActiveRunId(run._id);
void continueMigrationRun({ runId: run._id })
.then((result) => {
setLastResult({
kind: result.run?.operation ?? run.operation,
result: result.result,
run: result.run,
});
if (result.run?.continueCursor) setIndexCursor(result.run.continueCursor);
if (result.error) setError(result.error);
})
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveRunId(null));
};
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Claw Pack operations</h1>
<p className="section-subtitle">
Migration status, dry-run sampling, cursor resume, and rebuild controls for plugin Claw
Pack artifacts.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Back to management
</Link>
</Button>
</div>
<PluginOperationsNav current="clawpacks" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<MetricCard
label="Sample coverage"
value={coverage === null ? "unknown" : `${coverage}%`}
detail={sampleTotal > 0 ? `${sampleTotal} sampled releases` : "No sample rows yet"}
/>
<MetricCard
label="Missing sample"
value={migration ? String(migration.missingSampleSize) : "..."}
detail="eligible releases without artifacts"
/>
<MetricCard
label="Open failures"
value={migration ? String(migration.failureSampleSize) : "..."}
detail="recent failed artifact builds"
/>
<MetricCard
label="Generated sample"
value={migration ? String(migration.generatedClawPackSampleSize) : "..."}
detail="sampled releases with artifacts"
/>
<MetricCard
label="Generated bytes"
value={migration ? formatBytesCompact(migration.generatedClawPackBytes) : "..."}
detail="stored Claw Pack sample size"
/>
</div>
<Card className="mt-5">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Migration controls
</h2>
<p className="section-subtitle m-0">
Dry-run reads from the current sample. Build and index actions require confirmation.
</p>
</div>
<Badge variant="compact">{admin ? "admin write access" : "read only"}</Badge>
</div>
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">operation</span>
<select
className="management-field"
value={operation}
onChange={(event) => setOperation(event.target.value as ClawPackMigrationOperation)}
>
<option value="artifact-backfill">artifact backfill</option>
<option value="failure-retry">failure retry</option>
<option value="search-index-backfill">search index backfill</option>
</select>
</label>
<label className="management-control management-control-stack">
<span className="mono">batch limit</span>
<input
className="management-field"
type="number"
min={1}
max={100}
value={batchLimit}
onChange={(event) => setBatchLimit(Number.parseInt(event.target.value, 10) || 1)}
/>
</label>
<label className="management-control management-control-stack">
<span className="mono">index cursor</span>
<input
className="management-field"
value={indexCursor}
onChange={(event) => setIndexCursor(event.target.value)}
placeholder="optional continue cursor"
/>
</label>
</div>
<div className="management-actions management-actions-start">
<Button type="button" variant="outline" onClick={runDryRun}>
Dry-run operation
</Button>
{admin ? (
<Button type="button" onClick={createMigrationRun}>
Create migration run
</Button>
) : null}
</div>
{error ? <Badge variant="accent">{error}</Badge> : null}
{lastResult ? (
<div className="management-report-item">
<span className="management-report-meta">
Last {formatOperation(lastResult.kind)}
</span>
{lastResult.result ? (
<span>
processed {lastResult.result.processed ?? "?"} - succeeded{" "}
{lastResult.result.succeeded ?? "?"} - failed {lastResult.result.failed ?? "?"}
{lastResult.result.continueCursor
? ` - next cursor ${lastResult.result.continueCursor}`
: ""}
</span>
) : (
<span>
created {lastResult.run?._id ?? "migration run"} with status{" "}
{lastResult.run?.status ?? "pending"}
</span>
)}
</div>
) : null}
</div>
</Card>
{dryRunArgs ? (
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Dry-run result
</h2>
{dryRun === undefined ? (
<div className="stat mt-3">Loading dry-run candidates...</div>
) : (
<DryRunResult result={dryRun} />
)}
</Card>
) : null}
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Migration runs
</h2>
{migrationRuns === undefined ? (
<div className="stat mt-3">Loading migration run ledger...</div>
) : migrationRuns.items.length === 0 ? (
<div className="stat mt-3">No Claw Pack migration runs have been created yet.</div>
) : (
<MigrationRunRows
rows={migrationRuns.items}
admin={admin}
activeRunId={activeRunId}
onContinue={runMigrationBatch}
/>
)}
</Card>
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Missing artifacts
</h2>
{migration === undefined ? (
<div className="stat mt-3">Loading Claw Pack migration status...</div>
) : migration.missingSample.length === 0 ? (
<div className="stat mt-3">No missing Claw Pack artifacts in the current sample.</div>
) : (
<MigrationRows rows={migration.missingSample} />
)}
</Card>
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Failed artifact builds
</h2>
{migration === undefined ? (
<div className="stat mt-3">Loading failure ledger...</div>
) : migration.failureSample.length === 0 ? (
<div className="stat mt-3">No open Claw Pack backfill failures in the sample.</div>
) : (
<FailureRows rows={migration.failureSample} />
)}
</Card>
</main>
);
}
function MetricCard(props: { label: string; value: string; detail: string }) {
return (
<Card>
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<strong className="text-[color:var(--ink)]">{props.value}</strong>
</div>
<p className="section-subtitle m-0 mt-2">{props.detail}</p>
</Card>
);
}
function MigrationRows(props: { rows: ClawPackMigrationStatus["missingSample"] }) {
return (
<div className="management-list mt-3">
{props.rows.map((entry) => (
<MigrationRow key={entry.releaseId} entry={entry} />
))}
</div>
);
}
function DryRunResult(props: { result: ClawPackMigrationDryRun }) {
const result = props.result;
return (
<div className="mt-3 flex flex-col gap-3">
<div className="management-report-item">
<span className="management-report-meta">operation</span>
<span>
{formatOperation(result.operation)} - {result.candidateCount} candidates
{result.continueCursor ? ` - next cursor ${result.continueCursor}` : ""}
{result.isDone ? " - done" : ""}
</span>
</div>
{result.candidates.length === 0 ? (
<div className="stat">No candidates found for this dry-run.</div>
) : (
<div className="management-list">
{result.candidates.map((candidate, index) => (
<DryRunCandidateRow
key={candidate.failureId ?? candidate.releaseId ?? `${result.operation}-${index}`}
candidate={candidate}
/>
))}
</div>
)}
</div>
);
}
function DryRunCandidateRow(props: { candidate: ClawPackMigrationDryRunCandidate }) {
const candidate = props.candidate;
const title = candidate.displayName ?? candidate.name ?? "Claw Pack candidate";
return (
<div className="management-item">
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
{candidate.name ? (
<Link to="/plugins/$name" params={{ name: candidate.name }}>
{title}
</Link>
) : (
<strong>{title}</strong>
)}
{candidate.error ? <Badge variant="destructive">failed</Badge> : null}
</div>
<div className="section-subtitle m-0">
{candidate.name ?? candidate.packageId ?? "package"}@{candidate.version ?? "unknown"}
{candidate.attemptCount ? ` - ${candidate.attemptCount} attempts` : ""}
</div>
{candidate.error ? (
<div className="management-report-item">
<span className="management-report-meta">last error</span>
<span>{candidate.error}</span>
</div>
) : null}
</div>
{candidate.releaseId ? (
<div className="management-actions">
<Button asChild size="sm" variant="outline">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: candidate.releaseId }}
search={{ skill: undefined, plugin: undefined }}
>
Details
</Link>
</Button>
</div>
) : null}
</div>
);
}
function MigrationRunRows(props: {
rows: ClawPackMigrationRun[];
admin: boolean;
activeRunId: Id<"clawPackMigrationRuns"> | null;
onContinue: (run: ClawPackMigrationRun) => void;
}) {
return (
<div className="management-list mt-3">
{props.rows.map((run) => (
<MigrationRunRow
key={run._id}
run={run}
admin={props.admin}
active={props.activeRunId === run._id}
onContinue={props.onContinue}
/>
))}
</div>
);
}
function MigrationRunRow(props: {
run: ClawPackMigrationRun;
admin: boolean;
active: boolean;
onContinue: (run: ClawPackMigrationRun) => void;
}) {
const run = props.run;
const canContinue = props.admin && run.status !== "completed" && run.status !== "running";
return (
<div className="management-item">
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
<strong>{formatOperation(run.operation)}</strong>
<Badge variant={run.status === "failed" ? "destructive" : "compact"}>{run.status}</Badge>
</div>
<div className="section-subtitle m-0">
limit {run.limit} - processed {run.processed} - generated {run.generated} - skipped{" "}
{run.skipped} - failed {run.failed}
</div>
<div className="section-subtitle m-0">
created {formatTimestamp(run.createdAt)}
{run.actor?.handle ? ` by @${run.actor.handle}` : ""}
{run.continueCursor ? ` - next cursor ${run.continueCursor}` : ""}
</div>
{run.lastError ? (
<div className="management-report-item">
<span className="management-report-meta">last error</span>
<span>{run.lastError}</span>
</div>
) : null}
</div>
{canContinue ? (
<div className="management-actions">
<Button
type="button"
size="sm"
onClick={() => props.onContinue(run)}
disabled={props.active}
>
{props.active ? "Running..." : "Run next batch"}
</Button>
</div>
) : null}
</div>
);
}
function MigrationRow(props: { entry: ClawPackMigrationStatus["missingSample"][number] }) {
const entry = props.entry;
const lifecycle = deriveClawPackLifecycle({
available: entry.clawpackAvailable ?? false,
revokedAt: entry.clawpackRevokedAt,
});
return (
<div className="management-item">
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
<Link to="/plugins/$name" params={{ name: entry.name }}>
{entry.displayName}
</Link>
<Badge variant={lifecycle.severity === "danger" ? "destructive" : "accent"}>
{lifecycle.label}
</Badge>
</div>
<div className="section-subtitle m-0">
{entry.name}@{entry.version} - {entry.fileCount} files - published{" "}
{formatTimestamp(entry.createdAt)}
</div>
<div className="section-subtitle m-0">{lifecycle.action ?? lifecycle.description}</div>
</div>
<div className="management-actions">
<Button asChild size="sm" variant="outline">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: entry.releaseId }}
search={{ skill: undefined, plugin: undefined }}
>
Details
</Link>
</Button>
<Button asChild size="sm" variant="outline">
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
Manage
</Link>
</Button>
</div>
</div>
);
}
function FailureRows(props: { rows: ClawPackMigrationStatus["failureSample"] }) {
return (
<div className="management-list mt-3">
{props.rows.map((entry) => (
<FailureRow key={entry.failureId} entry={entry} />
))}
</div>
);
}
function FailureRow(props: { entry: ClawPackMigrationStatus["failureSample"][number] }) {
const entry = props.entry;
const lifecycle = deriveClawPackLifecycle({
available: false,
buildError: entry.error,
});
return (
<div className="management-item">
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
<Link to="/plugins/$name" params={{ name: entry.name }}>
{entry.name}@{entry.version}
</Link>
<Badge variant="destructive">{lifecycle.label}</Badge>
</div>
<div className="section-subtitle m-0">
{entry.attemptCount} attempts - last failed {formatTimestamp(entry.lastFailedAt)}
</div>
<div className="management-report-item">
<span className="management-report-meta">next action</span>
<span>{lifecycle.action}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">last error</span>
<span>{entry.error}</span>
</div>
</div>
<div className="management-actions">
<Button asChild size="sm" variant="outline">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: entry.releaseId }}
search={{ skill: undefined, plugin: undefined }}
>
Details
</Link>
</Button>
<Button asChild size="sm" variant="outline">
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
Manage
</Link>
</Button>
</div>
</div>
);
}
function formatBytesCompact(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0B";
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
function formatOperation(value: ClawPackMigrationOperation) {
return value.replaceAll("-", " ");
}
function formatMutationError(error: unknown) {
if (error instanceof Error && error.message.trim()) return error.message.trim();
return "Request failed.";
}
@@ -0,0 +1,475 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import type { Id } from "../../../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../../../components/PluginOperationsNav";
import { Badge } from "../../../../components/ui/badge";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { deriveClawPackLifecycle } from "../../../../lib/packageLifecycle";
import { isModerator } from "../../../../lib/roles";
import { useAuthStatus } from "../../../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
getClawPackReleaseForStaff: unknown;
};
};
type ReleaseSourceSummary = {
kind: string | null;
repo: string | null;
url: string | null;
ref: string | null;
commit: string | null;
path: string | null;
} | null;
type ClawPackReleaseDetail = {
package: {
packageId: Id<"packages">;
name: string;
displayName: string;
family: string;
channel: string;
isOfficial: boolean;
scanStatus: string;
updatedAt: number;
};
release: {
releaseId: Id<"packageReleases">;
version: string;
createdAt: number;
fileCount: number;
fileSample: Array<{ path: string; size: number; sha256: string }>;
clawpackStorageId: Id<"_storage"> | null;
clawpackSha256: string | null;
clawpackSize: number | null;
clawpackSpecVersion: number | null;
clawpackFormat: string | null;
clawpackFileCount: number | null;
clawpackManifestSha256: string | null;
clawpackBuiltAt: number | null;
clawpackBuildVersion: string | null;
clawpackRevokedAt: number | null;
clawpackRevocationReason: string | null;
hostTargetsSummary: Array<{ os?: string; arch?: string; libc?: string }>;
environmentSummary: {
requiresLocalDesktop?: boolean;
requiresBrowser?: boolean;
requiresAudioDevice?: boolean;
requiresNetwork?: boolean;
requiresExternalServices?: string[];
requiresOsPermissions?: string[];
} | null;
source: ReleaseSourceSummary;
verificationScanStatus: string | null;
vtStatus: string | null;
vtVerdict: string | null;
llmStatus: string | null;
llmVerdict: string | null;
staticScanStatus: string | null;
staticScanSummary: string | null;
staticScanReasonCodes: string[];
};
artifacts: Array<{
artifactId: Id<"packageReleaseArtifacts">;
kind: string;
targetKey: string | null;
storageId: Id<"_storage">;
sha256: string;
size: number;
format: string;
status: string;
createdAt: number;
revokedAt: number | null;
revocationReason: string | null;
}>;
failures: Array<{
failureId: Id<"packageClawPackBackfillFailures">;
error: string;
attemptCount: number;
firstFailedAt: number;
lastAttemptAt: number;
lastFailedAt: number;
resolvedAt: number | null;
}>;
searchIndexRows: Array<{
rowId: Id<"packageClawPackSearchIndex">;
kind: string;
key: string;
updatedAt: number;
createdAt: number;
}>;
} | null;
export const Route = createFileRoute("/management/clawpacks/releases/$releaseId")({
component: ClawPackReleaseDetailRoute,
});
function ClawPackReleaseDetailRoute() {
const { releaseId } = Route.useParams();
return <ClawPackReleaseDetailPage releaseId={releaseId as Id<"packageReleases">} />;
}
export function ClawPackReleaseDetailPage(props: { releaseId: Id<"packageReleases"> }) {
const { me } = useAuthStatus();
const staff = isModerator(me);
const detail = useQuery(
packageApiRefs.packages.getClawPackReleaseForStaff as never,
staff ? ({ releaseId: props.releaseId } as never) : "skip",
) as ClawPackReleaseDetail | undefined;
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Claw Pack release detail</h1>
<p className="section-subtitle">
Release-level artifact state, failure history, lookup rows, and provenance evidence.
</p>
</div>
<div className="management-actions">
<Button asChild variant="outline" size="sm">
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
Claw Pack ops
</Link>
</Button>
{detail?.package ? (
<Button asChild variant="ghost" size="sm">
<Link
to="/management/plugins/$name"
params={{ name: detail.package.name }}
search={{ skill: undefined, plugin: undefined }}
>
Plugin detail
</Link>
</Button>
) : null}
</div>
</div>
<PluginOperationsNav current="clawpacks" />
{detail === undefined ? (
<Card>Loading Claw Pack release...</Card>
) : detail === null ? (
<Card>No plugin release found for this Claw Pack record.</Card>
) : (
<ClawPackReleaseDetailBody detail={detail} />
)}
</main>
);
}
function ClawPackReleaseDetailBody(props: { detail: Exclude<ClawPackReleaseDetail, null> }) {
const { detail } = props;
const lifecycle = deriveClawPackLifecycle({
available: Boolean(detail.release.clawpackStorageId),
revokedAt: detail.release.clawpackRevokedAt ?? undefined,
buildError: detail.failures[0]?.error,
});
return (
<div className="grid gap-5">
<Card>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-display text-2xl font-bold text-[color:var(--ink)]">
{detail.package.displayName}
</h2>
<Badge variant={lifecycleBadgeVariant(lifecycle.severity)}>{lifecycle.label}</Badge>
{detail.package.isOfficial ? <Badge variant="compact">official</Badge> : null}
<Badge variant="compact">{detail.package.family}</Badge>
</div>
<p className="section-subtitle m-0">
<span className="mono">{detail.package.name}</span>@{detail.release.version}
</p>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn" size="sm">
<Link to="/plugins/$name" params={{ name: detail.package.name }}>
Public page
</Link>
</Button>
<Button asChild className="management-action-btn" size="sm" variant="outline">
<Link
to="/management/plugins/$name"
params={{ name: detail.package.name }}
search={{ skill: undefined, plugin: undefined }}
>
Plugin staff page
</Link>
</Button>
</div>
</div>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Claw Pack state
</h3>
<div className="management-sublist">
<ReportField label="lifecycle" value={lifecycle.description} />
<ReportField
label="built"
value={
detail.release.clawpackBuiltAt
? formatTimestamp(detail.release.clawpackBuiltAt)
: "missing"
}
/>
<ReportField label="format" value={detail.release.clawpackFormat ?? "missing"} />
<ReportField
label="zip digest"
value={detail.release.clawpackSha256 ?? "missing"}
mono={Boolean(detail.release.clawpackSha256)}
/>
<ReportField
label="manifest digest"
value={detail.release.clawpackManifestSha256 ?? "missing"}
mono={Boolean(detail.release.clawpackManifestSha256)}
/>
<ReportField
label="size"
value={
detail.release.clawpackSize
? formatBytesCompact(detail.release.clawpackSize)
: "missing"
}
/>
<ReportField
label="files"
value={detail.release.clawpackFileCount?.toString() ?? "missing"}
/>
<ReportField
label="revocation"
value={
detail.release.clawpackRevokedAt
? `${formatTimestamp(detail.release.clawpackRevokedAt)} - ${
detail.release.clawpackRevocationReason ?? "no reason"
}`
: "none"
}
/>
</div>
</Card>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Provenance and scans
</h3>
<div className="management-sublist">
<ReportField label="source" value={formatSource(detail.release.source)} />
<ReportField
label="verification"
value={detail.release.verificationScanStatus ?? "missing"}
/>
<ReportField label="static scan" value={detail.release.staticScanStatus ?? "missing"} />
<ReportField label="VirusTotal" value={detail.release.vtStatus ?? "missing"} />
<ReportField label="LLM review" value={detail.release.llmStatus ?? "missing"} />
<ReportField
label="static reasons"
value={
detail.release.staticScanReasonCodes.length
? detail.release.staticScanReasonCodes.join(", ")
: "none"
}
/>
</div>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<EvidenceList
title="Host targets"
values={detail.release.hostTargetsSummary.map(formatHostTarget)}
empty="No host target summary yet"
/>
<EvidenceList
title="Environment"
values={formatEnvironmentSummary(detail.release.environmentSummary)}
empty="No environment summary yet"
/>
</div>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Artifact rows
</h3>
<div className="management-list mt-3">
{detail.artifacts.length ? (
detail.artifacts.map((artifact) => (
<div className="management-item" key={artifact.artifactId}>
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={artifact.status === "active" ? "success" : "compact"}>
{artifact.status}
</Badge>
<span className="mono">{artifact.kind}</span>
{artifact.targetKey ? (
<Badge variant="compact">{artifact.targetKey}</Badge>
) : null}
</div>
<div className="section-subtitle m-0">
{formatBytesCompact(artifact.size)} - {artifact.format} - created{" "}
{formatTimestamp(artifact.createdAt)}
</div>
<div className="mono break-all">{artifact.sha256}</div>
</div>
</div>
))
) : (
<div className="stat">No artifact rows recorded for this release.</div>
)}
</div>
</Card>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Failure ledger
</h3>
<div className="management-list mt-3">
{detail.failures.length ? (
detail.failures.map((failure) => (
<div className="management-item" key={failure.failureId}>
<div className="management-item-main">
<div className="section-subtitle m-0">
{failure.attemptCount} attempts - last failed{" "}
{formatTimestamp(failure.lastFailedAt)}
</div>
<div className="management-report-item">
<span className="management-report-meta">error</span>
<span>{failure.error}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">resolved</span>
<span>{failure.resolvedAt ? formatTimestamp(failure.resolvedAt) : "open"}</span>
</div>
</div>
</div>
))
) : (
<div className="stat">No Claw Pack build failures recorded for this release.</div>
)}
</div>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Lookup index
</h3>
<div className="management-list mt-3">
{detail.searchIndexRows.length ? (
detail.searchIndexRows.map((row) => (
<div className="management-report-item" key={row.rowId}>
<span className="management-report-meta">{row.kind}</span>
<span className="mono">{row.key}</span>
</div>
))
) : (
<div className="stat">No lookup rows for this release.</div>
)}
</div>
</Card>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Release files
</h3>
<p className="section-subtitle m-0 mt-2">
Showing {detail.release.fileSample.length} of {detail.release.fileCount} files.
</p>
<div className="management-list mt-3">
{detail.release.fileSample.map((file) => (
<div className="management-report-item" key={file.path}>
<span className="mono break-all">{file.path}</span>
<span>{formatBytesCompact(file.size)}</span>
</div>
))}
</div>
</Card>
</div>
</div>
);
}
function ReportField(props: { label: string; value: string; mono?: boolean }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span className={props.mono ? "mono break-all" : undefined}>{props.value}</span>
</div>
);
}
function EvidenceList(props: { title: string; values: string[]; empty: string }) {
return (
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">{props.title}</h3>
<div className="management-sublist">
{props.values.length ? (
props.values.map((value) => (
<div className="management-report-item" key={value}>
<span>{value}</span>
</div>
))
) : (
<div className="stat">{props.empty}</div>
)}
</div>
</Card>
);
}
function lifecycleBadgeVariant(severity: string) {
if (severity === "success") return "success";
if (severity === "danger") return "destructive";
if (severity === "warning") return "warning";
return "compact";
}
function formatSource(source: ReleaseSourceSummary) {
if (!source) return "missing";
return (
[source.repo ?? source.url, source.ref, source.path, source.commit?.slice(0, 12)]
.filter(Boolean)
.join(" / ") || "missing"
);
}
function formatHostTarget(target: { os?: string; arch?: string; libc?: string }) {
return [target.os, target.arch, target.libc].filter(Boolean).join("-") || "unknown";
}
function formatEnvironmentSummary(
environment: Exclude<ClawPackReleaseDetail, null>["release"]["environmentSummary"],
) {
if (!environment) return [];
return [
environment.requiresLocalDesktop ? "desktop" : null,
environment.requiresBrowser ? "browser" : null,
environment.requiresAudioDevice ? "audio" : null,
environment.requiresNetwork ? "network" : null,
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
].filter((value): value is string => Boolean(value));
}
function formatBytesCompact(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0B";
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
+254
View File
@@ -0,0 +1,254 @@
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import {
formatReadinessSource,
formatReadinessClawPack,
readinessBlockerLabel,
type MigrationReadinessItem,
type MigrationReadinessResult,
readinessStateLabel,
} from "../../lib/officialMigrationReadiness";
import { isModerator } from "../../lib/roles";
import { useAuthStatus } from "../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
listOfficialMigrationReadinessForStaff: unknown;
};
};
export const Route = createFileRoute("/management/migrations")({
component: OfficialMigrationRoute,
});
export function OfficialMigrationRoute() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname !== "/management/migrations") return <Outlet />;
return <OfficialMigrationConsole />;
}
function OfficialMigrationConsole() {
const { me } = useAuthStatus();
const staff = isModerator(me);
const readiness = useQuery(
packageApiRefs.packages.listOfficialMigrationReadinessForStaff as never,
staff ? {} : "skip",
) as MigrationReadinessResult | undefined;
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
const items = readiness?.items ?? [];
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">OpenClaw migration readiness</h1>
<p className="section-subtitle">
ClawHub-side package, artifact, metadata, scan, and source gates for future bundled
plugin externalization.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Back to management
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
Claw Pack ops
</Link>
</Button>
</div>
</div>
<PluginOperationsNav current="migrations" />
<div className="grid gap-4 md:grid-cols-3">
<MetricCard
label="Ready"
value={readiness ? String(readiness.readyCount) : "..."}
detail="all ClawHub gates green"
/>
<MetricCard
label="Blocked"
value={readiness ? String(readiness.blockedCount) : "..."}
detail="missing package, artifact, metadata, source, or scan"
/>
<MetricCard
label="Tracked"
value={readiness ? String(readiness.items.length) : "..."}
detail={readiness ? `generated ${formatTimestamp(readiness.generatedAt)}` : "loading"}
/>
</div>
<div className="mt-5 grid gap-4">
{readiness === undefined ? (
<Card>Loading migration readiness</Card>
) : items.length === 0 ? (
<Card>No migration candidates are configured.</Card>
) : (
items.map((item) => <MigrationCard key={item.bundledPluginId} item={item} />)
)}
</div>
</main>
);
}
function MigrationCard(props: { item: MigrationReadinessItem }) {
const { item } = props;
const ready = item.readinessState === "ready-for-openclaw";
return (
<Card>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
{item.displayName}
</h2>
<Badge variant={ready ? "compact" : "accent"}>
{readinessStateLabel(item.readinessState)}
</Badge>
{item.package?.isOfficial ? <Badge variant="compact">official</Badge> : null}
</div>
<p className="section-subtitle m-0">
<span className="mono">{item.bundledPluginId}</span> {" "}
<span className="mono">{item.desiredPackageName}</span>
</p>
</div>
<div className="management-actions management-actions-start">
{item.package ? (
<Button asChild variant="outline" size="sm">
<Link to="/plugins/$name" params={{ name: item.package.name }}>
Plugin page
</Link>
</Button>
) : null}
{item.package ? (
<Button asChild variant="ghost" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: item.package.name }}>
Manage
</Link>
</Button>
) : null}
<Button asChild variant="ghost" size="sm">
<Link
to="/management/migrations/$bundledPluginId"
params={{ bundledPluginId: item.bundledPluginId }}
search={{ skill: undefined, plugin: undefined }}
>
Details
</Link>
</Button>
</div>
</div>
<div className="management-tool-grid">
<ReportField label="publisher" value={`@${item.publisherHandle}`} />
<ReportField
label="source"
value={formatReadinessSource(item)}
tone={item.gates.sourceLinked ? undefined : "warn"}
/>
<ReportField
label="release"
value={item.latestRelease ? `v${item.latestRelease.version}` : "missing"}
tone={item.latestRelease ? undefined : "warn"}
/>
<ReportField
label="Claw Pack"
value={formatReadinessClawPack(item)}
tone={item.gates.clawpackAvailable ? undefined : "warn"}
/>
</div>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Gate label="Package" ok={item.gates.packageExists} />
<Gate label="Release" ok={item.gates.releaseExists} />
<Gate label="Claw Pack" ok={item.gates.clawpackAvailable} />
<Gate label="Host matrix" ok={item.gates.hostMatrixComplete} />
<Gate label="Environment" ok={item.gates.environmentComplete} />
<Gate label="Source" ok={item.gates.sourceLinked} />
<Gate label="Scan" ok={item.gates.scanClear} />
<Gate label="Runtime bundle" ok={item.gates.runtimeBundleStatus === "not-required"} />
</div>
<div className="flex flex-wrap gap-1.5">
{item.latestRelease?.hostTargetKeys.length ? (
item.latestRelease.hostTargetKeys.map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))
) : (
<Badge variant="compact">no host targets</Badge>
)}
{item.latestRelease?.environmentFlags.map((flag) => (
<Badge key={flag} variant="compact">
{flag}
</Badge>
))}
</div>
{item.blockers.length > 0 ? (
<div className="management-report-item">
<span className="management-report-meta">blockers</span>
<span>{item.blockers.map(readinessBlockerLabel).join(", ")}</span>
</div>
) : null}
</div>
</Card>
);
}
function MetricCard(props: { label: string; value: string; detail: string }) {
return (
<Card>
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<strong className="text-[color:var(--ink)]">{props.value}</strong>
</div>
<p className="section-subtitle m-0 mt-2">{props.detail}</p>
</Card>
);
}
function ReportField(props: { label: string; value: string; tone?: "warn" }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span className={props.tone === "warn" ? "text-[color:var(--danger)]" : undefined}>
{props.value}
</span>
</div>
);
}
function Gate(props: { label: string; ok: boolean }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span
className={
props.ok ? "text-emerald-700 dark:text-emerald-300" : "text-[color:var(--danger)]"
}
>
{props.ok ? "ready" : "blocked"}
</span>
</div>
);
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
@@ -0,0 +1,240 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../convex/_generated/api";
import { ManagementAccessNotice } from "../../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../../components/PluginOperationsNav";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { Card } from "../../../components/ui/card";
import {
formatReadinessSource,
formatReadinessClawPack,
readinessBlockerLabel,
type MigrationReadinessItem,
type MigrationReadinessResult,
readinessStateLabel,
} from "../../../lib/officialMigrationReadiness";
import { isModerator } from "../../../lib/roles";
import { useAuthStatus } from "../../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
listOfficialMigrationReadinessForStaff: unknown;
};
};
export const Route = createFileRoute("/management/migrations/$bundledPluginId")({
component: OfficialMigrationDetailRoute,
});
export function OfficialMigrationDetailRoute() {
const params = Route.useParams();
return <OfficialMigrationDetailPage bundledPluginId={params.bundledPluginId} />;
}
export function OfficialMigrationDetailPage(props: { bundledPluginId: string }) {
const { me } = useAuthStatus();
const staff = isModerator(me);
const readiness = useQuery(
packageApiRefs.packages.listOfficialMigrationReadinessForStaff as never,
staff ? {} : "skip",
) as MigrationReadinessResult | undefined;
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
const item = readiness?.items.find(
(candidate) => candidate.bundledPluginId === props.bundledPluginId,
);
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Migration candidate</h1>
<p className="section-subtitle">
ClawHub readiness gates for one future OpenClaw bundled-plugin externalization target.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
Back to readiness
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
Claw Pack ops
</Link>
</Button>
</div>
</div>
<PluginOperationsNav current="migrations" />
{readiness === undefined ? (
<Card>Loading migration candidate</Card>
) : item ? (
<MigrationCandidateDetail item={item} />
) : (
<Card>
<div className="management-report-item">
<span className="management-report-meta">not found</span>
<span className="mono">{props.bundledPluginId}</span>
</div>
<p className="section-subtitle m-0 mt-2">
No official migration candidate is configured for this bundled plugin id.
</p>
</Card>
)}
</main>
);
}
function MigrationCandidateDetail(props: { item: MigrationReadinessItem }) {
const { item } = props;
const ready = item.readinessState === "ready-for-openclaw";
return (
<div className="grid gap-5">
<Card>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-display text-2xl font-bold text-[color:var(--ink)]">
{item.displayName}
</h2>
<Badge variant={ready ? "compact" : "accent"}>
{readinessStateLabel(item.readinessState)}
</Badge>
{item.package?.isOfficial ? <Badge variant="compact">official</Badge> : null}
</div>
<p className="section-subtitle m-0">
<span className="mono">{item.bundledPluginId}</span> to{" "}
<span className="mono">{item.desiredPackageName}</span>
</p>
</div>
<div className="management-actions management-actions-start">
{item.package ? (
<Button asChild variant="outline" size="sm">
<Link to="/plugins/$name" params={{ name: item.package.name }}>
Plugin page
</Link>
</Button>
) : null}
{item.package ? (
<Button asChild variant="ghost" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: item.package.name }}>
Manage package
</Link>
</Button>
) : null}
</div>
</div>
<div className="management-tool-grid">
<ReportField label="publisher" value={`@${item.publisherHandle}`} />
<ReportField label="source" value={formatReadinessSource(item)} />
<ReportField label="source path" value={item.sourcePath || "."} />
<ReportField label="Claw Pack" value={formatReadinessClawPack(item)} />
<ReportField
label="latest release"
value={item.latestRelease ? `v${item.latestRelease.version}` : "missing"}
/>
<ReportField
label="scan"
value={item.latestRelease?.scanStatus ?? item.package?.scanStatus ?? "missing"}
/>
<ReportField
label="runtime bundle"
value={item.gates.runtimeBundleStatus.replaceAll("-", " ")}
/>
<ReportField
label="generated package"
value={item.package ? item.package.displayName : "missing"}
/>
</div>
</div>
</Card>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
Readiness gates
</h3>
<div className="mt-3 grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<Gate label="Package exists" ok={item.gates.packageExists} />
<Gate label="Release exists" ok={item.gates.releaseExists} />
<Gate label="Claw Pack active" ok={item.gates.clawpackAvailable} />
<Gate label="Host matrix complete" ok={item.gates.hostMatrixComplete} />
<Gate label="Environment complete" ok={item.gates.environmentComplete} />
<Gate label="Source linked" ok={item.gates.sourceLinked} />
<Gate label="Scan clear" ok={item.gates.scanClear} />
<Gate
label="Runtime bundle decided"
ok={item.gates.runtimeBundleStatus === "not-required"}
/>
</div>
</Card>
<Card>
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">Evidence</h3>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<EvidenceList
title="Required hosts"
values={item.requiredHostTargets}
empty="no required hosts configured"
/>
<EvidenceList
title="Release hosts"
values={item.latestRelease?.hostTargetKeys ?? []}
empty="no release host targets"
/>
<EvidenceList
title="Environment flags"
values={item.latestRelease?.environmentFlags ?? []}
empty="no environment flags"
/>
<EvidenceList
title="Blockers"
values={item.blockers.map(readinessBlockerLabel)}
empty="no blockers"
/>
</div>
</Card>
</div>
);
}
function ReportField(props: { label: string; value: string }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span>{props.value}</span>
</div>
);
}
function Gate(props: { label: string; ok: boolean }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span
className={
props.ok ? "text-emerald-700 dark:text-emerald-300" : "text-[color:var(--danger)]"
}
>
{props.ok ? "ready" : "blocked"}
</span>
</div>
);
}
function EvidenceList(props: { title: string; values: string[]; empty: string }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.title}</span>
<span>{props.values.length ? props.values.join(", ") : props.empty}</span>
</div>
);
}
+428
View File
@@ -0,0 +1,428 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { familyLabel } from "../../lib/packageLabels";
import { isModerator } from "../../lib/roles";
import { useAuthStatus } from "../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
listModerationQueueForStaff: unknown;
setModerationVerdict: unknown;
};
};
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
type QueueStatus = PackageScanStatus | "needs-review";
type ModerationQueueItem = {
packageId: Id<"packages">;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
ownerHandle?: string;
ownerKind?: "user" | "org";
summary?: string;
latestVersion?: string;
runtimeId?: string;
executesCode?: boolean;
verificationTier?: string;
clawpackAvailable?: boolean;
hostTargetKeys: string[];
environmentFlags: string[];
scanStatus: PackageScanStatus;
updatedAt: number;
latestRelease: {
releaseId: Id<"packageReleases">;
version: string;
createdAt: number;
clawpackAvailable: boolean;
clawpackRevokedAt?: number;
clawpackSha256?: string;
clawpackFileCount?: number;
clawpackSize?: number;
clawpackManifestSha256?: string;
source: {
kind: string | null;
repo: string | null;
url: string | null;
ref: string | null;
commit: string | null;
path: string | null;
} | null;
verificationScanStatus: string | null;
vtStatus: string | null;
vtVerdict: string | null;
llmStatus: string | null;
llmVerdict: string | null;
llmSummary: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
staticScanSummary: string | null;
staticScanReasonCodes: string[];
} | null;
};
type ModerationQueueResult = {
items: ModerationQueueItem[];
status: QueueStatus;
limit: number;
hasMore: boolean;
counts?: Record<QueueStatus, { value: number; capped: boolean }>;
};
const QUEUE_STATUSES: Array<{ value: QueueStatus; label: string }> = [
{ value: "needs-review", label: "Needs review" },
{ value: "pending", label: "Pending" },
{ value: "suspicious", label: "Suspicious" },
{ value: "malicious", label: "Malicious" },
{ value: "not-run", label: "Not run" },
{ value: "clean", label: "Clean" },
];
const VERDICTS: Array<{ value: PackageScanStatus; label: string }> = [
{ value: "clean", label: "Approve clean" },
{ value: "suspicious", label: "Mark suspicious" },
{ value: "malicious", label: "Mark malicious" },
{ value: "pending", label: "Hold pending" },
];
export const Route = createFileRoute("/management/moderation")({
component: PluginModerationRoute,
});
export function PluginModerationRoute() {
const { me } = useAuthStatus();
const staff = isModerator(me);
const [status, setStatus] = useState<QueueStatus>("needs-review");
const [limit, setLimit] = useState(30);
const [activeWrite, setActiveWrite] = useState<string | null>(null);
const queue = useQuery(
packageApiRefs.packages.listModerationQueueForStaff as never,
staff ? ({ status, limit } as never) : "skip",
) as ModerationQueueResult | undefined;
const setModerationVerdict = useMutation(
packageApiRefs.packages.setModerationVerdict as never,
) as unknown as (args: {
packageId: Id<"packages">;
verdict: PackageScanStatus;
note?: string;
}) => Promise<unknown>;
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
const normalizedLimit = Math.max(1, Math.min(limit, 100));
const runVerdict = async (item: ModerationQueueItem, verdict: PackageScanStatus) => {
const note = window.prompt(`Audit note for ${item.name} -> ${verdict}`);
if (note === null) return;
const trimmed = note.trim();
if (!trimmed) {
window.alert("Audit note is required.");
return;
}
if (
!window.confirm(
`Set ${item.name} moderation verdict to ${verdict}?\n\nThis writes a package moderation verdict and audit log in Convex.`,
)
) {
return;
}
setActiveWrite(`${item.packageId}:${verdict}`);
try {
await setModerationVerdict({ packageId: item.packageId, verdict, note: trimmed });
} catch (error) {
window.alert(formatMutationError(error));
} finally {
setActiveWrite(null);
}
};
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Plugin moderation</h1>
<p className="section-subtitle">
Review code and bundle plugins by scan state, Claw Pack status, channel, and release
risk.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Back to management
</Link>
</Button>
</div>
<PluginOperationsNav current="moderation" />
<Card className="mb-5">
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">queue</span>
<select
className="management-field"
value={status}
onChange={(event) => setStatus(event.target.value as QueueStatus)}
>
{QUEUE_STATUSES.map((entry) => (
<option key={entry.value} value={entry.value}>
{entry.label}
{queue?.counts?.[entry.value]
? ` (${formatQueueCount(queue.counts[entry.value])})`
: ""}
</option>
))}
</select>
</label>
<label className="management-control management-control-stack">
<span className="mono">limit</span>
<input
className="management-field"
type="number"
min={1}
max={100}
value={limit}
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
/>
</label>
</div>
</Card>
{queue?.counts ? (
<div className="mb-5 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
{QUEUE_STATUSES.map((entry) => (
<button
key={entry.value}
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3 text-left transition hover:border-[color:var(--accent)]"
type="button"
onClick={() => setStatus(entry.value)}
>
<span className="management-report-meta">{entry.label}</span>
<strong className="mt-1 block text-lg text-[color:var(--ink)]">
{formatQueueCount(queue.counts?.[entry.value])}
</strong>
</button>
))}
</div>
) : null}
<div className="grid gap-4">
{(queue?.items ?? []).map((item) => (
<Card key={item.packageId}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
{item.displayName}
</h2>
<Badge variant={item.scanStatus === "malicious" ? "accent" : "compact"}>
{item.scanStatus}
</Badge>
<Badge variant="compact">{familyLabel(item.family)}</Badge>
<Badge variant="compact">{item.channel}</Badge>
</div>
<p className="section-subtitle m-0">
<span className="mono">{item.name}</span>
{item.runtimeId ? ` · runtime ${item.runtimeId}` : ""}
{item.latestVersion ? ` · latest ${item.latestVersion}` : ""}
</p>
</div>
<div className="management-actions management-actions-start">
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: item.name }}>
Manage
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link to="/plugins/$name" params={{ name: item.name }}>
Public page
</Link>
</Button>
</div>
</div>
<div className="management-tool-grid">
<ReportField label="owner" value={formatOwner(item)} />
<ReportField
label="source"
value={formatSourceState(item)}
tone={item.latestRelease?.source?.repo ? undefined : "warn"}
/>
<ReportField
label="Claw Pack"
value={formatClawPackState(item)}
tone={item.clawpackAvailable ? undefined : "warn"}
/>
<ReportField label="verification" value={item.verificationTier ?? "unverified"} />
<ReportField
label="updated"
value={new Date(item.updatedAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})}
/>
</div>
<div className="management-tool-grid">
<ReportField
label="scan rollup"
value={formatScanRollup(item)}
tone={item.scanStatus === "malicious" ? "warn" : undefined}
/>
<ReportField
label="static scan"
value={formatStaticScan(item)}
tone={item.latestRelease?.staticScanStatus === "malicious" ? "warn" : undefined}
/>
<ReportField label="LLM review" value={formatLlmReview(item)} />
<ReportField label="digest" value={formatArtifactDigest(item)} />
</div>
{item.summary ? (
<p className="m-0 text-sm text-[color:var(--ink-soft)]">{item.summary}</p>
) : null}
<div className="flex flex-wrap gap-2">
{item.hostTargetKeys.length > 0 ? (
item.hostTargetKeys.map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))
) : (
<Badge variant="compact">no host targets</Badge>
)}
{item.environmentFlags.map((flag) => (
<Badge key={flag} variant="compact">
{flag}
</Badge>
))}
</div>
<div className="management-actions management-actions-start">
{VERDICTS.map((verdict) => (
<Button
key={verdict.value}
type="button"
variant={verdict.value === "clean" ? "default" : "outline"}
size="sm"
disabled={activeWrite !== null}
onClick={() => void runVerdict(item, verdict.value)}
>
{activeWrite === `${item.packageId}:${verdict.value}`
? "Saving..."
: verdict.label}
</Button>
))}
</div>
</div>
</Card>
))}
</div>
{queue && queue.items.length === 0 ? <Card>No plugins in the selected queue.</Card> : null}
{queue?.hasMore ? (
<Card className="mt-4">
Showing the newest {normalizedLimit} rows. Narrow the queue or raise the limit for the
next review batch.
</Card>
) : null}
</main>
);
}
function ReportField(props: { label: string; value: string; tone?: "warn" }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{props.label}</span>
<span className={props.tone === "warn" ? "text-[color:var(--danger)]" : undefined}>
{props.value}
</span>
</div>
);
}
function formatOwner(item: ModerationQueueItem) {
const handle = item.ownerHandle?.trim();
if (!handle) return "unknown owner";
return `${handle}${item.ownerKind ? ` (${item.ownerKind})` : ""}`;
}
function formatClawPackState(item: ModerationQueueItem) {
if (item.latestRelease?.clawpackRevokedAt) {
return `revoked ${new Date(item.latestRelease.clawpackRevokedAt).toLocaleDateString()}`;
}
if (item.latestRelease?.clawpackAvailable || item.clawpackAvailable) {
const digest = item.latestRelease?.clawpackSha256?.slice(0, 12);
const count = item.latestRelease?.clawpackFileCount;
return (
[count ? `${count} files` : null, digest ?? null].filter(Boolean).join(" / ") || "stored"
);
}
return "missing artifact";
}
function formatSourceState(item: ModerationQueueItem) {
const source = item.latestRelease?.source;
if (!source?.repo) return item.ownerHandle ? `owned by ${item.ownerHandle}` : "missing source";
return [source.repo, source.ref ?? source.commit?.slice(0, 12), source.path]
.filter(Boolean)
.join(" / ");
}
function formatScanRollup(item: ModerationQueueItem) {
const releaseStatus = item.latestRelease?.verificationScanStatus;
return [
item.scanStatus,
releaseStatus && releaseStatus !== item.scanStatus ? releaseStatus : null,
]
.filter(Boolean)
.join(" / ");
}
function formatStaticScan(item: ModerationQueueItem) {
const release = item.latestRelease;
if (!release?.staticScanStatus) return "not run";
const reasonCodes = release.staticScanReasonCodes.slice(0, 2).join(", ");
return [release.staticScanStatus, reasonCodes || release.staticScanSummary]
.filter(Boolean)
.join(" / ");
}
function formatLlmReview(item: ModerationQueueItem) {
const release = item.latestRelease;
if (!release?.llmStatus) return "not run";
return [release.llmStatus, release.llmVerdict, release.llmSummary].filter(Boolean).join(" / ");
}
function formatArtifactDigest(item: ModerationQueueItem) {
const digest = item.latestRelease?.clawpackSha256?.slice(0, 12);
const manifestDigest = item.latestRelease?.clawpackManifestSha256?.slice(0, 12);
if (!digest && !manifestDigest) return "missing";
return [digest ? `zip ${digest}` : null, manifestDigest ? `manifest ${manifestDigest}` : null]
.filter(Boolean)
.join(" / ");
}
function formatMutationError(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
function formatQueueCount(count: { value: number; capped: boolean } | undefined) {
if (!count) return "0";
return `${count.value}${count.capped ? "+" : ""}`;
}
+281
View File
@@ -0,0 +1,281 @@
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { familyLabel } from "../../lib/packageLabels";
import { isModerator } from "../../lib/roles";
import { useAuthStatus } from "../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
listModerationQueueForStaff: unknown;
};
};
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
type QueueStatus = PackageScanStatus | "needs-review";
type PluginQueueItem = {
packageId: Id<"packages">;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
ownerHandle?: string;
summary?: string;
latestVersion?: string;
runtimeId?: string;
executesCode?: boolean;
verificationTier?: string;
clawpackAvailable?: boolean;
hostTargetKeys: string[];
environmentFlags: string[];
scanStatus: PackageScanStatus;
updatedAt: number;
latestRelease: {
releaseId: Id<"packageReleases">;
version: string;
createdAt: number;
clawpackAvailable: boolean;
clawpackRevokedAt?: number;
clawpackSha256?: string;
clawpackFileCount?: number;
source: {
repo: string | null;
ref: string | null;
path: string | null;
} | null;
verificationScanStatus: string | null;
} | null;
};
type PluginQueueResult = {
items: PluginQueueItem[];
status: QueueStatus;
limit: number;
hasMore: boolean;
};
const QUEUE_STATUSES: Array<{ value: QueueStatus; label: string }> = [
{ value: "needs-review", label: "Needs review" },
{ value: "pending", label: "Pending" },
{ value: "suspicious", label: "Suspicious" },
{ value: "malicious", label: "Malicious" },
{ value: "not-run", label: "Not run" },
{ value: "clean", label: "Clean" },
];
export const Route = createFileRoute("/management/plugins")({
component: PluginManagementRoute,
});
export function PluginManagementRoute() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname !== "/management/plugins") return <Outlet />;
return <PluginManagementConsole />;
}
function PluginManagementConsole() {
const { me } = useAuthStatus();
const staff = isModerator(me);
const [status, setStatus] = useState<QueueStatus>("needs-review");
const [limit, setLimit] = useState(30);
const queue = useQuery(
packageApiRefs.packages.listModerationQueueForStaff as never,
staff ? ({ status, limit } as never) : "skip",
) as PluginQueueResult | undefined;
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Plugin management</h1>
<p className="section-subtitle">
Staff package index for moderation queues, Claw Pack status, release provenance, and
direct package operations.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Back to management
</Link>
</Button>
</div>
<PluginOperationsNav current="plugins" />
<Card>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">queue</span>
<select
className="management-field"
value={status}
onChange={(event) => setStatus(event.target.value as QueueStatus)}
>
{QUEUE_STATUSES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="management-control management-control-stack">
<span className="mono">limit</span>
<input
className="management-field"
type="number"
min={1}
max={100}
value={limit}
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
/>
</label>
</div>
<div className="management-actions">
<Badge variant="compact">
{queue ? `${queue.items.length}${queue.hasMore ? "+" : ""} packages` : "loading"}
</Badge>
</div>
</div>
</Card>
<div className="mt-5 grid gap-4">
{queue === undefined ? (
<Card>Loading plugin queue...</Card>
) : queue.items.length === 0 ? (
<Card>No plugins found for this queue.</Card>
) : (
queue.items.map((item) => <PluginQueueCard key={item.packageId} item={item} />)
)}
</div>
</main>
);
}
function PluginQueueCard({ item }: { item: PluginQueueItem }) {
const release = item.latestRelease;
return (
<Card>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Link
className="font-display text-lg font-bold text-[color:var(--ink)]"
to="/management/plugins/$name"
params={{ name: item.name }}
search={{ skill: undefined, plugin: undefined }}
>
{item.displayName}
</Link>
<Badge variant={scanBadgeVariant(item.scanStatus)}>{item.scanStatus}</Badge>
{item.isOfficial ? <Badge variant="compact">official</Badge> : null}
{item.executesCode ? <Badge variant="compact">executes code</Badge> : null}
</div>
<p className="section-subtitle m-0">{item.summary ?? "No summary provided."}</p>
<div className="management-sublist">
<ReportField label="package" value={item.name} mono />
<ReportField
label="owner"
value={item.ownerHandle ? `@${item.ownerHandle}` : "unknown"}
/>
<ReportField label="family" value={`${familyLabel(item.family)} / ${item.channel}`} />
<ReportField
label="latest release"
value={
release ? `${release.version} / ${formatTimestamp(release.createdAt)}` : "none"
}
/>
<ReportField label="Claw Pack" value={formatClawPack(release, item)} />
<ReportField label="source" value={formatSource(release?.source ?? null)} />
<ReportField
label="targets"
value={item.hostTargetKeys.length ? item.hostTargetKeys.join(", ") : "none"}
/>
<ReportField
label="environment"
value={item.environmentFlags.length ? item.environmentFlags.join(", ") : "none"}
/>
</div>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn" size="sm">
<Link
to="/management/plugins/$name"
params={{ name: item.name }}
search={{ skill: undefined, plugin: undefined }}
>
Manage
</Link>
</Button>
<Button asChild className="management-action-btn" size="sm" variant="outline">
<Link to="/plugins/$name" params={{ name: item.name }}>
Public page
</Link>
</Button>
{release?.version ? (
<Button asChild className="management-action-btn" size="sm" variant="ghost">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: release.releaseId }}
search={{ skill: undefined, plugin: undefined }}
>
Release detail
</Link>
</Button>
) : null}
</div>
</div>
</Card>
);
}
function ReportField({ label, mono, value }: { label: string; mono?: boolean; value: string }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{label}</span>
<span className={mono ? "mono" : undefined}>{value}</span>
</div>
);
}
function scanBadgeVariant(status: PackageScanStatus) {
if (status === "clean") return "success";
if (status === "malicious") return "destructive";
if (status === "suspicious") return "warning";
return "pending";
}
function formatClawPack(release: PluginQueueItem["latestRelease"], item: PluginQueueItem) {
if (release?.clawpackRevokedAt) return `revoked ${formatTimestamp(release.clawpackRevokedAt)}`;
if (release?.clawpackAvailable || item.clawpackAvailable) {
return [
release?.clawpackFileCount ? `${release.clawpackFileCount} files` : "available",
release?.clawpackSha256 ? release.clawpackSha256.slice(0, 12) : null,
]
.filter(Boolean)
.join(" / ");
}
return "missing";
}
function formatSource(source: NonNullable<PluginQueueItem["latestRelease"]>["source"]) {
if (!source) return "missing";
return [source.repo, source.ref, source.path].filter(Boolean).join(" / ") || "missing";
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
+420
View File
@@ -0,0 +1,420 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../../../convex/_generated/api";
import type { Doc, Id } from "../../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../../components/PluginOperationsNav";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { Card } from "../../../components/ui/card";
import { familyLabel } from "../../../lib/packageLabels";
import type { PublicPublisher } from "../../../lib/publicUser";
import { isModerator } from "../../../lib/roles";
import { useAuthStatus } from "../../../lib/useAuthStatus";
const packageApiRefs = api as unknown as {
packages: {
getByNameForStaff: unknown;
setModerationVerdict: unknown;
revokeClawPackArtifact: unknown;
};
};
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
type PluginByNameResult = {
package: Doc<"packages">;
latestRelease: Doc<"packageReleases"> | null;
owner: PublicPublisher | null;
highlighted: { byUserId: Id<"users">; at: number } | null;
} | null;
export const Route = createFileRoute("/management/plugins/$name")({
component: PluginManagementDetailRoute,
});
function PluginManagementDetailRoute() {
const { name } = Route.useParams();
return <PluginManagementDetailPage name={name} />;
}
export function PluginManagementDetailPage({ name }: { name: string }) {
const { me } = useAuthStatus();
const staff = isModerator(me);
const detail = useQuery(
packageApiRefs.packages.getByNameForStaff as never,
staff ? ({ name } as never) : "skip",
) as PluginByNameResult | undefined;
const setPackageBatch = useMutation(api.packages.setBatch);
const setModerationVerdict = useMutation(
packageApiRefs.packages.setModerationVerdict as never,
) as unknown as (args: {
packageId: Id<"packages">;
verdict: PackageScanStatus;
note?: string;
}) => Promise<unknown>;
const revokeClawPackArtifact = useMutation(
packageApiRefs.packages.revokeClawPackArtifact as never,
) as unknown as (args: { releaseId: Id<"packageReleases">; reason?: string }) => Promise<unknown>;
const [verdict, setVerdict] = useState<PackageScanStatus>("clean");
const [note, setNote] = useState("");
const [activeWrite, setActiveWrite] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const plugin = detail?.package ?? null;
const release = detail?.latestRelease ?? null;
const owner = detail?.owner ?? null;
const highlighted = Boolean(detail?.highlighted);
useEffect(() => {
if (plugin?.scanStatus) setVerdict(plugin.scanStatus as PackageScanStatus);
}, [plugin?.scanStatus]);
if (!staff) {
return <ManagementAccessNotice me={me} />;
}
const saveVerdict = () => {
if (!plugin) return;
const trimmed = note.trim();
if (!trimmed) {
setError("Audit note required.");
return;
}
if (
!window.confirm(
`Set ${plugin.name} moderation verdict to ${verdict}?\n\nThis writes a package moderation verdict and audit log in Convex.`,
)
) {
return;
}
setError(null);
setActiveWrite("verdict");
void setModerationVerdict({ packageId: plugin._id, verdict, note: trimmed })
.then(() => setNote(""))
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
const toggleHighlight = () => {
if (!plugin) return;
const nextState = highlighted ? "remove highlighted badge from" : "mark highlighted for";
if (
!window.confirm(
`This will ${nextState} ${plugin.name}.\n\nThis writes package badge state in Convex.`,
)
) {
return;
}
setError(null);
setActiveWrite("highlight");
void setPackageBatch({
packageId: plugin._id,
batch: highlighted ? undefined : "highlighted",
})
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
const revokeClawPack = () => {
if (!plugin || !release?._id) return;
const reason = window.prompt(
`Revoke Claw Pack for ${plugin.name}@${release.version}. Reason required.`,
);
const trimmed = reason?.trim();
if (!trimmed) return;
if (
!window.confirm(
`Revoke Claw Pack artifact for ${plugin.name}@${release.version}?\n\nThis writes revocation metadata and an audit log in Convex.`,
)
) {
return;
}
setError(null);
setActiveWrite("clawpack");
void revokeClawPackArtifact({ releaseId: release._id, reason: trimmed })
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">Plugin package detail</h1>
<p className="section-subtitle">
Staff drilldown for release provenance, Claw Pack artifact state, moderation verdicts,
and package promotion controls.
</p>
</div>
<div className="management-actions">
<Button asChild variant="outline" size="sm">
<Link to="/management/plugins" search={{ skill: undefined, plugin: undefined }}>
Plugin index
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: name }}>
Legacy panel
</Link>
</Button>
</div>
</div>
<PluginOperationsNav current="plugins" />
{detail === undefined ? (
<Card>Loading plugin package...</Card>
) : !plugin ? (
<Card>No plugin package found for "{name}".</Card>
) : (
<>
<Card>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
{plugin.displayName}
</h2>
<Badge variant={scanBadgeVariant(plugin.scanStatus as PackageScanStatus)}>
{plugin.scanStatus}
</Badge>
{plugin.isOfficial ? <Badge variant="compact">official</Badge> : null}
{plugin.executesCode ? <Badge variant="compact">executes code</Badge> : null}
{highlighted ? <Badge variant="success">highlighted</Badge> : null}
</div>
<p className="section-subtitle m-0">{plugin.summary ?? "No summary provided."}</p>
<div className="management-sublist">
<ReportField label="package" value={plugin.name} mono />
<ReportField
label="owner"
value={owner?.handle ? `@${owner.handle}` : "unknown"}
/>
<ReportField
label="family"
value={`${familyLabel(plugin.family)} / ${plugin.channel}`}
/>
<ReportField label="runtime id" value={plugin.runtimeId ?? "none"} mono />
<ReportField label="verification" value={plugin.verification?.tier ?? "none"} />
<ReportField label="updated" value={formatTimestamp(plugin.updatedAt)} />
<ReportField
label="latest release"
value={
release
? `${release.version} / ${formatTimestamp(release.createdAt)}`
: "none"
}
/>
</div>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn" size="sm">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
Public page
</Link>
</Button>
{release?.version ? (
<Button asChild className="management-action-btn" size="sm" variant="outline">
<Link
to="/management/clawpacks/releases/$releaseId"
params={{ releaseId: release._id }}
search={{ skill: undefined, plugin: undefined }}
>
Release detail
</Link>
</Button>
) : null}
<Button
className="management-action-btn"
loading={activeWrite === "highlight"}
size="sm"
type="button"
onClick={toggleHighlight}
>
{highlighted ? "Unhighlight" : "Highlight"}
</Button>
</div>
</div>
</Card>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<Card>
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Claw Pack
</h2>
<div className="management-sublist">
<ReportField label="state" value={formatClawPackState(release)} />
<ReportField
label="zip digest"
value={release?.clawpackSha256 ?? "missing"}
mono={Boolean(release?.clawpackSha256)}
/>
<ReportField
label="manifest digest"
value={release?.clawpackManifestSha256 ?? "missing"}
mono={Boolean(release?.clawpackManifestSha256)}
/>
<ReportField
label="files"
value={release?.clawpackFileCount ? String(release.clawpackFileCount) : "none"}
/>
<ReportField
label="size"
value={release?.clawpackSize ? formatBytesCompact(release.clawpackSize) : "none"}
/>
<ReportField
label="host targets"
value={formatHostTargets(release?.hostTargetsSummary)}
/>
<ReportField
label="environment"
value={formatEnvironmentSummary(release?.environmentSummary)}
/>
</div>
<Button
className="self-start"
disabled={!release?.clawpackStorageId || Boolean(release.clawpackRevokedAt)}
loading={activeWrite === "clawpack"}
size="sm"
type="button"
variant="destructive"
onClick={revokeClawPack}
>
Revoke Claw Pack
</Button>
</Card>
<Card>
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Release provenance
</h2>
<div className="management-sublist">
<ReportField label="source" value={formatReleaseSource(release?.source)} />
<ReportField
label="verification scan"
value={release?.verification?.scanStatus ?? "missing"}
/>
<ReportField label="static scan" value={release?.staticScan?.status ?? "missing"} />
<ReportField label="VirusTotal" value={release?.vtAnalysis?.status ?? "missing"} />
<ReportField label="LLM review" value={release?.llmAnalysis?.status ?? "missing"} />
</div>
</Card>
</div>
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Moderation verdict
</h2>
<div className="management-tool-grid">
<label className="management-control management-control-stack">
<span className="mono">verdict</span>
<select
className="management-field"
value={verdict}
onChange={(event) => setVerdict(event.target.value as PackageScanStatus)}
>
<option value="clean">clean</option>
<option value="suspicious">suspicious</option>
<option value="malicious">malicious</option>
<option value="pending">pending</option>
<option value="not-run">not-run</option>
</select>
</label>
<label className="management-control management-control-stack">
<span className="mono">audit note</span>
<input
className="management-field"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Required"
/>
</label>
</div>
<div className="management-actions management-actions-start">
<Button
loading={activeWrite === "verdict"}
type="button"
disabled={!note.trim()}
onClick={saveVerdict}
>
Save verdict
</Button>
{error ? <Badge variant="destructive">{error}</Badge> : null}
</div>
</Card>
</>
)}
</main>
);
}
function ReportField({ label, mono, value }: { label: string; mono?: boolean; value: string }) {
return (
<div className="management-report-item">
<span className="management-report-meta">{label}</span>
<span className={mono ? "mono break-all" : undefined}>{value}</span>
</div>
);
}
function scanBadgeVariant(status: PackageScanStatus) {
if (status === "clean") return "success";
if (status === "malicious") return "destructive";
if (status === "suspicious") return "warning";
return "pending";
}
function formatClawPackState(release: Doc<"packageReleases"> | null | undefined) {
if (!release) return "no release";
if (release.clawpackRevokedAt) return `revoked ${formatTimestamp(release.clawpackRevokedAt)}`;
if (release.clawpackStorageId)
return `active ${formatTimestamp(release.clawpackBuiltAt ?? release.createdAt)}`;
return "missing";
}
function formatHostTargets(targets: Doc<"packageReleases">["hostTargetsSummary"]) {
if (!targets?.length) return "No target summary yet";
return targets
.map((target) => [target.os, target.arch, target.libc].filter(Boolean).join("-"))
.join(", ");
}
function formatEnvironmentSummary(environment: Doc<"packageReleases">["environmentSummary"]) {
if (!environment) return "No environment summary yet";
const labels = [
environment.requiresLocalDesktop ? "desktop" : null,
environment.requiresBrowser ? "browser" : null,
environment.requiresAudioDevice ? "audio" : null,
environment.requiresNetwork ? "network" : null,
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
].filter(Boolean);
return labels.length > 0 ? labels.join(", ") : "No special environment requirements";
}
function formatReleaseSource(source: Doc<"packageReleases">["source"]) {
if (!source || typeof source !== "object") return "missing";
const typed = source as { repo?: string; ref?: string; path?: string; commit?: string };
return (
[typed.repo, typed.ref, typed.path, typed.commit?.slice(0, 12)].filter(Boolean).join(" / ") ||
"missing"
);
}
function formatBytesCompact(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0B";
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
function formatMutationError(error: unknown) {
if (error instanceof Error && error.message.trim()) return error.message.trim();
return "Request failed.";
}
+229
View File
@@ -0,0 +1,229 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { isAdmin } from "../../lib/roles";
import { useAuthStatus } from "../../lib/useAuthStatus";
type UserRole = "admin" | "moderator" | "user";
type UserListResult = {
items: Doc<"users">[];
total: number;
};
export const Route = createFileRoute("/management/users")({
component: UserManagementRoute,
});
export function UserManagementRoute() {
const { me } = useAuthStatus();
const admin = isAdmin(me);
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(100);
const [activeWrite, setActiveWrite] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const users = useQuery(
api.users.list,
admin ? { limit, search: search.trim() || undefined } : "skip",
) as UserListResult | undefined;
const setRole = useMutation(api.users.setRole);
const banUser = useMutation(api.users.banUser);
const unbanUser = useMutation(api.users.unbanUser);
if (!admin) {
return <ManagementAccessNotice me={me} />;
}
const items = users?.items ?? [];
const changeRole = (user: Doc<"users">, role: UserRole) => {
const label = formatUserLabel(user);
const currentRole = (user.role ?? "user") as UserRole;
if (role === currentRole) return;
if (
!window.confirm(
`Set ${label} role from ${currentRole} to ${role}?\n\nThis writes users.setRole in Convex.`,
)
) {
return;
}
setError(null);
setActiveWrite(`role:${user._id}`);
void setRole({ userId: user._id, role })
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
const runBan = (user: Doc<"users">) => {
if (user._id === me?._id) return;
const label = formatUserLabel(user);
if (
!window.confirm(
`Ban ${label} and delete their skills?\n\nThis writes users.banUser in Convex.`,
)
) {
return;
}
const reason = window.prompt(`Ban reason for ${label}. Required.`);
const trimmed = reason?.trim();
if (!trimmed) return;
setError(null);
setActiveWrite(`ban:${user._id}`);
void banUser({ userId: user._id, reason: trimmed })
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
const runUnban = (user: Doc<"users">) => {
const label = formatUserLabel(user);
if (!window.confirm(`Unban ${label}?\n\nThis writes users.unbanUser in Convex.`)) return;
const reason = window.prompt(`Unban reason for ${label}. Required.`);
const trimmed = reason?.trim();
if (!trimmed) return;
setError(null);
setActiveWrite(`unban:${user._id}`);
void unbanUser({ userId: user._id, reason: trimmed })
.catch((requestError) => setError(formatMutationError(requestError)))
.finally(() => setActiveWrite(null));
};
return (
<main className="section">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="section-title">User roles</h1>
<p className="section-subtitle">
Admin-only user search, moderator setup, role changes, and ban recovery.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Back to management
</Link>
</Button>
</div>
<PluginOperationsNav current="users" />
<Card>
<div className="management-controls">
<label className="management-control management-search">
<span className="mono">Search</span>
<input
type="search"
placeholder="handle, name, email, or user id"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label className="management-control">
<span className="mono">Limit</span>
<input
type="number"
min={1}
max={200}
value={limit}
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
/>
</label>
<div className="management-count">
{users ? `${items.length} shown / ${users.total} matched` : "Loading users..."}
</div>
</div>
{error ? (
<div className="mt-3">
<Badge variant="destructive">{error}</Badge>
</div>
) : null}
</Card>
<Card className="mt-5">
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Role assignments
</h2>
<div className="management-list mt-3">
{users === undefined ? (
<div className="stat">Loading users...</div>
) : items.length === 0 ? (
<div className="stat">No matching users.</div>
) : (
items.map((user) => (
<div className="management-item" key={user._id}>
<div className="management-item-main">
<div className="flex flex-wrap items-center gap-2">
<span className="mono">{formatUserLabel(user)}</span>
<Badge variant={roleBadgeVariant(user.role)}>{user.role ?? "user"}</Badge>
{user.deletedAt ? <Badge variant="destructive">banned</Badge> : null}
{user.deactivatedAt ? <Badge variant="compact">deactivated</Badge> : null}
</div>
<div className="section-subtitle m-0">
{user.email ?? user.name ?? user._id} - joined{" "}
{formatTimestamp(user.createdAt ?? user._creationTime)}
</div>
{user.banReason ? (
<div className="management-report-item">
<span className="management-report-meta">ban reason</span>
<span>{user.banReason}</span>
</div>
) : null}
</div>
<div className="management-actions">
<select
value={user.role ?? "user"}
disabled={activeWrite === `role:${user._id}`}
onChange={(event) => changeRole(user, event.target.value as UserRole)}
>
<option value="user">User</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<Button
type="button"
size="sm"
disabled={user._id === me?._id || activeWrite === `ban:${user._id}`}
onClick={() => runBan(user)}
>
Ban
</Button>
{user.deletedAt && !user.deactivatedAt ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={activeWrite === `unban:${user._id}`}
onClick={() => runUnban(user)}
>
Unban
</Button>
) : null}
</div>
</div>
))
)}
</div>
</Card>
</main>
);
}
function roleBadgeVariant(role: Doc<"users">["role"]) {
if (role === "admin") return "destructive";
if (role === "moderator") return "warning";
return "compact";
}
function formatUserLabel(user: Pick<Doc<"users">, "_id" | "handle" | "name">) {
return `@${user.handle ?? user.name ?? user._id}`;
}
function formatTimestamp(value: number) {
return new Date(value).toLocaleString();
}
function formatMutationError(error: unknown) {
if (error instanceof Error && error.message.trim()) return error.message.trim();
return "Request failed.";
}
+641 -29
View File
@@ -6,6 +6,7 @@ import semver from "semver";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
import { InstallCopyButton } from "../components/InstallCopyButton";
import { Container } from "../components/layout/Container";
import { PackageSourceChooser } from "../components/PackageSourceChooser";
import { Badge } from "../components/ui/badge";
@@ -13,6 +14,11 @@ import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { normalizeClawPackImport, type ClawPackImportSummary } from "../lib/clawpackImport";
import {
fetchGitHubPackageSource,
type GitHubPackageSourceProgress,
} from "../lib/githubPackageSource";
import {
buildPackageUploadEntries,
filterIgnoredPackageFiles,
@@ -44,6 +50,314 @@ const apiRefs = api as unknown as {
};
};
type ClawPackPreviewFile = {
path: string;
size: number;
contentType?: string;
};
type ClawPackPreview = {
manifest: Record<string, unknown>;
manifestJson: string;
publishFiles: ClawPackPreviewFile[];
finalFileCount: number;
selectedBytes: number;
hostTargets: string[];
environment: string[];
blockers: string[];
warnings: string[];
};
type ClawPackIntakeGate = {
label: string;
status: "ready" | "review" | "blocked" | "pending";
detail: string;
};
type PublishSuccess = {
name: string;
version: string;
releaseId?: string;
};
const DEFAULT_CLAWPACK_TARGETS = ["darwin-arm64", "linux-x64-glibc", "win32-x64"];
function splitHostTargets(value: string) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function inferPreviewEnvironment(paths: string[]) {
const lowerPaths = paths.map((path) => path.toLowerCase());
const environment = [
"network",
lowerPaths.some((path) => path.includes("playwright") || path.includes("browser"))
? "browser"
: null,
lowerPaths.some((path) => path.includes("desktop") || path.includes("imessage"))
? "local desktop"
: null,
lowerPaths.some((path) => path.includes("audio") || path.includes("microphone"))
? "audio device"
: null,
].filter((entry): entry is string => Boolean(entry));
return [...new Set(environment)];
}
function hostTargetPreviewObjects(targets: string[], compatibility: PackageCompatibility | null) {
return targets.map((target) => {
const parts = target.toLowerCase().split(/[-_/]/).filter(Boolean);
const os = parts.find((part) => part === "darwin" || part === "linux" || part === "win32");
const arch = parts.find((part) => part === "arm64" || part === "x64");
const libc = parts.find((part) => part === "glibc" || part === "musl");
return {
target,
...(os ? { os } : {}),
...(arch ? { arch } : {}),
...(libc ? { libc } : {}),
supportState: os && arch ? "supported" : "setup-required",
...(compatibility?.minGatewayVersion
? { openclawRange: compatibility.minGatewayVersion }
: {}),
...(compatibility?.pluginApiRange ? { pluginApiRange: compatibility.pluginApiRange } : {}),
};
});
}
function formatPreviewBytes(value: number) {
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
}
function formatGitHubSourceProgress(progress: GitHubPackageSourceProgress) {
if (progress.phase === "resolving") return "Resolving GitHub repo and commit...";
if (progress.phase === "listing") return "Reading GitHub package file list...";
if (progress.phase === "downloading") {
const count =
typeof progress.current === "number" && typeof progress.total === "number"
? `${progress.current}/${progress.total}`
: "";
return `Downloading GitHub files${count ? ` ${count}` : ""}${
progress.path ? `: ${progress.path}` : ""
}`;
}
return "Fetching GitHub package...";
}
function buildClawPackPreview(input: {
files: File[];
normalizedPaths: string[];
family: "code-plugin" | "bundle-plugin";
name: string;
displayName: string;
ownerHandle: string;
version: string;
changelog: string;
sourceRepo: string;
sourceCommit: string;
sourceRef: string;
sourcePath: string;
bundleFormat: string;
hostTargets: string;
compatibility: PackageCompatibility | null;
codePluginFieldIssues: string[];
validationError: string | null;
}): ClawPackPreview | null {
if (input.files.length === 0) return null;
const normalized = normalizePackageUploadFiles(input.files);
const publishFiles = normalized
.filter((entry) => entry.path.toLowerCase() !== "clawpack.json")
.map((entry) => ({
path: entry.path,
size: entry.file.size,
...(entry.file.type ? { contentType: entry.file.type } : {}),
}))
.sort((a, b) => a.path.localeCompare(b.path));
const selectedBytes = publishFiles.reduce((sum, file) => sum + file.size, 0);
const suppliedClawPack = normalized.some((entry) => entry.path.toLowerCase() === "clawpack.json");
const rawTargets = input.family === "bundle-plugin" ? splitHostTargets(input.hostTargets) : [];
const hostTargets = rawTargets.length > 0 ? rawTargets : DEFAULT_CLAWPACK_TARGETS;
const environment = inferPreviewEnvironment(input.normalizedPaths);
const blockers = [
input.validationError,
input.name.trim() ? null : "Plugin name is required.",
input.version.trim() ? null : "Version is required.",
input.family === "code-plugin" && !input.sourceRepo.trim() ? "Source repo is required." : null,
input.family === "code-plugin" && !input.sourceCommit.trim()
? "Source commit is required."
: null,
...input.codePluginFieldIssues.map((field) => `Missing package metadata: ${field}.`),
].filter((entry): entry is string => Boolean(entry));
const warnings = [
input.changelog.trim() ? null : "Changelog is empty.",
suppliedClawPack ? "Existing pack manifest will be replaced by ClawHub." : null,
rawTargets.length === 0 && input.family === "bundle-plugin"
? "No bundle host targets provided; ClawHub will fall back to the default host matrix."
: null,
].filter((entry): entry is string => Boolean(entry));
const source =
input.sourceRepo.trim() && input.sourceCommit.trim()
? {
kind: "github",
repo: input.sourceRepo.trim(),
ref: input.sourceRef.trim() || input.sourceCommit.trim(),
commit: input.sourceCommit.trim(),
path: input.sourcePath.trim() || ".",
}
: null;
const manifest: Record<string, unknown> = {
specVersion: 1,
kind: "openclaw.clawpack",
package: {
name: input.name.trim() || "unresolved",
displayName: input.displayName.trim() || input.name.trim() || "unresolved",
owner: input.ownerHandle.trim() || "resolved-on-publish",
slug: input.name.trim() || "unresolved",
version: input.version.trim() || "unresolved",
family: input.family,
channel: "community",
},
release: {
packageId: "assigned-on-publish",
releaseId: "assigned-on-publish",
publishedAt: "assigned-on-publish",
source,
},
artifact: {
format: "zip",
root: "package/",
specVersion: 1,
contentSha256: "computed-on-publish",
fileCount: publishFiles.length,
},
files: publishFiles.map((file) => ({
path: file.path,
size: file.size,
sha256: "computed-on-publish",
...(file.contentType ? { contentType: file.contentType } : {}),
})),
compatibility: input.compatibility ?? null,
capabilities:
input.family === "bundle-plugin"
? {
format: input.bundleFormat.trim() || null,
hostTargets,
}
: null,
verification: { scanStatus: "pending" },
hostTargets: hostTargetPreviewObjects(hostTargets, input.compatibility),
environment: {
requiresNetwork: true,
requiresBrowser: environment.includes("browser"),
requiresLocalDesktop: environment.includes("local desktop"),
requiresAudioDevice: environment.includes("audio device"),
},
runtimeBundles: [],
};
return {
manifest,
manifestJson: JSON.stringify(manifest, null, 2),
publishFiles,
finalFileCount: publishFiles.length + 1,
selectedBytes,
hostTargets,
environment,
blockers,
warnings,
};
}
function buildClawPackIntakeGates(input: {
preview: ClawPackPreview;
family: "code-plugin" | "bundle-plugin";
sourceRepo: string;
sourceCommit: string;
sourceRef: string;
}) {
const hasSource = Boolean(input.sourceRepo.trim() && input.sourceCommit.trim());
const defaultMatrix =
input.family === "bundle-plugin" &&
input.preview.hostTargets.length === DEFAULT_CLAWPACK_TARGETS.length &&
input.preview.hostTargets.every((target, index) => target === DEFAULT_CLAWPACK_TARGETS[index]);
return [
{
label: "Archive contract",
status: input.preview.blockers.length > 0 ? "blocked" : "ready",
detail:
input.preview.blockers.length > 0
? "Resolve blocking metadata before ClawHub can build the canonical Claw Pack."
: `${input.preview.finalFileCount} files will be packaged with a generated manifest and digests.`,
},
{
label: "Source provenance",
status: hasSource ? "ready" : "blocked",
detail: hasSource
? `${input.sourceRepo.trim()} @ ${input.sourceRef.trim() || input.sourceCommit.trim()}`
: "Code plugins require a source repository and exact commit for review and future rebuild checks.",
},
{
label: "Platform matrix",
status: defaultMatrix ? "review" : "ready",
detail: defaultMatrix
? "Default macOS/Linux/Windows targets are selected. Confirm native dependencies before publish."
: input.preview.hostTargets.join(", "),
},
{
label: "Environment needs",
status: input.preview.environment.length > 0 ? "ready" : "review",
detail:
input.preview.environment.length > 0
? input.preview.environment.join(", ")
: "No environment signals detected beyond package metadata.",
},
{
label: "Security review",
status: "pending",
detail:
"Static, malware, and policy checks run in the background after the Claw Pack is accepted.",
},
] satisfies ClawPackIntakeGate[];
}
function ClawPackIntakeReview({ gates }: { gates: ClawPackIntakeGate[] }) {
return (
<Card className="mb-5">
<div className="flex flex-col gap-4">
<div>
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Claw Pack checks
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
ClawHub checks the package contract, source provenance, platform coverage, environment
needs, and background scanning before public install confidence.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{gates.map((gate) => (
<div
key={gate.label}
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-3">
<strong className="text-sm text-[color:var(--ink)]">{gate.label}</strong>
<Badge variant={gate.status === "blocked" ? "accent" : "compact"}>
{gate.status}
</Badge>
</div>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">{gate.detail}</p>
</div>
))}
</div>
</div>
</Card>
);
}
export function PublishPluginRoute() {
const search = useSearch({ from: "/publish-plugin" });
const { isAuthenticated } = useAuthStatus();
@@ -77,13 +391,22 @@ export function PublishPluginRoute() {
const [bundleFormat, setBundleFormat] = useState("");
const [hostTargets, setHostTargets] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [sourceUrl, setSourceUrl] = useState(
search.sourceRepo ? `https://github.com/${search.sourceRepo}` : "",
);
const [sourceUrlError, setSourceUrlError] = useState<string | null>(null);
const [sourceUrlStatus, setSourceUrlStatus] = useState<string | null>(null);
const [sourceUrlBusy, setSourceUrlBusy] = useState(false);
const [intakeStatus, setIntakeStatus] = useState<string | null>(null);
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
const [clawPackImport, setClawPackImport] = useState<ClawPackImportSummary | null>(null);
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
const [codePluginCompatibility, setCodePluginCompatibility] =
useState<PackageCompatibility | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [publishSuccess, setPublishSuccess] = useState<PublishSuccess | null>(null);
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
const normalizedPaths = useMemo(
@@ -111,30 +434,145 @@ export function PublishPluginRoute() {
const isMetadataLocked = files.length === 0;
const isSubmitting = status !== null;
const metadataDisabled = isMetadataLocked || isSubmitting;
const clawPackPreview = useMemo(
() =>
buildClawPackPreview({
files,
normalizedPaths,
family,
name,
displayName,
ownerHandle,
version,
changelog,
sourceRepo,
sourceCommit,
sourceRef,
sourcePath,
bundleFormat,
hostTargets,
compatibility: codePluginCompatibility,
codePluginFieldIssues,
validationError,
}),
[
files,
normalizedPaths,
family,
name,
displayName,
ownerHandle,
version,
changelog,
sourceRepo,
sourceCommit,
sourceRef,
sourcePath,
bundleFormat,
hostTargets,
codePluginCompatibility,
codePluginFieldIssues,
validationError,
],
);
const publishLifecycle = useMemo(() => {
if (status) return { label: status };
if (!files.length) return { label: "Waiting for files" };
if (clawPackPreview?.blockers.length) return { label: "Needs details" };
return { label: "Ready to publish" };
}, [files.length, clawPackPreview, status]);
const onPickFiles = async (selected: File[]) => {
const expanded = await expandFilesWithReport(selected, {
includeBinaryArchiveFiles: true,
});
const filtered = await filterIgnoredPackageFiles(expanded.files);
const normalized = normalizePackageUploadFiles(filtered.files);
const nextIgnoredPaths = [
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
];
setFiles(filtered.files);
setIgnoredPaths(nextIgnoredPaths);
setError(null);
const prefill = await derivePluginPrefill(normalized);
setDetectedPrefillFields(listPrefilledFields(prefill));
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
setCodePluginCompatibility(prefill.compatibility ?? null);
if (prefill.family) setFamily(prefill.family);
if (prefill.name) setName(prefill.name);
if (prefill.displayName) setDisplayName(prefill.displayName);
if (prefill.version) setVersion(prefill.version);
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
try {
setIntakeStatus("Reading package files...");
const expanded = await expandFilesWithReport(selected, {
includeBinaryArchiveFiles: true,
});
setIntakeStatus("Filtering local-only files...");
const filtered = await filterIgnoredPackageFiles(expanded.files);
setIntakeStatus("Looking for Claw Pack metadata...");
const imported = await normalizeClawPackImport(filtered.files);
const selectedFiles = imported.summary ? imported.files : filtered.files;
const normalized = normalizePackageUploadFiles(selectedFiles);
const nextIgnoredPaths = [
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
];
setFiles(selectedFiles);
setPublishSuccess(null);
setIgnoredPaths(nextIgnoredPaths);
setClawPackImport(imported.summary);
setError(null);
setIntakeStatus("Prefilling package details...");
const prefill = await derivePluginPrefill(normalized);
setDetectedPrefillFields(listPrefilledFields(prefill));
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
setCodePluginCompatibility(prefill.compatibility ?? null);
if (imported.summary?.family ?? prefill.family) {
setFamily((imported.summary?.family ?? prefill.family) as "code-plugin" | "bundle-plugin");
}
if (imported.summary?.packageName ?? prefill.name) {
setName(imported.summary?.packageName ?? prefill.name ?? "");
}
if (imported.summary?.displayName ?? prefill.displayName) {
setDisplayName(imported.summary?.displayName ?? prefill.displayName ?? "");
}
if (imported.summary?.version ?? prefill.version) {
setVersion(imported.summary?.version ?? prefill.version ?? "");
}
if (imported.summary?.sourceRepo ?? prefill.sourceRepo) {
setSourceRepo(imported.summary?.sourceRepo ?? prefill.sourceRepo ?? "");
}
if (imported.summary?.sourceCommit) setSourceCommit(imported.summary.sourceCommit);
if (imported.summary?.sourceRef) setSourceRef(imported.summary.sourceRef);
if (imported.summary?.sourcePath) setSourcePath(imported.summary.sourcePath);
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
if (imported.summary?.hostTargets.length) {
setHostTargets(imported.summary.hostTargets.join(", "));
} else if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
setIntakeStatus("Package ready for review.");
} catch (pickError) {
setFiles([]);
setPublishSuccess(null);
setIgnoredPaths([]);
setClawPackImport(null);
setDetectedPrefillFields([]);
setCodePluginFieldIssues([]);
setCodePluginCompatibility(null);
setIntakeStatus(null);
setError(formatPublishError(pickError));
}
};
const onApplySourceUrl = async () => {
if (!sourceUrl.trim()) {
setSourceUrlError("Paste a GitHub repo, tree, or blob URL.");
return;
}
try {
setSourceUrlBusy(true);
setSourceUrlError(null);
setSourceUrlStatus("Resolving GitHub URL...");
const imported = await fetchGitHubPackageSource(sourceUrl, {
maxFileBytes: MAX_PUBLISH_FILE_BYTES,
maxTotalBytes: MAX_PUBLISH_TOTAL_BYTES,
onProgress: (progress) => setSourceUrlStatus(formatGitHubSourceProgress(progress)),
});
setSourceUrlStatus(`Preparing ${imported.files.length} GitHub files for review...`);
await onPickFiles(imported.files);
setSourceRepo(imported.source.repo);
setSourceCommit(imported.source.commit);
setSourceRef(imported.source.ref);
setSourcePath(imported.source.path);
setSourceUrl(imported.source.url);
setSourceUrlStatus(`Fetched ${imported.files.length} files from ${imported.source.repo}.`);
setIntakeStatus("GitHub package ready for review.");
toast.success("GitHub package fetched. Review the inferred details.");
} catch (sourceError) {
setSourceUrlStatus(null);
setSourceUrlError(formatPublishError(sourceError));
} finally {
setSourceUrlBusy(false);
}
};
useEffect(() => {
@@ -154,10 +592,8 @@ export function PublishPluginRoute() {
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
</h1>
<p className="text-sm text-[color:var(--ink-soft)]">
Publish a native code plugin or bundle plugin release.
</p>
<p className="text-sm text-[color:var(--ink-soft)]">
New releases stay private until automated security checks and verification finish.
Plugin onboarding now builds a Claw Pack (npm pack compatible) from your files. Upload a
package or paste a GitHub URL, review the inferred details, then publish.
</p>
{search.name ? (
<p className="text-sm text-[color:var(--ink-soft)]">
@@ -169,20 +605,177 @@ export function PublishPluginRoute() {
) : null}
</header>
<div className="mb-5 grid gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4 text-sm md:grid-cols-3">
<div>
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
1. Intake
</span>
<strong className="text-[color:var(--ink)]">
{files.length ? `${files.length} files received` : "Choose files or source"}
</strong>
</div>
<div>
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
2. Details
</span>
<strong className="text-[color:var(--ink)]">
{files.length && !clawPackPreview?.blockers.length ? "Ready" : "Needs review"}
</strong>
</div>
<div>
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
3. Publish
</span>
<strong className="text-[color:var(--ink)]">{publishLifecycle.label}</strong>
</div>
</div>
<PackageSourceChooser
files={files}
totalBytes={totalBytes}
normalizedPaths={normalizedPaths}
normalizedPathSet={normalizedPathSet}
ignoredPaths={ignoredPaths}
sourceUrl={sourceUrl}
sourceUrlError={sourceUrlError}
sourceUrlStatus={sourceUrlStatus}
sourceUrlBusy={sourceUrlBusy}
intakeStatus={intakeStatus}
detectedPrefillFields={detectedPrefillFields}
family={family}
validationError={validationError}
codePluginFieldIssues={codePluginFieldIssues}
codePluginCompatibility={codePluginCompatibility}
clawPackImport={clawPackImport}
hostTargets={hostTargets}
onSourceUrlChange={setSourceUrl}
onApplySourceUrl={onApplySourceUrl}
onPickFiles={onPickFiles}
/>
{clawPackPreview ? (
<ClawPackIntakeReview
gates={buildClawPackIntakeGates({
preview: clawPackPreview,
family,
sourceRepo,
sourceCommit,
sourceRef,
})}
/>
) : null}
{clawPackPreview ? (
<Card className="mb-5">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
Claw Pack preview
</h2>
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
This is the generated package contract ClawHub will build on publish.
</p>
</div>
<InstallCopyButton
text={clawPackPreview.manifestJson}
ariaLabel="Copy Claw Pack preview manifest"
/>
</div>
<dl className="grid gap-3 text-sm sm:grid-cols-3">
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
<dt className="mb-1 text-[color:var(--ink-soft)]">Final archive</dt>
<dd className="font-semibold text-[color:var(--ink)]">
{clawPackPreview.finalFileCount} files including CLAWPACK.json
</dd>
</div>
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
<dt className="mb-1 text-[color:var(--ink-soft)]">Selected size</dt>
<dd className="font-semibold text-[color:var(--ink)]">
{formatPreviewBytes(clawPackPreview.selectedBytes)}
</dd>
</div>
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
<dt className="mb-1 text-[color:var(--ink-soft)]">Publish state</dt>
<dd className="font-semibold text-[color:var(--ink)]">
{publishLifecycle.label}
</dd>
</div>
</dl>
<div className="flex flex-wrap gap-1.5">
{clawPackPreview.hostTargets.map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
{clawPackPreview.environment.map((signal) => (
<Badge key={signal} variant="compact">
{signal}
</Badge>
))}
</div>
{clawPackPreview.blockers.length > 0 ? (
<div className="rounded-[var(--radius-sm)] border border-red-300/50 bg-red-50 p-3 text-sm text-red-900 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-100">
<strong>Blocking issues</strong>
<ul className="mt-2 list-disc pl-5">
{clawPackPreview.blockers.map((blocker) => (
<li key={blocker}>{blocker}</li>
))}
</ul>
</div>
) : null}
{clawPackPreview.warnings.length > 0 ? (
<div className="rounded-[var(--radius-sm)] border border-amber-300/50 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/30 dark:text-amber-100">
<strong>Warnings</strong>
<ul className="mt-2 list-disc pl-5">
{clawPackPreview.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
) : null}
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<strong className="text-sm text-[color:var(--ink)]">CLAWPACK.json</strong>
<span className="text-xs text-[color:var(--ink-soft)]">
digests computed after upload
</span>
</div>
<pre className="max-h-[420px] overflow-auto rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4 text-xs leading-5 text-[color:var(--ink)]">
<code>{clawPackPreview.manifestJson}</code>
</pre>
</div>
</div>
</Card>
) : null}
{publishSuccess ? (
<Card className="mb-5 border-emerald-300/50 bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-950/30">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<Badge variant="compact">Published</Badge>
<h2 className="mt-2 mb-1 font-display text-xl font-bold text-[color:var(--ink)]">
{publishSuccess.name}@{publishSuccess.version}
</h2>
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
The Claw Pack was accepted and stored. Public plugin pages are unchanged while
review, scanning, and rollout controls stay behind management surfaces.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button asChild variant="primary" size="sm">
<a href="/dashboard">Open dashboard</a>
</Button>
</div>
</div>
</Card>
) : null}
<Card
className={isMetadataLocked ? "pointer-events-none opacity-60" : ""}
aria-disabled={isMetadataLocked}
@@ -309,21 +902,29 @@ export function PublishPluginRoute() {
);
return;
}
const publishName = name.trim();
const publishVersion = version.trim();
setStatus("Uploading files...");
setError(null);
setPublishSuccess(null);
const uploaded = await buildPackageUploadEntries(files, {
generateUploadUrl,
hashFile,
uploadFile,
onProgress: (progress) => {
setStatus(
`Uploading file ${progress.current}/${progress.total}: ${progress.path}`,
);
},
});
setStatus("Publishing release...");
await publishRelease({
const result = await publishRelease({
payload: {
name: name.trim(),
name: publishName,
displayName: displayName.trim() || undefined,
ownerHandle: ownerHandle || undefined,
family,
version: version.trim(),
version: publishVersion,
changelog: changelog.trim(),
...(sourceRepo.trim() && sourceCommit.trim()
? {
@@ -357,6 +958,17 @@ export function PublishPluginRoute() {
setStatus(
"Published. Pending security checks and verification before public listing.",
);
setPublishSuccess({
name: publishName,
version: publishVersion,
releaseId:
typeof result === "object" &&
result !== null &&
"releaseId" in result &&
typeof result.releaseId === "string"
? result.releaseId
: undefined,
});
} catch (publishError) {
toast.error(formatPublishError(publishError));
setStatus(null);