mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"use node";
|
|
|
|
import { mkdir, mkdtemp, 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[];
|
|
authorRemediation?: InspectorAuthorRemediation;
|
|
fixture?: string;
|
|
decision?: string;
|
|
};
|
|
|
|
type InspectorAuthorRemediation = {
|
|
summary: string;
|
|
docsUrl?: 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 AUTHOR_REMEDIATION_DOCS_BASE = "https://docs.openclaw.ai/clawhub/plugin-validation-fixes";
|
|
const PACKAGE_INSPECTOR_TEMP_PREFIX = "clawhub-plugin-inspector-";
|
|
const SERVERLESS_TEMP_DIR = "/tmp";
|
|
const TEMP_DIR_FALLBACK_ERROR_CODES = new Set(["EACCES", "ENOENT", "ENOTDIR", "EPERM", "EROFS"]);
|
|
|
|
const LEGACY_AUTHOR_REMEDIATION_SUMMARIES = {
|
|
"channel-env-vars":
|
|
"Move legacy channel environment variable metadata into the current setup/config metadata.",
|
|
"legacy-before-agent-start":
|
|
"Replace the legacy before_agent_start hook with the current prompt/model hooks.",
|
|
"legacy-root-sdk-import":
|
|
"Prefer focused public plugin SDK subpath imports instead of the legacy root barrel.",
|
|
"manifest-name-missing": "Add a display name to the plugin manifest.",
|
|
"manifest-unknown-contracts":
|
|
"Remove unsupported manifest contract keys or move them to a documented OpenClaw contract field.",
|
|
"manifest-unknown-fields":
|
|
"Move unsupported top-level manifest fields into supported package metadata or remove them.",
|
|
"package-entrypoint-missing":
|
|
"Publish the entrypoint declared in OpenClaw package metadata or update the metadata to point at an existing file.",
|
|
"package-install-metadata-incomplete":
|
|
"Complete the OpenClaw install metadata so ClawHub can identify the install target.",
|
|
"package-json-missing": "Add a package.json to the plugin package.",
|
|
"package-manifest-version-drift":
|
|
"Align the plugin version declared in package.json and openclaw.plugin.json.",
|
|
"package-min-host-version-drift":
|
|
"Set the package minimum host version to the OpenClaw version range the plugin was built and tested against.",
|
|
"package-npm-pack-entrypoint-missing":
|
|
"Include the declared OpenClaw entrypoints in the npm-packed artifact.",
|
|
"package-npm-pack-metadata-missing":
|
|
"Include OpenClaw metadata files in the npm-packed artifact.",
|
|
"package-npm-pack-unavailable": "Make the package packable before publishing it through ClawHub.",
|
|
"package-openclaw-entry-missing":
|
|
"Declare the plugin runtime entrypoint in package.json OpenClaw metadata.",
|
|
"package-openclaw-metadata-missing": "Add the package.json openclaw metadata block.",
|
|
"package-openclaw-unsupported-metadata": "Remove unsupported OpenClaw package metadata fields.",
|
|
"package-plugin-api-compat-missing":
|
|
"Declare the OpenClaw plugin API range this package supports.",
|
|
"provider-auth-env-vars":
|
|
"Move legacy provider authentication environment variables into current provider setup metadata.",
|
|
"reserved-sdk-import": "Stop importing reserved bundled-plugin SDK compatibility paths.",
|
|
"security-manifest-schema-unavailable":
|
|
"Remove or update the unsupported security manifest schema reference.",
|
|
"sdk-load-session-store":
|
|
"Replace deprecated loadSessionStore whole-store access with row-scoped session helpers.",
|
|
"sdk-session-file-helper":
|
|
"Replace deprecated session file-path helpers with session entry and transcript identity APIs.",
|
|
"sdk-session-store-write":
|
|
"Replace deprecated whole-store session writes with row-scoped session helpers.",
|
|
"sdk-session-transcript-file-target":
|
|
"Replace legacy transcript file targets with public transcript identity or target helpers.",
|
|
"sdk-session-transcript-low-level":
|
|
"Replace low-level transcript writes with the structured transcript runtime helpers.",
|
|
"unrecognized-security-manifest":
|
|
"Remove unsupported security manifest files until OpenClaw documents a versioned security manifest schema.",
|
|
} satisfies Record<string, string>;
|
|
|
|
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())),
|
|
authorRemediation: v.optional(
|
|
v.object({
|
|
summary: v.string(),
|
|
docsUrl: v.optional(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 async function preparePublishInspectorOpenClawTarget<ResolvedTarget, PreparedTarget>(
|
|
root: string,
|
|
targets: {
|
|
resolveVersion: (requestedVersion: string) => Promise<ResolvedTarget>;
|
|
prepare: (
|
|
resolvedTarget: ResolvedTarget,
|
|
options: { cacheDir: string },
|
|
) => Promise<PreparedTarget>;
|
|
},
|
|
) {
|
|
const resolvedTarget = await targets.resolveVersion("latest");
|
|
return await targets.prepare(resolvedTarget, {
|
|
// The dependency defaults to os.homedir(), which can be unusable in serverless runtimes.
|
|
cacheDir: path.join(root, ".plugin-inspector-cache"),
|
|
});
|
|
}
|
|
|
|
export function buildPublishInspectorRunCheckOptions(
|
|
root: string,
|
|
generatedAt: string,
|
|
targetOpenClaw: unknown,
|
|
) {
|
|
return {
|
|
pluginRoot: root,
|
|
openclawPath: false,
|
|
targetOpenClaw,
|
|
outDir: "reports",
|
|
capture: false,
|
|
mockSdk: true,
|
|
allowExecution: false,
|
|
authorFacing: true,
|
|
generatedAt,
|
|
} as Parameters<(typeof import("@openclaw/plugin-inspector"))["pluginRoot"]["runCheck"]>[0] & {
|
|
authorFacing: true;
|
|
generatedAt: string;
|
|
};
|
|
}
|
|
|
|
export async function createPackageInspectorWorkspace(
|
|
preferredTempDir = tmpdir(),
|
|
createTempDir: (prefix: string) => Promise<string> = mkdtemp,
|
|
) {
|
|
try {
|
|
return await createTempDir(path.join(preferredTempDir, PACKAGE_INSPECTOR_TEMP_PREFIX));
|
|
} catch (error) {
|
|
const code =
|
|
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
if (
|
|
process.platform === "win32" ||
|
|
path.resolve(preferredTempDir) === SERVERLESS_TEMP_DIR ||
|
|
typeof code !== "string" ||
|
|
!TEMP_DIR_FALLBACK_ERROR_CODES.has(code)
|
|
) {
|
|
throw error;
|
|
}
|
|
return await createTempDir(path.join(SERVERLESS_TEMP_DIR, PACKAGE_INSPECTOR_TEMP_PREFIX));
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
let workspaceRoot: string | undefined;
|
|
try {
|
|
workspaceRoot = await createPackageInspectorWorkspace();
|
|
const root = path.join(workspaceRoot, "package");
|
|
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 { openClawTargets, pluginRoot } = await import("@openclaw/plugin-inspector");
|
|
const targetOpenClaw = await preparePublishInspectorOpenClawTarget(
|
|
workspaceRoot,
|
|
openClawTargets,
|
|
);
|
|
const runCheckOptions = buildPublishInspectorRunCheckOptions(
|
|
root,
|
|
new Date().toISOString(),
|
|
targetOpenClaw,
|
|
);
|
|
const { report } = await pluginRoot.runCheck(runCheckOptions);
|
|
|
|
return normalizeInspectorReportForPublish(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 {
|
|
if (workspaceRoot) {
|
|
await rm(workspaceRoot, { 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;
|
|
}
|
|
|
|
export function normalizeInspectorReportForPublish(report: unknown) {
|
|
const parsed = isRecord(report) ? (report as InspectorReport) : {};
|
|
const issues = normalizeFindings(parsed.issues, "warning").filter(hasAuthorRemediation);
|
|
const issueBreakages = issues.filter((finding) => isBreakageFinding(finding));
|
|
const issueWarnings = issues.filter((finding) => !isBreakageFinding(finding));
|
|
const breakages =
|
|
issues.length > 0
|
|
? issueBreakages
|
|
: normalizeFindings(parsed.breakages, "breakage").filter(hasAuthorRemediation);
|
|
const warnings =
|
|
issues.length > 0 ? issueWarnings : normalizeWarnings(parsed).filter(hasAuthorRemediation);
|
|
return {
|
|
status: breakages.length > 0 ? ("fail" as const) : ("pass" as const),
|
|
summary: {
|
|
breakageCount: breakages.length,
|
|
warningCount: warnings.length,
|
|
deprecationWarningCount: warnings.filter(
|
|
(finding) => finding.issueClass === "deprecation-warning",
|
|
).length,
|
|
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) => !isBreakageFinding(finding),
|
|
);
|
|
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,
|
|
authorRemediation:
|
|
normalizeAuthorRemediation(value.authorRemediation) ?? legacyAuthorRemediation(code),
|
|
fixture: stringValue(value.fixture),
|
|
decision: stringValue(value.decision),
|
|
};
|
|
}
|
|
|
|
function normalizeAuthorRemediation(value: unknown): InspectorAuthorRemediation | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const summary = stringValue(value.summary);
|
|
if (!summary) return undefined;
|
|
return {
|
|
summary,
|
|
docsUrl: stringValue(value.docsUrl),
|
|
};
|
|
}
|
|
|
|
function hasAuthorRemediation(finding: InspectorFinding) {
|
|
return Boolean(finding.authorRemediation?.summary);
|
|
}
|
|
|
|
function legacyAuthorRemediation(code: string): InspectorAuthorRemediation | undefined {
|
|
const summary =
|
|
LEGACY_AUTHOR_REMEDIATION_SUMMARIES[code as keyof typeof LEGACY_AUTHOR_REMEDIATION_SUMMARIES];
|
|
if (!summary) return undefined;
|
|
return {
|
|
summary,
|
|
docsUrl: `${AUTHOR_REMEDIATION_DOCS_BASE}#${code}`,
|
|
};
|
|
}
|
|
|
|
function isBreakageFinding(finding: InspectorFinding) {
|
|
return finding.level === "breakage" || finding.level === "error" || finding.severity === "P0";
|
|
}
|
|
|
|
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 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)
|
|
);
|
|
}
|