| import fs from "node:fs"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; |
| import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; |
| import { loadPluginManifest, type PluginManifest } from "./manifest.js"; |
| import { safeRealpathSync } from "./path-safety.js"; |
| import { resolvePluginCacheInputs } from "./roots.js"; |
| import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js"; |
|
|
| type SeenIdEntry = { |
| candidate: PluginCandidate; |
| recordIndex: number; |
| }; |
|
|
| |
| const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = { |
| config: 0, |
| workspace: 1, |
| global: 2, |
| bundled: 3, |
| }; |
|
|
| 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 = 1000; |
|
|
| 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; |
| env: NodeJS.ProcessEnv; |
| }): string { |
| const { roots, loadPaths } = resolvePluginCacheInputs({ |
| workspaceDir: params.workspaceDir, |
| loadPaths: params.plugins.loadPaths, |
| env: params.env, |
| }); |
| const workspaceKey = roots.workspace ?? ""; |
| const configExtensionsRoot = roots.global; |
| const bundledRoot = roots.stock ?? ""; |
| |
| |
| return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${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 env = params.env ?? process.env; |
| const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized, 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, |
| env, |
| }); |
| 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 rejectHardlinks = candidate.origin !== "bundled"; |
| const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks); |
| 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 schemaCacheKey = (() => { |
| if (!configSchema) { |
| return undefined; |
| } |
| const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath); |
| return manifestMtime |
| ? `${manifestRes.manifestPath}:${manifestMtime}` |
| : manifestRes.manifestPath; |
| })(); |
|
|
| const existing = seenIds.get(manifest.id); |
| if (existing) { |
| |
| |
| |
| const samePath = existing.candidate.rootDir === candidate.rootDir; |
| const samePlugin = (() => { |
| if (samePath) { |
| return true; |
| } |
| const existingReal = safeRealpathSync(existing.candidate.rootDir, realpathCache); |
| const candidateReal = safeRealpathSync(candidate.rootDir, realpathCache); |
| return Boolean(existingReal && candidateReal && existingReal === candidateReal); |
| })(); |
| if (samePlugin) { |
| |
| |
| 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; |
| } |
|
|