From ec8db0be7514e1c236c3a531692bc22198f98d6b Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:27:48 +0800 Subject: [PATCH] fix(gateway): resolve startup hang caused by qqbot plugin manifest ID mismatch & stderr flood (#718) --- electron/gateway/config-sync.ts | 42 +++++++++++++++++++++++++++--- electron/gateway/manager.ts | 15 +++++++++++ electron/gateway/startup-stderr.ts | 3 +++ electron/utils/channel-config.ts | 36 +++++++++++++++++++------ electron/utils/openclaw-auth.ts | 29 +++++++++++++++++++++ tests/unit/channel-config.test.ts | 2 +- 6 files changed, 115 insertions(+), 12 deletions(-) diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 932095424..b230f974a 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -20,7 +20,7 @@ import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-stor import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths'; import { getUvMirrorEnv } from '../utils/uv-env'; -import { cleanupDanglingWeChatPluginState, listConfiguredChannels } from '../utils/channel-config'; +import { cleanupDanglingWeChatPluginState, listConfiguredChannels, readOpenClawConfig } from '../utils/channel-config'; import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth'; import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; @@ -131,6 +131,10 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { } catch (err) { logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err); } + } else if (isInstalled) { + // Same version already installed — still patch manifest ID in case it was + // never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin). + fixupPluginManifest(targetDir); } continue; } @@ -141,10 +145,14 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { if (!existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) continue; const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json')); if (!sourceVersion) continue; - // Skip only if installed AND same version - if (isInstalled && installedVersion && sourceVersion === installedVersion) continue; + // Skip only if installed AND same version — but still patch manifest ID. + if (isInstalled && installedVersion && sourceVersion === installedVersion) { + fixupPluginManifest(targetDir); + continue; + } logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`); + try { mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); copyPluginFromNodeModules(npmPkgPath, targetDir, npmName); @@ -187,6 +195,34 @@ export async function syncGatewayConfigBeforeLaunch( // the plugin manifest ID matches what sanitize wrote to the config. try { const configuredChannels = await listConfiguredChannels(); + + // Also ensure plugins referenced in plugins.allow are installed even if + // they have no channels.X section yet (e.g. qqbot added via plugins.allow + // but never fully saved through ClawX UI). + try { + const rawCfg = await readOpenClawConfig(); + const allowList = Array.isArray(rawCfg.plugins?.allow) ? (rawCfg.plugins!.allow as string[]) : []; + // Build reverse maps: dirName → channelType AND known manifest IDs → channelType + const pluginIdToChannel: Record = {}; + for (const [channelType, info] of Object.entries(CHANNEL_PLUGIN_MAP)) { + pluginIdToChannel[info.dirName] = channelType; + } + // Known manifest IDs that differ from their dirName/channelType + pluginIdToChannel['openclaw-qqbot'] = 'qqbot'; + pluginIdToChannel['openclaw-lark'] = 'feishu'; + pluginIdToChannel['feishu-openclaw-plugin'] = 'feishu'; + + for (const pluginId of allowList) { + const channelType = pluginIdToChannel[pluginId] ?? pluginId; + if (CHANNEL_PLUGIN_MAP[channelType] && !configuredChannels.includes(channelType)) { + configuredChannels.push(channelType); + } + } + + } catch (err) { + logger.warn('[plugin] Failed to augment channel list from plugins.allow:', err); + } + ensureConfiguredPluginsUpgraded(configuredChannels); } catch (err) { logger.warn('Failed to auto-upgrade plugins:', err); diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index b118745af..ad171a64a 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -705,6 +705,9 @@ export class GatewayManager extends EventEmitter { await unloadLaunchctlGatewayService(); this.processExitCode = null; + // Per-process dedup map for stderr lines — resets on each new spawn. + const stderrDedup = new Map(); + const { child, lastSpawnSummary } = await launchGatewayProcess({ port: this.status.port, launchContext, @@ -715,6 +718,18 @@ export class GatewayManager extends EventEmitter { recordGatewayStartupStderrLine(this.recentStartupStderrLines, line); const classified = classifyGatewayStderrMessage(line); if (classified.level === 'drop') return; + + // Dedup: suppress identical stderr lines after the first occurrence. + const count = (stderrDedup.get(classified.normalized) ?? 0) + 1; + stderrDedup.set(classified.normalized, count); + if (count > 1) { + // Log a summary every 50 duplicates to stay visible without flooding. + if (count % 50 === 0) { + logger.debug(`[Gateway stderr] (suppressed ${count} repeats) ${classified.normalized}`); + } + return; + } + if (classified.level === 'debug') { logger.debug(`[Gateway stderr] ${classified.normalized}`); return; diff --git a/electron/gateway/startup-stderr.ts b/electron/gateway/startup-stderr.ts index fd7806937..4be0a4615 100644 --- a/electron/gateway/startup-stderr.ts +++ b/electron/gateway/startup-stderr.ts @@ -30,6 +30,9 @@ export function classifyGatewayStderrMessage(message: string): GatewayStderrClas if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg }; if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg }; + // Gateway config warnings (e.g. stale plugin entries) are informational, not actionable. + if (msg.includes('Config warnings:')) return { level: 'debug', normalized: msg }; + // Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless. if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) { return { level: 'debug', normalized: msg }; diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index a349611db..9a7a2a18e 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -22,6 +22,7 @@ import { const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); const WECOM_PLUGIN_ID = 'wecom'; +const QQBOT_PLUGIN_ID = 'openclaw-qqbot'; const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE; const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const; const DEFAULT_ACCOUNT_ID = 'default'; @@ -465,14 +466,33 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: if (channelType === 'qqbot') { if (!currentConfig.plugins) { - currentConfig.plugins = {}; - } - currentConfig.plugins.enabled = true; - const allow = Array.isArray(currentConfig.plugins.allow) - ? currentConfig.plugins.allow as string[] - : []; - if (!allow.includes('qqbot')) { - currentConfig.plugins.allow = [...allow, 'qqbot']; + currentConfig.plugins = { + allow: [QQBOT_PLUGIN_ID], + enabled: true, + entries: { + [QQBOT_PLUGIN_ID]: { enabled: true } + } + }; + } else { + currentConfig.plugins.enabled = true; + const allow: string[] = Array.isArray(currentConfig.plugins.allow) + ? (currentConfig.plugins.allow as string[]) + : []; + // Normalize: remove bare 'qqbot' and ensure the actual manifest ID is present. + const normalizedAllow = allow.filter((pluginId) => pluginId !== 'qqbot'); + if (!normalizedAllow.includes(QQBOT_PLUGIN_ID)) { + currentConfig.plugins.allow = [...normalizedAllow, QQBOT_PLUGIN_ID]; + } else if (normalizedAllow.length !== allow.length) { + currentConfig.plugins.allow = normalizedAllow; + } + + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + if (!currentConfig.plugins.entries[QQBOT_PLUGIN_ID]) { + currentConfig.plugins.entries[QQBOT_PLUGIN_ID] = {}; + } + currentConfig.plugins.entries[QQBOT_PLUGIN_ID].enabled = true; } } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 2f1dc727b..1f0e1aa53 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1436,6 +1436,35 @@ export async function sanitizeOpenClawConfig(): Promise { modified = true; } + // ── qqbot → openclaw-qqbot migration ──────────────────────── + // The qqbot npm package (@tencent-connect/openclaw-qqbot) declares + // id="openclaw-qqbot" in its manifest, but older ClawX versions + // wrote bare "qqbot" into plugins.allow. Migrate to the manifest ID + // so the Gateway can resolve the plugin correctly. + const LEGACY_QQBOT_ID = 'qqbot'; + const NEW_QQBOT_ID = 'openclaw-qqbot'; + if (Array.isArray(pluginsObj.allow)) { + const allowArr = pluginsObj.allow as string[]; + const legacyIdx = allowArr.indexOf(LEGACY_QQBOT_ID); + if (legacyIdx !== -1) { + if (!allowArr.includes(NEW_QQBOT_ID)) { + allowArr[legacyIdx] = NEW_QQBOT_ID; + } else { + allowArr.splice(legacyIdx, 1); + } + console.log(`[sanitize] Migrated plugins.allow: ${LEGACY_QQBOT_ID} → ${NEW_QQBOT_ID}`); + modified = true; + } + } + if (pEntries?.[LEGACY_QQBOT_ID]) { + if (!pEntries[NEW_QQBOT_ID]) { + pEntries[NEW_QQBOT_ID] = pEntries[LEGACY_QQBOT_ID]; + } + delete pEntries[LEGACY_QQBOT_ID]; + console.log(`[sanitize] Migrated plugins.entries: ${LEGACY_QQBOT_ID} → ${NEW_QQBOT_ID}`); + modified = true; + } + // ── Remove bare 'feishu' when canonical feishu plugin is present ── // The Gateway binary automatically adds bare 'feishu' to plugins.allow // because the official plugin registers the 'feishu' channel. diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index 2cd43f344..9cc542647 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -204,7 +204,7 @@ describe('WeCom plugin configuration', () => { const config = await readOpenClawJson(); const plugins = config.plugins as { allow: string[] }; - expect(plugins.allow).toEqual(expect.arrayContaining(['qqbot', 'discord', 'whatsapp'])); + expect(plugins.allow).toEqual(expect.arrayContaining(['openclaw-qqbot', 'discord', 'whatsapp'])); }); });