mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
268 lines
7.5 KiB
TypeScript
268 lines
7.5 KiB
TypeScript
import { normalizeCatalogTopics, normalizePluginCategories } from "clawhub-schema";
|
|
|
|
export const DEFAULT_PUBLIC_CORPUS_FIXTURE = "fixtures/public-corpus/corpus.jsonl";
|
|
|
|
export type PublicCorpusSkillRow = {
|
|
kind: "skill";
|
|
slug: string;
|
|
displayName: string;
|
|
version: string;
|
|
skillMd: string;
|
|
summary?: string;
|
|
createdAt?: number;
|
|
};
|
|
|
|
export type PublicCorpusPluginRow = {
|
|
kind: "plugin";
|
|
name: string;
|
|
displayName: string;
|
|
version: string;
|
|
readme: string;
|
|
summary?: string;
|
|
categories?: string[];
|
|
topics?: string[];
|
|
family?: "skill" | "code-plugin" | "bundle-plugin";
|
|
channel?: "official" | "community" | "private";
|
|
sourceRepoHost?: string | null;
|
|
createdAt?: number;
|
|
};
|
|
|
|
export type PublicCorpusRow = PublicCorpusSkillRow | PublicCorpusPluginRow;
|
|
|
|
export type CorpusValidationFinding = {
|
|
line: number;
|
|
reason:
|
|
| "invalid_json"
|
|
| "invalid_kind"
|
|
| "missing_required_field"
|
|
| "empty_skill_text"
|
|
| "empty_plugin_text"
|
|
| "disallowed_field"
|
|
| "raw_convex_id"
|
|
| "duplicate_slug"
|
|
| "invalid_catalog_metadata"
|
|
| "local_path"
|
|
| "secret_like_text";
|
|
field?: string;
|
|
value?: string;
|
|
};
|
|
|
|
export type CorpusValidationResult = {
|
|
ok: boolean;
|
|
rowCount: number;
|
|
skillCount: number;
|
|
pluginCount: number;
|
|
findings: CorpusValidationFinding[];
|
|
};
|
|
|
|
const DISALLOWED_FIELD_PATTERNS = [
|
|
/^owner/i,
|
|
/^publisher/i,
|
|
/^user/i,
|
|
/^email$/i,
|
|
/^auth/i,
|
|
/^token/i,
|
|
];
|
|
|
|
const RAW_CONVEX_ID_PATTERN =
|
|
/\b(?:users|publishers|skills|skillVersions|packages|packageReleases):[A-Za-z0-9_-]+\b/;
|
|
const RAW_LOCAL_PATH_PATTERN =
|
|
/(?:\/Users\/(?!\[REDACTED_USER\])|\/private\/tmp\/(?!\[REDACTED_PATH\])|\/var\/folders\/(?!\[REDACTED_PATH\])|\/mnt\/c\/Users\/(?!\[REDACTED_USER\])|C:\\Users\\(?!\[REDACTED_USER\]))/;
|
|
|
|
const SECRET_PATTERNS = [
|
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
|
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/,
|
|
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/,
|
|
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
|
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
/\b(?:authorization|x-api-key)\s*[:=]\s*["']?(?:bearer|basic)?\s+[A-Za-z0-9._~+/=-]{12,}/i,
|
|
];
|
|
|
|
export function parseCorpusJsonl(text: string): PublicCorpusRow[] {
|
|
return text
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => JSON.parse(line) as PublicCorpusRow);
|
|
}
|
|
|
|
export function validateCorpusJsonl(text: string): CorpusValidationResult {
|
|
const rows: PublicCorpusRow[] = [];
|
|
const findings: CorpusValidationFinding[] = [];
|
|
|
|
text.split(/\r?\n/).forEach((line, index) => {
|
|
if (line.trim().length === 0) return;
|
|
try {
|
|
rows.push(JSON.parse(line) as PublicCorpusRow);
|
|
} catch {
|
|
findings.push({ line: index + 1, reason: "invalid_json" });
|
|
}
|
|
});
|
|
|
|
return mergeValidationResults(validateCorpusRows(rows), findings);
|
|
}
|
|
|
|
export function validateCorpusRows(rows: PublicCorpusRow[]): CorpusValidationResult {
|
|
const findings: CorpusValidationFinding[] = [];
|
|
const seenKeys = new Set<string>();
|
|
let skillCount = 0;
|
|
let pluginCount = 0;
|
|
|
|
rows.forEach((row, index) => {
|
|
const line = index + 1;
|
|
if (!isRecord(row) || (row.kind !== "skill" && row.kind !== "plugin")) {
|
|
findings.push({ line, reason: "invalid_kind" });
|
|
return;
|
|
}
|
|
|
|
collectDisallowedFieldFindings(row, line, findings);
|
|
collectTextFindings(row, line, findings);
|
|
|
|
if (row.kind === "skill") {
|
|
skillCount += 1;
|
|
requireString(row.slug, "slug", line, findings);
|
|
requireString(row.displayName, "displayName", line, findings);
|
|
requireString(row.version, "version", line, findings);
|
|
if (!row.skillMd?.trim())
|
|
findings.push({ line, reason: "empty_skill_text", field: "skillMd" });
|
|
collectDuplicate("skill", row.slug, line, seenKeys, findings);
|
|
} else {
|
|
pluginCount += 1;
|
|
requireString(row.name, "name", line, findings);
|
|
requireString(row.displayName, "displayName", line, findings);
|
|
requireString(row.version, "version", line, findings);
|
|
if (!row.readme?.trim())
|
|
findings.push({ line, reason: "empty_plugin_text", field: "readme" });
|
|
collectPluginCatalogMetadataFindings(row, line, findings);
|
|
collectDuplicate("plugin", row.name, line, seenKeys, findings);
|
|
}
|
|
});
|
|
|
|
return {
|
|
ok: findings.length === 0,
|
|
rowCount: rows.length,
|
|
skillCount,
|
|
pluginCount,
|
|
findings,
|
|
};
|
|
}
|
|
|
|
function mergeValidationResults(
|
|
result: CorpusValidationResult,
|
|
findings: CorpusValidationFinding[],
|
|
): CorpusValidationResult {
|
|
return {
|
|
...result,
|
|
ok: result.ok && findings.length === 0,
|
|
findings: [...findings, ...result.findings],
|
|
};
|
|
}
|
|
|
|
function collectDuplicate(
|
|
kind: "skill" | "plugin",
|
|
slug: unknown,
|
|
line: number,
|
|
seenKeys: Set<string>,
|
|
findings: CorpusValidationFinding[],
|
|
) {
|
|
if (typeof slug !== "string" || !slug) return;
|
|
const key = `${kind}:${slug}`;
|
|
if (seenKeys.has(key)) findings.push({ line, reason: "duplicate_slug", value: key });
|
|
seenKeys.add(key);
|
|
}
|
|
|
|
function requireString(
|
|
value: unknown,
|
|
field: string,
|
|
line: number,
|
|
findings: CorpusValidationFinding[],
|
|
) {
|
|
if (typeof value === "string" && value.trim()) return;
|
|
findings.push({ line, reason: "missing_required_field", field });
|
|
}
|
|
|
|
function collectPluginCatalogMetadataFindings(
|
|
row: PublicCorpusPluginRow,
|
|
line: number,
|
|
findings: CorpusValidationFinding[],
|
|
) {
|
|
if (row.categories !== undefined) {
|
|
try {
|
|
normalizePluginCategories(row.categories);
|
|
} catch (error) {
|
|
findings.push({
|
|
line,
|
|
reason: "invalid_catalog_metadata",
|
|
field: "categories",
|
|
value: error instanceof Error ? error.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (row.topics !== undefined) {
|
|
try {
|
|
normalizeCatalogTopics(row.topics);
|
|
} catch (error) {
|
|
findings.push({
|
|
line,
|
|
reason: "invalid_catalog_metadata",
|
|
field: "topics",
|
|
value: error instanceof Error ? error.message : undefined,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectDisallowedFieldFindings(
|
|
value: unknown,
|
|
line: number,
|
|
findings: CorpusValidationFinding[],
|
|
path = "",
|
|
) {
|
|
if (!isRecord(value)) return;
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
const field = path ? `${path}.${key}` : key;
|
|
if (DISALLOWED_FIELD_PATTERNS.some((pattern) => pattern.test(key))) {
|
|
findings.push({ line, reason: "disallowed_field", field });
|
|
}
|
|
collectDisallowedFieldFindings(nested, line, findings, field);
|
|
}
|
|
}
|
|
|
|
function collectTextFindings(
|
|
value: unknown,
|
|
line: number,
|
|
findings: CorpusValidationFinding[],
|
|
path = "",
|
|
) {
|
|
if (typeof value === "string") {
|
|
if (RAW_CONVEX_ID_PATTERN.test(value)) {
|
|
findings.push({ line, reason: "raw_convex_id", field: path, value: preview(value) });
|
|
}
|
|
if (RAW_LOCAL_PATH_PATTERN.test(value)) {
|
|
findings.push({ line, reason: "local_path", field: path, value: preview(value) });
|
|
}
|
|
if (SECRET_PATTERNS.some((pattern) => pattern.test(value))) {
|
|
findings.push({ line, reason: "secret_like_text", field: path, value: preview(value) });
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, index) => collectTextFindings(item, line, findings, `${path}[${index}]`));
|
|
return;
|
|
}
|
|
if (isRecord(value)) {
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
collectTextFindings(nested, line, findings, path ? `${path}.${key}` : key);
|
|
}
|
|
}
|
|
}
|
|
|
|
function preview(value: string) {
|
|
return value.slice(0, 120);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|