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
This commit is contained in:
Patrick Erichsen
2026-07-31 17:37:52 -07:00
committed by GitHub
parent d76c965480
commit a16ff751bb
16 changed files with 687 additions and 394 deletions
@@ -19,10 +19,19 @@ on:
required: false
default: "20"
notify_owners:
description: "Email plugin owners when a manual scan finds issues"
description: "Email plugin owners from an explicitly selected reviewed scan"
required: false
default: true
default: false
type: boolean
notification_only:
description: "Notify from the exact stored scan result without inspecting packages again"
required: false
default: false
type: boolean
notification_source_run_id:
description: "Successful no-email scan run whose exact artifact is approved for notification"
required: false
default: ""
package_names:
description: "Optional comma or newline separated package names to scan instead of the rolling cursor"
required: false
@@ -37,6 +46,7 @@ on:
default: ""
permissions:
actions: read
contents: read
# Side-effecting scans queue behind the active run instead of overlapping.
@@ -60,6 +70,45 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Verify notification source run
if: ${{ inputs.notification_only }}
env:
GH_TOKEN: ${{ github.token }}
SOURCE_RUN_ID: ${{ inputs.notification_source_run_id }}
run: |
set -euo pipefail
test -n "$SOURCE_RUN_ID" || { echo "notification_source_run_id is required" >&2; exit 1; }
RUN_JSON="$(gh run view "$SOURCE_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node --input-type=module -e '
const chunks = [];
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => {
const run = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const checks = [
["workflowName", "Plugin Inspector Bulk Scan"],
["headBranch", "main"],
["event", "workflow_dispatch"],
["conclusion", "success"],
];
for (const [key, expected] of checks) {
if (run[key] !== expected) {
console.error(`Notification source run must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
process.exit(1);
}
}
console.log(`Using reviewed no-email scan: ${run.url}`);
});'
- name: Download reviewed scan artifact
if: ${{ inputs.notification_only }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: plugin-inspector-bulk-scan-reports
path: notification-source
repository: ${{ github.repository }}
run-id: ${{ inputs.notification_source_run_id }}
github-token: ${{ github.token }}
- name: Run plugin inspector bulk scan
if: ${{ github.ref == 'refs/heads/main' }}
env:
@@ -71,6 +120,8 @@ jobs:
PLUGIN_INSPECTOR_PACKAGE_NAMES: ${{ inputs.package_names || '' }}
PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta
PLUGIN_INSPECTOR_NOTIFY_OWNERS: ${{ github.event_name == 'schedule' && '0' || (inputs.notify_owners && '1' || '0') }}
PLUGIN_INSPECTOR_NOTIFICATION_ONLY: ${{ github.event_name == 'schedule' && '0' || (inputs.notification_only && '1' || '0') }}
PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST: ${{ inputs.notification_only && 'notification-source/run-summary.json' || '' }}
PLUGIN_INSPECTOR_SOURCE_PR: ${{ inputs.source_pr || '' }}
PLUGIN_INSPECTOR_SOURCE_SHA: ${{ inputs.source_sha || '' }}
PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-bulk-scan-reports
+3 -5
View File
@@ -15,7 +15,7 @@
"@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058",
"@openclaw/plugin-inspector": "0.3.20",
"@radix-ui/react-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-dropdown-menu": "2.1.21",
@@ -108,7 +108,7 @@
},
"dependencies": {
"@clack/prompts": "1.7.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058",
"@openclaw/plugin-inspector": "0.3.20",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
@@ -454,7 +454,7 @@
"@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@github:openclaw/plugin-inspector#c8c755c", { "dependencies": { "semver": "^7.8.5", "tar": "^7.5.22" }, "bin": { "plugin-inspector": "src/cli.js" } }, "openclaw-plugin-inspector-c8c755c", "sha512-yneM5thup+OSzUsklI5J7qex3JbL4943uLJPwc5Y7S3lQeHykZYnWyfhctoRt6i8DDrrR583SLI2xYsxt1gmRg=="],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.20", "", { "dependencies": { "semver": "^7.8.5", "tar": "^7.5.22" }, "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-Tyudswj2I/0BCCK9cQ2x8znzjOEuZZhT00hTnGaPiinYHvbPwiZIW7s8EMJKnGW53g7e1UGccCq+Fq2OCfKMsA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2092,8 +2092,6 @@
"babel-dead-code-elimination/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
"clawhub/@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@github:openclaw/plugin-inspector#c8c755c", { "dependencies": { "semver": "^7.8.5", "tar": "^7.5.22" }, "bin": { "plugin-inspector": "src/cli.js" } }, "openclaw-plugin-inspector-c8c755c", "sha512-yneM5thup+OSzUsklI5J7qex3JbL4943uLJPwc5Y7S3lQeHykZYnWyfhctoRt6i8DDrrR583SLI2xYsxt1gmRg=="],
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
+7
View File
@@ -72,6 +72,7 @@ import {
packageInspectorAcknowledgeHttp,
packageInspectorArtifactHttp,
packageInspectorClaimHttp,
packageInspectorNotifyHttp,
packageInspectorResultsHttp,
} from "./packageInspectorHttp";
import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp";
@@ -303,6 +304,12 @@ http.route({
handler: packageInspectorResultsHttp,
});
http.route({
path: "/api/v1/package-inspector/notify",
method: "POST",
handler: packageInspectorNotifyHttp,
});
http.route({
pathPrefix: `${ApiRoutes.packages}/`,
method: "POST",
+28 -168
View File
@@ -5,6 +5,7 @@ import {
buildAdminOneOffEmail,
buildBanNotificationEmail,
buildMaliciousArtifactEmail,
buildPackageInspectorValidationUrl,
buildPackageInspectorFindingsEmail,
buildPublisherAbuseWarningEmail,
buildRestoredAccountEmail,
@@ -276,181 +277,40 @@ describe("moderation notification email copy", () => {
expect(email.html).not.toContain("appeal this decision");
});
it("builds plugin inspector warning copy with local validation guidance", async () => {
it("builds the approved hard-error plugin compatibility email", async () => {
const validationUrl = buildPackageInspectorValidationUrl("@openclaw/demo-plugin");
const email = await buildPackageInspectorFindingsEmail({
handle: "octocat",
packageName: "demo-plugin",
packageName: "@openclaw/demo-plugin",
version: "1.0.0",
findings: [
{
findingKind: "warning",
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
severity: "P2",
message: "legacy before_agent_start hook is deprecated",
inspectorVersion: "0.4.0",
targetOpenClawVersion: "0.9.0",
scanSource: "publish",
authorRemediation: {
summary: "Replace the legacy before_agent_start hook with current prompt hooks.",
docsUrl:
"https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
},
},
],
validationUrl,
});
expect(email.subject).toBe("Plugin Inspector findings for demo-plugin@1.0.0");
expect(email.text).toContain("Hi octocat,");
expect(email.text).toContain("We found 1 issue with version 1.0.0 of demo-plugin.");
expect(email.text).toContain("OpenClaw Version: 0.9.0");
expect(email.text).toContain("Address the findings below in your plugin package.");
expect(email.text).toContain("Run the validation command locally against your changes.");
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
expect(validationUrl).toBe("https://clawhub.ai/openclaw/plugins/demo-plugin#validation");
expect(email.subject).toBe(
"Update required: @openclaw/demo-plugin will break in an upcoming OpenClaw release",
);
expect(email.text).toContain(
"- **WARNING** `legacy-before-agent-start` (deprecation-warning, P2)",
expect(email.text).toBe(
[
"Hi octocat,",
"",
"ClawHub validated @openclaw/demo-plugin@1.0.0 against the upcoming OpenClaw release.",
"",
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
"",
`Review the validation errors: ${validationUrl}`,
"",
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
"",
"Please update the plugin and publish a new version before the next OpenClaw release.",
"",
"—ClawHub",
].join("\n"),
);
expect(email.text).toContain(" legacy before_agent_start hook is deprecated");
expect(email.text).toContain(" Fix:");
expect(email.text).toContain(
" Replace the legacy before_agent_start hook with current prompt hooks.",
);
expect(email.text).toContain(" Docs:");
expect(email.text).toContain(
" https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
);
expect(email.text).not.toContain("ClawHub Security");
expect(email.html).toContain("Validate a local fix");
expect(email.html).toContain("Plugin Review");
expect(email.html).not.toContain("Open ClawHub");
expect(email.html).not.toContain('href="https://clawhub.ai" style="display:inline-block');
expect(email.html).not.toContain("You&#39;re receiving this because");
expect(email.html).not.toContain("You're receiving this because");
expect(email.html).toContain("https://docs.openclaw.ai");
expectFooterLinksUnderlined(email.html);
expect(email.html).toContain("OpenClaw Version");
expect(email.html).toContain("0.9.0");
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.9.0",
);
expect(email.html).toContain("legacy-before-agent-start");
expect(email.html).toContain("legacy-before-agent-start · deprecation-warning · P2");
expect(email.html).toContain("Fix");
expect(email.html).toContain("Replace the legacy before_agent_start hook");
expect(email.html).toContain("Docs →");
expect(email.html).toContain("plugin-validation-fixes#legacy-before-agent-start");
expect(email.html).not.toContain("plugin validation fix docs");
expect(email.html).not.toContain("ClawHub Security");
expect(email.text).not.toContain("Plugin Inspector: 0.4.0");
expect(email.text).not.toContain("Target OpenClaw:");
expect(email.html).not.toContain("<strong>Plugin Inspector:</strong>");
expect(email.html).not.toContain("<strong>Target OpenClaw:</strong>");
expect(email.html).not.toContain("Review:");
expect(email.html).not.toContain("plugin validation findings");
expect(email.html).not.toContain("https://clawhub.ai/plugins/demo-plugin#validation");
expect(email.html).not.toContain("Your plugin was published");
expect(email.html).not.toContain("published successfully");
});
it("includes one exact validation command per recorded OpenClaw target", async () => {
const email = await buildPackageInspectorFindingsEmail({
packageName: "demo-plugin",
version: "1.0.0",
findings: [
{
findingKind: "warning",
code: "legacy-before-agent-start",
message: "legacy hook is deprecated",
targetOpenClawVersion: "0.9.0",
},
{
findingKind: "error",
code: "missing-expected-seam",
message: "registerTool is no longer available",
targetOpenClawVersion: "0.10.0",
},
],
});
expect(email.text).toContain("OpenClaw Versions: 0.9.0, 0.10.0");
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
);
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
);
expect(email.text).toContain(
"`legacy-before-agent-start`\n legacy hook is deprecated\n OpenClaw target: 0.9.0",
);
expect(email.text).toContain(
"`missing-expected-seam`\n registerTool is no longer available\n OpenClaw target: 0.10.0",
);
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.9.0",
);
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.10.0",
);
expect(email.html).toContain("legacy-before-agent-start · OpenClaw 0.9.0");
expect(email.html).toContain("missing-expected-seam · OpenClaw 0.10.0");
});
it("builds plugin inspector error copy without publish-time wording", async () => {
const email = await buildPackageInspectorFindingsEmail({
packageName: "demo-plugin",
version: "1.0.1",
findings: [
{
findingKind: "error",
code: "missing-expected-seam",
issueClass: "compatibility-error",
severity: "P0",
level: "breakage",
message: "registerTool is no longer available",
inspectorVersion: "0.5.0",
targetOpenClawVersion: "0.10.0",
scanSource: "nightly",
},
],
});
expect(email.text).toContain("We found 1 issue with version 1.0.1 of demo-plugin.");
expect(email.text).toContain("Address the findings below in your plugin package.");
expect(email.text).toContain("Run the validation command locally against your changes.");
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
);
expect(email.text).toContain("- **ERROR** `missing-expected-seam` (compatibility-error, P0)");
expect(email.text).not.toContain("Your plugin was published");
expect(email.text).not.toContain("was published, but");
expect(email.text).not.toContain("Some findings are errors");
expect(email.text).not.toContain("nightly");
expect(email.html).toContain("missing-expected-seam");
expect(email.html).toContain("compatibility-error · P0");
});
it("does not rewrite inserted package names, versions, or issue counts", async () => {
const findings = Array.from({ length: 11 }, (_, index) => ({
findingKind: "warning" as const,
code: `finding-${index + 1}`,
issueClass: "compatibility-warning",
severity: "P2",
message: "review finding",
}));
const email = await buildPackageInspectorFindingsEmail({
packageName: "my-demo-plugin",
version: "1.0.0-beta",
findings,
});
expect(email.text).toContain("We found 11 issues with version 1.0.0-beta of my-demo-plugin.");
expect(email.html).toContain("11 issues found");
expect(email.html).toContain("my-demo-plugin@1.0.0-beta");
expect(email.html).not.toContain("my-my-demo-plugin");
expect(email.html).not.toContain("1.0.0-beta-beta");
expect(email.html).not.toContain("11 issueses");
expect(email.html).toContain(`href="${validationUrl}"`);
expect(email.html).toContain("Review the validation errors");
expect(email.html).not.toContain("legacy-before-agent-start");
expect(email.html).not.toContain("Email preferences");
});
it("builds publisher abuse warning emails with a deadline and Discord maintainer escalation", async () => {
+24 -96
View File
@@ -94,27 +94,11 @@ export type SecretBlockedPublishEmailArgs = {
version?: string;
};
export type PackageInspectorEmailFinding = {
findingKind: "warning" | "error";
code: string;
issueClass?: string;
level?: string;
severity?: string;
message: string;
authorRemediation?: {
summary: string;
docsUrl?: string;
};
inspectorVersion?: string;
targetOpenClawVersion?: string;
scanSource?: "publish" | "nightly";
};
export type PackageInspectorFindingsEmailArgs = {
handle?: string;
packageName: string;
version: string;
findings: PackageInspectorEmailFinding[];
validationUrl: string;
};
export type PublisherAbuseWarningScore = {
@@ -324,11 +308,6 @@ async function renderSecretBlockedPublishTemplate(args: {
return rendered.html;
}
function buildPluginValidateCommand(openClawVersion?: string) {
const command = "clawhub package validate <path-to-plugin>";
return openClawVersion ? `${command} --openclaw-version ${openClawVersion}` : command;
}
function normalizeEmailFindingSummary(value: string | undefined) {
const normalized = value?.replace(/\s+/g, " ").trim();
if (!normalized) return undefined;
@@ -535,67 +514,30 @@ export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishE
}
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
const targetOpenClawVersions = Array.from(
new Set(
args.findings
.map((finding) => finding.targetOpenClawVersion?.trim())
.filter((target): target is string => Boolean(target)),
),
);
const validateCommands = targetOpenClawVersions.length
? targetOpenClawVersions.map(buildPluginValidateCommand)
: [buildPluginValidateCommand()];
const subject = `Plugin Inspector findings for ${args.packageName}@${args.version}`;
const findingCount = args.findings.length;
const intro = `We found ${findingCount} ${findingCount === 1 ? "issue" : "issues"} with version ${args.version} of ${args.packageName}.`;
const nextSteps = [
"Address the findings below in your plugin package.",
"Run the validation command locally against your changes.",
"When validation passes, upload a new version.",
];
const findingLines = formatPackageInspectorFindingsText(args.findings);
const metadataLines = [
`Plugin: ${args.packageName}@${args.version}`,
targetOpenClawVersions.length
? `OpenClaw Version${targetOpenClawVersions.length === 1 ? "" : "s"}: ${targetOpenClawVersions.join(", ")}`
: null,
].filter((line): line is string => line !== null);
const subject = `Update required: ${args.packageName} will break in an upcoming OpenClaw release`;
const intro = `ClawHub validated ${args.packageName}@${args.version} against the upcoming OpenClaw release.`;
const lines = [
greeting(args.handle),
"",
intro,
"",
...metadataLines,
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
"",
"Next steps:",
...nextSteps.map((item) => `- ${item}`),
`Review the validation errors: ${args.validationUrl}`,
"",
"Findings:",
...findingLines,
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
"",
"Validate a local fix:",
...validateCommands,
"Please update the plugin and publish a new version before the next OpenClaw release.",
"",
"—ClawHub",
];
const { renderPluginInspectorFindingsEmail } = await import("./emailRendering");
const rendered = await renderPluginInspectorFindingsEmail({
owner: args.handle?.trim() || "there",
packageName: args.packageName,
version: args.version,
...(targetOpenClawVersions.length
? { openClawVersion: targetOpenClawVersions.join(", ") }
: {}),
findings: args.findings.map((finding) => ({
code: finding.code,
kind: finding.findingKind,
meta: [finding.code, finding.issueClass, finding.severity].filter(Boolean).join(" · "),
message: finding.message,
...(finding.targetOpenClawVersion
? { targetOpenClawVersion: finding.targetOpenClawVersion }
: {}),
...(finding.authorRemediation?.summary ? { fix: finding.authorRemediation.summary } : {}),
...(finding.authorRemediation?.docsUrl ? { docsUrl: finding.authorRemediation.docsUrl } : {}),
})),
validateCommands,
validationUrl: args.validationUrl,
preheader: intro,
});
@@ -606,6 +548,19 @@ export async function buildPackageInspectorFindingsEmail(args: PackageInspectorF
};
}
export function buildPackageInspectorValidationUrl(packageName: string) {
const normalized = packageName.trim();
if (normalized.startsWith("@")) {
const slashIndex = normalized.indexOf("/");
if (slashIndex > 1 && slashIndex < normalized.length - 1) {
const owner = normalized.slice(1, slashIndex);
const name = normalized.slice(slashIndex + 1);
return `https://clawhub.ai/${encodeURIComponent(owner)}/plugins/${encodeURIComponent(name)}#validation`;
}
}
return `https://clawhub.ai/plugins/${encodeURIComponent(normalized)}#validation`;
}
export async function buildPublisherAbuseWarningEmail(args: PublisherAbuseWarningEmailArgs) {
const publisherHandle = args.publisherHandle.trim().replace(/^@+/, "");
const publisherLabel = publisherHandle ? `@${publisherHandle}` : "your publisher";
@@ -678,30 +633,3 @@ export async function buildAdminOneOffEmail(args: AdminOneOffEmailArgs) {
html,
};
}
function formatPackageInspectorFindingsText(findings: PackageInspectorEmailFinding[]) {
if (findings.length === 0) return ["- No findings were included."];
return findings.flatMap((finding) => {
const lines = [
`- **${finding.findingKind.toUpperCase()}** \`${finding.code}\`${formatFindingMetaText(finding)}`,
` ${finding.message}`,
];
if (finding.targetOpenClawVersion) {
lines.push(` OpenClaw target: ${finding.targetOpenClawVersion}`);
}
if (finding.authorRemediation?.summary) {
lines.push(" Fix:");
lines.push(` ${finding.authorRemediation.summary}`);
if (finding.authorRemediation.docsUrl) {
lines.push(" Docs:");
lines.push(` ${finding.authorRemediation.docsUrl}`);
}
}
return lines;
});
}
function formatFindingMetaText(finding: PackageInspectorEmailFinding) {
const meta = [finding.issueClass, finding.severity].filter(Boolean).join(", ");
return meta ? ` (${meta})` : "";
}
+39
View File
@@ -7,6 +7,7 @@ import {
packageInspectorAcknowledgeHttp,
packageInspectorArtifactHttp,
packageInspectorClaimHttp,
packageInspectorNotifyHttp,
packageInspectorResultsHttp,
} from "./packageInspectorHttp";
@@ -18,6 +19,8 @@ const packageInspectorResultsHttpHandler = (packageInspectorResultsHttp as unkno
._handler;
const packageInspectorClaimHttpHandler = (packageInspectorClaimHttp as unknown as HttpHandler)
._handler;
const packageInspectorNotifyHttpHandler = (packageInspectorNotifyHttp as unknown as HttpHandler)
._handler;
const packageInspectorAcknowledgeHttpHandler = (
packageInspectorAcknowledgeHttp as unknown as HttpHandler
)._handler;
@@ -130,6 +133,42 @@ describe("package inspector HTTP helpers", () => {
});
});
it("completes warning-only notification rows without sending email", async () => {
vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token");
const runMutation = vi.fn().mockResolvedValue({ ok: true, marked: true });
const runAction = vi.fn().mockResolvedValue({
ok: true,
sent: false,
reason: "no-context",
});
const response = await packageInspectorNotifyHttpHandler(
{ runAction, runMutation },
new Request("https://example.com/api/v1/package-inspector/notify", {
method: "POST",
headers: {
Authorization: "Bearer worker-token",
"Content-Type": "application/json",
},
body: JSON.stringify({
packageId: "packages:demo",
releaseId: "packageReleases:demo-1",
inspectorVersion: "0.6.0",
targetOpenClawVersion: "2026.8.0-beta.1",
}),
}),
);
expect(response.status).toBe(200);
expect(runAction).toHaveBeenCalledOnce();
expect(runMutation).toHaveBeenCalledOnce();
expect(runMutation.mock.calls[0]?.[1]).toEqual({
packageId: "packages:demo",
releaseId: "packageReleases:demo-1",
inspectorVersion: "0.6.0",
targetOpenClawVersion: "2026.8.0-beta.1",
});
});
it("continues a persisted scan run from the caller cursor", async () => {
vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token");
const runMutation = vi.fn().mockResolvedValue({
+36
View File
@@ -13,6 +13,7 @@ const internalRefs = internal as unknown as {
getPackageInspectorArtifactInternal: unknown;
ingestPackageInspectorScanResultsInternal: unknown;
sendPackageInspectorFindingsEmailInternal: unknown;
markPackageInspectorNotificationCompletedInternal: unknown;
};
};
@@ -220,3 +221,38 @@ export const packageInspectorResultsHttp = httpAction(async (ctx, request) => {
}
return json(result);
});
export const packageInspectorNotifyHttp = httpAction(async (ctx, request) => {
const auth = requireWorkerToken(request);
if (!auth.ok) return auth.response;
const parsed = await parseJsonPayload(request, {});
if (!parsed.ok) return parsed.response;
const payload = parsed.payload;
if (
typeof payload.inspectorVersion !== "string" ||
!payload.inspectorVersion.trim() ||
typeof payload.targetOpenClawVersion !== "string" ||
!payload.targetOpenClawVersion.trim()
) {
return text("Missing exact scan identity", 400);
}
const args = {
packageId: payload.packageId,
releaseId: payload.releaseId,
inspectorVersion: payload.inspectorVersion,
targetOpenClawVersion: payload.targetOpenClawVersion,
};
const result = await runActionRef<{
ok: true;
sent: boolean;
reason?: "no-context";
}>(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, args);
if (!result.sent && result.reason === "no-context") {
await runMutationRef(
ctx,
internalRefs.packages.markPackageInspectorNotificationCompletedInternal,
args,
);
}
return json(result);
});
+81 -10
View File
@@ -11835,7 +11835,7 @@ describe("packages public queries", () => {
},
],
}),
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: true });
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: false });
expect(insert).toHaveBeenCalledTimes(1);
expect(insert).toHaveBeenCalledWith(
@@ -12002,7 +12002,7 @@ describe("packages public queries", () => {
);
});
it("does not notify for preserved publish findings after a clean nightly scan", async () => {
it("does not notify for a warning-only nightly result with a preserved publish error", async () => {
const ctx = {
db: {
get: vi.fn(),
@@ -12014,6 +12014,7 @@ describe("packages public queries", () => {
{
_id: "packageInspectorWarnings:publish",
scanSource: "publish",
findingKind: "error",
code: "manifest-name-missing",
message: "manifest name is missing",
authorRemediation: { summary: "Add a manifest name." },
@@ -12048,9 +12049,17 @@ describe("packages public queries", () => {
inspectorVersion: "0.6.0",
targetOpenClawVersion: "2026.8.0-beta.1",
notifyOwners: true,
findings: [],
findings: [
{
id: "demo:deprecated-api",
code: "deprecated-api",
level: "warning",
message: "The API is deprecated but still available.",
authorRemediation: { summary: "Migrate before a future release." },
},
],
}),
).resolves.toEqual({ ok: true, inserted: 0, shouldEmailOwner: false });
).resolves.toEqual({ ok: true, inserted: 1, shouldEmailOwner: false });
});
it("records a clean beta scan identity and completes a no-op notification", async () => {
@@ -12242,6 +12251,7 @@ describe("packages public queries", () => {
scanSource: "nightly",
inspectorVersion: "0.5.0",
targetOpenClawVersion: "0.10.0",
notifyOwners: true,
findings: [
{
id: "demo:runtime-tool-capture",
@@ -12283,7 +12293,7 @@ describe("packages public queries", () => {
);
});
it("retries package inspector finding emails when persisted findings were not notified", async () => {
it("stores an exact nightly hard error despite an identical preserved finding", async () => {
const insert = vi.fn();
const ctx = {
db: {
@@ -12292,6 +12302,7 @@ describe("packages public queries", () => {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([
{
findingKind: "error",
inspectorFindingId: "demo:legacy-before-agent-start",
code: "legacy-before-agent-start",
message: "legacy before_agent_start hook is deprecated",
@@ -12334,6 +12345,7 @@ describe("packages public queries", () => {
{
id: "demo:legacy-before-agent-start",
code: "legacy-before-agent-start",
level: "breakage",
message: "legacy before_agent_start hook is deprecated",
evidence: ["src/index.ts:4"],
fixture: "demo",
@@ -12345,9 +12357,17 @@ describe("packages public queries", () => {
},
],
}),
).resolves.toMatchObject({ ok: true, inserted: 0, shouldEmailOwner: true });
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: true });
expect(insert).not.toHaveBeenCalled();
expect(insert).toHaveBeenCalledWith(
"packageInspectorWarnings",
expect.objectContaining({
findingKind: "error",
scanSource: "nightly",
inspectorVersion: "0.5.0",
targetOpenClawVersion: "0.10.0",
}),
);
});
it("summarizes plugin inspector validation findings for signed-out viewers", async () => {
@@ -12614,7 +12634,7 @@ describe("packages public queries", () => {
);
});
it("excludes internal package inspector findings from owner emails", async () => {
it("excludes internal and warning-only findings from owner emails", async () => {
const ctx = {
db: {
get: vi.fn(async (id: string) => {
@@ -12668,6 +12688,19 @@ describe("packages public queries", () => {
},
createdAt: 1,
},
{
_id: "packageInspectorWarnings:error",
packageName: "demo-plugin",
version: "1.0.0",
findingKind: "error",
code: "missing-api",
issueClass: "compatibility-error",
message: "an API is unavailable in the upcoming OpenClaw release",
authorRemediation: {
summary: "Use the replacement API.",
},
createdAt: 0,
},
]),
})),
})),
@@ -12690,12 +12723,48 @@ describe("packages public queries", () => {
releaseId: "packageReleases:demo-1",
});
expect(result?.packageName).toBe("demo-plugin");
expect(result?.findings.map((finding) => finding.code)).toEqual(["legacy-before-agent-start"]);
expect(result?.findings.map((finding) => finding.code)).toEqual(["missing-api"]);
expect(result?.findings[0]?.authorRemediation).toMatchObject({
summary: "Move prompt mutation work to before_prompt_build.",
summary: "Use the replacement API.",
});
});
it("rejects a notification request whose release does not belong to the package", async () => {
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "packages:demo") {
return makePackageDoc({
_id: "packages:demo",
name: "demo-plugin",
ownerUserId: "users:owner",
});
}
if (id === "packageReleases:other") {
return makeReleaseDoc({
_id: "packageReleases:other",
packageId: "packages:other",
version: "1.0.0",
});
}
return null;
}),
query: vi.fn(() => {
throw new Error("Mismatched releases must be rejected before querying findings");
}),
},
};
await expect(
getPackageInspectorEmailContextInternalHandler(ctx as never, {
packageId: "packages:demo",
releaseId: "packageReleases:other",
inspectorVersion: "0.3.20",
targetOpenClawVersion: "2026.8.0-beta.1",
}),
).resolves.toBeNull();
});
it("builds email context for a new exact target despite an older release notification", async () => {
const ctx = {
db: {
@@ -12727,6 +12796,7 @@ describe("packages public queries", () => {
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([
{
findingKind: "error",
code: "missing-api",
scanSource: "nightly",
inspectorVersion: "0.6.0",
@@ -12794,6 +12864,7 @@ describe("packages public queries", () => {
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([
{
findingKind: "error",
code: "missing-api",
scanSource: "nightly",
inspectorVersion: "0.6.0",
+62 -16
View File
@@ -61,7 +61,10 @@ import {
clampActivityTrendEndDay,
getActivityTrendRangeForEndDay,
} from "./lib/downloadTrend";
import { buildPackageInspectorFindingsEmail } from "./lib/emails";
import {
buildPackageInspectorFindingsEmail,
buildPackageInspectorValidationUrl,
} from "./lib/emails";
import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./lib/experimentalClaws";
import { requireGitHubAccountAge } from "./lib/githubAccount";
import { normalizeGitHubRepository } from "./lib/githubActionsOidc";
@@ -9957,17 +9960,19 @@ async function insertPackageInspectorFindings(
return { ok: true as const, inserted: 0, shouldEmailOwner: false };
}
const existingWarningKeys = new Set(
existingAuthorWarnings.map((warning) =>
packageInspectorWarningDedupeKey({
id: warning.inspectorFindingId,
code: warning.code,
message: warning.message,
evidence: warning.evidence,
fixture: warning.fixture,
inspectorVersion: warning.inspectorVersion,
targetOpenClawVersion: warning.targetOpenClawVersion,
}),
),
replaceNightlyFindings
? []
: existingAuthorWarnings.map((warning) =>
packageInspectorWarningDedupeKey({
id: warning.inspectorFindingId,
code: warning.code,
message: warning.message,
evidence: warning.evidence,
fixture: warning.fixture,
inspectorVersion: warning.inspectorVersion,
targetOpenClawVersion: warning.targetOpenClawVersion,
}),
),
);
const shouldNotifyOwner = args.notifyOwners ?? args.scanSource !== "nightly";
const notificationCompleted = shouldNotifyOwner
@@ -9980,6 +9985,9 @@ async function insertPackageInspectorFindings(
: false;
const now = Date.now();
let inserted = 0;
let hasStoredHardError =
args.scanSource !== "nightly" &&
existingAuthorWarnings.some((warning) => warning.findingKind === "error");
for (const warning of findings.slice(0, 100)) {
const warningKey = packageInspectorWarningDedupeKey({
...warning,
@@ -9991,6 +9999,7 @@ async function insertPackageInspectorFindings(
warning.level === "breakage" || warning.level === "error" || warning.severity === "P0"
? "error"
: "warning";
if (findingKind === "error") hasStoredHardError = true;
await ctx.db.insert("packageInspectorWarnings", {
packageId: args.packageId,
releaseId: args.releaseId,
@@ -10025,6 +10034,7 @@ async function insertPackageInspectorFindings(
shouldEmailOwner:
shouldNotifyOwner &&
!notificationCompleted &&
hasStoredHardError &&
(args.scanSource === "nightly"
? findings.length > 0
: inserted > 0 || existingAuthorWarnings.length > 0),
@@ -10080,6 +10090,33 @@ export const markPackageInspectorFindingsEmailedInternal = internalMutation({
},
});
export const markPackageInspectorNotificationCompletedInternal = internalMutation({
args: {
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
inspectorVersion: v.string(),
targetOpenClawVersion: v.string(),
},
handler: async (ctx, args) => {
const scanState = await ctx.db
.query("packageInspectorScanStates")
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
q
.eq("releaseId", args.releaseId)
.eq("inspectorVersion", args.inspectorVersion)
.eq("targetOpenClawVersion", args.targetOpenClawVersion),
)
.unique();
if (!scanState || scanState.packageId !== args.packageId) {
return { ok: true as const, marked: false };
}
if (!scanState.notificationCompletedAt) {
await ctx.db.patch(scanState._id, { notificationCompletedAt: Date.now() });
}
return { ok: true as const, marked: true };
},
});
export const getPackageInspectorEmailContextInternal = internalQuery({
args: {
packageId: v.id("packages"),
@@ -10092,7 +10129,15 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
ctx.db.get(args.packageId),
ctx.db.get(args.releaseId),
]);
if (!pkg || pkg.softDeletedAt || !release || release.softDeletedAt) return null;
if (
!pkg ||
pkg.softDeletedAt ||
!release ||
release.softDeletedAt ||
release.packageId !== pkg._id
) {
return null;
}
const owner = await ctx.db.get(pkg.ownerUserId);
if (!owner || owner.deletedAt || owner.deactivatedAt || !owner.email) return null;
const exactScanIdentity = Boolean(args.inspectorVersion && args.targetOpenClawVersion);
@@ -10106,7 +10151,8 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
100,
)
: await takeAuthorRemediationWarningsByRelease(ctx, release._id, 100);
if (findings.length === 0) return null;
const hardErrors = findings.filter((finding) => finding.findingKind === "error");
if (hardErrors.length === 0) return null;
const notificationCompleted = await hasCompletedPackageInspectorNotification(ctx, {
releaseId: release._id,
inspectorVersion: args.inspectorVersion,
@@ -10123,7 +10169,7 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
ownerHandle: owner.handle,
packageName: pkg.name,
version: release.version,
findings: findings.map(toPublicPackageInspectorFinding),
findings: hardErrors.map(toPublicPackageInspectorFinding),
};
},
});
@@ -10164,7 +10210,7 @@ export const sendPackageInspectorFindingsEmailInternal = internalAction({
handle: context.ownerHandle,
packageName: context.packageName,
version: context.version,
findings: context.findings,
validationUrl: buildPackageInspectorValidationUrl(context.packageName),
});
const sent = await sendResendEmail({
to: context.ownerEmail,
+3 -1
View File
@@ -38,7 +38,9 @@ describe("React Email templates", () => {
expect(html).toContain("ClawHub");
expect(html).toContain("OpenClaw");
expect(html).toContain("https://docs.openclaw.ai/clawhub");
if (_name !== "plugin inspector findings") {
expect(html).toContain("https://docs.openclaw.ai/clawhub");
}
expect(text).toContain("ClawHub");
});
});
+62 -78
View File
@@ -1,98 +1,82 @@
import type { ReactNode } from "react";
import {
Badge,
ClawHubEmailLayout,
CodeBox,
DetailTable,
EmailHeading,
FindingCard,
Paragraph,
type FindingCardProps,
} from "./_components/clawhub";
export type PluginInspectorFindingEmailItem = FindingCardProps & {
code: string;
targetOpenClawVersion?: string;
};
import { Body, Button, Container, Head, Html, Text } from "@react-email/components";
export type PluginInspectorFindingsEmailProps = {
owner: string;
packageName: string;
version: string;
openClawVersion?: string;
findings: PluginInspectorFindingEmailItem[];
validateCommands: string[];
validationUrl: string;
preheader: string;
};
export default function PluginInspectorFindingsEmail({
owner,
packageName,
version,
openClawVersion,
findings,
validateCommands,
preheader,
validationUrl,
}: PluginInspectorFindingsEmailProps) {
const issueText = `${findings.length} ${findings.length === 1 ? "issue" : "issues"}`;
return (
<ClawHubEmailLayout preview={preheader} railLabel="Plugin Review">
<Badge>{`${issueText} found`}</Badge>
<EmailHeading>Plugin Inspector findings</EmailHeading>
<Paragraph>{`We found ${issueText} with version ${version} of ${packageName}.`}</Paragraph>
<DetailTable
rows={[
["Plugin", `${packageName}@${version}`],
["OpenClaw Version", openClawVersion ?? "current"],
]}
/>
<HeadingLabel>Findings</HeadingLabel>
{findings.map((finding, index) => (
<FindingCard
key={`${finding.code}:${finding.targetOpenClawVersion ?? index}`}
{...finding}
meta={
finding.targetOpenClawVersion
? `${finding.meta} · OpenClaw ${finding.targetOpenClawVersion}`
: finding.meta
}
/>
))}
<HeadingLabel>Validate a local fix</HeadingLabel>
{validateCommands.map((command) => (
<CodeBox key={command}>{command}</CodeBox>
))}
</ClawHubEmailLayout>
<Html lang="en">
<Head />
<Body style={bodyStyle}>
<Container style={containerStyle}>
<Text style={paragraphStyle}>{`Hi ${owner},`}</Text>
<Text style={paragraphStyle}>
{`ClawHub validated ${packageName}@${version} against the upcoming OpenClaw release.`}
</Text>
<Text style={paragraphStyle}>
The plugin uses an import, API, or hook that will no longer be available. If unchanged,
the affected functionality will fail when users upgrade OpenClaw.
</Text>
<Button href={validationUrl} style={buttonStyle}>
Review the validation errors
</Button>
<Text style={paragraphStyle}>
Your plugin page includes the exact errors, affected files, tested OpenClaw version,
reproduction command, and fix guidance when available.
</Text>
<Text style={paragraphStyle}>
Please update the plugin and publish a new version before the next OpenClaw release.
</Text>
<Text style={paragraphStyle}>ClawHub</Text>
</Container>
</Body>
</Html>
);
}
function HeadingLabel({ children }: { children: ReactNode }) {
return (
<h2
style={{
margin: "28px 0 14px",
fontFamily: "Helvetica, Arial, sans-serif",
fontSize: "16px",
color: "#f5f5f5",
}}
>
{children}
</h2>
);
}
const bodyStyle = { margin: 0, padding: "32px 16px", backgroundColor: "#0a0a0b" };
const containerStyle = {
width: "600px",
maxWidth: "600px",
padding: "36px",
backgroundColor: "#141416",
border: "1px solid #26262a",
borderRadius: "14px",
};
const paragraphStyle = {
margin: "0 0 18px",
fontFamily: "Helvetica, Arial, sans-serif",
fontSize: "15px",
lineHeight: "23px",
color: "#f5f5f5",
};
const buttonStyle = {
display: "inline-block",
margin: "0 0 18px",
padding: "12px 18px",
backgroundColor: "#e8443a",
borderRadius: "8px",
color: "#ffffff",
fontFamily: "Helvetica, Arial, sans-serif",
fontSize: "14px",
fontWeight: "bold",
textDecoration: "none",
};
PluginInspectorFindingsEmail.PreviewProps = {
owner: "octocat",
packageName: "demo-plugin",
version: "1.0.0",
openClawVersion: "2026.4.0",
validateCommands: ["clawhub package validate <path-to-plugin>"],
preheader: "Plugin Inspector found 1 issue with demo-plugin@1.0.0.",
findings: [
{
code: "legacy-before-agent-start",
kind: "warning",
meta: "legacy-before-agent-start · deprecation-warning · P2",
message: "legacy before_agent_start hook is deprecated",
fix: "Replace the legacy before_agent_start hook with current prompt hooks.",
docsUrl: "https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
},
],
validationUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
preheader: "ClawHub validated demo-plugin@1.0.0 against the upcoming OpenClaw release.",
} satisfies PluginInspectorFindingsEmailProps;
+1 -1
View File
@@ -103,7 +103,7 @@
"@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058",
"@openclaw/plugin-inspector": "0.3.20",
"@radix-ui/react-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-dropdown-menu": "2.1.21",
+1 -1
View File
@@ -38,7 +38,7 @@
},
"dependencies": {
"@clack/prompts": "1.7.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058",
"@openclaw/plugin-inspector": "0.3.20",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
acknowledgeBatch,
downloadPackageArtifactForScan,
loadNotificationManifest,
prepareExtractedPluginRoot,
prepareBulkOpenClawTarget,
resolveNightlyOpenClawTarget,
@@ -424,6 +425,76 @@ describe("package-inspector-nightly-scan", () => {
expect(renderImpactMarkdown(summary)).toContain("- Skipped unchanged releases: 1");
});
it("loads only hard-error releases from an exact completed no-email scan", async () => {
const root = await mkdtemp(path.join(tmpdir(), "clawhub-inspector-notification-manifest-"));
temporaryRoots.push(root);
const manifestPath = path.join(root, "run-summary.json");
await writeFile(
manifestPath,
JSON.stringify({
dryRun: false,
notificationOnly: false,
notifyOwners: false,
truncated: false,
nextCursor: null,
inspectorVersion: "0.3.20",
targetOpenClawVersion: "2026.8.0-beta.1",
packages: [
{
packageId: "packages:error",
releaseId: "packageReleases:error",
packageName: "error-plugin",
version: "1.0.0",
errorCount: 1,
},
{
packageId: "packages:warning",
releaseId: "packageReleases:warning",
packageName: "warning-plugin",
version: "2.0.0",
errorCount: 0,
},
],
}),
);
await expect(loadNotificationManifest(manifestPath, "0.3.20")).resolves.toEqual({
targetOpenClawVersion: "2026.8.0-beta.1",
items: [
{
packageId: "packages:error",
releaseId: "packageReleases:error",
packageName: "error-plugin",
version: "1.0.0",
downloadUrl: "",
},
],
});
});
it("rejects notification manifests that do not prove a completed no-email scan", async () => {
const root = await mkdtemp(path.join(tmpdir(), "clawhub-inspector-notification-manifest-"));
temporaryRoots.push(root);
const manifestPath = path.join(root, "run-summary.json");
await writeFile(
manifestPath,
JSON.stringify({
dryRun: false,
notificationOnly: false,
notifyOwners: true,
truncated: false,
nextCursor: null,
inspectorVersion: "0.3.20",
targetOpenClawVersion: "2026.8.0-beta.1",
packages: [],
}),
);
await expect(loadNotificationManifest(manifestPath, "0.3.20")).rejects.toThrow(
"completed no-email production scan",
);
});
it("resolves and prepares the beta target once for reuse across the bulk run", async () => {
const resolved = { requestedVersion: "beta", version: "2026.8.0-beta.1" };
const prepared = { ...resolved, status: "ok", cache: { hit: false, key: "beta-key" } };
+206 -15
View File
@@ -45,6 +45,8 @@ type NormalizedFinding = {
};
type ImpactEntry = {
packageId: string;
releaseId: string;
packageName: string;
version: string;
ownerUserId?: string;
@@ -62,6 +64,12 @@ type UploadResult = {
shouldEmailOwner: boolean;
};
type NotificationResult = {
ok: true;
sent: boolean;
reason?: string;
};
type PluginInspectorModule = {
openClawTargets?: {
resolveVersion: (requestedVersion: string) => Promise<Record<string, unknown>>;
@@ -89,6 +97,8 @@ 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,
@@ -137,26 +147,60 @@ export async function prepareBulkOpenClawTarget(
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 requestedOpenClawVersion = resolveNightlyOpenClawTarget(
process.env.PLUGIN_INSPECTOR_OPENCLAW_VERSION,
);
const preparedTarget = await prepareBulkOpenClawTarget(requestedOpenClawVersion, inspectorModule);
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 (targetPackageNames.length > 0) {
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;
@@ -170,16 +214,13 @@ export async function runPackageInspectorNightlyScan() {
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 {
} else if (preparedTarget) {
do {
const claimCursor = cursor;
const claim = await claimBatch(
cursor,
inspectorVersion,
preparedTarget.exactVersion,
scanRunId,
);
const claim = await claimBatch(cursor, inspectorVersion, targetOpenClawVersion, scanRunId);
if (claim.leased) {
throw new Error("Plugin Inspector bulk scan lease is owned by another run");
}
@@ -202,6 +243,8 @@ export async function runPackageInspectorNightlyScan() {
}
if (result.impactEntry) impactEntries.push(result.impactEntry);
if (result.scanned) scanned += 1;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
}
if (!dryRun) {
@@ -220,8 +263,11 @@ export async function runPackageInspectorNightlyScan() {
}
}
} while (cursor);
} else {
throw new Error("The scan target was not prepared");
}
const scanDurationMs = Date.now() - scanStartedAt;
const summary = summarizeImpact({
claimed,
scanned,
@@ -230,7 +276,13 @@ export async function runPackageInspectorNightlyScan() {
truncated,
nextCursor: cursor,
inspectorVersion,
targetOpenClawVersion: preparedTarget.exactVersion,
targetOpenClawVersion,
notifyOwners,
notificationOnly,
notificationAttempts,
notificationsSent,
scanStartedAt: new Date(scanStartedAt).toISOString(),
scanDurationMs,
entries: impactEntries,
});
await writeFile(
@@ -246,7 +298,9 @@ export async function runPackageInspectorNightlyScan() {
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
}
console.log(
`Bulk scan target OpenClaw ${summary.targetOpenClawVersion}: scanned=${summary.scannedReleases}, skippedUnchanged=${summary.skippedUnchangedReleases}, errors=${summary.pluginsWithErrors}, warnings=${summary.pluginsWithWarnings}.`,
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) {
@@ -254,6 +308,112 @@ export async function runPackageInspectorNightlyScan() {
}
}
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,
@@ -328,6 +488,8 @@ async function inspectPackageItem(
return {
failed: false,
scanned: true,
notificationAttempted: false,
notificationSent: false,
impactEntry: toImpactEntry(item, findings, targetOpenClawVersion),
};
} catch (error) {
@@ -335,7 +497,13 @@ async function inspectPackageItem(
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, impactEntry: undefined };
return {
failed: true,
scanned: false,
notificationAttempted: false,
notificationSent: false,
impactEntry: undefined,
};
} finally {
await rm(workRoot, { recursive: true, force: true });
}
@@ -765,6 +933,8 @@ function toImpactEntry(
else warningCount += 1;
}
return {
packageId: item.packageId,
releaseId: item.releaseId,
packageName: item.packageName,
version: item.version,
ownerUserId: item.ownerUserId,
@@ -790,6 +960,12 @@ export function summarizeImpact(args: {
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>();
@@ -826,6 +1002,16 @@ export function summarizeImpact(args: {
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,
@@ -856,7 +1042,12 @@ export function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>
`- 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}`,
+10 -1
View File
@@ -48,6 +48,9 @@ describe("package publish workflow", () => {
workflow_dispatch?: {
inputs?: {
dry_run?: { default?: string };
notification_only?: { default?: boolean };
notification_source_run_id?: { default?: string };
notify_owners?: { default?: boolean };
package_names?: { default?: string };
};
};
@@ -68,7 +71,11 @@ describe("package publish workflow", () => {
);
expect(workflow).toContain("PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta");
expect(workflow).toContain("notify_owners:");
expect(workflow).toContain("default: true");
expect(parsedWorkflow.on?.workflow_dispatch?.inputs?.notify_owners?.default).toBe(false);
expect(parsedWorkflow.on?.workflow_dispatch?.inputs?.notification_only?.default).toBe(false);
expect(parsedWorkflow.on?.workflow_dispatch?.inputs?.notification_source_run_id?.default).toBe(
"",
);
expect(workflow).toContain(
"PLUGIN_INSPECTOR_NOTIFY_OWNERS: ${{ github.event_name == 'schedule' && '0' || (inputs.notify_owners && '1' || '0') }}",
);
@@ -76,6 +83,8 @@ describe("package publish workflow", () => {
expect(workflow).toContain("if: ${{ github.ref == 'refs/heads/main' }}");
expect(workflow).toContain("bun install --frozen-lockfile");
expect(workflow).toContain("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN");
expect(workflow).toContain("Download reviewed scan artifact");
expect(workflow).toContain("PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST");
expect(script).toContain("package-inspector/claim");
expect(script).toContain("prepareBulkOpenClawTarget");
expect(script).toContain("targetOpenClaw: preparedTarget.target");