mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -19,10 +19,19 @@ on:
|
||||
required: false
|
||||
default: "20"
|
||||
notify_owners:
|
||||
description: "Email plugin owners when a manual scan finds issues"
|
||||
description: "Email plugin owners from an explicitly selected reviewed scan"
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
notification_only:
|
||||
description: "Notify from the exact stored scan result without inspecting packages again"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
notification_source_run_id:
|
||||
description: "Successful no-email scan run whose exact artifact is approved for notification"
|
||||
required: false
|
||||
default: ""
|
||||
package_names:
|
||||
description: "Optional comma or newline separated package names to scan instead of the rolling cursor"
|
||||
required: false
|
||||
@@ -37,6 +46,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
# Side-effecting scans queue behind the active run instead of overlapping.
|
||||
@@ -60,6 +70,45 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Verify notification source run
|
||||
if: ${{ inputs.notification_only }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_RUN_ID: ${{ inputs.notification_source_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$SOURCE_RUN_ID" || { echo "notification_source_run_id is required" >&2; exit 1; }
|
||||
RUN_JSON="$(gh run view "$SOURCE_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node --input-type=module -e '
|
||||
const chunks = [];
|
||||
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
||||
process.stdin.on("end", () => {
|
||||
const run = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
const checks = [
|
||||
["workflowName", "Plugin Inspector Bulk Scan"],
|
||||
["headBranch", "main"],
|
||||
["event", "workflow_dispatch"],
|
||||
["conclusion", "success"],
|
||||
];
|
||||
for (const [key, expected] of checks) {
|
||||
if (run[key] !== expected) {
|
||||
console.error(`Notification source run must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(`Using reviewed no-email scan: ${run.url}`);
|
||||
});'
|
||||
|
||||
- name: Download reviewed scan artifact
|
||||
if: ${{ inputs.notification_only }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: plugin-inspector-bulk-scan-reports
|
||||
path: notification-source
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.notification_source_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Run plugin inspector bulk scan
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
@@ -71,6 +120,8 @@ jobs:
|
||||
PLUGIN_INSPECTOR_PACKAGE_NAMES: ${{ inputs.package_names || '' }}
|
||||
PLUGIN_INSPECTOR_OPENCLAW_VERSION: beta
|
||||
PLUGIN_INSPECTOR_NOTIFY_OWNERS: ${{ github.event_name == 'schedule' && '0' || (inputs.notify_owners && '1' || '0') }}
|
||||
PLUGIN_INSPECTOR_NOTIFICATION_ONLY: ${{ github.event_name == 'schedule' && '0' || (inputs.notification_only && '1' || '0') }}
|
||||
PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST: ${{ inputs.notification_only && 'notification-source/run-summary.json' || '' }}
|
||||
PLUGIN_INSPECTOR_SOURCE_PR: ${{ inputs.source_pr || '' }}
|
||||
PLUGIN_INSPECTOR_SOURCE_SHA: ${{ inputs.source_sha || '' }}
|
||||
PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-bulk-scan-reports
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
packageInspectorAcknowledgeHttp,
|
||||
packageInspectorArtifactHttp,
|
||||
packageInspectorClaimHttp,
|
||||
packageInspectorNotifyHttp,
|
||||
packageInspectorResultsHttp,
|
||||
} from "./packageInspectorHttp";
|
||||
import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp";
|
||||
@@ -303,6 +304,12 @@ http.route({
|
||||
handler: packageInspectorResultsHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: "/api/v1/package-inspector/notify",
|
||||
method: "POST",
|
||||
handler: packageInspectorNotifyHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.packages}/`,
|
||||
method: "POST",
|
||||
|
||||
+28
-168
@@ -5,6 +5,7 @@ import {
|
||||
buildAdminOneOffEmail,
|
||||
buildBanNotificationEmail,
|
||||
buildMaliciousArtifactEmail,
|
||||
buildPackageInspectorValidationUrl,
|
||||
buildPackageInspectorFindingsEmail,
|
||||
buildPublisherAbuseWarningEmail,
|
||||
buildRestoredAccountEmail,
|
||||
@@ -276,181 +277,40 @@ describe("moderation notification email copy", () => {
|
||||
expect(email.html).not.toContain("appeal this decision");
|
||||
});
|
||||
|
||||
it("builds plugin inspector warning copy with local validation guidance", async () => {
|
||||
it("builds the approved hard-error plugin compatibility email", async () => {
|
||||
const validationUrl = buildPackageInspectorValidationUrl("@openclaw/demo-plugin");
|
||||
const email = await buildPackageInspectorFindingsEmail({
|
||||
handle: "octocat",
|
||||
packageName: "demo-plugin",
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
version: "1.0.0",
|
||||
findings: [
|
||||
{
|
||||
findingKind: "warning",
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
severity: "P2",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
inspectorVersion: "0.4.0",
|
||||
targetOpenClawVersion: "0.9.0",
|
||||
scanSource: "publish",
|
||||
authorRemediation: {
|
||||
summary: "Replace the legacy before_agent_start hook with current prompt hooks.",
|
||||
docsUrl:
|
||||
"https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
|
||||
},
|
||||
},
|
||||
],
|
||||
validationUrl,
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("Plugin Inspector findings for demo-plugin@1.0.0");
|
||||
expect(email.text).toContain("Hi octocat,");
|
||||
expect(email.text).toContain("We found 1 issue with version 1.0.0 of demo-plugin.");
|
||||
expect(email.text).toContain("OpenClaw Version: 0.9.0");
|
||||
expect(email.text).toContain("Address the findings below in your plugin package.");
|
||||
expect(email.text).toContain("Run the validation command locally against your changes.");
|
||||
expect(email.text).toContain(
|
||||
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
|
||||
expect(validationUrl).toBe("https://clawhub.ai/openclaw/plugins/demo-plugin#validation");
|
||||
expect(email.subject).toBe(
|
||||
"Update required: @openclaw/demo-plugin will break in an upcoming OpenClaw release",
|
||||
);
|
||||
expect(email.text).toContain(
|
||||
"- **WARNING** `legacy-before-agent-start` (deprecation-warning, P2)",
|
||||
expect(email.text).toBe(
|
||||
[
|
||||
"Hi octocat,",
|
||||
"",
|
||||
"ClawHub validated @openclaw/demo-plugin@1.0.0 against the upcoming OpenClaw release.",
|
||||
"",
|
||||
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
|
||||
"",
|
||||
`Review the validation errors: ${validationUrl}`,
|
||||
"",
|
||||
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
|
||||
"",
|
||||
"Please update the plugin and publish a new version before the next OpenClaw release.",
|
||||
"",
|
||||
"—ClawHub",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(email.text).toContain(" legacy before_agent_start hook is deprecated");
|
||||
expect(email.text).toContain(" Fix:");
|
||||
expect(email.text).toContain(
|
||||
" Replace the legacy before_agent_start hook with current prompt hooks.",
|
||||
);
|
||||
expect(email.text).toContain(" Docs:");
|
||||
expect(email.text).toContain(
|
||||
" https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
|
||||
);
|
||||
expect(email.text).not.toContain("ClawHub Security");
|
||||
expect(email.html).toContain("Validate a local fix");
|
||||
expect(email.html).toContain("Plugin Review");
|
||||
expect(email.html).not.toContain("Open ClawHub");
|
||||
expect(email.html).not.toContain('href="https://clawhub.ai" style="display:inline-block');
|
||||
expect(email.html).not.toContain("You'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("<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 <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 <path-to-plugin> --openclaw-version 0.10.0",
|
||||
);
|
||||
expect(email.text).toContain("- **ERROR** `missing-expected-seam` (compatibility-error, P0)");
|
||||
expect(email.text).not.toContain("Your plugin was published");
|
||||
expect(email.text).not.toContain("was published, but");
|
||||
expect(email.text).not.toContain("Some findings are errors");
|
||||
expect(email.text).not.toContain("nightly");
|
||||
expect(email.html).toContain("missing-expected-seam");
|
||||
expect(email.html).toContain("compatibility-error · P0");
|
||||
});
|
||||
|
||||
it("does not rewrite inserted package names, versions, or issue counts", async () => {
|
||||
const findings = Array.from({ length: 11 }, (_, index) => ({
|
||||
findingKind: "warning" as const,
|
||||
code: `finding-${index + 1}`,
|
||||
issueClass: "compatibility-warning",
|
||||
severity: "P2",
|
||||
message: "review finding",
|
||||
}));
|
||||
const email = await buildPackageInspectorFindingsEmail({
|
||||
packageName: "my-demo-plugin",
|
||||
version: "1.0.0-beta",
|
||||
findings,
|
||||
});
|
||||
|
||||
expect(email.text).toContain("We found 11 issues with version 1.0.0-beta of my-demo-plugin.");
|
||||
expect(email.html).toContain("11 issues found");
|
||||
expect(email.html).toContain("my-demo-plugin@1.0.0-beta");
|
||||
expect(email.html).not.toContain("my-my-demo-plugin");
|
||||
expect(email.html).not.toContain("1.0.0-beta-beta");
|
||||
expect(email.html).not.toContain("11 issueses");
|
||||
expect(email.html).toContain(`href="${validationUrl}"`);
|
||||
expect(email.html).toContain("Review the validation errors");
|
||||
expect(email.html).not.toContain("legacy-before-agent-start");
|
||||
expect(email.html).not.toContain("Email preferences");
|
||||
});
|
||||
|
||||
it("builds publisher abuse warning emails with a deadline and Discord maintainer escalation", async () => {
|
||||
|
||||
+24
-96
@@ -94,27 +94,11 @@ export type SecretBlockedPublishEmailArgs = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type PackageInspectorEmailFinding = {
|
||||
findingKind: "warning" | "error";
|
||||
code: string;
|
||||
issueClass?: string;
|
||||
level?: string;
|
||||
severity?: string;
|
||||
message: string;
|
||||
authorRemediation?: {
|
||||
summary: string;
|
||||
docsUrl?: string;
|
||||
};
|
||||
inspectorVersion?: string;
|
||||
targetOpenClawVersion?: string;
|
||||
scanSource?: "publish" | "nightly";
|
||||
};
|
||||
|
||||
export type PackageInspectorFindingsEmailArgs = {
|
||||
handle?: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
findings: PackageInspectorEmailFinding[];
|
||||
validationUrl: string;
|
||||
};
|
||||
|
||||
export type PublisherAbuseWarningScore = {
|
||||
@@ -324,11 +308,6 @@ async function renderSecretBlockedPublishTemplate(args: {
|
||||
return rendered.html;
|
||||
}
|
||||
|
||||
function buildPluginValidateCommand(openClawVersion?: string) {
|
||||
const command = "clawhub package validate <path-to-plugin>";
|
||||
return openClawVersion ? `${command} --openclaw-version ${openClawVersion}` : command;
|
||||
}
|
||||
|
||||
function normalizeEmailFindingSummary(value: string | undefined) {
|
||||
const normalized = value?.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return undefined;
|
||||
@@ -535,67 +514,30 @@ export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishE
|
||||
}
|
||||
|
||||
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
|
||||
const targetOpenClawVersions = Array.from(
|
||||
new Set(
|
||||
args.findings
|
||||
.map((finding) => finding.targetOpenClawVersion?.trim())
|
||||
.filter((target): target is string => Boolean(target)),
|
||||
),
|
||||
);
|
||||
const validateCommands = targetOpenClawVersions.length
|
||||
? targetOpenClawVersions.map(buildPluginValidateCommand)
|
||||
: [buildPluginValidateCommand()];
|
||||
const subject = `Plugin Inspector findings for ${args.packageName}@${args.version}`;
|
||||
const findingCount = args.findings.length;
|
||||
const intro = `We found ${findingCount} ${findingCount === 1 ? "issue" : "issues"} with version ${args.version} of ${args.packageName}.`;
|
||||
const nextSteps = [
|
||||
"Address the findings below in your plugin package.",
|
||||
"Run the validation command locally against your changes.",
|
||||
"When validation passes, upload a new version.",
|
||||
];
|
||||
const findingLines = formatPackageInspectorFindingsText(args.findings);
|
||||
const metadataLines = [
|
||||
`Plugin: ${args.packageName}@${args.version}`,
|
||||
targetOpenClawVersions.length
|
||||
? `OpenClaw Version${targetOpenClawVersions.length === 1 ? "" : "s"}: ${targetOpenClawVersions.join(", ")}`
|
||||
: null,
|
||||
].filter((line): line is string => line !== null);
|
||||
const subject = `Update required: ${args.packageName} will break in an upcoming OpenClaw release`;
|
||||
const intro = `ClawHub validated ${args.packageName}@${args.version} against the upcoming OpenClaw release.`;
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
intro,
|
||||
"",
|
||||
...metadataLines,
|
||||
"The plugin uses an import, API, or hook that will no longer be available. If unchanged, the affected functionality will fail when users upgrade OpenClaw.",
|
||||
"",
|
||||
"Next steps:",
|
||||
...nextSteps.map((item) => `- ${item}`),
|
||||
`Review the validation errors: ${args.validationUrl}`,
|
||||
"",
|
||||
"Findings:",
|
||||
...findingLines,
|
||||
"Your plugin page includes the exact errors, affected files, tested OpenClaw version, reproduction command, and fix guidance when available.",
|
||||
"",
|
||||
"Validate a local fix:",
|
||||
...validateCommands,
|
||||
"Please update the plugin and publish a new version before the next OpenClaw release.",
|
||||
"",
|
||||
"—ClawHub",
|
||||
];
|
||||
|
||||
const { renderPluginInspectorFindingsEmail } = await import("./emailRendering");
|
||||
const rendered = await renderPluginInspectorFindingsEmail({
|
||||
owner: args.handle?.trim() || "there",
|
||||
packageName: args.packageName,
|
||||
version: args.version,
|
||||
...(targetOpenClawVersions.length
|
||||
? { openClawVersion: targetOpenClawVersions.join(", ") }
|
||||
: {}),
|
||||
findings: args.findings.map((finding) => ({
|
||||
code: finding.code,
|
||||
kind: finding.findingKind,
|
||||
meta: [finding.code, finding.issueClass, finding.severity].filter(Boolean).join(" · "),
|
||||
message: finding.message,
|
||||
...(finding.targetOpenClawVersion
|
||||
? { targetOpenClawVersion: finding.targetOpenClawVersion }
|
||||
: {}),
|
||||
...(finding.authorRemediation?.summary ? { fix: finding.authorRemediation.summary } : {}),
|
||||
...(finding.authorRemediation?.docsUrl ? { docsUrl: finding.authorRemediation.docsUrl } : {}),
|
||||
})),
|
||||
validateCommands,
|
||||
validationUrl: args.validationUrl,
|
||||
preheader: intro,
|
||||
});
|
||||
|
||||
@@ -606,6 +548,19 @@ export async function buildPackageInspectorFindingsEmail(args: PackageInspectorF
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPackageInspectorValidationUrl(packageName: string) {
|
||||
const normalized = packageName.trim();
|
||||
if (normalized.startsWith("@")) {
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex > 1 && slashIndex < normalized.length - 1) {
|
||||
const owner = normalized.slice(1, slashIndex);
|
||||
const name = normalized.slice(slashIndex + 1);
|
||||
return `https://clawhub.ai/${encodeURIComponent(owner)}/plugins/${encodeURIComponent(name)}#validation`;
|
||||
}
|
||||
}
|
||||
return `https://clawhub.ai/plugins/${encodeURIComponent(normalized)}#validation`;
|
||||
}
|
||||
|
||||
export async function buildPublisherAbuseWarningEmail(args: PublisherAbuseWarningEmailArgs) {
|
||||
const publisherHandle = args.publisherHandle.trim().replace(/^@+/, "");
|
||||
const publisherLabel = publisherHandle ? `@${publisherHandle}` : "your publisher";
|
||||
@@ -678,30 +633,3 @@ export async function buildAdminOneOffEmail(args: AdminOneOffEmailArgs) {
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPackageInspectorFindingsText(findings: PackageInspectorEmailFinding[]) {
|
||||
if (findings.length === 0) return ["- No findings were included."];
|
||||
return findings.flatMap((finding) => {
|
||||
const lines = [
|
||||
`- **${finding.findingKind.toUpperCase()}** \`${finding.code}\`${formatFindingMetaText(finding)}`,
|
||||
` ${finding.message}`,
|
||||
];
|
||||
if (finding.targetOpenClawVersion) {
|
||||
lines.push(` OpenClaw target: ${finding.targetOpenClawVersion}`);
|
||||
}
|
||||
if (finding.authorRemediation?.summary) {
|
||||
lines.push(" Fix:");
|
||||
lines.push(` ${finding.authorRemediation.summary}`);
|
||||
if (finding.authorRemediation.docsUrl) {
|
||||
lines.push(" Docs:");
|
||||
lines.push(` ${finding.authorRemediation.docsUrl}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
}
|
||||
|
||||
function formatFindingMetaText(finding: PackageInspectorEmailFinding) {
|
||||
const meta = [finding.issueClass, finding.severity].filter(Boolean).join(", ");
|
||||
return meta ? ` (${meta})` : "";
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -11835,7 +11835,7 @@ describe("packages public queries", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: true });
|
||||
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: false });
|
||||
|
||||
expect(insert).toHaveBeenCalledTimes(1);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
@@ -12002,7 +12002,7 @@ describe("packages public queries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not notify for preserved publish findings after a clean nightly scan", async () => {
|
||||
it("does not notify for a warning-only nightly result with a preserved publish error", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(),
|
||||
@@ -12014,6 +12014,7 @@ describe("packages public queries", () => {
|
||||
{
|
||||
_id: "packageInspectorWarnings:publish",
|
||||
scanSource: "publish",
|
||||
findingKind: "error",
|
||||
code: "manifest-name-missing",
|
||||
message: "manifest name is missing",
|
||||
authorRemediation: { summary: "Add a manifest name." },
|
||||
@@ -12048,9 +12049,17 @@ describe("packages public queries", () => {
|
||||
inspectorVersion: "0.6.0",
|
||||
targetOpenClawVersion: "2026.8.0-beta.1",
|
||||
notifyOwners: true,
|
||||
findings: [],
|
||||
findings: [
|
||||
{
|
||||
id: "demo:deprecated-api",
|
||||
code: "deprecated-api",
|
||||
level: "warning",
|
||||
message: "The API is deprecated but still available.",
|
||||
authorRemediation: { summary: "Migrate before a future release." },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, inserted: 0, shouldEmailOwner: false });
|
||||
).resolves.toEqual({ ok: true, inserted: 1, shouldEmailOwner: false });
|
||||
});
|
||||
|
||||
it("records a clean beta scan identity and completes a no-op notification", async () => {
|
||||
@@ -12242,6 +12251,7 @@ describe("packages public queries", () => {
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.5.0",
|
||||
targetOpenClawVersion: "0.10.0",
|
||||
notifyOwners: true,
|
||||
findings: [
|
||||
{
|
||||
id: "demo:runtime-tool-capture",
|
||||
@@ -12283,7 +12293,7 @@ describe("packages public queries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retries package inspector finding emails when persisted findings were not notified", async () => {
|
||||
it("stores an exact nightly hard error despite an identical preserved finding", async () => {
|
||||
const insert = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -12292,6 +12302,7 @@ describe("packages public queries", () => {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([
|
||||
{
|
||||
findingKind: "error",
|
||||
inspectorFindingId: "demo:legacy-before-agent-start",
|
||||
code: "legacy-before-agent-start",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
@@ -12334,6 +12345,7 @@ describe("packages public queries", () => {
|
||||
{
|
||||
id: "demo:legacy-before-agent-start",
|
||||
code: "legacy-before-agent-start",
|
||||
level: "breakage",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
evidence: ["src/index.ts:4"],
|
||||
fixture: "demo",
|
||||
@@ -12345,9 +12357,17 @@ describe("packages public queries", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, inserted: 0, shouldEmailOwner: true });
|
||||
).resolves.toMatchObject({ ok: true, inserted: 1, shouldEmailOwner: true });
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageInspectorWarnings",
|
||||
expect.objectContaining({
|
||||
findingKind: "error",
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.5.0",
|
||||
targetOpenClawVersion: "0.10.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes plugin inspector validation findings for signed-out viewers", async () => {
|
||||
@@ -12614,7 +12634,7 @@ describe("packages public queries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes internal package inspector findings from owner emails", async () => {
|
||||
it("excludes internal and warning-only findings from owner emails", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
@@ -12668,6 +12688,19 @@ describe("packages public queries", () => {
|
||||
},
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
_id: "packageInspectorWarnings:error",
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
findingKind: "error",
|
||||
code: "missing-api",
|
||||
issueClass: "compatibility-error",
|
||||
message: "an API is unavailable in the upcoming OpenClaw release",
|
||||
authorRemediation: {
|
||||
summary: "Use the replacement API.",
|
||||
},
|
||||
createdAt: 0,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
@@ -12690,12 +12723,48 @@ describe("packages public queries", () => {
|
||||
releaseId: "packageReleases:demo-1",
|
||||
});
|
||||
expect(result?.packageName).toBe("demo-plugin");
|
||||
expect(result?.findings.map((finding) => finding.code)).toEqual(["legacy-before-agent-start"]);
|
||||
expect(result?.findings.map((finding) => finding.code)).toEqual(["missing-api"]);
|
||||
expect(result?.findings[0]?.authorRemediation).toMatchObject({
|
||||
summary: "Move prompt mutation work to before_prompt_build.",
|
||||
summary: "Use the replacement API.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a notification request whose release does not belong to the package", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") {
|
||||
return makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
});
|
||||
}
|
||||
if (id === "packageReleases:other") {
|
||||
return makeReleaseDoc({
|
||||
_id: "packageReleases:other",
|
||||
packageId: "packages:other",
|
||||
version: "1.0.0",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => {
|
||||
throw new Error("Mismatched releases must be rejected before querying findings");
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
getPackageInspectorEmailContextInternalHandler(ctx as never, {
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:other",
|
||||
inspectorVersion: "0.3.20",
|
||||
targetOpenClawVersion: "2026.8.0-beta.1",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("builds email context for a new exact target despite an older release notification", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -12727,6 +12796,7 @@ describe("packages public queries", () => {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
findingKind: "error",
|
||||
code: "missing-api",
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.6.0",
|
||||
@@ -12794,6 +12864,7 @@ describe("packages public queries", () => {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
findingKind: "error",
|
||||
code: "missing-api",
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.6.0",
|
||||
|
||||
+62
-16
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,98 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Badge,
|
||||
ClawHubEmailLayout,
|
||||
CodeBox,
|
||||
DetailTable,
|
||||
EmailHeading,
|
||||
FindingCard,
|
||||
Paragraph,
|
||||
type FindingCardProps,
|
||||
} from "./_components/clawhub";
|
||||
|
||||
export type PluginInspectorFindingEmailItem = FindingCardProps & {
|
||||
code: string;
|
||||
targetOpenClawVersion?: string;
|
||||
};
|
||||
import { Body, Button, Container, Head, Html, Text } from "@react-email/components";
|
||||
|
||||
export type PluginInspectorFindingsEmailProps = {
|
||||
owner: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
openClawVersion?: string;
|
||||
findings: PluginInspectorFindingEmailItem[];
|
||||
validateCommands: string[];
|
||||
validationUrl: string;
|
||||
preheader: string;
|
||||
};
|
||||
|
||||
export default function PluginInspectorFindingsEmail({
|
||||
owner,
|
||||
packageName,
|
||||
version,
|
||||
openClawVersion,
|
||||
findings,
|
||||
validateCommands,
|
||||
preheader,
|
||||
validationUrl,
|
||||
}: PluginInspectorFindingsEmailProps) {
|
||||
const issueText = `${findings.length} ${findings.length === 1 ? "issue" : "issues"}`;
|
||||
return (
|
||||
<ClawHubEmailLayout preview={preheader} railLabel="Plugin Review">
|
||||
<Badge>{`${issueText} found`}</Badge>
|
||||
<EmailHeading>Plugin Inspector findings</EmailHeading>
|
||||
<Paragraph>{`We found ${issueText} with version ${version} of ${packageName}.`}</Paragraph>
|
||||
<DetailTable
|
||||
rows={[
|
||||
["Plugin", `${packageName}@${version}`],
|
||||
["OpenClaw Version", openClawVersion ?? "current"],
|
||||
]}
|
||||
/>
|
||||
<HeadingLabel>Findings</HeadingLabel>
|
||||
{findings.map((finding, index) => (
|
||||
<FindingCard
|
||||
key={`${finding.code}:${finding.targetOpenClawVersion ?? index}`}
|
||||
{...finding}
|
||||
meta={
|
||||
finding.targetOpenClawVersion
|
||||
? `${finding.meta} · OpenClaw ${finding.targetOpenClawVersion}`
|
||||
: finding.meta
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<HeadingLabel>Validate a local fix</HeadingLabel>
|
||||
{validateCommands.map((command) => (
|
||||
<CodeBox key={command}>{command}</CodeBox>
|
||||
))}
|
||||
</ClawHubEmailLayout>
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<Body style={bodyStyle}>
|
||||
<Container style={containerStyle}>
|
||||
<Text style={paragraphStyle}>{`Hi ${owner},`}</Text>
|
||||
<Text style={paragraphStyle}>
|
||||
{`ClawHub validated ${packageName}@${version} against the upcoming OpenClaw release.`}
|
||||
</Text>
|
||||
<Text style={paragraphStyle}>
|
||||
The plugin uses an import, API, or hook that will no longer be available. If unchanged,
|
||||
the affected functionality will fail when users upgrade OpenClaw.
|
||||
</Text>
|
||||
<Button href={validationUrl} style={buttonStyle}>
|
||||
Review the validation errors
|
||||
</Button>
|
||||
<Text style={paragraphStyle}>
|
||||
Your plugin page includes the exact errors, affected files, tested OpenClaw version,
|
||||
reproduction command, and fix guidance when available.
|
||||
</Text>
|
||||
<Text style={paragraphStyle}>
|
||||
Please update the plugin and publish a new version before the next OpenClaw release.
|
||||
</Text>
|
||||
<Text style={paragraphStyle}>—ClawHub</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
style={{
|
||||
margin: "28px 0 14px",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
fontSize: "16px",
|
||||
color: "#f5f5f5",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
const bodyStyle = { margin: 0, padding: "32px 16px", backgroundColor: "#0a0a0b" };
|
||||
const containerStyle = {
|
||||
width: "600px",
|
||||
maxWidth: "600px",
|
||||
padding: "36px",
|
||||
backgroundColor: "#141416",
|
||||
border: "1px solid #26262a",
|
||||
borderRadius: "14px",
|
||||
};
|
||||
const paragraphStyle = {
|
||||
margin: "0 0 18px",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
fontSize: "15px",
|
||||
lineHeight: "23px",
|
||||
color: "#f5f5f5",
|
||||
};
|
||||
const buttonStyle = {
|
||||
display: "inline-block",
|
||||
margin: "0 0 18px",
|
||||
padding: "12px 18px",
|
||||
backgroundColor: "#e8443a",
|
||||
borderRadius: "8px",
|
||||
color: "#ffffff",
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
PluginInspectorFindingsEmail.PreviewProps = {
|
||||
owner: "octocat",
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
openClawVersion: "2026.4.0",
|
||||
validateCommands: ["clawhub package validate <path-to-plugin>"],
|
||||
preheader: "Plugin Inspector found 1 issue with demo-plugin@1.0.0.",
|
||||
findings: [
|
||||
{
|
||||
code: "legacy-before-agent-start",
|
||||
kind: "warning",
|
||||
meta: "legacy-before-agent-start · deprecation-warning · P2",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
fix: "Replace the legacy before_agent_start hook with current prompt hooks.",
|
||||
docsUrl: "https://docs.openclaw.ai/clawhub/plugin-validation-fixes#legacy-before-agent-start",
|
||||
},
|
||||
],
|
||||
validationUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
|
||||
preheader: "ClawHub validated demo-plugin@1.0.0 against the upcoming OpenClaw release.",
|
||||
} satisfies PluginInspectorFindingsEmailProps;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.7.0",
|
||||
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#c8c755c4279b43a31deeb80b8fae36165e0dd058",
|
||||
"@openclaw/plugin-inspector": "0.3.20",
|
||||
"arktype": "2.2.3",
|
||||
"commander": "15.0.0",
|
||||
"croner": "10.0.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
acknowledgeBatch,
|
||||
downloadPackageArtifactForScan,
|
||||
loadNotificationManifest,
|
||||
prepareExtractedPluginRoot,
|
||||
prepareBulkOpenClawTarget,
|
||||
resolveNightlyOpenClawTarget,
|
||||
@@ -424,6 +425,76 @@ describe("package-inspector-nightly-scan", () => {
|
||||
expect(renderImpactMarkdown(summary)).toContain("- Skipped unchanged releases: 1");
|
||||
});
|
||||
|
||||
it("loads only hard-error releases from an exact completed no-email scan", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "clawhub-inspector-notification-manifest-"));
|
||||
temporaryRoots.push(root);
|
||||
const manifestPath = path.join(root, "run-summary.json");
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
dryRun: false,
|
||||
notificationOnly: false,
|
||||
notifyOwners: false,
|
||||
truncated: false,
|
||||
nextCursor: null,
|
||||
inspectorVersion: "0.3.20",
|
||||
targetOpenClawVersion: "2026.8.0-beta.1",
|
||||
packages: [
|
||||
{
|
||||
packageId: "packages:error",
|
||||
releaseId: "packageReleases:error",
|
||||
packageName: "error-plugin",
|
||||
version: "1.0.0",
|
||||
errorCount: 1,
|
||||
},
|
||||
{
|
||||
packageId: "packages:warning",
|
||||
releaseId: "packageReleases:warning",
|
||||
packageName: "warning-plugin",
|
||||
version: "2.0.0",
|
||||
errorCount: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadNotificationManifest(manifestPath, "0.3.20")).resolves.toEqual({
|
||||
targetOpenClawVersion: "2026.8.0-beta.1",
|
||||
items: [
|
||||
{
|
||||
packageId: "packages:error",
|
||||
releaseId: "packageReleases:error",
|
||||
packageName: "error-plugin",
|
||||
version: "1.0.0",
|
||||
downloadUrl: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects notification manifests that do not prove a completed no-email scan", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "clawhub-inspector-notification-manifest-"));
|
||||
temporaryRoots.push(root);
|
||||
const manifestPath = path.join(root, "run-summary.json");
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
dryRun: false,
|
||||
notificationOnly: false,
|
||||
notifyOwners: true,
|
||||
truncated: false,
|
||||
nextCursor: null,
|
||||
inspectorVersion: "0.3.20",
|
||||
targetOpenClawVersion: "2026.8.0-beta.1",
|
||||
packages: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadNotificationManifest(manifestPath, "0.3.20")).rejects.toThrow(
|
||||
"completed no-email production scan",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves and prepares the beta target once for reuse across the bulk run", async () => {
|
||||
const resolved = { requestedVersion: "beta", version: "2026.8.0-beta.1" };
|
||||
const prepared = { ...resolved, status: "ok", cache: { hit: false, key: "beta-key" } };
|
||||
|
||||
@@ -45,6 +45,8 @@ type NormalizedFinding = {
|
||||
};
|
||||
|
||||
type ImpactEntry = {
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
ownerUserId?: string;
|
||||
@@ -62,6 +64,12 @@ type UploadResult = {
|
||||
shouldEmailOwner: boolean;
|
||||
};
|
||||
|
||||
type NotificationResult = {
|
||||
ok: true;
|
||||
sent: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type PluginInspectorModule = {
|
||||
openClawTargets?: {
|
||||
resolveVersion: (requestedVersion: string) => Promise<Record<string, unknown>>;
|
||||
@@ -89,6 +97,8 @@ const token = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN;
|
||||
const batchSize = process.env.PLUGIN_INSPECTOR_BATCH_SIZE ?? "25";
|
||||
const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN);
|
||||
const notifyOwners = parseBoolean(process.env.PLUGIN_INSPECTOR_NOTIFY_OWNERS);
|
||||
const notificationOnly = parseBoolean(process.env.PLUGIN_INSPECTOR_NOTIFICATION_ONLY);
|
||||
const notificationManifestPath = process.env.PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST?.trim();
|
||||
const targetPackageNames = parsePackageNames(process.env.PLUGIN_INSPECTOR_PACKAGE_NAMES);
|
||||
const dryRunMaxBatches = Math.max(
|
||||
1,
|
||||
@@ -137,26 +147,60 @@ export async function prepareBulkOpenClawTarget(
|
||||
|
||||
export async function runPackageInspectorNightlyScan() {
|
||||
if (!token) throw new Error("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN is required");
|
||||
if (notificationOnly && !notifyOwners) {
|
||||
throw new Error("PLUGIN_INSPECTOR_NOTIFICATION_ONLY requires owner notifications");
|
||||
}
|
||||
if (notifyOwners && !notificationOnly) {
|
||||
throw new Error("Owner notifications require notification-only mode");
|
||||
}
|
||||
if (notificationOnly && dryRun) {
|
||||
throw new Error("Notification-only mode cannot run as a dry run");
|
||||
}
|
||||
if (notificationOnly && targetPackageNames.length > 0) {
|
||||
throw new Error(
|
||||
"Notification-only mode requires the reviewed scan manifest, not package names",
|
||||
);
|
||||
}
|
||||
|
||||
const inspectorVersion = getInspectorVersion();
|
||||
const inspectorModule =
|
||||
(await import("@openclaw/plugin-inspector")) as unknown as PluginInspectorModule;
|
||||
const requestedOpenClawVersion = resolveNightlyOpenClawTarget(
|
||||
process.env.PLUGIN_INSPECTOR_OPENCLAW_VERSION,
|
||||
);
|
||||
const preparedTarget = await prepareBulkOpenClawTarget(requestedOpenClawVersion, inspectorModule);
|
||||
const notificationManifest = notificationOnly
|
||||
? await loadNotificationManifest(notificationManifestPath, inspectorVersion)
|
||||
: null;
|
||||
const preparedTarget = notificationManifest
|
||||
? null
|
||||
: await prepareBulkOpenClawTarget(
|
||||
resolveNightlyOpenClawTarget(process.env.PLUGIN_INSPECTOR_OPENCLAW_VERSION),
|
||||
inspectorModule,
|
||||
);
|
||||
const targetOpenClawVersion =
|
||||
notificationManifest?.targetOpenClawVersion ?? preparedTarget?.exactVersion;
|
||||
if (!targetOpenClawVersion) throw new Error("Unable to resolve an exact OpenClaw target");
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const scanStartedAt = Date.now();
|
||||
|
||||
let hadWorkerFailure = false;
|
||||
const impactEntries: ImpactEntry[] = [];
|
||||
let claimed = 0;
|
||||
let scanned = 0;
|
||||
let skippedUnchanged = 0;
|
||||
let notificationAttempts = 0;
|
||||
let notificationsSent = 0;
|
||||
let cursor: string | null = null;
|
||||
let batches = 0;
|
||||
let truncated = false;
|
||||
|
||||
if (targetPackageNames.length > 0) {
|
||||
if (notificationManifest) {
|
||||
batches = 1;
|
||||
claimed = notificationManifest.items.length;
|
||||
for (const item of notificationManifest.items) {
|
||||
const result = await notifyPackageItem(item, inspectorVersion, targetOpenClawVersion);
|
||||
if (result.failed) hadWorkerFailure = true;
|
||||
if (result.notificationAttempted) notificationAttempts += 1;
|
||||
if (result.notificationSent) notificationsSent += 1;
|
||||
}
|
||||
} else if (preparedTarget && targetPackageNames.length > 0) {
|
||||
const items = await resolveTargetPackageItems(targetPackageNames);
|
||||
batches = 1;
|
||||
claimed = items.length;
|
||||
@@ -170,16 +214,13 @@ export async function runPackageInspectorNightlyScan() {
|
||||
if (result.failed) hadWorkerFailure = true;
|
||||
if (result.impactEntry) impactEntries.push(result.impactEntry);
|
||||
if (result.scanned) scanned += 1;
|
||||
if (result.notificationAttempted) notificationAttempts += 1;
|
||||
if (result.notificationSent) notificationsSent += 1;
|
||||
}
|
||||
} else {
|
||||
} else if (preparedTarget) {
|
||||
do {
|
||||
const claimCursor = cursor;
|
||||
const claim = await claimBatch(
|
||||
cursor,
|
||||
inspectorVersion,
|
||||
preparedTarget.exactVersion,
|
||||
scanRunId,
|
||||
);
|
||||
const claim = await claimBatch(cursor, inspectorVersion, targetOpenClawVersion, scanRunId);
|
||||
if (claim.leased) {
|
||||
throw new Error("Plugin Inspector bulk scan lease is owned by another run");
|
||||
}
|
||||
@@ -202,6 +243,8 @@ export async function runPackageInspectorNightlyScan() {
|
||||
}
|
||||
if (result.impactEntry) impactEntries.push(result.impactEntry);
|
||||
if (result.scanned) scanned += 1;
|
||||
if (result.notificationAttempted) notificationAttempts += 1;
|
||||
if (result.notificationSent) notificationsSent += 1;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
@@ -220,8 +263,11 @@ export async function runPackageInspectorNightlyScan() {
|
||||
}
|
||||
}
|
||||
} while (cursor);
|
||||
} else {
|
||||
throw new Error("The scan target was not prepared");
|
||||
}
|
||||
|
||||
const scanDurationMs = Date.now() - scanStartedAt;
|
||||
const summary = summarizeImpact({
|
||||
claimed,
|
||||
scanned,
|
||||
@@ -230,7 +276,13 @@ export async function runPackageInspectorNightlyScan() {
|
||||
truncated,
|
||||
nextCursor: cursor,
|
||||
inspectorVersion,
|
||||
targetOpenClawVersion: preparedTarget.exactVersion,
|
||||
targetOpenClawVersion,
|
||||
notifyOwners,
|
||||
notificationOnly,
|
||||
notificationAttempts,
|
||||
notificationsSent,
|
||||
scanStartedAt: new Date(scanStartedAt).toISOString(),
|
||||
scanDurationMs,
|
||||
entries: impactEntries,
|
||||
});
|
||||
await writeFile(
|
||||
@@ -246,7 +298,9 @@ export async function runPackageInspectorNightlyScan() {
|
||||
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
|
||||
}
|
||||
console.log(
|
||||
`Bulk scan target OpenClaw ${summary.targetOpenClawVersion}: scanned=${summary.scannedReleases}, skippedUnchanged=${summary.skippedUnchangedReleases}, errors=${summary.pluginsWithErrors}, warnings=${summary.pluginsWithWarnings}.`,
|
||||
notificationOnly
|
||||
? `Notification phase for OpenClaw ${summary.targetOpenClawVersion}: attempted=${summary.notificationAttempts}, sent=${summary.notificationsSent}, skipped=${summary.skippedUnchangedReleases}.`
|
||||
: `Bulk scan target OpenClaw ${summary.targetOpenClawVersion}: scanned=${summary.scannedReleases}, skippedUnchanged=${summary.skippedUnchangedReleases}, errors=${summary.pluginsWithErrors}, warnings=${summary.pluginsWithWarnings}.`,
|
||||
);
|
||||
|
||||
if (hadWorkerFailure) {
|
||||
@@ -254,6 +308,112 @@ export async function runPackageInspectorNightlyScan() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNotificationManifest(
|
||||
manifestPath: string | undefined,
|
||||
expectedInspectorVersion: string,
|
||||
) {
|
||||
if (!manifestPath) {
|
||||
throw new Error(
|
||||
"PLUGIN_INSPECTOR_NOTIFICATION_MANIFEST is required for notification-only mode",
|
||||
);
|
||||
}
|
||||
const raw = JSON.parse(await readFile(manifestPath, "utf8")) as unknown;
|
||||
if (!isPlainObject(raw)) throw new Error("Notification manifest must be a JSON object");
|
||||
if (
|
||||
raw.dryRun !== false ||
|
||||
raw.notificationOnly !== false ||
|
||||
raw.notifyOwners !== false ||
|
||||
raw.truncated !== false ||
|
||||
raw.nextCursor !== null
|
||||
) {
|
||||
throw new Error("Notification manifest must come from a completed no-email production scan");
|
||||
}
|
||||
const inspectorVersion = stringValue(raw.inspectorVersion);
|
||||
if (inspectorVersion !== expectedInspectorVersion) {
|
||||
throw new Error(
|
||||
`Notification manifest uses Plugin Inspector ${inspectorVersion ?? "unknown"}; expected ${expectedInspectorVersion}`,
|
||||
);
|
||||
}
|
||||
const targetOpenClawVersion = stringValue(raw.targetOpenClawVersion);
|
||||
if (!targetOpenClawVersion) {
|
||||
throw new Error("Notification manifest is missing the exact OpenClaw target version");
|
||||
}
|
||||
if (!Array.isArray(raw.packages)) throw new Error("Notification manifest is missing packages");
|
||||
const items: ClaimItem[] = [];
|
||||
for (const entry of raw.packages) {
|
||||
if (!isPlainObject(entry) || typeof entry.errorCount !== "number" || entry.errorCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const packageId = stringValue(entry.packageId);
|
||||
const releaseId = stringValue(entry.releaseId);
|
||||
const packageName = stringValue(entry.packageName);
|
||||
const version = stringValue(entry.version);
|
||||
if (!packageId || !releaseId || !packageName || !version) {
|
||||
throw new Error("Notification manifest contains an incomplete hard-error release");
|
||||
}
|
||||
items.push({ packageId, releaseId, packageName, version, downloadUrl: "" });
|
||||
}
|
||||
return { targetOpenClawVersion, items };
|
||||
}
|
||||
|
||||
async function notifyPackageItem(
|
||||
item: ClaimItem,
|
||||
inspectorVersion: string,
|
||||
targetOpenClawVersion: string,
|
||||
) {
|
||||
if (dryRun) {
|
||||
return {
|
||||
failed: false,
|
||||
scanned: false,
|
||||
notificationAttempted: false,
|
||||
notificationSent: false,
|
||||
impactEntry: undefined,
|
||||
};
|
||||
}
|
||||
const reportDir = path.resolve(
|
||||
artifactRoot,
|
||||
safeArtifactName(`${item.packageName}-${item.version}`),
|
||||
);
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
try {
|
||||
const result = await postJson<NotificationResult>(
|
||||
`${siteUrl}/api/v1/package-inspector/notify`,
|
||||
{
|
||||
packageId: item.packageId,
|
||||
releaseId: item.releaseId,
|
||||
inspectorVersion,
|
||||
targetOpenClawVersion,
|
||||
},
|
||||
);
|
||||
await writeFile(
|
||||
path.join(reportDir, "notification-result.json"),
|
||||
`${JSON.stringify(result, null, 2)}\n`,
|
||||
);
|
||||
console.log(
|
||||
`Notification ${item.packageName}@${item.version}: sent=${result.sent}${result.reason ? `, reason=${result.reason}` : ""}`,
|
||||
);
|
||||
return {
|
||||
failed: false,
|
||||
scanned: false,
|
||||
notificationAttempted: true,
|
||||
notificationSent: result.sent,
|
||||
impactEntry: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await writeFile(path.join(reportDir, "notification-error.txt"), message);
|
||||
console.error(`Plugin owner notification failed for ${item.packageName}@${item.version}`);
|
||||
console.error(message);
|
||||
return {
|
||||
failed: true,
|
||||
scanned: false,
|
||||
notificationAttempted: true,
|
||||
notificationSent: false,
|
||||
impactEntry: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPackageItem(
|
||||
item: ClaimItem,
|
||||
inspectorVersion: string,
|
||||
@@ -328,6 +488,8 @@ async function inspectPackageItem(
|
||||
return {
|
||||
failed: false,
|
||||
scanned: true,
|
||||
notificationAttempted: false,
|
||||
notificationSent: false,
|
||||
impactEntry: toImpactEntry(item, findings, targetOpenClawVersion),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -335,7 +497,13 @@ async function inspectPackageItem(
|
||||
await writeFile(path.join(reportDir, "error.txt"), message);
|
||||
console.error(`Plugin Inspector bulk scan failed for ${item.packageName}@${item.version}`);
|
||||
console.error(message);
|
||||
return { failed: true, scanned: false, impactEntry: undefined };
|
||||
return {
|
||||
failed: true,
|
||||
scanned: false,
|
||||
notificationAttempted: false,
|
||||
notificationSent: false,
|
||||
impactEntry: undefined,
|
||||
};
|
||||
} finally {
|
||||
await rm(workRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -765,6 +933,8 @@ function toImpactEntry(
|
||||
else warningCount += 1;
|
||||
}
|
||||
return {
|
||||
packageId: item.packageId,
|
||||
releaseId: item.releaseId,
|
||||
packageName: item.packageName,
|
||||
version: item.version,
|
||||
ownerUserId: item.ownerUserId,
|
||||
@@ -790,6 +960,12 @@ export function summarizeImpact(args: {
|
||||
nextCursor: string | null;
|
||||
inspectorVersion: string;
|
||||
targetOpenClawVersion?: string;
|
||||
notifyOwners?: boolean;
|
||||
notificationOnly?: boolean;
|
||||
notificationAttempts?: number;
|
||||
notificationsSent?: number;
|
||||
scanStartedAt?: string;
|
||||
scanDurationMs?: number;
|
||||
entries: ImpactEntry[];
|
||||
}) {
|
||||
const impactedOwners = new Set<string>();
|
||||
@@ -826,6 +1002,16 @@ export function summarizeImpact(args: {
|
||||
siteUrl,
|
||||
inspectorVersion: args.inspectorVersion,
|
||||
targetOpenClawVersion: args.targetOpenClawVersion,
|
||||
notifyOwners: args.notifyOwners ?? false,
|
||||
notificationOnly: args.notificationOnly ?? false,
|
||||
notificationAttempts: args.notificationAttempts ?? 0,
|
||||
notificationsSent: args.notificationsSent ?? 0,
|
||||
scanStartedAt: args.scanStartedAt ?? new Date().toISOString(),
|
||||
scanDurationMs: args.scanDurationMs ?? 0,
|
||||
scanThroughputReleasesPerSecond:
|
||||
args.scanDurationMs && args.scanDurationMs > 0
|
||||
? args.scanned / (args.scanDurationMs / 1000)
|
||||
: 0,
|
||||
batchSize: Number.parseInt(batchSize, 10) || batchSize,
|
||||
batches: args.batches,
|
||||
truncated: args.truncated,
|
||||
@@ -856,7 +1042,12 @@ export function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>
|
||||
`- Site: ${summary.siteUrl}`,
|
||||
`- Inspector: ${summary.inspectorVersion}`,
|
||||
`- Target OpenClaw: ${summary.targetOpenClawVersion ?? "unknown"}`,
|
||||
`- Mode: ${summary.notificationOnly ? "notification-only" : "scan"}`,
|
||||
`- Scanned latest releases: ${summary.scannedReleases}`,
|
||||
`- Notification attempts: ${summary.notificationAttempts}`,
|
||||
`- Notifications sent: ${summary.notificationsSent}`,
|
||||
`- Scan wall time: ${(summary.scanDurationMs / 1000).toFixed(3)} seconds`,
|
||||
`- Scan throughput: ${summary.scanThroughputReleasesPerSecond.toFixed(3)} releases/second`,
|
||||
`- Skipped unchanged releases: ${summary.skippedUnchangedReleases}`,
|
||||
`- Plugins with errors: ${summary.pluginsWithErrors}`,
|
||||
`- Plugins with warnings: ${summary.pluginsWithWarnings}`,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user