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 required: false
default: "20" default: "20"
notify_owners: notify_owners:
description: "Email plugin owners when a manual scan finds issues" description: "Email plugin owners from an explicitly selected reviewed scan"
required: false required: false
default: true default: false
type: boolean 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: package_names:
description: "Optional comma or newline separated package names to scan instead of the rolling cursor" description: "Optional comma or newline separated package names to scan instead of the rolling cursor"
required: false required: false
@@ -37,6 +46,7 @@ on:
default: "" default: ""
permissions: permissions:
actions: read
contents: read contents: read
# Side-effecting scans queue behind the active run instead of overlapping. # Side-effecting scans queue behind the active run instead of overlapping.
@@ -60,6 +70,45 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile 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 - name: Run plugin inspector bulk scan
if: ${{ github.ref == 'refs/heads/main' }} if: ${{ github.ref == 'refs/heads/main' }}
env: env:
@@ -71,6 +120,8 @@ jobs:
PLUGIN_INSPECTOR_PACKAGE_NAMES: ${{ inputs.package_names || '' }} PLUGIN_INSPECTOR_PACKAGE_NAMES: ${{ inputs.package_names || '' }}
PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta
PLUGIN_INSPECTOR_NOTIFY_OWNERS: ${{ github.event_name == 'schedule' && '0' || (inputs.notify_owners && '1' || '0') }} 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_PR: ${{ inputs.source_pr || '' }}
PLUGIN_INSPECTOR_SOURCE_SHA: ${{ inputs.source_sha || '' }} PLUGIN_INSPECTOR_SOURCE_SHA: ${{ inputs.source_sha || '' }}
PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-bulk-scan-reports PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-bulk-scan-reports
+3 -5
View File
@@ -15,7 +15,7 @@
"@fontsource/noto-sans-sc": "5.3.0", "@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.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-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20", "@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-dropdown-menu": "2.1.21", "@radix-ui/react-dropdown-menu": "2.1.21",
@@ -108,7 +108,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "1.7.0", "@clack/prompts": "1.7.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058", "@openclaw/plugin-inspector": "0.3.20",
"arktype": "2.2.3", "arktype": "2.2.3",
"commander": "15.0.0", "commander": "15.0.0",
"croner": "10.0.1", "croner": "10.0.1",
@@ -454,7 +454,7 @@
"@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"], "@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=="], "@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=="], "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=="], "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=="], "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, packageInspectorAcknowledgeHttp,
packageInspectorArtifactHttp, packageInspectorArtifactHttp,
packageInspectorClaimHttp, packageInspectorClaimHttp,
packageInspectorNotifyHttp,
packageInspectorResultsHttp, packageInspectorResultsHttp,
} from "./packageInspectorHttp"; } from "./packageInspectorHttp";
import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp"; import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp";
@@ -303,6 +304,12 @@ http.route({
handler: packageInspectorResultsHttp, handler: packageInspectorResultsHttp,
}); });
http.route({
path: "/api/v1/package-inspector/notify",
method: "POST",
handler: packageInspectorNotifyHttp,
});
http.route({ http.route({
pathPrefix: `${ApiRoutes.packages}/`, pathPrefix: `${ApiRoutes.packages}/`,
method: "POST", method: "POST",
+28 -168
View File
@@ -5,6 +5,7 @@ import {
buildAdminOneOffEmail, buildAdminOneOffEmail,
buildBanNotificationEmail, buildBanNotificationEmail,
buildMaliciousArtifactEmail, buildMaliciousArtifactEmail,
buildPackageInspectorValidationUrl,
buildPackageInspectorFindingsEmail, buildPackageInspectorFindingsEmail,
buildPublisherAbuseWarningEmail, buildPublisherAbuseWarningEmail,
buildRestoredAccountEmail, buildRestoredAccountEmail,
@@ -276,181 +277,40 @@ describe("moderation notification email copy", () => {
expect(email.html).not.toContain("appeal this decision"); 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({ const email = await buildPackageInspectorFindingsEmail({
handle: "octocat", handle: "octocat",
packageName: "demo-plugin", packageName: "@openclaw/demo-plugin",
version: "1.0.0", version: "1.0.0",
findings: [ validationUrl,
{
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",
},
},
],
}); });
expect(email.subject).toBe("Plugin Inspector findings for demo-plugin@1.0.0"); expect(validationUrl).toBe("https://clawhub.ai/openclaw/plugins/demo-plugin#validation");
expect(email.text).toContain("Hi octocat,"); expect(email.subject).toBe(
expect(email.text).toContain("We found 1 issue with version 1.0.0 of demo-plugin."); "Update required: @openclaw/demo-plugin will break in an upcoming OpenClaw release",
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(email.text).toContain( expect(email.text).toBe(
"- **WARNING** `legacy-before-agent-start` (deprecation-warning, P2)", [
"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.html).toContain(`href="${validationUrl}"`);
expect(email.text).toContain(" Fix:"); expect(email.html).toContain("Review the validation errors");
expect(email.text).toContain( expect(email.html).not.toContain("legacy-before-agent-start");
" Replace the legacy before_agent_start hook with current prompt hooks.", expect(email.html).not.toContain("Email preferences");
);
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");
}); });
it("builds publisher abuse warning emails with a deadline and Discord maintainer escalation", async () => { 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; 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 = { export type PackageInspectorFindingsEmailArgs = {
handle?: string; handle?: string;
packageName: string; packageName: string;
version: string; version: string;
findings: PackageInspectorEmailFinding[]; validationUrl: string;
}; };
export type PublisherAbuseWarningScore = { export type PublisherAbuseWarningScore = {
@@ -324,11 +308,6 @@ async function renderSecretBlockedPublishTemplate(args: {
return rendered.html; 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) { function normalizeEmailFindingSummary(value: string | undefined) {
const normalized = value?.replace(/\s+/g, " ").trim(); const normalized = value?.replace(/\s+/g, " ").trim();
if (!normalized) return undefined; if (!normalized) return undefined;
@@ -535,67 +514,30 @@ export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishE
} }
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) { export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
const targetOpenClawVersions = Array.from( const subject = `Update required: ${args.packageName} will break in an upcoming OpenClaw release`;
new Set( const intro = `ClawHub validated ${args.packageName}@${args.version} against the upcoming OpenClaw release.`;
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 lines = [ const lines = [
greeting(args.handle), greeting(args.handle),
"", "",
intro, 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:", `Review the validation errors: ${args.validationUrl}`,
...nextSteps.map((item) => `- ${item}`),
"", "",
"Findings:", "Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
...findingLines,
"", "",
"Validate a local fix:", "Please update the plugin and publish a new version before the next OpenClaw release.",
...validateCommands, "",
"—ClawHub",
]; ];
const { renderPluginInspectorFindingsEmail } = await import("./emailRendering"); const { renderPluginInspectorFindingsEmail } = await import("./emailRendering");
const rendered = await renderPluginInspectorFindingsEmail({ const rendered = await renderPluginInspectorFindingsEmail({
owner: args.handle?.trim() || "there",
packageName: args.packageName, packageName: args.packageName,
version: args.version, version: args.version,
...(targetOpenClawVersions.length validationUrl: args.validationUrl,
? { 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,
preheader: intro, 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) { export async function buildPublisherAbuseWarningEmail(args: PublisherAbuseWarningEmailArgs) {
const publisherHandle = args.publisherHandle.trim().replace(/^@+/, ""); const publisherHandle = args.publisherHandle.trim().replace(/^@+/, "");
const publisherLabel = publisherHandle ? `@${publisherHandle}` : "your publisher"; const publisherLabel = publisherHandle ? `@${publisherHandle}` : "your publisher";
@@ -678,30 +633,3 @@ export async function buildAdminOneOffEmail(args: AdminOneOffEmailArgs) {
html, 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, packageInspectorAcknowledgeHttp,
packageInspectorArtifactHttp, packageInspectorArtifactHttp,
packageInspectorClaimHttp, packageInspectorClaimHttp,
packageInspectorNotifyHttp,
packageInspectorResultsHttp, packageInspectorResultsHttp,
} from "./packageInspectorHttp"; } from "./packageInspectorHttp";
@@ -18,6 +19,8 @@ const packageInspectorResultsHttpHandler = (packageInspectorResultsHttp as unkno
._handler; ._handler;
const packageInspectorClaimHttpHandler = (packageInspectorClaimHttp as unknown as HttpHandler) const packageInspectorClaimHttpHandler = (packageInspectorClaimHttp as unknown as HttpHandler)
._handler; ._handler;
const packageInspectorNotifyHttpHandler = (packageInspectorNotifyHttp as unknown as HttpHandler)
._handler;
const packageInspectorAcknowledgeHttpHandler = ( const packageInspectorAcknowledgeHttpHandler = (
packageInspectorAcknowledgeHttp as unknown as HttpHandler packageInspectorAcknowledgeHttp as unknown as HttpHandler
)._handler; )._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 () => { it("continues a persisted scan run from the caller cursor", async () => {
vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token"); vi.stubEnv("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN", "worker-token");
const runMutation = vi.fn().mockResolvedValue({ const runMutation = vi.fn().mockResolvedValue({
+36
View File
@@ -13,6 +13,7 @@ const internalRefs = internal as unknown as {
getPackageInspectorArtifactInternal: unknown; getPackageInspectorArtifactInternal: unknown;
ingestPackageInspectorScanResultsInternal: unknown; ingestPackageInspectorScanResultsInternal: unknown;
sendPackageInspectorFindingsEmailInternal: unknown; sendPackageInspectorFindingsEmailInternal: unknown;
markPackageInspectorNotificationCompletedInternal: unknown;
}; };
}; };
@@ -220,3 +221,38 @@ export const packageInspectorResultsHttp = httpAction(async (ctx, request) => {
} }
return json(result); 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).toHaveBeenCalledTimes(1);
expect(insert).toHaveBeenCalledWith( 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 = { const ctx = {
db: { db: {
get: vi.fn(), get: vi.fn(),
@@ -12014,6 +12014,7 @@ describe("packages public queries", () => {
{ {
_id: "packageInspectorWarnings:publish", _id: "packageInspectorWarnings:publish",
scanSource: "publish", scanSource: "publish",
findingKind: "error",
code: "manifest-name-missing", code: "manifest-name-missing",
message: "manifest name is missing", message: "manifest name is missing",
authorRemediation: { summary: "Add a manifest name." }, authorRemediation: { summary: "Add a manifest name." },
@@ -12048,9 +12049,17 @@ describe("packages public queries", () => {
inspectorVersion: "0.6.0", inspectorVersion: "0.6.0",
targetOpenClawVersion: "2026.8.0-beta.1", targetOpenClawVersion: "2026.8.0-beta.1",
notifyOwners: true, 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 () => { it("records a clean beta scan identity and completes a no-op notification", async () => {
@@ -12242,6 +12251,7 @@ describe("packages public queries", () => {
scanSource: "nightly", scanSource: "nightly",
inspectorVersion: "0.5.0", inspectorVersion: "0.5.0",
targetOpenClawVersion: "0.10.0", targetOpenClawVersion: "0.10.0",
notifyOwners: true,
findings: [ findings: [
{ {
id: "demo:runtime-tool-capture", 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 insert = vi.fn();
const ctx = { const ctx = {
db: { db: {
@@ -12292,6 +12302,7 @@ describe("packages public queries", () => {
withIndex: vi.fn(() => ({ withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([ collect: vi.fn().mockResolvedValue([
{ {
findingKind: "error",
inspectorFindingId: "demo:legacy-before-agent-start", inspectorFindingId: "demo:legacy-before-agent-start",
code: "legacy-before-agent-start", code: "legacy-before-agent-start",
message: "legacy before_agent_start hook is deprecated", message: "legacy before_agent_start hook is deprecated",
@@ -12334,6 +12345,7 @@ describe("packages public queries", () => {
{ {
id: "demo:legacy-before-agent-start", id: "demo:legacy-before-agent-start",
code: "legacy-before-agent-start", code: "legacy-before-agent-start",
level: "breakage",
message: "legacy before_agent_start hook is deprecated", message: "legacy before_agent_start hook is deprecated",
evidence: ["src/index.ts:4"], evidence: ["src/index.ts:4"],
fixture: "demo", 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 () => { 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 = { const ctx = {
db: { db: {
get: vi.fn(async (id: string) => { get: vi.fn(async (id: string) => {
@@ -12668,6 +12688,19 @@ describe("packages public queries", () => {
}, },
createdAt: 1, 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", releaseId: "packageReleases:demo-1",
}); });
expect(result?.packageName).toBe("demo-plugin"); 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({ 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 () => { it("builds email context for a new exact target despite an older release notification", async () => {
const ctx = { const ctx = {
db: { db: {
@@ -12727,6 +12796,7 @@ describe("packages public queries", () => {
order: vi.fn(() => ({ order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([ take: vi.fn().mockResolvedValue([
{ {
findingKind: "error",
code: "missing-api", code: "missing-api",
scanSource: "nightly", scanSource: "nightly",
inspectorVersion: "0.6.0", inspectorVersion: "0.6.0",
@@ -12794,6 +12864,7 @@ describe("packages public queries", () => {
order: vi.fn(() => ({ order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([ take: vi.fn().mockResolvedValue([
{ {
findingKind: "error",
code: "missing-api", code: "missing-api",
scanSource: "nightly", scanSource: "nightly",
inspectorVersion: "0.6.0", inspectorVersion: "0.6.0",
+62 -16
View File
@@ -61,7 +61,10 @@ import {
clampActivityTrendEndDay, clampActivityTrendEndDay,
getActivityTrendRangeForEndDay, getActivityTrendRangeForEndDay,
} from "./lib/downloadTrend"; } from "./lib/downloadTrend";
import { buildPackageInspectorFindingsEmail } from "./lib/emails"; import {
buildPackageInspectorFindingsEmail,
buildPackageInspectorValidationUrl,
} from "./lib/emails";
import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./lib/experimentalClaws"; import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./lib/experimentalClaws";
import { requireGitHubAccountAge } from "./lib/githubAccount"; import { requireGitHubAccountAge } from "./lib/githubAccount";
import { normalizeGitHubRepository } from "./lib/githubActionsOidc"; import { normalizeGitHubRepository } from "./lib/githubActionsOidc";
@@ -9957,17 +9960,19 @@ async function insertPackageInspectorFindings(
return { ok: true as const, inserted: 0, shouldEmailOwner: false }; return { ok: true as const, inserted: 0, shouldEmailOwner: false };
} }
const existingWarningKeys = new Set( const existingWarningKeys = new Set(
existingAuthorWarnings.map((warning) => replaceNightlyFindings
packageInspectorWarningDedupeKey({ ? []
id: warning.inspectorFindingId, : existingAuthorWarnings.map((warning) =>
code: warning.code, packageInspectorWarningDedupeKey({
message: warning.message, id: warning.inspectorFindingId,
evidence: warning.evidence, code: warning.code,
fixture: warning.fixture, message: warning.message,
inspectorVersion: warning.inspectorVersion, evidence: warning.evidence,
targetOpenClawVersion: warning.targetOpenClawVersion, fixture: warning.fixture,
}), inspectorVersion: warning.inspectorVersion,
), targetOpenClawVersion: warning.targetOpenClawVersion,
}),
),
); );
const shouldNotifyOwner = args.notifyOwners ?? args.scanSource !== "nightly"; const shouldNotifyOwner = args.notifyOwners ?? args.scanSource !== "nightly";
const notificationCompleted = shouldNotifyOwner const notificationCompleted = shouldNotifyOwner
@@ -9980,6 +9985,9 @@ async function insertPackageInspectorFindings(
: false; : false;
const now = Date.now(); const now = Date.now();
let inserted = 0; let inserted = 0;
let hasStoredHardError =
args.scanSource !== "nightly" &&
existingAuthorWarnings.some((warning) => warning.findingKind === "error");
for (const warning of findings.slice(0, 100)) { for (const warning of findings.slice(0, 100)) {
const warningKey = packageInspectorWarningDedupeKey({ const warningKey = packageInspectorWarningDedupeKey({
...warning, ...warning,
@@ -9991,6 +9999,7 @@ async function insertPackageInspectorFindings(
warning.level === "breakage" || warning.level === "error" || warning.severity === "P0" warning.level === "breakage" || warning.level === "error" || warning.severity === "P0"
? "error" ? "error"
: "warning"; : "warning";
if (findingKind === "error") hasStoredHardError = true;
await ctx.db.insert("packageInspectorWarnings", { await ctx.db.insert("packageInspectorWarnings", {
packageId: args.packageId, packageId: args.packageId,
releaseId: args.releaseId, releaseId: args.releaseId,
@@ -10025,6 +10034,7 @@ async function insertPackageInspectorFindings(
shouldEmailOwner: shouldEmailOwner:
shouldNotifyOwner && shouldNotifyOwner &&
!notificationCompleted && !notificationCompleted &&
hasStoredHardError &&
(args.scanSource === "nightly" (args.scanSource === "nightly"
? findings.length > 0 ? findings.length > 0
: inserted > 0 || existingAuthorWarnings.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({ export const getPackageInspectorEmailContextInternal = internalQuery({
args: { args: {
packageId: v.id("packages"), packageId: v.id("packages"),
@@ -10092,7 +10129,15 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
ctx.db.get(args.packageId), ctx.db.get(args.packageId),
ctx.db.get(args.releaseId), 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); const owner = await ctx.db.get(pkg.ownerUserId);
if (!owner || owner.deletedAt || owner.deactivatedAt || !owner.email) return null; if (!owner || owner.deletedAt || owner.deactivatedAt || !owner.email) return null;
const exactScanIdentity = Boolean(args.inspectorVersion && args.targetOpenClawVersion); const exactScanIdentity = Boolean(args.inspectorVersion && args.targetOpenClawVersion);
@@ -10106,7 +10151,8 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
100, 100,
) )
: await takeAuthorRemediationWarningsByRelease(ctx, release._id, 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, { const notificationCompleted = await hasCompletedPackageInspectorNotification(ctx, {
releaseId: release._id, releaseId: release._id,
inspectorVersion: args.inspectorVersion, inspectorVersion: args.inspectorVersion,
@@ -10123,7 +10169,7 @@ export const getPackageInspectorEmailContextInternal = internalQuery({
ownerHandle: owner.handle, ownerHandle: owner.handle,
packageName: pkg.name, packageName: pkg.name,
version: release.version, version: release.version,
findings: findings.map(toPublicPackageInspectorFinding), findings: hardErrors.map(toPublicPackageInspectorFinding),
}; };
}, },
}); });
@@ -10164,7 +10210,7 @@ export const sendPackageInspectorFindingsEmailInternal = internalAction({
handle: context.ownerHandle, handle: context.ownerHandle,
packageName: context.packageName, packageName: context.packageName,
version: context.version, version: context.version,
findings: context.findings, validationUrl: buildPackageInspectorValidationUrl(context.packageName),
}); });
const sent = await sendResendEmail({ const sent = await sendResendEmail({
to: context.ownerEmail, to: context.ownerEmail,
+3 -1
View File
@@ -38,7 +38,9 @@ describe("React Email templates", () => {
expect(html).toContain("ClawHub"); expect(html).toContain("ClawHub");
expect(html).toContain("OpenClaw"); 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"); expect(text).toContain("ClawHub");
}); });
}); });
+62 -78
View File
@@ -1,98 +1,82 @@
import type { ReactNode } from "react"; import { Body, Button, Container, Head, Html, Text } from "@react-email/components";
import {
Badge,
ClawHubEmailLayout,
CodeBox,
DetailTable,
EmailHeading,
FindingCard,
Paragraph,
type FindingCardProps,
} from "./_components/clawhub";
export type PluginInspectorFindingEmailItem = FindingCardProps & {
code: string;
targetOpenClawVersion?: string;
};
export type PluginInspectorFindingsEmailProps = { export type PluginInspectorFindingsEmailProps = {
owner: string;
packageName: string; packageName: string;
version: string; version: string;
openClawVersion?: string; validationUrl: string;
findings: PluginInspectorFindingEmailItem[];
validateCommands: string[];
preheader: string; preheader: string;
}; };
export default function PluginInspectorFindingsEmail({ export default function PluginInspectorFindingsEmail({
owner,
packageName, packageName,
version, version,
openClawVersion, validationUrl,
findings,
validateCommands,
preheader,
}: PluginInspectorFindingsEmailProps) { }: PluginInspectorFindingsEmailProps) {
const issueText = `${findings.length} ${findings.length === 1 ? "issue" : "issues"}`;
return ( return (
<ClawHubEmailLayout preview={preheader} railLabel="Plugin Review"> <Html lang="en">
<Badge>{`${issueText} found`}</Badge> <Head />
<EmailHeading>Plugin Inspector findings</EmailHeading> <Body style={bodyStyle}>
<Paragraph>{`We found ${issueText} with version ${version} of ${packageName}.`}</Paragraph> <Container style={containerStyle}>
<DetailTable <Text style={paragraphStyle}>{`Hi ${owner},`}</Text>
rows={[ <Text style={paragraphStyle}>
["Plugin", `${packageName}@${version}`], {`ClawHub validated ${packageName}@${version} against the upcoming OpenClaw release.`}
["OpenClaw Version", openClawVersion ?? "current"], </Text>
]} <Text style={paragraphStyle}>
/> The plugin uses an import, API, or hook that will no longer be available. If unchanged,
<HeadingLabel>Findings</HeadingLabel> the affected functionality will fail when users upgrade OpenClaw.
{findings.map((finding, index) => ( </Text>
<FindingCard <Button href={validationUrl} style={buttonStyle}>
key={`${finding.code}:${finding.targetOpenClawVersion ?? index}`} Review the validation errors
{...finding} </Button>
meta={ <Text style={paragraphStyle}>
finding.targetOpenClawVersion Your plugin page includes the exact errors, affected files, tested OpenClaw version,
? `${finding.meta} · OpenClaw ${finding.targetOpenClawVersion}` reproduction command, and fix guidance when available.
: finding.meta </Text>
} <Text style={paragraphStyle}>
/> Please update the plugin and publish a new version before the next OpenClaw release.
))} </Text>
<HeadingLabel>Validate a local fix</HeadingLabel> <Text style={paragraphStyle}>ClawHub</Text>
{validateCommands.map((command) => ( </Container>
<CodeBox key={command}>{command}</CodeBox> </Body>
))} </Html>
</ClawHubEmailLayout>
); );
} }
function HeadingLabel({ children }: { children: ReactNode }) { const bodyStyle = { margin: 0, padding: "32px 16px", backgroundColor: "#0a0a0b" };
return ( const containerStyle = {
<h2 width: "600px",
style={{ maxWidth: "600px",
margin: "28px 0 14px", padding: "36px",
fontFamily: "Helvetica, Arial, sans-serif", backgroundColor: "#141416",
fontSize: "16px", border: "1px solid #26262a",
color: "#f5f5f5", borderRadius: "14px",
}} };
> const paragraphStyle = {
{children} margin: "0 0 18px",
</h2> 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 = { PluginInspectorFindingsEmail.PreviewProps = {
owner: "octocat",
packageName: "demo-plugin", packageName: "demo-plugin",
version: "1.0.0", version: "1.0.0",
openClawVersion: "2026.4.0", validationUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
validateCommands: ["clawhub package validate <path-to-plugin>"], preheader: "ClawHub validated demo-plugin@1.0.0 against the upcoming OpenClaw release.",
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",
},
],
} satisfies PluginInspectorFindingsEmailProps; } satisfies PluginInspectorFindingsEmailProps;
+1 -1
View File
@@ -103,7 +103,7 @@
"@fontsource/noto-sans-sc": "5.3.0", "@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.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-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20", "@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-dropdown-menu": "2.1.21", "@radix-ui/react-dropdown-menu": "2.1.21",
+1 -1
View File
@@ -38,7 +38,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "1.7.0", "@clack/prompts": "1.7.0",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058", "@openclaw/plugin-inspector": "0.3.20",
"arktype": "2.2.3", "arktype": "2.2.3",
"commander": "15.0.0", "commander": "15.0.0",
"croner": "10.0.1", "croner": "10.0.1",
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
acknowledgeBatch, acknowledgeBatch,
downloadPackageArtifactForScan, downloadPackageArtifactForScan,
loadNotificationManifest,
prepareExtractedPluginRoot, prepareExtractedPluginRoot,
prepareBulkOpenClawTarget, prepareBulkOpenClawTarget,
resolveNightlyOpenClawTarget, resolveNightlyOpenClawTarget,
@@ -424,6 +425,76 @@ describe("package-inspector-nightly-scan", () => {
expect(renderImpactMarkdown(summary)).toContain("- Skipped unchanged releases: 1"); 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 () => { 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 resolved = { requestedVersion: "beta", version: "2026.8.0-beta.1" };
const prepared = { ...resolved, status: "ok", cache: { hit: false, key: "beta-key" } }; const prepared = { ...resolved, status: "ok", cache: { hit: false, key: "beta-key" } };
+206 -15
View File
@@ -45,6 +45,8 @@ type NormalizedFinding = {
}; };
type ImpactEntry = { type ImpactEntry = {
packageId: string;
releaseId: string;
packageName: string; packageName: string;
version: string; version: string;
ownerUserId?: string; ownerUserId?: string;
@@ -62,6 +64,12 @@ type UploadResult = {
shouldEmailOwner: boolean; shouldEmailOwner: boolean;
}; };
type NotificationResult = {
ok: true;
sent: boolean;
reason?: string;
};
type PluginInspectorModule = { type PluginInspectorModule = {
openClawTargets?: { openClawTargets?: {
resolveVersion: (requestedVersion: string) => Promise<Record<string, unknown>>; 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 batchSize = process.env.PLUGIN_INSPECTOR_BATCH_SIZE ?? "25";
const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN); const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN);
const notifyOwners = parseBoolean(process.env.PLUGIN_INSPECTOR_NOTIFY_OWNERS); 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 targetPackageNames = parsePackageNames(process.env.PLUGIN_INSPECTOR_PACKAGE_NAMES);
const dryRunMaxBatches = Math.max( const dryRunMaxBatches = Math.max(
1, 1,
@@ -137,26 +147,60 @@ export async function prepareBulkOpenClawTarget(
export async function runPackageInspectorNightlyScan() { export async function runPackageInspectorNightlyScan() {
if (!token) throw new Error("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN is required"); 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 inspectorVersion = getInspectorVersion();
const inspectorModule = const inspectorModule =
(await import("@openclaw/plugin-inspector")) as unknown as PluginInspectorModule; (await import("@openclaw/plugin-inspector")) as unknown as PluginInspectorModule;
const requestedOpenClawVersion = resolveNightlyOpenClawTarget( const notificationManifest = notificationOnly
process.env.PLUGIN_INSPECTOR_OPENCLAW_VERSION, ? await loadNotificationManifest(notificationManifestPath, inspectorVersion)
); : null;
const preparedTarget = await prepareBulkOpenClawTarget(requestedOpenClawVersion, inspectorModule); 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 }); await mkdir(artifactRoot, { recursive: true });
const scanStartedAt = Date.now();
let hadWorkerFailure = false; let hadWorkerFailure = false;
const impactEntries: ImpactEntry[] = []; const impactEntries: ImpactEntry[] = [];
let claimed = 0; let claimed = 0;
let scanned = 0; let scanned = 0;
let skippedUnchanged = 0; let skippedUnchanged = 0;
let notificationAttempts = 0;
let notificationsSent = 0;
let cursor: string | null = null; let cursor: string | null = null;
let batches = 0; let batches = 0;
let truncated = false; 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); const items = await resolveTargetPackageItems(targetPackageNames);
batches = 1; batches = 1;
claimed = items.length; claimed = items.length;
@@ -170,16 +214,13 @@ export async function runPackageInspectorNightlyScan() {
if (result.failed) hadWorkerFailure = true; if (result.failed) hadWorkerFailure = true;
if (result.impactEntry) impactEntries.push(result.impactEntry); if (result.impactEntry) impactEntries.push(result.impactEntry);
if (result.scanned) scanned += 1; if (result.scanned) scanned += 1;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
} }
} else { } else if (preparedTarget) {
do { do {
const claimCursor = cursor; const claimCursor = cursor;
const claim = await claimBatch( const claim = await claimBatch(cursor, inspectorVersion, targetOpenClawVersion, scanRunId);
cursor,
inspectorVersion,
preparedTarget.exactVersion,
scanRunId,
);
if (claim.leased) { if (claim.leased) {
throw new Error("Plugin Inspector bulk scan lease is owned by another run"); 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.impactEntry) impactEntries.push(result.impactEntry);
if (result.scanned) scanned += 1; if (result.scanned) scanned += 1;
if (result.notificationAttempted) notificationAttempts += 1;
if (result.notificationSent) notificationsSent += 1;
} }
if (!dryRun) { if (!dryRun) {
@@ -220,8 +263,11 @@ export async function runPackageInspectorNightlyScan() {
} }
} }
} while (cursor); } while (cursor);
} else {
throw new Error("The scan target was not prepared");
} }
const scanDurationMs = Date.now() - scanStartedAt;
const summary = summarizeImpact({ const summary = summarizeImpact({
claimed, claimed,
scanned, scanned,
@@ -230,7 +276,13 @@ export async function runPackageInspectorNightlyScan() {
truncated, truncated,
nextCursor: cursor, nextCursor: cursor,
inspectorVersion, inspectorVersion,
targetOpenClawVersion: preparedTarget.exactVersion, targetOpenClawVersion,
notifyOwners,
notificationOnly,
notificationAttempts,
notificationsSent,
scanStartedAt: new Date(scanStartedAt).toISOString(),
scanDurationMs,
entries: impactEntries, entries: impactEntries,
}); });
await writeFile( await writeFile(
@@ -246,7 +298,9 @@ export async function runPackageInspectorNightlyScan() {
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary)); await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
} }
console.log( 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) { 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( async function inspectPackageItem(
item: ClaimItem, item: ClaimItem,
inspectorVersion: string, inspectorVersion: string,
@@ -328,6 +488,8 @@ async function inspectPackageItem(
return { return {
failed: false, failed: false,
scanned: true, scanned: true,
notificationAttempted: false,
notificationSent: false,
impactEntry: toImpactEntry(item, findings, targetOpenClawVersion), impactEntry: toImpactEntry(item, findings, targetOpenClawVersion),
}; };
} catch (error) { } catch (error) {
@@ -335,7 +497,13 @@ async function inspectPackageItem(
await writeFile(path.join(reportDir, "error.txt"), message); await writeFile(path.join(reportDir, "error.txt"), message);
console.error(`Plugin Inspector bulk scan failed for ${item.packageName}@${item.version}`); console.error(`Plugin Inspector bulk scan failed for ${item.packageName}@${item.version}`);
console.error(message); console.error(message);
return { failed: true, scanned: false, impactEntry: undefined }; return {
failed: true,
scanned: false,
notificationAttempted: false,
notificationSent: false,
impactEntry: undefined,
};
} finally { } finally {
await rm(workRoot, { recursive: true, force: true }); await rm(workRoot, { recursive: true, force: true });
} }
@@ -765,6 +933,8 @@ function toImpactEntry(
else warningCount += 1; else warningCount += 1;
} }
return { return {
packageId: item.packageId,
releaseId: item.releaseId,
packageName: item.packageName, packageName: item.packageName,
version: item.version, version: item.version,
ownerUserId: item.ownerUserId, ownerUserId: item.ownerUserId,
@@ -790,6 +960,12 @@ export function summarizeImpact(args: {
nextCursor: string | null; nextCursor: string | null;
inspectorVersion: string; inspectorVersion: string;
targetOpenClawVersion?: string; targetOpenClawVersion?: string;
notifyOwners?: boolean;
notificationOnly?: boolean;
notificationAttempts?: number;
notificationsSent?: number;
scanStartedAt?: string;
scanDurationMs?: number;
entries: ImpactEntry[]; entries: ImpactEntry[];
}) { }) {
const impactedOwners = new Set<string>(); const impactedOwners = new Set<string>();
@@ -826,6 +1002,16 @@ export function summarizeImpact(args: {
siteUrl, siteUrl,
inspectorVersion: args.inspectorVersion, inspectorVersion: args.inspectorVersion,
targetOpenClawVersion: args.targetOpenClawVersion, 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, batchSize: Number.parseInt(batchSize, 10) || batchSize,
batches: args.batches, batches: args.batches,
truncated: args.truncated, truncated: args.truncated,
@@ -856,7 +1042,12 @@ export function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>
`- Site: ${summary.siteUrl}`, `- Site: ${summary.siteUrl}`,
`- Inspector: ${summary.inspectorVersion}`, `- Inspector: ${summary.inspectorVersion}`,
`- Target OpenClaw: ${summary.targetOpenClawVersion ?? "unknown"}`, `- Target OpenClaw: ${summary.targetOpenClawVersion ?? "unknown"}`,
`- Mode: ${summary.notificationOnly ? "notification-only" : "scan"}`,
`- Scanned latest releases: ${summary.scannedReleases}`, `- 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}`, `- Skipped unchanged releases: ${summary.skippedUnchangedReleases}`,
`- Plugins with errors: ${summary.pluginsWithErrors}`, `- Plugins with errors: ${summary.pluginsWithErrors}`,
`- Plugins with warnings: ${summary.pluginsWithWarnings}`, `- Plugins with warnings: ${summary.pluginsWithWarnings}`,
+10 -1
View File
@@ -48,6 +48,9 @@ describe("package publish workflow", () => {
workflow_dispatch?: { workflow_dispatch?: {
inputs?: { inputs?: {
dry_run?: { default?: string }; dry_run?: { default?: string };
notification_only?: { default?: boolean };
notification_source_run_id?: { default?: string };
notify_owners?: { default?: boolean };
package_names?: { default?: string }; package_names?: { default?: string };
}; };
}; };
@@ -68,7 +71,11 @@ describe("package publish workflow", () => {
); );
expect(workflow).toContain("PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta"); expect(workflow).toContain("PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta");
expect(workflow).toContain("notify_owners:"); 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( expect(workflow).toContain(
"PLUGIN_INSPECTOR_NOTIFY_OWNERS: ${{ github.event_name == 'schedule' && '0' || (inputs.notify_owners && '1' || '0') }}", "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("if: ${{ github.ref == 'refs/heads/main' }}");
expect(workflow).toContain("bun install --frozen-lockfile"); expect(workflow).toContain("bun install --frozen-lockfile");
expect(workflow).toContain("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN"); 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("package-inspector/claim");
expect(script).toContain("prepareBulkOpenClawTarget"); expect(script).toContain("prepareBulkOpenClawTarget");
expect(script).toContain("targetOpenClaw: preparedTarget.target"); expect(script).toContain("targetOpenClaw: preparedTarget.target");