From 61291ff83fe1f166c86433f7e4252109a0f2dc7c Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:18:20 +0800 Subject: [PATCH] Fix upgrade 3.13 (#488) --- electron/gateway/config-sync.ts | 177 +++++++++++++++++++++++++++----- electron/utils/openclaw-auth.ts | 34 +++--- package.json | 2 +- scripts/after-pack.cjs | 30 +++++- tests/unit/agent-config.test.ts | 6 +- 5 files changed, 208 insertions(+), 41 deletions(-) diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index b4d47cca9..8ff07f829 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 } from 'fs'; +import { existsSync, readFileSync, cpSync, mkdirSync, rmSync, readdirSync, realpathSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; import { getAllSettings } from '../utils/store'; @@ -30,11 +30,11 @@ export interface GatewayLaunchContext { // ── Auto-upgrade bundled plugins on startup ────────────────────── -const CHANNEL_PLUGIN_MAP: Record = { - dingtalk: 'dingtalk', - wecom: 'wecom', - feishu: 'feishu-openclaw-plugin', - qqbot: 'qqbot', +const CHANNEL_PLUGIN_MAP: Record = { + dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' }, + wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' }, + feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' }, + qqbot: { dirName: 'qqbot', npmName: '@sliverp/qqbot' }, }; function readPluginVersion(pkgJsonPath: string): string | null { @@ -47,7 +47,113 @@ function readPluginVersion(pkgJsonPath: string): string | null { } } -function buildPluginCandidateSources(pluginDirName: string): string[] { +/** 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 ? [ join(process.resourcesPath, 'openclaw-plugins', pluginDirName), @@ -62,33 +168,54 @@ function buildPluginCandidateSources(pluginDirName: string): string[] { /** * Auto-upgrade all configured channel plugins before Gateway start. - * Compares the installed version in ~/.openclaw/extensions/ with the - * bundled version and overwrites if the bundled version is newer. + * - Packaged mode: uses bundled plugins from resources/ (includes deps) + * - Dev mode: falls back to node_modules/ with pnpm-aware dep collection */ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { for (const channelType of configuredChannels) { - const pluginDirName = CHANNEL_PLUGIN_MAP[channelType]; - if (!pluginDirName) continue; + const pluginInfo = CHANNEL_PLUGIN_MAP[channelType]; + if (!pluginInfo) continue; + const { dirName, npmName } = pluginInfo; - const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName); + const targetDir = join(homedir(), '.openclaw', 'extensions', dirName); const targetManifest = join(targetDir, 'openclaw.plugin.json'); if (!existsSync(targetManifest)) continue; // not installed, nothing to upgrade - const sources = buildPluginCandidateSources(pluginDirName); - const sourceDir = sources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json'))); - if (!sourceDir) continue; // no bundled source available - const installedVersion = readPluginVersion(join(targetDir, 'package.json')); - const sourceVersion = readPluginVersion(join(sourceDir, 'package.json')); - if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) continue; - logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion}`); - try { - mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); - rmSync(targetDir, { recursive: true, force: true }); - cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); - } catch (err) { - logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin:`, err); + // Try bundled sources first (packaged mode or if bundle-plugins was run) + const bundledSources = buildBundledPluginSources(dirName); + const bundledDir = bundledSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json'))); + + if (bundledDir) { + const sourceVersion = readPluginVersion(join(bundledDir, 'package.json')); + if (sourceVersion && installedVersion && sourceVersion !== installedVersion) { + logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (bundled)`); + try { + mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + rmSync(targetDir, { recursive: true, force: true }); + cpSync(bundledDir, targetDir, { recursive: true, dereference: true }); + } catch (err) { + logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin:`, err); + } + } + continue; + } + + // Dev mode fallback: copy from node_modules/ with pnpm dep resolution + if (!app.isPackaged) { + 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; + + logger.info(`[plugin] Auto-upgrading ${channelType} plugin: ${installedVersion} → ${sourceVersion} (dev/node_modules)`); + try { + mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); + } catch (err) { + logger.warn(`[plugin] Failed to auto-upgrade ${channelType} plugin from node_modules:`, err); + } } } } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index f8339cc92..cfb67ab6b 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1055,20 +1055,30 @@ export async function sanitizeOpenClawConfig(): Promise { modified = true; } - // ── Disable bare 'feishu' when openclaw-lark is present ──────── - // The Gateway binary automatically adds bare 'feishu' to plugins - // config because the openclaw-lark plugin registers the 'feishu' - // channel. We can't DELETE it (triggers config-change → restart → - // Gateway re-adds it → loop). Instead, disable it so it doesn't - // conflict with openclaw-lark. - const allowArr = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : []; - const hasNewFeishu = allowArr.includes(NEW_FEISHU_ID) || !!pEntries?.[NEW_FEISHU_ID]; - if (hasNewFeishu && pEntries?.feishu) { - if (pEntries.feishu.enabled !== false) { - pEntries.feishu.enabled = false; - console.log(`[sanitize] Disabled bare plugins.entries.feishu (openclaw-lark is configured)`); + // ── 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. + // However, there's no plugin with id='feishu', so Gateway validation + // fails with "plugin not found: feishu". Remove it from allow[] and + // disable the entries.feishu entry to prevent Gateway from re-adding it. + const allowArr2 = Array.isArray(pluginsObj.allow) ? pluginsObj.allow as string[] : []; + const hasNewFeishu = allowArr2.includes(NEW_FEISHU_ID) || !!pEntries?.[NEW_FEISHU_ID]; + if (hasNewFeishu) { + // Remove bare 'feishu' from plugins.allow + const bareFeishuIdx = allowArr2.indexOf('feishu'); + if (bareFeishuIdx !== -1) { + allowArr2.splice(bareFeishuIdx, 1); + console.log('[sanitize] Removed bare "feishu" from plugins.allow (openclaw-lark is configured)'); modified = true; } + // Disable bare 'feishu' in plugins.entries so Gateway won't re-add it + if (pEntries?.feishu) { + if (pEntries.feishu.enabled !== false) { + pEntries.feishu.enabled = false; + console.log('[sanitize] Disabled bare plugins.entries.feishu (openclaw-lark is configured)'); + modified = true; + } + } } } diff --git a/package.json b/package.json index d52ed8756..35cf2c1ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.2.3-beta.0", + "version": "0.2.3-beta.2", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 928611452..2d1ecb4f2 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -162,7 +162,7 @@ const MODULE_PATCHES = { }; function patchBrokenModules(nodeModulesDir) { - const { writeFileSync } = require('fs'); + const { writeFileSync, readFileSync } = require('fs'); let count = 0; for (const [rel, content] of Object.entries(MODULE_PATCHES)) { const target = join(nodeModulesDir, rel); @@ -171,6 +171,34 @@ function patchBrokenModules(nodeModulesDir) { count++; } } + + // https-proxy-agent@8.x only defines exports.import (ESM) with no CJS + // fallback. The openclaw Gateway loads it via require(), which triggers + // ERR_PACKAGE_PATH_NOT_EXPORTED. Patch exports to add CJS conditions. + const hpaPkgPath = join(nodeModulesDir, 'https-proxy-agent', 'package.json'); + if (existsSync(hpaPkgPath)) { + try { + const raw = readFileSync(hpaPkgPath, 'utf8'); + const pkg = JSON.parse(raw); + const exp = pkg.exports; + // Only patch if exports exists and lacks a CJS 'require' condition + if (exp && exp.import && !exp.require && !exp['.']) { + pkg.exports = { + '.': { + import: exp.import, + require: exp.import, // ESM dist works for CJS too via Node.js interop + default: typeof exp.import === 'string' ? exp.import : exp.import.default, + }, + }; + writeFileSync(hpaPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + count++; + console.log('[after-pack] 🩹 Patched https-proxy-agent exports for CJS compatibility'); + } + } catch (err) { + console.warn('[after-pack] ⚠️ Failed to patch https-proxy-agent:', err.message); + } + } + if (count > 0) { console.log(`[after-pack] 🩹 Patched ${count} broken module(s) in ${nodeModulesDir}`); } diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts index db2c19725..0a6bf8341 100644 --- a/tests/unit/agent-config.test.ts +++ b/tests/unit/agent-config.test.ts @@ -163,7 +163,7 @@ describe('agent config lifecycle', () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); const { deleteAgentConfig } = await import('@electron/utils/agent-config'); - const snapshot = await deleteAgentConfig('test2'); + const { snapshot } = await deleteAgentConfig('test2'); expect(snapshot.agents.map((agent) => agent.id)).toEqual(['main', 'test3']); expect(snapshot.channelOwners.feishu).toBe('main'); @@ -175,7 +175,9 @@ describe('agent config lifecycle', () => { ]); expect(config.bindings).toEqual([]); await expect(access(test2RuntimeDir)).rejects.toThrow(); - await expect(access(test2WorkspaceDir)).rejects.toThrow(); + // Workspace deletion is intentionally deferred by `deleteAgentConfig` to avoid + // ENOENT errors during Gateway restart, so it should still exist here. + await expect(access(test2WorkspaceDir)).resolves.toBeUndefined(); infoSpy.mockRestore(); });