feat: validate plugins against stable OpenClaw (#3321)

* test: preserve generic plugin inspector remediation

* feat: show exact plugin validation reproduction command

* feat: reproduce plugin findings against exact target

* test: preserve static and compatibility findings

* feat: validate plugins against stable OpenClaw

* test: seed reproducible plugin findings

* chore: pin merged plugin inspector

* fix: preserve mixed validation targets

* fix: show every validation target

* fix: label findings with validation target
This commit is contained in:
Patrick Erichsen
2026-07-30 14:36:26 -07:00
committed by GitHub
parent 2cd6317c00
commit f8901222a4
21 changed files with 612 additions and 63 deletions
+14 -4
View File
@@ -15,7 +15,7 @@
"@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
"@openclaw/plugin-inspector": "0.3.17",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#021c6586af78884d27f1be8acedca492acd9f9ae",
"@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": "0.3.17",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#021c6586af78884d27f1be8acedca492acd9f9ae",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
@@ -404,6 +404,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -452,7 +454,7 @@
"@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.17", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw=="],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@github:openclaw/plugin-inspector#021c658", { "dependencies": { "semver": "^7.8.5", "tar": "^7.5.22" }, "bin": { "plugin-inspector": "src/cli.js" } }, "openclaw-plugin-inspector-021c658", "sha512-Cz3NDscaCxvySTNmBz1mQnImKHWdmk50da1VhZnQYKE/EK3qlV/Z/UpYo/7440EEQJ7TQ90V5Eyr6I2811dp6Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1096,6 +1098,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -1606,6 +1610,8 @@
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1882,6 +1888,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2002,7 +2010,7 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -2146,6 +2154,8 @@
"vitest/vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+8
View File
@@ -1374,12 +1374,20 @@ describe("devSeed local fixtures", () => {
packageName: scannedPluginName,
findingKind: "warning",
code: "legacy-before-agent-start",
targetOpenClawVersion: "2026.3.24-beta.2",
authorRemediation: {
summary: "Replace the legacy before_agent_start hook with the current lifecycle API.",
},
}),
expect.objectContaining({
packageName: scannedPluginName,
findingKind: "error",
code: "missing-expected-seam",
scanSource: "nightly",
targetOpenClawVersion: "2026.4.0",
authorRemediation: {
summary: "Replace registerTool with an API available in the selected OpenClaw version.",
},
}),
]),
);
+6
View File
@@ -3703,6 +3703,9 @@ export async function seedLocalModerationFixturesHandler(
deprecated: true,
message: "legacy before_agent_start hook is deprecated for the current OpenClaw plugin API",
evidence: ["src/index.ts:4", "hook:before_agent_start"],
authorRemediation: {
summary: "Replace the legacy before_agent_start hook with the current lifecycle API.",
},
inspectorFindingId: "local-scanned-runtime-plugin:legacy-before-agent-start",
createdAt: now,
});
@@ -3723,6 +3726,9 @@ export async function seedLocalModerationFixturesHandler(
issueClass: "compatibility-error",
message: "registerTool is no longer available on the target OpenClaw compatibility surface",
evidence: ["src/index.ts:12", "target:OpenClaw 2026.4.0"],
authorRemediation: {
summary: "Replace registerTool with an API available in the selected OpenClaw version.",
},
inspectorFindingId: "local-scanned-runtime-plugin:missing-expected-seam",
createdAt: now + 1,
});
+52 -3
View File
@@ -306,7 +306,9 @@ describe("moderation notification email copy", () => {
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>");
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
);
expect(email.text).toContain(
"- **WARNING** `legacy-before-agent-start` (deprecation-warning, P2)",
);
@@ -330,7 +332,9 @@ describe("moderation notification email copy", () => {
expectFooterLinksUnderlined(email.html);
expect(email.html).toContain("OpenClaw Version");
expect(email.html).toContain("0.9.0");
expect(email.html).toContain("clawhub package validate &lt;path-to-plugin&gt;");
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.9.0",
);
expect(email.html).toContain("legacy-before-agent-start");
expect(email.html).toContain("legacy-before-agent-start · deprecation-warning · P2");
expect(email.html).toContain("Fix");
@@ -350,6 +354,49 @@ describe("moderation notification email copy", () => {
expect(email.html).not.toContain("published successfully");
});
it("includes one exact validation command per recorded OpenClaw target", async () => {
const email = await buildPackageInspectorFindingsEmail({
packageName: "demo-plugin",
version: "1.0.0",
findings: [
{
findingKind: "warning",
code: "legacy-before-agent-start",
message: "legacy hook is deprecated",
targetOpenClawVersion: "0.9.0",
},
{
findingKind: "error",
code: "missing-expected-seam",
message: "registerTool is no longer available",
targetOpenClawVersion: "0.10.0",
},
],
});
expect(email.text).toContain("OpenClaw Versions: 0.9.0, 0.10.0");
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
);
expect(email.text).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
);
expect(email.text).toContain(
"`legacy-before-agent-start`\n legacy hook is deprecated\n OpenClaw target: 0.9.0",
);
expect(email.text).toContain(
"`missing-expected-seam`\n registerTool is no longer available\n OpenClaw target: 0.10.0",
);
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.9.0",
);
expect(email.html).toContain(
"clawhub package validate &lt;path-to-plugin&gt; --openclaw-version 0.10.0",
);
expect(email.html).toContain("legacy-before-agent-start · OpenClaw 0.9.0");
expect(email.html).toContain("missing-expected-seam · OpenClaw 0.10.0");
});
it("builds plugin inspector error copy without publish-time wording", async () => {
const email = await buildPackageInspectorFindingsEmail({
packageName: "demo-plugin",
@@ -372,7 +419,9 @@ describe("moderation notification email copy", () => {
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>");
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");
+27 -10
View File
@@ -324,8 +324,9 @@ async function renderSecretBlockedPublishTemplate(args: {
return rendered.html;
}
function buildPluginValidateCommand() {
return "clawhub package validate <path-to-plugin>";
function buildPluginValidateCommand(openClawVersion?: string) {
const command = "clawhub package validate <path-to-plugin>";
return openClawVersion ? `${command} --openclaw-version ${openClawVersion}` : command;
}
function normalizeEmailFindingSummary(value: string | undefined) {
@@ -534,10 +535,16 @@ export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishE
}
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
const targetOpenClawVersion = args.findings.find(
(finding) => finding.targetOpenClawVersion,
)?.targetOpenClawVersion;
const validateCommand = buildPluginValidateCommand();
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}.`;
@@ -549,7 +556,9 @@ export async function buildPackageInspectorFindingsEmail(args: PackageInspectorF
const findingLines = formatPackageInspectorFindingsText(args.findings);
const metadataLines = [
`Plugin: ${args.packageName}@${args.version}`,
targetOpenClawVersion ? `OpenClaw Version: ${targetOpenClawVersion}` : null,
targetOpenClawVersions.length
? `OpenClaw Version${targetOpenClawVersions.length === 1 ? "" : "s"}: ${targetOpenClawVersions.join(", ")}`
: null,
].filter((line): line is string => line !== null);
const lines = [
greeting(args.handle),
@@ -565,23 +574,28 @@ export async function buildPackageInspectorFindingsEmail(args: PackageInspectorF
...findingLines,
"",
"Validate a local fix:",
validateCommand,
...validateCommands,
];
const { renderPluginInspectorFindingsEmail } = await import("./emailRendering");
const rendered = await renderPluginInspectorFindingsEmail({
packageName: args.packageName,
version: args.version,
...(targetOpenClawVersion ? { openClawVersion: targetOpenClawVersion } : {}),
...(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 } : {}),
})),
validateCommand,
validateCommands,
preheader: intro,
});
@@ -672,6 +686,9 @@ function formatPackageInspectorFindingsText(findings: PackageInspectorEmailFindi
`- **${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}`);
+1
View File
@@ -19,6 +19,7 @@ declare module "@openclaw/plugin-inspector" {
runCheck(options?: {
pluginRoot?: string;
openclawPath?: string | false;
openclawVersion?: string;
outDir?: string;
capture?: boolean;
mockSdk?: boolean;
+109 -1
View File
@@ -1,9 +1,23 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { normalizeInspectorReportForPublish } from "./packageInspectorNode";
import {
buildPublishInspectorRunCheckOptions,
normalizeInspectorReportForPublish,
} from "./packageInspectorNode";
describe("package inspector publish normalization", () => {
it("targets latest stable OpenClaw for publish-time inspection", () => {
expect(buildPublishInspectorRunCheckOptions("/tmp/plugin", "2026-07-30T00:00:00.000Z")).toEqual(
expect.objectContaining({
pluginRoot: "/tmp/plugin",
openclawPath: false,
openclawVersion: "latest",
authorFacing: true,
}),
);
});
it("keeps legacy author-facing hard findings without remediation metadata", () => {
const result = normalizeInspectorReportForPublish({
status: "fail",
@@ -97,4 +111,98 @@ describe("package inspector publish normalization", () => {
],
});
});
it("keeps missing-API findings with generic remediation and no docs URL", () => {
const result = normalizeInspectorReportForPublish({
status: "fail",
summary: { breakageCount: 1, warningCount: 0, issueCount: 1 },
issues: [
{
code: "missing-openclaw-api",
level: "breakage",
issueClass: "compatibility-error",
message: "registerMemoryRuntime is unavailable in the selected OpenClaw target",
authorRemediation: {
summary: "Replace this call with an API available in the selected OpenClaw version.",
},
},
],
});
expect(result.breakages).toEqual([
expect.objectContaining({
code: "missing-openclaw-api",
authorRemediation: {
summary: "Replace this call with an API available in the selected OpenClaw version.",
docsUrl: undefined,
},
}),
]);
});
it("keeps static and compatibility findings with the exact resolved target", () => {
const result = normalizeInspectorReportForPublish({
status: "fail",
targetOpenClaw: {
requested: "beta",
version: "2026.8.0-beta.4",
},
issues: [
{
code: "package-entrypoint-missing",
level: "breakage",
issueClass: "package-integrity",
message: "declared OpenClaw entrypoint does not exist",
},
{
code: "missing-openclaw-api",
level: "breakage",
issueClass: "compatibility-error",
message: "registerMemoryRuntime is unavailable in the selected OpenClaw target",
authorRemediation: {
summary: "Replace this call with an API available in the selected OpenClaw version.",
},
},
],
});
expect(result.metadata.targetOpenClawVersion).toBe("2026.8.0-beta.4");
expect(result.breakages.map((finding) => finding.code)).toEqual([
"package-entrypoint-missing",
"missing-openclaw-api",
]);
});
it("keeps out-of-range compatibility information non-blocking", () => {
const result = normalizeInspectorReportForPublish({
status: "pass",
targetOpenClaw: { version: "2026.7.2-beta.4" },
issues: [
{
code: "unknown-registration-name",
level: "suggestion",
severity: "P2",
issueClass: "compatibility-information",
message:
"plugin calls registrars missing from target OpenClaw outside its declared range",
authorRemediation: {
summary: "Widen the declared range only after updating these registrations.",
},
},
],
});
expect(result).toMatchObject({
status: "pass",
summary: { breakageCount: 0, warningCount: 1 },
breakages: [],
warnings: [
{
code: "unknown-registration-name",
level: "suggestion",
issueClass: "compatibility-information",
},
],
});
});
});
+18 -13
View File
@@ -127,6 +127,23 @@ const inspectorMetadataValidator = v.object({
targetOpenClawVersion: v.optional(v.string()),
});
export function buildPublishInspectorRunCheckOptions(root: string, generatedAt: string) {
return {
pluginRoot: root,
openclawPath: false,
openclawVersion: "latest",
outDir: "reports",
capture: false,
mockSdk: true,
allowExecution: false,
authorFacing: true,
generatedAt,
} as Parameters<(typeof import("@openclaw/plugin-inspector"))["pluginRoot"]["runCheck"]>[0] & {
authorFacing: true;
generatedAt: string;
};
}
export const runPackageInspectorForPublishInternal = internalAction({
args: {
packageName: v.string(),
@@ -164,19 +181,7 @@ export const runPackageInspectorForPublishInternal = internalAction({
await writeSyntheticInspectorConfigIfNeeded(root, args.files, args.packageName);
const { pluginRoot } = await import("@openclaw/plugin-inspector");
const runCheckOptions = {
pluginRoot: root,
openclawPath: false,
outDir: "reports",
capture: false,
mockSdk: true,
allowExecution: false,
authorFacing: true,
generatedAt: new Date().toISOString(),
} as Parameters<typeof pluginRoot.runCheck>[0] & {
authorFacing: true;
generatedAt: string;
};
const runCheckOptions = buildPublishInspectorRunCheckOptions(root, new Date().toISOString());
const { report } = await pluginRoot.runCheck(runCheckOptions);
return normalizeInspectorReportForPublish(report);
+6
View File
@@ -11391,6 +11391,10 @@ describe("packages public queries", () => {
},
},
],
metadata: {
inspectorVersion: "0.3.19",
targetOpenClawVersion: "2026.7.1-2",
},
})),
scheduler: {
runAfter: vi.fn(),
@@ -11475,6 +11479,8 @@ describe("packages public queries", () => {
releaseId: "packageReleases:demo-1",
packageName: "demo-plugin",
version: "1.0.0",
inspectorVersion: "0.3.19",
targetOpenClawVersion: "2026.7.1-2",
findings: [
expect.objectContaining({
code: "legacy-before-agent-start",
+17 -6
View File
@@ -12,6 +12,7 @@ import {
export type PluginInspectorFindingEmailItem = FindingCardProps & {
code: string;
targetOpenClawVersion?: string;
};
export type PluginInspectorFindingsEmailProps = {
@@ -19,7 +20,7 @@ export type PluginInspectorFindingsEmailProps = {
version: string;
openClawVersion?: string;
findings: PluginInspectorFindingEmailItem[];
validateCommand: string;
validateCommands: string[];
preheader: string;
};
@@ -28,7 +29,7 @@ export default function PluginInspectorFindingsEmail({
version,
openClawVersion,
findings,
validateCommand,
validateCommands,
preheader,
}: PluginInspectorFindingsEmailProps) {
const issueText = `${findings.length} ${findings.length === 1 ? "issue" : "issues"}`;
@@ -44,11 +45,21 @@ export default function PluginInspectorFindingsEmail({
]}
/>
<HeadingLabel>Findings</HeadingLabel>
{findings.map((finding) => (
<FindingCard key={finding.code} {...finding} />
{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>
<CodeBox>{validateCommand}</CodeBox>
{validateCommands.map((command) => (
<CodeBox key={command}>{command}</CodeBox>
))}
</ClawHubEmailLayout>
);
}
@@ -72,7 +83,7 @@ PluginInspectorFindingsEmail.PreviewProps = {
packageName: "demo-plugin",
version: "1.0.0",
openClawVersion: "2026.4.0",
validateCommand: "clawhub package validate <path-to-plugin>",
validateCommands: ["clawhub package validate <path-to-plugin>"],
preheader: "Plugin Inspector found 1 issue with demo-plugin@1.0.0.",
findings: [
{
+1 -1
View File
@@ -103,7 +103,7 @@
"@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
"@openclaw/plugin-inspector": "0.3.17",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#021c6586af78884d27f1be8acedca492acd9f9ae",
"@radix-ui/react-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-dropdown-menu": "2.1.21",
+1 -1
View File
@@ -38,7 +38,7 @@
},
"dependencies": {
"@clack/prompts": "1.7.0",
"@openclaw/plugin-inspector": "0.3.17",
"@openclaw/plugin-inspector": "github:openclaw/plugin-inspector#021c6586af78884d27f1be8acedca492acd9f9ae",
"arktype": "2.2.3",
"commander": "15.0.0",
"croner": "10.0.1",
+1
View File
@@ -630,6 +630,7 @@ registerCommand(packageCmd, ["package", "validate"])
.argument("<source>", "Package folder path")
.option("--out <dir>", "Directory for Plugin Inspector reports", "reports")
.option("--openclaw <path>", "Optional local OpenClaw checkout to inspect against")
.option("--openclaw-version <version>", "OpenClaw target: latest, beta, or an exact version")
.option("--runtime", "Enable runtime capture; imports plugin code")
.option("--allow-execute", "Allow runtime capture in an isolated workspace")
.option("--no-mock-sdk", "Disable mocked OpenClaw SDK during runtime capture")
@@ -258,6 +258,7 @@ describe("package commands", () => {
configPath: expect.stringContaining("plugin-inspector.config.json"),
mockSdk: true,
openclawPath: false,
openclawVersion: "latest",
outDir: "reports",
pluginRoot: folder,
authorFacing: true,
@@ -276,6 +277,51 @@ describe("package commands", () => {
}
});
it.each(["latest", "beta", "2026.7.2-beta.4"])(
"validates against the requested OpenClaw target %s",
async (openclawVersion) => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "targeted-plugin");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
'{"name":"targeted-plugin","version":"1.0.0"}\n',
);
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
report: { status: "pass", summary: { breakageCount: 0 } },
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
});
await cmdValidatePackage(makeOpts(workdir), "targeted-plugin", { openclawVersion });
expect(inspectorMocks.pluginRoot.runCheck).toHaveBeenCalledWith(
expect.objectContaining({ openclawVersion }),
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
},
);
it("rejects combining a local OpenClaw checkout with a version target", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "targeted-plugin");
await mkdir(folder, { recursive: true });
await expect(
cmdValidatePackage(makeOpts(workdir), "targeted-plugin", {
openclaw: "../openclaw",
openclawVersion: "latest",
}),
).rejects.toThrow("Choose either --openclaw or --openclaw-version");
expect(inspectorMocks.pluginRoot.runCheck).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("fails package validation when Plugin Inspector reports hard breakages", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -340,6 +386,104 @@ describe("package commands", () => {
}
});
it("keeps missing-API findings with generic remediation and no docs URL", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "removed-api-plugin");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
'{"name":"removed-api-plugin","version":"1.0.0"}\n',
);
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
report: {
status: "fail",
summary: { breakageCount: 1, warningCount: 0, issueCount: 1 },
issues: [
{
code: "missing-openclaw-api",
level: "breakage",
issueClass: "compatibility-error",
message: "registerMemoryRuntime is unavailable in the selected OpenClaw target",
authorRemediation: {
summary:
"Replace this call with an API available in the selected OpenClaw version.",
},
},
],
},
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
});
await expect(cmdValidatePackage(makeOpts(workdir), "removed-api-plugin", {})).rejects.toThrow(
"Plugin Inspector found 1 hard error",
);
const output = mockLog.mock.calls.join("\n");
expect(output).toContain("ERROR missing-openclaw-api (compatibility-error)");
expect(output).toContain(
"Fix: Replace this call with an API available in the selected OpenClaw version.",
);
expect(output).not.toContain("Docs:");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("surfaces Honcho-equivalent removed registrations against the affected exact beta", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "honcho-plugin");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "package.json"), '{"name":"honcho-plugin","version":"1.0.0"}\n');
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
report: {
status: "fail",
targetOpenClaw: {
requestedVersion: "2026.7.2-beta.4",
version: "2026.7.2-beta.4",
eligibilityVersion: "2026.7.2",
},
summary: { breakageCount: 1, warningCount: 0, issueCount: 1 },
issues: [
{
code: "unknown-registration-name",
level: "breakage",
severity: "P0",
issueClass: "live-issue",
message: "fixture calls registrars missing from target OpenClaw",
evidence: [
"registerMemoryPromptSection @ index.ts:97",
"registerMemoryRuntime @ runtime.ts:276",
],
authorRemediation: {
summary:
"Update the plugin to use APIs available in the target OpenClaw version, or narrow its declared compatibility range.",
},
},
],
},
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
});
await expect(
cmdValidatePackage(makeOpts(workdir), "honcho-plugin", {
openclawVersion: "2026.7.2-beta.4",
}),
).rejects.toThrow("Plugin Inspector found 1 hard error");
expect(inspectorMocks.pluginRoot.runCheck).toHaveBeenCalledWith(
expect.objectContaining({ openclawVersion: "2026.7.2-beta.4" }),
);
const output = mockLog.mock.calls.join("\n");
expect(output).toContain("registerMemoryPromptSection @ index.ts:97");
expect(output).toContain("registerMemoryRuntime @ runtime.ts:276");
expect(output).not.toContain("Docs:");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("prints author-facing package validation findings before report paths by default", async () => {
const workdir = await makeTmpWorkdir();
try {
@@ -175,6 +175,7 @@ type PackagePackOptions = {
type PackageValidateOptions = {
out?: string;
openclaw?: string;
openclawVersion?: string;
runtime?: boolean;
allowExecute?: boolean;
mockSdk?: boolean;
@@ -716,9 +717,13 @@ export async function cmdValidatePackage(
const sourcePath = resolvedSource.path;
const sourceStat = await stat(sourcePath).catch(() => null);
if (!sourceStat?.isDirectory()) fail("Path must be a package folder");
if (options.openclaw?.trim() && options.openclawVersion?.trim()) {
fail("Choose either --openclaw or --openclaw-version");
}
const outDir = options.out?.trim() || "reports";
const openclawPath = options.openclaw?.trim() ? resolve(opts.workdir, options.openclaw) : false;
const openclawVersion = options.openclawVersion?.trim() || (openclawPath ? undefined : "latest");
const generatedConfig = await createPluginInspectorConfigIfNeeded(sourcePath);
let report: Awaited<ReturnType<typeof pluginRoot.runCheck>>["report"];
let paths: Awaited<ReturnType<typeof pluginRoot.runCheck>>["paths"];
@@ -730,6 +735,7 @@ export async function cmdValidatePackage(
configPath: generatedConfig?.path,
mockSdk: options.mockSdk !== false,
openclawPath,
...(openclawVersion ? { openclawVersion } : {}),
outDir,
pluginRoot: sourcePath,
} as Parameters<typeof pluginRoot.runCheck>[0] & { authorFacing: true };
+1
View File
@@ -22,6 +22,7 @@ declare module "@openclaw/plugin-inspector" {
configPath?: string;
mockSdk?: boolean;
openclawPath?: string | false;
openclawVersion?: string;
outDir?: string;
pluginRoot?: string;
}): Promise<{
@@ -2016,6 +2016,11 @@ describe("plugin detail route", () => {
expect(within(validationRegion).getByText("v1.0.0")).toBeTruthy();
expect(within(validationRegion).getByText("Target")).toBeTruthy();
expect(within(validationRegion).getByText("OpenClaw 0.9.0")).toBeTruthy();
expect(
within(validationRegion).getByText(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
),
).toBeTruthy();
expect(screen.getByText(/Legacy before_agent_start hook is deprecated\./)).toBeTruthy();
expect(screen.getByText(/We found/)).toBeTruthy();
expect(screen.queryByText(/Hey,/)).toBeNull();
@@ -57,6 +57,12 @@ type PluginInspectorFinding = {
const PLUGIN_VALIDATE_CLI = "clawhub package validate <path-to-plugin>";
function pluginValidateCommand(openClawVersion?: string) {
return openClawVersion
? `${PLUGIN_VALIDATE_CLI} --openclaw-version ${openClawVersion}`
: PLUGIN_VALIDATE_CLI;
}
export function DashboardNeedsAttention({ items }: DashboardNeedsAttentionProps) {
const [selectedGroup, setSelectedGroup] = useState<DashboardAttentionGroup | null>(null);
const reviewScrollRef = useRef<HTMLDivElement>(null);
@@ -353,7 +359,23 @@ function PluginValidationSheetReview({
const errors = displayedFindings.filter((finding) => finding.findingKind === "error");
const warnings = displayedFindings.filter((finding) => finding.findingKind !== "error");
const version = displayedFindings.find((finding) => finding.version)?.version ?? null;
const instructions = buildValidationInstructions(group.title, version, displayedFindings);
const targetOpenClawVersions = Array.from(
new Set(
displayedFindings
.map((finding) => finding.targetOpenClawVersion?.trim())
.filter((target): target is string => Boolean(target)),
),
);
const validateCommands = targetOpenClawVersions.length
? targetOpenClawVersions.map(pluginValidateCommand)
: [pluginValidateCommand()];
const validateCommand = validateCommands.join("\n");
const instructions = buildValidationInstructions(
group.title,
version,
displayedFindings,
validateCommands,
);
return (
<>
@@ -383,12 +405,20 @@ function PluginValidationSheetReview({
<span className="plugin-validation-toolbar-label">
Validate locally before publishing
</span>
<div className="plugin-validation-toolbar">
<div
className={`plugin-validation-toolbar${
validateCommands.length > 1 ? " is-multi-target" : ""
}`}
>
<div className="plugin-validation-toolbar-cli">
<code className="plugin-validation-command">{PLUGIN_VALIDATE_CLI}</code>
<code className="plugin-validation-command">{validateCommand}</code>
<InstallCopyButton
text={PLUGIN_VALIDATE_CLI}
ariaLabel="Copy validate command"
text={validateCommand}
ariaLabel={
validateCommands.length === 1
? "Copy validate command"
: "Copy validate commands"
}
showLabel={false}
className="plugin-validation-toolbar-copy"
/>
@@ -542,14 +572,17 @@ function buildValidationInstructions(
packageName: string,
version: string | null,
findings: PluginInspectorFinding[],
validateCommands: string[],
) {
const lines = [
`Fix the following OpenClaw plugin validation findings for package "${packageName}".`,
...(version ? [`Validated release: v${version}.`] : []),
"",
"Make the minimum code and manifest changes needed to resolve every issue below.",
"After editing, run locally:",
PLUGIN_VALIDATE_CLI,
validateCommands.length === 1
? "After editing, run locally:"
: "After editing, run locally against every recorded OpenClaw target:",
...validateCommands,
"",
];
for (const finding of findings) {
+123 -13
View File
@@ -49,6 +49,8 @@ const mocks = vi.hoisted(() => ({
rerenderDashboard: null as null | (() => void),
}));
const writeTextMock = vi.fn();
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
usePaginatedQuery: (...args: unknown[]) => mocks.usePaginatedQuery(...args),
@@ -366,6 +368,12 @@ describe("Dashboard rows", () => {
mocks.dashboardSearch = {};
mocks.rerenderDashboard = null;
window.localStorage.clear();
writeTextMock.mockReset();
writeTextMock.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: writeTextMock },
});
mocks.usePaginatedQuery.mockReturnValue({
results: [],
status: "LoadingFirstPage",
@@ -687,21 +695,43 @@ describe("Dashboard rows", () => {
});
it("links public plugin finding counts to the plugin validation tab", () => {
arrangeDashboard({
packages: [
createPackage({
inspectorWarningCount: 2,
scanStatus: "clean",
stats: { downloads: 42, installs: 9, stars: 0, versions: 1 },
latestRelease: {
const packages = [
createPackage({
inspectorWarningCount: 2,
scanStatus: "clean",
stats: { downloads: 42, installs: 9, stars: 0, versions: 1 },
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
},
}),
];
arrangeDashboard({ packages });
mocks.useQuery.mockImplementation((query: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = getFunctionName(query as never);
if (name === "publishers:listMine") return publishers;
if (name === "packages:list") return packages;
if (name === "dashboard:getDownloadMetrics") return downloadMetrics;
if (name === "packages:listPackageInspectorWarningsForManager") {
return [
{
_id: "packageInspectorWarnings:1",
packageName: "local-scanned-runtime-plugin",
version: "1.0.0",
createdAt: 1,
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
findingKind: "warning",
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
severity: "P2",
message: "legacy before_agent_start hook is deprecated",
targetOpenClawVersion: "0.9.0",
},
}),
],
];
}
return [];
});
renderDashboard();
@@ -715,6 +745,86 @@ describe("Dashboard rows", () => {
screen.getByRole("dialog", { name: "Local Flagged Runtime Plugin review" }),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "Validation" })).toBeTruthy();
expect(
screen.getByText("clawhub package validate <path-to-plugin> --openclaw-version 0.9.0"),
).toBeTruthy();
});
it("copies one exact validation command per recorded OpenClaw target", async () => {
const packages = [
createPackage({
inspectorWarningCount: 2,
scanStatus: "clean",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
},
}),
];
arrangeDashboard({ packages });
mocks.useQuery.mockImplementation((query: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = getFunctionName(query as never);
if (name === "publishers:listMine") return publishers;
if (name === "packages:list") return packages;
if (name === "dashboard:getDownloadMetrics") return downloadMetrics;
if (name === "packages:listPackageInspectorWarningsForManager") {
return [
{
_id: "packageInspectorWarnings:1",
findingKind: "warning",
code: "legacy-before-agent-start",
message: "legacy hook is deprecated",
targetOpenClawVersion: "0.9.0",
},
{
_id: "packageInspectorWarnings:2",
findingKind: "error",
code: "missing-expected-seam",
message: "registerTool is no longer available",
targetOpenClawVersion: "0.10.0",
},
];
}
return [];
});
renderDashboard();
fireEvent.click(
screen.getByRole("button", { name: /Local Flagged Runtime Plugin\. 1 issue/i }),
);
expect(document.querySelector(".plugin-validation-command")?.textContent).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
);
expect(document.querySelector(".plugin-validation-command")?.textContent).toContain(
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
);
fireEvent.click(screen.getByRole("button", { name: "Copy validate commands" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
[
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
].join("\n"),
);
});
fireEvent.click(screen.getByRole("button", { name: "Copy fix instructions" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining(
"clawhub package validate <path-to-plugin> --openclaw-version 0.9.0",
),
);
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining(
"clawhub package validate <path-to-plugin> --openclaw-version 0.10.0",
),
);
});
});
it("shows a publisher selector and loads org packages when switching publishers", async () => {
+18 -4
View File
@@ -690,6 +690,12 @@ function PluginManifestSkillsPanel({
}
const PLUGIN_VALIDATE_CLI = "clawhub package validate <path-to-plugin>";
function pluginValidateCommand(openClawVersion?: string) {
return openClawVersion
? `${PLUGIN_VALIDATE_CLI} --openclaw-version ${openClawVersion}`
: PLUGIN_VALIDATE_CLI;
}
const PLUGIN_VALIDATE_TOOLBAR_LABEL = "Validate locally before publishing";
const INSPECTOR_ISSUE_CLASS_LABELS: Record<string, string> = {
@@ -954,10 +960,18 @@ function PluginValidationFindingCard({
</div>
) : null}
{finding.targetOpenClawVersion ? (
<div className="plugin-warning-meta-field">
<dt className="plugin-warning-meta-key">Target</dt>
<dd className="plugin-warning-meta-value">OpenClaw {finding.targetOpenClawVersion}</dd>
</div>
<>
<div className="plugin-warning-meta-field">
<dt className="plugin-warning-meta-key">Target</dt>
<dd className="plugin-warning-meta-value">OpenClaw {finding.targetOpenClawVersion}</dd>
</div>
<div className="plugin-warning-meta-field">
<dt className="plugin-warning-meta-key">Reproduce</dt>
<dd className="plugin-warning-meta-value">
<code>{pluginValidateCommand(finding.targetOpenClawVersion)}</code>
</dd>
</div>
</>
) : null}
</dl>
) : null;
+14
View File
@@ -5192,6 +5192,20 @@ code {
width: 100%;
}
.plugin-validation-toolbar.is-multi-target {
height: auto;
align-items: flex-start;
}
.plugin-validation-toolbar.is-multi-target .plugin-validation-toolbar-cli {
align-items: flex-start;
}
.plugin-validation-toolbar.is-multi-target .plugin-validation-command {
white-space: pre-wrap;
line-height: 1.45;
}
.plugin-validation-toolbar-copy.skill-install-copy-button {
flex: 0 0 auto;
width: 2rem;