import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfigWithResolver, type NormalizedPluginsConfig, } from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, type OpenClawPackageManifest, type PluginManifest, type PluginManifestChannelConfig, type PluginManifestContracts, } from "./manifest.js"; import { checkMinHostVersion } from "./min-host-version.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { PluginBundleFormat, PluginConfigUiHint, PluginDiagnostic, PluginFormat, PluginKind, PluginOrigin, } from "./types.js"; type SeenIdEntry = { candidate: PluginCandidate; recordIndex: number; }; // Canonicalize identical physical plugin roots with the most explicit source. // This only applies when multiple candidates resolve to the same on-disk plugin. const PLUGIN_ORIGIN_RANK: Readonly> = { config: 0, workspace: 1, global: 2, bundled: 3, }; export type PluginManifestRecord = { id: string; name?: string; description?: string; version?: string; enabledByDefault?: boolean; autoEnableWhenConfiguredProviders?: string[]; legacyPluginIds?: string[]; format?: PluginFormat; bundleFormat?: PluginBundleFormat; bundleCapabilities?: string[]; kind?: PluginKind | PluginKind[]; channels: string[]; providers: string[]; cliBackends: string[]; providerAuthEnvVars?: Record; providerAuthChoices?: PluginManifest["providerAuthChoices"]; skills: string[]; settingsFiles?: string[]; hooks: string[]; origin: PluginOrigin; workspaceDir?: string; rootDir: string; source: string; setupSource?: string; startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; configUiHints?: Record; contracts?: PluginManifestContracts; channelConfigs?: Record; channelCatalogMeta?: { id: string; label?: string; blurb?: string; preferOver?: readonly string[]; }; }; export type PluginManifestRegistry = { plugins: PluginManifestRecord[]; diagnostics: PluginDiagnostic[]; }; const registryCache = new Map(); // Keep a short cache window to collapse bursty reloads during startup flows. 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 ?? ""; const runtimeServiceVersion = resolveCompatibilityHostVersion(params.env); // 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. return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${runtimeServiceVersion}::${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 normalizePreferredPluginIds(raw: unknown): string[] | undefined { if (!Array.isArray(raw)) { return undefined; } const values = raw .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter(Boolean); return values.length > 0 ? values : undefined; } function mergePackageChannelMetaIntoChannelConfigs(params: { channelConfigs?: Record; packageChannel?: OpenClawPackageManifest["channel"]; }): Record | undefined { const channelId = params.packageChannel?.id?.trim(); if (!channelId || !params.channelConfigs?.[channelId]) { return params.channelConfigs; } const existing = params.channelConfigs[channelId]; const label = existing.label ?? (typeof params.packageChannel?.label === "string" ? params.packageChannel.label.trim() : ""); const description = existing.description ?? (typeof params.packageChannel?.blurb === "string" ? params.packageChannel.blurb.trim() : ""); const preferOver = existing.preferOver ?? normalizePreferredPluginIds(params.packageChannel?.preferOver); return { ...params.channelConfigs, [channelId]: { ...existing, ...(label ? { label } : {}), ...(description ? { description } : {}), ...(preferOver?.length ? { preferOver } : {}), }, }; } function buildRecord(params: { manifest: PluginManifest; candidate: PluginCandidate; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; }): PluginManifestRecord { const channelConfigs = mergePackageChannelMetaIntoChannelConfigs({ channelConfigs: params.manifest.channelConfigs, packageChannel: params.candidate.packageManifest?.channel, }); 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, enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined, autoEnableWhenConfiguredProviders: params.manifest.autoEnableWhenConfiguredProviders, legacyPluginIds: params.manifest.legacyPluginIds, format: params.candidate.format ?? "openclaw", bundleFormat: params.candidate.bundleFormat, kind: params.manifest.kind, channels: params.manifest.channels ?? [], providers: params.manifest.providers ?? [], cliBackends: params.manifest.cliBackends ?? [], providerAuthEnvVars: params.manifest.providerAuthEnvVars, providerAuthChoices: params.manifest.providerAuthChoices, skills: params.manifest.skills ?? [], settingsFiles: [], hooks: [], origin: params.candidate.origin, workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, source: params.candidate.source, setupSource: params.candidate.setupSource, startupDeferConfiguredChannelFullLoadUntilAfterListen: params.candidate.packageManifest?.startup?.deferConfiguredChannelFullLoadUntilAfterListen === true, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, contracts: params.manifest.contracts, channelConfigs, ...(params.candidate.packageManifest?.channel?.id ? { channelCatalogMeta: { id: params.candidate.packageManifest.channel.id, ...(typeof params.candidate.packageManifest.channel.label === "string" ? { label: params.candidate.packageManifest.channel.label } : {}), ...(typeof params.candidate.packageManifest.channel.blurb === "string" ? { blurb: params.candidate.packageManifest.channel.blurb } : {}), ...(params.candidate.packageManifest.channel.preferOver ? { preferOver: params.candidate.packageManifest.channel.preferOver } : {}), }, } : {}), }; } function buildBundleRecord(params: { manifest: { id: string; name?: string; description?: string; version?: string; skills: string[]; settingsFiles?: string[]; hooks: string[]; capabilities: string[]; }; candidate: PluginCandidate; manifestPath: string; }): PluginManifestRecord { return { id: params.manifest.id, name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint, description: normalizeManifestLabel(params.manifest.description), version: normalizeManifestLabel(params.manifest.version), format: "bundle", bundleFormat: params.candidate.bundleFormat, bundleCapabilities: params.manifest.capabilities, channels: [], providers: [], cliBackends: [], skills: params.manifest.skills ?? [], settingsFiles: params.manifest.settingsFiles ?? [], hooks: params.manifest.hooks ?? [], origin: params.candidate.origin, workspaceDir: params.candidate.workspaceDir, rootDir: params.candidate.rootDir, source: params.candidate.source, manifestPath: params.manifestPath, schemaCacheKey: undefined, configSchema: undefined, configUiHints: undefined, channelConfigs: undefined, }; } function matchesInstalledPluginRecord(params: { pluginId: string; candidate: PluginCandidate; config?: OpenClawConfig; env: NodeJS.ProcessEnv; }): boolean { if (params.candidate.origin !== "global") { return false; } const record = params.config?.plugins?.installs?.[params.pluginId]; if (!record) { return false; } const candidateSource = resolveUserPath(params.candidate.source, params.env); const trackedPaths = [record.installPath, record.sourcePath] .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) .map((entry) => resolveUserPath(entry, params.env)); if (trackedPaths.length === 0) { return false; } return trackedPaths.some((trackedPath) => { return candidateSource === trackedPath || isPathInside(trackedPath, candidateSource); }); } function resolveDuplicatePrecedenceRank(params: { pluginId: string; candidate: PluginCandidate; config?: OpenClawConfig; env: NodeJS.ProcessEnv; }): number { if (params.candidate.origin === "config") { return 0; } if ( params.candidate.origin === "global" && matchesInstalledPluginRecord({ pluginId: params.pluginId, candidate: params.candidate, config: params.config, env: params.env, }) ) { return 1; } if (params.candidate.origin === "bundled") { // Bundled plugin ids are reserved unless the operator explicitly overrides them. return 2; } if (params.candidate.origin === "workspace") { return 3; } return 4; } export function loadPluginManifestRegistry( params: { config?: OpenClawConfig; workspaceDir?: string; cache?: boolean; env?: NodeJS.ProcessEnv; candidates?: PluginCandidate[]; diagnostics?: PluginDiagnostic[]; } = {}, ): PluginManifestRegistry { const config = params.config ?? {}; const normalized = normalizePluginsConfigWithResolver(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, cache: params.cache, env, }); const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics]; const candidates: PluginCandidate[] = discovery.candidates; const records: PluginManifestRecord[] = []; const seenIds = new Map(); const realpathCache = new Map(); const currentHostVersion = resolveCompatibilityHostVersion(env); for (const candidate of candidates) { const rejectHardlinks = candidate.origin !== "bundled"; const isBundleRecord = (candidate.format ?? "openclaw") === "bundle"; const manifestRes: | ReturnType | ReturnType | { ok: true; manifest: PluginManifest; manifestPath: string } = candidate.origin === "bundled" && candidate.bundledManifest && candidate.bundledManifestPath ? { ok: true, manifest: candidate.bundledManifest, manifestPath: candidate.bundledManifestPath, } : isBundleRecord && candidate.bundleFormat ? loadBundleManifest({ rootDir: candidate.rootDir, bundleFormat: candidate.bundleFormat, rejectHardlinks, }) : loadPluginManifest(candidate.rootDir, rejectHardlinks); if (!manifestRes.ok) { diagnostics.push({ level: "error", message: manifestRes.error, source: manifestRes.manifestPath, }); continue; } const manifest = manifestRes.manifest; const minHostVersionCheck = checkMinHostVersion({ currentVersion: currentHostVersion, minHostVersion: candidate.packageManifest?.install?.minHostVersion, }); if (!minHostVersionCheck.ok) { const packageManifestSource = path.join( candidate.packageDir ?? candidate.rootDir, "package.json", ); diagnostics.push({ level: minHostVersionCheck.kind === "unknown_host_version" ? "warn" : "error", pluginId: manifest.id, source: packageManifestSource, message: minHostVersionCheck.kind === "invalid" ? `plugin manifest invalid | ${minHostVersionCheck.error}` : minHostVersionCheck.kind === "unknown_host_version" ? `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined; skipping load` : `plugin requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}; skipping load`, }); continue; } const configSchema = "configSchema" in manifest ? manifest.configSchema : undefined; 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) { // 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 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) { // 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] = isBundleRecord ? buildBundleRecord({ manifest: manifest as Parameters[0]["manifest"], candidate, manifestPath: manifestRes.manifestPath, }) : buildRecord({ manifest: manifest as PluginManifest, 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: resolveDuplicatePrecedenceRank({ pluginId: manifest.id, candidate, config, env, }) < resolveDuplicatePrecedenceRank({ pluginId: manifest.id, candidate: existing.candidate, config, env, }) ? `duplicate plugin id detected; ${existing.candidate.origin} plugin will be overridden by ${candidate.origin} plugin (${candidate.source})` : `duplicate plugin id detected; ${candidate.origin} plugin will be overridden by ${existing.candidate.origin} plugin (${candidate.source})`, }); } else { seenIds.set(manifest.id, { candidate, recordIndex: records.length }); } records.push( isBundleRecord ? buildBundleRecord({ manifest: manifest as Parameters[0]["manifest"], candidate, manifestPath: manifestRes.manifestPath, }) : buildRecord({ manifest: manifest as PluginManifest, 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; }