Files
clawhub/scripts/package-inspector-nightly-scan.ts
Patrick Erichsen a16ff751bb feat: notify plugin owners only for hard compatibility errors (#3365)
* chore: update plugin inspector to 0.3.20

* feat: gate plugin compatibility emails on hard errors
2026-07-31 17:37:52 -07:00

1154 lines
40 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
type ClaimItem = {
packageId: string;
releaseId: string;
ownerUserId?: string;
ownerPublisherId?: string;
packageName: string;
version: string;
artifactKind?: string;
downloadUrl: string;
};
type ClaimResponse = {
ok: true;
leased: boolean;
dryRun?: boolean;
nextCursor?: string | null;
skippedUnchanged?: number;
items: ClaimItem[];
};
type NormalizedFinding = {
id?: string;
code: string;
level: string;
severity?: string;
issueClass?: string;
compatStatus?: string;
deprecated?: boolean;
message: string;
evidence?: string[];
fixture?: string;
decision?: string;
authorRemediation?: {
summary: string;
docsUrl?: string;
};
};
type ImpactEntry = {
packageId: string;
releaseId: string;
packageName: string;
version: string;
ownerUserId?: string;
ownerPublisherId?: string;
findingCount: number;
errorCount: number;
warningCount: number;
targetOpenClawVersion?: string;
findings: NormalizedFinding[];
};
type UploadResult = {
ok: true;
inserted: number;
shouldEmailOwner: boolean;
};
type NotificationResult = {
ok: true;
sent: boolean;
reason?: string;
};
type PluginInspectorModule = {
openClawTargets?: {
resolveVersion: (requestedVersion: string) => Promise<Record<string, unknown>>;
prepare: (resolvedTarget: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
pluginRoot?: {
runCheck: (options: Record<string, unknown>) => Promise<{
report: Record<string, unknown>;
paths: { jsonPath: string };
}>;
};
ci?: {
writeOutputs: (
report: Record<string, unknown>,
options: { cwd: string; outDir: string },
) => Promise<unknown>;
};
reports?: {
sanitizeArtifact: (report: Record<string, unknown>) => unknown;
};
};
const siteUrl = (process.env.CLAWHUB_SITE_URL ?? "https://clawhub.ai").replace(/\/+$/, "");
const token = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN;
const batchSize = process.env.PLUGIN_INSPECTOR_BATCH_SIZE ?? "25";
const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN);
const notifyOwners = parseBoolean(process.env.PLUGIN_INSPECTOR_NOTIFY_OWNERS);
const notificationOnly = parseBoolean(process.env.PLUGIN_INSPECTOR_NOTIFICATION_ONLY);
const notificationManifestPath = process.env.PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST?.trim();
const targetPackageNames = parsePackageNames(process.env.PLUGIN_INSPECTOR_PACKAGE_NAMES);
const dryRunMaxBatches = Math.max(
1,
Math.min(
Number.parseInt(process.env.PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES ?? "20", 10) || 20,
100,
),
);
const artifactRoot =
process.env.PLUGIN_INSPECTOR_ARTIFACT_DIR ?? "plugin-inspector-bulk-scan-reports";
const scanRunId = resolveScanRunId(process.env);
const legacyFileDownloadAttempts = 4;
export function resolveScanRunId(env: Record<string, string | undefined>) {
return env.PLUGIN_INSPECTOR_RUN_ID?.trim() || env.GITHUB_RUN_ID?.trim() || randomUUID();
}
export function resolveNightlyOpenClawTarget(value: string | undefined) {
const requested = value?.trim() || "beta";
if (requested !== "beta") {
throw new Error("Nightly plugin scans only support the OpenClaw beta target");
}
return requested;
}
export async function prepareBulkOpenClawTarget(
requestedVersion: string,
inspectorModule?: PluginInspectorModule,
) {
const inspector =
inspectorModule ??
((await import("@openclaw/plugin-inspector")) as unknown as PluginInspectorModule);
if (!inspector.openClawTargets) {
throw new Error(
"The bundled Plugin Inspector does not support version-resolved OpenClaw targets",
);
}
const resolved = await inspector.openClawTargets.resolveVersion(requestedVersion);
const target = await inspector.openClawTargets.prepare(resolved);
const exactVersion = stringValue(target.version) ?? stringValue(resolved.version);
if (!exactVersion) {
throw new Error("Plugin Inspector did not return an exact OpenClaw target version");
}
return { exactVersion, target };
}
export async function runPackageInspectorNightlyScan() {
if (!token) throw new Error("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN is required");
if (notificationOnly && !notifyOwners) {
throw new Error("PLUGIN_INSPECTOR_NOTIFICATION_ONLY requires owner notifications");
}
if (notifyOwners && !notificationOnly) {
throw new Error("Owner notifications require notification-only mode");
}
if (notificationOnly && dryRun) {
throw new Error("Notification-only mode cannot run as a dry run");
}
if (notificationOnly && targetPackageNames.length > 0) {
throw new Error(
"Notification-only mode requires the reviewed scan manifest, not package names",
);
}
const inspectorVersion = getInspectorVersion();
const inspectorModule =
(await import("@openclaw/plugin-inspector")) as unknown as PluginInspectorModule;
const notificationManifest = notificationOnly
? await loadNotificationManifest(notificationManifestPath, inspectorVersion)
: null;
const preparedTarget = notificationManifest
? null
: await prepareBulkOpenClawTarget(
resolveNightlyOpenClawTarget(process.env.PLUGIN_INSPECTOR_OPENCLAW_VERSION),
inspectorModule,
);
const targetOpenClawVersion =
notificationManifest?.targetOpenClawVersion ?? preparedTarget?.exactVersion;
if (!targetOpenClawVersion) throw new Error("Unable to resolve an exact OpenClaw target");
await mkdir(artifactRoot, { recursive: true });
const scanStartedAt = Date.now();
let hadWorkerFailure = false;
const impactEntries: ImpactEntry[] = [];
let claimed = 0;
let scanned = 0;
let skippedUnchanged = 0;
let notificationAttempts = 0;
let notificationsSent = 0;
let cursor: string | null = null;
let batches = 0;
let truncated = false;
if (notificationManifest) {
batches = 1;
claimed = notificationManifest.items.length;
for (const item of notificationManifest.items) {
const result = await notifyPackageItem(item, inspectorVersion, targetOpenClawVersion);
if (result.failed) hadWorkerFailure = true;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
}
} else if (preparedTarget && targetPackageNames.length > 0) {
const items = await resolveTargetPackageItems(targetPackageNames);
batches = 1;
claimed = items.length;
for (const item of items) {
const result = await inspectPackageItem(
item,
inspectorVersion,
preparedTarget,
inspectorModule,
);
if (result.failed) hadWorkerFailure = true;
if (result.impactEntry) impactEntries.push(result.impactEntry);
if (result.scanned) scanned += 1;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
}
} else if (preparedTarget) {
do {
const claimCursor = cursor;
const claim = await claimBatch(cursor, inspectorVersion, targetOpenClawVersion, scanRunId);
if (claim.leased) {
throw new Error("Plugin Inspector bulk scan lease is owned by another run");
}
batches += 1;
const nextCursor = claim.nextCursor ?? null;
claimed += claim.items.length;
skippedUnchanged += claim.skippedUnchanged ?? 0;
let batchFailed = false;
for (const item of claim.items) {
const result = await inspectPackageItem(
item,
inspectorVersion,
preparedTarget,
inspectorModule,
);
if (result.failed) {
hadWorkerFailure = true;
batchFailed = true;
}
if (result.impactEntry) impactEntries.push(result.impactEntry);
if (result.scanned) scanned += 1;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
}
if (!dryRun) {
if (batchFailed) {
cursor = claimCursor;
break;
}
await acknowledgeBatch(nextCursor, scanRunId);
}
cursor = nextCursor;
if (cursor && batches >= dryRunMaxBatches) {
if (dryRun) {
truncated = true;
break;
}
}
} while (cursor);
} else {
throw new Error("The scan target was not prepared");
}
const scanDurationMs = Date.now() - scanStartedAt;
const summary = summarizeImpact({
claimed,
scanned,
skippedUnchanged,
batches,
truncated,
nextCursor: cursor,
inspectorVersion,
targetOpenClawVersion,
notifyOwners,
notificationOnly,
notificationAttempts,
notificationsSent,
scanStartedAt: new Date(scanStartedAt).toISOString(),
scanDurationMs,
entries: impactEntries,
});
await writeFile(
path.join(artifactRoot, "run-summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
await writeFile(path.join(artifactRoot, "run-summary.md"), renderImpactMarkdown(summary));
if (dryRun) {
await writeFile(
path.join(artifactRoot, "impact-summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
}
console.log(
notificationOnly
? `Notification phase for OpenClaw ${summary.targetOpenClawVersion}: attempted=${summary.notificationAttempts}, sent=${summary.notificationsSent}, skipped=${summary.skippedUnchangedReleases}.`
: `Bulk scan target OpenClaw ${summary.targetOpenClawVersion}: scanned=${summary.scannedReleases}, skippedUnchanged=${summary.skippedUnchangedReleases}, errors=${summary.pluginsWithErrors}, warnings=${summary.pluginsWithWarnings}.`,
);
if (hadWorkerFailure) {
process.exitCode = 1;
}
}
export async function loadNotificationManifest(
manifestPath: string | undefined,
expectedInspectorVersion: string,
) {
if (!manifestPath) {
throw new Error(
"PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST is required for notification-only mode",
);
}
const raw = JSON.parse(await readFile(manifestPath, "utf8")) as unknown;
if (!isPlainObject(raw)) throw new Error("Notification manifest must be a JSON object");
if (
raw.dryRun !== false ||
raw.notificationOnly !== false ||
raw.notifyOwners !== false ||
raw.truncated !== false ||
raw.nextCursor !== null
) {
throw new Error("Notification manifest must come from a completed no-email production scan");
}
const inspectorVersion = stringValue(raw.inspectorVersion);
if (inspectorVersion !== expectedInspectorVersion) {
throw new Error(
`Notification manifest uses Plugin Inspector ${inspectorVersion ?? "unknown"}; expected ${expectedInspectorVersion}`,
);
}
const targetOpenClawVersion = stringValue(raw.targetOpenClawVersion);
if (!targetOpenClawVersion) {
throw new Error("Notification manifest is missing the exact OpenClaw target version");
}
if (!Array.isArray(raw.packages)) throw new Error("Notification manifest is missing packages");
const items: ClaimItem[] = [];
for (const entry of raw.packages) {
if (!isPlainObject(entry) || typeof entry.errorCount !== "number" || entry.errorCount <= 0) {
continue;
}
const packageId = stringValue(entry.packageId);
const releaseId = stringValue(entry.releaseId);
const packageName = stringValue(entry.packageName);
const version = stringValue(entry.version);
if (!packageId || !releaseId || !packageName || !version) {
throw new Error("Notification manifest contains an incomplete hard-error release");
}
items.push({ packageId, releaseId, packageName, version, downloadUrl: "" });
}
return { targetOpenClawVersion, items };
}
async function notifyPackageItem(
item: ClaimItem,
inspectorVersion: string,
targetOpenClawVersion: string,
) {
if (dryRun) {
return {
failed: false,
scanned: false,
notificationAttempted: false,
notificationSent: false,
impactEntry: undefined,
};
}
const reportDir = path.resolve(
artifactRoot,
safeArtifactName(`${item.packageName}-${item.version}`),
);
await mkdir(reportDir, { recursive: true });
try {
const result = await postJson<NotificationResult>(
`${siteUrl}/api/v1/package-inspector/notify`,
{
packageId: item.packageId,
releaseId: item.releaseId,
inspectorVersion,
targetOpenClawVersion,
},
);
await writeFile(
path.join(reportDir, "notification-result.json"),
`${JSON.stringify(result, null, 2)}\n`,
);
console.log(
`Notification ${item.packageName}@${item.version}: sent=${result.sent}${result.reason ? `, reason=${result.reason}` : ""}`,
);
return {
failed: false,
scanned: false,
notificationAttempted: true,
notificationSent: result.sent,
impactEntry: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await writeFile(path.join(reportDir, "notification-error.txt"), message);
console.error(`Plugin owner notification failed for ${item.packageName}@${item.version}`);
console.error(message);
return {
failed: true,
scanned: false,
notificationAttempted: true,
notificationSent: false,
impactEntry: undefined,
};
}
}
async function inspectPackageItem(
item: ClaimItem,
inspectorVersion: string,
preparedTarget: { exactVersion: string; target: Record<string, unknown> },
inspectorModule: PluginInspectorModule,
) {
const workRoot = path.join(
tmpdir(),
`clawhub-plugin-inspector-bulk-scan-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const pluginRoot = path.join(workRoot, "plugin");
const reportDir = path.resolve(
artifactRoot,
safeArtifactName(`${item.packageName}-${item.version}`),
);
await mkdir(pluginRoot, { recursive: true });
await mkdir(reportDir, { recursive: true });
try {
const artifactKind = await downloadPackageArtifactForScan(item, workRoot, pluginRoot);
const scanRoot = await prepareExtractedPluginRoot(pluginRoot, artifactKind, item.packageName);
if (!inspectorModule.pluginRoot || !inspectorModule.ci || !inspectorModule.reports) {
throw new Error("The bundled Plugin Inspector bulk APIs are unavailable");
}
const scan = await inspectorModule.pluginRoot.runCheck({
allowExecution: false,
authorFacing: true,
capture: false,
configPath: resolveInspectorConfigPath(scanRoot),
mockSdk: true,
outDir: reportDir,
pluginRoot: scanRoot,
targetOpenClaw: preparedTarget.target,
});
const report = scan.report;
await writeFile(scan.paths.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
await inspectorModule.ci.writeOutputs(report, {
cwd: path.dirname(scan.paths.jsonPath),
outDir: ".",
});
await writeFile(
path.join(reportDir, "stdout.txt"),
`${JSON.stringify(inspectorModule.reports.sanitizeArtifact(report), null, 2)}\n`,
);
await writeFile(path.join(reportDir, "stderr.txt"), "");
const findings = normalizeFindings(report);
const targetOpenClawVersion = extractTargetOpenClawVersion(report.targetOpenClaw);
if (targetOpenClawVersion !== preparedTarget.exactVersion) {
throw new Error(
`Plugin Inspector reported OpenClaw ${targetOpenClawVersion ?? "unknown"}; expected ${preparedTarget.exactVersion}`,
);
}
if (!dryRun) {
const uploadResult = await postJson<UploadResult>(
`${siteUrl}/api/v1/package-inspector/results`,
{
packageId: item.packageId,
releaseId: item.releaseId,
inspectorVersion,
targetOpenClawVersion,
notifyOwners,
findings,
},
);
await writeFile(
path.join(reportDir, "upload-result.json"),
`${JSON.stringify({ findingCount: findings.length, ...uploadResult }, null, 2)}\n`,
);
console.log(
`Uploaded ${item.packageName}@${item.version}: findings=${findings.length}, inserted=${uploadResult.inserted}, shouldEmailOwner=${uploadResult.shouldEmailOwner}`,
);
}
return {
failed: false,
scanned: true,
notificationAttempted: false,
notificationSent: false,
impactEntry: toImpactEntry(item, findings, targetOpenClawVersion),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await writeFile(path.join(reportDir, "error.txt"), message);
console.error(`Plugin Inspector bulk scan failed for ${item.packageName}@${item.version}`);
console.error(message);
return {
failed: true,
scanned: false,
notificationAttempted: false,
notificationSent: false,
impactEntry: undefined,
};
} finally {
await rm(workRoot, { recursive: true, force: true });
}
}
export async function downloadPackageArtifactForScan(
item: ClaimItem,
workRoot: string,
pluginRoot: string,
) {
const artifact = await fetch(item.downloadUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!artifact.ok) {
const detail = await artifact.text();
if (artifact.status === 500 && item.artifactKind === "legacy-zip") {
await downloadLegacyPackageFiles(item, pluginRoot);
console.warn(
`Protected legacy archive failed for ${item.packageName}@${item.version}; reconstructed verified files instead.`,
);
return "legacy-zip" as const;
}
throw new Error(`download failed ${artifact.status}: ${detail}`);
}
const artifactKind = resolveArtifactKind(item.artifactKind, artifact.headers);
const artifactPath = path.join(
workRoot,
artifactKind === "npm-pack" ? "plugin.tgz" : "plugin.zip",
);
await writeFile(artifactPath, Buffer.from(await artifact.arrayBuffer()));
if (artifactKind === "npm-pack") {
run("tar", ["-xzf", artifactPath, "-C", pluginRoot, "--strip-components=1"]);
} else {
run("unzip", ["-q", artifactPath, "-d", pluginRoot]);
}
return artifactKind;
}
async function downloadLegacyPackageFiles(item: ClaimItem, pluginRoot: string) {
const versionUrl = new URL(
`/api/v1/packages/${encodeURIComponent(item.packageName)}/versions/${encodeURIComponent(item.version)}`,
siteUrl,
);
const detail = await fetch(versionUrl);
if (!detail.ok) {
throw new Error(`legacy package lookup failed ${detail.status}: ${await detail.text()}`);
}
const payload = (await detail.json()) as unknown;
if (!isPlainObject(payload) || !isPlainObject(payload.version)) {
throw new Error("legacy package lookup returned no version");
}
const files = payload.version.files;
if (!Array.isArray(files) || files.length === 0) {
throw new Error("legacy package lookup returned no files");
}
const packageRoot = path.join(pluginRoot, "package");
for (const value of files) {
if (!isPlainObject(value)) throw new Error("legacy package lookup returned an invalid file");
const filePath = stringValue(value.path);
const expectedSha256 = stringValue(value.sha256);
const expectedSize = typeof value.size === "number" ? value.size : undefined;
if (!filePath || !expectedSha256 || expectedSize === undefined) {
throw new Error("legacy package lookup returned incomplete file metadata");
}
const destination = resolveLegacyScanFilePath(packageRoot, filePath);
const fileUrl = new URL(
`/api/v1/packages/${encodeURIComponent(item.packageName)}/file`,
siteUrl,
);
fileUrl.searchParams.set("path", filePath);
fileUrl.searchParams.set("version", item.version);
const response = await fetchLegacyPackageFile(fileUrl, filePath);
if (!response.ok) {
throw new Error(
`legacy package file download failed for ${filePath} ${response.status}: ${await response.text()}`,
);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.byteLength !== expectedSize) {
throw new Error(
`legacy package file size mismatch for ${filePath}: expected ${expectedSize}, got ${bytes.byteLength}`,
);
}
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
if (actualSha256 !== expectedSha256) {
throw new Error(`legacy package file checksum mismatch for ${filePath}`);
}
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, bytes);
}
}
async function fetchLegacyPackageFile(fileUrl: URL, filePath: string) {
for (let attempt = 1; attempt <= legacyFileDownloadAttempts; attempt += 1) {
const response = await fetch(fileUrl);
const retryAfter = response.headers.get("Retry-After");
if (response.ok || response.status !== 503 || retryAfter === null) return response;
if (attempt === legacyFileDownloadAttempts) return response;
const delayMs = parseRetryAfterDelayMs(retryAfter);
if (delayMs === undefined) return response;
await response.body?.cancel();
console.warn(
`Legacy package file download temporarily unavailable for ${filePath}; retrying in ${delayMs}ms (attempt ${attempt + 1}/${legacyFileDownloadAttempts}).`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error(`legacy package file retry exhausted unexpectedly for ${filePath}`);
}
function parseRetryAfterDelayMs(value: string) {
const seconds = Number(value);
if (Number.isSafeInteger(seconds) && seconds >= 0) return Math.min(seconds * 1000, 5000);
const retryAt = Date.parse(value);
if (!Number.isFinite(retryAt)) return undefined;
return Math.min(Math.max(0, retryAt - Date.now()), 5000);
}
function resolveLegacyScanFilePath(packageRoot: string, filePath: string) {
if (
filePath.length > 500 ||
filePath !== filePath.trim() ||
filePath.startsWith("/") ||
filePath.includes("\\") ||
filePath.includes("\0")
) {
throw new Error(`legacy package contains unsafe file path: ${filePath}`);
}
const segments = filePath.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`legacy package contains unsafe file path: ${filePath}`);
}
return path.join(packageRoot, ...segments);
}
export function resolveArtifactKind(value: string | undefined, headers: Headers) {
if (value === "npm-pack" || value === "legacy-zip") return value;
const header = headers.get("X-ClawHub-Artifact-Type")?.trim();
if (header === "npm-pack-tarball") return "npm-pack";
if (header === "legacy-plugin-zip") return "legacy-zip";
return "legacy-zip";
}
export async function prepareExtractedPluginRoot(
pluginRoot: string,
artifactKind: "npm-pack" | "legacy-zip",
packageName: string,
) {
const scanRoot =
artifactKind === "legacy-zip" && existsSync(path.join(pluginRoot, "package"))
? path.join(pluginRoot, "package")
: pluginRoot;
if (artifactKind === "legacy-zip") {
await removePosixArchiveMetadata(scanRoot);
}
await normalizePluginJsonManifests(scanRoot);
await writeSyntheticConfigIfNeeded(scanRoot, packageName);
return scanRoot;
}
if (import.meta.main) {
await runPackageInspectorNightlyScan();
}
async function claimBatch(
cursor: string | null,
inspectorVersion: string,
targetOpenClawVersion: string,
runId: string,
) {
const url = new URL(`${siteUrl}/api/v1/package-inspector/claim`);
url.searchParams.set("batchSize", batchSize);
url.searchParams.set("dryRun", dryRun ? "true" : "false");
url.searchParams.set("inspectorVersion", inspectorVersion);
url.searchParams.set("targetOpenClawVersion", targetOpenClawVersion);
url.searchParams.set("notifyOwners", notifyOwners ? "true" : "false");
if (!dryRun) url.searchParams.set("runId", runId);
if (cursor) url.searchParams.set("cursor", cursor);
return await postJson<ClaimResponse>(url.toString(), {});
}
export async function acknowledgeBatch(cursor: string | null, runId: string) {
const url = new URL(`${siteUrl}/api/v1/package-inspector/acknowledge`);
url.searchParams.set("runId", runId);
if (cursor) url.searchParams.set("cursor", cursor);
return await postJson<{ ok: true; cursor: string | null; completed: boolean }>(
url.toString(),
{},
);
}
function resolveInspectorConfigPath(root: string) {
for (const filename of ["plugin-inspector.config.json", ".plugin-inspector.json"]) {
const candidate = path.join(root, filename);
if (existsSync(candidate)) return candidate;
}
return undefined;
}
async function resolveTargetPackageItems(packageNames: string[]): Promise<ClaimItem[]> {
const items: ClaimItem[] = [];
for (const packageName of packageNames) {
const detail = await fetch(`${siteUrl}/api/v1/packages/${encodeURIComponent(packageName)}`);
if (!detail.ok) {
throw new Error(
`package lookup failed for ${packageName} ${detail.status}: ${await detail.text()}`,
);
}
const payload = (await detail.json()) as { package?: unknown };
const pkg = payload.package;
if (!isPlainObject(pkg))
throw new Error(`package lookup returned no package for ${packageName}`);
const packageRecord = pkg as Record<string, unknown>;
const packageId = stringValue(packageRecord._id);
const releaseId = stringValue(packageRecord.latestReleaseId);
const version = stringValue(packageRecord.latestVersion);
const family = stringValue(packageRecord.family);
const channel = stringValue(packageRecord.channel);
if (!packageId || !releaseId || !version) {
throw new Error(
`package lookup returned incomplete latest release metadata for ${packageName}`,
);
}
if (family !== "code-plugin" && family !== "bundle-plugin") {
throw new Error(`${packageName} is not a plugin package`);
}
if (channel === "private") {
throw new Error(`${packageName} is private and cannot be scanned by this workflow`);
}
items.push({
packageId,
releaseId,
packageName: stringValue(packageRecord.name) ?? packageName,
version,
downloadUrl: `${siteUrl}/api/v1/package-inspector/artifact?releaseId=${encodeURIComponent(releaseId)}`,
});
}
return items;
}
async function postJson<T = unknown>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`POST ${url} failed ${response.status}: ${await response.text()}`);
}
return (await response.json()) as T;
}
async function writeSyntheticConfigIfNeeded(root: string, packageName: string) {
if (
existsSync(path.join(root, "plugin-inspector.config.json")) ||
existsSync(path.join(root, ".plugin-inspector.json"))
) {
return;
}
const packageJson = await readJsonIfExists(path.join(root, "package.json"));
if (hasInspectorConfig(packageJson)) {
return;
}
await writeFile(
path.join(root, ".plugin-inspector.json"),
`${JSON.stringify({ version: 1, plugin: { id: safeFixtureId(packageName) } }, null, 2)}\n`,
);
}
async function normalizePluginJsonManifests(root: string): Promise<void> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
await normalizePluginJsonManifests(entryPath);
continue;
}
if (
entry.isFile() &&
(entry.name === "package.json" || entry.name === "openclaw.plugin.json")
) {
await readJsonIfExists(entryPath);
}
}
}
async function readJsonIfExists(filePath: string) {
if (!existsSync(filePath)) return null;
const contents = await readFile(filePath, "utf8");
const normalizedContents = contents.startsWith("\uFEFF") ? contents.slice(1) : contents;
const parsed = JSON.parse(normalizedContents) as unknown;
if (normalizedContents !== contents) {
await writeFile(filePath, normalizedContents);
}
return parsed;
}
async function removePosixArchiveMetadata(root: string): Promise<void> {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
await removePosixArchiveMetadata(entryPath);
if (entry.name === "PaxHeader" && (await readdir(entryPath)).length === 0) {
await rmdir(entryPath);
}
continue;
}
if (entry.isFile() && path.basename(root) === "PaxHeader") {
const { size } = await stat(entryPath);
if (size <= 64 * 1024 && isPosixExtendedHeader(await readFile(entryPath))) {
await rm(entryPath);
}
}
}
}
function isPosixExtendedHeader(contents: Uint8Array) {
let offset = 0;
let records = 0;
while (offset < contents.length) {
let space = offset;
while (space < contents.length && contents[space] >= 48 && contents[space] <= 57) space += 1;
if (space === offset || contents[space] !== 32) return false;
const recordLength = Number.parseInt(
new TextDecoder().decode(contents.subarray(offset, space)),
10,
);
const recordEnd = offset + recordLength;
if (!Number.isSafeInteger(recordLength) || recordEnd > contents.length) return false;
if (contents[recordEnd - 1] !== 10) return false;
const payload = contents.subarray(space + 1, recordEnd - 1);
const equals = payload.indexOf(61);
if (equals <= 0) return false;
offset = recordEnd;
records += 1;
}
return records > 0;
}
function hasInspectorConfig(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return isPlainObject(record.pluginInspector) || isPlainObject(record["plugin-inspector"]);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function normalizeFindings(report: Record<string, unknown>): NormalizedFinding[] {
const issues = Array.isArray(report.issues)
? report.issues
.map((issue) => normalizeFinding(issue, "warning"))
.filter(isFinding)
.filter(isAuthorFacingFinding)
: [];
if (issues.length > 0) return issues;
return [
...normalizeFindingArray(report.breakages, "breakage"),
...normalizeFindingArray(report.warnings, "warning"),
...normalizeFindingArray(report.suggestions, "warning"),
].filter(isAuthorFacingFinding);
}
function normalizeFindingArray(value: unknown, fallbackLevel: string) {
return Array.isArray(value)
? value.map((finding) => normalizeFinding(finding, fallbackLevel)).filter(isFinding)
: [];
}
function normalizeFinding(value: unknown, fallbackLevel: string): NormalizedFinding | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const message = stringValue(record.message) ?? stringValue(record.title);
const code = stringValue(record.code) ?? "plugin-inspector-finding";
if (!message) return null;
const level =
stringValue(record.level) ??
(record.status === "blocking" || fallbackLevel === "breakage" ? "breakage" : "warning");
return {
id: stringValue(record.id),
code,
level,
severity: stringValue(record.severity),
issueClass: stringValue(record.issueClass),
compatStatus: stringValue(record.compatStatus),
deprecated: typeof record.deprecated === "boolean" ? record.deprecated : undefined,
message,
evidence: Array.isArray(record.evidence) ? record.evidence.map(String).slice(0, 12) : undefined,
fixture: stringValue(record.fixture),
decision: stringValue(record.decision),
authorRemediation: normalizeAuthorRemediation(record.authorRemediation),
};
}
function normalizeAuthorRemediation(value: unknown) {
if (!isPlainObject(value)) return undefined;
const record = value as Record<string, unknown>;
const summary = stringValue(record.summary);
if (!summary) return undefined;
const docsUrl = stringValue(record.docsUrl);
return docsUrl ? { summary, docsUrl } : { summary };
}
function isFinding(value: NormalizedFinding | null): value is NormalizedFinding {
return value !== null;
}
function isAuthorFacingFinding(value: NormalizedFinding) {
return value.issueClass !== "inspector-gap";
}
function toImpactEntry(
item: ClaimItem,
findings: NormalizedFinding[],
targetOpenClawVersion: string | undefined,
): ImpactEntry {
let errorCount = 0;
let warningCount = 0;
for (const finding of findings) {
if (isErrorFinding(finding)) errorCount += 1;
else warningCount += 1;
}
return {
packageId: item.packageId,
releaseId: item.releaseId,
packageName: item.packageName,
version: item.version,
ownerUserId: item.ownerUserId,
ownerPublisherId: item.ownerPublisherId,
findingCount: findings.length,
errorCount,
warningCount,
targetOpenClawVersion,
findings,
};
}
function isErrorFinding(finding: Pick<NormalizedFinding, "level" | "severity">) {
return finding.level === "breakage" || finding.level === "error" || finding.severity === "P0";
}
export function summarizeImpact(args: {
claimed: number;
scanned: number;
skippedUnchanged?: number;
batches: number;
truncated: boolean;
nextCursor: string | null;
inspectorVersion: string;
targetOpenClawVersion?: string;
notifyOwners?: boolean;
notificationOnly?: boolean;
notificationAttempts?: number;
notificationsSent?: number;
scanStartedAt?: string;
scanDurationMs?: number;
entries: ImpactEntry[];
}) {
const impactedOwners = new Set<string>();
const frequency = new Map<
string,
{ code: string; count: number; errorCount: number; warningCount: number }
>();
let pluginsWithErrors = 0;
let pluginsWithWarnings = 0;
let totalErrors = 0;
let totalWarnings = 0;
for (const entry of args.entries) {
if (entry.findingCount > 0 && entry.ownerUserId) impactedOwners.add(entry.ownerUserId);
if (entry.errorCount > 0) pluginsWithErrors += 1;
if (entry.warningCount > 0) pluginsWithWarnings += 1;
totalErrors += entry.errorCount;
totalWarnings += entry.warningCount;
for (const finding of entry.findings) {
const current = frequency.get(finding.code) ?? {
code: finding.code,
count: 0,
errorCount: 0,
warningCount: 0,
};
current.count += 1;
if (isErrorFinding(finding)) current.errorCount += 1;
else current.warningCount += 1;
frequency.set(finding.code, current);
}
}
return {
dryRun,
generatedAt: new Date().toISOString(),
siteUrl,
inspectorVersion: args.inspectorVersion,
targetOpenClawVersion: args.targetOpenClawVersion,
notifyOwners: args.notifyOwners ?? false,
notificationOnly: args.notificationOnly ?? false,
notificationAttempts: args.notificationAttempts ?? 0,
notificationsSent: args.notificationsSent ?? 0,
scanStartedAt: args.scanStartedAt ?? new Date().toISOString(),
scanDurationMs: args.scanDurationMs ?? 0,
scanThroughputReleasesPerSecond:
args.scanDurationMs && args.scanDurationMs > 0
? args.scanned / (args.scanDurationMs / 1000)
: 0,
batchSize: Number.parseInt(batchSize, 10) || batchSize,
batches: args.batches,
truncated: args.truncated,
nextCursor: args.nextCursor,
claimedReleases: args.claimed,
scannedReleases: args.scanned,
skippedUnchangedReleases: args.skippedUnchanged ?? 0,
pluginsWithFindings: args.entries.filter((entry) => entry.findingCount > 0).length,
pluginsWithErrors,
pluginsWithWarnings,
impactedOwners: impactedOwners.size,
totalErrors,
totalWarnings,
findingFrequency: [...frequency.values()].sort((a, b) => b.count - a.count),
packages: args.entries.filter((entry) => entry.findingCount > 0),
};
}
function getInspectorVersion() {
return process.env.PLUGIN_INSPECTOR_VERSION ?? resolveBundledPluginInspectorVersion();
}
export function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>) {
const lines = [
"# Plugin Inspector Bulk Scan Run",
"",
`- Generated: ${summary.generatedAt}`,
`- Site: ${summary.siteUrl}`,
`- Inspector: ${summary.inspectorVersion}`,
`- Target OpenClaw: ${summary.targetOpenClawVersion ?? "unknown"}`,
`- Mode: ${summary.notificationOnly ? "notification-only" : "scan"}`,
`- Scanned latest releases: ${summary.scannedReleases}`,
`- Notification attempts: ${summary.notificationAttempts}`,
`- Notifications sent: ${summary.notificationsSent}`,
`- Scan wall time: ${(summary.scanDurationMs / 1000).toFixed(3)} seconds`,
`- Scan throughput: ${summary.scanThroughputReleasesPerSecond.toFixed(3)} releases/second`,
`- Skipped unchanged releases: ${summary.skippedUnchangedReleases}`,
`- Plugins with errors: ${summary.pluginsWithErrors}`,
`- Plugins with warnings: ${summary.pluginsWithWarnings}`,
`- Impacted owners: ${summary.impactedOwners}`,
`- Truncated: ${summary.truncated ? "yes" : "no"}`,
"",
"## Finding Frequency",
"",
];
if (summary.findingFrequency.length === 0) {
lines.push("No findings.");
} else {
lines.push("| Code | Count | Errors | Warnings |", "| --- | ---: | ---: | ---: |");
for (const finding of summary.findingFrequency) {
lines.push(
`| ${finding.code} | ${finding.count} | ${finding.errorCount} | ${finding.warningCount} |`,
);
}
}
lines.push("", "## Impacted Plugins", "");
if (summary.packages.length === 0) {
lines.push("No impacted plugins.");
} else {
lines.push(
"| Plugin | Version | Errors | Warnings | Target OpenClaw |",
"| --- | --- | ---: | ---: | --- |",
);
for (const entry of summary.packages) {
lines.push(
`| ${entry.packageName} | ${entry.version} | ${entry.errorCount} | ${entry.warningCount} | ${entry.targetOpenClawVersion ?? ""} |`,
);
}
}
return `${lines.join("\n")}\n`;
}
function extractTargetOpenClawVersion(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
return (
stringValue(record.version) ??
stringValue(record.openclawVersion) ??
stringValue(record.label) ??
stringValue(record.status)
);
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function safeArtifactName(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "plugin"
);
}
function safeFixtureId(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "") || "plugin"
);
}
function parseBoolean(value: string | undefined) {
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
}
export function parsePackageNames(value: string | undefined) {
return [
...new Set(
(value ?? "")
.split(/[\s,]+/)
.map((name) => name.trim())
.filter(Boolean),
),
];
}
function run(command: string, args: string[]) {
const result = spawnSync(command, args, { stdio: "pipe", encoding: "utf8" });
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
}
}
function resolveBundledPluginInspectorVersion() {
const require = createRequire(import.meta.url);
const entry = require.resolve("@openclaw/plugin-inspector");
const packageJsonPath = path.resolve(path.dirname(entry), "..", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version?: unknown;
};
if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
throw new Error("Unable to resolve bundled @openclaw/plugin-inspector version");
}
return packageJson.version.trim();
}