import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { PluginLruCache, type PluginLruCacheResult } from "./plugin-lru-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { PluginRuntime } from "./runtime/types.js"; import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js"; import type { CliBackendPlugin, OpenClawPluginModule, PluginConfigMigration, PluginLogger, PluginSetupAutoEnableProbe, ProviderPlugin, } from "./types.js"; const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); const RUNNING_FROM_BUILT_ARTIFACT = CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) || CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`); type SetupProviderEntry = { pluginId: string; provider: ProviderPlugin; }; type SetupCliBackendEntry = { pluginId: string; backend: CliBackendPlugin; }; type SetupConfigMigrationEntry = { pluginId: string; migrate: PluginConfigMigration; }; type SetupAutoEnableProbeEntry = { pluginId: string; probe: PluginSetupAutoEnableProbe; }; export type PluginSetupRegistryDiagnosticCode = | "setup-descriptor-runtime-disabled" | "setup-descriptor-provider-missing-runtime" | "setup-descriptor-provider-runtime-undeclared" | "setup-descriptor-cli-backend-missing-runtime" | "setup-descriptor-cli-backend-runtime-undeclared"; export type PluginSetupRegistryDiagnostic = { pluginId: string; code: PluginSetupRegistryDiagnosticCode; declaredId?: string; runtimeId?: string; message: string; }; type PluginSetupRegistry = { providers: SetupProviderEntry[]; cliBackends: SetupCliBackendEntry[]; configMigrations: SetupConfigMigrationEntry[]; autoEnableProbes: SetupAutoEnableProbeEntry[]; diagnostics: PluginSetupRegistryDiagnostic[]; }; type SetupAutoEnableReason = { pluginId: string; reason: string; }; type PluginApiBuildParams = Parameters[0]; const EMPTY_RUNTIME = {} as PluginRuntime; const NOOP_LOGGER: PluginLogger = { info() {}, warn() {}, error() {}, }; const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128; const jitiLoaders: PluginJitiLoaderCache = new Map(); const setupRegistryCache = new PluginLruCache(MAX_SETUP_LOOKUP_CACHE_ENTRIES); const setupProviderCache = new PluginLruCache( MAX_SETUP_LOOKUP_CACHE_ENTRIES, ); const setupCliBackendCache = new PluginLruCache( MAX_SETUP_LOOKUP_CACHE_ENTRIES, ); export const __testing = { get maxSetupLookupCacheEntries() { return setupRegistryCache.maxEntries; }, setMaxSetupLookupCacheEntriesForTest(value?: number) { setupRegistryCache.setMaxEntriesForTest(value); setupProviderCache.setMaxEntriesForTest(value); setupCliBackendCache.setMaxEntriesForTest(value); }, getCacheSizes() { return { setupRegistry: setupRegistryCache.size, setupProvider: setupProviderCache.size, setupCliBackend: setupCliBackendCache.size, }; }, } as const; export function clearPluginSetupRegistryCache(): void { jitiLoaders.clear(); setupRegistryCache.clear(); setupProviderCache.clear(); setupCliBackendCache.clear(); } function getJiti(modulePath: string) { return getCachedPluginJitiLoader({ cache: jitiLoaders, modulePath, importerUrl: import.meta.url, }); } function getCachedSetupValue(cache: PluginLruCache, key: string): PluginLruCacheResult { return cache.getResult(key); } function setCachedSetupValue(cache: PluginLruCache, key: string, value: T): void { cache.set(key, value); } function buildSetupRegistryCacheKey(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, env: params.env, loadPaths: params.config?.plugins?.load?.paths, }); return JSON.stringify({ roots, loadPaths, hasConfig: Boolean(params.config), pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, }); } function buildSetupProviderCacheKey(params: { provider: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): string { return JSON.stringify({ provider: normalizeProviderId(params.provider), registry: buildSetupRegistryCacheKey(params), }); } function buildSetupCliBackendCacheKey(params: { backend: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string { return JSON.stringify({ backend: normalizeProviderId(params.backend), registry: buildSetupRegistryCacheKey(params), }); } function resolveSetupApiPath( rootDir: string, options?: { includeBundledSourceFallback?: boolean }, ): string | null { const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT ? SETUP_API_EXTENSIONS : ([...SETUP_API_EXTENSIONS.slice(3), ...SETUP_API_EXTENSIONS.slice(0, 3)] as const); const findSetupApi = (candidateRootDir: string): string | null => { for (const extension of orderedExtensions) { const candidate = path.join(candidateRootDir, `setup-api${extension}`); if (fs.existsSync(candidate)) { return candidate; } } return null; }; const direct = findSetupApi(rootDir); if (direct) { return direct; } if (options?.includeBundledSourceFallback === false) { return null; } const bundledExtensionDir = path.basename(rootDir); const repoRootCandidates = [path.resolve(path.dirname(CURRENT_MODULE_PATH), "..", "..")]; for (const repoRoot of repoRootCandidates) { const sourceExtensionRoot = path.join(repoRoot, "extensions", bundledExtensionDir); if (sourceExtensionRoot === rootDir) { continue; } const sourceFallback = findSetupApi(sourceExtensionRoot); if (sourceFallback) { return sourceFallback; } } return null; } function collectConfiguredPluginEntryIds(config: OpenClawConfig): string[] { const entries = config.plugins?.entries; if (!entries || typeof entries !== "object") { return []; } return Object.keys(entries) .map((pluginId) => pluginId.trim()) .filter(Boolean) .toSorted(); } function resolveRelevantSetupMigrationPluginIds(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string[] { const ids = new Set(collectConfiguredPluginEntryIds(params.config)); const registry = loadSetupManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); for (const plugin of registry.plugins) { const paths = plugin.configContracts?.compatibilityMigrationPaths; if (!paths?.length) { continue; } if ( paths.some( (pathPattern) => collectPluginConfigContractMatches({ root: params.config, pathPattern, }).length > 0, ) ) { ids.add(plugin.id); } } return [...ids].toSorted(); } function resolveRegister(mod: OpenClawPluginModule): { definition?: { id?: string }; register?: (api: ReturnType) => void | Promise; } { if (typeof mod === "function") { return { register: mod }; } if (mod && typeof mod === "object" && typeof mod.register === "function") { return { definition: mod as { id?: string }, register: mod.register.bind(mod), }; } return {}; } function resolveLoadableSetupRuntimeSource(record: PluginManifestRecord): string | null { return record.setupSource ?? resolveSetupApiPath(record.rootDir); } function resolveDeclaredSetupRuntimeSource(record: PluginManifestRecord): string | null { return ( record.setupSource ?? resolveSetupApiPath(record.rootDir, { includeBundledSourceFallback: false, }) ); } function resolveSetupRegistration(record: PluginManifestRecord): { setupSource: string; register: (api: ReturnType) => void | Promise; } | null { if (record.setup?.requiresRuntime === false) { return null; } const setupSource = resolveLoadableSetupRuntimeSource(record); if (!setupSource) { return null; } let mod: OpenClawPluginModule; try { mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; } catch { return null; } const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod); if (!resolved.register) { return null; } if (resolved.definition?.id && resolved.definition.id !== record.id) { return null; } return { setupSource, register: resolved.register, }; } function buildSetupPluginApi(params: { record: PluginManifestRecord; setupSource: string; handlers: PluginApiBuildParams["handlers"]; }): ReturnType { return buildPluginApi({ id: params.record.id, name: params.record.name ?? params.record.id, version: params.record.version, description: params.record.description, source: params.setupSource, rootDir: params.record.rootDir, registrationMode: "setup-only", config: {} as OpenClawConfig, runtime: EMPTY_RUNTIME, logger: NOOP_LOGGER, resolvePath: (input) => input, handlers: params.handlers, }); } function ignoreAsyncSetupRegisterResult(result: void | Promise): void { if (!result || typeof result.then !== "function") { return; } // Setup-only registration is sync-only. Swallow async rejections so they do // not trip the global unhandledRejection fatal path. void Promise.resolve(result).catch(() => undefined); } function matchesProvider(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); if (normalizeProviderId(provider.id) === normalized) { return true; } return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some( (alias) => normalizeProviderId(alias) === normalized, ); } function loadSetupManifestRegistry(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }) { const env = params?.env ?? process.env; return loadPluginManifestRegistryForPluginRegistry({ config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, includeDisabled: true, }); } function findUniqueSetupManifestOwner(params: { registry: ReturnType; normalizedId: string; listIds: (record: PluginManifestRecord) => readonly string[]; }): PluginManifestRecord | undefined { const matches = params.registry.plugins.filter((entry) => params.listIds(entry).some((id) => normalizeProviderId(id) === params.normalizedId), ); if (matches.length === 0) { return undefined; } // Setup lookup can execute plugin code. Refuse ambiguous ownership instead of // depending on manifest ordering across bundled/workspace/global sources. return matches.length === 1 ? matches[0] : undefined; } function mapNormalizedIds(ids: readonly string[]): Map { const mapped = new Map(); for (const id of ids) { const normalized = normalizeProviderId(id); if (!normalized || mapped.has(normalized)) { continue; } mapped.set(normalized, id); } return mapped; } function pushDescriptorRuntimeDisabledDiagnostic(params: { record: PluginManifestRecord; diagnostics: PluginSetupRegistryDiagnostic[]; }): void { if (!resolveDeclaredSetupRuntimeSource(params.record)) { return; } params.diagnostics.push({ pluginId: params.record.id, code: "setup-descriptor-runtime-disabled", message: "setup.requiresRuntime is false, so OpenClaw ignored the plugin setup runtime entry. Remove setup-api/openclaw.setupEntry or set requiresRuntime true if setup lookup still needs plugin code.", }); } function pushSetupDescriptorDriftDiagnostics(params: { record: PluginManifestRecord; providers: readonly ProviderPlugin[]; cliBackends: readonly CliBackendPlugin[]; diagnostics: PluginSetupRegistryDiagnostic[]; }): void { const declaredProviderIds = params.record.setup?.providers?.map((entry) => entry.id); if (declaredProviderIds) { for (const declaredId of declaredProviderIds) { if (!params.providers.some((provider) => matchesProvider(provider, declaredId))) { params.diagnostics.push({ pluginId: params.record.id, code: "setup-descriptor-provider-missing-runtime", declaredId, message: `setup.providers declares "${declaredId}" but setup runtime did not register a matching provider.`, }); } } for (const provider of params.providers) { if (!declaredProviderIds.some((declaredId) => matchesProvider(provider, declaredId))) { params.diagnostics.push({ pluginId: params.record.id, code: "setup-descriptor-provider-runtime-undeclared", runtimeId: provider.id, message: `setup runtime registered provider "${provider.id}" but setup.providers does not declare it.`, }); } } } const declaredCliBackendIds = params.record.setup?.cliBackends; if (declaredCliBackendIds) { const declaredCliBackends = mapNormalizedIds(declaredCliBackendIds); const runtimeCliBackends = mapNormalizedIds(params.cliBackends.map((backend) => backend.id)); for (const [normalized, declaredId] of declaredCliBackends) { if (!runtimeCliBackends.has(normalized)) { params.diagnostics.push({ pluginId: params.record.id, code: "setup-descriptor-cli-backend-missing-runtime", declaredId, message: `setup.cliBackends declares "${declaredId}" but setup runtime did not register a matching CLI backend.`, }); } } for (const [normalized, runtimeId] of runtimeCliBackends) { if (!declaredCliBackends.has(normalized)) { params.diagnostics.push({ pluginId: params.record.id, code: "setup-descriptor-cli-backend-runtime-undeclared", runtimeId, message: `setup runtime registered CLI backend "${runtimeId}" but setup.cliBackends does not declare it.`, }); } } } } export function resolvePluginSetupRegistry(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; const cacheKey = buildSetupRegistryCacheKey({ config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, }); const cached = getCachedSetupValue(setupRegistryCache, cacheKey); if (cached.hit) { return cached.value; } const selectedPluginIds = params?.pluginIds ? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) : null; if (selectedPluginIds && selectedPluginIds.size === 0) { const empty = { providers: [], cliBackends: [], configMigrations: [], autoEnableProbes: [], diagnostics: [], } satisfies PluginSetupRegistry; setCachedSetupValue(setupRegistryCache, cacheKey, empty); return empty; } const providers: SetupProviderEntry[] = []; const cliBackends: SetupCliBackendEntry[] = []; const configMigrations: SetupConfigMigrationEntry[] = []; const autoEnableProbes: SetupAutoEnableProbeEntry[] = []; const diagnostics: PluginSetupRegistryDiagnostic[] = []; const providerKeys = new Set(); const cliBackendKeys = new Set(); const manifestRegistry = loadSetupManifestRegistry({ config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, }); for (const record of manifestRegistry.plugins) { if (selectedPluginIds && !selectedPluginIds.has(record.id)) { continue; } if (record.setup?.requiresRuntime === false) { pushDescriptorRuntimeDisabledDiagnostic({ record, diagnostics, }); continue; } const setupRegistration = resolveSetupRegistration(record); if (!setupRegistration) { continue; } const recordProviders: ProviderPlugin[] = []; const recordCliBackends: CliBackendPlugin[] = []; const api = buildSetupPluginApi({ record, setupSource: setupRegistration.setupSource, handlers: { registerProvider(provider) { const key = `${record.id}:${normalizeProviderId(provider.id)}`; if (providerKeys.has(key)) { return; } providerKeys.add(key); providers.push({ pluginId: record.id, provider, }); recordProviders.push(provider); }, registerCliBackend(backend) { const key = `${record.id}:${normalizeProviderId(backend.id)}`; if (cliBackendKeys.has(key)) { return; } cliBackendKeys.add(key); cliBackends.push({ pluginId: record.id, backend, }); recordCliBackends.push(backend); }, registerConfigMigration(migrate) { configMigrations.push({ pluginId: record.id, migrate, }); }, registerAutoEnableProbe(probe) { autoEnableProbes.push({ pluginId: record.id, probe, }); }, }, }); try { const result = setupRegistration.register(api); if (result && typeof result.then === "function") { // Keep setup registration sync-only. ignoreAsyncSetupRegisterResult(result); } } catch { continue; } pushSetupDescriptorDriftDiagnostics({ record, providers: recordProviders, cliBackends: recordCliBackends, diagnostics, }); } const registry = { providers, cliBackends, configMigrations, autoEnableProbes, diagnostics, } satisfies PluginSetupRegistry; setCachedSetupValue(setupRegistryCache, cacheKey, registry); return registry; } export function resolvePluginSetupProvider(params: { provider: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): ProviderPlugin | undefined { const cacheKey = buildSetupProviderCacheKey(params); const cached = getCachedSetupValue(setupProviderCache, cacheKey); if (cached.hit) { return cached.value ?? undefined; } const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); const manifestRegistry = loadSetupManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env, pluginIds: params.pluginIds, }); const record = findUniqueSetupManifestOwner({ registry: manifestRegistry, normalizedId: normalizedProvider, listIds: listSetupProviderIds, }); if (!record) { setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } const setupRegistration = resolveSetupRegistration(record); if (!setupRegistration) { setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } let matchedProvider: ProviderPlugin | undefined; const localProviderKeys = new Set(); const api = buildSetupPluginApi({ record, setupSource: setupRegistration.setupSource, handlers: { registerProvider(provider) { const key = normalizeProviderId(provider.id); if (localProviderKeys.has(key)) { return; } localProviderKeys.add(key); if (matchesProvider(provider, normalizedProvider)) { matchedProvider = provider; } }, registerConfigMigration() {}, registerAutoEnableProbe() {}, }, }); try { const result = setupRegistration.register(api); if (result && typeof result.then === "function") { // Keep setup registration sync-only. ignoreAsyncSetupRegisterResult(result); } } catch { setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } setCachedSetupValue(setupProviderCache, cacheKey, matchedProvider ?? null); return matchedProvider; } export function resolvePluginSetupCliBackend(params: { backend: string; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { const cacheKey = buildSetupCliBackendCacheKey(params); const cached = getCachedSetupValue(setupCliBackendCache, cacheKey); if (cached.hit) { return cached.value ?? undefined; } const normalized = normalizeProviderId(params.backend); const env = params.env ?? process.env; // Narrow setup lookup from manifest-owned descriptors before executing any // plugin setup module. This avoids booting every setup-api just to find one // backend owner. const manifestRegistry = loadSetupManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env, }); const record = findUniqueSetupManifestOwner({ registry: manifestRegistry, normalizedId: normalized, listIds: listSetupCliBackendIds, }); if (!record) { setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } const setupRegistration = resolveSetupRegistration(record); if (!setupRegistration) { setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } let matchedBackend: CliBackendPlugin | undefined; const localBackendKeys = new Set(); const api = buildSetupPluginApi({ record, setupSource: setupRegistration.setupSource, handlers: { registerProvider() {}, registerConfigMigration() {}, registerAutoEnableProbe() {}, registerCliBackend(backend) { const key = normalizeProviderId(backend.id); if (localBackendKeys.has(key)) { return; } localBackendKeys.add(key); if (key === normalized) { matchedBackend = backend; } }, }, }); try { const result = setupRegistration.register(api); if (result && typeof result.then === "function") { // Keep setup registration sync-only. ignoreAsyncSetupRegisterResult(result); } } catch { setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } const resolvedEntry = matchedBackend ? { pluginId: record.id, backend: matchedBackend } : null; setCachedSetupValue(setupCliBackendCache, cacheKey, resolvedEntry); return resolvedEntry ?? undefined; } export function runPluginSetupConfigMigrations(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): { config: OpenClawConfig; changes: string[]; } { let next = params.config; const changes: string[] = []; const pluginIds = resolveRelevantSetupMigrationPluginIds(params); if (pluginIds.length === 0) { return { config: next, changes }; } for (const entry of resolvePluginSetupRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, pluginIds, }).configMigrations) { const migration = entry.migrate(next); if (!migration || migration.changes.length === 0) { continue; } next = migration.config; changes.push(...migration.changes); } return { config: next, changes }; } export function resolvePluginSetupAutoEnableReasons(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): SetupAutoEnableReason[] { const env = params.env ?? process.env; const reasons: SetupAutoEnableReason[] = []; const seen = new Set(); for (const entry of resolvePluginSetupRegistry({ config: params.config, workspaceDir: params.workspaceDir, env, pluginIds: params.pluginIds, }).autoEnableProbes) { const raw = entry.probe({ config: params.config, env, }); const values = Array.isArray(raw) ? raw : raw ? [raw] : []; for (const reason of values) { const normalized = reason.trim(); if (!normalized) { continue; } const key = `${entry.pluginId}:${normalized}`; if (seen.has(key)) { continue; } seen.add(key); reasons.push({ pluginId: entry.pluginId, reason: normalized, }); } } return reasons; }