import { createJiti } from "jiti"; import type { ClawdbotConfig } from "../config/config.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { discoverClawdbotPlugins } from "./discovery.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry, } from "./registry.js"; import type { ClawdbotPluginConfigSchema, ClawdbotPluginDefinition, ClawdbotPluginModule, PluginConfigUiHint, PluginDiagnostic, PluginLogger, } from "./types.js"; export type PluginLoadResult = PluginRegistry; export type PluginLoadOptions = { config?: ClawdbotConfig; workspaceDir?: string; logger?: PluginLogger; coreGatewayHandlers?: Record; cache?: boolean; }; type NormalizedPluginsConfig = { enabled: boolean; allow: string[]; deny: string[]; loadPaths: string[]; entries: Record< string, { enabled?: boolean; config?: Record } >; }; const registryCache = new Map(); const defaultLogger = () => createSubsystemLogger("plugins"); const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) return []; return value .map((entry) => (typeof entry === "string" ? entry.trim() : "")) .filter(Boolean); }; const normalizePluginEntries = ( entries: unknown, ): NormalizedPluginsConfig["entries"] => { if (!entries || typeof entries !== "object" || Array.isArray(entries)) { return {}; } const normalized: NormalizedPluginsConfig["entries"] = {}; for (const [key, value] of Object.entries(entries)) { if (!key.trim()) continue; if (!value || typeof value !== "object" || Array.isArray(value)) { normalized[key] = {}; continue; } const entry = value as Record; normalized[key] = { enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined, config: entry.config && typeof entry.config === "object" && !Array.isArray(entry.config) ? (entry.config as Record) : undefined, }; } return normalized; }; const normalizePluginsConfig = ( config?: ClawdbotConfig["plugins"], ): NormalizedPluginsConfig => { return { enabled: config?.enabled !== false, allow: normalizeList(config?.allow), deny: normalizeList(config?.deny), loadPaths: normalizeList(config?.load?.paths), entries: normalizePluginEntries(config?.entries), }; }; function buildCacheKey(params: { workspaceDir?: string; plugins: NormalizedPluginsConfig; }): string { const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : ""; return `${workspaceKey}::${JSON.stringify(params.plugins)}`; } function resolveEnableState( id: string, config: NormalizedPluginsConfig, ): { enabled: boolean; reason?: string } { if (!config.enabled) { return { enabled: false, reason: "plugins disabled" }; } if (config.deny.includes(id)) { return { enabled: false, reason: "blocked by denylist" }; } if (config.allow.length > 0 && !config.allow.includes(id)) { return { enabled: false, reason: "not in allowlist" }; } const entry = config.entries[id]; if (entry?.enabled === false) { return { enabled: false, reason: "disabled in config" }; } return { enabled: true }; } function validatePluginConfig(params: { schema?: ClawdbotPluginConfigSchema; value?: Record; }): { ok: boolean; value?: Record; errors?: string[] } { const schema = params.schema; if (!schema) return { ok: true, value: params.value }; if (typeof schema.validate === "function") { const result = schema.validate(params.value); if (result.ok) { return { ok: true, value: result.value as Record }; } return { ok: false, errors: result.errors }; } if (typeof schema.safeParse === "function") { const result = schema.safeParse(params.value); if (result.success) { return { ok: true, value: result.data as Record }; } const issues = result.error?.issues ?? []; const errors = issues.map((issue) => { const path = issue.path.length > 0 ? issue.path.join(".") : ""; return `${path}: ${issue.message}`; }); return { ok: false, errors }; } if (typeof schema.parse === "function") { try { const parsed = schema.parse(params.value); return { ok: true, value: parsed as Record }; } catch (err) { return { ok: false, errors: [String(err)] }; } } return { ok: true, value: params.value }; } function resolvePluginModuleExport(moduleExport: unknown): { definition?: ClawdbotPluginDefinition; register?: ClawdbotPluginDefinition["register"]; } { const resolved = moduleExport && typeof moduleExport === "object" && "default" in (moduleExport as Record) ? (moduleExport as { default: unknown }).default : moduleExport; if (typeof resolved === "function") { return { register: resolved as ClawdbotPluginDefinition["register"], }; } if (resolved && typeof resolved === "object") { const def = resolved as ClawdbotPluginDefinition; 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: [], gatewayMethods: [], cliCommands: [], services: [], configSchema: params.configSchema, configUiHints: undefined, }; } function pushDiagnostics( diagnostics: PluginDiagnostic[], append: PluginDiagnostic[], ) { diagnostics.push(...append); } export function loadClawdbotPlugins( options: PluginLoadOptions = {}, ): PluginRegistry { const cfg = options.config ?? {}; const logger = options.logger ?? defaultLogger(); const normalized = normalizePluginsConfig(cfg.plugins); const cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, plugins: normalized, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { const cached = registryCache.get(cacheKey); if (cached) return cached; } const { registry, createApi } = createPluginRegistry({ logger, coreGatewayHandlers: options.coreGatewayHandlers as Record< string, GatewayRequestHandler >, }); const discovery = discoverClawdbotPlugins({ workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, }); pushDiagnostics(registry.diagnostics, discovery.diagnostics); const jiti = createJiti(import.meta.url, { interopDefault: true, }); for (const candidate of discovery.candidates) { const enableState = resolveEnableState(candidate.idHint, normalized); const entry = normalized.entries[candidate.idHint]; const record = createPluginRecord({ id: candidate.idHint, name: candidate.packageName ?? candidate.idHint, description: candidate.packageDescription, version: candidate.packageVersion, source: candidate.source, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, configSchema: false, }); if (!enableState.enabled) { record.status = "disabled"; record.error = enableState.reason; registry.plugins.push(record); continue; } let mod: ClawdbotPluginModule | null = null; try { mod = jiti(candidate.source) as ClawdbotPluginModule; } catch (err) { record.status = "error"; record.error = String(err); registry.plugins.push(record); registry.diagnostics.push({ level: "error", pluginId: record.id, source: record.source, message: `failed to load plugin: ${String(err)}`, }); 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; record.configSchema = Boolean(definition?.configSchema); record.configUiHints = definition?.configSchema && typeof definition.configSchema === "object" && (definition.configSchema as { uiHints?: unknown }).uiHints && typeof (definition.configSchema as { uiHints?: unknown }).uiHints === "object" && !Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints) ? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record< string, PluginConfigUiHint >) : undefined; const validatedConfig = validatePluginConfig({ schema: definition?.configSchema, value: entry?.config, }); if (!validatedConfig.ok) { record.status = "error"; record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`; registry.plugins.push(record); registry.diagnostics.push({ level: "error", pluginId: record.id, source: record.source, message: record.error, }); continue; } if (typeof register !== "function") { record.status = "error"; record.error = "plugin export missing register/activate"; registry.plugins.push(record); registry.diagnostics.push({ level: "error", pluginId: record.id, source: record.source, message: record.error, }); continue; } const api = createApi(record, { config: cfg, pluginConfig: validatedConfig.value, }); try { const result = register(api); if (result && typeof (result as Promise).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); } catch (err) { record.status = "error"; record.error = String(err); registry.plugins.push(record); registry.diagnostics.push({ level: "error", pluginId: record.id, source: record.source, message: `plugin failed during register: ${String(err)}`, }); } } if (cacheEnabled) { registryCache.set(cacheKey, registry); } return registry; }