| import fs from "node:fs"; |
| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import { createJiti } from "jiti"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import type { PluginInstallRecord } from "../config/types.plugins.js"; |
| import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; |
| import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; |
| import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; |
| import { createSubsystemLogger } from "../logging/subsystem.js"; |
| import { resolveUserPath } from "../utils.js"; |
| import { clearPluginCommands } from "./commands.js"; |
| import { |
| applyTestPluginDefaults, |
| normalizePluginsConfig, |
| resolveEffectiveEnableState, |
| resolveMemorySlotDecision, |
| type NormalizedPluginsConfig, |
| } from "./config-state.js"; |
| import { discoverOpenClawPlugins } from "./discovery.js"; |
| import { initializeGlobalHookRunner } from "./hook-runner-global.js"; |
| import { loadPluginManifestRegistry } from "./manifest-registry.js"; |
| import { isPathInside, safeStatSync } from "./path-safety.js"; |
| import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; |
| import { resolvePluginCacheInputs } from "./roots.js"; |
| import { setActivePluginRegistry } from "./runtime.js"; |
| import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js"; |
| import type { PluginRuntime } from "./runtime/types.js"; |
| import { validateJsonSchemaValue } from "./schema-validator.js"; |
| import type { |
| OpenClawPluginDefinition, |
| OpenClawPluginModule, |
| PluginDiagnostic, |
| PluginLogger, |
| } from "./types.js"; |
|
|
| export type PluginLoadResult = PluginRegistry; |
|
|
| export type PluginLoadOptions = { |
| config?: OpenClawConfig; |
| workspaceDir?: string; |
| |
| |
| env?: NodeJS.ProcessEnv; |
| logger?: PluginLogger; |
| coreGatewayHandlers?: Record<string, GatewayRequestHandler>; |
| runtimeOptions?: CreatePluginRuntimeOptions; |
| cache?: boolean; |
| mode?: "full" | "validate"; |
| }; |
|
|
| const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; |
| const registryCache = new Map<string, PluginRegistry>(); |
| const openAllowlistWarningCache = new Set<string>(); |
|
|
| export function clearPluginLoaderCache(): void { |
| registryCache.clear(); |
| openAllowlistWarningCache.clear(); |
| } |
|
|
| const defaultLogger = () => createSubsystemLogger("plugins"); |
|
|
| type PluginSdkAliasCandidateKind = "dist" | "src"; |
|
|
| function resolvePluginSdkAliasCandidateOrder(params: { |
| modulePath: string; |
| isProduction: boolean; |
| }): PluginSdkAliasCandidateKind[] { |
| const normalizedModulePath = params.modulePath.replace(/\\/g, "/"); |
| const isDistRuntime = normalizedModulePath.includes("/dist/"); |
| return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"]; |
| } |
|
|
| function listPluginSdkAliasCandidates(params: { |
| srcFile: string; |
| distFile: string; |
| modulePath: string; |
| }) { |
| const orderedKinds = resolvePluginSdkAliasCandidateOrder({ |
| modulePath: params.modulePath, |
| isProduction: process.env.NODE_ENV === "production", |
| }); |
| let cursor = path.dirname(params.modulePath); |
| const candidates: string[] = []; |
| for (let i = 0; i < 6; i += 1) { |
| const candidateMap = { |
| src: path.join(cursor, "src", "plugin-sdk", params.srcFile), |
| dist: path.join(cursor, "dist", "plugin-sdk", params.distFile), |
| } as const; |
| for (const kind of orderedKinds) { |
| candidates.push(candidateMap[kind]); |
| } |
| const parent = path.dirname(cursor); |
| if (parent === cursor) { |
| break; |
| } |
| cursor = parent; |
| } |
| return candidates; |
| } |
|
|
| const resolvePluginSdkAliasFile = (params: { |
| srcFile: string; |
| distFile: string; |
| modulePath?: string; |
| }): string | null => { |
| try { |
| const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); |
| for (const candidate of listPluginSdkAliasCandidates({ |
| srcFile: params.srcFile, |
| distFile: params.distFile, |
| modulePath, |
| })) { |
| if (fs.existsSync(candidate)) { |
| return candidate; |
| } |
| } |
| } catch { |
| |
| } |
| return null; |
| }; |
|
|
| const resolvePluginSdkAlias = (): string | null => |
| resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); |
|
|
| const cachedPluginSdkExportedSubpaths = new Map<string, string[]>(); |
|
|
| function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { |
| const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); |
| const packageRoot = resolveOpenClawPackageRootSync({ |
| cwd: path.dirname(modulePath), |
| }); |
| if (!packageRoot) { |
| return []; |
| } |
| const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); |
| if (cached) { |
| return cached; |
| } |
| try { |
| const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); |
| const pkg = JSON.parse(pkgRaw) as { |
| exports?: Record<string, unknown>; |
| }; |
| const subpaths = Object.keys(pkg.exports ?? {}) |
| .filter((key) => key.startsWith("./plugin-sdk/")) |
| .map((key) => key.slice("./plugin-sdk/".length)) |
| .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) |
| .toSorted(); |
| cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); |
| return subpaths; |
| } catch { |
| return []; |
| } |
| } |
|
|
| const resolvePluginSdkScopedAliasMap = (): Record<string, string> => { |
| const aliasMap: Record<string, string> = {}; |
| for (const subpath of listPluginSdkExportedSubpaths()) { |
| const resolved = resolvePluginSdkAliasFile({ |
| srcFile: `${subpath}.ts`, |
| distFile: `${subpath}.js`, |
| }); |
| if (resolved) { |
| aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; |
| } |
| } |
| return aliasMap; |
| }; |
|
|
| export const __testing = { |
| listPluginSdkAliasCandidates, |
| listPluginSdkExportedSubpaths, |
| resolvePluginSdkAliasCandidateOrder, |
| resolvePluginSdkAliasFile, |
| maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, |
| }; |
|
|
| function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined { |
| const cached = registryCache.get(cacheKey); |
| if (!cached) { |
| return undefined; |
| } |
| |
| registryCache.delete(cacheKey); |
| registryCache.set(cacheKey, cached); |
| return cached; |
| } |
|
|
| function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void { |
| if (registryCache.has(cacheKey)) { |
| registryCache.delete(cacheKey); |
| } |
| registryCache.set(cacheKey, registry); |
| while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) { |
| const oldestKey = registryCache.keys().next().value; |
| if (!oldestKey) { |
| break; |
| } |
| registryCache.delete(oldestKey); |
| } |
| } |
|
|
| function buildCacheKey(params: { |
| workspaceDir?: string; |
| plugins: NormalizedPluginsConfig; |
| installs?: Record<string, PluginInstallRecord>; |
| env: NodeJS.ProcessEnv; |
| }): string { |
| const { roots, loadPaths } = resolvePluginCacheInputs({ |
| workspaceDir: params.workspaceDir, |
| loadPaths: params.plugins.loadPaths, |
| env: params.env, |
| }); |
| const installs = Object.fromEntries( |
| Object.entries(params.installs ?? {}).map(([pluginId, install]) => [ |
| pluginId, |
| { |
| ...install, |
| installPath: |
| typeof install.installPath === "string" |
| ? resolveUserPath(install.installPath, params.env) |
| : install.installPath, |
| sourcePath: |
| typeof install.sourcePath === "string" |
| ? resolveUserPath(install.sourcePath, params.env) |
| : install.sourcePath, |
| }, |
| ]), |
| ); |
| return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ |
| ...params.plugins, |
| installs, |
| loadPaths, |
| })}`; |
| } |
|
|
| export function buildPluginRegistryCacheKey(params: { |
| config?: OpenClawConfig; |
| workspaceDir?: string; |
| env?: NodeJS.ProcessEnv; |
| }): string { |
| const env = params.env ?? process.env; |
| const cfg = applyTestPluginDefaults(params.config ?? {}, env); |
| const normalized = normalizePluginsConfig(cfg.plugins); |
| return buildCacheKey({ |
| workspaceDir: params.workspaceDir, |
| plugins: normalized, |
| installs: cfg.plugins?.installs, |
| env, |
| }); |
| } |
|
|
| function validatePluginConfig(params: { |
| schema?: Record<string, unknown>; |
| cacheKey?: string; |
| value?: unknown; |
| }): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } { |
| const schema = params.schema; |
| if (!schema) { |
| return { ok: true, value: params.value as Record<string, unknown> | undefined }; |
| } |
| const cacheKey = params.cacheKey ?? JSON.stringify(schema); |
| const result = validateJsonSchemaValue({ |
| schema, |
| cacheKey, |
| value: params.value ?? {}, |
| }); |
| if (result.ok) { |
| return { ok: true, value: params.value as Record<string, unknown> | undefined }; |
| } |
| return { ok: false, errors: result.errors.map((error) => error.text) }; |
| } |
|
|
| function resolvePluginModuleExport(moduleExport: unknown): { |
| definition?: OpenClawPluginDefinition; |
| register?: OpenClawPluginDefinition["register"]; |
| } { |
| const resolved = |
| moduleExport && |
| typeof moduleExport === "object" && |
| "default" in (moduleExport as Record<string, unknown>) |
| ? (moduleExport as { default: unknown }).default |
| : moduleExport; |
| if (typeof resolved === "function") { |
| return { |
| register: resolved as OpenClawPluginDefinition["register"], |
| }; |
| } |
| if (resolved && typeof resolved === "object") { |
| const def = resolved as OpenClawPluginDefinition; |
| const register = def.register ?? def.activate; |
| return { definition: def, register }; |
| } |
| return {}; |
| } |
|
|
| function createPluginRecord(params: { |
| id: string; |
| name?: string; |
| description?: string; |
| version?: string; |
| source: string; |
| origin: PluginRecord["origin"]; |
| workspaceDir?: string; |
| enabled: boolean; |
| configSchema: boolean; |
| }): PluginRecord { |
| return { |
| id: params.id, |
| name: params.name ?? params.id, |
| description: params.description, |
| version: params.version, |
| source: params.source, |
| origin: params.origin, |
| workspaceDir: params.workspaceDir, |
| enabled: params.enabled, |
| status: params.enabled ? "loaded" : "disabled", |
| toolNames: [], |
| hookNames: [], |
| channelIds: [], |
| providerIds: [], |
| gatewayMethods: [], |
| cliCommands: [], |
| services: [], |
| commands: [], |
| httpRoutes: 0, |
| hookCount: 0, |
| configSchema: params.configSchema, |
| configUiHints: undefined, |
| configJsonSchema: undefined, |
| }; |
| } |
|
|
| function recordPluginError(params: { |
| logger: PluginLogger; |
| registry: PluginRegistry; |
| record: PluginRecord; |
| seenIds: Map<string, PluginRecord["origin"]>; |
| pluginId: string; |
| origin: PluginRecord["origin"]; |
| error: unknown; |
| logPrefix: string; |
| diagnosticMessagePrefix: string; |
| }) { |
| const errorText = String(params.error); |
| const deprecatedApiHint = |
| errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function") |
| ? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes" |
| : null; |
| const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText; |
| params.logger.error(`${params.logPrefix}${displayError}`); |
| params.record.status = "error"; |
| params.record.error = displayError; |
| params.registry.plugins.push(params.record); |
| params.seenIds.set(params.pluginId, params.origin); |
| params.registry.diagnostics.push({ |
| level: "error", |
| pluginId: params.record.id, |
| source: params.record.source, |
| message: `${params.diagnosticMessagePrefix}${displayError}`, |
| }); |
| } |
|
|
| function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) { |
| diagnostics.push(...append); |
| } |
|
|
| type PathMatcher = { |
| exact: Set<string>; |
| dirs: string[]; |
| }; |
|
|
| type InstallTrackingRule = { |
| trackedWithoutPaths: boolean; |
| matcher: PathMatcher; |
| }; |
|
|
| type PluginProvenanceIndex = { |
| loadPathMatcher: PathMatcher; |
| installRules: Map<string, InstallTrackingRule>; |
| }; |
|
|
| function createPathMatcher(): PathMatcher { |
| return { exact: new Set<string>(), dirs: [] }; |
| } |
|
|
| function addPathToMatcher( |
| matcher: PathMatcher, |
| rawPath: string, |
| env: NodeJS.ProcessEnv = process.env, |
| ): void { |
| const trimmed = rawPath.trim(); |
| if (!trimmed) { |
| return; |
| } |
| const resolved = resolveUserPath(trimmed, env); |
| if (!resolved) { |
| return; |
| } |
| if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { |
| return; |
| } |
| const stat = safeStatSync(resolved); |
| if (stat?.isDirectory()) { |
| matcher.dirs.push(resolved); |
| return; |
| } |
| matcher.exact.add(resolved); |
| } |
|
|
| function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean { |
| if (matcher.exact.has(sourcePath)) { |
| return true; |
| } |
| return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); |
| } |
|
|
| function buildProvenanceIndex(params: { |
| config: OpenClawConfig; |
| normalizedLoadPaths: string[]; |
| env: NodeJS.ProcessEnv; |
| }): PluginProvenanceIndex { |
| const loadPathMatcher = createPathMatcher(); |
| for (const loadPath of params.normalizedLoadPaths) { |
| addPathToMatcher(loadPathMatcher, loadPath, params.env); |
| } |
|
|
| const installRules = new Map<string, InstallTrackingRule>(); |
| const installs = params.config.plugins?.installs ?? {}; |
| for (const [pluginId, install] of Object.entries(installs)) { |
| const rule: InstallTrackingRule = { |
| trackedWithoutPaths: false, |
| matcher: createPathMatcher(), |
| }; |
| const trackedPaths = [install.installPath, install.sourcePath] |
| .map((entry) => (typeof entry === "string" ? entry.trim() : "")) |
| .filter(Boolean); |
| if (trackedPaths.length === 0) { |
| rule.trackedWithoutPaths = true; |
| } else { |
| for (const trackedPath of trackedPaths) { |
| addPathToMatcher(rule.matcher, trackedPath, params.env); |
| } |
| } |
| installRules.set(pluginId, rule); |
| } |
|
|
| return { loadPathMatcher, installRules }; |
| } |
|
|
| function isTrackedByProvenance(params: { |
| pluginId: string; |
| source: string; |
| index: PluginProvenanceIndex; |
| env: NodeJS.ProcessEnv; |
| }): boolean { |
| const sourcePath = resolveUserPath(params.source, params.env); |
| const installRule = params.index.installRules.get(params.pluginId); |
| if (installRule) { |
| if (installRule.trackedWithoutPaths) { |
| return true; |
| } |
| if (matchesPathMatcher(installRule.matcher, sourcePath)) { |
| return true; |
| } |
| } |
| return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); |
| } |
|
|
| function warnWhenAllowlistIsOpen(params: { |
| logger: PluginLogger; |
| pluginsEnabled: boolean; |
| allow: string[]; |
| warningCacheKey: string; |
| discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>; |
| }) { |
| if (!params.pluginsEnabled) { |
| return; |
| } |
| if (params.allow.length > 0) { |
| return; |
| } |
| const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled"); |
| if (nonBundled.length === 0) { |
| return; |
| } |
| if (openAllowlistWarningCache.has(params.warningCacheKey)) { |
| return; |
| } |
| const preview = nonBundled |
| .slice(0, 6) |
| .map((entry) => `${entry.id} (${entry.source})`) |
| .join(", "); |
| const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : ""; |
| openAllowlistWarningCache.add(params.warningCacheKey); |
| params.logger.warn( |
| `[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`, |
| ); |
| } |
|
|
| function warnAboutUntrackedLoadedPlugins(params: { |
| registry: PluginRegistry; |
| provenance: PluginProvenanceIndex; |
| logger: PluginLogger; |
| env: NodeJS.ProcessEnv; |
| }) { |
| for (const plugin of params.registry.plugins) { |
| if (plugin.status !== "loaded" || plugin.origin === "bundled") { |
| continue; |
| } |
| if ( |
| isTrackedByProvenance({ |
| pluginId: plugin.id, |
| source: plugin.source, |
| index: params.provenance, |
| env: params.env, |
| }) |
| ) { |
| continue; |
| } |
| const message = |
| "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records"; |
| params.registry.diagnostics.push({ |
| level: "warn", |
| pluginId: plugin.id, |
| source: plugin.source, |
| message, |
| }); |
| params.logger.warn(`[plugins] ${plugin.id}: ${message} (${plugin.source})`); |
| } |
| } |
|
|
| function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): void { |
| setActivePluginRegistry(registry, cacheKey); |
| initializeGlobalHookRunner(registry); |
| } |
|
|
| export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { |
| const env = options.env ?? process.env; |
| |
| |
| const cfg = applyTestPluginDefaults(options.config ?? {}, env); |
| const logger = options.logger ?? defaultLogger(); |
| const validateOnly = options.mode === "validate"; |
| const normalized = normalizePluginsConfig(cfg.plugins); |
| const cacheKey = buildCacheKey({ |
| workspaceDir: options.workspaceDir, |
| plugins: normalized, |
| installs: cfg.plugins?.installs, |
| env, |
| }); |
| const cacheEnabled = options.cache !== false; |
| if (cacheEnabled) { |
| const cached = getCachedPluginRegistry(cacheKey); |
| if (cached) { |
| activatePluginRegistry(cached, cacheKey); |
| return cached; |
| } |
| } |
|
|
| |
| clearPluginCommands(); |
|
|
| |
| |
| let resolvedRuntime: PluginRuntime | null = null; |
| const resolveRuntime = (): PluginRuntime => { |
| resolvedRuntime ??= createPluginRuntime(options.runtimeOptions); |
| return resolvedRuntime; |
| }; |
| const runtime = new Proxy({} as PluginRuntime, { |
| get(_target, prop, receiver) { |
| return Reflect.get(resolveRuntime(), prop, receiver); |
| }, |
| set(_target, prop, value, receiver) { |
| return Reflect.set(resolveRuntime(), prop, value, receiver); |
| }, |
| has(_target, prop) { |
| return Reflect.has(resolveRuntime(), prop); |
| }, |
| ownKeys() { |
| return Reflect.ownKeys(resolveRuntime() as object); |
| }, |
| getOwnPropertyDescriptor(_target, prop) { |
| return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop); |
| }, |
| defineProperty(_target, prop, attributes) { |
| return Reflect.defineProperty(resolveRuntime() as object, prop, attributes); |
| }, |
| deleteProperty(_target, prop) { |
| return Reflect.deleteProperty(resolveRuntime() as object, prop); |
| }, |
| getPrototypeOf() { |
| return Reflect.getPrototypeOf(resolveRuntime() as object); |
| }, |
| }); |
| const { registry, createApi } = createPluginRegistry({ |
| logger, |
| runtime, |
| coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>, |
| }); |
|
|
| const discovery = discoverOpenClawPlugins({ |
| workspaceDir: options.workspaceDir, |
| extraPaths: normalized.loadPaths, |
| cache: options.cache, |
| env, |
| }); |
| const manifestRegistry = loadPluginManifestRegistry({ |
| config: cfg, |
| workspaceDir: options.workspaceDir, |
| cache: options.cache, |
| env, |
| candidates: discovery.candidates, |
| diagnostics: discovery.diagnostics, |
| }); |
| pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); |
| warnWhenAllowlistIsOpen({ |
| logger, |
| pluginsEnabled: normalized.enabled, |
| allow: normalized.allow, |
| warningCacheKey: cacheKey, |
| discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({ |
| id: plugin.id, |
| source: plugin.source, |
| origin: plugin.origin, |
| })), |
| }); |
| const provenance = buildProvenanceIndex({ |
| config: cfg, |
| normalizedLoadPaths: normalized.loadPaths, |
| env, |
| }); |
|
|
| |
| let jitiLoader: ReturnType<typeof createJiti> | null = null; |
| const getJiti = () => { |
| if (jitiLoader) { |
| return jitiLoader; |
| } |
| const pluginSdkAlias = resolvePluginSdkAlias(); |
| const aliasMap = { |
| ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), |
| ...resolvePluginSdkScopedAliasMap(), |
| }; |
| jitiLoader = createJiti(import.meta.url, { |
| interopDefault: true, |
| extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], |
| ...(Object.keys(aliasMap).length > 0 |
| ? { |
| alias: aliasMap, |
| } |
| : {}), |
| }); |
| return jitiLoader; |
| }; |
|
|
| const manifestByRoot = new Map( |
| manifestRegistry.plugins.map((record) => [record.rootDir, record]), |
| ); |
|
|
| const seenIds = new Map<string, PluginRecord["origin"]>(); |
| const memorySlot = normalized.slots.memory; |
| let selectedMemoryPluginId: string | null = null; |
| let memorySlotMatched = false; |
|
|
| for (const candidate of discovery.candidates) { |
| const manifestRecord = manifestByRoot.get(candidate.rootDir); |
| if (!manifestRecord) { |
| continue; |
| } |
| const pluginId = manifestRecord.id; |
| const existingOrigin = seenIds.get(pluginId); |
| if (existingOrigin) { |
| const record = createPluginRecord({ |
| id: pluginId, |
| name: manifestRecord.name ?? pluginId, |
| description: manifestRecord.description, |
| version: manifestRecord.version, |
| source: candidate.source, |
| origin: candidate.origin, |
| workspaceDir: candidate.workspaceDir, |
| enabled: false, |
| configSchema: Boolean(manifestRecord.configSchema), |
| }); |
| record.status = "disabled"; |
| record.error = `overridden by ${existingOrigin} plugin`; |
| registry.plugins.push(record); |
| continue; |
| } |
|
|
| const enableState = resolveEffectiveEnableState({ |
| id: pluginId, |
| origin: candidate.origin, |
| config: normalized, |
| rootConfig: cfg, |
| }); |
| const entry = normalized.entries[pluginId]; |
| const record = createPluginRecord({ |
| id: pluginId, |
| name: manifestRecord.name ?? pluginId, |
| description: manifestRecord.description, |
| version: manifestRecord.version, |
| source: candidate.source, |
| origin: candidate.origin, |
| workspaceDir: candidate.workspaceDir, |
| enabled: enableState.enabled, |
| configSchema: Boolean(manifestRecord.configSchema), |
| }); |
| record.kind = manifestRecord.kind; |
| record.configUiHints = manifestRecord.configUiHints; |
| record.configJsonSchema = manifestRecord.configSchema; |
| const pushPluginLoadError = (message: string) => { |
| record.status = "error"; |
| record.error = message; |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| registry.diagnostics.push({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: record.error, |
| }); |
| }; |
|
|
| if (!enableState.enabled) { |
| record.status = "disabled"; |
| record.error = enableState.reason; |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| continue; |
| } |
|
|
| |
| |
| if (candidate.origin === "bundled" && manifestRecord.kind === "memory") { |
| const earlyMemoryDecision = resolveMemorySlotDecision({ |
| id: record.id, |
| kind: "memory", |
| slot: memorySlot, |
| selectedId: selectedMemoryPluginId, |
| }); |
| if (!earlyMemoryDecision.enabled) { |
| record.enabled = false; |
| record.status = "disabled"; |
| record.error = earlyMemoryDecision.reason; |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| continue; |
| } |
| } |
|
|
| if (!manifestRecord.configSchema) { |
| pushPluginLoadError("missing config schema"); |
| continue; |
| } |
|
|
| const pluginRoot = safeRealpathOrResolve(candidate.rootDir); |
| const opened = openBoundaryFileSync({ |
| absolutePath: candidate.source, |
| rootPath: pluginRoot, |
| boundaryLabel: "plugin root", |
| rejectHardlinks: candidate.origin !== "bundled", |
| skipLexicalRootCheck: true, |
| }); |
| if (!opened.ok) { |
| pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks"); |
| continue; |
| } |
| const safeSource = opened.path; |
| fs.closeSync(opened.fd); |
|
|
| let mod: OpenClawPluginModule | null = null; |
| try { |
| mod = getJiti()(safeSource) as OpenClawPluginModule; |
| } catch (err) { |
| recordPluginError({ |
| logger, |
| registry, |
| record, |
| seenIds, |
| pluginId, |
| origin: candidate.origin, |
| error: err, |
| logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, |
| diagnosticMessagePrefix: "failed to load plugin: ", |
| }); |
| continue; |
| } |
|
|
| const resolved = resolvePluginModuleExport(mod); |
| const definition = resolved.definition; |
| const register = resolved.register; |
|
|
| if (definition?.id && definition.id !== record.id) { |
| registry.diagnostics.push({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`, |
| }); |
| } |
|
|
| record.name = definition?.name ?? record.name; |
| record.description = definition?.description ?? record.description; |
| record.version = definition?.version ?? record.version; |
| const manifestKind = record.kind as string | undefined; |
| const exportKind = definition?.kind as string | undefined; |
| if (manifestKind && exportKind && exportKind !== manifestKind) { |
| registry.diagnostics.push({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`, |
| }); |
| } |
| record.kind = definition?.kind ?? record.kind; |
|
|
| if (record.kind === "memory" && memorySlot === record.id) { |
| memorySlotMatched = true; |
| } |
|
|
| const memoryDecision = resolveMemorySlotDecision({ |
| id: record.id, |
| kind: record.kind, |
| slot: memorySlot, |
| selectedId: selectedMemoryPluginId, |
| }); |
|
|
| if (!memoryDecision.enabled) { |
| record.enabled = false; |
| record.status = "disabled"; |
| record.error = memoryDecision.reason; |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| continue; |
| } |
|
|
| if (memoryDecision.selected && record.kind === "memory") { |
| selectedMemoryPluginId = record.id; |
| } |
|
|
| const validatedConfig = validatePluginConfig({ |
| schema: manifestRecord.configSchema, |
| cacheKey: manifestRecord.schemaCacheKey, |
| value: entry?.config, |
| }); |
|
|
| if (!validatedConfig.ok) { |
| logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`); |
| pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`); |
| continue; |
| } |
|
|
| if (validateOnly) { |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| continue; |
| } |
|
|
| if (typeof register !== "function") { |
| logger.error(`[plugins] ${record.id} missing register/activate export`); |
| pushPluginLoadError("plugin export missing register/activate"); |
| continue; |
| } |
|
|
| const api = createApi(record, { |
| config: cfg, |
| pluginConfig: validatedConfig.value, |
| hookPolicy: entry?.hooks, |
| }); |
|
|
| try { |
| const result = register(api); |
| if (result && typeof result.then === "function") { |
| registry.diagnostics.push({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: "plugin register returned a promise; async registration is ignored", |
| }); |
| } |
| registry.plugins.push(record); |
| seenIds.set(pluginId, candidate.origin); |
| } catch (err) { |
| recordPluginError({ |
| logger, |
| registry, |
| record, |
| seenIds, |
| pluginId, |
| origin: candidate.origin, |
| error: err, |
| logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, |
| diagnosticMessagePrefix: "plugin failed during register: ", |
| }); |
| } |
| } |
|
|
| if (typeof memorySlot === "string" && !memorySlotMatched) { |
| registry.diagnostics.push({ |
| level: "warn", |
| message: `memory slot plugin not found or not marked as memory: ${memorySlot}`, |
| }); |
| } |
|
|
| warnAboutUntrackedLoadedPlugins({ |
| registry, |
| provenance, |
| logger, |
| env, |
| }); |
|
|
| if (cacheEnabled) { |
| setCachedPluginRegistry(cacheKey, registry); |
| } |
| activatePluginRegistry(registry, cacheKey); |
| return registry; |
| } |
|
|
| function safeRealpathOrResolve(value: string): string { |
| try { |
| return fs.realpathSync(value); |
| } catch { |
| return path.resolve(value); |
| } |
| } |
|
|