Publish public Plugin Inspector findings (#2525)

* feat: gate plugin publishes with inspector warnings

* fix: harden plugin publish inspector gate

* feat: publish public plugin inspector findings

* fix: refine plugin inspector publish errors

* feat: add package validation command

* fix: refine plugin validation findings UI

* fix: scan only latest plugin releases nightly

* feat: add dry run for nightly plugin inspection

* test: fix plugin validation tab e2e matcher
This commit is contained in:
Patrick Erichsen
2026-06-08 19:41:48 -07:00
committed by GitHub
parent 9321c83b93
commit be70ae5e9f
47 changed files with 5396 additions and 277 deletions
+15
View File
@@ -1,5 +1,19 @@
version: 2
updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/Los_Angeles"
open-pull-requests-limit: 3
labels:
- dependencies
- needs-cli-release
allow:
- dependency-name: "@openclaw/plugin-inspector"
- package-ecosystem: "bun"
directory: "/"
schedule:
@@ -9,6 +23,7 @@ updates:
timezone: "America/Los_Angeles"
open-pull-requests-limit: 10
ignore:
- dependency-name: "@openclaw/plugin-inspector"
- dependency-name: "@auth/core"
update-types:
- "version-update:semver-minor"
+145 -6
View File
@@ -61,11 +61,6 @@ on:
description: Optional source path inside the repository for monorepo package publishes.
required: false
type: string
clawhub_version:
description: Legacy npm CLI version input. Kept for compatibility; the workflow now runs the checked-out source.
required: false
type: string
default: latest
secrets:
clawhub_token:
required: false
@@ -219,6 +214,7 @@ jobs:
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
- name: Resolve publish command
id: resolve_publish
env:
INPUT_SOURCE: ${{ inputs.source }}
INPUT_REF: ${{ inputs.ref }}
@@ -233,6 +229,7 @@ jobs:
INPUT_SITE: ${{ inputs.site }}
INPUT_REGISTRY: ${{ inputs.registry }}
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
@@ -243,6 +240,78 @@ jobs:
import os
import shlex
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import quote, urlparse
from urllib.request import Request, urlopen
def split_ref_path(value):
if not value:
return "", ""
if ":" not in value:
return value, ""
ref, path = value.split(":", 1)
return ref, path.strip("/")
def github_commit_exists(repo, ref):
token = os.environ.get("GITHUB_TOKEN", "").strip()
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "clawhub-package-publish",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(
f"https://api.github.com/repos/{repo}/commits/{quote(ref, safe='')}",
headers=headers,
)
try:
with urlopen(request, timeout=10) as response:
return 200 <= response.status < 300
except HTTPError as error:
if error.code in (404, 422):
return False
raise
def resolve_github_url_ref_and_path(repo, kind, segments):
min_path_segments = 1 if kind == "blob" else 0
max_ref_segments = len(segments) - min_path_segments
for ref_segment_count in range(max_ref_segments, 0, -1):
ref = "/".join(segments[:ref_segment_count])
path = "/".join(segments[ref_segment_count:]).strip("/")
if kind == "blob" and not path:
continue
if not github_commit_exists(repo, ref):
continue
if kind == "blob":
path = "/".join(path.split("/")[:-1]).strip("/")
return ref, path
raise SystemExit(f"GitHub ref not found in source URL for {repo}")
def parse_github_source(value):
raw = value.strip()
if raw.startswith("github:"):
raw = raw[len("github:"):]
if raw.startswith("https://") or raw.startswith("http://"):
parsed = urlparse(raw)
if parsed.netloc.lower() != "github.com":
return None
parts = [part for part in parsed.path.strip("/").split("/") if part]
if len(parts) < 2:
return None
repo_name = parts[1][:-4] if parts[1].endswith(".git") else parts[1]
repo = f"{parts[0]}/{repo_name}"
if len(parts) >= 4 and parts[2] in {"tree", "blob"}:
ref, path = resolve_github_url_ref_and_path(repo, parts[2], parts[3:])
return {"repo": repo, "ref": ref, "path": path}
return {"repo": repo, "ref": "", "path": ""}
source_part, at, ref_part = raw.partition("@")
repo_parts = source_part.split("/")
if len(repo_parts) != 2 or not repo_parts[0] or not repo_parts[1]:
return None
repo_name = repo_parts[1][:-4] if repo_parts[1].endswith(".git") else repo_parts[1]
ref, path = split_ref_path(ref_part if at else "")
return {"repo": f"{repo_parts[0]}/{repo_name}", "ref": ref, "path": path}
source = os.environ["INPUT_SOURCE"].strip()
if not source:
@@ -254,6 +323,30 @@ jobs:
is_local_source = source.startswith(".") or source.startswith("/") or Path(source).exists()
if ref and "@" not in source and not source.startswith("http") and not is_local_source:
source = f"{source}@{ref}"
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
inspect_checkout_repository = ""
inspect_checkout_ref = ""
inspect_local_root = str(Path(os.environ["GITHUB_WORKSPACE"]).resolve())
inspect_subdir = source_path
if is_local_source:
inspect_local_root = str(Path(source).resolve())
else:
github_source = parse_github_source(source)
source_ref_differs_from_checkout = (
bool(github_source and github_source["ref"])
and github_source["ref"] != os.environ["GITHUB_SHA"]
)
if github_source and (
github_source["repo"] != os.environ["GITHUB_REPOSITORY"]
or source_ref_differs_from_checkout
):
inspect_checkout_repository = github_source["repo"]
inspect_checkout_ref = github_source["ref"]
inspect_local_root = str((Path(os.environ["GITHUB_WORKSPACE"]) / "clawhub-publish-source").resolve())
inspect_subdir = source_path or github_source["path"]
elif github_source:
inspect_subdir = source_path or github_source["path"]
cli_entry = (
Path(os.environ["GITHUB_WORKSPACE"])
@@ -294,7 +387,6 @@ jobs:
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
if source_repo:
cmd += ["--source-repo", source_repo]
if source_commit:
@@ -318,8 +410,55 @@ jobs:
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
path.chmod(0o755)
print(shell_line)
output_path = Path(os.environ["GITHUB_OUTPUT"])
with output_path.open("a", encoding="utf-8") as fh:
fh.write(f"inspect_checkout_repository={inspect_checkout_repository}\n")
fh.write(f"inspect_checkout_ref={inspect_checkout_ref}\n")
fh.write(f"inspect_local_root={inspect_local_root}\n")
fh.write(f"inspect_subdir={inspect_subdir}\n")
PY
- name: Checkout publish source for plugin inspector
if: steps.resolve_publish.outputs.inspect_checkout_repository != ''
uses: actions/checkout@v6
with:
repository: ${{ steps.resolve_publish.outputs.inspect_checkout_repository }}
ref: ${{ steps.resolve_publish.outputs.inspect_checkout_ref }}
path: clawhub-publish-source
- name: Run plugin validation
env:
INSPECT_LOCAL_ROOT: ${{ steps.resolve_publish.outputs.inspect_local_root }}
INSPECT_SUBDIR: ${{ steps.resolve_publish.outputs.inspect_subdir }}
run: |
set -euo pipefail
inspect_root="$(python3 - <<'PY'
import os
from pathlib import Path
root = Path(os.environ["INSPECT_LOCAL_ROOT"]).resolve()
subdir = os.environ["INSPECT_SUBDIR"].strip()
inspect_root = (root / subdir).resolve() if subdir else root
if inspect_root != root and root not in inspect_root.parents:
raise SystemExit(f"Inspector source path escapes publish source: {subdir}")
print(inspect_root)
PY
)"
if [ ! -f "$inspect_root/package.json" ] && [ ! -f "$inspect_root/openclaw.plugin.json" ]; then
echo "::warning::Plugin Inspector skipped because $inspect_root is not a plugin root."
exit 0
fi
bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" package validate "$inspect_root" --out "$RUNNER_TEMP/plugin-inspector"
- name: Upload plugin inspector reports
if: always()
uses: actions/upload-artifact@v7
with:
name: plugin-inspector-report
path: ${{ runner.temp }}/plugin-inspector
if-no-files-found: ignore
- name: Run package publish
run: |
set -euo pipefail
@@ -0,0 +1,55 @@
name: Plugin Inspector Nightly
on:
workflow_dispatch:
inputs:
batch_size:
description: "Maximum plugin releases to scan"
required: false
default: "25"
dry_run:
description: "Preview impact without persisting findings or sending emails"
required: false
default: "false"
type: boolean
dry_run_max_batches:
description: "Maximum preview batches to scan when dry_run is enabled"
required: false
default: "20"
schedule:
- cron: "41 8 * * *"
permissions:
contents: read
jobs:
scan:
name: Scan published plugins
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run nightly plugin inspector scan
env:
CLAWHUB_SITE_URL: ${{ vars.CLAWHUB_SITE_URL || 'https://clawhub.ai' }}
CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN: ${{ secrets.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN }}
PLUGIN_INSPECTOR_BATCH_SIZE: ${{ inputs.batch_size || '25' }}
PLUGIN_INSPECTOR_DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES: ${{ inputs.dry_run_max_batches || '20' }}
PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-nightly-reports
run: bun scripts/package-inspector-nightly-scan.ts
- name: Upload inspector reports
if: always()
uses: actions/upload-artifact@v4
with:
name: plugin-inspector-nightly-reports
path: plugin-inspector-nightly-reports
if-no-files-found: warn
+5 -1
View File
@@ -12,6 +12,7 @@
"@fontsource/manrope": "5.2.8",
"@fontsource/noto-sans-sc": "5.2.9",
"@monaco-editor/react": "4.7.0",
"@openclaw/plugin-inspector": "0.3.11",
"@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-dialog": "1.1.16",
"@radix-ui/react-dropdown-menu": "2.1.17",
@@ -82,13 +83,14 @@
},
"packages/clawhub": {
"name": "clawhub",
"version": "0.20.0",
"version": "0.20.1",
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js",
},
"dependencies": {
"@clack/prompts": "1.5.1",
"@openclaw/plugin-inspector": "0.3.11",
"arktype": "2.2.0",
"commander": "15.0.0",
"fflate": "0.8.3",
@@ -321,6 +323,8 @@
"@openclaw/clawhub-mod": ["@openclaw/clawhub-mod@workspace:packages/clawhub-mod"],
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.11", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-bTUTq0Smg+U/9P+qJO8cBfdL+ASayfjft8WY9hfRvzJcG6lT3XXCSvs5fJE8jiX76Y911JXZQ8Jg9c5uZsKSUw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
+4
View File
@@ -126,6 +126,8 @@ import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
import type * as managementDevSeed from "../managementDevSeed.js";
import type * as packageInspectorHttp from "../packageInspectorHttp.js";
import type * as packageInspectorNode from "../packageInspectorNode.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as publisherAbuse from "../publisherAbuse.js";
@@ -280,6 +282,8 @@ declare const fullApi: ApiFromModules<{
llmEval: typeof llmEval;
maintenance: typeof maintenance;
managementDevSeed: typeof managementDevSeed;
packageInspectorHttp: typeof packageInspectorHttp;
packageInspectorNode: typeof packageInspectorNode;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
publisherAbuse: typeof publisherAbuse;
+15
View File
@@ -501,6 +501,21 @@ describe("devSeed local fixtures", () => {
scannedPluginName,
]);
expect(tables.packages?.every((pkg) => pkg.ownerUserId === userId)).toBe(true);
expect(tables.packageInspectorWarnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
packageName: scannedPluginName,
findingKind: "warning",
code: "legacy-before-agent-start",
}),
expect.objectContaining({
packageName: scannedPluginName,
findingKind: "error",
code: "missing-expected-seam",
scanSource: "nightly",
}),
]),
);
});
it("retires legacy @local-owner seed publishers so dev-auth users can claim the handle", async () => {
+49
View File
@@ -1453,11 +1453,18 @@ async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
.collect();
const inspectorFindings = await ctx.db
.query("packageInspectorWarnings")
.withIndex("by_package_created", (q) => q.eq("packageId", existing._id))
.collect();
await deletePackageBadgesForPackage(ctx, existing._id);
await ctx.db.delete(existing._id);
for (const release of releases) {
await ctx.db.delete(release._id);
}
for (const finding of inspectorFindings) {
await ctx.db.delete(finding._id);
}
}
async function deleteSeedPluginFixture(ctx: MutationCtx, name = FLAGGED_PLUGIN_NAME) {
@@ -2515,6 +2522,48 @@ export async function seedLocalModerationFixturesHandler(
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
updatedAt: now,
});
await ctx.db.insert("packageInspectorWarnings", {
packageId: scannedPackageId,
releaseId: scannedPackageReleaseId,
ownerUserId: userId,
ownerPublisherId: publisherId,
packageName: scannedPluginName,
version: "0.1.0",
findingKind: "warning",
scanSource: "publish",
inspectorVersion: "0.3.11",
targetOpenClawVersion: "2026.3.24-beta.2",
code: "legacy-before-agent-start",
severity: "P2",
level: "warning",
issueClass: "deprecation-warning",
compatStatus: "deprecated",
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"],
inspectorFindingId: "local-scanned-runtime-plugin:legacy-before-agent-start",
createdAt: now,
});
await ctx.db.insert("packageInspectorWarnings", {
packageId: scannedPackageId,
releaseId: scannedPackageReleaseId,
ownerUserId: userId,
ownerPublisherId: publisherId,
packageName: scannedPluginName,
version: "0.1.0",
findingKind: "error",
scanSource: "nightly",
inspectorVersion: "0.4.0",
targetOpenClawVersion: "2026.4.0",
code: "missing-expected-seam",
severity: "P0",
level: "breakage",
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"],
inspectorFindingId: "local-scanned-runtime-plugin:missing-expected-seam",
createdAt: now + 1,
});
await ctx.db.patch(userId, {
publishedSkills: 6,
totalStars: 3,
+23
View File
@@ -59,6 +59,11 @@ import {
whoamiV1Http,
} from "./httpApiV1";
import { preflightHandler } from "./httpPreflight";
import {
packageInspectorArtifactHttp,
packageInspectorClaimHttp,
packageInspectorResultsHttp,
} from "./packageInspectorHttp";
const http = httpRouter();
@@ -190,6 +195,24 @@ http.route({
handler: mintPublishTokenV1Http,
});
http.route({
path: "/api/v1/package-inspector/claim",
method: "POST",
handler: packageInspectorClaimHttp,
});
http.route({
path: "/api/v1/package-inspector/artifact",
method: "GET",
handler: packageInspectorArtifactHttp,
});
http.route({
path: "/api/v1/package-inspector/results",
method: "POST",
handler: packageInspectorResultsHttp,
});
http.route({
pathPrefix: `${ApiRoutes.packages}/`,
method: "POST",
+42
View File
@@ -0,0 +1,42 @@
declare module "@openclaw/plugin-inspector" {
export type PluginInspectorReport = {
status?: string;
summary?: {
breakageCount?: number;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type PluginInspectorPaths = {
jsonPath: string;
markdownPath?: string;
issuesPath?: string;
[key: string]: unknown;
};
export const pluginRoot: {
runCheck(options?: {
pluginRoot?: string;
openclawPath?: string | false;
outDir?: string;
capture?: boolean;
mockSdk?: boolean;
allowExecution?: boolean;
configPath?: string;
generatedAt?: string;
}): Promise<{ report: PluginInspectorReport; paths: PluginInspectorPaths }>;
};
export const reports: {
renderTextSummary(report: PluginInspectorReport, options?: Record<string, unknown>): string;
sanitizeArtifact(report: PluginInspectorReport): unknown;
};
export const ci: {
writeOutputs(
report: PluginInspectorReport,
options?: Record<string, unknown>,
): Promise<unknown>;
};
}
+14
View File
@@ -0,0 +1,14 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { absolutePackageArtifactUrl } from "./packageInspectorHttp";
describe("package inspector HTTP helpers", () => {
it("returns the protected artifact route for scan claims", () => {
const request = new Request("https://example.com/api/v1/package-inspector/claim");
expect(absolutePackageArtifactUrl(request, "packageReleases:demo-1")).toBe(
"https://example.com/api/v1/package-inspector/artifact?releaseId=packageReleases%3Ademo-1",
);
});
});
+191
View File
@@ -0,0 +1,191 @@
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
import type { ActionCtx } from "./_generated/server";
import { json, parseJsonPayload, text } from "./httpApiV1/shared";
import { buildDeterministicPackageZip } from "./lib/skillZip";
const internalRefs = internal as unknown as {
packages: {
claimPackageInspectorScanBatchInternal: unknown;
previewPackageInspectorScanBatchInternal: unknown;
getPackageInspectorArtifactInternal: unknown;
ingestPackageInspectorScanResultsInternal: unknown;
sendPackageInspectorFindingsEmailInternal: unknown;
};
};
function readBearerToken(request: Request) {
return (
request.headers
.get("authorization")
?.match(/^Bearer\s+(.+)$/i)?.[1]
?.trim() ?? ""
);
}
function requireWorkerToken(request: Request) {
const expected = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN?.trim() || "";
if (!expected) return { ok: false as const, response: text("Worker unavailable", 503) };
if (readBearerToken(request) !== expected) {
return { ok: false as const, response: text("Unauthorized", 401) };
}
return { ok: true as const };
}
export function absolutePackageArtifactUrl(request: Request, releaseId: string) {
const url = new URL("/api/v1/package-inspector/artifact", request.url);
url.searchParams.set("releaseId", releaseId);
return url.toString();
}
function isTruthyParam(value: string | null) {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
async function runMutationRef<T>(ctx: Pick<ActionCtx, "runMutation">, ref: unknown, args: unknown) {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
async function runQueryRef<T>(ctx: Pick<ActionCtx, "runQuery">, ref: unknown, args: unknown) {
return (await ctx.runQuery(ref as never, args as never)) as T;
}
async function runActionRef<T>(ctx: Pick<ActionCtx, "runAction">, ref: unknown, args: unknown) {
return (await ctx.runAction(ref as never, args as never)) as T;
}
export const packageInspectorClaimHttp = httpAction(async (ctx, request) => {
const auth = requireWorkerToken(request);
if (!auth.ok) return auth.response;
const url = new URL(request.url);
const batchSize = Number(url.searchParams.get("batchSize") ?? "25");
const cursor = url.searchParams.get("cursor");
const dryRun = isTruthyParam(url.searchParams.get("dryRun"));
type ClaimResult = {
ok: true;
leased: boolean;
nextCursor: string | null;
items: Array<{
packageId: string;
releaseId: string;
ownerUserId?: string;
ownerPublisherId?: string;
packageName: string;
version: string;
artifactKind: string;
}>;
};
const claimArgs = {
batchSize: Number.isFinite(batchSize) ? batchSize : undefined,
...(dryRun ? { cursor } : {}),
};
const result = dryRun
? await runQueryRef<ClaimResult>(
ctx,
internalRefs.packages.previewPackageInspectorScanBatchInternal,
claimArgs,
)
: await runMutationRef<ClaimResult>(
ctx,
internalRefs.packages.claimPackageInspectorScanBatchInternal,
claimArgs,
);
return json({
...result,
dryRun,
items: result.items.map((item) => ({
...item,
downloadUrl: absolutePackageArtifactUrl(request, item.releaseId),
})),
});
});
export const packageInspectorArtifactHttp = httpAction(async (ctx, request) => {
const auth = requireWorkerToken(request);
if (!auth.ok) return auth.response;
const releaseId = new URL(request.url).searchParams.get("releaseId")?.trim();
if (!releaseId) return text("Missing releaseId", 400);
const artifact = await runQueryRef<{
packageName: string;
version: string;
artifactKind: "legacy-zip" | "npm-pack";
clawpackStorageId?: string;
clawpackSha256?: string;
npmIntegrity?: string;
npmShasum?: string;
npmTarballName?: string;
files: Array<{ path: string; storageId: string }>;
} | null>(ctx, internalRefs.packages.getPackageInspectorArtifactInternal, {
releaseId,
});
if (!artifact) return text("Artifact not found", 404);
if (artifact.artifactKind === "npm-pack") {
if (!artifact.clawpackStorageId) return text("Artifact not found", 404);
const blob = await ctx.storage.get(artifact.clawpackStorageId as Id<"_storage">);
if (!blob) return text("Artifact not found", 404);
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${artifact.npmTarballName ?? `${artifact.packageName.replaceAll("/", "-")}-${artifact.version}.tgz`}"`,
"X-ClawHub-Artifact-Type": "npm-pack-tarball",
};
if (artifact.clawpackSha256) {
headers.ETag = `"sha256:${artifact.clawpackSha256}"`;
headers["X-ClawHub-Artifact-Sha256"] = artifact.clawpackSha256;
}
if (artifact.npmIntegrity) headers["X-ClawHub-Npm-Integrity"] = artifact.npmIntegrity;
if (artifact.npmShasum) headers["X-ClawHub-Npm-Shasum"] = artifact.npmShasum;
return new Response(blob, { status: 200, headers });
}
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of artifact.files) {
const blob = await ctx.storage.get(file.storageId as Id<"_storage">);
if (!blob) return text(`Missing stored file: ${file.path}`, 500);
entries.push({
path: file.path,
bytes: new Uint8Array(await blob.arrayBuffer()),
});
}
const zip = buildDeterministicPackageZip(entries);
return new Response(new Blob([zip], { type: "application/zip" }), {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${artifact.packageName.replaceAll("/", "-")}-${artifact.version}.zip"`,
"X-ClawHub-Artifact-Type": "legacy-plugin-zip",
},
});
});
export const packageInspectorResultsHttp = 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;
const result = await runMutationRef<{
ok: true;
inserted: number;
shouldEmailOwner: boolean;
}>(ctx, internalRefs.packages.ingestPackageInspectorScanResultsInternal, {
packageId: payload.packageId,
releaseId: payload.releaseId,
inspectorVersion: payload.inspectorVersion,
targetOpenClawVersion: payload.targetOpenClawVersion,
findings: Array.isArray(payload.findings) ? payload.findings : [],
});
if (result.shouldEmailOwner) {
try {
await runActionRef(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, {
packageId: payload.packageId,
releaseId: payload.releaseId,
});
} catch (error) {
console.error("Package Inspector findings email failed", error);
}
}
return json(result);
});
+301
View File
@@ -0,0 +1,301 @@
"use node";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
type InspectorFinding = {
id?: string;
code: string;
severity?: string;
level?: string;
issueClass?: string;
compatStatus?: string;
deprecated?: boolean;
message: string;
evidence?: string[];
fixture?: string;
decision?: string;
};
type InspectorReport = {
status?: string;
targetOpenClaw?: unknown;
summary?: {
breakageCount?: number;
warningCount?: number;
deprecationWarningCount?: number;
issueCount?: number;
};
breakages?: unknown[];
warnings?: unknown[];
suggestions?: unknown[];
issues?: unknown[];
};
const publishFileValidator = v.object({
path: v.string(),
size: v.number(),
storageId: v.id("_storage"),
sha256: v.string(),
contentType: v.optional(v.string()),
});
const findingValidator = v.object({
id: v.optional(v.string()),
code: v.string(),
severity: v.optional(v.string()),
level: v.optional(v.string()),
issueClass: v.optional(v.string()),
compatStatus: v.optional(v.string()),
deprecated: v.optional(v.boolean()),
message: v.string(),
evidence: v.optional(v.array(v.string())),
fixture: v.optional(v.string()),
decision: v.optional(v.string()),
});
const inspectorMetadataValidator = v.object({
inspectorVersion: v.optional(v.string()),
targetOpenClawVersion: v.optional(v.string()),
});
export const runPackageInspectorForPublishInternal = internalAction({
args: {
packageName: v.string(),
version: v.string(),
files: v.array(publishFileValidator),
},
returns: v.object({
status: v.union(v.literal("pass"), v.literal("fail")),
summary: v.object({
breakageCount: v.number(),
warningCount: v.number(),
deprecationWarningCount: v.number(),
issueCount: v.number(),
}),
breakages: v.array(findingValidator),
warnings: v.array(findingValidator),
metadata: inspectorMetadataValidator,
}),
handler: async (ctx, args) => {
const root = path.join(
tmpdir(),
`clawhub-plugin-inspector-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
try {
await mkdir(root, { recursive: true });
for (const file of args.files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) {
throw new Error(`missing package file ${file.path}`);
}
const target = safeFilePath(root, file.path);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, Buffer.from(await blob.arrayBuffer()));
}
await writeSyntheticInspectorConfigIfNeeded(root, args.files, args.packageName);
const { pluginRoot } = await import("@openclaw/plugin-inspector");
const { report } = await pluginRoot.runCheck({
pluginRoot: root,
openclawPath: false,
outDir: "reports",
capture: false,
mockSdk: true,
allowExecution: false,
generatedAt: new Date().toISOString(),
});
return normalizeInspectorReport(report);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
status: "fail" as const,
summary: {
breakageCount: 1,
warningCount: 0,
deprecationWarningCount: 0,
issueCount: 1,
},
breakages: [
{
code: "plugin-inspector-error",
severity: "P0",
level: "breakage",
message: `Plugin Inspector could not inspect ${args.packageName}@${args.version}: ${message}`,
},
],
warnings: [],
metadata: {
inspectorVersion: getBundledInspectorVersion(),
},
};
} finally {
await rm(root, { recursive: true, force: true });
}
},
});
async function writeSyntheticInspectorConfigIfNeeded(
root: string,
files: Array<{ path: string }>,
packageName: string,
) {
const lowerRootPaths = new Set(files.map((file) => file.path.toLowerCase()));
if (
lowerRootPaths.has("plugin-inspector.config.json") ||
lowerRootPaths.has(".plugin-inspector.json")
) {
return;
}
try {
const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")) as {
pluginInspector?: unknown;
"plugin-inspector"?: unknown;
};
if (
packageJson.pluginInspector &&
typeof packageJson.pluginInspector === "object" &&
!Array.isArray(packageJson.pluginInspector)
) {
return;
}
if (
packageJson["plugin-inspector"] &&
typeof packageJson["plugin-inspector"] === "object" &&
!Array.isArray(packageJson["plugin-inspector"])
) {
return;
}
} catch {
// Existing publish validation and the inspector report own malformed package metadata.
}
await writeFile(
path.join(root, ".plugin-inspector.json"),
`${JSON.stringify(
{
version: 1,
plugin: {
id: toInspectorFixtureId(packageName),
},
},
null,
2,
)}\n`,
);
}
function toInspectorFixtureId(packageName: string) {
const base = packageName.split("/").pop() ?? packageName;
const normalized = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "published-plugin";
}
function safeFilePath(root: string, filePath: string) {
const normalized = path.normalize(filePath).replace(/^(\.\.(?:\/|\\|$))+/, "");
const target = path.resolve(root, normalized);
const rootWithSeparator = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (target !== root && !target.startsWith(rootWithSeparator)) {
throw new Error(`unsafe package file path ${filePath}`);
}
return target;
}
function normalizeInspectorReport(report: unknown) {
const parsed = isRecord(report) ? (report as InspectorReport) : {};
const breakages = normalizeFindings(parsed.breakages, "breakage");
const warnings = normalizeWarnings(parsed);
return {
status:
breakages.length > 0 || parsed.status === "fail" ? ("fail" as const) : ("pass" as const),
summary: {
breakageCount: numberValue(parsed.summary?.breakageCount, breakages.length),
warningCount: numberValue(parsed.summary?.warningCount, warnings.length),
deprecationWarningCount: numberValue(
parsed.summary?.deprecationWarningCount,
warnings.filter((finding) => finding.issueClass === "deprecation-warning").length,
),
issueCount: numberValue(parsed.summary?.issueCount, warnings.length + breakages.length),
},
breakages,
warnings,
metadata: {
inspectorVersion: getBundledInspectorVersion(),
targetOpenClawVersion: extractTargetOpenClawVersion(parsed.targetOpenClaw),
},
};
}
function normalizeWarnings(report: InspectorReport) {
const issueWarnings = normalizeFindings(report.issues, "warning").filter(
(finding) => finding.level !== "breakage",
);
if (issueWarnings.length > 0) return issueWarnings;
return [
...normalizeFindings(report.warnings, "warning"),
...normalizeFindings(report.suggestions, "suggestion"),
];
}
function normalizeFindings(value: unknown, defaultLevel: string): InspectorFinding[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => normalizeFinding(item, defaultLevel))
.filter((finding): finding is InspectorFinding => Boolean(finding));
}
function normalizeFinding(value: unknown, defaultLevel: string): InspectorFinding | null {
if (!isRecord(value)) return null;
const message = stringValue(value.message) ?? stringValue(value.title);
const code = stringValue(value.code) ?? "plugin-inspector-finding";
if (!message) return null;
return {
id: stringValue(value.id),
code,
severity: stringValue(value.severity),
level: stringValue(value.level) ?? defaultLevel,
issueClass: stringValue(value.issueClass),
compatStatus: stringValue(value.compatStatus),
deprecated: typeof value.deprecated === "boolean" ? value.deprecated : undefined,
message,
evidence: Array.isArray(value.evidence)
? value.evidence.map((entry) => String(entry)).slice(0, 12)
: undefined,
fixture: stringValue(value.fixture),
decision: stringValue(value.decision),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown, fallback: number) {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function getBundledInspectorVersion() {
return stringValue(process.env.CLAWHUB_PLUGIN_INSPECTOR_VERSION);
}
function extractTargetOpenClawVersion(targetOpenClaw: unknown) {
if (!isRecord(targetOpenClaw)) return undefined;
return (
stringValue(targetOpenClaw.version) ??
stringValue(targetOpenClaw.openclawVersion) ??
stringValue(targetOpenClaw.label) ??
stringValue(targetOpenClaw.status)
);
}
File diff suppressed because it is too large Load Diff
+898 -5
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -1313,6 +1313,57 @@ const packageReleases = defineTable({
.index("by_package_version", ["packageId", "version"])
.index("by_sha256hash", ["sha256hash"]);
const packageInspectorWarnings = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
packageName: v.string(),
version: v.string(),
findingKind: v.optional(v.union(v.literal("warning"), v.literal("error"))),
scanSource: v.optional(v.union(v.literal("publish"), v.literal("nightly"))),
inspectorVersion: v.optional(v.string()),
targetOpenClawVersion: v.optional(v.string()),
code: v.string(),
severity: v.optional(v.string()),
level: v.optional(v.string()),
issueClass: v.optional(v.string()),
compatStatus: v.optional(v.string()),
deprecated: v.optional(v.boolean()),
message: v.string(),
evidence: v.optional(v.array(v.string())),
fixture: v.optional(v.string()),
decision: v.optional(v.string()),
inspectorFindingId: v.optional(v.string()),
createdAt: v.number(),
})
.index("by_package_created", ["packageId", "createdAt"])
.index("by_release", ["releaseId"])
.index("by_release_created", ["releaseId", "createdAt"])
.index("by_owner_user_created", ["ownerUserId", "createdAt"])
.index("by_owner_publisher_created", ["ownerPublisherId", "createdAt"]);
const packageInspectorFindingNotifications = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
packageName: v.string(),
version: v.string(),
email: v.string(),
findingCount: v.number(),
sentAt: v.number(),
})
.index("by_release", ["releaseId"])
.index("by_owner_user_sent", ["ownerUserId", "sentAt"]);
const packageInspectorScanCursors = defineTable({
name: v.string(),
cursor: v.optional(v.union(v.string(), v.null())),
leaseExpiresAt: v.optional(v.number()),
updatedAt: v.number(),
}).index("by_name", ["name"]);
const securityScanJobs = defineTable({
targetKind: securityScanTargetKindValidator,
skillVersionId: v.optional(v.id("skillVersions")),
@@ -2479,6 +2530,9 @@ export default defineSchema({
skillSlugAliases,
packages,
packageReleases,
packageInspectorWarnings,
packageInspectorFindingNotifications,
packageInspectorScanCursors,
securityScanJobs,
skillScanRequests,
skillCardGenerationJobs,
+22
View File
@@ -404,6 +404,28 @@ clawhub package verify ./example-plugin-1.2.3.tgz --package @openclaw/example-pl
clawhub package verify ./example-plugin-1.2.3.tgz --sha256 <hex>
```
### `package validate <source>`
- Runs the ClawHub CLI's bundled Plugin Inspector against a local plugin package
folder.
- Defaults to offline/static validation, without locating or importing a local
OpenClaw checkout.
- Hard compatibility errors exit non-zero. Warning-only findings are printed but
exit zero.
- Flags:
- `--out <dir>`: write Plugin Inspector reports to this directory.
- `--openclaw <path>`: inspect against an explicit local OpenClaw checkout.
- `--runtime`: enable runtime capture; imports plugin code.
- `--allow-execute`: allow runtime capture in an isolated workspace.
- `--no-mock-sdk`: disable mocked OpenClaw SDK during runtime capture.
- `--json`: machine-readable output.
Example:
```bash
clawhub package validate ./example-plugin
```
### `package delete <name>`
- Soft-deletes a package and all releases.
+194 -3
View File
@@ -43,10 +43,77 @@ async function readRequestBody(req: IncomingMessage) {
return Buffer.concat(chunks).toString("utf8");
}
async function startPackagePublishRegistry(
handler: (req: IncomingMessage, body: string) => { status: number; body: unknown; text?: true },
) {
const server = createServer(async (req, res) => {
const body = await readRequestBody(req);
if (req.method !== "POST" || !req.url?.startsWith(ApiRoutes.packages)) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
return;
}
const response = handler(req, body);
res.writeHead(response.status, {
"Content-Type": response.text ? "text/plain" : "application/json",
});
res.end(response.text ? String(response.body) : JSON.stringify(response.body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return {
registry: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
};
}
async function writeCodePluginFixture(root: string, name: string) {
const folder = join(root, name);
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
`${JSON.stringify(
{
name,
version: "1.0.0",
type: "module",
main: "dist/index.js",
openclaw: {
extensions: ["./dist/index.js"],
compat: { pluginApi: ">=2026.3.24-beta.2" },
build: { openclawVersion: "2026.3.24-beta.2" },
configSchema: { type: "object", additionalProperties: false },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: name,
name,
configSchema: { type: "object", additionalProperties: false },
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
return folder;
}
async function spawnCommand(
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
options: { cwd: string; env: NodeJS.ProcessEnv; encoding?: BufferEncoding; timeoutMs?: number },
) {
return await new Promise<{
status: number | null;
@@ -54,6 +121,7 @@ async function spawnCommand(
stdout: string;
stderr: string;
}>((resolve, reject) => {
let settled = false;
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
@@ -69,8 +137,22 @@ async function spawnCommand(
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
const timeout =
options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
if (settled) return;
child.kill("SIGTERM");
reject(new Error(`${command} ${args.join(" ")} timed out`));
}, options.timeoutMs)
: null;
child.on("error", (error) => {
settled = true;
if (timeout) clearTimeout(timeout);
reject(error);
});
child.on("close", (status, signal) => {
settled = true;
if (timeout) clearTimeout(timeout);
resolve({ status, signal, stdout, stderr });
});
});
@@ -109,7 +191,7 @@ describe("clawhub e2e", () => {
const cfg = await makeTempConfig(registry, token);
try {
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-workdir-"));
const result = spawnSync(
const result = await spawnCommand(
"bun",
[
"clawhub",
@@ -828,6 +910,115 @@ describe("clawhub e2e", () => {
expect(output).not.toHaveProperty("releaseId");
}, 30_000);
it("package publish exits non-zero when Plugin Inspector hard errors block publish", async () => {
const root = await mkdtemp(join(tmpdir(), "clawhub-cli-inspector-hard-"));
const registry = await startPackagePublishRegistry(() => ({
status: 400,
text: true,
body: "Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
}));
const cfg = await makeTempConfig(registry.registry, "test-token");
try {
const plugin = await writeCodePluginFixture(root, "cli-inspector-hard-plugin");
const result = await spawnCommand(
"node",
[
join(process.cwd(), "packages/clawhub/dist/cli.js"),
"package",
"publish",
plugin,
"--registry",
registry.registry,
"--site",
registry.registry,
"--source-repo",
"openclaw/cli-inspector-hard-plugin",
"--source-commit",
"deadbeef",
],
{
cwd: process.cwd(),
env: {
...process.env,
CLAWHUB_CONFIG_PATH: cfg.path,
CLAWHUB_DISABLE_TELEMETRY: "1",
NO_COLOR: "1",
},
timeoutMs: 25_000,
},
);
expect(result.status).not.toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toMatch(/Plugin Inspector blocked publish/);
expect(`${result.stdout}\n${result.stderr}`).toMatch(/missing-expected-seam/);
} finally {
await registry.close();
await rm(root, { recursive: true, force: true });
await rm(cfg.dir, { recursive: true, force: true });
}
}, 30_000);
it("package publish exits zero and prints Plugin Inspector warnings", async () => {
const root = await mkdtemp(join(tmpdir(), "clawhub-cli-inspector-warning-"));
const registry = await startPackagePublishRegistry(() => ({
status: 200,
body: {
ok: true,
packageId: "pkg_cli_warning",
releaseId: "rel_cli_warning",
inspectorFindings: [
{
findingKind: "warning",
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
message: "legacy before_agent_start hook is deprecated",
},
],
},
}));
const cfg = await makeTempConfig(registry.registry, "test-token");
try {
const plugin = await writeCodePluginFixture(root, "cli-inspector-warning-plugin");
const result = await spawnCommand(
"node",
[
join(process.cwd(), "packages/clawhub/dist/cli.js"),
"package",
"publish",
plugin,
"--registry",
registry.registry,
"--site",
registry.registry,
"--source-repo",
"openclaw/cli-inspector-warning-plugin",
"--source-commit",
"abc123",
],
{
cwd: process.cwd(),
env: {
...process.env,
CLAWHUB_CONFIG_PATH: cfg.path,
CLAWHUB_DISABLE_TELEMETRY: "1",
NO_COLOR: "1",
},
timeoutMs: 25_000,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(/Plugin Inspector findings: 1 warning/);
expect(result.stdout).toMatch(
/WARNING legacy-before-agent-start \(deprecation-warning\): legacy before_agent_start hook is deprecated/,
);
} finally {
await registry.close();
await rm(root, { recursive: true, force: true });
await rm(cfg.dir, { recursive: true, force: true });
}
}, 30_000);
it("package publish help shows the new source argument and flags", async () => {
const result = spawnSync("bun", ["clawhub", "package", "publish", "--help"], {
cwd: process.cwd(),
@@ -0,0 +1,155 @@
import { writeFile } from "node:fs/promises";
import { expect, type Page, test, type TestInfo } from "@playwright/test";
import { strToU8, zipSync } from "fflate";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import { signInAsLocalPersona } from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
"local-auth plugin inspector tests require the local dev auth runner",
);
if (process.env.CLAWHUB_CAPTURE_PLUGIN_INSPECTOR_PROOF === "1") {
test.use({ video: "on" });
}
type PluginFixtureKind = "hard-error" | "warning";
function pluginPackageJson(args: { name: string; displayName: string; kind: PluginFixtureKind }) {
const pluginInspector =
args.kind === "hard-error"
? { version: 1, plugin: { id: "invalid.fixture.id" } }
: { version: 1, plugin: { id: args.name, sourceRoot: "dist" } };
return JSON.stringify(
{
name: args.name,
version: "1.0.0",
type: "module",
main: "dist/index.js",
repository: `https://github.com/openclaw/${args.name}.git`,
pluginInspector,
openclaw: {
extensions: ["./dist/index.js"],
compat: { pluginApi: ">=2026.3.24-beta.2" },
build: { openclawVersion: "2026.3.24-beta.2" },
},
},
null,
2,
);
}
async function writePluginZip(
testInfo: TestInfo,
args: {
name: string;
displayName: string;
kind: PluginFixtureKind;
},
) {
const entrypoint =
args.kind === "warning"
? 'export function activate(api) { api.on("before_agent_start", () => {}); }\n'
: "export const demo = true;\n";
const zipBytes = zipSync({
[`${args.name}/package.json`]: strToU8(pluginPackageJson(args)),
[`${args.name}/openclaw.plugin.json`]: strToU8(
JSON.stringify(
{
id: args.name,
name: args.displayName,
configSchema: { type: "object", additionalProperties: false },
},
null,
2,
),
),
[`${args.name}/dist/index.js`]: strToU8(entrypoint),
[`${args.name}/README.md`]: strToU8(`# ${args.displayName}\n\nLocal Playwright fixture.\n`),
});
const zipPath = testInfo.outputPath(`${args.name}.zip`);
await writeFile(zipPath, zipBytes);
return zipPath;
}
async function uploadPluginZip(page: Page, zipPath: string) {
await page.locator('input[type="file"]').first().setInputFiles(zipPath);
await waitForHydration(page);
}
async function captureProof(page: Page, testInfo: TestInfo, name: string) {
if (process.env.CLAWHUB_CAPTURE_PLUGIN_INSPECTOR_PROOF !== "1") return;
await page.screenshot({
path: testInfo.outputPath(`${name}.png`),
fullPage: true,
});
}
test("plugin inspector blocks hard publish errors and publishes warning findings", async ({
page,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const suffix = Date.now().toString(36);
const badName = `pw-inspector-bad-${suffix}`;
const warningName = `pw-inspector-warning-${suffix}`;
const warningDisplayName = `Playwright Inspector Warning Plugin ${suffix}`;
await signInAsLocalPersona(page, "admin");
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.getByRole("heading", { name: "Publish Plugin" })).toBeVisible();
await uploadPluginZip(
page,
await writePluginZip(testInfo, {
name: badName,
displayName: "Playwright Inspector Bad Plugin",
kind: "hard-error",
}),
);
await expect(page.locator("#pluginName")).toHaveValue(badName);
await page.locator("#pluginSourceCommit").fill("abc123");
await page.getByRole("button", { name: "Publish plugin" }).click();
await expect(page.getByRole("alert")).toContainText("Plugin Inspector blocked publish", {
timeout: 60_000,
});
await captureProof(page, testInfo, "01-upload-hard-error");
errors.length = 0;
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await uploadPluginZip(
page,
await writePluginZip(testInfo, {
name: warningName,
displayName: warningDisplayName,
kind: "warning",
}),
);
await expect(page.locator("#pluginName")).toHaveValue(warningName);
await page.locator("#pluginSourceCommit").fill("abc123");
await page.getByRole("button", { name: "Publish plugin" }).click();
await expect(page.getByText("Published. Pending security checks")).toBeVisible({
timeout: 60_000,
});
await captureProof(page, testInfo, "02-upload-warning-success");
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
const dashboardWarningLink = page.locator(`a[href="/plugins/${warningName}#validation"]`);
await expect(dashboardWarningLink).toBeVisible({ timeout: 30_000 });
await captureProof(page, testInfo, "03-dashboard-warning-count");
await dashboardWarningLink.click();
await expect(page).toHaveURL(new RegExp(`/plugins/${warningName}#validation$`));
await expect(page.getByRole("tab", { name: /Validation \(\d+\)/ })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByText("legacy-before-agent-start")).toBeVisible();
await expect(page.getByText("deprecation-warning")).toBeVisible();
await expect(page.getByText(/before_agent_start hook compatibility/i)).toBeVisible();
await captureProof(page, testInfo, "04-plugin-public-warnings");
await expectHealthyPage(page, errors);
});
+2
View File
@@ -14,6 +14,8 @@ const config = {
".vercel/**",
"coverage/**",
"dist/**",
// Template contract for the planned Resend integration; intentionally not called yet.
"src/lib/packageInspectorEmailTemplates.ts",
"src/routeTree.gen.ts",
"convex/_generated/**",
"packages/*/dist/**",
+2 -1
View File
@@ -12,7 +12,7 @@
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:secrets": "bun scripts/check-staged-secrets.mjs",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
@@ -78,6 +78,7 @@
"@fontsource/manrope": "5.2.8",
"@fontsource/noto-sans-sc": "5.2.9",
"@monaco-editor/react": "4.7.0",
"@openclaw/plugin-inspector": "0.3.11",
"@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-dialog": "1.1.16",
"@radix-ui/react-dropdown-menu": "2.1.17",
+1
View File
@@ -56,6 +56,7 @@ clawhub package explore --family code-plugin
clawhub package inspect @openclaw/example-plugin
clawhub package download @openclaw/example-plugin --tag latest
clawhub package verify ./example-plugin-1.0.0.tgz --package @openclaw/example-plugin --version 1.0.0
clawhub package validate ./example-plugin
clawhub package publish openclaw/example-plugin
clawhub package publish openclaw/example-plugin@v1.0.0
clawhub package publish https://github.com/openclaw/example-plugin --dry-run
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawhub",
"version": "0.20.0",
"version": "0.20.1",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
@@ -38,6 +38,7 @@
},
"dependencies": {
"@clack/prompts": "1.5.1",
"@openclaw/plugin-inspector": "0.3.11",
"arktype": "2.2.0",
"commander": "15.0.0",
"fflate": "0.8.3",
+15
View File
@@ -27,6 +27,7 @@ import {
cmdReportPackage,
cmdTransferPackage,
cmdUndeletePackage,
cmdValidatePackage,
cmdVerifyPackage,
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
@@ -573,6 +574,20 @@ registerCommand(packageCmd, ["package", "verify"])
});
});
registerCommand(packageCmd, ["package", "validate"])
.description("Validate a local plugin package with the bundled Plugin Inspector")
.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("--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")
.option("--json", "Output JSON")
.action(async (source, options) => {
const opts = await resolveGlobalOpts();
await cmdValidatePackage(opts, source, options);
});
registerCommand(packageCmd, ["package", "delete"])
.description("Soft-delete a package and all releases")
.argument("<name>", "Package name")
@@ -19,6 +19,18 @@ const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
const inspectorMocks = {
pluginRoot: {
runCheck: vi.fn(),
},
reports: {
renderTextSummary: vi.fn((report: { status?: string }) => `Plugin Inspector: ${report.status}`),
sanitizeArtifact: vi.fn((report: unknown) => report),
},
ci: {
writeOutputs: vi.fn(),
},
};
const originalOidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const originalOidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
@@ -26,6 +38,7 @@ vi.mock("../../http.js", () => httpMocks.moduleFactory());
vi.mock("../registry.js", () => registryMocks.moduleFactory());
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../ui.js", () => uiMocks.moduleFactory());
vi.mock("@openclaw/plugin-inspector", () => inspectorMocks);
const {
cmdDeletePackage,
@@ -41,6 +54,7 @@ const {
cmdReportPackage,
cmdTransferPackage,
cmdUndeletePackage,
cmdValidatePackage,
cmdVerifyPackage,
} = await import("./packages");
const {
@@ -214,6 +228,100 @@ afterEach(() => {
});
describe("package commands", () => {
it("validates a local plugin package with bundled Plugin Inspector offline", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "demo-plugin");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "package.json"), '{"name":"demo-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), "demo-plugin", {});
expect(inspectorMocks.pluginRoot.runCheck).toHaveBeenCalledWith(
expect.objectContaining({
allowExecution: false,
capture: false,
configPath: expect.stringContaining("plugin-inspector.config.json"),
mockSdk: true,
openclawPath: false,
outDir: "reports",
pluginRoot: folder,
}),
);
expect(inspectorMocks.ci.writeOutputs).toHaveBeenCalledWith(
{ status: "pass", summary: { breakageCount: 0 } },
{ cwd: join(folder, "reports"), outDir: "." },
);
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector: pass");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("fails package validation when Plugin Inspector reports hard breakages", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "broken-plugin");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "package.json"), '{"name":"broken-plugin","version":"1.0.0"}\n');
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
report: { status: "fail", summary: { breakageCount: 1 } },
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
});
await expect(cmdValidatePackage(makeOpts(workdir), "broken-plugin", {})).rejects.toThrow(
"Plugin Inspector found 1 hard error",
);
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector: fail");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("prints package validation JSON from the sanitized Plugin Inspector report", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "warning-plugin");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
'{"name":"warning-plugin","version":"1.0.0"}\n',
);
const report = {
status: "pass",
summary: { breakageCount: 0, warningCount: 1 },
issues: [{ code: "legacy-hook", level: "warning" }],
};
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
report,
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
});
inspectorMocks.reports.sanitizeArtifact.mockReturnValueOnce({
status: "pass",
issues: [{ code: "legacy-hook", level: "warning" }],
});
await cmdValidatePackage(makeOpts(workdir), "warning-plugin", { json: true });
expect(mockWrite).toHaveBeenCalledWith(
`${JSON.stringify(
{ status: "pass", issues: [{ code: "legacy-hook", level: "warning" }] },
null,
2,
)}\n`,
);
expect(mockLog).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("searches package catalog via /api/v1/packages/search", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
results: [
@@ -2175,6 +2283,99 @@ describe("package commands", () => {
}
});
it("fails CLI publish on server Plugin Inspector hard errors", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "broken-plugin");
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "broken-plugin",
displayName: "Broken Plugin",
version: "1.0.0",
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "broken.plugin" }),
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
httpMocks.apiRequestForm.mockRejectedValueOnce(
new Error(
"Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
),
);
await expect(
cmdPublishPackage(makeOpts(workdir), "broken-plugin", {
sourceRepo: "openclaw/broken-plugin",
sourceCommit: "deadbeef",
}),
).rejects.toThrow("Plugin Inspector blocked publish");
expect(uiMocks.spinner.fail).toHaveBeenCalledWith(
"Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
);
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("prints Plugin Inspector warnings for successful CLI publishes", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "warning-plugin");
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "warning-plugin",
displayName: "Warning Plugin",
version: "1.0.0",
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "warning.plugin" }),
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
inspectorFindings: [
{
findingKind: "warning",
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
message: "legacy before_agent_start hook is deprecated",
},
],
});
await cmdPublishPackage(makeOpts(workdir), "warning-plugin", {
sourceRepo: "openclaw/warning-plugin",
sourceCommit: "abc123",
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Published warning-plugin@1.0.0 (rel_1)",
);
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector findings: 1 warning");
expect(mockLog).toHaveBeenCalledWith(
"- WARNING legacy-before-agent-start (deprecation-warning): legacy before_agent_start hook is deprecated",
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("auto-detects local git source metadata and matches the explicit payload", async () => {
const workdir = await makeTmpWorkdir();
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(987_654_321);
+172 -1
View File
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { ci, pluginRoot, reports } from "@openclaw/plugin-inspector";
import ignore from "ignore";
import mime from "mime";
import semver from "semver";
@@ -25,6 +26,7 @@ import {
ApiV1PackageListResponseSchema,
ApiV1PackageModerationStatusResponseSchema,
ApiV1PackagePublishResponseSchema,
type ApiV1PackagePublishResponse,
ApiV1PackageReadinessResponseSchema,
ApiV1PackageReportResponseSchema,
ApiV1PackageResponseSchema,
@@ -121,6 +123,15 @@ type PackagePackOptions = {
json?: boolean;
};
type PackageValidateOptions = {
out?: string;
openclaw?: string;
runtime?: boolean;
allowExecute?: boolean;
mockSdk?: boolean;
json?: boolean;
};
type PackageDownloadOptions = {
version?: string;
tag?: string;
@@ -575,6 +586,124 @@ export async function cmdPackPackage(
}
}
export async function cmdValidatePackage(
opts: GlobalOpts,
sourceArg: string,
options: PackageValidateOptions = {},
) {
if (!sourceArg?.trim()) fail("Path required");
const resolvedSource = await resolveSourceInput(sourceArg, {
workdir: opts.workdir,
localWorkdirs: [process.cwd(), opts.workdir],
});
if (resolvedSource.kind !== "local") fail("Path must be a package folder");
const sourcePath = resolvedSource.path;
const sourceStat = await stat(sourcePath).catch(() => null);
if (!sourceStat?.isDirectory()) fail("Path must be a package folder");
const outDir = options.out?.trim() || "reports";
const openclawPath = options.openclaw?.trim() ? resolve(opts.workdir, options.openclaw) : false;
const generatedConfig = await createPluginInspectorConfigIfNeeded(sourcePath);
let report: Awaited<ReturnType<typeof pluginRoot.runCheck>>["report"];
let paths: Awaited<ReturnType<typeof pluginRoot.runCheck>>["paths"];
try {
const result = await pluginRoot.runCheck({
allowExecution: options.allowExecute === true,
capture: options.runtime === true,
configPath: generatedConfig?.path,
mockSdk: options.mockSdk !== false,
openclawPath,
outDir,
pluginRoot: sourcePath,
});
report = result.report;
paths = result.paths;
} finally {
if (generatedConfig) {
await rm(generatedConfig.dir, { recursive: true, force: true });
}
}
await ci.writeOutputs(report, {
cwd: dirname(paths.jsonPath),
outDir: ".",
});
if (options.json) {
process.stdout.write(`${JSON.stringify(reports.sanitizeArtifact(report), null, 2)}\n`);
} else {
console.log(reports.renderTextSummary(report, { artifacts: paths }));
}
if (reportStatus(report) !== "pass") {
const breakageCount = reportBreakageCount(report);
throw new Error(
`Plugin Inspector found ${breakageCount} hard error${breakageCount === 1 ? "" : "s"}`,
);
}
}
async function createPluginInspectorConfigIfNeeded(sourcePath: string) {
if (
(await fileExists(join(sourcePath, "plugin-inspector.config.json"))) ||
(await fileExists(join(sourcePath, ".plugin-inspector.json")))
) {
return null;
}
const packageJson = await readJsonFile(join(sourcePath, "package.json"));
const pluginManifest = await readJsonFile(join(sourcePath, "openclaw.plugin.json"));
if (!packageJson && !pluginManifest) {
return null;
}
if (hasPackagePluginInspectorConfig(packageJson)) {
return null;
}
const rawName =
packageJsonString(packageJson, "name") ??
packageJsonString(pluginManifest, "id") ??
basename(sourcePath);
const configDir = await mkdtemp(join(tmpdir(), "clawhub-plugin-inspector-config-"));
const configPath = join(configDir, "plugin-inspector.config.json");
await writeFile(
configPath,
`${JSON.stringify(
{
version: 1,
plugin: {
id: pluginInspectorFixtureId(rawName),
},
},
null,
2,
)}\n`,
);
return { dir: configDir, path: configPath };
}
async function fileExists(path: string) {
return Boolean(await stat(path).catch(() => null));
}
function hasPackagePluginInspectorConfig(packageJson: Record<string, unknown> | null) {
if (!packageJson) return false;
return (
isPlainRecord(packageJson.pluginInspector) || isPlainRecord(packageJson["plugin-inspector"])
);
}
function pluginInspectorFixtureId(rawName: string) {
return (
rawName
.split("/")
.pop()
?.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "published-plugin"
);
}
async function createClawPackFromFolder(options: {
sourcePath: string;
packDestination: string;
@@ -726,12 +855,21 @@ export async function cmdPublishPackage(
if (options.json) {
process.stdout.write(
`${JSON.stringify({ ...plan.output, releaseId: result.releaseId }, null, 2)}\n`,
`${JSON.stringify(
{
...plan.output,
releaseId: result.releaseId,
inspectorFindings: result.inspectorFindings,
},
null,
2,
)}\n`,
);
} else {
spinner?.succeed(
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
);
printPackageInspectorFindings(result);
}
} catch (error) {
spinner?.fail(formatError(error));
@@ -742,6 +880,25 @@ export async function cmdPublishPackage(
}
}
function printPackageInspectorFindings(result: ApiV1PackagePublishResponse) {
const findings = result.inspectorFindings ?? [];
if (findings.length === 0) return;
const errorCount = findings.filter((finding) => finding.findingKind === "error").length;
const warningCount = findings.length - errorCount;
const parts = [
warningCount > 0 ? `${warningCount} warning${warningCount === 1 ? "" : "s"}` : null,
errorCount > 0 ? `${errorCount} error${errorCount === 1 ? "" : "s"}` : null,
].filter((part): part is string => Boolean(part));
console.log(`Plugin Inspector findings: ${parts.join(", ")}`);
for (const finding of findings.slice(0, 10)) {
const label = finding.issueClass ? `${finding.code} (${finding.issueClass})` : finding.code;
console.log(`- ${finding.findingKind.toUpperCase()} ${label}: ${finding.message}`);
}
if (findings.length > 10) {
console.log(`- ...and ${findings.length - 10} more findings`);
}
}
export async function cmdDownloadPackage(
opts: GlobalOpts,
packageName: string,
@@ -1243,6 +1400,20 @@ function normalizePackageNameOrFail(raw: string) {
return trimmed;
}
function reportStatus(report: unknown): string | null {
return isPlainRecord(report) && typeof report.status === "string" ? report.status : null;
}
function reportBreakageCount(report: unknown): number {
if (!isPlainRecord(report) || !isPlainRecord(report.summary)) return 0;
const value = report.summary.breakageCount;
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function spinnerText(spinner: ReturnType<typeof createSpinner> | null, text: string) {
if (spinner) spinner.text = text;
}
+12
View File
@@ -841,6 +841,18 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
severity: "string?",
level: "string?",
issueClass: "string?",
message: "string",
inspectorVersion: "string?",
targetOpenClawVersion: "string?",
})
.array()
.optional(),
});
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
+44
View File
@@ -0,0 +1,44 @@
declare module "@openclaw/plugin-inspector" {
export type PluginInspectorReport = {
status?: string;
summary?: {
breakageCount?: number;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type PluginInspectorPaths = {
jsonPath: string;
markdownPath?: string;
issuesPath?: string;
[key: string]: unknown;
};
export const pluginRoot: {
runCheck(options: {
allowExecution?: boolean;
capture?: boolean;
configPath?: string;
mockSdk?: boolean;
openclawPath?: string | false;
outDir?: string;
pluginRoot?: string;
}): Promise<{
report: PluginInspectorReport;
paths: PluginInspectorPaths;
}>;
};
export const reports: {
renderTextSummary(report: PluginInspectorReport, options?: Record<string, unknown>): string;
sanitizeArtifact(report: PluginInspectorReport): unknown;
};
export const ci: {
writeOutputs(
report: PluginInspectorReport,
options?: Record<string, unknown>,
): Promise<unknown>;
};
}
+10
View File
@@ -1050,6 +1050,16 @@ export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal
ok: true;
packageId: string;
releaseId: string;
inspectorFindings?: {
findingKind: "error" | "warning";
code: string;
message: string;
severity?: string | undefined;
level?: string | undefined;
issueClass?: string | undefined;
inspectorVersion?: string | undefined;
targetOpenClawVersion?: string | undefined;
}[] | undefined;
}, {}>;
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
export declare const PackageTrustedPublisherUpsertRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+12
View File
@@ -686,6 +686,18 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
severity: "string?",
level: "string?",
issueClass: "string?",
message: "string",
inspectorVersion: "string?",
targetOpenClawVersion: "string?",
})
.array()
.optional(),
});
export const PackageTrustedPublisherUpsertRequestSchema = type({
repository: "string",
File diff suppressed because one or more lines are too long
+12
View File
@@ -871,6 +871,18 @@ export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
inspectorFindings: type({
findingKind: '"warning"|"error"',
code: "string",
severity: "string?",
level: "string?",
issueClass: "string?",
message: "string",
inspectorVersion: "string?",
targetOpenClawVersion: "string?",
})
.array()
.optional(),
});
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
+471
View File
@@ -0,0 +1,471 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
type ClaimItem = {
packageId: string;
releaseId: string;
ownerUserId?: string;
ownerPublisherId?: string;
packageName: string;
version: string;
artifactKind: string;
downloadUrl: string;
};
type ClaimResponse = {
ok: true;
leased: boolean;
dryRun?: boolean;
nextCursor?: string | null;
items: ClaimItem[];
};
type NormalizedFinding = {
id?: string;
code: string;
level: string;
severity?: string;
issueClass?: string;
compatStatus?: string;
deprecated?: boolean;
message: string;
evidence?: string[];
fixture?: string;
decision?: string;
};
type ImpactEntry = {
packageName: string;
version: string;
ownerUserId?: string;
ownerPublisherId?: string;
findingCount: number;
errorCount: number;
warningCount: number;
targetOpenClawVersion?: string;
findings: NormalizedFinding[];
};
const siteUrl = (process.env.CLAWHUB_SITE_URL ?? "https://clawhub.ai").replace(/\/+$/, "");
const token = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN;
const batchSize = process.env.PLUGIN_INSPECTOR_BATCH_SIZE ?? "25";
const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN);
const dryRunMaxBatches = Math.max(
1,
Math.min(
Number.parseInt(process.env.PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES ?? "20", 10) || 20,
100,
),
);
const inspectorVersion =
process.env.PLUGIN_INSPECTOR_VERSION ?? resolveBundledPluginInspectorVersion();
const artifactRoot =
process.env.PLUGIN_INSPECTOR_ARTIFACT_DIR ?? "plugin-inspector-nightly-reports";
const repoRoot = path.resolve(process.env.GITHUB_WORKSPACE ?? process.cwd());
const clawhubCliEntry = path.join(repoRoot, "packages", "clawhub", "src", "cli.ts");
if (!token) throw new Error("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN is required");
await mkdir(artifactRoot, { recursive: true });
let hadWorkerFailure = false;
const impactEntries: ImpactEntry[] = [];
let claimed = 0;
let scanned = 0;
let cursor: string | null = null;
let batches = 0;
let truncated = false;
do {
const claim = await claimBatch(cursor);
batches += 1;
cursor = claim.nextCursor ?? null;
claimed += claim.items.length;
for (const item of claim.items) {
const workRoot = path.join(
tmpdir(),
`clawhub-plugin-inspector-nightly-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const pluginRoot = path.join(workRoot, "plugin");
const reportDir = path.resolve(
artifactRoot,
safeArtifactName(`${item.packageName}-${item.version}`),
);
await mkdir(pluginRoot, { recursive: true });
await mkdir(reportDir, { recursive: true });
try {
const artifactPath = path.join(
workRoot,
item.artifactKind === "npm-pack" ? "plugin.tgz" : "plugin.zip",
);
const artifact = await fetch(item.downloadUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!artifact.ok) {
throw new Error(`download failed ${artifact.status}: ${await artifact.text()}`);
}
await writeFile(artifactPath, Buffer.from(await artifact.arrayBuffer()));
if (item.artifactKind === "npm-pack") {
run("tar", ["-xzf", artifactPath, "-C", pluginRoot, "--strip-components=1"]);
} else {
run("unzip", ["-q", artifactPath, "-d", pluginRoot]);
}
const scanRoot =
item.artifactKind === "legacy-zip" && existsSync(path.join(pluginRoot, "package"))
? path.join(pluginRoot, "package")
: pluginRoot;
await writeSyntheticConfigIfNeeded(scanRoot, item.packageName);
const scan = spawnSync(
"bun",
[clawhubCliEntry, "package", "validate", scanRoot, "--out", reportDir, "--json"],
{ cwd: repoRoot, encoding: "utf8" },
);
await writeFile(path.join(reportDir, "stdout.txt"), scan.stdout ?? "");
await writeFile(path.join(reportDir, "stderr.txt"), scan.stderr ?? "");
const reportPath = path.join(reportDir, "plugin-inspector-report.json");
if (!existsSync(reportPath)) {
throw new Error(
scan.stderr || scan.stdout || `clawhub package validate exited ${scan.status}`,
);
}
const report = JSON.parse(await readFile(reportPath, "utf8"));
const findings = normalizeFindings(report);
const targetOpenClawVersion = extractTargetOpenClawVersion(report.targetOpenClaw);
scanned += 1;
if (dryRun) {
impactEntries.push(toImpactEntry(item, findings, targetOpenClawVersion));
}
if (!dryRun) {
await postJson(`${siteUrl}/api/v1/package-inspector/results`, {
packageId: item.packageId,
releaseId: item.releaseId,
inspectorVersion,
targetOpenClawVersion,
findings,
});
}
} catch (error) {
hadWorkerFailure = true;
const message = error instanceof Error ? error.message : String(error);
await writeFile(path.join(reportDir, "error.txt"), message);
console.error(
`Nightly Plugin Inspector worker failed for ${item.packageName}@${item.version}`,
);
console.error(message);
} finally {
await rm(workRoot, { recursive: true, force: true });
}
}
if (!dryRun) break;
if (cursor && batches >= dryRunMaxBatches) {
truncated = true;
break;
}
} while (dryRun && cursor);
if (dryRun) {
const summary = summarizeImpact({
claimed,
scanned,
batches,
truncated,
nextCursor: cursor,
entries: impactEntries,
});
await writeFile(
path.join(artifactRoot, "impact-summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
console.log(
`Dry run scanned ${summary.scannedReleases} latest plugin releases: ${summary.pluginsWithErrors} with errors, ${summary.pluginsWithWarnings} with warnings, ${summary.impactedOwners} owner(s) impacted.`,
);
}
if (hadWorkerFailure) {
process.exitCode = 1;
}
async function claimBatch(cursor: string | null) {
const url = new URL(`${siteUrl}/api/v1/package-inspector/claim`);
url.searchParams.set("batchSize", batchSize);
url.searchParams.set("dryRun", dryRun ? "true" : "false");
if (dryRun && cursor) url.searchParams.set("cursor", cursor);
return await postJson<ClaimResponse>(url.toString(), {});
}
async function postJson<T = unknown>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`POST ${url} failed ${response.status}: ${await response.text()}`);
}
return (await response.json()) as T;
}
async function writeSyntheticConfigIfNeeded(root: string, packageName: string) {
if (
existsSync(path.join(root, "plugin-inspector.config.json")) ||
existsSync(path.join(root, ".plugin-inspector.json"))
) {
return;
}
const packageJson = await readJsonIfExists(path.join(root, "package.json"));
if (hasInspectorConfig(packageJson)) {
return;
}
await writeFile(
path.join(root, ".plugin-inspector.json"),
`${JSON.stringify({ version: 1, plugin: { id: safeArtifactName(packageName) } }, null, 2)}\n`,
);
}
async function readJsonIfExists(filePath: string) {
if (!existsSync(filePath)) return null;
return JSON.parse(await readFile(filePath, "utf8")) as unknown;
}
function hasInspectorConfig(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return isPlainObject(record.pluginInspector) || isPlainObject(record["plugin-inspector"]);
}
function isPlainObject(value: unknown) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeFindings(report: Record<string, unknown>): NormalizedFinding[] {
const issues = Array.isArray(report.issues)
? report.issues.map((issue) => normalizeFinding(issue, "warning")).filter(isFinding)
: [];
if (issues.length > 0) return issues;
return [
...normalizeFindingArray(report.breakages, "breakage"),
...normalizeFindingArray(report.warnings, "warning"),
...normalizeFindingArray(report.suggestions, "warning"),
];
}
function normalizeFindingArray(value: unknown, fallbackLevel: string) {
return Array.isArray(value)
? value.map((finding) => normalizeFinding(finding, fallbackLevel)).filter(isFinding)
: [];
}
function normalizeFinding(value: unknown, fallbackLevel: string): NormalizedFinding | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const message = stringValue(record.message) ?? stringValue(record.title);
const code = stringValue(record.code) ?? "plugin-inspector-finding";
if (!message) return null;
const level =
stringValue(record.level) ??
(record.status === "blocking" || fallbackLevel === "breakage" ? "breakage" : "warning");
return {
id: stringValue(record.id),
code,
level,
severity: stringValue(record.severity),
issueClass: stringValue(record.issueClass),
compatStatus: stringValue(record.compatStatus),
deprecated: typeof record.deprecated === "boolean" ? record.deprecated : undefined,
message,
evidence: Array.isArray(record.evidence) ? record.evidence.map(String).slice(0, 12) : undefined,
fixture: stringValue(record.fixture),
decision: stringValue(record.decision),
};
}
function isFinding(value: NormalizedFinding | null): value is NormalizedFinding {
return value !== null;
}
function toImpactEntry(
item: ClaimItem,
findings: NormalizedFinding[],
targetOpenClawVersion: string | undefined,
): ImpactEntry {
let errorCount = 0;
let warningCount = 0;
for (const finding of findings) {
if (isErrorFinding(finding)) errorCount += 1;
else warningCount += 1;
}
return {
packageName: item.packageName,
version: item.version,
ownerUserId: item.ownerUserId,
ownerPublisherId: item.ownerPublisherId,
findingCount: findings.length,
errorCount,
warningCount,
targetOpenClawVersion,
findings,
};
}
function isErrorFinding(finding: Pick<NormalizedFinding, "level" | "severity">) {
return finding.level === "breakage" || finding.level === "error" || finding.severity === "P0";
}
function summarizeImpact(args: {
claimed: number;
scanned: number;
batches: number;
truncated: boolean;
nextCursor: string | null;
entries: ImpactEntry[];
}) {
const impactedOwners = new Set<string>();
const frequency = new Map<
string,
{ code: string; count: number; errorCount: number; warningCount: number }
>();
let pluginsWithErrors = 0;
let pluginsWithWarnings = 0;
let totalErrors = 0;
let totalWarnings = 0;
for (const entry of args.entries) {
if (entry.findingCount > 0 && entry.ownerUserId) impactedOwners.add(entry.ownerUserId);
if (entry.errorCount > 0) pluginsWithErrors += 1;
if (entry.warningCount > 0) pluginsWithWarnings += 1;
totalErrors += entry.errorCount;
totalWarnings += entry.warningCount;
for (const finding of entry.findings) {
const current = frequency.get(finding.code) ?? {
code: finding.code,
count: 0,
errorCount: 0,
warningCount: 0,
};
current.count += 1;
if (isErrorFinding(finding)) current.errorCount += 1;
else current.warningCount += 1;
frequency.set(finding.code, current);
}
}
return {
dryRun,
generatedAt: new Date().toISOString(),
siteUrl,
inspectorVersion,
batchSize: Number.parseInt(batchSize, 10) || batchSize,
batches: args.batches,
truncated: args.truncated,
nextCursor: args.nextCursor,
claimedReleases: args.claimed,
scannedReleases: args.scanned,
pluginsWithFindings: args.entries.filter((entry) => entry.findingCount > 0).length,
pluginsWithErrors,
pluginsWithWarnings,
impactedOwners: impactedOwners.size,
totalErrors,
totalWarnings,
findingFrequency: [...frequency.values()].sort((a, b) => b.count - a.count),
packages: args.entries.filter((entry) => entry.findingCount > 0),
};
}
function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>) {
const lines = [
"# Plugin Inspector Nightly Dry Run",
"",
`- Generated: ${summary.generatedAt}`,
`- Site: ${summary.siteUrl}`,
`- Inspector: ${summary.inspectorVersion}`,
`- Scanned latest releases: ${summary.scannedReleases}`,
`- Plugins with errors: ${summary.pluginsWithErrors}`,
`- Plugins with warnings: ${summary.pluginsWithWarnings}`,
`- Impacted owners: ${summary.impactedOwners}`,
`- Truncated: ${summary.truncated ? "yes" : "no"}`,
"",
"## Finding Frequency",
"",
];
if (summary.findingFrequency.length === 0) {
lines.push("No findings.");
} else {
lines.push("| Code | Count | Errors | Warnings |", "| --- | ---: | ---: | ---: |");
for (const finding of summary.findingFrequency) {
lines.push(
`| ${finding.code} | ${finding.count} | ${finding.errorCount} | ${finding.warningCount} |`,
);
}
}
lines.push("", "## Impacted Plugins", "");
if (summary.packages.length === 0) {
lines.push("No impacted plugins.");
} else {
lines.push(
"| Plugin | Version | Errors | Warnings | Target OpenClaw |",
"| --- | --- | ---: | ---: | --- |",
);
for (const entry of summary.packages) {
lines.push(
`| ${entry.packageName} | ${entry.version} | ${entry.errorCount} | ${entry.warningCount} | ${entry.targetOpenClawVersion ?? ""} |`,
);
}
}
return `${lines.join("\n")}\n`;
}
function extractTargetOpenClawVersion(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
return (
stringValue(record.version) ??
stringValue(record.openclawVersion) ??
stringValue(record.label) ??
stringValue(record.status)
);
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function safeArtifactName(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "plugin"
);
}
function parseBoolean(value: string | undefined) {
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
}
function run(command: string, args: string[]) {
const result = spawnSync(command, args, { stdio: "pipe", encoding: "utf8" });
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
}
}
function resolveBundledPluginInspectorVersion() {
const require = createRequire(import.meta.url);
const entry = require.resolve("@openclaw/plugin-inspector");
const packageJsonPath = path.resolve(path.dirname(entry), "..", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version?: unknown;
};
if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
throw new Error("Unable to resolve bundled @openclaw/plugin-inspector version");
}
return packageJson.version.trim();
}
+21 -12
View File
@@ -92,14 +92,6 @@ async function resolveAppPort() {
throw new Error(`No available preview port found starting at ${requested}.`);
}
function requireLocalUrlPort(url: string, label: string) {
const parsed = new URL(url);
if (!parsed.port) {
throw new Error(`${label} must include an explicit port: ${url}`);
}
return parsed.port;
}
function buildAuthKeys() {
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
@@ -137,6 +129,24 @@ function devAuthDeploymentMarker(deployment: string) {
return deployment.startsWith("anonymous-") ? `anonymous:${deployment}` : deployment;
}
function getLocalUrlPort(url: string, label: string) {
const parsed = new URL(url);
const isLocalhost =
parsed.hostname === "127.0.0.1" ||
parsed.hostname === "localhost" ||
parsed.hostname === "::1" ||
parsed.hostname === "[::1]";
if (!isLocalhost) {
throw new Error(`${label} must be a localhost URL for the local-auth runner: ${url}`);
}
const port = Number(parsed.port);
if (!Number.isInteger(port) || port <= 0) {
throw new Error(`${label} must include an explicit port: ${url}`);
}
return port;
}
function spawnManaged(command: string, args: string[], env: NodeJS.ProcessEnv) {
const child = spawn(command, args, {
cwd: process.cwd(),
@@ -320,10 +330,9 @@ async function main() {
const appUrl = `http://127.0.0.1:${appPort}`;
const convexUrl = runnerConfig.convexUrl;
const convexSiteUrl = runnerConfig.convexSiteUrl;
const convexCloudPort = requireLocalUrlPort(convexUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_URL");
const convexSitePort = requireLocalUrlPort(
convexSiteUrl,
"PLAYWRIGHT_LOCAL_AUTH_CONVEX_SITE_URL",
const convexCloudPort = String(getLocalUrlPort(convexUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_URL"));
const convexSitePort = String(
getLocalUrlPort(convexSiteUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_SITE_URL"),
);
if (await isReachable(convexUrl)) {
throw new Error(
+208 -8
View File
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getFunctionName } from "convex/server";
import type { AnchorHTMLAttributes, ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -17,6 +18,7 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
);
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
let pathnameMock = "/plugins/demo-plugin";
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -63,10 +65,8 @@ vi.mock("@tanstack/react-router", () => ({
select,
}: {
select?: (state: { location: { pathname: string } }) => string;
}) =>
select
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
: `/plugins/${paramsMock.name}`,
}) => (select ? select({ location: { pathname: pathnameMock } }) : pathnameMock),
Outlet: () => <div data-testid="nested-plugin-route" />,
Link: ({
children,
to,
@@ -128,6 +128,8 @@ async function loadRoute() {
describe("plugin detail route", () => {
beforeEach(() => {
paramsMock = { name: "demo-plugin" };
pathnameMock = "/plugins/demo-plugin";
window.location.hash = "";
vi.mocked(fetchPackageDetail).mockReset();
vi.mocked(fetchPackageReadme).mockReset();
vi.mocked(fetchPackageVersion).mockReset();
@@ -419,14 +421,13 @@ describe("plugin detail route", () => {
sidebarMetadata?.querySelectorAll(".sidebar-metadata-label") ?? [],
(label) => label.textContent?.trim(),
);
const capabilitiesTab = screen.getByRole("tab", { name: "Capabilities" });
const securityAuditLabelIndex = sidebarLabels.findIndex((label) =>
label?.startsWith("Security audit"),
);
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Downloads"));
fireEvent.click(capabilitiesTab);
expect(screen.getByText("Tags")).toBeTruthy();
expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull();
});
it("does not render owner-only plugin scanner rerun state in the detail security summary", async () => {
@@ -596,6 +597,205 @@ describe("plugin detail route", () => {
);
});
it("shows a public incompatibility alert without exposing validation outputs", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
useQueryMock.mockImplementation((query: unknown) => {
const name = getFunctionName(query as never);
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
return {
findingCount: 2,
errorCount: 1,
warningCount: 1,
incompatibleAfterOpenClawVersion: "0.9.0",
};
}
return null;
});
loaderDataMock = {
detail: {
package: {
...loaderDataMock.detail.package!,
latestVersion: "1.0.0",
},
owner: null,
},
version: {
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
},
version: {
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [],
compatibility: null,
capabilities: null,
verification: null,
artifact: null,
sha256hash: null,
vtAnalysis: null,
llmAnalysis: null,
staticScan: null,
},
},
readme: null,
rateLimited: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(
screen.getByText("This plugin is incompatible with OpenClaw versions greater than 0.9.0."),
).toBeTruthy();
expect(screen.queryByRole("tab", { name: /Validation/ })).toBeNull();
expect(screen.queryByText("missing-expected-seam")).toBeNull();
});
it("shows validation outputs to plugin managers on the validation tab", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:owner" },
});
useQueryMock.mockImplementation((query: unknown) => {
const name = getFunctionName(query as never);
if (name === "packages:getManageContext") {
return {
package: { name: "demo-plugin", displayName: "Demo Plugin" },
latestRelease: { version: "1.0.0" },
};
}
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
return {
findingCount: 2,
errorCount: 1,
warningCount: 1,
incompatibleAfterOpenClawVersion: "0.9.0",
};
}
if (name === "packages:listPackageInspectorWarningsForManager") {
return [
{
packageName: "demo-plugin",
version: "1.0.0",
findingKind: "warning",
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
severity: "P2",
message: "legacy before_agent_start hook is deprecated",
evidence: ["src/index.ts:4"],
inspectorVersion: "0.4.0",
targetOpenClawVersion: "0.9.0",
scanSource: "nightly",
createdAt: 1,
},
{
packageName: "demo-plugin",
version: "1.0.0",
findingKind: "error",
code: "missing-expected-seam",
issueClass: "compatibility-error",
severity: "P0",
message: "registerTool is no longer available",
evidence: ["dist/index.js:2"],
inspectorVersion: "0.4.0",
targetOpenClawVersion: "0.9.0",
scanSource: "nightly",
createdAt: 2,
},
];
}
return null;
});
loaderDataMock = {
detail: {
package: {
...loaderDataMock.detail.package!,
latestVersion: "1.0.0",
},
owner: null,
},
version: {
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
},
version: {
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [],
compatibility: null,
capabilities: null,
verification: null,
artifact: null,
sha256hash: null,
vtAnalysis: null,
llmAnalysis: null,
staticScan: null,
},
},
readme: null,
rateLimited: null,
};
window.location.hash = "#validation";
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("tab", { name: "Validation (2)" })).toBeTruthy();
expect(screen.queryByRole("link", { name: "2 warnings" })).toBeNull();
expect(
screen.getByText(
/Validation outputs are only visible to plugin owners and admins. Run locally using the CLI:/,
),
).toBeTruthy();
expect(screen.getByText("clawhub package validate <path-to-plugin>")).toBeTruthy();
expect(screen.getByText("legacy-before-agent-start")).toBeTruthy();
expect(screen.getByText("missing-expected-seam")).toBeTruthy();
expect(screen.getByText("registerTool is no longer available")).toBeTruthy();
expect(screen.getAllByText("OpenClaw 0.9.0").length).toBeGreaterThan(0);
});
it("does not show validation outputs to signed-out viewers when the hash changes", async () => {
useQueryMock.mockImplementation((query: unknown) => {
const name = getFunctionName(query as never);
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
return {
findingCount: 1,
errorCount: 0,
warningCount: 1,
incompatibleAfterOpenClawVersion: null,
};
}
return null;
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.queryByText("legacy-before-agent-start")).toBeNull();
await act(async () => {
window.location.hash = "#validation";
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(screen.queryByText("legacy-before-agent-start")).toBeNull();
});
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
loaderDataMock = {
detail: { package: null, owner: null },
@@ -0,0 +1,65 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("package publish workflow", () => {
it("runs plugin-inspector before publishing and uploads inspector artifacts", () => {
const workflow = readFileSync(resolve(".github/workflows/package-publish.yml"), "utf8");
const inspectorIndex = workflow.indexOf("Run plugin validation");
const publishIndex = workflow.indexOf("Run package publish");
const checkoutPublishSourceIndex = workflow.indexOf(
"Checkout publish source for plugin inspector",
);
expect(inspectorIndex).toBeGreaterThan(-1);
expect(publishIndex).toBeGreaterThan(-1);
expect(checkoutPublishSourceIndex).toBeGreaterThan(-1);
expect(checkoutPublishSourceIndex).toBeLessThan(inspectorIndex);
expect(inspectorIndex).toBeLessThan(publishIndex);
expect(workflow).toContain("inspect_checkout_repository");
expect(workflow).toContain("clawhub-publish-source");
expect(workflow).toContain("INSPECT_LOCAL_ROOT");
expect(workflow).toContain("source_ref_differs_from_checkout");
expect(workflow).toContain("resolve_github_url_ref_and_path");
expect(workflow).toContain("quote(ref, safe='')");
expect(workflow).toContain("error.code in (404, 422)");
expect(workflow).toContain("package validate");
expect(workflow).not.toContain('config_path = root / ".plugin-inspector.json"');
expect(workflow).not.toContain("generated_config_path.write_text(str(config_path)");
expect(workflow).not.toContain("cleanup_generated_inspector_config");
expect(workflow).toContain("plugin-inspector-report");
expect(workflow).toContain("actions/upload-artifact");
});
it("runs nightly plugin inspector rescans with the bundled CLI validator", () => {
const workflow = readFileSync(
resolve(".github/workflows/plugin-inspector-nightly.yml"),
"utf8",
);
const script = readFileSync(resolve("scripts/package-inspector-nightly-scan.ts"), "utf8");
const http = readFileSync(resolve("convex/packageInspectorHttp.ts"), "utf8");
expect(workflow).toContain("schedule:");
expect(workflow).toContain("bun install --frozen-lockfile");
expect(workflow).toContain("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN");
expect(script).toContain("package-inspector/claim");
expect(script).toContain('"package", "validate"');
expect(script).toContain("resolveBundledPluginInspectorVersion");
expect(http).toContain("package-inspector/artifact");
expect(script).toContain("package-inspector/results");
expect(script).toContain("Authorization: `Bearer ${token}`");
expect(script).toContain('path.join(pluginRoot, "package")');
expect(script).not.toContain("plugin-inspector-nightly-error");
expect(script).toContain("pluginInspector");
expect(workflow).toContain("dry_run:");
expect(workflow).toContain("PLUGIN_INSPECTOR_DRY_RUN");
expect(workflow).toContain("PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES");
expect(script).toContain("const dryRun =");
expect(script).toContain('dryRun ? "true" : "false"');
expect(script).toContain("impact-summary.json");
expect(script).toContain("summarizeImpact");
expect(script).toContain("if (!dryRun) {");
expect(workflow).toContain("actions/upload-artifact");
});
});
@@ -5,6 +5,10 @@ import { DocsLinks } from "clawhub-schema";
import { createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: (path: string) => (config: { component: unknown }) => ({
__config: config,
@@ -24,6 +28,12 @@ vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({ signIn: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
},
}));
const generateUploadUrl = vi.fn();
const publishRelease = vi.fn();
const fetchMock = vi.fn();
@@ -88,6 +98,7 @@ describe("plugins publish route", () => {
fetchMock.mockReset();
useAuthStatusMock.mockReset();
useQueryMock.mockReset();
toastErrorMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
@@ -282,6 +293,59 @@ describe("plugins publish route", () => {
});
});
it("shows backend publish failures inline on the upload form", async () => {
publishRelease.mockRejectedValueOnce(
new Error(
"Plugin Inspector blocked publish: 1 breakage. missing-expected-seam: missing expected registration registerTool",
),
);
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo-plugin/openclaw.plugin.json",
);
const dist = withRelativePath(
new File(["export const demo = true;\n"], "index.js", { type: "text/javascript" }),
"demo-plugin/dist/index.js",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, dist] } });
await waitFor(() => {
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc123" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("Plugin Inspector blocked publish");
});
expect(screen.getByRole("columnheader", { name: "Code" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Message" })).toBeTruthy();
expect(screen.getByText("missing-expected-seam")).toBeTruthy();
expect(screen.getByText("missing expected registration registerTool")).toBeTruthy();
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("surfaces missing OpenClaw compatibility metadata before publish", async () => {
renderPublishRoute();
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
renderPluginInspectorBlockedPublishEmail,
renderPluginInspectorWarningsEmail,
} from "./packageInspectorEmailTemplates";
describe("package inspector email templates", () => {
it("renders blocked publish copy with hard findings", () => {
const email = renderPluginInspectorBlockedPublishEmail({
packageName: "demo-plugin",
version: "1.0.0",
findings: [
{
code: "missing-expected-seam",
message: "missing expected registration registerTool",
},
],
});
expect(email.subject).toContain("demo-plugin");
expect(email.text).toContain("blocked");
expect(email.text).toContain("missing-expected-seam");
expect(email.text).toContain("missing expected registration registerTool");
});
it("renders warning-only publish copy with non-blocking findings", () => {
const email = renderPluginInspectorWarningsEmail({
packageName: "demo-plugin",
version: "1.0.0",
warningUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
inspectorVersion: "0.4.0",
targetOpenClawVersion: "0.9.0",
warnings: [
{
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
message: "legacy before_agent_start hook is deprecated",
},
],
});
expect(email.subject).toContain("findings");
expect(email.text).toContain("published");
expect(email.text).toContain("legacy-before-agent-start");
expect(email.text).toContain("https://clawhub.ai/plugins/demo-plugin#validation");
expect(email.html).toContain("<html");
expect(email.html).toContain("Plugin Inspector findings");
expect(email.html).toContain("0.4.0");
expect(email.html).toContain("0.9.0");
});
it("renders nightly warning and error findings as rich HTML", () => {
const email = renderPluginInspectorWarningsEmail({
packageName: "demo-plugin",
version: "1.0.1",
warningUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
inspectorVersion: "0.5.0",
targetOpenClawVersion: "0.10.0",
intro:
"A nightly Plugin Inspector rescan found compatibility findings for an already published plugin.",
warnings: [
{
code: "legacy-before-agent-start",
issueClass: "deprecation-warning",
message: "legacy before_agent_start hook is deprecated",
},
{
code: "missing-expected-seam",
issueClass: "compatibility-error",
level: "breakage",
message: "registerTool is no longer available",
},
],
});
expect(email.subject).toContain("findings");
expect(email.text).toContain("nightly Plugin Inspector rescan");
expect(email.text).toContain("missing-expected-seam");
expect(email.html).toContain("compatibility-error");
expect(email.html).toContain("registerTool is no longer available");
});
});
+138
View File
@@ -0,0 +1,138 @@
type EmailFinding = {
code: string;
issueClass?: string;
level?: string;
severity?: string;
message: string;
};
type InspectorEmail = {
subject: string;
text: string;
html: string;
};
export function renderPluginInspectorBlockedPublishEmail(args: {
packageName: string;
version: string;
findings: EmailFinding[];
}): InspectorEmail {
const text = [
`Your ClawHub publish for ${args.packageName}@${args.version} was blocked by Plugin Inspector.`,
"",
"Fix the hard findings below and publish again:",
"",
...formatFindings(args.findings),
].join("\n");
return {
subject: `Plugin publish blocked for ${args.packageName}@${args.version}`,
text,
html: renderHtml({
title: "Plugin publish blocked",
intro: `Your ClawHub publish for ${args.packageName}@${args.version} was blocked by Plugin Inspector.`,
packageName: args.packageName,
version: args.version,
findings: args.findings,
}),
};
}
export function renderPluginInspectorWarningsEmail(args: {
packageName: string;
version: string;
warningUrl: string;
inspectorVersion?: string;
targetOpenClawVersion?: string;
intro?: string;
warnings: EmailFinding[];
}): InspectorEmail {
const intro =
args.intro ??
`Your ClawHub publish for ${args.packageName}@${args.version} was published, but Plugin Inspector found non-blocking warnings.`;
const text = [
intro,
"",
`Plugin: ${args.packageName}@${args.version}`,
args.inspectorVersion ? `Plugin Inspector: ${args.inspectorVersion}` : null,
args.targetOpenClawVersion ? `Target OpenClaw: ${args.targetOpenClawVersion}` : null,
"",
"Review the findings:",
args.warningUrl,
"",
...formatFindings(args.warnings),
]
.filter((line): line is string => line !== null)
.join("\n");
return {
subject: `Plugin Inspector findings for ${args.packageName}@${args.version}`,
text,
html: renderHtml({
title: "Plugin Inspector findings",
intro,
packageName: args.packageName,
version: args.version,
inspectorVersion: args.inspectorVersion,
targetOpenClawVersion: args.targetOpenClawVersion,
warningUrl: args.warningUrl,
findings: args.warnings,
}),
};
}
function formatFindings(findings: EmailFinding[]) {
if (findings.length === 0) return ["- No findings were included."];
return findings.map((finding) => {
const label = finding.issueClass ? `${finding.code} (${finding.issueClass})` : finding.code;
return `- ${label}: ${finding.message}`;
});
}
function renderHtml(args: {
title: string;
intro: string;
packageName: string;
version: string;
warningUrl?: string;
inspectorVersion?: string;
targetOpenClawVersion?: string;
findings: EmailFinding[];
}) {
return `<!doctype html>
<html>
<body style="margin:0;background:#f6f7f9;color:#1f2933;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<main style="max-width:640px;margin:0 auto;padding:32px 18px;">
<section style="background:#ffffff;border:1px solid #d8dee6;border-radius:8px;padding:24px;">
<p style="margin:0 0 8px;color:#5b6472;font-size:13px;">ClawHub Plugin Inspector</p>
<h1 style="margin:0 0 14px;font-size:24px;line-height:1.25;">${escapeHtml(args.title)}</h1>
<p style="margin:0 0 18px;line-height:1.55;">${escapeHtml(args.intro)}</p>
<div style="margin:0 0 18px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;background:#f8fafc;">
<p style="margin:0 0 6px;"><strong>Plugin:</strong> ${escapeHtml(args.packageName)}@${escapeHtml(args.version)}</p>
${args.inspectorVersion ? `<p style="margin:0 0 6px;"><strong>Plugin Inspector:</strong> ${escapeHtml(args.inspectorVersion)}</p>` : ""}
${args.targetOpenClawVersion ? `<p style="margin:0;"><strong>Target OpenClaw:</strong> ${escapeHtml(args.targetOpenClawVersion)}</p>` : ""}
</div>
<ul style="margin:0 0 20px;padding:0;list-style:none;">
${args.findings.map(renderFindingHtml).join("")}
</ul>
${args.warningUrl ? `<p style="margin:0;"><a href="${escapeHtml(args.warningUrl)}" style="display:inline-block;border-radius:6px;background:#111827;color:#ffffff;text-decoration:none;padding:10px 14px;font-weight:700;">View plugin validation</a></p>` : ""}
</section>
</main>
</body>
</html>`;
}
function renderFindingHtml(finding: EmailFinding) {
const kind = finding.level === "breakage" || finding.severity === "P0" ? "error" : "warning";
const color = kind === "error" ? "#b42318" : "#a15c07";
return `<li style="margin:0 0 10px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;">
<p style="margin:0 0 6px;"><span style="display:inline-block;margin-right:8px;color:${color};font-weight:700;text-transform:uppercase;">${kind}</span><code>${escapeHtml(finding.code)}</code>${finding.issueClass ? ` <span style="color:#5b6472;">${escapeHtml(finding.issueClass)}</span>` : ""}</p>
<p style="margin:0;line-height:1.5;">${escapeHtml(finding.message)}</p>
</li>`;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+4
View File
@@ -25,6 +25,10 @@ export function buildPluginSecurityAuditHref(name: string) {
return `${buildPluginDetailHref(name)}/security-audit`;
}
export function buildPluginValidationHref(name: string) {
return `${buildPluginDetailHref(name)}#validation`;
}
export function packageNameFromScopedRoute(scope: string, name: string) {
if (!scope.startsWith("@") || !name || name.includes("/")) return null;
return `${scope}/${name}`;
-43
View File
@@ -45,12 +45,10 @@ import { Route as CliDeviceRouteImport } from './routes/cli/device'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
import { Route as PluginsNameSettingsRouteImport } from './routes/plugins/$name/settings'
import { Route as PluginsNameSecurityAuditRouteImport } from './routes/plugins/$name/security-audit'
import { Route as PackagesScopeNameRouteImport } from './routes/packages/$scope/$name'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as OwnerSlugSecurityAuditRouteImport } from './routes/$owner/$slug/security-audit'
import { Route as PluginsScopeNameSettingsRouteImport } from './routes/plugins/$scope/$name/settings'
import { Route as PluginsScopeNameSecurityAuditRouteImport } from './routes/plugins/$scope/$name/security-audit'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
@@ -236,11 +234,6 @@ const PluginsScopeNameRoute = PluginsScopeNameRouteImport.update({
path: '/plugins/$scope/$name',
getParentRoute: () => rootRouteImport,
} as any)
const PluginsNameSettingsRoute = PluginsNameSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => PluginsNameRoute,
} as any)
const PluginsNameSecurityAuditRoute =
PluginsNameSecurityAuditRouteImport.update({
id: '/security-audit',
@@ -262,12 +255,6 @@ const OwnerSlugSecurityAuditRoute = OwnerSlugSecurityAuditRouteImport.update({
path: '/security-audit',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsScopeNameSettingsRoute =
PluginsScopeNameSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
const PluginsScopeNameSecurityAuditRoute =
PluginsScopeNameSecurityAuditRouteImport.update({
id: '/security-audit',
@@ -333,12 +320,10 @@ export interface FileRoutesByFullPath {
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesByTo {
@@ -381,12 +366,10 @@ export interface FileRoutesByTo {
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesById {
@@ -430,12 +413,10 @@ export interface FileRoutesById {
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRouteTypes {
@@ -480,12 +461,10 @@ export interface FileRouteTypes {
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
@@ -528,12 +507,10 @@ export interface FileRouteTypes {
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
id:
| '__root__'
@@ -576,12 +553,10 @@ export interface FileRouteTypes {
| '/$owner/$slug/settings'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$name/settings'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/plugins/$scope/$name/settings'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
@@ -879,13 +854,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsScopeNameRouteImport
parentRoute: typeof rootRouteImport
}
'/plugins/$name/settings': {
id: '/plugins/$name/settings'
path: '/settings'
fullPath: '/plugins/$name/settings'
preLoaderRoute: typeof PluginsNameSettingsRouteImport
parentRoute: typeof PluginsNameRoute
}
'/plugins/$name/security-audit': {
id: '/plugins/$name/security-audit'
path: '/security-audit'
@@ -914,13 +882,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugSecurityAuditRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$scope/$name/settings': {
id: '/plugins/$scope/$name/settings'
path: '/settings'
fullPath: '/plugins/$scope/$name/settings'
preLoaderRoute: typeof PluginsScopeNameSettingsRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
'/plugins/$scope/$name/security-audit': {
id: '/plugins/$scope/$name/security-audit'
path: '/security-audit'
@@ -970,13 +931,11 @@ const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
interface PluginsNameRouteChildren {
PluginsNameSecurityAuditRoute: typeof PluginsNameSecurityAuditRoute
PluginsNameSettingsRoute: typeof PluginsNameSettingsRoute
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
}
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
PluginsNameSecurityAuditRoute: PluginsNameSecurityAuditRoute,
PluginsNameSettingsRoute: PluginsNameSettingsRoute,
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
}
@@ -986,13 +945,11 @@ const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
interface PluginsScopeNameRouteChildren {
PluginsScopeNameSecurityAuditRoute: typeof PluginsScopeNameSecurityAuditRoute
PluginsScopeNameSettingsRoute: typeof PluginsScopeNameSettingsRoute
PluginsScopeNameSecurityScannerRoute: typeof PluginsScopeNameSecurityScannerRoute
}
const PluginsScopeNameRouteChildren: PluginsScopeNameRouteChildren = {
PluginsScopeNameSecurityAuditRoute: PluginsScopeNameSecurityAuditRoute,
PluginsScopeNameSettingsRoute: PluginsScopeNameSettingsRoute,
PluginsScopeNameSecurityScannerRoute: PluginsScopeNameSecurityScannerRoute,
}
+29
View File
@@ -132,6 +132,7 @@ type TestPackage = {
sourceRepo: string | null;
summary: string;
latestVersion: string;
inspectorWarningCount?: number;
updatedAt: number;
stats: {
downloads: number;
@@ -212,6 +213,7 @@ function createPackage(overrides?: Partial<TestPackage>): TestPackage {
sourceRepo: null,
summary: "Flagged plugin fixture.",
latestVersion: "1.0.0",
inspectorWarningCount: 0,
updatedAt: 1,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
verification: null,
@@ -309,6 +311,33 @@ describe("Dashboard rows", () => {
).toBeNull();
});
it("links public plugin finding counts to the plugin validation tab", () => {
arrangeDashboard({
packages: [
createPackage({
inspectorWarningCount: 2,
scanStatus: "clean",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "clean",
llmStatus: "clean",
staticScanStatus: "clean",
},
}),
],
});
renderDashboard();
const validationLink = screen.getByRole("link", {
name: "View 2 validation findings for Local Flagged Runtime Plugin",
});
expect(validationLink.getAttribute("href")).toBe(
"/plugins/local-flagged-runtime-plugin#validation",
);
});
it("shows a publisher selector and loads org packages when switching publishers", async () => {
const orgPublishers = [
publishers[0],
+20 -2
View File
@@ -1,6 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { usePaginatedQuery, useQuery } from "convex/react";
import { Box, Loader2, Package, Plus, Settings } from "lucide-react";
import { AlertTriangle, Box, Loader2, Package, Plus, Settings } from "lucide-react";
import { useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
@@ -17,7 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from "../components/ui/select";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import { buildPluginDetailHref, buildPluginValidationHref } from "../lib/pluginRoutes";
import { useAuthStatus } from "../lib/useAuthStatus";
const emptyPluginPublishSearch = {
@@ -78,6 +78,7 @@ type DashboardPackage = {
sourceRepo?: string | null;
summary?: string | null;
latestVersion?: string | null;
inspectorWarningCount?: number;
updatedAt: number;
stats: {
downloads: number;
@@ -341,6 +342,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
function PackageRow({ pkg }: { pkg: DashboardPackage }) {
const status = packageArtifactStatus(pkg);
const detailHref = buildPluginDetailHref(pkg.name);
const validationCount = pkg.inspectorWarningCount ?? 0;
const titleId = `dashboard-package-title-${pkg._id}`;
const stats = [
{ label: "Downloads", value: formatCompactNumber(pkg.stats.downloads ?? 0) },
@@ -356,6 +358,22 @@ function PackageRow({ pkg }: { pkg: DashboardPackage }) {
icon={<Package className="h-5 w-5" />}
status={status}
stats={stats}
actions={
validationCount > 0 ? (
<div className="dashboard-row-action">
<Button asChild variant="ghost" size="sm">
<a
href={buildPluginValidationHref(pkg.name)}
aria-label={`View ${validationCount} validation findings for ${pkg.displayName}`}
title="Validation"
>
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
{validationCount}
</a>
</Button>
</div>
) : null
}
/>
);
}
+163 -156
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router";
import { createFileRoute, Outlet, redirect, useRouterState } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { AlertTriangle, Download, Upload } from "lucide-react";
import { useState, type ReactNode } from "react";
import { AlertTriangle, Download, Info, Upload } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../../../convex/_generated/api";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import {
@@ -15,6 +15,7 @@ import { MarkdownPreview } from "../../components/MarkdownPreview";
import { OfficialTag } from "../../components/OfficialBadge";
import { SidebarMetadata } from "../../components/SidebarMetadata";
import { SkillDetailSkeleton } from "../../components/skeletons/SkillDetailSkeleton";
import { Alert, AlertDescription } from "../../components/ui/alert";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
@@ -46,7 +47,30 @@ type PluginDetailRateLimitState = {
retryAfterSeconds: number | null;
} | null;
type PluginDetailTab = "readme" | "capabilities" | "compatibility" | "verification";
type PluginDetailTab = "readme" | "compatibility" | "validation";
type PluginInspectorFinding = {
packageName: string;
version: string;
findingKind?: "warning" | "error";
code: string;
severity?: string;
level?: string;
issueClass?: string;
message: string;
evidence?: string[];
inspectorVersion?: string;
targetOpenClawVersion?: string;
scanSource?: "publish" | "nightly";
createdAt: number;
};
type PluginInspectorValidationSummary = {
findingCount: number;
errorCount: number;
warningCount: number;
incompatibleAfterOpenClawVersion: string | null;
};
export type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -174,24 +198,6 @@ export const Route = createFileRoute("/plugins/$name")({
component: PluginDetailRoute,
});
const CAPABILITY_LABELS: Record<string, string> = {
executesCode: "Executes code",
runtimeId: "Runtime ID",
pluginKind: "Plugin kind",
channels: "Channels",
providers: "Providers",
hooks: "Hooks",
bundledSkills: "Bundled skills",
setupEntry: "Setup entry",
toolNames: "Tools",
commandNames: "Commands",
serviceNames: "Services",
capabilityTags: "Tags",
httpRouteCount: "HTTP routes",
bundleFormat: "Bundle format",
hostTargets: "Host targets",
};
function formatCapabilityValue(value: unknown): string {
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "number") return String(value);
@@ -200,14 +206,6 @@ function formatCapabilityValue(value: unknown): string {
return JSON.stringify(value);
}
function formatDisplayValue(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function formatArtifactSize(value: number | null | undefined): string | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
if (value < 1024) return `${value} B`;
@@ -221,25 +219,27 @@ function formatArtifactSize(value: number | null | undefined): string | null {
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
}
function isEmptyObject(obj: unknown): boolean {
if (!obj || typeof obj !== "object") return true;
return Object.keys(obj).length === 0;
function pluginDetailTabFromHash(hashValue: string): PluginDetailTab {
const hash = hashValue.replace("#", "");
if (hash === "warnings") return "validation";
if (hash === "capabilities" || hash === "verification") return "compatibility";
return hash === "compatibility" || hash === "validation" ? hash : "readme";
}
function PluginDetailTabs({
activeTab,
setActiveTab,
readmePanel,
capabilitiesPanel,
compatibilityPanel,
verificationPanel,
validationPanel,
validationCount,
}: {
activeTab: PluginDetailTab;
setActiveTab: (tab: PluginDetailTab) => void;
readmePanel: ReactNode;
capabilitiesPanel: ReactNode | null;
compatibilityPanel: ReactNode | null;
verificationPanel: ReactNode | null;
validationPanel: ReactNode | null;
validationCount: number;
}) {
const selectTab = (tab: PluginDetailTab) => {
setActiveTab(tab);
@@ -253,21 +253,17 @@ function PluginDetailTabs({
};
const effectiveActiveTab =
activeTab === "capabilities" && capabilitiesPanel
? "capabilities"
: activeTab === "compatibility" && compatibilityPanel
? "compatibility"
: activeTab === "verification" && verificationPanel
? "verification"
: "readme";
activeTab === "compatibility" && compatibilityPanel
? "compatibility"
: activeTab === "validation" && validationPanel
? "validation"
: "readme";
const activePanel =
effectiveActiveTab === "capabilities" && capabilitiesPanel
? capabilitiesPanel
: effectiveActiveTab === "compatibility" && compatibilityPanel
? compatibilityPanel
: effectiveActiveTab === "verification" && verificationPanel
? verificationPanel
: readmePanel;
effectiveActiveTab === "compatibility" && compatibilityPanel
? compatibilityPanel
: effectiveActiveTab === "validation" && validationPanel
? validationPanel
: readmePanel;
return (
<div className="tab-card">
@@ -281,17 +277,6 @@ function PluginDetailTabs({
>
README
</button>
{capabilitiesPanel ? (
<button
className={`tab-button${effectiveActiveTab === "capabilities" ? " is-active" : ""}`}
type="button"
role="tab"
aria-selected={effectiveActiveTab === "capabilities"}
onClick={() => selectTab("capabilities")}
>
Capabilities
</button>
) : null}
{compatibilityPanel ? (
<button
className={`tab-button${effectiveActiveTab === "compatibility" ? " is-active" : ""}`}
@@ -303,15 +288,15 @@ function PluginDetailTabs({
Compatibility
</button>
) : null}
{verificationPanel ? (
{validationPanel ? (
<button
className={`tab-button${effectiveActiveTab === "verification" ? " is-active" : ""}`}
className={`tab-button${effectiveActiveTab === "validation" ? " is-active" : ""}`}
type="button"
role="tab"
aria-selected={effectiveActiveTab === "verification"}
onClick={() => selectTab("verification")}
aria-selected={effectiveActiveTab === "validation"}
onClick={() => selectTab("validation")}
>
Verification
Validation ({validationCount})
</button>
) : null}
</div>
@@ -359,13 +344,24 @@ export function PluginDetailPage({
? { name: manageLookupName, candidateNames: manageCandidateNames }
: "skip",
);
const validationSummary = useQuery(
api.packages.getPackageInspectorValidationSummaryPublic,
detail.package ? { name: detail.package.name } : "skip",
) as PluginInspectorValidationSummary | undefined;
const inspectorFindings = useQuery(
api.packages.listPackageInspectorWarningsForManager,
manageContext ? { name: manageContext.package.name, limit: 100 } : "skip",
) as PluginInspectorFinding[] | undefined;
const [activeTab, setActiveTab] = useState<PluginDetailTab>(() => {
if (typeof window === "undefined") return "readme";
const hash = window.location.hash.replace("#", "");
return hash === "capabilities" || hash === "compatibility" || hash === "verification"
? hash
: "readme";
return pluginDetailTabFromHash(window.location.hash);
});
useEffect(() => {
const syncTabFromHash = () => setActiveTab(pluginDetailTabFromHash(window.location.hash));
window.addEventListener("hashchange", syncTabFromHash);
syncTabFromHash();
return () => window.removeEventListener("hashchange", syncTabFromHash);
}, []);
if (isNestedPluginRoute) {
return <Outlet />;
}
@@ -435,18 +431,10 @@ export function PluginDetailPage({
displayName: pkg.displayName,
}).toString()}`
: null;
const capEntries = capabilities
? Object.entries(capabilities).filter(
([, v]) =>
v !== undefined && v !== null && v !== false && !(Array.isArray(v) && v.length === 0),
)
: [];
const executesCodeValue =
typeof capabilities?.executesCode === "boolean"
? formatCapabilityValue(capabilities.executesCode)
: null;
const tabCapEntries = capEntries.filter(([key]) => key !== "executesCode");
const compatEntries = compatibility
? Object.entries(compatibility).filter(([, v]) => v !== undefined && v !== null)
: [];
@@ -458,39 +446,6 @@ export function PluginDetailPage({
<p className="empty-state-body">This plugin doesn't have a README yet.</p>
</div>
);
const capabilitiesPanel =
tabCapEntries.length > 0 ? (
<div className="plugin-tab-panel">
<dl className="plugin-kv-grid">
{tabCapEntries.map(([key, value]) => (
<div key={key} className="plugin-kv-row">
<dt className="plugin-kv-label">{CAPABILITY_LABELS[key] ?? key}</dt>
<dd className="plugin-kv-value">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="plugin-tag-list">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="plugin-tag-list">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</div>
) : null;
const compatibilityPanel =
compatEntries.length > 0 || artifact ? (
<div className="plugin-tab-panel">
@@ -549,29 +504,78 @@ export function PluginDetailPage({
</dl>
</div>
) : null;
const verificationPanel =
verification && !isEmptyObject(verification) ? (
<div className="plugin-tab-panel">
<dl className="plugin-kv-grid">
{verification.tier ? (
<div className="plugin-kv-row">
<dt className="plugin-kv-label">Tier</dt>
<dd className="plugin-kv-value">{formatDisplayValue(verification.tier)}</dd>
</div>
) : null}
{verification.scope ? (
<div className="plugin-kv-row">
<dt className="plugin-kv-label">Scope</dt>
<dd className="plugin-kv-value">{formatDisplayValue(verification.scope)}</dd>
</div>
) : null}
{verification.summary ? (
<div className="plugin-kv-row">
<dt className="plugin-kv-label">Summary</dt>
<dd className="plugin-kv-value">{verification.summary}</dd>
</div>
) : null}
</dl>
const validationCount = inspectorFindings?.length ?? validationSummary?.findingCount ?? 0;
const incompatibilityAlert =
validationSummary &&
validationSummary.errorCount > 0 &&
validationSummary.incompatibleAfterOpenClawVersion ? (
<Alert variant="destructive" className="plugin-validation-alert">
<AlertTriangle size={16} aria-hidden="true" />
<AlertDescription>
This plugin is incompatible with OpenClaw versions greater than{" "}
{validationSummary.incompatibleAfterOpenClawVersion}.
</AlertDescription>
</Alert>
) : null;
const validationPanel =
inspectorFindings && inspectorFindings.length > 0 ? (
<div className="plugin-tab-panel plugin-warnings-panel">
<Alert variant="info" role="status">
<Info size={16} aria-hidden="true" />
<AlertDescription>
Validation outputs are only visible to plugin owners and admins. Run locally using the
CLI: <code>clawhub package validate &lt;path-to-plugin&gt;</code>
</AlertDescription>
</Alert>
<div className="plugin-warning-list">
{inspectorFindings.map((finding) => (
<article
key={`${finding.version}:${finding.code}:${finding.message}`}
className={`plugin-warning-item is-${finding.findingKind ?? "warning"}`}
>
<div className="plugin-warning-item-header">
<Badge variant={finding.findingKind === "error" ? "destructive" : "warning"}>
{finding.findingKind === "error" ? "Error" : "Warning"}
</Badge>
<code>{finding.code}</code>
{finding.issueClass ? <span>{finding.issueClass}</span> : null}
{finding.severity ? <span>{finding.severity}</span> : null}
</div>
<p>{finding.message}</p>
<dl className="plugin-warning-meta">
<div>
<dt>Plugin version</dt>
<dd>v{finding.version}</dd>
</div>
{finding.targetOpenClawVersion ? (
<div>
<dt>Target</dt>
<dd>OpenClaw {finding.targetOpenClawVersion}</dd>
</div>
) : null}
{finding.inspectorVersion ? (
<div>
<dt>Inspector</dt>
<dd>{finding.inspectorVersion}</dd>
</div>
) : null}
{finding.scanSource ? (
<div>
<dt>Scan</dt>
<dd>{finding.scanSource}</dd>
</div>
) : null}
</dl>
{finding.evidence && finding.evidence.length > 0 ? (
<ul className="plugin-warning-evidence">
{finding.evidence.slice(0, 4).map((entry) => (
<li key={entry}>{entry}</li>
))}
</ul>
) : null}
</article>
))}
</div>
</div>
) : null;
const sourceRepoLink = verification?.sourceRepo
@@ -733,33 +737,36 @@ export function PluginDetailPage({
</div>
}
>
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-install-command-wrap">
<div className="skill-install-command-shell">
<pre className="skill-install-command">
<code>{installSnippet}</code>
</pre>
<InstallCopyButton
text={installSnippet}
ariaLabel="Copy plugin install command"
showLabel={false}
className="skill-install-command-inline-button"
/>
<div className="plugin-install-stack">
{incompatibilityAlert}
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-install-command-wrap">
<div className="skill-install-command-shell">
<pre className="skill-install-command">
<code>{installSnippet}</code>
</pre>
<InstallCopyButton
text={installSnippet}
ariaLabel="Copy plugin install command"
showLabel={false}
className="skill-install-command-inline-button"
/>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
<PluginDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
readmePanel={readmePanel}
capabilitiesPanel={capabilitiesPanel}
compatibilityPanel={compatibilityPanel}
verificationPanel={verificationPanel}
validationPanel={validationPanel}
validationCount={validationCount}
/>
</DetailHero>
</DetailPageShell>
-11
View File
@@ -1,11 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { buildPluginDetailHref } from "../../../lib/pluginRoutes";
export const Route = createFileRoute("/plugins/$name/settings")({
beforeLoad: ({ params }) => {
throw redirect({
href: buildPluginDetailHref(params.name),
statusCode: 308,
});
},
});
@@ -1,17 +0,0 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { packageNameFromScopedRoute, buildPluginDetailHref } from "../../../../lib/pluginRoutes";
function packageNameFromParams(params: { scope: string; name: string }) {
const packageName = packageNameFromScopedRoute(params.scope, params.name);
if (!packageName) throw notFound();
return packageName;
}
export const Route = createFileRoute("/plugins/$scope/$name/settings")({
beforeLoad: ({ params }) => {
throw redirect({
href: buildPluginDetailHref(packageNameFromParams(params)),
statusCode: 308,
});
},
});
+80 -6
View File
@@ -105,6 +105,80 @@ async function scanReadmeRelativeAssets(files: File[]): Promise<RelativeReadmeAs
}
}
type ParsedInspectorPublishError = {
summary: string;
findings: Array<{ code: string; message: string }>;
};
const PLUGIN_INSPECTOR_BLOCKED_PREFIX = "Plugin Inspector blocked publish:";
function parsePluginInspectorPublishError(message: string): ParsedInspectorPublishError | null {
if (!message.startsWith(PLUGIN_INSPECTOR_BLOCKED_PREFIX)) return null;
const body = message.slice(PLUGIN_INSPECTOR_BLOCKED_PREFIX.length).trim();
if (!body) return { summary: "Hard findings blocked this publish.", findings: [] };
const [summaryPart, ...detailParts] = body.split(". ");
const summary = summaryPart?.trim() || "Hard findings blocked this publish.";
const details = detailParts.join(". ").trim();
if (!details) return { summary, findings: [] };
const findings = details
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const match = part.match(/^([a-z0-9._-]+):\s+(.+)$/i);
return match
? { code: match[1]!, message: match[2]! }
: { code: "plugin-inspector", message: part };
});
return { summary, findings };
}
function isPluginInspectorPublishError(message: string) {
return Boolean(parsePluginInspectorPublishError(message));
}
function PluginPublishError({ message }: { message: string }) {
const inspectorError = parsePluginInspectorPublishError(message);
if (!inspectorError) {
return (
<div className="plugin-publish-error-text" role="alert">
{message}
</div>
);
}
return (
<div className="plugin-publish-error-panel" role="alert">
<div className="plugin-publish-error-heading">
<strong>Plugin Inspector blocked publish</strong>
<span>{inspectorError.summary}</span>
</div>
{inspectorError.findings.length > 0 ? (
<div className="plugin-publish-error-table-wrap">
<table className="plugin-publish-error-table">
<thead>
<tr>
<th scope="col">Code</th>
<th scope="col">Message</th>
</tr>
</thead>
<tbody>
{inspectorError.findings.map((finding) => (
<tr key={`${finding.code}:${finding.message}`}>
<td>
<code>{finding.code}</code>
</td>
<td>{finding.message}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}
export function PublishPluginRoute() {
const search = useSearch({ from: "/plugins/publish" });
const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus();
@@ -636,11 +710,7 @@ export function PublishPluginRoute() {
<div className="mt-5 flex items-center justify-between gap-4">
<div className="flex flex-col gap-2">
{error ? (
<div className="text-sm font-medium text-red-600 dark:text-red-400" role="alert">
{error}
</div>
) : null}
{error ? <PluginPublishError message={error} /> : null}
{status ? <div className="text-sm text-[color:var(--ink-soft)]">{status}</div> : null}
{!status ? (
<div className="text-sm text-[color:var(--ink-soft)]">
@@ -727,7 +797,11 @@ export function PublishPluginRoute() {
"Published. Pending security checks and verification before public listing.",
);
} catch (publishError) {
toast.error(formatPublishError(publishError));
const message = formatPublishError(publishError);
setError(message);
if (!isPluginInspectorPublishError(message)) {
toast.error(message);
}
setStatus(null);
} finally {
setIsSubmitting(false);
+153
View File
@@ -3807,6 +3807,12 @@ code {
margin-top: 16px;
}
.plugin-install-stack {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.plugin-install-bar {
display: flex;
align-items: center;
@@ -3867,6 +3873,153 @@ code {
min-width: 0;
}
.plugin-warnings-panel {
gap: var(--space-4);
}
.plugin-warning-list {
display: grid;
gap: var(--space-3);
}
.plugin-warning-item {
display: grid;
gap: var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-4);
}
.plugin-warning-item.is-error {
border-color: color-mix(in srgb, var(--status-error-fg) 32%, var(--line));
}
.plugin-warning-item-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.plugin-warning-item-header code {
border-radius: 4px;
background: var(--surface-muted);
padding: 0.125rem 0.35rem;
font-size: 0.82rem;
}
.plugin-warning-item p {
margin: 0;
color: var(--ink);
}
.plugin-warning-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin: 0;
}
.plugin-warning-meta div {
display: grid;
gap: 0.1rem;
}
.plugin-warning-meta dt {
color: var(--ink-muted);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.plugin-warning-meta dd {
margin: 0;
color: var(--ink-soft);
font-size: 0.86rem;
}
.plugin-warning-evidence {
margin: 0;
padding-left: var(--space-4);
color: var(--ink-soft);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.plugin-publish-error-text {
color: var(--status-error-fg);
font-size: 0.9rem;
font-weight: 700;
}
.plugin-publish-error-panel {
display: grid;
gap: var(--space-3);
width: min(100%, 720px);
border: 1px solid color-mix(in srgb, var(--status-error-fg) 30%, var(--line));
border-radius: var(--radius-md);
background: var(--status-error-bg);
padding: var(--space-4);
color: var(--ink);
}
.plugin-publish-error-heading {
display: grid;
gap: var(--space-1);
}
.plugin-publish-error-heading strong {
color: var(--status-error-fg);
font-size: 0.92rem;
}
.plugin-publish-error-heading span {
color: var(--ink-soft);
font-size: 0.86rem;
}
.plugin-publish-error-table-wrap {
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--status-error-fg) 20%, var(--line));
border-radius: var(--radius-sm);
background: var(--surface);
}
.plugin-publish-error-table {
width: 100%;
min-width: 420px;
border-collapse: collapse;
font-size: 0.84rem;
}
.plugin-publish-error-table th,
.plugin-publish-error-table td {
border-bottom: 1px solid var(--line);
padding: 0.65rem 0.75rem;
text-align: left;
vertical-align: top;
}
.plugin-publish-error-table th {
color: var(--ink-muted);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.plugin-publish-error-table tr:last-child td {
border-bottom: 0;
}
.plugin-publish-error-table code {
border-radius: 4px;
background: var(--surface-muted);
padding: 0.125rem 0.35rem;
color: var(--ink);
font-size: 0.8rem;
}
.plugin-tab-panel-header {
display: flex;
align-items: center;