/** * bootstrap.js — 1:1 复制自 openclaw/src/plugins/loader.ts * * 唯一区别: * - 去掉 TypeScript 类型标注 * - static import 改为 await import()(CJS→ESM 桥接) * - import.meta.url 替换为 __filename(CJS 环境) * * 如果 OpenClaw 上游 loader.ts 更新,直接对比 diff 同步即可。 */ const fs = require('fs'); const path = require('path'); const Module = require('module'); // ── 所有 OpenClaw 子模块,通过 core-bundle.mjs 一次性加载 ── let _OC = null; async function loadOpenClawModules(openclawRoot) { if (_OC) return _OC; const t0 = Date.now(); // 优先从 core-bundle.mjs 加载(自包含 bundle,无外部依赖) const bundlePath = path.join(openclawRoot, 'core-bundle.mjs'); if (fs.existsSync(bundlePath)) { console.log('[plugins] 加载 OpenClaw 核心模块 (core-bundle.mjs)...'); const bundle = await import(bundlePath); _OC = { normalizePluginsConfig: bundle.normalizePluginsConfig, applyTestPluginDefaults: bundle.applyTestPluginDefaults, resolveEnableState: bundle.resolveEnableState, resolveMemorySlotDecision: bundle.resolveMemorySlotDecision, discoverOpenClawPlugins: bundle.discoverOpenClawPlugins, initializeGlobalHookRunner: bundle.initializeGlobalHookRunner, loadPluginManifestRegistry: bundle.loadPluginManifestRegistry, createPluginRegistry: bundle.createPluginRegistry, setActivePluginRegistry: bundle.setActivePluginRegistry, createPluginRuntime: bundle.createPluginRuntime, validateJsonSchemaValue: bundle.validateJsonSchemaValue, clearPluginCommands: bundle.clearPluginCommands, resolveUserPath: bundle.resolveUserPath, }; console.log(`[plugins] 核心模块加载完成 (${((Date.now() - t0) / 1000).toFixed(1)}s)`); return _OC; } // Fallback: 逐模块从 dist/ 或 src/ 加载(需要完整 openclaw 目录 + node_modules) console.log('[plugins] 加载 OpenClaw 核心模块 (dist/ fallback)...'); const dist = path.join(openclawRoot, 'dist', 'plugins'); const src = path.join(openclawRoot, 'src', 'plugins'); const resolve = (name) => { const d = path.join(dist, name); if (fs.existsSync(d)) return d; return path.join(src, name); }; const [ configState, discovery, hookRunnerGlobal, manifestRegistry, registry, runtime, runtimeIndex, schemaValidator, commands, utils, ] = await Promise.all([ import(resolve('config-state.js')), import(resolve('discovery.js')), import(resolve('hook-runner-global.js')), import(resolve('manifest-registry.js')), import(resolve('registry.js')), import(resolve('runtime.js')), import(resolve('runtime/index.js')), import(resolve('schema-validator.js')), import(resolve('commands.js')), import(path.join(openclawRoot, fs.existsSync(path.join(openclawRoot, 'dist/utils.js')) ? 'dist/utils.js' : 'src/utils.js')), ]); _OC = { normalizePluginsConfig: configState.normalizePluginsConfig, applyTestPluginDefaults: configState.applyTestPluginDefaults, resolveEnableState: configState.resolveEnableState, resolveMemorySlotDecision: configState.resolveMemorySlotDecision, discoverOpenClawPlugins: discovery.discoverOpenClawPlugins, initializeGlobalHookRunner: hookRunnerGlobal.initializeGlobalHookRunner, loadPluginManifestRegistry: manifestRegistry.loadPluginManifestRegistry, createPluginRegistry: registry.createPluginRegistry, setActivePluginRegistry: runtime.setActivePluginRegistry, createPluginRuntime: runtimeIndex.createPluginRuntime, validateJsonSchemaValue: schemaValidator.validateJsonSchemaValue, clearPluginCommands: commands.clearPluginCommands, resolveUserPath: utils.resolveUserPath, }; console.log(`[plugins] 核心模块加载完成 (${((Date.now() - t0) / 1000).toFixed(1)}s)`); return _OC; } // ── 以下 1:1 复制自 loader.ts,仅去掉类型标注 ── const registryCache = new Map(); // loader.ts L46-85: resolvePluginSdkAliasFile / resolvePluginSdkAlias / resolvePluginSdkAccountIdAlias function resolvePluginSdkAliasFile(openclawRoot, params) { try { // 优先查找 bundle 版本(自包含,无散装依赖问题) const bundleCandidate = path.join(openclawRoot, 'plugin-sdk', params.bundleFile || params.distFile.replace('.js', '.mjs')); if (fs.existsSync(bundleCandidate)) { return bundleCandidate; } const srcCandidate = path.join(openclawRoot, 'src', 'plugin-sdk', params.srcFile); const distCandidate = path.join(openclawRoot, 'dist', 'plugin-sdk', params.distFile); const isProduction = process.env.NODE_ENV === 'production'; const isTest = process.env.VITEST || process.env.NODE_ENV === 'test'; const orderedCandidates = isProduction ? (isTest ? [distCandidate, srcCandidate] : [distCandidate]) : [srcCandidate, distCandidate]; for (const candidate of orderedCandidates) { if (fs.existsSync(candidate)) { return candidate; } } } catch { // ignore } return null; } function resolvePluginSdkAlias(openclawRoot) { return resolvePluginSdkAliasFile(openclawRoot, { srcFile: 'index.ts', distFile: 'index.js' }); } function resolvePluginSdkAccountIdAlias(openclawRoot) { return resolvePluginSdkAliasFile(openclawRoot, { srcFile: 'account-id.ts', distFile: 'account-id.js' }); } // loader.ts L87-93 function buildCacheKey(params) { const workspaceKey = params.workspaceDir ? _OC.resolveUserPath(params.workspaceDir) : ''; return `${workspaceKey}::${JSON.stringify(params.plugins)}`; } // loader.ts L95-114 function validatePluginConfig(params) { const schema = params.schema; if (!schema) { return { ok: true, value: params.value }; } const cacheKey = params.cacheKey ?? JSON.stringify(schema); const result = _OC.validateJsonSchemaValue({ schema, cacheKey, value: params.value ?? {}, }); if (result.ok) { return { ok: true, value: params.value }; } return { ok: false, errors: result.errors }; } // loader.ts L116-137 function resolvePluginModuleExport(moduleExport) { const resolved = moduleExport && typeof moduleExport === 'object' && 'default' in moduleExport ? moduleExport.default : moduleExport; if (typeof resolved === 'function') { return { register: resolved }; } if (resolved && typeof resolved === 'object') { const def = resolved; const register = def.register ?? def.activate; return { definition: def, register }; } return {}; } // loader.ts L139-174 function createPluginRecord(params) { 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: [], httpHandlers: 0, hookCount: 0, configSchema: params.configSchema, configUiHints: undefined, configJsonSchema: undefined, }; } // loader.ts L176-178 function pushDiagnostics(diagnostics, append) { diagnostics.push(...append); } // ── loader.ts L180-480: loadOpenClawPlugins ── // 唯一修改:改为 async(因为 await import),其余 1:1 复制 async function loadOpenClawPlugins(openclawRoot, options = {}) { const OC = await loadOpenClawModules(openclawRoot); const cfg = OC.applyTestPluginDefaults(options.config ?? {}, process.env); const logger = options.logger || { info: console.log, warn: console.warn, error: console.error, debug: () => { }, }; const validateOnly = options.mode === 'validate'; const normalized = OC.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) { OC.setActivePluginRegistry(cached, cacheKey); return cached; } } // Clear previously registered plugin commands before reloading OC.clearPluginCommands(); const runtime = OC.createPluginRuntime(); const { registry, createApi } = OC.createPluginRegistry({ logger, runtime, coreGatewayHandlers: options.coreGatewayHandlers, }); const discovery = OC.discoverOpenClawPlugins({ workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, }); const manifestRegistry = OC.loadPluginManifestRegistry({ config: cfg, workspaceDir: options.workspaceDir, cache: options.cache, candidates: discovery.candidates, diagnostics: discovery.diagnostics, }); pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); // Plugin loader: 优先加载 .esbuild-cache 中的 pre-built bundle, // 无需 esbuild 运行时依赖。仅在 cache 不存在时 fallback 到 esbuild 编译。 const getPluginLoader = () => { return async (filename) => { // 1. 优先从 .esbuild-cache 加载 pre-built bundle const cacheDir = path.join(path.dirname(filename), '.esbuild-cache'); if (fs.existsSync(cacheDir)) { const bundles = fs.readdirSync(cacheDir).filter(f => f.endsWith('.mjs')); if (bundles.length > 0) { // 取最新的 bundle(按文件修改时间) const latest = bundles .map(f => ({ name: f, mtime: fs.statSync(path.join(cacheDir, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime)[0]; const bundlePath = path.join(cacheDir, latest.name); const mod = await import(bundlePath); return mod.default || mod; } } // 2. Fallback: esbuild 编译(开发环境) let esbuild; try { esbuild = require('esbuild'); } catch (e) { try { esbuild = require(path.join(process.cwd(), 'node_modules', 'esbuild')); } catch (e2) { throw new Error( `No pre-built bundle in ${cacheDir} and esbuild not available. ` + `Run the vendor script to generate pre-built bundles.` ); } } const pluginSdkAlias = resolvePluginSdkAlias(openclawRoot); const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias(openclawRoot); const aliasConfig = {}; if (pluginSdkAlias) aliasConfig['openclaw/plugin-sdk'] = pluginSdkAlias; if (pluginSdkAccountIdAlias) aliasConfig['openclaw/plugin-sdk/account-id'] = pluginSdkAccountIdAlias; const crypto = require('crypto'); const result = await esbuild.build({ entryPoints: [filename], bundle: true, platform: 'node', format: 'esm', packages: 'external', alias: aliasConfig, write: false, target: 'node18', sourcemap: 'inline', }); const code = result.outputFiles[0].text; const hash = crypto.createHash('md5').update(code).digest('hex').slice(0, 8); fs.mkdirSync(cacheDir, { recursive: true }); const tmpFile = path.join(cacheDir, `bundle-${hash}.mjs`); if (!fs.existsSync(tmpFile)) { fs.writeFileSync(tmpFile, code); } const mod = await import(tmpFile); return mod.default || mod; }; }; const loadPlugin = getPluginLoader(); const manifestByRoot = new Map( manifestRegistry.plugins.map((record) => [record.rootDir, record]) ); const seenIds = new Map(); const memorySlot = normalized.slots.memory; let selectedMemoryPluginId = 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 = OC.resolveEnableState(pluginId, candidate.origin, normalized); 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; if (!enableState.enabled) { record.status = 'disabled'; record.error = enableState.reason; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); continue; } if (!manifestRecord.configSchema) { record.status = 'error'; record.error = 'missing config schema'; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: 'error', pluginId: record.id, source: record.source, message: record.error, }); continue; } let mod = null; try { logger.info(`[plugins] 加载 ${record.id} (esbuild)...`); const t = Date.now(); // 使用 esbuild loader 替代 jiti mod = await loadPlugin(candidate.source); logger.info(`[plugins] ${record.id} 加载完成 (${((Date.now() - t) / 1000).toFixed(3)}s)`); } catch (err) { logger.error(`[plugins] ${record.id} failed to load from ${record.source}: ${String(err)}`); record.status = 'error'; record.error = String(err); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); 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; const manifestKind = record.kind; const exportKind = definition?.kind; 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 = OC.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(', ')}`); record.status = 'error'; record.error = `invalid config: ${validatedConfig.errors?.join(', ')}`; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: 'error', pluginId: record.id, source: record.source, message: record.error, }); 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`); record.status = 'error'; record.error = 'plugin export missing register/activate'; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); 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.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) { logger.error( `[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}` ); record.status = 'error'; record.error = String(err); registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: 'error', pluginId: record.id, source: record.source, message: `plugin failed during register: ${String(err)}`, }); } } if (typeof memorySlot === 'string' && !memorySlotMatched) { registry.diagnostics.push({ level: 'warn', message: `memory slot plugin not found or not marked as memory: ${memorySlot}`, }); } if (cacheEnabled) { registryCache.set(cacheKey, registry); } OC.setActivePluginRegistry(registry, cacheKey); OC.initializeGlobalHookRunner(registry); // 原版只返回 registry,但 runtime 是我们外部需要的,一并返回 return { registry, runtime }; } // ── 外部 API ── /** * @param {Object} config * @param {string} config.openclawRoot * @param {Object} [config.clawdbotConfig] - 完整配置(含 channels, agents) * @param {Object} [config.pluginsConfig] - plugins 子配置 * @param {string} [config.workspaceDir] * @param {string} [config.mode] * @param {Object} [config.coreGatewayHandlers] * @param {Object} [config.logger] */ async function bootstrapPlugins(config) { if (!config.openclawRoot) { throw new Error('bootstrapPlugins: config.openclawRoot is required'); } const openclawRoot = path.resolve(config.openclawRoot); const cfg = { ...(config.clawdbotConfig || {}), plugins: config.pluginsConfig || config.clawdbotConfig?.plugins || {}, }; const result = await loadOpenClawPlugins(openclawRoot, { config: cfg, workspaceDir: config.workspaceDir, mode: config.mode, coreGatewayHandlers: config.coreGatewayHandlers, logger: config.logger, }); const { registry, runtime } = result; // 便利方法 return { registry, runtime, getToolNames() { return (registry.tools || []).map(t => t.name || t.id); }, getChannels() { return registry.channels || []; }, getChannel(id) { // OpenClaw 的 PluginChannelRegistration 结构:{ pluginId, plugin, dock, source } // channel id 在 plugin.id 里 return (registry.channels || []).find(c => c.plugin?.id === id || c.pluginId === id) || null; }, getHooks() { return registry.hooks || []; }, getProviders() { return registry.providers || []; }, getPlugins() { return registry.plugins || []; }, getDiagnostics() { return registry.diagnostics || []; }, }; } /** * 定位 @mas/openclaw-dist 包目录 * * require.resolve() 对纯 ESM 包不可用("type": "module"), * 改用 node_modules 向上遍历。 */ function resolveOpenclawDist(startDir) { let dir = startDir || __dirname; while (dir !== path.dirname(dir)) { const candidate = path.join(dir, 'node_modules', '@mas', 'openclaw-dist'); if (fs.existsSync(candidate)) { return fs.realpathSync(candidate); } dir = path.dirname(dir); } throw new Error('Cannot locate @mas/openclaw-dist package'); } module.exports = { bootstrapPlugins, resolveOpenclawDist };