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
|
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
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
@@ -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'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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
@@ -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})` : "";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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" } };
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user