evolclaw 3.1.10 → 3.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.11 (2026-06-04)
4
+
5
+ ### Improvements
6
+
7
+ - **统一入站图片识别** — 新增 `bufferToInboundImage()`,AUN/飞书/微信共用 magic-bytes → 元数据 → 后缀判定链,消除各通道重复实现的 `detectImageMime`
8
+ - **AUN 附件处理重构** — 抽出 `processAttachments()`,私聊/群聊统一处理;图片注入视觉通道不再追加冗余 `[文件: …]` 文本行
9
+ - **入站去抖** — bridge 默认 inbound debounce 2s → 0(消息即处理)
10
+ - **Windows npm 安装** — `npmInstallGlobal` 改用 `cmd /c`,消除 Node 22 的 `shell:true` 弃用警告
11
+
12
+ ### Bug Fixes
13
+
14
+ - **截断图片缓冲** — `validateImage` 捕获 image-type 的 EndOfStreamError,避免短/截断 buffer 抛异常
15
+ - **evolclaw-web 退出容错** — `evolclaw web` 容忍前台进程信号终止(SIGINT/SIGKILL)/非零退出,不再向用户打印堆栈
16
+
3
17
  ## v3.1.10 (2026-06-04)
4
18
 
5
19
  ### Bug Fixes
package/README.md CHANGED
@@ -13,7 +13,6 @@ EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex
13
13
  - 👥 **双模式会话**:多用户私聊会话隔离,群聊会话共享,满足不同协作场景
14
14
  - 🌐 **多渠道接入**:Channel Adapter 模式,飞书 + 微信 + 钉钉 + QQ频道 + 企业微信 + AUN 网络
15
15
  - 🤖 **Agent 间互联**:通过 AUN 网络,你的 Agent 可被其他 Agent 发现和调用
16
- - 🖥️ **终端 TUI 客户端**:`evolclaw tui` 直接在终端与远程 Agent 对话,无需 IM
17
16
  - 🔐 **分层权限**:三级权限体系(user/admin/owner),多用户安全隔离
18
17
  - 🛠️ **Agent 自管理**:Agent 可通过 CLI 命令自主管理运行时(查看状态、切换模型、调整配置等)
19
18
  - 📦 **项目搬家**:`evolclaw mv` 一键迁移项目目录,保留 Claude/Codex/EvolClaw 全部会话历史
@@ -29,7 +28,6 @@ EvolClaw 是一个轻量级 AI Agent 网关系统。它为 Claude Code / Codex
29
28
 
30
29
  - **通勤路上**:手机打开飞书,继续昨晚的代码 review,到公司无缝切回终端
31
30
  - **会议间隙**:微信快速问一句「这个接口的返回格式是什么」,Agent 直接查代码回复
32
- - **终端直连**:`evolclaw tui` 在任意终端直接与远程 Agent 对话,无需打开 IM
33
31
  - **Agent 协作**:通过 AUN 网络,让你的 Agent 被其他 Agent 调用,组成分布式协作
34
32
  - **外出离开工位**:不带电脑也能通过 IM 给 Agent 下达任务,回来看结果
35
33
  - **团队协作**:拉个飞书群,成员共享同一个 Agent 会话,一起讨论和调试
@@ -170,7 +168,6 @@ evolclaw stop # 停止服务
170
168
  evolclaw restart # 重启服务
171
169
  evolclaw status # 查看状态
172
170
  evolclaw logs # 查看日志(tail -f)
173
- evolclaw tui # 启动 AUN TUI 终端客户端
174
171
  evolclaw agent # 管理 EvolAgent(list / show / new / reload)
175
172
  evolclaw mv <old> <new> # 项目搬家(保留全部会话)
176
173
  evolclaw diagnose # 诊断启动环境
@@ -281,7 +278,6 @@ evolclaw/
281
278
  ## TODO
282
279
 
283
280
  - [x] AUN Mesh 网络通道接入
284
- - [x] TUI 终端客户端(`evolclaw tui`)
285
281
  - [x] 项目搬家工具(`evolclaw mv`)
286
282
  - [x] 手动授权支持(文本回复 + 飞书卡片)
287
283
  - [x] 自动授权可配置(自动放行/自动拒绝)
@@ -7,7 +7,7 @@ import { logger, localTimestamp } from '../utils/logger.js';
7
7
  import { LogWriter } from '../utils/log-writer.js';
8
8
  import { normalizeChannelInstances, getChannelShowActivities } from '../utils/channel-helpers.js';
9
9
  import { resolvePaths, getPackageRoot, agentMdPath as agentMdPathFn, agentDir as agentDirPath, resolveRoot } from '../paths.js';
10
- import { saveToUploads, sanitizeFileName } from '../utils/media-cache.js';
10
+ import { saveToUploads, sanitizeFileName, bufferToInboundImage } from '../utils/media-cache.js';
11
11
  import { appendAidEvent } from '../utils/instance-registry.js';
12
12
  import { appendMessageLog, buildOutboundEntry } from '../core/message/message-log.js';
13
13
  import { chatDirPath } from '../core/session/session-fs-store.js';
@@ -848,43 +848,55 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
848
848
  }
849
849
  // ── Event handlers ──────────────────────────────────────────
850
850
  /**
851
- * 判断附件是否为图片,返回 MIME 类型(非图片返回空)。
852
- * 多重检测:附件元数据字段 → 文件名后缀 → 文件 magic bytes。
851
+ * 统一处理入站附件:下载 图片识别+base64 注入 → 拼接文本。
852
+ *
853
+ * - 图片:base64 注入视觉通道(不再追加 [文件: …] 文本行,避免冗余)
854
+ * - 非图片:拼 [文件: name → path],并提示用 Read 工具读取
855
+ *
856
+ * @param baseText 已解析的正文(私聊 text / 群聊 strippedText)
857
+ * @param channelId 下载归属(私聊 fromAid / 群聊 groupId)
858
+ * @param preCollected 已收集的附件(群聊路径会提前 collect,避免重复)
853
859
  */
854
- detectImageMime(att, filePath) {
855
- const extToMime = {
856
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
857
- '.gif': 'image/gif', '.webp': 'image/webp',
858
- };
859
- // 1. 附件元数据字段(content_type / mime_type / mimeType)
860
- const metaCt = (att?.content_type || att?.mime_type || att?.mimeType || '');
861
- if (typeof metaCt === 'string' && metaCt.startsWith('image/'))
862
- return metaCt;
863
- // 2. 文件名后缀
864
- const name = (att?.filename || att?.object_key || filePath || '').toLowerCase();
865
- for (const [ext, mime] of Object.entries(extToMime)) {
866
- if (name.endsWith(ext))
867
- return mime;
868
- }
869
- // 3. magic bytes
870
- try {
871
- const { openSync, readSync, closeSync } = require('node:fs');
872
- const fd = openSync(filePath, 'r');
873
- const head = Buffer.alloc(12);
874
- readSync(fd, head, 0, 12, 0);
875
- closeSync(fd);
876
- if (head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47)
877
- return 'image/png';
878
- if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)
879
- return 'image/jpeg';
880
- if (head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46)
881
- return 'image/gif';
882
- if (head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 &&
883
- head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50)
884
- return 'image/webp';
885
- }
886
- catch { /* not readable, skip */ }
887
- return '';
860
+ async processAttachments(payload, baseText, channelId, preCollected) {
861
+ const rawAttachments = preCollected ?? this.collectAllAttachments(payload);
862
+ const images = [];
863
+ let finalText = baseText;
864
+ if (rawAttachments.length === 0 || !this.client) {
865
+ return { finalText, images };
866
+ }
867
+ const fileParts = [];
868
+ for (const att of rawAttachments) {
869
+ const filePath = await this.downloadAttachment(att, channelId);
870
+ if (!filePath)
871
+ continue;
872
+ const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
873
+ let img = null;
874
+ try {
875
+ const { readFileSync } = await import('node:fs');
876
+ img = await bufferToInboundImage(readFileSync(filePath), {
877
+ contentType: att.content_type, mimeType: att.mime_type, filename: name,
878
+ });
879
+ }
880
+ catch { /* read failed, treat as non-image file */ }
881
+ if (img) {
882
+ images.push(img);
883
+ // 图片已注入视觉通道,不追加 [文件: …] 文本行
884
+ }
885
+ else {
886
+ fileParts.push(`[文件: ${name} ${filePath}]`);
887
+ }
888
+ }
889
+ const parts = [];
890
+ if (baseText)
891
+ parts.push(baseText);
892
+ if (fileParts.length > 0) {
893
+ parts.push(...fileParts);
894
+ parts.push('请使用 Read 工具读取文件内容。');
895
+ }
896
+ if (parts.length > 0)
897
+ finalText = parts.join('\n\n');
898
+ logger.info(`${this.logPrefix()} [attachments] count=${rawAttachments.length} images=${images.length} files=${fileParts.length}`);
899
+ return { finalText, images };
888
900
  }
889
901
  async downloadAttachment(att, channelId) {
890
902
  const ownerAid = att.owner_aid || this._aid || '';
@@ -974,37 +986,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
974
986
  mentions.push(this._aid);
975
987
  }
976
988
  // Process attachments (顶层 + 嵌套在 merge.items / quote.quote 中的)
977
- const rawAttachments = this.collectAllAttachments(payload);
978
- let finalText = text;
979
- const inboundImages = [];
980
- if (rawAttachments.length > 0 && this.client) {
981
- const fileParts = [];
982
- for (const att of rawAttachments) {
983
- const filePath = await this.downloadAttachment(att, fromAid);
984
- if (filePath) {
985
- const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
986
- const mime = this.detectImageMime(att, filePath);
987
- if (mime) {
988
- try {
989
- const { readFileSync } = await import('node:fs');
990
- inboundImages.push({ data: readFileSync(filePath).toString('base64'), mimeType: mime });
991
- }
992
- catch { /* fallback to file path */ }
993
- }
994
- fileParts.push(`[文件: ${name} → ${filePath}]`);
995
- }
996
- }
997
- if (fileParts.length > 0) {
998
- const parts = [];
999
- if (text)
1000
- parts.push(text);
1001
- parts.push(...fileParts);
1002
- if (inboundImages.length === 0)
1003
- parts.push('请使用 Read 工具读取文件内容。');
1004
- finalText = parts.join('\n\n');
1005
- }
1006
- logger.info(`${this.logPrefix()} [img-debug] private attachments=${rawAttachments.length} images=${inboundImages.length}`);
1007
- }
989
+ const { finalText, images: inboundImages } = await this.processAttachments(payload, text, fromAid);
1008
990
  // 私聊 channelId = 对端 AID(不再读 payload.chat_id 含 device 三段式)
1009
991
  // device_id 仅 SDK 内部多实例去重用,evolclaw session 层面跨端共享会话
1010
992
  const chatId = fromAid;
@@ -1210,35 +1192,7 @@ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
1210
1192
  ? ['all']
1211
1193
  : mentionedSelf && this._aid ? [this._aid] : [];
1212
1194
  // Process attachments
1213
- let finalText = strippedText;
1214
- const inboundImages = [];
1215
- if (hasAttachments && this.client) {
1216
- const fileParts = [];
1217
- for (const att of rawAttachments) {
1218
- const filePath = await this.downloadAttachment(att, groupId);
1219
- if (filePath) {
1220
- const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
1221
- const mime = this.detectImageMime(att, filePath);
1222
- if (mime) {
1223
- try {
1224
- const { readFileSync } = await import('node:fs');
1225
- inboundImages.push({ data: readFileSync(filePath).toString('base64'), mimeType: mime });
1226
- }
1227
- catch { /* fallback to file path */ }
1228
- }
1229
- fileParts.push(`[文件: ${name} → ${filePath}]`);
1230
- }
1231
- }
1232
- if (fileParts.length > 0) {
1233
- const parts = [];
1234
- if (strippedText)
1235
- parts.push(strippedText);
1236
- parts.push(...fileParts);
1237
- if (inboundImages.length === 0)
1238
- parts.push('请使用 Read 工具读取文件内容。');
1239
- finalText = parts.join('\n\n');
1240
- }
1241
- }
1195
+ const { finalText, images: inboundImages } = await this.processAttachments(payload, strippedText, groupId, rawAttachments);
1242
1196
  const selfAgentDir = path.join(resolvePaths().agentsDir, this.config.aid);
1243
1197
  const peerIdentity = await PeerIdentityCache.resolve('aun', senderAid, selfAgentDir, this.store, false);
1244
1198
  const shortAid = this.getShortAid(senderAid);
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import imageType from 'image-type';
4
- import { sanitizeFileName, saveToUploads, validateImage } from '../utils/media-cache.js';
4
+ import { sanitizeFileName, saveToUploads, bufferToInboundImage } from '../utils/media-cache.js';
5
5
  import { logger } from '../utils/logger.js';
6
6
  import { hasRichContent, renderAllRichContent, checkDependencies } from '../utils/rich-content-renderer.js';
7
7
  import { formatItemsAsText } from '../core/message/items-formatter.js';
@@ -789,18 +789,14 @@ export class FeishuChannel {
789
789
  logger.warn('[Feishu] Empty response from image download');
790
790
  return null;
791
791
  }
792
- // 统一图片验证(类型白名单 + 大小限制)
793
- const result = await validateImage(buffer);
794
- if (result.mime === null) {
795
- logger.warn(`[Feishu] Image validation failed: ${result.reason}`);
792
+ // 统一图片识别 + base64 注入(magic bytes → 元数据 → 后缀)
793
+ const img = await bufferToInboundImage(buffer);
794
+ if (!img) {
795
+ logger.warn('[Feishu] Image validation failed (not a supported image)');
796
796
  return null;
797
797
  }
798
- const base64Data = buffer.toString('base64');
799
- logger.debug('[Feishu] Image downloaded successfully, type:', result.mime, 'size:', base64Data.length);
800
- return {
801
- data: base64Data,
802
- mimeType: result.mime
803
- };
798
+ logger.debug('[Feishu] Image downloaded successfully, type:', img.mimeType, 'size:', img.data.length);
799
+ return img;
804
800
  }
805
801
  logger.error('[Feishu] Image download failed: no valid method');
806
802
  return null;
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { resolvePaths } from '../paths.js';
5
5
  import { logger } from '../utils/logger.js';
6
- import { sanitizeFileName, saveToUploads, safeFetch } from '../utils/media-cache.js';
6
+ import { sanitizeFileName, saveToUploads, safeFetch, bufferToInboundImage } from '../utils/media-cache.js';
7
7
  import { markdownToPlainText } from '../utils/rich-content-renderer.js';
8
8
  import { formatItemsAsText } from '../core/message/items-formatter.js';
9
9
  const CHANNEL_VERSION = '1.0.0';
@@ -527,7 +527,13 @@ export class WechatChannel {
527
527
  try {
528
528
  if (item.type === MSG_ITEM_IMAGE && item.image_item?.media) {
529
529
  const buf = await downloadMedia(item.image_item.media, item.image_item.aeskey);
530
- images.push({ data: buf.toString('base64'), mimeType: 'image/jpeg' });
530
+ // 统一图片识别:magic bytes 优先正确区分 jpeg/png/gif/webp;
531
+ // 检测失败时回退到 image/jpeg(微信入站图片实际均为 jpeg,保留历史行为)。
532
+ const img = await bufferToInboundImage(buf, { contentType: 'image/jpeg' });
533
+ if (img)
534
+ images.push(img);
535
+ else
536
+ logger.warn('[WeChat] Image validation failed (not a supported image)');
531
537
  }
532
538
  else if (item.type === MSG_ITEM_FILE && item.file_item?.media) {
533
539
  const buf = await downloadMedia(item.file_item.media);
package/dist/cli/index.js CHANGED
@@ -2127,13 +2127,27 @@ async function cmdWatchWeb() {
2127
2127
  }
2128
2128
  // Node 18.20+/20+/22 起,execFile 拒绝直接 spawn .cmd/.bat(CVE-2024-27980),必须 shell:true。
2129
2129
  // shell 模式下含空格的路径/参数需加引号。
2130
+ // evolclaw-web 是前台长驻服务:用户 Ctrl-C、被新实例的单实例保护 SIGKILL、或正常退出,
2131
+ // execFileSync 都会抛错(signal 终止时 status=null)。这些都是正常生命周期,
2132
+ // 不应让父进程 evolclaw 带堆栈崩溃。只有真正的非信号失败才提示。
2130
2133
  const isBatch = /\.(cmd|bat)$/i.test(exe);
2131
- if (isBatch) {
2132
- const q = (s) => `"${s}"`;
2133
- execFileSync(q(exe), ['--home', q(home)], { stdio: 'inherit', shell: true });
2134
+ try {
2135
+ if (isBatch) {
2136
+ const q = (s) => `"${s}"`;
2137
+ execFileSync(q(exe), ['--home', q(home)], { stdio: 'inherit', shell: true });
2138
+ }
2139
+ else {
2140
+ execFileSync(exe, ['--home', home], { stdio: 'inherit' });
2141
+ }
2134
2142
  }
2135
- else {
2136
- execFileSync(exe, ['--home', home], { stdio: 'inherit' });
2143
+ catch (e) {
2144
+ // 信号终止(SIGINT/SIGTERM/SIGKILL)= 用户主动退出或被新实例顶替,静默返回
2145
+ if (e?.signal)
2146
+ return;
2147
+ // 退出码非 0 但非信号:可能是启动失败,提示但不崩溃
2148
+ if (typeof e?.status === 'number' && e.status !== 0) {
2149
+ process.stderr.write(`⚠ evolclaw-web 退出(code ${e.status})\n`);
2150
+ }
2137
2151
  }
2138
2152
  }
2139
2153
  async function cmdRestartMonitor() {
@@ -85,7 +85,14 @@ export async function validateImage(buffer, opts) {
85
85
  }
86
86
  // 动态导入 image-type(ESM only)
87
87
  const { default: imageType } = await import('image-type');
88
- const type = await imageType(buffer);
88
+ // image-type 对极短/截断的 buffer 会抛 EndOfStreamError,捕获后按「无法识别」处理。
89
+ let type;
90
+ try {
91
+ type = await imageType(buffer);
92
+ }
93
+ catch (e) {
94
+ return { mime: null, reason: `Image type detection error: ${e.message}` };
95
+ }
89
96
  if (!type) {
90
97
  return { mime: null, reason: 'Unable to detect image type' };
91
98
  }
@@ -94,6 +101,38 @@ export async function validateImage(buffer, opts) {
94
101
  }
95
102
  return { mime: type.mime };
96
103
  }
104
+ /**
105
+ * 把已下载的附件 Buffer 转成入站图片条目(供 baseagent 视觉通道)。
106
+ *
107
+ * 适用于所有「需要先下载再注入」的通道(AUN ticket 下载、未来其它 CDN 下载等)。
108
+ * 飞书/微信已有各自的 SDK 下载路径,可按需复用本函数统一 MIME 判定。
109
+ *
110
+ * 判定优先级:
111
+ * 1. magic bytes(image-type 库,最可靠,同时做大小/白名单校验)
112
+ * 2. 元数据 MIME 字段(att.content_type / mime_type / mimeType)
113
+ * 3. 文件名后缀
114
+ *
115
+ * @returns 是图片返回 InboundImage;非图片或校验失败返回 null。
116
+ */
117
+ export async function bufferToInboundImage(buffer, hints) {
118
+ // 1. magic bytes(含大小 + 白名单校验;validateImage 已吞掉 image-type 的异常)
119
+ const validated = await validateImage(buffer);
120
+ if (validated.mime) {
121
+ return { data: buffer.toString('base64'), mimeType: validated.mime };
122
+ }
123
+ // 2/3. magic bytes 未识别时,回退到元数据字段 / 文件名后缀
124
+ // (仍受 image 白名单约束,避免把任意文件当图片注入)
125
+ const metaCt = hints?.contentType || hints?.mimeType || '';
126
+ const byMeta = typeof metaCt === 'string' && ALLOWED_IMAGE_MIMES.has(metaCt) ? metaCt : '';
127
+ const byExt = hints?.filename
128
+ ? (ALLOWED_IMAGE_MIMES.has(guessMime(hints.filename)) ? guessMime(hints.filename) : '')
129
+ : '';
130
+ const fallback = byMeta || byExt;
131
+ if (fallback && buffer.length > 0 && buffer.length <= DEFAULT_MAX_IMAGE_SIZE) {
132
+ return { data: buffer.toString('base64'), mimeType: fallback };
133
+ }
134
+ return null;
135
+ }
97
136
  /**
98
137
  * 保存 Buffer 到 uploads 目录
99
138
  * - 自动创建目录
@@ -15,10 +15,20 @@ import { isWindows } from './cross-platform.js';
15
15
  const execFileAsync = promisify(execFile);
16
16
  // ── npm install -g (shared) ────────────────────────────────────────────────
17
17
  export async function npmInstallGlobal(pkg) {
18
- const npmCmd = isWindows ? 'npm.cmd' : 'npm';
19
- const execOpts = { timeout: 180000, shell: isWindows };
18
+ let cmd;
19
+ let args;
20
+ // Windows: run via cmd /c to avoid shell:true deprecation warning on Node 22.
21
+ // Unix: npm directly, no shell needed.
22
+ if (isWindows) {
23
+ cmd = 'cmd';
24
+ args = ['/c', 'npm', 'install', '-g', pkg];
25
+ }
26
+ else {
27
+ cmd = 'npm';
28
+ args = ['install', '-g', pkg];
29
+ }
20
30
  try {
21
- await execFileAsync(npmCmd, ['install', '-g', pkg], execOpts);
31
+ await execFileAsync(cmd, args, { timeout: 180000 });
22
32
  }
23
33
  catch (e) {
24
34
  if (e.stderr?.includes('EACCES') || e.message?.includes('EACCES')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "3.1.10",
3
+ "version": "3.1.11",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",