From f1e2e9fa01b1546ac53cfd91b690500b33aaf4bc Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:08:50 +0800 Subject: [PATCH] fix wecom channel (#530) --- electron/gateway/config-sync.ts | 131 ++---------- electron/utils/channel-config.ts | 18 +- electron/utils/openclaw-auth.ts | 25 +++ electron/utils/plugin-install.ts | 298 ++++++++++++++++++++++++++-- package.json | 4 +- pnpm-lock.yaml | 33 +-- scripts/after-pack.cjs | 44 ++++ scripts/bundle-openclaw-plugins.mjs | 59 ++++++ tests/unit/channel-config.test.ts | 25 ++- 9 files changed, 475 insertions(+), 162 deletions(-) diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 8ff07f829..31454f78e 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; import path from 'path'; -import { existsSync, readFileSync, cpSync, mkdirSync, rmSync, readdirSync, realpathSync } from 'fs'; +import { existsSync, readFileSync, cpSync, mkdirSync, rmSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { getAllSettings } from '../utils/store'; @@ -14,6 +14,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { logger } from '../utils/logger'; import { prependPathEntry } from '../utils/env-path'; +import { copyPluginFromNodeModules, fixupPluginManifest } from '../utils/plugin-install'; export interface GatewayLaunchContext { appSettings: Awaited>; @@ -47,112 +48,6 @@ function readPluginVersion(pkgJsonPath: string): string | null { } } -/** Walk up from a path until we find a parent named node_modules. */ -function findParentNodeModules(startPath: string): string | null { - let dir = startPath; - while (dir !== path.dirname(dir)) { - if (path.basename(dir) === 'node_modules') return dir; - dir = path.dirname(dir); - } - return null; -} - -/** List packages inside a node_modules dir (handles @scoped packages). */ -function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> { - const result: Array<{ name: string; fullPath: string }> = []; - if (!existsSync(nodeModulesDir)) return result; - const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']); - for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; - if (SKIP.has(entry.name)) continue; - const entryPath = join(nodeModulesDir, entry.name); - if (entry.name.startsWith('@')) { - try { - for (const sub of readdirSync(entryPath)) { - result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) }); - } - } catch { /* ignore */ } - } else { - result.push({ name: entry.name, fullPath: entryPath }); - } - } - return result; -} - -/** - * Copy a plugin from a pnpm node_modules location, including its - * transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs - * logic). - */ -function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void { - let realPath: string; - try { - realPath = realpathSync(npmPkgPath); - } catch { - throw new Error(`Cannot resolve real path for ${npmPkgPath}`); - } - - // 1. Copy plugin package itself - rmSync(targetDir, { recursive: true, force: true }); - mkdirSync(targetDir, { recursive: true }); - cpSync(realPath, targetDir, { recursive: true, dereference: true }); - - // 2. Collect transitive deps from pnpm virtual store - const rootVirtualNM = findParentNodeModules(realPath); - if (!rootVirtualNM) { - logger.warn(`[plugin] Cannot find virtual store node_modules for ${npmName}, plugin may lack deps`); - return; - } - - // Read peer deps to skip (they're provided by the host gateway) - const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); - try { - const pluginPkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8')); - for (const peer of Object.keys(pluginPkg.peerDependencies || {})) { - SKIP_PACKAGES.add(peer); - } - } catch { /* ignore */ } - - const collected = new Map(); // realPath → packageName - const queue: Array<{ nodeModulesDir: string; skipPkg: string }> = [ - { nodeModulesDir: rootVirtualNM, skipPkg: npmName }, - ]; - - while (queue.length > 0) { - const { nodeModulesDir, skipPkg } = queue.shift()!; - for (const { name, fullPath } of listPackagesInDir(nodeModulesDir)) { - if (name === skipPkg) continue; - if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue; - let depRealPath: string; - try { - depRealPath = realpathSync(fullPath); - } catch { continue; } - if (collected.has(depRealPath)) continue; - collected.set(depRealPath, name); - const depVirtualNM = findParentNodeModules(depRealPath); - if (depVirtualNM && depVirtualNM !== nodeModulesDir) { - queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); - } - } - } - - // 3. Copy flattened deps into targetDir/node_modules/ - const outputNM = join(targetDir, 'node_modules'); - mkdirSync(outputNM, { recursive: true }); - const copiedNames = new Set(); - for (const [depRealPath, pkgName] of collected) { - if (copiedNames.has(pkgName)) continue; - copiedNames.add(pkgName); - const dest = join(outputNM, pkgName); - try { - mkdirSync(path.dirname(dest), { recursive: true }); - cpSync(depRealPath, dest, { recursive: true, dereference: true }); - } catch { /* skip individual dep failures */ } - } - - logger.info(`[plugin] Copied ${copiedNames.size} deps for ${npmName}`); -} - function buildBundledPluginSources(pluginDirName: string): string[] { return app.isPackaged ? [ @@ -179,9 +74,8 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { const targetDir = join(homedir(), '.openclaw', 'extensions', dirName); const targetManifest = join(targetDir, 'openclaw.plugin.json'); - if (!existsSync(targetManifest)) continue; // not installed, nothing to upgrade - - const installedVersion = readPluginVersion(join(targetDir, 'package.json')); + const isInstalled = existsSync(targetManifest); + const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null; // Try bundled sources first (packaged mode or if bundle-plugins was run) const bundledSources = buildBundledPluginSources(dirName); @@ -189,14 +83,16 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { if (bundledDir) { const sourceVersion = readPluginVersion(join(bundledDir, 'package.json')); - if (sourceVersion && installedVersion && sourceVersion !== installedVersion) { - logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (bundled)`); + // Install or upgrade if version differs or plugin not installed + if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion)) { + logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (bundled)`); try { mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); rmSync(targetDir, { recursive: true, force: true }); cpSync(bundledDir, targetDir, { recursive: true, dereference: true }); + fixupPluginManifest(targetDir); } catch (err) { - logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin:`, err); + logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err); } } continue; @@ -207,14 +103,17 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/')); if (!existsSync(join(npmPkgPath, 'openclaw.plugin.json'))) continue; const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json')); - if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) continue; + if (!sourceVersion) continue; + // Skip only if installed AND same version + if (isInstalled && installedVersion && sourceVersion === installedVersion) continue; - logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (dev/node_modules)`); + logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`); try { mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); + fixupPluginManifest(targetDir); } catch (err) { - logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin from node_modules:`, err); + logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err); } } } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 83f1eb5ea..83fe95a50 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -15,7 +15,7 @@ import { withConfigLock } from './config-mutex'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); -const WECOM_PLUGIN_ID = 'wecom-openclaw-plugin'; +const WECOM_PLUGIN_ID = 'wecom'; const FEISHU_PLUGIN_ID = 'openclaw-lark'; const LEGACY_FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin'; const DEFAULT_ACCOUNT_ID = 'default'; @@ -176,7 +176,13 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin if (channelType === 'wecom') { if (!currentConfig.plugins) { - currentConfig.plugins = { allow: [WECOM_PLUGIN_ID], enabled: true }; + currentConfig.plugins = { + allow: [WECOM_PLUGIN_ID], + enabled: true, + entries: { + [WECOM_PLUGIN_ID]: { enabled: true } + } + }; } else { currentConfig.plugins.enabled = true; const allow: string[] = Array.isArray(currentConfig.plugins.allow) @@ -188,6 +194,14 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin } else if (normalizedAllow.length !== allow.length) { currentConfig.plugins.allow = normalizedAllow; } + + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + if (!currentConfig.plugins.entries[WECOM_PLUGIN_ID]) { + currentConfig.plugins.entries[WECOM_PLUGIN_ID] = {}; + } + currentConfig.plugins.entries[WECOM_PLUGIN_ID].enabled = true; } } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index cfb67ab6b..330f45ebc 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1055,6 +1055,31 @@ export async function sanitizeOpenClawConfig(): Promise { modified = true; } + // ── wecom-openclaw-plugin → wecom migration ──────────────── + const LEGACY_WECOM_ID = 'wecom-openclaw-plugin'; + const NEW_WECOM_ID = 'wecom'; + if (Array.isArray(pluginsObj.allow)) { + const allowArr = pluginsObj.allow as string[]; + const legacyIdx = allowArr.indexOf(LEGACY_WECOM_ID); + if (legacyIdx !== -1) { + if (!allowArr.includes(NEW_WECOM_ID)) { + allowArr[legacyIdx] = NEW_WECOM_ID; + } else { + allowArr.splice(legacyIdx, 1); + } + console.log(`[sanitize] Migrated plugins.allow: ${LEGACY_WECOM_ID} → ${NEW_WECOM_ID}`); + modified = true; + } + } + if (pEntries?.[LEGACY_WECOM_ID]) { + if (!pEntries[NEW_WECOM_ID]) { + pEntries[NEW_WECOM_ID] = pEntries[LEGACY_WECOM_ID]; + } + delete pEntries[LEGACY_WECOM_ID]; + console.log(`[sanitize] Migrated plugins.entries: ${LEGACY_WECOM_ID} → ${NEW_WECOM_ID}`); + modified = true; + } + // ── Remove bare 'feishu' when openclaw-lark is present ───────── // The Gateway binary automatically adds bare 'feishu' to plugins.allow // because the openclaw-lark plugin registers the 'feishu' channel. diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index 34f589364..8907c11c2 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -6,11 +6,136 @@ * stale plugins) and when a user configures a channel. */ import { app } from 'electron'; -import { existsSync, cpSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { existsSync, cpSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { logger } from './logger'; +// ── Known plugin-ID corrections ───────────────────────────────────────────── +// Some npm packages ship with an openclaw.plugin.json whose "id" field +// doesn't match the ID the plugin code actually exports. After copying we +// patch both the manifest AND the compiled JS so the Gateway accepts them. +const MANIFEST_ID_FIXES: Record = { + 'wecom-openclaw-plugin': 'wecom', +}; + +/** + * After a plugin has been copied to ~/.openclaw/extensions/, fix any + * known manifest-ID mismatches so the Gateway can load the plugin. + * Also patches package.json fields that the Gateway uses as "entry hints". + */ +export function fixupPluginManifest(targetDir: string): void { + // 1. Fix openclaw.plugin.json id + const manifestPath = join(targetDir, 'openclaw.plugin.json'); + try { + const raw = readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw); + const oldId = manifest.id as string | undefined; + if (oldId && MANIFEST_ID_FIXES[oldId]) { + const newId = MANIFEST_ID_FIXES[oldId]; + manifest.id = newId; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + logger.info(`[plugin] Fixed manifest ID: ${oldId} → ${newId}`); + } + } catch { + // manifest may not exist yet — ignore + } + + // 2. Fix package.json fields that Gateway uses as "entry hints" + const pkgPath = join(targetDir, 'package.json'); + try { + const raw = readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(raw); + let modified = false; + + // Check if the package name contains a legacy ID that needs fixing + for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) { + if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) { + pkg.name = pkg.name.replace(oldId, newId); + modified = true; + } + const install = pkg.openclaw?.install; + if (install) { + if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) { + install.npmSpec = install.npmSpec.replace(oldId, newId); + modified = true; + } + if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) { + install.localPath = install.localPath.replace(oldId, newId); + modified = true; + } + } + } + + if (modified) { + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8'); + logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`); + } + } catch { + // ignore + } + + // 3. Fix hardcoded plugin IDs in compiled JS entry files. + // The Gateway validates that the JS export's `id` matches the manifest. + patchPluginEntryIds(targetDir); +} + +/** + * Patch the compiled JS entry files so the hardcoded `id` field in the + * plugin export matches the manifest. Without this, the Gateway rejects + * the plugin with "plugin id mismatch". + */ +function patchPluginEntryIds(targetDir: string): void { + const pkgPath = join(targetDir, 'package.json'); + let pkg: Record; + try { + pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + } catch { + return; + } + + const entryFiles = [pkg.main, pkg.module].filter(Boolean) as string[]; + + for (const entry of entryFiles) { + const entryPath = join(targetDir, entry); + if (!existsSync(entryPath)) continue; + + let content: string; + try { + content = readFileSync(entryPath, 'utf-8'); + } catch { + continue; + } + + let patched = false; + for (const [wrongId, correctId] of Object.entries(MANIFEST_ID_FIXES)) { + // Match patterns like: id: "wecom-openclaw-plugin" or id: 'wecom-openclaw-plugin' + const escapedWrongId = wrongId.replace(/-/g, '\\-'); + const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${escapedWrongId}\\2`, 'g'); + const replaced = content.replace(pattern, `$1$2${correctId}$2`); + if (replaced !== content) { + content = replaced; + patched = true; + logger.info(`[plugin] Patched plugin ID in ${entry}: "${wrongId}" → "${correctId}"`); + } + } + + if (patched) { + writeFileSync(entryPath, content, 'utf-8'); + } + } +} + +// ── Plugin npm name mapping ────────────────────────────────────────────────── + +const PLUGIN_NPM_NAMES: Record = { + dingtalk: '@soimy/dingtalk', + wecom: '@wecom/wecom-openclaw-plugin', + 'feishu-openclaw-plugin': '@larksuite/openclaw-lark', + qqbot: '@sliverp/qqbot', +}; + // ── Version helper ─────────────────────────────────────────────────────────── function readPluginVersion(pkgJsonPath: string): string | null { @@ -23,6 +148,114 @@ function readPluginVersion(pkgJsonPath: string): string | null { } } +// ── pnpm-aware node_modules copy helpers ───────────────────────────────────── + +/** Walk up from a path until we find a parent named node_modules. */ +function findParentNodeModules(startPath: string): string | null { + let dir = startPath; + while (dir !== path.dirname(dir)) { + if (path.basename(dir) === 'node_modules') return dir; + dir = path.dirname(dir); + } + return null; +} + +/** List packages inside a node_modules dir (handles @scoped packages). */ +function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> { + const result: Array<{ name: string; fullPath: string }> = []; + if (!existsSync(nodeModulesDir)) return result; + const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']); + for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (SKIP.has(entry.name)) continue; + const entryPath = join(nodeModulesDir, entry.name); + if (entry.name.startsWith('@')) { + try { + for (const sub of readdirSync(entryPath)) { + result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) }); + } + } catch { /* ignore */ } + } else { + result.push({ name: entry.name, fullPath: entryPath }); + } + } + return result; +} + +/** + * Copy a plugin from a pnpm node_modules location, including its + * transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs + * logic). + */ +export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void { + let realPath: string; + try { + realPath = realpathSync(npmPkgPath); + } catch { + throw new Error(`Cannot resolve real path for ${npmPkgPath}`); + } + + // 1. Copy plugin package itself + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(targetDir, { recursive: true }); + cpSync(realPath, targetDir, { recursive: true, dereference: true }); + + // 2. Collect transitive deps from pnpm virtual store + const rootVirtualNM = findParentNodeModules(realPath); + if (!rootVirtualNM) { + logger.warn(`[plugin] Cannot find virtual store node_modules for ${npmName}, plugin may lack deps`); + return; + } + + // Read peer deps to skip (they're provided by the host gateway) + const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); + try { + const pluginPkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8')); + for (const peer of Object.keys(pluginPkg.peerDependencies || {})) { + SKIP_PACKAGES.add(peer); + } + } catch { /* ignore */ } + + const collected = new Map(); // realPath → packageName + const queue: Array<{ nodeModulesDir: string; skipPkg: string }> = [ + { nodeModulesDir: rootVirtualNM, skipPkg: npmName }, + ]; + + while (queue.length > 0) { + const { nodeModulesDir, skipPkg } = queue.shift()!; + for (const { name, fullPath } of listPackagesInDir(nodeModulesDir)) { + if (name === skipPkg) continue; + if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue; + let depRealPath: string; + try { + depRealPath = realpathSync(fullPath); + } catch { continue; } + if (collected.has(depRealPath)) continue; + collected.set(depRealPath, name); + const depVirtualNM = findParentNodeModules(depRealPath); + if (depVirtualNM && depVirtualNM !== nodeModulesDir) { + queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); + } + } + } + + // 3. Copy flattened deps into targetDir/node_modules/ + const outputNM = join(targetDir, 'node_modules'); + mkdirSync(outputNM, { recursive: true }); + const copiedNames = new Set(); + for (const [depRealPath, pkgName] of collected) { + if (copiedNames.has(pkgName)) continue; + copiedNames.add(pkgName); + const dest = join(outputNM, pkgName); + try { + mkdirSync(path.dirname(dest), { recursive: true }); + cpSync(depRealPath, dest, { recursive: true, dereference: true }); + } catch { /* skip individual dep failures */ } + } + + logger.info(`[plugin] Copied ${copiedNames.size} deps for ${npmName}`); +} + // ── Core install / upgrade logic ───────────────────────────────────────────── export function ensurePluginInstalled( @@ -50,26 +283,57 @@ export function ensurePluginInstalled( ); } - // Fresh install or upgrade - if (!sourceDir) { - return { - installed: false, - warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`, - }; + // Fresh install or upgrade — try bundled/build sources first + if (sourceDir) { + try { + mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + rmSync(targetDir, { recursive: true, force: true }); + cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); + if (!existsSync(join(targetDir, 'openclaw.plugin.json'))) { + return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; + } + fixupPluginManifest(targetDir); + logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`); + return { installed: true }; + } catch { + return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` }; + } } - try { - mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); - rmSync(targetDir, { recursive: true, force: true }); - cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); - if (!existsSync(join(targetDir, 'openclaw.plugin.json'))) { - return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; + // Dev mode fallback: copy from node_modules with pnpm-aware dep resolution + if (!app.isPackaged) { + const npmName = PLUGIN_NPM_NAMES[pluginDirName]; + if (npmName) { + const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/')); + if (existsSync(join(npmPkgPath, 'openclaw.plugin.json'))) { + const installedVersion = existsSync(targetPkgJson) ? readPluginVersion(targetPkgJson) : null; + const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json')); + if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) { + logger.info( + `[plugin] ${installedVersion ? 'Upgrading' : 'Installing'} ${pluginLabel} plugin` + + `${installedVersion ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`, + ); + try { + mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); + fixupPluginManifest(targetDir); + if (existsSync(join(targetDir, 'openclaw.plugin.json'))) { + return { installed: true }; + } + } catch (err) { + logger.warn(`[plugin] Failed to install ${pluginLabel} plugin from node_modules:`, err); + } + } else if (existsSync(targetManifest)) { + return { installed: true }; // same version, already installed + } + } } - logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`); - return { installed: true }; - } catch { - return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` }; } + + return { + installed: false, + warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`, + }; } // ── Candidate source path builder ──────────────────────────────────────────── diff --git a/package.json b/package.json index 1d55e0bb2..f7e5fd0a0 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", "@vitejs/plugin-react": "^5.1.4", - "@wecom/wecom-openclaw-plugin": "^1.0.6", + "@wecom/wecom-openclaw-plugin": "^1.0.11", "autoprefixer": "^10.4.24", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -124,4 +124,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5dc9caa4..8d3e10be2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,8 +109,8 @@ importers: specifier: ^5.1.4 version: 5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@1.21.7)(yaml@2.8.2)) '@wecom/wecom-openclaw-plugin': - specifier: ^1.0.6 - version: 1.0.6 + specifier: ^1.0.11 + version: 1.0.11 autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) @@ -3216,11 +3216,11 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - '@wecom/aibot-node-sdk@1.0.1': - resolution: {integrity: sha512-c/sa1IvRKIP+4rZfRV2v70FaXB92+BJIh+vedZkPa8wZ1dwIUyvGg7ydkfYRIwFDzjO9IJZUX5V14EUQYVopAg==} + '@wecom/aibot-node-sdk@1.0.2': + resolution: {integrity: sha512-azClUIMWWF5vs8K1YWBiNykTFUawej0Z1ooN0ZMGX/PlLB/BK0dQfwbLc1a5Wj3bLRLaFb8HuCTuBrxLnJKJ7g==} - '@wecom/wecom-openclaw-plugin@1.0.6': - resolution: {integrity: sha512-1yn6P3KGdEfKoTuGH0Ot4vuoHOFqZJ+qlVrEXYBzkPwtNHb7s2ja2YKizaffYWb0h2s464PEXKhmkQq/RRJwkg==} + '@wecom/wecom-openclaw-plugin@1.0.11': + resolution: {integrity: sha512-TqyWvi8AxPyii/fUZk/rVR4a5jXl6PHte6wuqgtbrWXFoOfYyKtnyjmtGJk3/kf1ZOjgHu2N6lfhz5fA6bTCyw==} '@whiskeysockets/baileys@7.0.0-rc.9': resolution: {integrity: sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==} @@ -4295,10 +4295,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@21.3.0: - resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} - engines: {node: '>=20'} - file-type@21.3.2: resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==} engines: {node: '>=20'} @@ -10885,7 +10881,7 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - '@wecom/aibot-node-sdk@1.0.1': + '@wecom/aibot-node-sdk@1.0.2': dependencies: axios: 1.13.5(debug@4.4.3) eventemitter3: 5.0.4 @@ -10895,10 +10891,10 @@ snapshots: - debug - utf-8-validate - '@wecom/wecom-openclaw-plugin@1.0.6': + '@wecom/wecom-openclaw-plugin@1.0.11': dependencies: - '@wecom/aibot-node-sdk': 1.0.1 - file-type: 21.3.0 + '@wecom/aibot-node-sdk': 1.0.2 + file-type: 21.3.2 transitivePeerDependencies: - bufferutil - debug @@ -12239,15 +12235,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-type@21.3.0: - dependencies: - '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.4 - token-types: 6.1.2 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - file-type@21.3.2: dependencies: '@tokenizer/inflate': 0.4.1 diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 2d1ecb4f2..508863000 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -204,6 +204,48 @@ function patchBrokenModules(nodeModulesDir) { } } +// ── Plugin ID mismatch patcher ─────────────────────────────────────────────── +// Some plugins (e.g. wecom) have a compiled JS entry that hardcodes a different +// ID than what openclaw.plugin.json declares. The Gateway rejects mismatches, +// so we fix them after copying. + +const PLUGIN_ID_FIXES = { + 'wecom-openclaw-plugin': 'wecom', +}; + +function patchPluginIds(pluginDir, expectedId) { + const { readFileSync, writeFileSync } = require('fs'); + + const pkgJsonPath = join(pluginDir, 'package.json'); + if (!existsSync(pkgJsonPath)) return; + + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + const entryFiles = [pkg.main, pkg.module].filter(Boolean); + + for (const entry of entryFiles) { + const entryPath = join(pluginDir, entry); + if (!existsSync(entryPath)) continue; + + let content = readFileSync(entryPath, 'utf8'); + let patched = false; + + for (const [wrongId, correctId] of Object.entries(PLUGIN_ID_FIXES)) { + if (correctId !== expectedId) continue; + const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${wrongId.replace(/-/g, '\\-')}\\2`, 'g'); + const replaced = content.replace(pattern, `$1$2${correctId}$2`); + if (replaced !== content) { + content = replaced; + patched = true; + console.log(`[after-pack] 🩹 Patching plugin ID in ${entry}: "${wrongId}" → "${correctId}"`); + } + } + + if (patched) { + writeFileSync(entryPath, content, 'utf8'); + } + } +} + // ── Plugin bundler ─────────────────────────────────────────────────────────── // Bundles a single OpenClaw plugin (and its transitive deps) from node_modules // directly into the packaged resources directory. Mirrors the logic in @@ -383,6 +425,8 @@ exports.default = async function afterPack(context) { cleanupKoffi(pluginNM, platform, arch); cleanupNativePlatformPackages(pluginNM, platform, arch); } + // Fix hardcoded plugin ID mismatches in compiled JS + patchPluginIds(pluginDestDir, pluginId); } } diff --git a/scripts/bundle-openclaw-plugins.mjs b/scripts/bundle-openclaw-plugins.mjs index 0efbcca89..a9762c154 100644 --- a/scripts/bundle-openclaw-plugins.mjs +++ b/scripts/bundle-openclaw-plugins.mjs @@ -171,9 +171,68 @@ function bundleOnePlugin({ npmName, pluginId }) { throw new Error(`Missing openclaw.plugin.json in bundled plugin output: ${pluginId}`); } + // 4) Patch plugin ID mismatch: some npm packages hardcode a different ID in + // their JS output than what openclaw.plugin.json declares. The Gateway + // validates that these match, so we fix it post-copy. + patchPluginId(outputDir, pluginId); + echo` ✅ ${pluginId}: copied ${copiedCount} deps (skipped dupes: ${skippedDupes})`; } +/** + * Patch plugin entry JS files so the exported `id` matches openclaw.plugin.json. + * Some plugins (e.g. wecom) ship with a hardcoded ID in their compiled output + * that differs from the manifest, causing a Gateway "plugin id mismatch" error. + */ +function patchPluginId(pluginDir, expectedId) { + const manifestPath = path.join(pluginDir, 'openclaw.plugin.json'); + if (!fs.existsSync(manifestPath)) return; + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const manifestId = manifest.id; + if (manifestId !== expectedId) { + echo` ⚠️ Manifest ID "${manifestId}" doesn't match expected "${expectedId}", skipping patch`; + return; + } + + // Read the package.json to find the main entry point + const pkgJsonPath = path.join(pluginDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return; + + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const entryFiles = [pkg.main, pkg.module].filter(Boolean); + + // Known ID mismatches to patch. Keys are the wrong ID found in compiled JS, + // values are the correct ID (must match openclaw.plugin.json). + const ID_FIXES = { + 'wecom-openclaw-plugin': 'wecom', + }; + + for (const entry of entryFiles) { + const entryPath = path.join(pluginDir, entry); + if (!fs.existsSync(entryPath)) continue; + + let content = fs.readFileSync(entryPath, 'utf8'); + let patched = false; + + for (const [wrongId, correctId] of Object.entries(ID_FIXES)) { + if (correctId !== expectedId) continue; + // Replace id: "wecom-openclaw-plugin" or id: 'wecom-openclaw-plugin' + const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${wrongId.replace(/-/g, '\\-')}\\2`, 'g'); + const replaced = content.replace(pattern, `$1$2${correctId}$2`); + if (replaced !== content) { + content = replaced; + patched = true; + echo` 🩹 Patching plugin ID in ${entry}: "${wrongId}" → "${correctId}"`; + } + } + + if (patched) { + fs.writeFileSync(entryPath, content, 'utf8'); + } + } +} + echo`📦 Bundling OpenClaw plugin mirrors...`; fs.mkdirSync(OUTPUT_ROOT, { recursive: true }); diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index e5ae3eefc..08bd1921c 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -124,7 +124,7 @@ describe('parseDoctorValidationOutput', () => { expect(out.undetermined).toBe(true); expect(out.errors).toEqual([]); - expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true); + expect(out.warnings.some((w: string) => w.includes('falling back to local channel config checks'))).toBe(true); }); it('falls back with hint when output is empty', async () => { @@ -134,6 +134,27 @@ describe('parseDoctorValidationOutput', () => { expect(out.undetermined).toBe(true); expect(out.errors).toEqual([]); - expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true); + expect(out.warnings.some((w: string) => w.includes('falling back to local channel config checks'))).toBe(true); + }); +}); + +describe('WeCom plugin configuration', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('sets plugins.entries.wecom.enabled when saving wecom config', async () => { + const { saveChannelConfig } = await import('@electron/utils/channel-config'); + + await saveChannelConfig('wecom', { botId: 'test-bot', secret: 'test-secret' }, 'agent-a'); + + const config = await readOpenClawJson(); + const plugins = config.plugins as { allow: string[], entries: Record }; + + expect(plugins.allow).toContain('wecom'); + expect(plugins.entries['wecom'].enabled).toBe(true); }); });