feat(admin): export plugin validation reports (#3326)

* feat(admin): add plugin validation report command

* test(admin): cover validation report edge cases

* test(admin): satisfy validation report static gate

* feat(admin): serve plugin validation reports
This commit is contained in:
Patrick Erichsen
2026-07-30 16:08:40 -07:00
committed by GitHub
parent 6afd21e1a2
commit ee065b6d11
17 changed files with 1043 additions and 7 deletions
+77
View File
@@ -8302,6 +8302,83 @@ describe("httpApiV1 handlers", () => {
expect(runMutation).toHaveBeenCalledTimes(1);
});
it("returns a paginated plugin validation report to moderators", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const page = {
items: [
{
package: { id: "packages:demo", name: "demo", displayName: "Demo" },
release: { id: "packageReleases:demo", version: "1.0.0", createdAt: 100 },
references: { packagePage: "/plugins/demo", release: "demo@1.0.0" },
scan: {
status: "clean",
scannedAt: 200,
target: { channel: "beta", version: "2026.7.30-beta.1" },
inspectorVersion: "0.3.19",
skipReason: null,
},
findings: [],
},
],
nextCursor: "page-2",
done: false,
};
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return page;
});
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery }),
new Request("https://example.com/api/v1/packages/validation-report?limit=40&cursor=page-1", {
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(page);
expect(runQuery).toHaveBeenCalledWith(
(internal as unknown as { packages: Record<string, unknown> }).packages
.listPluginValidationReportPageInternal,
{ cursor: "page-1", numItems: 40 },
);
});
it("rejects unauthenticated plugin validation report requests", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValue(new Error("Unauthorized"));
const runQuery = vi.fn();
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery }),
new Request("https://example.com/api/v1/packages/validation-report"),
);
expect(response.status).toBe(401);
expect(runQuery).not.toHaveBeenCalled();
});
it("forbids ordinary users from reading the plugin validation report", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:viewer",
user: { _id: "users:viewer", role: "user" },
} as never);
const runQuery = vi.fn();
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery }),
new Request("https://example.com/api/v1/packages/validation-report", {
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(403);
await expect(response.text()).resolves.toBe("Moderator role required.");
expect(runQuery).not.toHaveBeenCalled();
});
it("package hard-delete dry-runs through the admin-only API", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
+33
View File
@@ -2,6 +2,7 @@ import {
ApiRoutes,
ApiV1PackageOfficialMigrationListResponseSchema,
ApiV1PackageOfficialMigrationResponseSchema,
ApiV1PackageValidationReportPageSchema,
ApiV1PackageModerationStatusResponseSchema,
ApiV1PackageSecurityResponseSchema,
PackageHardDeleteRequestSchema,
@@ -84,6 +85,7 @@ import {
resolveTagsBatch,
requireApiTokenUserOrResponse,
requireAdminOrResponse,
requireModeratorOrResponse,
requirePackagePublishAuthOrResponse,
safeStoredFilePreviewResponse,
safeStoredFileResponse,
@@ -113,6 +115,7 @@ const internalRefs = internal as unknown as {
getByNameForViewerInternal: unknown;
hasMissingRecommendationScoresInternal: unknown;
listPluginExportPageInternal: unknown;
listPluginValidationReportPageInternal: unknown;
listPageForViewerInternal: unknown;
searchForViewerInternal: unknown;
listVersionsForViewerInternal: unknown;
@@ -3633,6 +3636,36 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
return await searchPackages(ctx, request, { includeSkills: true });
}
if (segments[0] === "validation-report" && segments.length === 1) {
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 moderator = requireModeratorOrResponse(auth.user, rate.headers);
if (!moderator.ok) return moderator.response;
const url = new URL(request.url);
const limit = Math.max(
1,
Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 100, 100),
);
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
const result = await runQueryRef(
ctx,
internalRefs.packages.listPluginValidationReportPageInternal,
{ cursor, numItems: limit },
);
return json(
parseArk(
ApiV1PackageValidationReportPageSchema,
result,
"Package validation report response",
),
200,
rate.headers,
);
}
if (segments[0] === "moderation" && segments[1] === "queue" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
+10 -1
View File
@@ -7,7 +7,7 @@ import {
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { assertAdmin } from "../lib/access";
import { assertAdmin, assertModerator } from "../lib/access";
import { requireApiTokenUser, requirePackagePublishAuth } from "../lib/apiTokenAuth";
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
@@ -261,6 +261,15 @@ export function requireAdminOrResponse(user: Doc<"users">, headers: HeadersInit)
}
}
export function requireModeratorOrResponse(user: Doc<"users">, headers: HeadersInit) {
try {
assertModerator(user);
return { ok: true as const };
} catch {
return { ok: false as const, response: text("Moderator role required.", 403, headers) };
}
}
export function toOptionalNumber(value: string | null) {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
+219
View File
@@ -50,6 +50,7 @@ import {
findPackagePublishResultInternal,
listPackageModerationQueueInternal,
listPluginExportPageInternal,
listPluginValidationReportPageInternal,
reservePackageNameInternal,
listPublicPage,
listPublicNewPluginsPage,
@@ -218,6 +219,20 @@ const listPluginExportPageInternalHandler = (
}
>
)._handler;
const listPluginValidationReportPageInternalHandler = (
listPluginValidationReportPageInternal as unknown as WrappedHandler<
{ cursor?: string; numItems?: number },
{
items: Array<{
package: { id: string; name: string; displayName: string };
scan: { status: string; scannedAt: number | null };
findings: Array<{ severity: string; code: string; message: string }>;
}>;
nextCursor: string | null;
done: boolean;
}
>
)._handler;
const listVersionsHandler = (
listVersions as unknown as WrappedHandler<
{
@@ -2051,6 +2066,95 @@ function makePluginExportCtx(
};
}
function makePluginValidationReportCtx(
digests: Array<Record<string, unknown>>,
options: {
statesByRelease?: Record<string, Array<Record<string, unknown>>>;
findingsByRelease?: Record<string, Array<Record<string, unknown>>>;
} = {},
) {
const base = makePluginExportCtx(digests);
const releasesById = new Map(
digests.map((digest) => {
const name = String(digest.name);
const packageId = String(digest.packageId);
return [
`packageReleases:${name}-1`,
{
_id: `packageReleases:${name}-1`,
packageId,
version: typeof digest.latestVersion === "string" ? digest.latestVersion : "1.0.0",
createdAt: Number(digest.createdAt),
},
];
}),
);
return {
db: {
get: vi.fn(async (id: string) => (await base.db.get(id)) ?? releasesById.get(id) ?? null),
query: vi.fn((table: string) => {
if (table === "packageSearchDigest" || table === "packages") {
return base.db.query(table);
}
if (table === "packageInspectorScanStates") {
let releaseId = "";
return {
withIndex: vi.fn(
(
_indexName: string,
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
const queryBuilder = {
eq: (field: string, value: unknown) => {
if (field === "releaseId") releaseId = String(value);
return queryBuilder;
},
};
builder(queryBuilder);
return {
order: vi.fn(() => ({
first: vi.fn(
async () =>
[...(options.statesByRelease?.[releaseId] ?? [])].sort(
(a, b) => Number(b.completedAt) - Number(a.completedAt),
)[0] ?? null,
),
})),
};
},
),
};
}
if (table === "packageInspectorWarnings") {
let releaseId = "";
return {
withIndex: vi.fn(
(
_indexName: string,
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
const queryBuilder = {
eq: (field: string, value: unknown) => {
if (field === "releaseId") releaseId = String(value);
return queryBuilder;
},
};
builder(queryBuilder);
return {
order: vi.fn(() => ({
collect: vi.fn(async () => options.findingsByRelease?.[releaseId] ?? []),
})),
};
},
),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
};
}
function makeInsertReleaseCtx(
existing: Record<string, unknown> | null,
priorReleases: Array<Record<string, unknown>> = [],
@@ -3047,6 +3151,121 @@ describe("packages public queries", () => {
expect(third.nextCursor).toBeNull();
});
it("exports the latest scan state and normalized current findings", async () => {
const digest = makePluginExportDigest("demo", "code-plugin", 300);
const releaseId = "packageReleases:demo-1";
const ctx = makePluginValidationReportCtx([digest], {
statesByRelease: {
[releaseId]: [
{
releaseId,
inspectorVersion: "0.3.18",
targetOpenClawVersion: "2026.7.29-beta.1",
completedAt: 100,
},
{
releaseId,
inspectorVersion: "0.3.19",
targetOpenClawVersion: "2026.7.30-beta.1",
completedAt: 200,
},
],
},
findingsByRelease: {
[releaseId]: [
{
scanSource: "nightly",
inspectorVersion: "0.3.18",
targetOpenClawVersion: "2026.7.29-beta.1",
findingKind: "error",
code: "stale-missing-api",
message: "Old target API is unavailable",
},
{
scanSource: "publish",
findingKind: "warning",
code: "deprecated-api",
message: "API is deprecated",
},
{
scanSource: "nightly",
inspectorVersion: "0.3.19",
targetOpenClawVersion: "2026.7.30-beta.1",
findingKind: "error",
code: "missing-api",
message: "Required API is unavailable",
},
],
},
});
const result = await listPluginValidationReportPageInternalHandler(ctx, { numItems: 20 });
expect(result).toEqual({
items: [
expect.objectContaining({
package: { id: "packages:demo", name: "demo", displayName: "demo" },
scan: expect.objectContaining({
status: "error",
scannedAt: 200,
target: { channel: "beta", version: "2026.7.30-beta.1" },
inspectorVersion: "0.3.19",
}),
findings: [
{ severity: "warning", code: "deprecated-api", message: "API is deprecated" },
{ severity: "error", code: "missing-api", message: "Required API is unavailable" },
],
}),
],
nextCursor: null,
done: true,
});
});
it("represents missing scans and preserves plugin pagination continuity", async () => {
const ctx = makePluginValidationReportCtx(
[
makePluginExportDigest("newer-code", "code-plugin", 300),
makePluginExportDigest("older-bundle", "bundle-plugin", 200),
],
{
findingsByRelease: {
"packageReleases:newer-code-1": [
{
scanSource: "publish",
findingKind: "error",
code: "publish-error",
message: "Publish-time validation failed",
},
],
},
},
);
const first = await listPluginValidationReportPageInternalHandler(ctx, { numItems: 1 });
const second = await listPluginValidationReportPageInternalHandler(ctx, {
cursor: first.nextCursor ?? undefined,
numItems: 1,
});
expect(first.items).toHaveLength(1);
expect(first.items[0]).toMatchObject({
package: { name: "newer-code" },
scan: { status: "error", scannedAt: null },
findings: [{ severity: "error", code: "publish-error" }],
});
expect(first.done).toBe(false);
expect(first.nextCursor).toBeTruthy();
expect(second.items).toHaveLength(1);
expect(second.items[0]).toMatchObject({
package: { name: "older-bundle" },
scan: { status: "not-scanned", scannedAt: null },
findings: [],
});
expect(second.done).toBe(true);
expect(second.nextCursor).toBeNull();
});
it("keeps buffered cursor items aligned across paginated public pages", async () => {
const { ctx, paginate } = makeDigestCtx({
pages: [
+100
View File
@@ -135,6 +135,7 @@ const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
const MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS = 500;
const MAX_PUBLIC_LIST_FILTER_SCAN_PAGES = 6;
const MAX_PLUGIN_EXPORT_LIST_LIMIT = 250;
const MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE = 50;
const MAX_SEARCH_PAGE_SIZE = 200;
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
const MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES = 40;
@@ -3968,6 +3969,105 @@ export const listPluginExportPageInternal = internalQuery({
},
});
function normalizePackageValidationFinding(warning: Doc<"packageInspectorWarnings">) {
const severity =
warning.findingKind === "error" ||
warning.level === "breakage" ||
warning.level === "error" ||
warning.severity === "P0"
? ("error" as const)
: warning.severity?.toLowerCase() === "info"
? ("info" as const)
: ("warning" as const);
return { severity, code: warning.code, message: warning.message };
}
export const listPluginValidationReportPageInternal = internalQuery({
args: {
cursor: v.optional(v.string()),
numItems: v.optional(v.number()),
},
handler: async (ctx, args) => {
const numItems = Math.max(
1,
Math.min(
args.numItems ?? MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
),
);
const result = await listMergedPluginExportPage(ctx, {
startDate: 0,
endDate: Number.MAX_SAFE_INTEGER,
cursor: args.cursor,
numItems,
});
const items = [];
for (const digest of result.page) {
if (!digest.latestReleaseId) continue;
const release = await ctx.db.get(digest.latestReleaseId);
if (!release || release.softDeletedAt || release.packageId !== digest.packageId) continue;
const scanState = await ctx.db
.query("packageInspectorScanStates")
.withIndex("by_release_and_completed_at", (q) => q.eq("releaseId", release._id))
.order("desc")
.first();
// CLAW-626 reconciles nightly findings per release and preserves publish/static findings.
// Matching the selected tuple also fails closed if stale nightly rows survive a partial rollout.
const storedWarnings = await ctx.db
.query("packageInspectorWarnings")
.withIndex("by_release_created", (q) => q.eq("releaseId", release._id))
.order("asc")
.collect();
const warnings = storedWarnings.filter(
(warning) =>
warning.scanSource !== "nightly" ||
(scanState !== null &&
warning.inspectorVersion === scanState.inspectorVersion &&
warning.targetOpenClawVersion === scanState.targetOpenClawVersion),
);
const findings = warnings.map(normalizePackageValidationFinding);
const status = findings.some((finding) => finding.severity === "error")
? ("error" as const)
: findings.length > 0
? ("warning" as const)
: !scanState
? ("not-scanned" as const)
: ("clean" as const);
items.push({
package: {
id: String(digest.packageId),
name: digest.name,
displayName: digest.displayName,
},
release: {
id: String(release._id),
version: release.version,
createdAt: release.createdAt,
},
references: {
packagePage: `/plugins/${encodeURIComponent(digest.name)}`,
release: `${digest.name}@${release.version}`,
},
scan: {
status,
scannedAt: scanState?.completedAt ?? null,
target: scanState
? { channel: "beta" as const, version: scanState.targetOpenClawVersion }
: null,
inspectorVersion: scanState?.inspectorVersion ?? null,
skipReason: null,
},
findings,
});
}
return {
items,
nextCursor: result.nextCursor,
done: !result.hasMore,
};
},
});
export const listPageForViewerInternal = internalQuery({
args: {
family: v.optional(
+7 -5
View File
@@ -1993,11 +1993,13 @@ const packageInspectorScanStates = defineTable({
targetOpenClawVersion: v.string(),
completedAt: v.number(),
notificationCompletedAt: v.optional(v.number()),
}).index("by_release_and_inspector_version_and_target_openclaw_version", [
"releaseId",
"inspectorVersion",
"targetOpenClawVersion",
]);
})
.index("by_release_and_inspector_version_and_target_openclaw_version", [
"releaseId",
"inspectorVersion",
"targetOpenClawVersion",
])
.index("by_release_and_completed_at", ["releaseId", "completedAt"]);
const securityScanJobs = defineTable({
targetKind: securityScanTargetKindValidator,
+5
View File
@@ -129,6 +129,7 @@ bun run admin -- plugins queue [--status open|blocked|manual|all]
bun run admin -- plugins reports [--status open|confirmed|dismissed|all]
bun run admin -- plugins triage-report <report-id> --status open|confirmed|dismissed [--note <text>] [--action none|quarantine|revoke] [--yes]
bun run admin -- packages validation-report --json > plugin-validation-report.json
bun run admin -- plugins migrations [--phase <phase>]
bun run admin -- plugins set-migration <bundled-plugin-id> --package <name>
bun run admin -- plugins hard-delete <name> --owner <handle> --reason <text> [--apply --confirm <token> --yes] [--json]
@@ -139,3 +140,7 @@ bun run admin -- plugins trusted-publisher delete <name>
```
All skill and plugin commands accept `--json` where the underlying endpoint supports machine-readable output.
`packages validation-report --json` exhaustively fetches the current validation state for every
plugin and writes exactly one JSON document to stdout. Redirect stdout to archive the report;
authentication, registry, and request failures are written to stderr by the CLI error handler.
+10
View File
@@ -52,6 +52,7 @@ import {
} from "./commands/orgs.js";
import {
cmdDeletePackageTrustedPublisher,
cmdExportPackageValidationReport,
cmdHardDeletePackage,
cmdListPackageMigrations,
cmdListPackageReports,
@@ -767,6 +768,15 @@ function registerPluginModerationCommands(command: Command) {
}
function registerPluginOperations(command: Command) {
command
.command("validation-report")
.description("Export current plugin validation results as JSON")
.requiredOption("--json", "Output one JSON report document")
.action(async (options) => {
const opts = await resolveGlobalOpts();
await cmdExportPackageValidationReport(opts, options);
});
command
.command("moderate")
.description("Set plugin release moderation state")
@@ -1,6 +1,7 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiV1PackageValidationReportPageSchema } from "../../../clawhub/src/schema/index.js";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
@@ -20,6 +21,7 @@ vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
const {
cmdExportPackageValidationReport,
cmdHardDeletePackage,
cmdRepairPackageName,
cmdRepairPackageRuntimeId,
@@ -28,6 +30,205 @@ const {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
httpMocks.apiRequest.mockReset();
vi.useRealTimers();
});
describe("cmdExportPackageValidationReport", () => {
it("fetches every page and writes one catalog-wide JSON document", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-30T20:00:00.000Z"));
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const clean = {
package: { id: "packages:alpha", name: "alpha", displayName: "Alpha" },
release: { id: "packageReleases:alpha", version: "1.0.0", createdAt: 100 },
references: { packagePage: "/plugins/alpha", release: "alpha@1.0.0" },
scan: {
status: "clean",
scannedAt: 200,
target: { channel: "beta", version: "2026.7.30-beta.1" },
inspectorVersion: "0.5.0",
skipReason: null,
},
findings: [],
};
const failing = {
package: { id: "packages:beta", name: "beta", displayName: "Beta" },
release: { id: "packageReleases:beta", version: "2.0.0", createdAt: 300 },
references: { packagePage: "/plugins/beta", release: "beta@2.0.0" },
scan: {
status: "error",
scannedAt: 400,
target: { channel: "beta", version: "2026.7.30-beta.1" },
inspectorVersion: "0.5.0",
skipReason: null,
},
findings: [
{
severity: "error",
code: "missing-api",
message: "Required API is unavailable",
},
],
};
httpMocks.apiRequest
.mockResolvedValueOnce({ items: [clean], nextCursor: "page-2", done: false })
.mockResolvedValueOnce({ items: [failing], nextCursor: null, done: true });
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
expect(authTokenMocks.requireAuthToken).toHaveBeenCalledOnce();
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2);
expect(httpMocks.apiRequest.mock.calls[0]?.[1]).toMatchObject({ method: "GET", token: "tkn" });
expect(httpMocks.apiRequest.mock.calls[0]?.[1]?.url).toBe(
"https://clawhub.ai/api/v1/packages/validation-report?limit=100",
);
expect(httpMocks.apiRequest.mock.calls[0]?.[2]).toBe(ApiV1PackageValidationReportPageSchema);
expect(httpMocks.apiRequest.mock.calls[1]?.[1]?.url).toBe(
"https://clawhub.ai/api/v1/packages/validation-report?limit=100&cursor=page-2",
);
expect(report).toEqual({
schemaVersion: 1,
generatedAt: "2026-07-30T20:00:00.000Z",
source: { registry: "https://clawhub.ai", pages: 2 },
totals: {
plugins: 2,
byScanStatus: { notScanned: 0, skipped: 0, clean: 1, warning: 0, error: 1 },
findings: { total: 1, bySeverity: { info: 0, warning: 0, error: 1 } },
},
plugins: [clean, failing],
});
expect(stdout).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toEqual(report);
expect(stderr).not.toHaveBeenCalled();
});
it("returns an explicit all-zero report for an empty catalog", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-30T20:00:00.000Z"));
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
httpMocks.apiRequest.mockResolvedValueOnce({ items: [], nextCursor: null, done: true });
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
expect(report).toEqual({
schemaVersion: 1,
generatedAt: "2026-07-30T20:00:00.000Z",
source: { registry: "https://clawhub.ai", pages: 1 },
totals: {
plugins: 0,
byScanStatus: { notScanned: 0, skipped: 0, clean: 0, warning: 0, error: 0 },
findings: { total: 0, bySeverity: { info: 0, warning: 0, error: 0 } },
},
plugins: [],
});
expect(stdout).toHaveBeenCalledTimes(1);
});
it("counts explicit missing, skipped, warning, and mixed-severity scan results", async () => {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const makePlugin = (
id: string,
status: "not-scanned" | "skipped" | "warning",
findings: Array<{ severity: "info" | "warning" | "error"; code: string; message: string }>,
) => ({
package: { id: `packages:${id}`, name: id, displayName: id },
release: { id: `packageReleases:${id}`, version: "1.0.0", createdAt: 100 },
references: { packagePage: `/plugins/${id}`, release: `${id}@1.0.0` },
scan: {
status,
scannedAt: status === "not-scanned" ? null : 200,
target: status === "not-scanned" ? null : { channel: "beta", version: "beta.1" },
inspectorVersion: status === "not-scanned" ? null : "0.5.0",
skipReason: status === "skipped" ? "unchanged" : null,
},
findings,
});
httpMocks.apiRequest.mockResolvedValueOnce({
items: [
makePlugin("missing", "not-scanned", []),
makePlugin("skipped", "skipped", []),
makePlugin("warning", "warning", [
{ severity: "info", code: "info", message: "Informational" },
{ severity: "warning", code: "warning", message: "Warning" },
{ severity: "error", code: "error", message: "Error" },
]),
],
nextCursor: null,
done: true,
});
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
expect(report.totals).toEqual({
plugins: 3,
byScanStatus: { notScanned: 1, skipped: 1, clean: 0, warning: 1, error: 0 },
findings: { total: 3, bySeverity: { info: 1, warning: 1, error: 1 } },
});
});
it("does not write stdout when authentication fails", async () => {
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
authTokenMocks.requireAuthToken.mockRejectedValueOnce(new Error("Authentication required"));
await expect(
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
).rejects.toThrow("Authentication required");
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
expect(stdout).not.toHaveBeenCalled();
});
it("fails closed when the API repeats a pagination cursor", async () => {
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
httpMocks.apiRequest
.mockResolvedValueOnce({ items: [], nextCursor: "same-page", done: false })
.mockResolvedValueOnce({ items: [], nextCursor: "same-page", done: false })
.mockRejectedValueOnce(new Error("unexpected third request"));
await expect(
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
).rejects.toThrow("Validation report response repeated a pagination cursor");
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2);
expect(stdout).not.toHaveBeenCalled();
});
it("fails closed when pages repeat a plugin", async () => {
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const plugin = {
package: { id: "packages:alpha", name: "alpha", displayName: "Alpha" },
release: { id: "packageReleases:alpha", version: "1.0.0", createdAt: 100 },
references: { packagePage: "/plugins/alpha", release: "alpha@1.0.0" },
scan: {
status: "not-scanned",
scannedAt: null,
target: null,
inspectorVersion: null,
skipReason: null,
},
findings: [],
};
httpMocks.apiRequest
.mockResolvedValueOnce({ items: [plugin], nextCursor: "page-2", done: false })
.mockResolvedValueOnce({ items: [plugin], nextCursor: null, done: true });
await expect(
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
).rejects.toThrow("Validation report response repeated package packages:alpha");
expect(stdout).not.toHaveBeenCalled();
});
it("fails closed when a truncated page omits its continuation cursor", async () => {
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
httpMocks.apiRequest.mockResolvedValueOnce({ items: [], nextCursor: null, done: false });
await expect(
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
).rejects.toThrow("Validation report response omitted its pagination cursor");
expect(stdout).not.toHaveBeenCalled();
});
});
describe("cmdHardDeletePackage", () => {
@@ -15,6 +15,7 @@ import {
import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
import {
ApiRoutes,
ApiV1PackageValidationReportPageSchema,
ApiV1PackageHardDeleteResponseSchema,
ApiV1PackageModerationQueueResponseSchema,
ApiV1PackageOfficialMigrationListResponseSchema,
@@ -32,6 +33,8 @@ import {
type PackageReportStatus,
type PackageReleaseModerationState,
type PackageTrustedPublisher,
type ApiV1PackageValidationReportPage,
type PackageValidationReportItem,
} from "../../../clawhub/src/schema/index.js";
type PackageTrustedPublisherSetOptions = {
@@ -130,6 +133,81 @@ type PackageHardDeleteOptions = {
yes?: boolean;
};
type PackageValidationReportOptions = {
json?: boolean;
};
export async function cmdExportPackageValidationReport(
opts: GlobalOpts,
options: PackageValidationReportOptions = {},
) {
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const plugins: PackageValidationReportItem[] = [];
let cursor: string | null = null;
let pages = 0;
const seenCursors = new Set<string>();
const seenPackageIds = new Set<string>();
do {
const url = registryUrl(`${ApiRoutes.packages}/validation-report`, registry);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await apiRequest<ApiV1PackageValidationReportPage>(
registry,
{
method: "GET",
url: url.toString(),
token,
},
ApiV1PackageValidationReportPageSchema,
);
pages += 1;
for (const plugin of page.items) {
if (seenPackageIds.has(plugin.package.id)) {
fail(`Validation report response repeated package ${plugin.package.id}`);
}
seenPackageIds.add(plugin.package.id);
plugins.push(plugin);
}
if (!page.done && !page.nextCursor) {
fail("Validation report response omitted its pagination cursor");
}
const nextCursor = page.done ? null : page.nextCursor;
if (nextCursor && seenCursors.has(nextCursor)) {
fail("Validation report response repeated a pagination cursor");
}
if (nextCursor) seenCursors.add(nextCursor);
cursor = nextCursor;
} while (cursor);
const byScanStatus = { notScanned: 0, skipped: 0, clean: 0, warning: 0, error: 0 };
const bySeverity = { info: 0, warning: 0, error: 0 };
let findingTotal = 0;
for (const plugin of plugins) {
const statusKey = plugin.scan.status === "not-scanned" ? "notScanned" : plugin.scan.status;
byScanStatus[statusKey] += 1;
for (const finding of plugin.findings) {
bySeverity[finding.severity] += 1;
findingTotal += 1;
}
}
const report = {
schemaVersion: 1 as const,
generatedAt: new Date().toISOString(),
source: { registry, pages },
totals: {
plugins: plugins.length,
byScanStatus,
findings: { total: findingTotal, bySeverity },
},
plugins,
};
if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return report;
}
export async function cmdHardDeletePackage(
opts: GlobalOpts,
packageName: string,
@@ -59,6 +59,41 @@ describe("packed admin CLI", () => {
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Usage: clawhub-admin");
const packagesHelp = spawnSync(
process.execPath,
[join(installDir, "package", "bin", "clawhub-admin.js"), "packages", "--help"],
{
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0" },
},
);
expect(packagesHelp.status).toBe(0);
expect(packagesHelp.stdout).toContain("validation-report");
expect(packagesHelp.stdout).toContain("Export current plugin validation results as JSON");
const authFailure = spawnSync(
process.execPath,
[
join(installDir, "package", "bin", "clawhub-admin.js"),
"--registry",
"https://example.invalid",
"packages",
"validation-report",
"--json",
],
{
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
CLAWHUB_CONFIG_PATH: join(tempDir, "missing-auth.json"),
FORCE_COLOR: "0",
},
},
);
expect(authFailure.status).not.toBe(0);
expect(authFailure.stdout).toBe("");
expect(authFailure.stderr).toMatch(/not logged in|login/i);
await expect(
readFile(join(installDir, "package", "dist", "clawhub-admin", "src", "cli.js")),
).resolves.toBeTruthy();
+48
View File
@@ -373,6 +373,54 @@ export const ApiV1PackageListResponseSchema = type({
nextCursor: "string|null",
});
export const PackageValidationReportScanStatusSchema = type(
'"not-scanned"|"skipped"|"clean"|"warning"|"error"',
);
export type PackageValidationReportScanStatus =
(typeof PackageValidationReportScanStatusSchema)[inferred];
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
export type PackageValidationReportFindingSeverity =
(typeof PackageValidationReportFindingSeveritySchema)[inferred];
export const PackageValidationReportItemSchema = type({
package: {
id: "string",
name: "string",
displayName: "string",
},
release: {
id: "string",
version: "string",
createdAt: "number",
},
references: {
packagePage: "string",
release: "string",
},
scan: {
status: PackageValidationReportScanStatusSchema,
scannedAt: "number|null",
target: type({ channel: "string", version: "string" }).or("null"),
inspectorVersion: "string|null",
skipReason: "string|null",
},
findings: type({
severity: PackageValidationReportFindingSeveritySchema,
code: "string",
message: "string",
}).array(),
});
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
export const ApiV1PackageValidationReportPageSchema = type({
items: PackageValidationReportItemSchema.array(),
nextCursor: "string|null",
done: "boolean",
});
export type ApiV1PackageValidationReportPage =
(typeof ApiV1PackageValidationReportPageSchema)[inferred];
export const ApiV1PackageSearchResponseSchema = type({
results: type({
score: "number",
+72
View File
@@ -389,6 +389,78 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
nextCursor: string | null;
}, {}>;
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
export declare const PackageValidationReportScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"clean" | "error" | "not-scanned" | "skipped" | "warning", {}>;
export type PackageValidationReportScanStatus = (typeof PackageValidationReportScanStatusSchema)[inferred];
export declare const PackageValidationReportFindingSeveritySchema: import("arktype/internal/variants/string.ts").StringType<"error" | "info" | "warning", {}>;
export type PackageValidationReportFindingSeverity = (typeof PackageValidationReportFindingSeveritySchema)[inferred];
export declare const PackageValidationReportItemSchema: import("arktype/internal/variants/object.ts").ObjectType<{
package: {
id: string;
name: string;
displayName: string;
};
release: {
id: string;
version: string;
createdAt: number;
};
references: {
packagePage: string;
release: string;
};
scan: {
status: "clean" | "error" | "not-scanned" | "skipped" | "warning";
scannedAt: number | null;
target: {
channel: string;
version: string;
} | null;
inspectorVersion: string | null;
skipReason: string | null;
};
findings: {
severity: "error" | "info" | "warning";
code: string;
message: string;
}[];
}, {}>;
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
export declare const ApiV1PackageValidationReportPageSchema: import("arktype/internal/variants/object.ts").ObjectType<{
items: {
package: {
id: string;
name: string;
displayName: string;
};
release: {
id: string;
version: string;
createdAt: number;
};
references: {
packagePage: string;
release: string;
};
scan: {
status: "clean" | "error" | "not-scanned" | "skipped" | "warning";
scannedAt: number | null;
target: {
channel: string;
version: string;
} | null;
inspectorVersion: string | null;
skipReason: string | null;
};
findings: {
severity: "error" | "info" | "warning";
code: string;
message: string;
}[];
}[];
nextCursor: string | null;
done: boolean;
}, {}>;
export type ApiV1PackageValidationReportPage = (typeof ApiV1PackageValidationReportPageSchema)[inferred];
export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
results: {
score: number;
+35
View File
@@ -301,6 +301,41 @@ export const ApiV1PackageListResponseSchema = type({
items: PackageListItemSchema.array(),
nextCursor: "string|null",
});
export const PackageValidationReportScanStatusSchema = type('"not-scanned"|"skipped"|"clean"|"warning"|"error"');
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
export const PackageValidationReportItemSchema = type({
package: {
id: "string",
name: "string",
displayName: "string",
},
release: {
id: "string",
version: "string",
createdAt: "number",
},
references: {
packagePage: "string",
release: "string",
},
scan: {
status: PackageValidationReportScanStatusSchema,
scannedAt: "number|null",
target: type({ channel: "string", version: "string" }).or("null"),
inspectorVersion: "string|null",
skipReason: "string|null",
},
findings: type({
severity: PackageValidationReportFindingSeveritySchema,
code: "string",
message: "string",
}).array(),
});
export const ApiV1PackageValidationReportPageSchema = type({
items: PackageValidationReportItemSchema.array(),
nextCursor: "string|null",
done: "boolean",
});
export const ApiV1PackageSearchResponseSchema = type({
results: type({
score: "number",
File diff suppressed because one or more lines are too long
+48
View File
@@ -404,6 +404,54 @@ export const ApiV1PackageListResponseSchema = type({
});
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
export const PackageValidationReportScanStatusSchema = type(
'"not-scanned"|"skipped"|"clean"|"warning"|"error"',
);
export type PackageValidationReportScanStatus =
(typeof PackageValidationReportScanStatusSchema)[inferred];
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
export type PackageValidationReportFindingSeverity =
(typeof PackageValidationReportFindingSeveritySchema)[inferred];
export const PackageValidationReportItemSchema = type({
package: {
id: "string",
name: "string",
displayName: "string",
},
release: {
id: "string",
version: "string",
createdAt: "number",
},
references: {
packagePage: "string",
release: "string",
},
scan: {
status: PackageValidationReportScanStatusSchema,
scannedAt: "number|null",
target: type({ channel: "string", version: "string" }).or("null"),
inspectorVersion: "string|null",
skipReason: "string|null",
},
findings: type({
severity: PackageValidationReportFindingSeveritySchema,
code: "string",
message: "string",
}).array(),
});
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
export const ApiV1PackageValidationReportPageSchema = type({
items: PackageValidationReportItemSchema.array(),
nextCursor: "string|null",
done: "boolean",
});
export type ApiV1PackageValidationReportPage =
(typeof ApiV1PackageValidationReportPageSchema)[inferred];
export const ApiV1PackageSearchResponseSchema = type({
results: type({
score: "number",
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { parseArk } from "./ark.js";
import { ApiV1PackageValidationReportPageSchema } from "./packages.js";
describe("ApiV1PackageValidationReportPageSchema", () => {
it("accepts a paginated plugin validation report page", () => {
const page = {
items: [
{
package: { id: "packages:demo", name: "@openclaw/demo", displayName: "Demo" },
release: { id: "packageReleases:demo", version: "1.2.3", createdAt: 100 },
references: {
packagePage: "/plugins/%40openclaw%2Fdemo",
release: "@openclaw/demo@1.2.3",
},
scan: {
status: "error",
scannedAt: 200,
target: { channel: "beta", version: "2026.7.30-beta.1" },
inspectorVersion: "0.3.19",
skipReason: null,
},
findings: [
{ severity: "error", code: "missing-api", message: "Required API is unavailable" },
],
},
],
nextCursor: "page-2",
done: false,
};
expect(parseArk(ApiV1PackageValidationReportPageSchema, page, "validation report")).toEqual(
page,
);
});
it("rejects unknown scan statuses", () => {
expect(() =>
parseArk(
ApiV1PackageValidationReportPageSchema,
{
items: [
{
package: { id: "packages:demo", name: "demo", displayName: "Demo" },
release: { id: "packageReleases:demo", version: "1.0.0", createdAt: 100 },
references: { packagePage: "/plugins/demo", release: "demo@1.0.0" },
scan: {
status: "stale",
scannedAt: null,
target: null,
inspectorVersion: null,
skipReason: null,
},
findings: [],
},
],
nextCursor: null,
done: true,
},
"validation report",
),
).toThrow(/status/);
});
});