fix: enforce plugin config schemas (#1272) (thanks @thewilloftheshadow)
Co-authored-by: thewilloftheshadow <thewilloftheshadow@users.noreply.github.com>
This commit is contained in:
committed by
Peter Steinberger
parent
48f733e4b3
commit
2f6d5805de
189
src/plugins/manifest-registry.ts
Normal file
189
src/plugins/manifest-registry.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
||||
import { discoverClawdbotPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
|
||||
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
||||
|
||||
export type PluginManifestRecord = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
kind?: PluginKind;
|
||||
channels: string[];
|
||||
providers: string[];
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
rootDir: string;
|
||||
source: string;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
};
|
||||
|
||||
export type PluginManifestRegistry = {
|
||||
plugins: PluginManifestRecord[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
const registryCache = new Map<string, { expiresAt: number; registry: PluginManifestRegistry }>();
|
||||
|
||||
const DEFAULT_MANIFEST_CACHE_MS = 200;
|
||||
|
||||
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.CLAWDBOT_PLUGIN_MANIFEST_CACHE_MS?.trim();
|
||||
if (raw === "" || raw === "0") return 0;
|
||||
if (!raw) return DEFAULT_MANIFEST_CACHE_MS;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_MANIFEST_CACHE_MS;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
|
||||
const disabled = env.CLAWDBOT_DISABLE_PLUGIN_MANIFEST_CACHE?.trim();
|
||||
if (disabled) return false;
|
||||
return resolveManifestCacheMs(env) > 0;
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
workspaceDir?: string;
|
||||
plugins: NormalizedPluginsConfig;
|
||||
}): string {
|
||||
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
||||
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
|
||||
}
|
||||
|
||||
function safeStatMtimeMs(filePath: string): number | null {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifestLabel(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function buildRecord(params: {
|
||||
manifest: PluginManifest;
|
||||
candidate: PluginCandidate;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
}): PluginManifestRecord {
|
||||
return {
|
||||
id: params.manifest.id,
|
||||
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
|
||||
description:
|
||||
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
|
||||
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
|
||||
kind: params.manifest.kind,
|
||||
channels: params.manifest.channels ?? [],
|
||||
providers: params.manifest.providers ?? [],
|
||||
origin: params.candidate.origin,
|
||||
workspaceDir: params.candidate.workspaceDir,
|
||||
rootDir: params.candidate.rootDir,
|
||||
source: params.candidate.source,
|
||||
manifestPath: params.manifestPath,
|
||||
schemaCacheKey: params.schemaCacheKey,
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: params.manifest.uiHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPluginManifestRegistry(params: {
|
||||
config?: ClawdbotConfig;
|
||||
workspaceDir?: string;
|
||||
cache?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
candidates?: PluginCandidate[];
|
||||
diagnostics?: PluginDiagnostic[];
|
||||
}): PluginManifestRegistry {
|
||||
const config = params.config ?? {};
|
||||
const normalized = normalizePluginsConfig(config.plugins);
|
||||
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized });
|
||||
const env = params.env ?? process.env;
|
||||
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
|
||||
if (cacheEnabled) {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.registry;
|
||||
}
|
||||
|
||||
const discovery = params.candidates
|
||||
? {
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics ?? [],
|
||||
}
|
||||
: discoverClawdbotPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
});
|
||||
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
|
||||
const candidates: PluginCandidate[] = discovery.candidates;
|
||||
const records: PluginManifestRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const manifestRes = loadPluginManifest(candidate.rootDir);
|
||||
if (!manifestRes.ok) {
|
||||
diagnostics.push({
|
||||
level: "error",
|
||||
message: manifestRes.error,
|
||||
source: manifestRes.manifestPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const manifest = manifestRes.manifest;
|
||||
|
||||
if (candidate.idHint && candidate.idHint !== manifest.id) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: manifest.id,
|
||||
source: candidate.source,
|
||||
message: `plugin id mismatch (manifest uses "${manifest.id}", entry hints "${candidate.idHint}")`,
|
||||
});
|
||||
}
|
||||
|
||||
if (seenIds.has(manifest.id)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: manifest.id,
|
||||
source: candidate.source,
|
||||
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
|
||||
});
|
||||
} else {
|
||||
seenIds.add(manifest.id);
|
||||
}
|
||||
|
||||
const configSchema = manifest.configSchema;
|
||||
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
|
||||
const schemaCacheKey = manifestMtime
|
||||
? `${manifestRes.manifestPath}:${manifestMtime}`
|
||||
: manifestRes.manifestPath;
|
||||
|
||||
records.push(
|
||||
buildRecord({
|
||||
manifest,
|
||||
candidate,
|
||||
manifestPath: manifestRes.manifestPath,
|
||||
schemaCacheKey,
|
||||
configSchema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const registry = { plugins: records, diagnostics };
|
||||
if (cacheEnabled) {
|
||||
const ttl = resolveManifestCacheMs(env);
|
||||
if (ttl > 0) {
|
||||
registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry });
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
Reference in New Issue
Block a user