diff --git a/openclaw-extensions/mcp-bridge/index.ts b/openclaw-extensions/mcp-bridge/index.ts new file mode 100644 index 0000000..d5d55ab --- /dev/null +++ b/openclaw-extensions/mcp-bridge/index.ts @@ -0,0 +1,257 @@ +import { Type } from '@sinclair/typebox'; +import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'; + +type McpBridgeToolConfig = { + server: string; + name: string; + description?: string; + inputSchema: Record; +}; + +type McpBridgePluginConfig = { + callbackUrl: string; + secret: string; + requestTimeoutMs: number; + tools: McpBridgeToolConfig[]; +}; + +type ToolResultPayload = { + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + isError?: boolean; + details?: unknown; +}; + +const DEFAULT_TIMEOUT_MS = 120_000; +const FALLBACK_INPUT_SCHEMA = Type.Object({}, { additionalProperties: true }); + +const isRecord = (value: unknown): value is Record => { + return !!value && typeof value === 'object' && !Array.isArray(value); +}; + +const sanitizeToolSegment = (value: string): string => { + const sanitized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return sanitized || 'tool'; +}; + +const buildRegisteredToolName = ( + server: string, + tool: string, + usedNames: Set, +): string => { + const base = `mcp_${sanitizeToolSegment(server)}_${sanitizeToolSegment(tool)}`; + let next = base; + let index = 2; + while (usedNames.has(next)) { + next = `${base}_${index}`; + index += 1; + } + usedNames.add(next); + return next; +}; + +const normalizeInputSchema = (value: unknown): Record => { + return isRecord(value) ? value : FALLBACK_INPUT_SCHEMA; +}; + +const parseToolConfig = (value: unknown): McpBridgeToolConfig | null => { + if (!isRecord(value)) { + return null; + } + + const server = typeof value.server === 'string' ? value.server.trim() : ''; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!server || !name) { + return null; + } + + return { + server, + name, + description: typeof value.description === 'string' ? value.description.trim() : undefined, + inputSchema: normalizeInputSchema(value.inputSchema), + }; +}; + +const parsePluginConfig = (value: unknown): McpBridgePluginConfig => { + const raw = isRecord(value) ? value : {}; + const tools = Array.isArray(raw.tools) + ? raw.tools.map(parseToolConfig).filter((tool): tool is McpBridgeToolConfig => !!tool) + : []; + + return { + callbackUrl: typeof raw.callbackUrl === 'string' ? raw.callbackUrl.trim() : '', + secret: typeof raw.secret === 'string' ? raw.secret.trim() : '', + requestTimeoutMs: + typeof raw.requestTimeoutMs === 'number' && Number.isFinite(raw.requestTimeoutMs) && raw.requestTimeoutMs > 0 + ? Math.max(1_000, Math.floor(raw.requestTimeoutMs)) + : DEFAULT_TIMEOUT_MS, + tools, + }; +}; + +const extractErrorMessage = (payload: unknown): string | null => { + if (!payload) { + return null; + } + if (typeof payload === 'string') { + return payload.trim() || null; + } + if (!isRecord(payload)) { + return null; + } + if (typeof payload.error === 'string' && payload.error.trim()) { + return payload.error.trim(); + } + const content = Array.isArray(payload.content) ? payload.content : []; + for (const block of content) { + if (!isRecord(block)) { + continue; + } + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + return block.text.trim(); + } + } + return null; +}; + +const ensureToolResultPayload = ( + payload: unknown, + details: Record, +): ToolResultPayload => { + if (isRecord(payload) && Array.isArray(payload.content)) { + return { + ...payload, + details: payload.details ?? details, + } as ToolResultPayload; + } + + const text = + typeof payload === 'string' + ? payload + : JSON.stringify(payload ?? { ok: true }, null, 2); + + return { + content: [{ type: 'text', text }], + details: payload ?? details, + }; +}; + +const buildToolDescription = (tool: McpBridgeToolConfig): string => { + const parts = [ + `Proxy to MCP tool "${tool.name}" on server "${tool.server}".`, + ]; + if (tool.description) { + parts.push(tool.description); + } + return parts.join(' '); +}; + +const invokeBridge = async ( + config: McpBridgePluginConfig, + tool: McpBridgeToolConfig, + args: Record, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + try { + const response = await fetch(config.callbackUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-mcp-bridge-secret': config.secret, + }, + body: JSON.stringify({ + server: tool.server, + tool: tool.name, + args, + }), + signal: controller.signal, + }); + + const responseText = await response.text(); + let payload: unknown = null; + + if (responseText.trim()) { + try { + payload = JSON.parse(responseText); + } catch { + if (!response.ok) { + throw new Error(`MCP bridge HTTP ${response.status}: ${responseText.trim()}`); + } + payload = responseText; + } + } + + if (!response.ok) { + const message = extractErrorMessage(payload) || response.statusText || 'Unknown MCP bridge error'; + throw new Error(`MCP bridge HTTP ${response.status}: ${message}`); + } + + return payload; + } finally { + clearTimeout(timer); + } +}; + +const plugin = { + id: 'mcp-bridge', + name: 'MCP Bridge', + description: 'Expose LobsterAI-managed MCP servers as native OpenClaw tools.', + configSchema: { + parse(value: unknown): McpBridgePluginConfig { + return parsePluginConfig(value); + }, + uiHints: { + callbackUrl: { label: 'Callback URL', advanced: true }, + secret: { label: 'Secret', sensitive: true, advanced: true }, + requestTimeoutMs: { label: 'Request Timeout (ms)', advanced: true }, + }, + }, + register(api: OpenClawPluginApi) { + const config = parsePluginConfig(api.pluginConfig); + if (!config.callbackUrl || !config.secret || config.tools.length === 0) { + api.logger.info('[mcp-bridge] skipped registration because callbackUrl/secret/tools are incomplete.'); + return; + } + + const usedToolNames = new Set(); + + for (const tool of config.tools) { + const registeredName = buildRegisteredToolName(tool.server, tool.name, usedToolNames); + const details = { + alias: registeredName, + server: tool.server, + tool: tool.name, + }; + + api.registerTool({ + name: registeredName, + label: `MCP ${tool.server}/${tool.name}`, + description: buildToolDescription(tool), + parameters: tool.inputSchema, + async execute(_id: string, params: Record) { + try { + const payload = await invokeBridge(config, tool, params); + return ensureToolResultPayload(payload, details); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: message }], + isError: true, + details, + }; + } + }, + }); + } + + api.logger.info(`[mcp-bridge] registered ${config.tools.length} tool(s).`); + }, +}; + +export default plugin; diff --git a/openclaw-extensions/mcp-bridge/openclaw.plugin.json b/openclaw-extensions/mcp-bridge/openclaw.plugin.json new file mode 100644 index 0000000..fe78a52 --- /dev/null +++ b/openclaw-extensions/mcp-bridge/openclaw.plugin.json @@ -0,0 +1,46 @@ +{ + "id": "mcp-bridge", + "name": "MCP Bridge", + "description": "Expose LobsterAI-managed MCP servers as native OpenClaw tools.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "callbackUrl": { + "type": "string", + "minLength": 1 + }, + "secret": { + "type": "string", + "minLength": 1 + }, + "requestTimeoutMs": { + "type": "number", + "minimum": 1000 + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "server": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "inputSchema": { + "type": "object" + } + } + } + } + } + } +} diff --git a/openclaw-extensions/mcp-bridge/package.json b/openclaw-extensions/mcp-bridge/package.json new file mode 100644 index 0000000..1ef2e6f --- /dev/null +++ b/openclaw-extensions/mcp-bridge/package.json @@ -0,0 +1,11 @@ +{ + "name": "@lobsterai/openclaw-mcp-bridge", + "version": "0.0.0", + "description": "Expose LobsterAI-managed MCP servers as native OpenClaw tools.", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/package.json b/package.json index 9042741..9614ea6 100644 --- a/package.json +++ b/package.json @@ -70,15 +70,16 @@ "openclaw:ensure": "node scripts/ensure-openclaw-version.cjs", "openclaw:patch": "node scripts/apply-openclaw-patches.cjs", "openclaw:plugins": "node scripts/ensure-openclaw-plugins.cjs", + "openclaw:extensions:local": "node scripts/sync-local-openclaw-extensions.cjs", "openclaw:bundle": "node scripts/bundle-openclaw-gateway.cjs", "openclaw:precompile": "node scripts/precompile-openclaw-extensions.cjs", "openclaw:runtime:host": "node scripts/openclaw-runtime-host.cjs", - "openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", - "openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", - "openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", - "openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", - "openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", - "openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:precompile", + "openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", + "openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", + "openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", + "openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", + "openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", + "openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile", "regenerate:icon": "bash scripts/regenerate-mac-icon.sh", "fix:mac-icon": "bash scripts/fix-mac-icon-display.sh" }, @@ -157,4 +158,4 @@ "vite-plugin-electron-renderer": "^0.14.5", "wait-on": "^7.2.0" } -} \ No newline at end of file +} diff --git a/scripts/electron-builder-hooks.cjs b/scripts/electron-builder-hooks.cjs index 9507d67..b8322fc 100644 --- a/scripts/electron-builder-hooks.cjs +++ b/scripts/electron-builder-hooks.cjs @@ -6,6 +6,7 @@ const { spawnSync } = require('child_process'); const asar = require('@electron/asar'); const { ensurePortableGit } = require('./setup-mingit.js'); const { ensurePortablePythonRuntime, checkRuntimeHealth } = require('./setup-python-runtime.js'); +const { syncLocalOpenClawExtensions } = require('./sync-local-openclaw-extensions.cjs'); function isWindowsTarget(context) { return context?.electronPlatformName === 'win32'; @@ -126,6 +127,11 @@ function ensureBundledOpenClawRuntime(context) { const { runtimeRoot, targetId } = syncCurrentOpenClawRuntimeForTarget(context); const buildHint = getOpenClawRuntimeBuildHint(targetId); + const localMcpBridgeDir = path.join(runtimeRoot, 'extensions', 'mcp-bridge'); + if (!existsSync(localMcpBridgeDir)) { + syncLocalOpenClawExtensions(runtimeRoot); + } + const requiredExternalPaths = [ path.join(runtimeRoot, 'node_modules'), ]; diff --git a/scripts/sync-local-openclaw-extensions.cjs b/scripts/sync-local-openclaw-extensions.cjs new file mode 100644 index 0000000..44c3779 --- /dev/null +++ b/scripts/sync-local-openclaw-extensions.cjs @@ -0,0 +1,61 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function syncLocalOpenClawExtensions(runtimeRoot) { + const rootDir = path.resolve(__dirname, '..'); + const sourceDir = path.join(rootDir, 'openclaw-extensions'); + const targetRoot = runtimeRoot + ? path.resolve(runtimeRoot) + : path.join(rootDir, 'vendor', 'openclaw-runtime', 'current'); + const targetExtensionsDir = path.join(targetRoot, 'extensions'); + + if (!fs.existsSync(sourceDir)) { + return { sourceDir, targetRoot, copied: [] }; + } + if (!fs.existsSync(targetExtensionsDir)) { + throw new Error(`Runtime extensions directory does not exist: ${targetExtensionsDir}`); + } + + const copied = []; + const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const src = path.join(sourceDir, entry.name); + const dest = path.join(targetExtensionsDir, entry.name); + fs.cpSync(src, dest, { recursive: true, force: true }); + copied.push(entry.name); + } + + return { sourceDir, targetRoot, copied }; +} + +function main() { + try { + const runtimeRoot = (process.argv[2] || '').trim() || undefined; + const result = syncLocalOpenClawExtensions(runtimeRoot); + if (result.copied.length === 0) { + console.log('[sync-local-openclaw-extensions] No local extensions to sync.'); + return; + } + console.log( + `[sync-local-openclaw-extensions] Synced ${result.copied.join(', ')} -> ${path.join(result.targetRoot, 'extensions')}`, + ); + } catch (error) { + console.error( + `[sync-local-openclaw-extensions] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + syncLocalOpenClawExtensions, +}; diff --git a/src/main/libs/openclawConfigSync.ts b/src/main/libs/openclawConfigSync.ts index 916cd83..27b526f 100644 --- a/src/main/libs/openclawConfigSync.ts +++ b/src/main/libs/openclawConfigSync.ts @@ -8,6 +8,7 @@ import { resolveRawApiConfig } from './claudeSettings'; import type { OpenClawEngineManager } from './openclawEngineManager'; import { parseChannelSessionKey } from './openclawChannelSessionSync'; import type { McpToolManifestEntry } from './mcpServerManager'; +import { hasBundledOpenClawExtension } from './openclawLocalExtensions'; import { buildScheduledTaskEnginePrompt } from './scheduledTaskEnginePrompt'; export type McpBridgeConfig = { @@ -278,6 +279,10 @@ const readPreinstalledPluginIds = (): string[] => { } }; +const isBundledPluginAvailable = (pluginId: string): boolean => { + return hasBundledOpenClawExtension(pluginId); +}; + export type OpenClawConfigSyncResult = { ok: boolean; changed: boolean; @@ -367,6 +372,7 @@ export class OpenClawConfigSync { const workspaceDir = (coworkConfig.workingDirectory || '').trim(); const preinstalledPluginIds = readPreinstalledPluginIds(); + const hasMcpBridgePlugin = isBundledPluginAvailable('mcp-bridge'); const dingTalkConfig = this.getDingTalkConfig(); // DingTalk runs through OpenClaw plugin but still needs the gateway HTTP endpoint (chatCompletions) @@ -435,46 +441,45 @@ export class OpenClawConfigSync { maxConcurrentRuns: 3, sessionRetention: '7d', }, - ...(preinstalledPluginIds.length > 0 - ? { - plugins: { - entries: { - ...Object.fromEntries( - preinstalledPluginIds.map((id) => { - // Sync plugin enabled state with the corresponding channel config. - // When a channel is disabled in the UI, its plugin must also be - // disabled so OpenClaw doesn't load it at all. - const pluginEnabled = (() => { - if (id === 'dingtalk-connector') return !!(dingTalkConfig?.enabled && dingTalkConfig.clientId); - if (id === 'feishu-openclaw-plugin') return !!(feishuConfig?.enabled && feishuConfig.appId); - if (id === 'qqbot') return !!(qqConfig?.enabled && qqConfig.appId); - if (id === 'wecom-openclaw-plugin') return !!(wecomConfig?.enabled && wecomConfig.botId); - return true; // other plugins stay enabled - })(); - return [id, { enabled: pluginEnabled }]; - }), - ), - // Disable the built-in feishu plugin when the official one is preinstalled - ...(preinstalledPluginIds.includes('feishu-openclaw-plugin') - ? { feishu: { enabled: false } } - : {}), - 'mcp-bridge': { enabled: true }, + ...((() => { + const pluginEntries: Record = { + ...Object.fromEntries( + preinstalledPluginIds.map((id) => { + // Sync plugin enabled state with the corresponding channel config. + // When a channel is disabled in the UI, its plugin must also be + // disabled so OpenClaw doesn't load it at all. + const pluginEnabled = (() => { + if (id === 'dingtalk-connector') return !!(dingTalkConfig?.enabled && dingTalkConfig.clientId); + if (id === 'feishu-openclaw-plugin') return !!(feishuConfig?.enabled && feishuConfig.appId); + if (id === 'qqbot') return !!(qqConfig?.enabled && qqConfig.appId); + if (id === 'wecom-openclaw-plugin') return !!(wecomConfig?.enabled && wecomConfig.botId); + return true; // other plugins stay enabled + })(); + return [id, { enabled: pluginEnabled }]; + }), + ), + ...(preinstalledPluginIds.includes('feishu-openclaw-plugin') + ? { feishu: { enabled: false } } + : {}), + ...(hasMcpBridgePlugin + ? { 'mcp-bridge': { enabled: true } } + : {}), + }; + + return Object.keys(pluginEntries).length > 0 + ? { + plugins: { + entries: pluginEntries, }, - }, - } - : { - plugins: { - entries: { - 'mcp-bridge': { enabled: true }, - }, - }, - }), + } + : {}; + })()) }; // Sync MCP Bridge config into the plugin's own config section // (root-level keys are rejected by OpenClaw's strict schema validation) const mcpBridgeCfg = this.getMcpBridgeConfig?.(); - if (mcpBridgeCfg && mcpBridgeCfg.tools.length > 0) { + if (hasMcpBridgePlugin && mcpBridgeCfg && mcpBridgeCfg.tools.length > 0 && managedConfig.plugins) { const plugins = managedConfig.plugins as Record; const entries = plugins.entries as Record>; entries['mcp-bridge'] = { diff --git a/src/main/libs/openclawEngineManager.ts b/src/main/libs/openclawEngineManager.ts index 5b85242..da4b05d 100644 --- a/src/main/libs/openclawEngineManager.ts +++ b/src/main/libs/openclawEngineManager.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import net from 'net'; import path from 'path'; import { getElectronNodeRuntimePath } from './coworkUtil'; +import { syncLocalOpenClawExtensionsIntoRuntime } from './openclawLocalExtensions'; import { applyBundledOpenClawRuntimeHotfixes } from './openclawRuntimeHotfix'; const DEFAULT_OPENCLAW_VERSION = '2026.2.23'; @@ -256,6 +257,11 @@ export class OpenClawEngineManager extends EventEmitter { return this.getStatus(); } + const localExtensionSync = syncLocalOpenClawExtensionsIntoRuntime(runtime.root); + if (localExtensionSync.copied.length > 0) { + console.log(`[OpenClaw] synced local extensions: ${localExtensionSync.copied.join(', ')}`); + } + if (this.status.phase === 'running') { return this.getStatus(); } diff --git a/src/main/libs/openclawLocalExtensions.ts b/src/main/libs/openclawLocalExtensions.ts new file mode 100644 index 0000000..4f3064e --- /dev/null +++ b/src/main/libs/openclawLocalExtensions.ts @@ -0,0 +1,119 @@ +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; + +const LOCAL_EXTENSIONS_DIR = 'openclaw-extensions'; + +const findLocalExtensionsSourceDir = (): string | null => { + if (app.isPackaged) { + return null; + } + + const candidates = [ + path.join(app.getAppPath(), LOCAL_EXTENSIONS_DIR), + path.join(process.cwd(), LOCAL_EXTENSIONS_DIR), + ]; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) { + return candidate; + } + } catch { + // Ignore missing candidates. + } + } + + return null; +}; + +const findBundledExtensionsDir = (): string | null => { + const candidates = app.isPackaged + ? [path.join(process.resourcesPath, 'cfmind', 'extensions')] + : [ + path.join(app.getAppPath(), 'vendor', 'openclaw-runtime', 'current', 'extensions'), + path.join(process.cwd(), 'vendor', 'openclaw-runtime', 'current', 'extensions'), + ]; + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) { + return candidate; + } + } catch { + // Ignore missing candidates. + } + } + + return null; +}; + +export const syncLocalOpenClawExtensionsIntoRuntime = ( + runtimeRoot: string, +): { sourceDir: string | null; copied: string[] } => { + const sourceDir = findLocalExtensionsSourceDir(); + if (!sourceDir) { + return { sourceDir: null, copied: [] }; + } + + const targetExtensionsDir = path.join(runtimeRoot, 'extensions'); + try { + if (!fs.statSync(targetExtensionsDir).isDirectory()) { + return { sourceDir, copied: [] }; + } + } catch { + return { sourceDir, copied: [] }; + } + + const copied: string[] = []; + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + fs.cpSync( + path.join(sourceDir, entry.name), + path.join(targetExtensionsDir, entry.name), + { recursive: true, force: true }, + ); + copied.push(entry.name); + } + + return { sourceDir, copied }; +}; + +export const listLocalOpenClawExtensionIds = (): string[] => { + const sourceDir = findLocalExtensionsSourceDir(); + if (!sourceDir) { + return []; + } + + try { + return fs.readdirSync(sourceDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => fs.existsSync(path.join(sourceDir, entry.name, 'openclaw.plugin.json'))) + .map((entry) => entry.name); + } catch { + return []; + } +}; + +export const listBundledOpenClawExtensionIds = (): string[] => { + const extensionsDir = findBundledExtensionsDir(); + if (!extensionsDir) { + return []; + } + + try { + return fs.readdirSync(extensionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => fs.existsSync(path.join(extensionsDir, entry.name, 'openclaw.plugin.json'))) + .map((entry) => entry.name); + } catch { + return []; + } +}; + +export const hasBundledOpenClawExtension = (extensionId: string): boolean => { + return listBundledOpenClawExtensionIds().includes(extensionId) + || listLocalOpenClawExtensionIds().includes(extensionId); +};