262 lines
8.3 KiB
TypeScript
262 lines
8.3 KiB
TypeScript
import fs from "node:fs";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
|
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
|
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
|
|
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
|
|
|
type SeenIdEntry = {
|
|
candidate: PluginCandidate;
|
|
recordIndex: number;
|
|
};
|
|
|
|
// Precedence: config > workspace > global > bundled
|
|
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
|
|
config: 0,
|
|
workspace: 1,
|
|
global: 2,
|
|
bundled: 3,
|
|
};
|
|
|
|
function safeRealpathSync(rootDir: string, cache: Map<string, string>): string | null {
|
|
const cached = cache.get(rootDir);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
try {
|
|
const resolved = fs.realpathSync(rootDir);
|
|
cache.set(rootDir, resolved);
|
|
return resolved;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type PluginManifestRecord = {
|
|
id: string;
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
kind?: PluginKind;
|
|
channels: string[];
|
|
providers: string[];
|
|
skills: 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;
|
|
|
|
export function clearPluginManifestRegistryCache(): void {
|
|
registryCache.clear();
|
|
}
|
|
|
|
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
|
|
const raw = env.OPENCLAW_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.OPENCLAW_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) : "";
|
|
// The manifest registry only depends on where plugins are discovered from (workspace + load paths).
|
|
// It does not depend on allow/deny/entries enable-state, so exclude those for higher cache hit rates.
|
|
const loadPaths = params.plugins.loadPaths
|
|
.map((p) => resolveUserPath(p))
|
|
.map((p) => p.trim())
|
|
.filter(Boolean)
|
|
.toSorted();
|
|
return `${workspaceKey}::${JSON.stringify(loadPaths)}`;
|
|
}
|
|
|
|
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 ?? [],
|
|
skills: params.manifest.skills ?? [],
|
|
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?: OpenClawConfig;
|
|
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 ?? [],
|
|
}
|
|
: discoverOpenClawPlugins({
|
|
workspaceDir: params.workspaceDir,
|
|
extraPaths: normalized.loadPaths,
|
|
});
|
|
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
|
|
const candidates: PluginCandidate[] = discovery.candidates;
|
|
const records: PluginManifestRecord[] = [];
|
|
const seenIds = new Map<string, SeenIdEntry>();
|
|
const realpathCache = new Map<string, 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}")`,
|
|
});
|
|
}
|
|
|
|
const configSchema = manifest.configSchema;
|
|
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
|
|
const schemaCacheKey = manifestMtime
|
|
? `${manifestRes.manifestPath}:${manifestMtime}`
|
|
: manifestRes.manifestPath;
|
|
|
|
const existing = seenIds.get(manifest.id);
|
|
if (existing) {
|
|
// Check whether both candidates point to the same physical directory
|
|
// (e.g. via symlinks or different path representations). If so, this
|
|
// is a false-positive duplicate and can be silently skipped.
|
|
const existingReal = safeRealpathSync(existing.candidate.rootDir, realpathCache);
|
|
const candidateReal = safeRealpathSync(candidate.rootDir, realpathCache);
|
|
const samePlugin = Boolean(existingReal && candidateReal && existingReal === candidateReal);
|
|
if (samePlugin) {
|
|
// Prefer higher-precedence origins even if candidates are passed in
|
|
// an unexpected order (config > workspace > global > bundled).
|
|
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
|
|
records[existing.recordIndex] = buildRecord({
|
|
manifest,
|
|
candidate,
|
|
manifestPath: manifestRes.manifestPath,
|
|
schemaCacheKey,
|
|
configSchema,
|
|
});
|
|
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
|
|
}
|
|
continue;
|
|
}
|
|
diagnostics.push({
|
|
level: "warn",
|
|
pluginId: manifest.id,
|
|
source: candidate.source,
|
|
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
|
|
});
|
|
} else {
|
|
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
|
|
}
|
|
|
|
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;
|
|
}
|