From a16ff751bb1ef817df6b4f6a679f82accdf49cb1 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Fri, 31 Jul 2026 17:37:52 -0700 Subject: [PATCH] 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 --- .../workflows/plugin-inspector-bulk-scan.yml | 55 ++++- bun.lock | 8 +- convex/http.ts | 7 + convex/lib/emails.test.ts | 196 +++------------- convex/lib/emails.ts | 120 ++-------- convex/packageInspectorHttp.test.ts | 39 ++++ convex/packageInspectorHttp.ts | 36 +++ convex/packages.public.test.ts | 91 +++++++- convex/packages.ts | 78 +++++-- emails/emailTemplates.test.tsx | 4 +- emails/plugin-inspector-findings.tsx | 140 +++++------ package.json | 2 +- packages/clawhub/package.json | 2 +- .../package-inspector-nightly-scan.test.ts | 71 ++++++ scripts/package-inspector-nightly-scan.ts | 221 ++++++++++++++++-- .../package-publish-workflow.test.ts | 11 +- 16 files changed, 687 insertions(+), 394 deletions(-) diff --git a/.github/workflows/plugin-inspector-bulk-scan.yml b/.github/workflows/plugin-inspector-bulk-scan.yml index 7989ddc5..7d244faf 100644 --- a/.github/workflows/plugin-inspector-bulk-scan.yml +++ b/.github/workflows/plugin-inspector-bulk-scan.yml @@ -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] ?? ""}.`); + 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 diff --git a/bun.lock b/bun.lock index dd809187..72a3d973 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/convex/http.ts b/convex/http.ts index ad6e082b..33926d7d 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -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", diff --git a/convex/lib/emails.test.ts b/convex/lib/emails.test.ts index 982deaac..af3c338b 100644 --- a/convex/lib/emails.test.ts +++ b/convex/lib/emails.test.ts @@ -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 --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'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 <path-to-plugin> --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("Plugin Inspector:"); - expect(email.html).not.toContain("Target OpenClaw:"); - 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 --openclaw-version 0.9.0", - ); - expect(email.text).toContain( - "clawhub package validate --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 <path-to-plugin> --openclaw-version 0.9.0", - ); - expect(email.html).toContain( - "clawhub package validate <path-to-plugin> --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 --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 () => { diff --git a/convex/lib/emails.ts b/convex/lib/emails.ts index 11021825..9a7bf9b8 100644 --- a/convex/lib/emails.ts +++ b/convex/lib/emails.ts @@ -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 "; - 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})` : ""; -} diff --git a/convex/packageInspectorHttp.test.ts b/convex/packageInspectorHttp.test.ts index f9705ebe..cc900bf4 100644 --- a/convex/packageInspectorHttp.test.ts +++ b/convex/packageInspectorHttp.test.ts @@ -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({ diff --git a/convex/packageInspectorHttp.ts b/convex/packageInspectorHttp.ts index d9e1e376..9dc253e5 100644 --- a/convex/packageInspectorHttp.ts +++ b/convex/packageInspectorHttp.ts @@ -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); +}); diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 0c31c49c..67e453b6 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -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", diff --git a/convex/packages.ts b/convex/packages.ts index f2f7c492..116f5e36 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -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, diff --git a/emails/emailTemplates.test.tsx b/emails/emailTemplates.test.tsx index 2453b3c3..331e8c7d 100644 --- a/emails/emailTemplates.test.tsx +++ b/emails/emailTemplates.test.tsx @@ -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"); }); }); diff --git a/emails/plugin-inspector-findings.tsx b/emails/plugin-inspector-findings.tsx index d444f108..f28c5602 100644 --- a/emails/plugin-inspector-findings.tsx +++ b/emails/plugin-inspector-findings.tsx @@ -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 ( - - {`${issueText} found`} - Plugin Inspector findings - {`We found ${issueText} with version ${version} of ${packageName}.`} - - Findings - {findings.map((finding, index) => ( - - ))} - Validate a local fix - {validateCommands.map((command) => ( - {command} - ))} - + + + + + {`Hi ${owner},`} + + {`ClawHub validated ${packageName}@${version} 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. + + + + 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 + + + ); } -function HeadingLabel({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} +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 "], - 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; diff --git a/package.json b/package.json index 6a80dbdd..f1f067fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/clawhub/package.json b/packages/clawhub/package.json index 6e96575e..dce569e3 100644 --- a/packages/clawhub/package.json +++ b/packages/clawhub/package.json @@ -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", diff --git a/scripts/package-inspector-nightly-scan.test.ts b/scripts/package-inspector-nightly-scan.test.ts index 04b93690..56ecee32 100644 --- a/scripts/package-inspector-nightly-scan.test.ts +++ b/scripts/package-inspector-nightly-scan.test.ts @@ -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" } }; diff --git a/scripts/package-inspector-nightly-scan.ts b/scripts/package-inspector-nightly-scan.ts index d4b047a8..edbca32f 100644 --- a/scripts/package-inspector-nightly-scan.ts +++ b/scripts/package-inspector-nightly-scan.ts @@ -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>; @@ -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( + `${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(); @@ -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 `- 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}`, diff --git a/src/__tests__/package-publish-workflow.test.ts b/src/__tests__/package-publish-workflow.test.ts index 3f756ab0..1fa3185f 100644 --- a/src/__tests__/package-publish-workflow.test.ts +++ b/src/__tests__/package-publish-workflow.test.ts @@ -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");