From ee797a941fb0519ba71788936dc685a0a8347061 Mon Sep 17 00:00:00 2001 From: PerryLink Date: Mon, 19 Oct 2026 12:12:17 +0800 Subject: [PATCH 1/2] fix(security): narrow WeChat remote-control surface; ci: add Linux precheck Security: narrow the remote-control surface exposed through WeChat and close the 0.10.0-related paths (audit + secure-fs helpers, config/state validation, bridge/slash hardening). CI: add a Linux local precheck script so Windows-green/CI-red regressions are caught before push. --- CHANGELOG.md | 27 + README.md | 39 +- scripts/ci-local.sh | 62 ++ src/adapter/inbound.ts | 10 +- src/bridge/bridge.ts | 1044 ++++++++++++++++++++++++++++- src/bridge/slash.ts | 27 +- src/client.js | 57 +- src/config-store.ts | 76 ++- src/config.ts | 69 ++ src/dsh/types.ts | 9 +- src/index.ts | 17 +- src/state.ts | 14 +- src/utils/audit.ts | 126 ++++ src/utils/redact.ts | 19 + src/utils/secure-fs.ts | 76 +++ src/weixin/auth.ts | 8 +- src/weixin/monitor.ts | 7 +- tests/bridge-core.test.ts | 28 +- tests/busy-enter.test.ts | 4 + tests/client-panel-wiring.test.ts | 24 +- tests/config-store.test.ts | 71 ++ tests/corrupt-session.test.ts | 5 + tests/delivery-hardening.test.ts | 367 ++++++++++ tests/forwarding.test.ts | 3 + tests/issue-9-inbound.test.ts | 5 +- tests/merge-window.test.ts | 451 +++++++++++++ tests/native-command.test.ts | 3 + tests/process-forwarding.test.ts | 4 +- tests/push-governance.test.ts | 15 +- tests/relogin-peer.test.ts | 3 + tests/security-hardening.test.ts | 515 ++++++++++++++ tests/send-wechat.test.ts | 16 +- tests/surface-prompt.test.ts | 3 + 33 files changed, 3128 insertions(+), 76 deletions(-) create mode 100644 scripts/ci-local.sh create mode 100644 src/utils/audit.ts create mode 100644 src/utils/secure-fs.ts create mode 100644 tests/delivery-hardening.test.ts create mode 100644 tests/merge-window.test.ts create mode 100644 tests/security-hardening.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 594ae46..44a87b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ ## [Unreleased] +### Added + +- **`scripts/ci-local.sh` + README 开发须知:Windows 上"全绿"不等于 CI 绿。** CI 是 `ubuntu-latest`,而 PR #11 里一条断言把 Windows 形状的字面量 `C:\home\.dsh` 同时当输入和期望前缀——实现会先对该根目录做 `path.resolve`,在 POSIX 上它是**相对路径**、会解析到 cwd 之下,于是 `startsWith("C:\home\.dsh/sessions")` 恒为 false:本地必绿、CI 必红(该 PR 恰好从未跑过 CI,直到下一次推送才暴露,并因此让 `ci-green` 门禁拦住了 0.11.0 的发布)。脚本在与 CI 完全一致的环境里(复制到本机临时目录后 `npm ci && tsc --noEmit && vitest run`)复现整个流程,且不会用 Linux 版 esbuild/rollup 覆盖工作副本的 `node_modules`。 + +- **打字合并 / inbound merge window(`mergeEnabled`,⚠️ 默认开启 = 默认行为变更)** —— 此前每条微信消息都是一次独立 turn,一句话分五段发就要烧掉五轮。现在同一聊天的裸文本进入缓冲区,静默窗口(`mergeSilenceMs`,默认 3000ms)到点或条数上限(`mergeMaxMsgs`,默认 10)到达时**合并成一条 prompt** 投递。安全规则是这次实现的重点,因为它们决定了这个功能是「省轮次」还是「卡住通道」: + - **乱序的旧消息不延长计时器**——消息自带的 `create_time_ms` 早于已缓冲的最新时间戳时只追加、不重置静默窗口,否则一条迟到重放就能把批次一直挂住;更新的消息才会顺延。 + - **条数硬上限**(默认 10)作为防御性边界,缓冲区永远不会超过它。 + - **强制放行**(`mergeForcedReleaseMs`,默认 180000 = 3 分钟):无论静默窗口、`..` 暂挂、还是上一批仍在投递,到点一定发出并记日志——「宁可两句话一轮,也不让通道卡死」。 + - **在途批次不吞消息**:上一批还没投递完时到达的消息进入**新**批次,只为保序等待上一批,且等待上限同样是强制放行时间;超过就记日志后照发。 + - **非文本不合并**:图片/文件/视频/语音(无转写)到达时先把已输入的文字作为**一轮**发出去,再把自己作为**一轮**交给 agent,顺序与用户输入一致。 + - 后缀开关 `mergeSuffixTokens`(默认开):正文末尾 `..` = 还要接着说(暂不发出),`!!` = 立刻提交;两者会从正文里去掉,且「整条消息只有后缀、又没有待合并内容」时按普通文本处理(不会让消息消失)。裸 `!!` 在有待合并内容时是纯控制符,不会污染正文。 + - 开关:微信 `/merge on|off|status`(别名 `/mg`,与 `/silent` 同款接线:parser、dispatcher、卡片 bypass、`LOCAL_COMMAND_NAMES`、`/help`、`config.json` 持久化),设置页「打字合并」同一个值;`/merge off` 会先把暂存的消息发出去再关闭(那是用户自己说的话)。`/status` 显示窗口/上限/暂存条数。 +- **权限提权闸门(S1)** —— 微信 `/perm default <预设>` 此前直接 `settings.update("permission", {defaultPreset})`:这是**全局且持久**的写入,对所有新会话生效(与微信无关),一条聊天消息就能把整机默认权限放宽到 `danger-full-access`。现在默认拒绝**提权**写入:解析出的 `permissionPresets.resolve(name)` 沙箱为 `danger-full-access` **或**审批策略为 `never`(含只降审批的自定义预设)即视为提权,回复里明确要求在 DSH 界面「设置 → 权限」操作。不放宽的写入照旧;**会话级 `/perm switch` 不受影响**(不持久)。新开关 `allowRemotePermissionEscalation`(默认 `false`)可显式恢复旧行为。无法解析的预设按提权处理。 +- **`send_wechat` 文件边界(S2)** —— 此前 `file_path` 是任意绝对路径:这个工具因此是一个**任意文件读取原语**(任何 agent、以及任何能说服 agent 的东西都能把 `~/.ssh/id_rsa` 发到微信)。现在只允许**调用会话**工作目录或已注册工作区内的文件(两侧都按 `fs.realpath` 归一后比较,`..` 与符号链接都逃不出去),并有字节上限 `maxOutgoingFileBytes`(默认 20 MiB,`stat` 阶段即拒绝,不会读进内存)。边界按**实际发起调用的会话**求值,而不是按「未绑定会话回退到首个微信用户」解析出的收件人——否则任意会话都能借回退读取别人的工作区。新开关 `allowAnyFilePath`(默认 `false`)恢复旧行为;工具描述同步写明边界。缓存队列里的文件在冲刷时也会重新校验大小。 +- **入站白名单升级播种(S3)** —— `allowedUsers` 默认 `[]` 意味着「首个入站者成为绑定对端」,于是**老部署升级后第一个给 bot 发消息的陌生人就能接管它**。现在:`config.json` 里从未写过该键、且 `state.json` 已有绑定用户时,启动把该绑定用户写进 `allowedUsers` 并持久化,同时在微信里**提示一次**、并在 `/status` 增加「入站白名单」行(**显示你自己的 user id**,不必再去别处找)。全新安装(没有绑定用户)**不播种**,保持 first-contact 行为,绝不会把新用户锁在门外;显式存过 `[]`(= 决定开放)也永不覆盖。 +- **文件系统与日志卫生 + 审计(S4)** —— + - `~/.dsh-wechat` 与其下所有文件(`auth/token.json`、`state.json`、`config.json`、`sync-buf.json`、`audit.jsonl`)显式以 `0700` / `0600` 创建(创建时给 mode **并且** `chmod` 一次,否则已存在的文件会保留旧权限)。⚠️ Windows 忽略 mode 参数,实际依赖 `%USERPROFILE%` 继承的 ACL —— 这一点写进了 README。 + - 入站日志行此前打印**完整**发送者 id 与明文正文预览;现在 id 走 `maskUserId`(保留头 2 + 尾 2 便于关联),预览过一次 `maskSecrets`。允许名单拒绝日志同样只记脱敏 id。 + - 新增追加式审计 `~/.dsh-wechat/audit.jsonl`(JSONL,`0600`):入站(脱敏 id + 字符数,**不含正文**)、slash 命令名、审批/提问决定、`send_wechat` 文件路径、每次出站发送的 kind/结果/窗口计数。按 1 MiB 上限轮转为 `audit.jsonl.1`(只留一份历史)并记日志;写失败**绝不影响**消息路径。 + +### Fixed + +- **卡片被标记为「已推送」但从未发出(S5)** —— 六处 `markCardPushed` 都在 `sendReply` **之前**调用,而机器人登出/`-14` 放弃时 `sendReply` 是**丢弃**(不排队)的:卡片因此被标记成已推送、`isCardPushed` 又挡住重推,直到 30 分钟卡片超时才解封——用户根本看不到卡。现在先确认可达(`botReady()` 且未 give-up)再占用一次性标记,投递抛错时**回滚标记**(与 `notifyCachePendingLocked` 同款),并把「未标记、稍后可重推」记进日志。 +- **出站串行槽既不会报错也不会超时(R1)** —— `serializeOutbound` 没有重入断言、也没有上界:锁内再调用公共入口会把该操作排到**自己**后面,永久卡死整条出站链路(这一类缺陷已经真实发生过一次,且是致命的)。现在:① 用 `AsyncLocalStorage` **精确**识别「同一条调用链内重入」(只是恰好同时到达的并发调用仍属正常排队,不会被误判),命中即**立刻抛错并记日志**,修复方式唯一——改调 `*Locked` 版本;② 等待上一个操作**有上界**(30s),超时记日志后照常发出,牺牲极端情况下的严格顺序换取活性。空闲路径仍然零定时器、零额外跳转。 +- **四个只涨不降的内存表(R2)** —— `lastAssistantTextBySession`(存整条助手正文,上限 64 会话)、`lastActivityBySession`(上限 512 会话)、`wechatMessageIds`(一次性标记,10 分钟 TTL **加** 512 条硬上限;此前只有 echo 到达才会删,echo 永不到达就永久驻留)、`modelOverrides`(上限 128 会话)现在都有确定性淘汰(插入序 FIFO,淘汰即记日志)。它们都是**缓存**:淘汰只会让显示退回默认值,不会丢队列里的任何消息。 +- **重启静默丢弃排队出站(R3)** —— 运行时队列硬上限是 400 条,而 `state.json` 重新加载只保留最后 100 条:一次重启就会**静默丢掉第 101–400 条**(正好是通知、结论、工具推送这些最不该丢的内容)。加载上限现与运行时硬上限对齐(400),重启完整恢复持久化的有界集合。 + ## [0.11.0] - 2026-10-13 > 本版并入上游 v0.9.7 → v0.9.11(流式帧处理、执行过程分组、官方 Markdown 语义、FIFO 发送顺序、入站重放去重、可取消长轮询、`describe()` 设置读取),在其之上加入结论模式、0.2.x 适配修复与投递可信度改进。 diff --git a/README.md b/README.md index 7e29a79..daa9b7d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ DSH 设置页内扫码登录与连接配置。以静态 Cordis 插件交付, - **发送** — 微信文本/图片/文件/语音消息 → DSH agent(媒体自动下载解密到 `~/.dsh-wechat/tempfile/`,本地路径作为附件注入) +- **打字合并(默认开启,⚠️ 改变了旧行为)** — 连续几条微信消息在静默窗口(默认 3 秒)内到达时**合并成一轮**,不再一句话分五段就烧掉五轮;条数上限(默认 10)或上限时间(默认 3 分钟,到点必发)会强制释放。消息末尾 `..` = 还要接着说(暂不发出),`!!` = 立刻提交(两者开启时会被去掉)。**图片/文件不参与合并**:它们会先把已经输入的文字作为一轮发出去,再自己作为一轮进入 agent,顺序不变。微信端 `/merge on|off|status` 切换,设置页同款开关;`mergeSilenceMs: 0` 或 `/merge off` 恢复「每条消息一轮」 - **接收** — agent 正文独立发送;正文之间的思考与工具调用/结果合并成一条「执行过程」,按桌面端的分组方式阅读。过程在收到下一段正文的第一个有效流式片段时立即推送,正文接收完成后独立发送;提问/审批卡或轮次结束也会发送待发过程;`send_wechat` 工具可主动推送文本/文件到微信 - **微信 slash 命令** — `/workspace`、`/session`、`/preset`、`/model`、 `/perm`、`/silent`、`/flush`、`/notify`、`/next`、`/status`、`/stop`、`/rq` 等 @@ -26,6 +27,11 @@ DSH 设置页内扫码登录与连接配置。以静态 Cordis 插件交付, - **静默模式** — `/silent on` 后每轮只发送最终回复,设置页可切换(全局配置,重新扫码后仍保留)。⚠️ 单位是**轮**:一次需求若被拆成多轮(例如目标自动续跑 8 轮),仍会收到 8 条 - **结论模式(只回最终结论)** — 设置页勾选或微信发 `/final on`,跨轮次的中间过程一律暂存,整条任务彻底停下来后**只发一条最终结论**:自动续跑再多轮也只有一条。默认静默 45 秒后交付,最长 10 分钟必发一次;权限卡、提问卡、报错始终立即推送,不受压制;「执行过程」分组直接丢弃;微信发 `/flush` 可随时立即取回。开启后优先于「静默」。配置键 `finalOnly` / `finalOnlyIdleMs` / `finalOnlyCeilingMs` - **投递可信度** — 错误与摘录先过秘密脱敏(JWT / `sk-` / `gh*_` / `xox*` / `AKIA` / `Bearer` / 长 hex / 长 base64 → `***`,**绝不作用于正常回复正文**);跨会话完成卡预览取**尾部** 80 字(结论在最后);长回复按 `(i/n)` 有序分段,代码围栏与表格原子不可切;出站队列满时**强制发出最旧一条**而不是丢弃;`send_wechat` 每分钟限流;`allowedUsers` 白名单可拒绝陌生联系人驱动 bot +- **权限提权只能在本机做** — 微信里的 `/perm default <预设>` 只在**不放宽**权限时写入全局默认;`danger-full-access` 沙箱、或审批策略为 `never` 的预设会被拒绝,并提示到 DSH 设置页操作(全局默认对**所有**新会话生效,与微信无关)。会话级 `/perm switch` 不受影响(非持久)。确需远程改默认时开启 `allowRemotePermissionEscalation` +- **`send_wechat` 不是任意文件读取原语** — 只允许发送**调用会话**工作目录或已注册工作区内的文件(按 `fs.realpath` 归一后比较,`..` 与符号链接都逃不出去),并有字节上限(默认 20 MiB,读取前即拒绝)。要放开任意路径需显式开启 `allowAnyFilePath` +- **入站白名单升级播种** — 老部署升级时若 `state.json` 里**已有绑定用户**且白名单从未配置过,会自动把该用户写入 `allowedUsers`(否则升级后第一个发消息的陌生人就会接管 bot),并在微信里**提示一次**、`/status` 里显示你自己的 user id;全新安装(还没有绑定用户)保持「首个给你发消息的人成为绑定对端」,不会被锁在门外 +- **日志与审计** — 入站日志只记**脱敏**后的发送者 id,正文预览再过一次 `maskSecrets`;`~/.dsh-wechat/audit.jsonl` 追加记录入站(脱敏 id + 长度,不含正文)、slash 命令名、审批/提问决定、`send_wechat` 文件路径、每次出站发送结果,按 1 MiB 上限轮转为 `audit.jsonl.1`;`~/.dsh-wechat` 目录 `0700`、其下文件(token/state/config/sync-buf/审计)均为 `0600` +- **出站串行槽可诊断** — 锁内再调用公共入口会**立刻报错**(而不是把自己排到自己后面永久卡死),单次操作挂死时后续发送最多等 30 秒后照常发出并记日志 - **繁忙时投递(与 DSH 同源)** — 按 `busyEnter` 排队/插话;微信 `/enter` 同步 - **跨会话决策推送** — `/notify on` 后,任意会话的权限/提问卡整卡推送到微信。当前会话可直接回;其它会话必须 `P{n}=…`(`P1=/rq` 关闭该卡)。默认关。跨会话任务完成/报错走 `/notify tasks on|off` - **二维码登录** — `http://127.0.0.1:3080/wechat/qr` 扫码登录,设置页内嵌 @@ -141,6 +147,7 @@ profile 实际注册的所有原生命令;本地命令表里已有的名字自 | `/enter queue\|steer\|status`(`/busy`) | 繁忙时投递:agent 运行中收到微信消息时排队(`queue`)还是插话进当前轮次(`steer`);读写 DSH 设置 `ui-conversation.busyEnter`,与 GUI「繁忙时 Enter 键行为」同源同步;空闲会话始终新开一轮 | | `/silent on\|off`(`/sl`) | 静默模式:开启后仅在轮次结束发送最后一条助手正文;关闭后思考与工具合并为「执行过程」,正文独立发送;写入 `config.json`,重新扫码后仍保留,设置页可切换。⚠️ 单位是「轮」——一次需求若被拆成多轮(例如目标自动续跑 8 轮),仍会收到 8 条;要按「任务」只收一条请用结论模式(`/final on`) | | `/final on\|off\|status`(`/fo`) | 结论模式开关:开启后跨轮次暂存,整条任务停下后只发一条最终结论(优先于 `/silent`);`off` 会把**当前暂存的内容先交付**再关闭(暂存是 agent 已经产出的结论,关掉不该把它销毁);`status` 显示模式与暂存条数。写入 `config.json`,重新扫码后仍保留,与设置页「结论模式」同一个值。⚠️ `/f` 是 `/flush` 的别名,**不是** `/final` 的 | +| `/merge on\|off\|status`(`/mg`) | 打字合并开关:**默认开启**,连续几条微信消息在静默窗口(默认 3 秒)内合并成一轮;`status` 显示窗口/上限/强制放行/当前暂存条数。消息末尾 `..` 表示还要接着说(暂不发出),`!!` 表示立刻提交(开启时两个后缀都会从正文去掉;整条消息只有后缀且没有待合并内容时按普通文本处理)。达到条数上限、`!!`、或强制放行上限(默认 3 分钟,图片/文件到达也会先冲刷)时一定发出;乱序的旧消息**不会**延长窗口。`off` 会先把暂存的消息作为一轮发出去再关闭(那是你自己说的话,不该被开关吞掉)。写入 `config.json`,重新扫码后仍保留,与设置页「打字合并」同一个值 | | `/flush`(`/f`) | 立即取回结论模式暂存的最终结论,不必等静默期;没有暂存内容时提示为空 | | `/surface on\|off\|status`(`/wxprompt`) | 微信渠道提示词注入开关(默认关);正文在设置页编辑,不在微信改 | | `/notify on\|off\|status`(`/watch`) | 跨会话决策推送总闸(开:任意会话权限/提问卡整卡推送并可直接回复;关:只答当前会话),默认关闭;`/notify tasks on\|off` 单独开关跨会话任务完成/报错提醒(设置页可切换) | @@ -181,10 +188,10 @@ profile 实际注册的所有原生命令;本地命令表里已有的名字自 同款交付),挂载到 `settings.section` slot(nav 顺序 40): - **状态卡** — 登录阶段(未登录/等待扫码/已扫码,待确认/已登录/登录失败)、Bot ID、 - 监控运行状态、已绑定用户数,与 `跨会话决策推送` / `跨会话任务完成提醒` / `静默` / `结论模式` 开关同卡展示 + 监控运行状态、已绑定用户数,与 `跨会话决策推送` / `跨会话任务完成提醒` / `静默` / `结论模式` / `打字合并` 开关同卡展示 - **扫码** — 未登录时页面内直接显示二维码,扫码确认后自动进入已登录 - **操作按钮** — `重新扫码`(清除 token 重新登录)、`退出登录`,与保存配置同行 -- **连接配置** — 设置页展示 `cwd` / `cardTimeoutMs` / `sendWechatRateLimitPerMinute` / `allowedUsers`,以及 `跨会话决策推送` / `跨会话任务完成提醒` / `静默` / `结论模式` / 微信渠道提示词(开关 + 正文);保存即生效。`baseUrl` / `cdnBaseUrl` / `botType` / `textChunkLimit` / `finalOnlyIdleMs` / `finalOnlyCeilingMs` 仍可写 `~/.dsh-wechat/config.json` 或插件行 `config:`,设置页保存不会覆盖它们。网关参数变更会自动重启长轮询;存储于 `~/.dsh-wechat/config.json` 与 `state.json` +- **连接配置** — 设置页展示 `cwd` / `cardTimeoutMs` / `sendWechatRateLimitPerMinute` / `allowedUsers` / `mergeSilenceMs` / `mergeMaxMsgs` / `mergeForcedReleaseMs` / `maxOutgoingFileBytes`,以及 `跨会话决策推送` / `跨会话任务完成提醒` / `静默` / `结论模式` / `微信渠道提示词` / `打字合并` / `合并后缀 .. 与 !!` / `允许微信放宽全局默认权限` / `允许 send_wechat 发送任意路径`(开关 + 正文);保存即生效。`baseUrl` / `cdnBaseUrl` / `botType` / `textChunkLimit` / `finalOnlyIdleMs` / `finalOnlyCeilingMs` 仍可写 `~/.dsh-wechat/config.json` 或插件行 `config:`,设置页保存不会覆盖它们。网关参数变更会自动重启长轮询;存储于 `~/.dsh-wechat/config.json` 与 `state.json` - **帮助** — 卡片标题行的 **帮助** 按钮展开本地命令表(与微信 `/help` 同源);DSH 原生命令仍只在微信 `/help` 末尾按当前 profile 列出 与宿主通信走插件自己的 HTTP API(`/wechat/api/status|help|config|relogin| @@ -218,7 +225,15 @@ logout`),客户端零 `@deepseek-ai` 依赖。 | `finalOnlyIdleMs` | `45000` | 结论模式静默期:任一活动事件(助手正文、轮次结束、`agent/status` 空闲/运行)都会把它重新推后 | | `finalOnlyCeilingMs` | `600000` | 结论模式硬顶:从第一次暂存起算,到时无论是否仍在续跑都强制交付一次(10 分钟) | | `sendWechatRateLimitPerMinute` | `10` | `send_wechat` 工具每分钟推送上限(滑动 60 秒窗口;`0` = 不限)。超限时工具**明确返回失败**并告知模型,而不是继续刷用户 | -| `allowedUsers` | `[]` | 入站白名单(微信 user id 数组)。**空 = 现有行为**:首个入站者成为绑定对端;非空 = 只有名单内的 id 会被处理,其余在去重/预算/建会话/命令分发之前拦下并记日志,绝不回复 | +| `allowedUsers` | `[]` | 入站白名单(微信 user id 数组)。**空 = 现有行为**:首个入站者成为绑定对端;非空 = 只有名单内的 id 会被处理,其余在去重/预算/建会话/命令分发之前拦下并记日志,绝不回复。**升级播种**:`config.json` 里从未写过该键、且 `state.json` 已有绑定用户时,启动会把该用户写入这里并持久化(微信里提示一次,`/status` 显示你的 id);显式存过 `[]` 则永不覆盖 | +| `allowRemotePermissionEscalation` | `false` | 是否允许微信 `/perm default` 放宽**全局**默认权限。关闭时:解析出的沙箱为 `danger-full-access` 或审批为 `never` 的预设被拒绝并提示到 DSH 设置页操作;不放宽的写入仍允许;会话级 `/perm switch` 不受影响 | +| `allowAnyFilePath` | `false` | 是否允许 `send_wechat` 发送任意路径的文件。关闭时只允许**调用会话**工作目录或已注册工作区内的文件(`fs.realpath` 归一后比较,`..`/符号链接逃不出去) | +| `maxOutgoingFileBytes` | `20971520` | `send_wechat` 发送文件的字节上限(20 MiB)。超限在 `stat` 阶段即拒绝,不会读进内存;缓存队列里的文件在冲刷时也会重新校验 | +| `mergeEnabled` | `true` | **打字合并总闸(默认开启,改变了旧行为)**:连续几条微信消息合并成一轮。`/merge on\|off` 与设置页同款;关闭后每条消息各自一轮 | +| `mergeSilenceMs` | `3000` | 合并静默窗口(毫秒):窗口内到达的消息继续追加;`0` = 关闭窗口(等同关闭合并)。乱序更旧的消息不会延长窗口 | +| `mergeMaxMsgs` | `10` | 单批消息条数硬上限:达到即立刻合并发出(防御性上限,永不超过) | +| `mergeSuffixTokens` | `true` | 是否识别正文末尾的 `..`(继续追加)与 `!!`(立刻提交);开启时后缀会从正文中去掉 | +| `mergeForcedReleaseMs` | `180000` | 强制放行上限(毫秒,3 分钟):无论窗口、`..` 暂挂、还是上一批仍在投递,到点一定发出——「宁可两句话一轮,也不让通道卡死」 | | `surfacePromptEnabled` | `false` | 微信渠道提示词总闸(微信消息驱动时注入,GUI 消息时仍隐藏) | | `surfacePrompt` | 见默认中文 | 注入正文(设置页编辑;含 `{{` 会被打散以免打断 DSH interpolate) | @@ -227,9 +242,19 @@ logout`),客户端零 `@deepseek-ai` 依赖。 ```bash npm install npm run build # tsc → dist/ -npm test # vitest(splitText/出站 Markdown 过滤/解析/帧处理/waterfall 竞速/状态存储/命令解析/超时恢复/状态颜色/历史截断/渠道提示词/结论模式/脱敏/限流与白名单/dispose 语义/驱动宿主版本区间/DSH >=0.2.0-rc.2 适配) +npm test # vitest(splitText/出站 Markdown 过滤/解析/帧处理/waterfall 竞速/状态存储/命令解析/超时恢复/状态颜色/历史截断/渠道提示词/结论模式/打字合并/脱敏/限流与白名单/权限提权闸门/send_wechat 文件边界/白名单播种/审计与文件权限/出站串行槽/有界缓存/dispose 语义/驱动宿主版本区间/DSH >=0.2.0-rc.2 适配) ``` +⚠️ **CI 跑在 Linux(`ubuntu-latest`)上,所以 Windows 上"全绿"不等于 CI 绿。** 已经踩过一次:一条断言把 Windows 形状的字面量 `C:\home\.dsh` 同时当输入和期望前缀,而实现会先对该根目录做 `path.resolve`——在 POSIX 上那是**相对路径**,会解析到 cwd 之下,于是 `built.startsWith("C:\home\.dsh/sessions")` 恒为 false,本地 Windows 必绿、CI 必红。 + +改动测试(尤其涉及路径/权限/文件系统的用例)后,请在 Linux 上再跑一遍完整 CI 步骤: + +```bash +bash scripts/ci-local.sh # Linux / macOS / Windows 的 WSL 里都可用 +``` + +它把检出复制到本机临时目录再 `npm ci && tsc --noEmit && vitest run`(与 CI 完全一致),因此不会用 Linux 版的 esbuild/rollup 覆盖你工作副本里的 `node_modules`。 + 当前版本针对 **DeepSeek Harness `>=0.2.0-rc.2 <0.3.0-0`** 的宿主 API;仓库本身不依赖任何 `@deepseek-ai/dsh-*` 运行时包(`peerDependencies` 里的 `@deepseek-ai/dsh` 是**宿主契约声明**,不是依赖,并已标 `optional` 以免安装时把整个 harness 拖进 profile)。宿主版本要求写了两处、值完全一致: - `engines.dsh` —— **声明性**。宿主 `evaluatePluginCompatibility` 只校验 `peerDependencies`,**没有任何代码读 `engines`**;真正读它的是插件市场 `dshmarket`(顶层 `engines.dsh` 优先于嵌套 `dsh.engines.dsh`),且固定以 `{ includePrerelease: true }` 求值。 @@ -251,7 +276,11 @@ npm test # vitest(splitText/出站 Markdown 过滤/解析/帧处理/wa - 结论模式(`finalOnly`)按**任务**而非轮次收敛:暂存的是「这条任务最终那一段正文」,因此**中间轮次的正文与「执行过程」都不会到达微信**,交付时若跨了多轮会在首行标注 `✅ 已完成(共 N 轮)`。它不改变 agent 的行为,只改变推送时机;等待期间随时可发 `/flush` 取回,发下一条需求也会先交付上一件事的结论。微信端用 `/final on|off|status` 开关(与设置页同一个值),`/final off` 会先交付暂存再关闭。硬顶存在的原因是「宁可晚一点,也不能永远沉默」。暂存只在内存里,插件 dispose 会冲刷交付、登出/换绑会丢弃。 - 秘密脱敏(`maskSecrets`)作用范围刻意只限**错误文本与摘录**:`32+ hex` 与 `40+ base64` 两条规则会误伤 commit SHA、长 id 与长路径,所以绝不用于 agent 的正常回复正文。这是有意的取舍,`tests/redact.test.ts` 用两条断言把这个误伤边界固定下来,而不是假装它不存在。 - `(i/n)` 分段按**码点**计预算、前缀长度计入预算,代码围栏与 GFM 表格原子不可切;唯一允许超长的例外是「最小合法形式本身就超过预算」的构造(围栏分隔符、或表头+分隔行+首行),此时宁可超长也不产出残缺的 Markdown。 -- `allowedUsers` 为空时是**现有行为**(首个给你发消息的人成为绑定对端),不是「拒绝所有人」——这样老部署升级后不会突然失联;要收紧就填名单,未列出的 id 会在去重/建会话/命令分发之前被拦下并记日志,绝不会得到回复。 +- `allowedUsers` 为空时是**现有行为**(首个给你发消息的人成为绑定对端),不是「拒绝所有人」——这样老部署升级后不会突然失联;要收紧就填名单,未列出的 id 会在去重/建会话/命令分发之前被拦下并记日志,绝不会得到回复。⚠️ **升级播种会改变这一状态**:`config.json` 里从未配置过该键、而 `state.json` 已有绑定用户时,启动会把该绑定用户写进名单(并在微信里提示一次)——这正是为了堵住「升级后第一个陌生人接管 bot」,但也意味着升级后**其他人**发消息会被忽略;要放开就在设置页清空该字段(显式存过 `[]` 后不会被再次播种)。 +- 打字合并(`mergeEnabled`)**默认开启,属于默认行为变更**:此前每条微信消息各自一轮,现在窗口内的多条会合并成一轮。它对语义的影响是「一轮里可能包含多条用户消息」——需要每条消息单独成轮时发 `/merge off`、把 `mergeSilenceMs` 设为 `0`,或显式用设置页关掉。图片/文件类消息**不参与合并**(先冲刷已输入的文字,再自己作为一轮),因此一次「文字 + 图片」仍会产生两轮;`..` 暂挂的批次仍受条数上限与 3 分钟强制放行约束,不会无限期等下去。 +- 记忆表有界:跨会话完成卡的正文预览(每会话一条,上限 64 个会话)、`/s list` 的最近活动时间(上限 512 个会话)、`/model` 覆盖(上限 128 个会话)、微信消息来源标记(上限 512 条 / 10 分钟 TTL)都是**缓存**——被淘汰只会让该行退回默认显示(创建时间、部署默认模型、按会话 id 显示标题),不会丢失队列里的任何消息。出站队列的内存边界仍是硬上限 400 条(`MAX_OUTBOUND_QUEUE_HARD`),重启后**完整**恢复(此前只恢复最后 100 条,第 101–400 条会被静默丢弃)。 +- 打字合并的缓冲区**不因 slash 命令而提前释放**:`/status`、`/next` 等命令照常立即执行,缓冲的文本仍由静默窗口 / 条数上限 / 强制放行三者之一释放(因此不会丢,只是可能晚几秒)。插件 dispose 会先把缓冲区投递再注销交互;登出 / 重新扫码 / 换绑则**丢弃**(身份变了,别人说的话不该发给下一个绑定者)。 +- `~/.dsh-wechat` 与其下文件(`auth/token.json`、`state.json`、`config.json`、`sync-buf.json`、`audit.jsonl`)以 `0700` / `0600` 创建。⚠️ **Windows 上 Node 忽略 mode 参数**(`chmod` 只能切只读位),实际保护来自 `%USERPROFILE%` 继承的 ACL(仅本人、`SYSTEM`、`Administrators` 可读)——这是依赖继承 ACL,不是本插件自己设的权限。审计文件按 1 MiB 轮转为 `audit.jsonl.1`(只保留一份历史),轮转会记一行日志。 - 审批/提问卡挂在 Host 的 `approval/request` 与 `user-questions/request` waterfall 上,与 GUI 竞速;无微信 peer 时立即 `next()`,软超时只撤微信卡、不替用户决策。微信先答时会 abort 传给 GUI 的 `request.signal`(不碰 turn 的 `exec.signal`),Web composer 卡随 Gateway `cancel` 帧收起。DSH 重启后未决卡片随 turn 消失(上游已知限制)。 - 微信回答卡片后的回执**每条占一次微信发送额度**(10 条/窗口)。一次 `P1=1 P2=2` 会发 2 条回执;额度用尽时回执与其他出站消息一样进入 `/next` FIFO 缓存,不会被丢弃。`P{n}` 是整批处理开始时快照的编号(卡片在结算时即被摘除,现算取不到编号、回执会退化成不带编号的文案)。跨会话卡的 `❗来自其他会话` 只是正文提示标记,不影响编号或回复语法。 diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100644 index 0000000..ca70b75 --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Run exactly what CI runs, on a Linux filesystem, without touching you +# Windows/macOS working copy's node_modules. +# +# Why this exists: CI is `ubuntu-latest`. A test that asserts on a path built by +# `path.resolve` / `path.join` from a Windows-shaped literal (e.g. `C:\home\.dsh`) +# passes locally on Windows and fails on Linux, because on POSIX that literal is +# a RELATIVE path and resolves under the cwd — so `built.startsWith("C:\home\.dsh/sessions")` +# is never true. That exact assertion shipped in a PR, was green locally, and went +# red on the first Linux run. Run this before opening a PR that touches tests. +# +# Usage (Linux, macOS, or Windows via WSL): +# bash scripts/ci-local.sh +# +# On Windows the working copy lives on a DrvFs mount (`/mnt/c/...` or `/mnt/d/...`), +# where npm's platform-specific devDependencies (esbuild / rollup) would be +# installed for the wrong OS. So the script copies the checkout into $TMPDIR first +# and installs there, leaving your working copy untouched. + +set -euo pipefail + +# Guard against the failure mode this script exists to prevent, in its own +# environment: under WSL, Windows binaries are reachable on PATH through +# interop, so an unqualified `node` can be the WINDOWS node. That runs `tsc` +# from a UNC working directory (fails with "CMD.EXE does not support UNC +# paths") and, worse, would have exercised win32 path semantics while claiming +# to test Linux — the exact false green this script is meant to eliminate. +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found on PATH. CI pins Node 22; install it and put it first on PATH." >&2 + exit 2 +fi +node_bin="$(command -v node)" +case "$node_bin" in + /mnt/[a-z]/*) + echo "ERROR: the 'node' on PATH is a WINDOWS binary ($node_bin) reached through WSL interop." >&2 + echo "That tests the wrong platform — POSIX path semantics are what CI exercises." >&2 + echo "Put a Linux node first on PATH, e.g.: export PATH=\"\$HOME/node22/bin:\$PATH\"" >&2 + exit 2 + ;; +esac + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wechat-ci-XXXXXX")" +trap 'rm -rf "$work"' EXIT + +echo "=== source: $root" +echo "=== workdir: $work (a Linux-native filesystem; your checkout is not touched)" +echo "=== node: $(node -v) at $node_bin" + +# Copy without the heavy/generated dirs; .git is not needed to build or test. +( cd "$root" && tar -cf - --exclude=.git --exclude=node_modules --exclude=dist . ) | ( cd "$work" && tar -xf - ) + +cd "$work" +echo "=== npm ci" +npm ci --no-audit --no-fund +echo "=== tsc --noEmit" +npx tsc --noEmit +echo "=== vitest run" +npx vitest run + +echo +echo "All CI steps passed on $(uname -s)." diff --git a/src/adapter/inbound.ts b/src/adapter/inbound.ts index 3580c75..af2f51d 100644 --- a/src/adapter/inbound.ts +++ b/src/adapter/inbound.ts @@ -4,12 +4,12 @@ * https://github.com/pan17/wechat-opencode (src/adapter/inbound.ts) */ -import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import type { WeixinMessage, MessageItem } from "../weixin/types.js"; import { MessageItemType } from "../weixin/types.js"; import { parseAesKey, downloadAndDecrypt } from "../weixin/media.js"; +import { ensurePrivateDir, writePrivateFile } from "../utils/secure-fs.js"; export interface PromptBlock { type: "text"; @@ -18,12 +18,16 @@ export interface PromptBlock { /** * Save downloaded file to temp directory with a unique name. + * + * Inbound media is the user's own content, so it lives under the same private + * policy as the rest of the storage dir: directory 0o700, file 0o600 (Windows + * ignores the mode and relies on the inherited ACLs — see secure-fs). */ function saveToTemp(buffer: Buffer, fileName: string, tempDir: string): string { - fs.mkdirSync(tempDir, { recursive: true }); + ensurePrivateDir(tempDir); const safeName = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${fileName.replace(/[^\w.-]/g, "_")}`; const filePath = path.join(tempDir, safeName); - fs.writeFileSync(filePath, buffer); + writePrivateFile(filePath, buffer); return filePath; } diff --git a/src/bridge/bridge.ts b/src/bridge/bridge.ts index f9ae7ab..2c0c4b6 100644 --- a/src/bridge/bridge.ts +++ b/src/bridge/bridge.ts @@ -16,6 +16,7 @@ import fs from "node:fs"; import path from "node:path"; +import { AsyncLocalStorage } from "node:async_hooks"; import { login, loadToken, type TokenData } from "../weixin/auth.js"; import { startMonitor, clearSyncBuf } from "../weixin/monitor.js"; import { sendTextMessage, sendMediaMessage, splitText } from "../weixin/send.js"; @@ -26,7 +27,7 @@ import { isMessageLimitError, isSessionTimeoutError, } from "../weixin/api.js"; -import { MessageType, TypingStatus, UploadMediaType } from "../weixin/types.js"; +import { MessageItemType, MessageType, TypingStatus, UploadMediaType } from "../weixin/types.js"; import type { WeixinMessage } from "../weixin/types.js"; import { extractText, weixinMessageToPrompt } from "../adapter/inbound.js"; import { formatForWeChat } from "../adapter/outbound.js"; @@ -47,8 +48,10 @@ import { type UserState, } from "../state.js"; import type { WeChatDSHConfig } from "../config.js"; +import { defaultConfig } from "../config.js"; import { EDITABLE_KEYS, type ConfigStore, type EditableConfig } from "../config-store.js"; -import { maskSecrets } from "../utils/redact.js"; +import { maskSecrets, maskUserId } from "../utils/redact.js"; +import { AuditLog } from "../utils/audit.js"; import { detectUnknownSlashCommand, formatHelp, @@ -60,6 +63,7 @@ import { renderProjectionSection, parseEnterCommand, parseFinalCommand, + parseMergeCommand, parseModelCommand, splitProviderModelTarget, parseNextCommand, @@ -84,6 +88,7 @@ import { type EnterCommand, type FinalCommand, type HistoryCommand, + type MergeCommand, type ModelCommand, type NotifyCommand, type SurfaceCommand, @@ -109,6 +114,37 @@ const INBOUND_TTL_MS = 30 * 60_000; const INBOUND_MAX_SEEN = 4096; /** How long a session's message-source marker stays valid without a touch. */ const SURFACE_TTL_MS = 7 * 24 * 60 * 60_000; +/** + * Bounded wait for the outbound serialization slot (R1). A single hung + * outbound operation must not wedge every later send forever: when the + * predecessor does not settle within this window, the waiter logs loudly and + * proceeds WITHOUT the slot (the alternative — waiting forever — is the + * failure this bound exists to prevent). 30s is far above any real gateway + * call (the send itself has its own HTTP timeout) and far below "wedged". + */ +const OUTBOUND_SLOT_WAIT_MS = 30_000; +/** + * Hard caps for the per-session memo maps (R2). These are caches, not queues: + * nothing is lost when an entry is evicted (a preview simply shows the session + * id instead of a quoted tail, `/s list` falls back to the session's creation + * time, a model override falls back to the deployment default). The caps are + * insertion-ordered FIFO — deterministic and allocation-free — rather than + * true LRU: the readers are `/s list` (a window over the roster) and `/status` + * (one row), so read-recency would not predict what is needed next. + */ +const ASSISTANT_PREVIEW_MAX_SESSIONS = 64; +const ACTIVITY_MAX_SESSIONS = 512; +const MODEL_OVERRIDE_MAX_SESSIONS = 128; +/** + * One-shot TTL for a WeChat-injected message id. The mark is consumed by the + * `user/message` echo; an echo that never arrives (turn cancelled, agent + * never claimed the inbox item) must not pin the id for the process lifetime. + * Generous because queue-mode delivery can take minutes while the agent is + * busy — dropping the mark early would mislabel the next turn as GUI-driven + * and silently drop the WeChat surface prompt for it. + */ +const WECHAT_MSG_ID_TTL_MS = 10 * 60_000; +const WECHAT_MSG_IDS_MAX = 512; /** typing_ticket cache TTL — re-fetch via getconfig after expiry. */ const TYPING_TICKET_TTL_MS = 5 * 60_000; /** @@ -208,6 +244,35 @@ interface PendingFinal { ceiling?: NodeJS.Timeout; } +/** + * Per-chat merge state (打字合并). + * + * Entries are appended in arrival order and joined with newlines into ONE + * prompt. `newestSourceAt` is the newest message timestamp seen (or arrival + * time when iLink supplied none) and exists for exactly one rule: an + * OUT-OF-ORDER older message must not extend the silence timer — otherwise a + * late replay could hold a batch open indefinitely while the user waits. + * + * `silence` is absent while a `..` suffix holds the batch open; `ceiling` is + * the forced release that always exists, so no batch can outlive + * {@link WeChatDSHBridge.mergeForcedReleaseMs} regardless of suffixes, + * message storms or a previous batch that never settles. + */ +interface MergeState { + userId: string; + entries: string[]; + /** Newest source timestamp seen (the out-of-order guard compares against it). */ + newestSourceAt: number; + /** When the batch opened — reported by `/merge status` (never write-only). */ + firstAt: number; + silence?: NodeJS.Timeout; + ceiling?: NodeJS.Timeout; + /** `..` seen: keep appending, no silence flush until the cap/`!!`/ceiling. */ + holdOpen: boolean; + /** In-flight dispatch of this chat's previous batch (ordering barrier). */ + inFlight: Promise | null; +} + /** * One pending approval card mirrored from an `approval/request`. * Identical information and choices to the GUI card; whoever answers @@ -390,6 +455,13 @@ export class WeChatDSHBridge { * `from_user_id`s are ignored. Single-user deployments only. */ private peerUserId: string | null = null; + /** + * Peer id written into `allowedUsers` by the S3 upgrade seeding, until its + * one-time notice has been delivered (see {@link notifyAllowlistSeed}). + * Reading it is what makes the notice exactly-once instead of once per + * inbound message. + */ + private allowlistSeededPeer: string | null = null; /** * Latest inbound iLink `context_token` for the peer. Wired into the * send payload when present; never used as a send gate. @@ -447,19 +519,48 @@ export class WeChatDSHBridge { private capFlushes = 0; /** Serializes all outbound budget/queue mutations across assistant and tool sends. */ private outboundSerial: Promise = Promise.resolve(); + /** + * Marks the async call chain of the body currently holding the outbound + * slot. `AsyncLocalStorage` (rather than a boolean) is what makes the + * re-entrancy check exact: an unrelated caller that merely ARRIVES while + * the slot is held is legitimate FIFO queueing, while a call made from + * INSIDE the held body is the self-deadlock this guards against. + */ + private readonly outboundChain = new AsyncLocalStorage(); + /** Operations enqueued but not settled — keeps the happy path timer-free. */ + private outboundPending = 0; + /** Bounded wait for the slot; a test seam, and the R1 escape from a wedged predecessor. */ + private outboundSlotWaitMs = OUTBOUND_SLOT_WAIT_MS; + /** Append-only audit trail (S4): JSONL under the storage dir, capped + rotated. */ + private readonly audit: AuditLog; /** Per-agent model overrides set by `/model switch` / `/reasoning` (applied via agent/request). */ private readonly modelOverrides = new Map(); /** Dedup for cross-session turn/end notifications (userId -> sessionIds already notified this turn). */ private readonly notifiedCrossSessionTurns = new Map>(); - /** Last assistant text per session (for cross-session completion preview). */ + /** + * Last assistant text per session (for cross-session completion preview). + * Bounded FIFO at {@link ASSISTANT_PREVIEW_MAX_SESSIONS}: the value is a + * whole assistant message, so an unbounded map is a real leak on a long + * lived install with many sessions; eviction only means a completion card + * loses its quoted tail. + */ private readonly lastAssistantTextBySession = new Map(); /** * Last user-prompt time per session (from `user/message` events with * source.kind === "user" — the same activity the GUI sidebar uses). Used * for `/s list` ordering and the time column; sessions without a recorded * activity (cold after restart) fall back to their creation time. + * Bounded FIFO at {@link ACTIVITY_MAX_SESSIONS} (numbers only — the cap + * exists because every session the process ever sees would otherwise stay + * resident forever). */ private readonly lastActivityBySession = new Map(); + /** + * Inbound merge window state per WeChat chat (F1). Present only while a + * batch is open or a dispatch is in flight; deleted once drained, so this + * map never grows past the number of chats being merged (one peer here). + */ + private readonly mergedText = new Map(); /** * The most recent numbered session list per WeChat user. Keeping the exact * ids (rather than only its scope) lets a following shorthand `/s switch @@ -486,7 +587,8 @@ export class WeChatDSHBridge { /** * Ids of user messages this bridge injected from WeChat (pending their * `user/message` echo), so `handleSessionEvent` can tell WeChat-injected - * messages apart from GUI-typed ones. One-shot consumption with a TTL. + * messages apart from GUI-typed ones. One-shot consumption with a TTL and a + * hard cap (see {@link markWechatMessage}). */ private readonly wechatMessageIds = new Map(); /** True after plugin dispose; pending waterfall waiters must not settle to WeChat. */ @@ -533,7 +635,9 @@ export class WeChatDSHBridge { this.config = config; this.configStore = configStore; this.state = new StateStore(config.storageDir); + this.audit = new AuditLog(config.storageDir, (message) => this.log(message)); this.migrateSilentFromUsers(); + this.seedAllowlistFromPeer(); this.restoreOutboundState(); this.agents = new AgentStore(ctx); this.ops = new DshOps(ctx); @@ -551,6 +655,72 @@ export class WeChatDSHBridge { this.persistEditable({ silent: true }); } + /** + * S3 — seed the inbound allowlist from the peer this install already + * belongs to. + * + * `allowedUsers` is empty by default, which means "the first inbound user + * becomes the peer". That is right for a fresh install and WRONG for an + * install that already has a bound peer: after the upgrade, the first + * stranger to message the bot would take it over. So when the list has + * never been configured (`config.json` carries no `allowedUsers` key) and + * `state.json` already names a peer, the peer is written into the list. + * + * Deliberately gated on "never configured": an explicitly stored `[]` is a + * decision (open to everyone) and is never overwritten. A brand-new install + * has no peer at all, so it keeps first-contact-wins and can never lock its + * owner out. + */ + private seedAllowlistFromPeer(): void { + if (!this.configStore) return; + if (this.config.allowedUsers.length > 0) return; + if (this.configStore.stored().allowedUsers !== undefined) return; + const peer = this.boundPeerFromState(); + if (!peer) return; + this.config.allowedUsers = [peer]; + this.persistEditable({ allowedUsers: [peer] }); + this.allowlistSeededPeer = peer; + this.log( + `seeded allowedUsers with the already bound peer ${maskUserId(peer)}` + + " (upgrade hardening: without it the next stranger to message the bot would become the peer)", + ); + } + + /** The peer an existing install already belongs to, or undefined on a fresh one. */ + private boundPeerFromState(): string | undefined { + const users = this.state.all(); + // Prefer a user with a real session binding; fall back to the first + // persisted user (the pre-existing `lockPeerFromState` rule: on a restart + // the first entry in `state.json` IS the peer) and then to a persisted + // outbound snapshot, which names the peer too. + const bound = users.find((u) => typeof u.sessionId === "string" && u.sessionId.length > 0); + return bound?.userId ?? users[0]?.userId ?? this.state.outbound()?.peerUserId ?? undefined; + } + + /** + * Deliver the S3 seeding notice exactly once, to the peer it concerns. + * + * Called from the inbound path (and after login), so it needs no timer: the + * pending flag is cleared only when the message actually left the process, + * which keeps the "one-shot mark is a commit" rule — a logged-out bot does + * not burn the notice on a drop. + */ + private notifyAllowlistSeed(userId: string): void { + const peer = this.allowlistSeededPeer; + if (!peer || peer !== userId) return; + if (!this.botReady() || this.tokenGiveUp) return; + this.allowlistSeededPeer = null; + void this.sendReply( + userId, + "🔐 升级提示:此前你的微信已是本 bot 的绑定用户,已自动把该账号写入入站白名单(allowedUsers)," + + "其它微信用户发消息将被忽略。\n" + + `你的微信 user id:${userId}\n` + + "查看:/status;修改或清空:DSH 设置 → WeChat → 入站白名单。", + ).catch((err) => { + this.log(`allowlist seed notice failed: ${String(err)}`); + }); + } + /** Bind or create the WeChat user, inheriting the global silent default. */ private ensureBoundUser(userId: string): UserState { return this.state.ensureUser(userId, this.config.cwd, { silent: this.config.silent }); @@ -592,13 +762,91 @@ export class WeChatDSHBridge { } } - /** Serialize remote sends with budget and durable queue mutations. */ + /** + * Serialize remote sends with budget and durable queue mutations. + * + * Two safety properties, both added after the re-entrancy deadlock that + * shipped once (a locked body calling a public entry queued itself behind + * the operation it was awaiting, wedging the WHOLE outbound chain forever): + * + * 1. **Re-entrancy assertion.** Entering the slot from inside the async + * chain that currently holds it is a programming error, not a queueing + * decision. It is detected exactly (via `AsyncLocalStorage`, so a + * legitimate concurrent caller that merely arrives while the slot is + * held is NOT flagged) and fails LOUDLY and immediately instead of + * deadlocking. The fix is always the same: call the `*Locked` variant. + * 2. **Bounded wait.** A single hung operation must not wedge every later + * send: a waiter gives its predecessor {@link OUTBOUND_SLOT_WAIT_MS} + * and then proceeds without it, with a loud log. This trades strict + * FIFO order — only in the pathological case — for liveness. + * + * Happy path is deliberately cheap: when nothing is pending (`outboundPending + * === 0`) there is no timer, no race and no extra promise hop. + */ private serializeOutbound(operation: () => Promise): Promise { - const run = this.outboundSerial.then(operation, operation); - this.outboundSerial = run.then(() => undefined, () => undefined); + if (this.outboundChain.getStore() === true) { + const detail = + "serializeOutbound re-entered from inside the outbound critical section: " + + "a locked body must call the *Locked helper, never a public entry " + + "(this call would queue itself behind the operation it is awaiting and wedge every later send)"; + console.error(`[dsh-wechat] ${detail}`); + this.audit.record({ event: "outbound-reentry", detail }); + throw new Error(`dsh-wechat internal: ${detail}`); + } + const previous = this.outboundSerial; + const queued = this.outboundPending > 0; + const run = (async () => { + if (queued) { + await this.waitForOutboundSlot(previous); + } else { + // Free slot: still defer by one microtask, exactly like the + // `outboundSerial.then(...)` this replaced. Work enqueued in the same + // tick must be able to observe state changes that land before the + // first send starts — e.g. `logout()` bumping `outboundGeneration` + // right after a `void sendReply(...)`, which is what makes logout + // discard not-yet-sent payloads. + await Promise.resolve(); + } + return this.outboundChain.run(true, () => operation()); + })(); + this.trackOutbound(run); return run; } + /** Keep the FIFO tail and the pending counter honest for one enqueued operation. */ + private trackOutbound(run: Promise): void { + this.outboundPending++; + const settled = run.then( + () => { this.outboundPending--; }, + () => { this.outboundPending--; }, + ); + this.outboundSerial = settled; + } + + /** + * Wait for the operation currently holding the slot, but never forever. + * `previous` never rejects (`trackOutbound` maps both outcomes), so the + * race cannot surface a spurious error into the next operation. + */ + private async waitForOutboundSlot(previous: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + const timedOut = await Promise.race([ + previous.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), this.outboundSlotWaitMs); + timer.unref?.(); + }), + ]); + if (timer) clearTimeout(timer); + if (timedOut) { + this.log( + `outbound slot still held after ${this.outboundSlotWaitMs}ms; proceeding WITHOUT it ` + + "(a previous outbound operation appears wedged — liveness beats strict ordering here)", + ); + this.audit.record({ event: "outbound-slot-timeout", waitMs: this.outboundSlotWaitMs }); + } + } + /** * Late-bind the native command registry (`ctx.commands`). * @@ -796,7 +1044,36 @@ export class WeChatDSHBridge { /** Remember one WeChat-injected message id for echo matching. */ private markWechatMessage(messageId: string): void { - this.wechatMessageIds.set(messageId, Date.now()); + const at = Date.now(); + // One-shot mark + its release timer armed in the SAME block, so "every + // mark expires" is assertable by construction: the mark is consumed by + // the `user/message` echo, and an echo that never arrives still releases + // it here. The cap below is the hard ceiling above that soft TTL — the + // timer alone cannot bound a burst of messages the agent never claims. + const timer = setTimeout(() => { + if (this.wechatMessageIds.get(messageId) === at) this.wechatMessageIds.delete(messageId); + }, WECHAT_MSG_ID_TTL_MS); + timer.unref?.(); + this.boundedSet(this.wechatMessageIds, messageId, at, WECHAT_MSG_IDS_MAX, "wechatMessageIds"); + } + + /** + * Insert into a memo map with a deterministic hard cap: re-inserting moves + * the key to the tail (so the eviction order is "least recently WRITTEN"), + * and overflowing drops the head — the oldest entry — with a loud log, the + * same rule `parkOutbound`'s hard ceiling follows. Every caller of this + * helper is a cache, never a queue: the evicted value is recomputable or + * merely cosmetic, which is why the cap may be small. + */ + private boundedSet(map: Map, key: K, value: V, cap: number, label: string): void { + map.delete(key); + map.set(key, value); + while (map.size > cap) { + const oldest = map.keys().next(); + if (oldest.done) break; + map.delete(oldest.value); + this.log(`${label}: cap ${cap} reached; evicted oldest entry (a cache, not a queue — nothing is lost)`); + } } /** Record that the last user message in `sessionId` came from WeChat. */ @@ -1001,6 +1278,12 @@ export class WeChatDSHBridge { */ async stop(): Promise { this.stopIlink("plugin-stop"); + // Deliver what 打字合并 is holding BEFORE `stopInteraction()`: the merged + // batch is forwarded through `forwardToAgent`, which aborts as soon as the + // interaction is marked stopped — flushing afterwards would silently + // discard the user's own words. Same rule as the 结论模式 flush below: the + // peer is not changing, so already-produced input is delivered. + await this.flushAllMergedText("dispose"); this.stopInteraction(); this.processes.clear(); this.assistantStreams.clear(); @@ -1101,6 +1384,9 @@ export class WeChatDSHBridge { this.pendingApprovals.clear(); this.pushedCardRpcIds.clear(); this.notifiedCrossSessionTurns.clear(); + // Identity change: a half-typed batch belongs to the peer we no longer + // serve and must never be handed to whoever binds next. + this.dropMergedText(); this.discardPendingFinal(); try { this.state.clearUsers(); @@ -1133,6 +1419,7 @@ export class WeChatDSHBridge { this.cacheNoticeSent = false; this.wechatMsgCount = 0; this.silentBuffers.clear(); + this.dropMergedText(); this.discardPendingFinal(); this.persistOutboundState(); if (parked > 0) { @@ -1256,6 +1543,12 @@ export class WeChatDSHBridge { } } + // Turning 打字合并 off from the settings page must not swallow whatever + // the window was holding: those are the user's own words. + if (patch.mergeEnabled === false) { + await this.flushAllMergedText("config-merge-off"); + } + const editable: Record = {}; for (const key of EDITABLE_KEYS) { editable[key] = this.config[key]; @@ -1323,8 +1616,14 @@ export class WeChatDSHBridge { const allowed = this.config.allowedUsers; if (allowed.length > 0 && !allowed.some((id) => id === msg.from_user_id)) { // Logged, never answered: replying would confirm the bot is live to an - // unknown sender and spend a gateway message doing it. - this.log(`ignore inbound from ${msg.from_user_id}: not in allowedUsers`); + // unknown sender and spend a gateway message doing it. Both the log line + // and the audit record carry the MASKED id (S4). + this.log(`ignore inbound from ${maskUserId(msg.from_user_id)}: not in allowedUsers`); + this.audit.record({ + event: "inbound-rejected", + user: maskUserId(msg.from_user_id), + reason: "allowedUsers", + }); return; } if (this.peerUserId && this.peerUserId !== msg.from_user_id) return; @@ -1373,6 +1672,9 @@ export class WeChatDSHBridge { const user = this.ensureBoundUser(userId); this.persistOutboundState(userId); + // S3 one-time notice: the allowlist was seeded on upgrade, and the peer + // should learn about it exactly once (see `notifyAllowlistSeed`). + this.notifyAllowlistSeed(userId); // Pull text once so both the card handler and the slash command // bypass check see the same input. @@ -1440,9 +1742,23 @@ export class WeChatDSHBridge { await this.flushPending(userId); } - this.log(`Message from ${userId}: ${this.previewMessage(msg)}`); + this.log(`Message from ${maskUserId(userId)}: ${maskSecrets(this.previewMessage(msg))}`); + // S4 audit: masked sender + SIZE only — never the body. Recorded after the + // dedupe gate, so a replayed duplicate is not a second inbound event. + this.audit.record({ + event: "inbound", + user: maskUserId(userId), + chars: text.length, + items: (msg.item_list ?? []).length, + }); if (text) { + // One audit line per slash command (name only — never its arguments, + // which can carry a path, a preset name or an answer). + if (text.trim().startsWith("/")) { + const name = parseCommandName(text) ?? text.trim().split(/\s+/)[0]!.slice(0, 32); + this.audit.record({ event: "command", user: maskUserId(userId), command: name }); + } if (isNext) { await this.flushPending(userId); return; @@ -1480,6 +1796,12 @@ export class WeChatDSHBridge { return; } + const mergeCmd = parseMergeCommand(text); + if (mergeCmd) { + await this.handleMergeCommand(userId, mergeCmd); + return; + } + if (parseFlushCommand(text)) { const flushed = await this.flushAllPendingFinal("command"); await this.sendReply( @@ -1584,7 +1906,7 @@ export class WeChatDSHBridge { } } - await this.forwardToAgent(user, msg); + await this.enqueueInbound(user, msg); } private persistSessionBinding(user: UserState): void { @@ -1627,6 +1949,280 @@ export class WeChatDSHBridge { this.agents.followup(agent, blocks, mode, (id) => this.markWechatMessage(id)); } + // ─── Inbound merge window (F1, 打字合并) ─── + // + // One thought typed as five WeChat messages used to cost five turns. Bare + // text now enters a per-chat buffer that is delivered as ONE prompt when the + // silence window elapses or the message cap is reached. The rules that keep + // this from becoming a trap: + // + // - an OUT-OF-ORDER older message never extends the silence timer (a late + // replay must not hold a batch open while the user waits); + // - the buffered count has a defensive hard cap (`mergeMaxMsgs`); + // - a FORCED RELEASE (`mergeForcedReleaseMs`, default 3 min) dispatches the + // batch no matter what — "rather two messages in one turn than a + // permanently blocked channel"; + // - a message that arrives while a previous batch is in flight starts a + // NEW batch that waits for the in-flight one only up to that same bound, + // so it is never silently swallowed; + // - non-text items (image / file / video / voice-without-transcript) are + // NEVER merged: they first flush the pending text as its own turn, then + // go to the agent as their own turn, which preserves the order the user + // typed and keeps a media attachment out of a text batch. + // + // Toggle from WeChat with `/merge on|off|status`; parameter defaults live in + // `config.ts`. `mergeSilenceMs: 0` (or `/merge off`) restores one-turn-per- + // message exactly. + + private mergeEnabled(): boolean { + return this.config.mergeEnabled === true; + } + + private mergeSilenceMs(): number { + const value = this.config.mergeSilenceMs; + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 3000; + } + + private mergeMaxMsgs(): number { + const value = this.config.mergeMaxMsgs; + // `Math.max(1, …)`: a fractional value must still leave a usable cap — + // flooring 0.5 to 0 would flush on every single message. + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.max(1, Math.floor(value)) + : 10; + } + + private mergeForcedReleaseMs(): number { + const value = this.config.mergeForcedReleaseMs; + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 3 * 60_000; + } + + /** Entry point for every inbound item: decide merge vs. immediate turn. */ + private async enqueueInbound(user: UserState, msg: WeixinMessage): Promise { + const text = extractText(msg.item_list); + const items = msg.item_list ?? []; + const hasMedia = items.some((item) => item.type !== undefined && item.type !== MessageItemType.TEXT); + if (!this.mergeEnabled() || this.mergeSilenceMs() <= 0 || !text || hasMedia) { + if (hasMedia && this.mergedText.has(user.userId)) { + // Deliver what was typed BEFORE the media item first: a batch that + // swallowed it and then handed over the image would reorder the + // conversation for the agent. + await this.flushMergedText(user.userId, "media"); + } + await this.forwardToAgent(user, msg); + return; + } + + // Suffix tokens. `!!` commits now, `..` keeps the batch open. Both are + // stripped from the text. A message that is ONLY the token acts as pure + // control when there is something to control (a bare `!!` commits the + // pending batch without polluting it) and stays literal text otherwise — + // a trailing token must never make the message disappear. + const state = this.mergeStateFor(user.userId); + const buffered = state.entries.length; + let body = text; + let commitNow = false; + let holdOpen = false; + if (this.config.mergeSuffixTokens === true) { + const trimmed = text.replace(/\s+$/, ""); + const stripped = trimmed.slice(0, -2).trim(); + const commitToken = trimmed.endsWith("!!") && (stripped.length > 0 || buffered > 0); + const holdToken = !commitToken && trimmed.endsWith("..") && (stripped.length > 0 || buffered > 0); + if (commitToken) { + commitNow = true; + body = stripped; + } else if (holdToken) { + holdOpen = true; + body = stripped; + } + } + + const now = Date.now(); + const sourceAt = typeof msg.create_time_ms === "number" && Number.isFinite(msg.create_time_ms) + ? msg.create_time_ms + : now; + const newer = state.entries.length === 0 || sourceAt >= state.newestSourceAt; + if (body.length > 0) { + state.entries.push(body); + if (newer) state.newestSourceAt = sourceAt; + } + if (!state.ceiling) state.ceiling = this.armMergeTimer(state, this.mergeForcedReleaseMs(), "forced-release"); + + if (state.entries.length >= this.mergeMaxMsgs()) { + await this.flushMergedText(user.userId, "cap"); + return; + } + if (commitNow) { + await this.flushMergedText(user.userId, "suffix-commit"); + return; + } + if (holdOpen) { + // `..` = "I am not done": no silence flush; the cap, a later `!!`, a + // media message or the forced release still deliver the batch. + state.holdOpen = true; + if (state.silence) { + clearTimeout(state.silence); + state.silence = undefined; + } + this.log(`merge: batch for ${maskUserId(user.userId)} held open by '..' (${state.entries.length} msg)`); + return; + } + if (state.holdOpen) { + // Still inside an explicit `..` hold: appending is all this message does. + return; + } + // Only a message that is not older than everything buffered re-arms the + // silence window (the out-of-order guard). + if (newer) { + if (state.silence) clearTimeout(state.silence); + state.silence = this.armMergeTimer(state, this.mergeSilenceMs(), "silence"); + } else { + this.log(`merge: ignoring out-of-order older message for ${maskUserId(user.userId)} (silence timer not extended)`); + } + } + + private mergeStateFor(userId: string): MergeState { + let state = this.mergedText.get(userId); + if (!state) { + state = { + userId, + entries: [], + newestSourceAt: 0, + firstAt: Date.now(), + holdOpen: false, + inFlight: null, + }; + this.mergedText.set(userId, state); + } + return state; + } + + /** Arm one batch timer; both timers are cleared on every flush path. */ + private armMergeTimer(state: MergeState, ms: number, reason: string): NodeJS.Timeout { + const timer = setTimeout(() => { + void this.flushMergedText(state.userId, reason).catch((err) => { + this.log(`merge flush (${reason}) failed for ${maskUserId(state.userId)}: ${String(err)}`); + }); + }, ms); + timer.unref?.(); + return timer; + } + + /** + * Deliver the chat's buffered text as ONE prompt. + * + * Ordering: a batch that arrives while the previous one is still being + * dispatched waits for it — but only up to `mergeForcedReleaseMs`, after + * which it logs and dispatches anyway. Waiting is about preserving the + * user's message order; it is never allowed to become a stuck channel. + */ + private async flushMergedText(userId: string, reason: string): Promise { + const state = this.mergedText.get(userId); + if (!state || state.entries.length === 0) return; + const entries = state.entries.splice(0, state.entries.length); + state.holdOpen = false; + if (state.silence) { + clearTimeout(state.silence); + state.silence = undefined; + } + if (state.ceiling) { + clearTimeout(state.ceiling); + state.ceiling = undefined; + } + state.newestSourceAt = 0; + const user = this.state.getUser(userId); + if (!user) { + this.log(`merge: dropping ${entries.length} buffered message(s) for unknown user ${maskUserId(userId)}`); + this.dropMergedText(userId); + return; + } + const text = entries.join("\n"); + const merged: WeixinMessage = { + message_type: MessageType.USER, + from_user_id: userId, + ...(this.wireContextToken ? { context_token: this.wireContextToken } : {}), + item_list: [{ type: MessageItemType.TEXT, text_item: { text } }], + }; + this.log( + `merge flush (${reason}): ${entries.length} message(s), ${text.length} chars for ${maskUserId(userId)}`, + ); + const previous = state.inFlight; + const run = (async () => { + if (previous) { + let timer: NodeJS.Timeout | undefined; + const timedOut = await Promise.race([ + previous.then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), this.mergeForcedReleaseMs()); + timer.unref?.(); + }), + ]); + if (timer) clearTimeout(timer); + if (timedOut) { + this.log( + `merge: previous batch for ${maskUserId(userId)} still in flight after ` + + `${this.mergeForcedReleaseMs()}ms; dispatching anyway (a blocked channel is worse than two turns)`, + ); + } + } + await this.forwardToAgent(user, merged); + })(); + const tracked = run.then( + () => undefined, + (err) => { + this.log(`merge dispatch failed for ${maskUserId(userId)}: ${String(err)}`); + }, + ); + state.inFlight = tracked; + await tracked; + if (state.inFlight === tracked) state.inFlight = null; + if (state.entries.length === 0 && state.inFlight === null) this.mergedText.delete(userId); + } + + /** Deliver every open batch (plugin dispose — the user's own words are not destroyed). */ + private async flushAllMergedText(reason: string): Promise { + for (const userId of [...this.mergedText.keys()]) { + try { + await this.flushMergedText(userId, reason); + } catch (err) { + this.log(`merge flush-all failed for ${maskUserId(userId)}: ${String(err)}`); + } + } + } + + /** Drop a chat's buffer and timers (identity change: logout / re-login / re-bind). */ + private dropMergedText(userId?: string): void { + const targets = userId === undefined ? [...this.mergedText.keys()] : [userId]; + for (const key of targets) { + const state = this.mergedText.get(key); + if (!state) continue; + if (state.silence) clearTimeout(state.silence); + if (state.ceiling) clearTimeout(state.ceiling); + this.mergedText.delete(key); + } + } + + /** Human-readable merge state for `/merge status` and `/status` (never write-only state). */ + private mergeStatusText(): string { + if (!this.mergeEnabled()) return "off(每条消息各自一轮)"; + const pending = this.pendingMergedCount(); + const window = this.mergeSilenceMs(); + if (window <= 0) return "on(窗口 0ms = 立即转发)"; + // How long the oldest open batch has been waiting: the window is otherwise + // invisible until it fires, which makes "why is nothing happening?" hard + // to answer from WeChat alone. + const open = [...this.mergedText.values()].find((state) => state.entries.length > 0); + const openFor = open ? Math.round((Date.now() - open.firstAt) / 1000) : undefined; + return `on(静默 ${(window / 1000).toFixed(1)}s / 上限 ${this.mergeMaxMsgs()} 条 / 强制放行 ${Math.round(this.mergeForcedReleaseMs() / 1000)}s${pending > 0 ? ` / 暂存 ${pending} 条(已等 ${openFor}s)` : ""})`; + } + + /** Buffered (not yet dispatched) inbound message count across chats. */ + private pendingMergedCount(): number { + let total = 0; + for (const state of this.mergedText.values()) total += state.entries.length; + return total; + } + // ─── Outbound: DSH → WeChat ─── /** @@ -1690,9 +2286,12 @@ export class WeChatDSHBridge { if (event.type === "user/message") { const data = event.data as { source?: { kind?: string }; id?: string }; if (data?.source?.kind === "user") { - this.lastActivityBySession.set( + this.boundedSet( + this.lastActivityBySession, sessionId, typeof event.time === "number" ? event.time : Date.now(), + ACTIVITY_MAX_SESSIONS, + "lastActivityBySession", ); // Fallback source marking (the authoritative one happens on // agent/inbox/spliced above; this echo is emitted AFTER assembly, @@ -1730,7 +2329,15 @@ export class WeChatDSHBridge { .filter((b) => b.type === "text" && b.text) .map((b) => b.text) .join("\n"); - if (text) this.lastAssistantTextBySession.set(sessionId, text); + if (text) { + this.boundedSet( + this.lastAssistantTextBySession, + sessionId, + text, + ASSISTANT_PREVIEW_MAX_SESSIONS, + "lastAssistantTextBySession", + ); + } } const user = this.userForAgent(sessionId); @@ -2269,8 +2876,7 @@ export class WeChatDSHBridge { this.pendingApprovals.set(userId, list); const userState = this.state.getUser(userId); if (userState?.sessionId === sessionId || this.shouldNotifyCrossSession(userId)) { - this.markCardPushed(userId, rpcId); - void this.sendApprovalCard(userId, card).catch(() => {}); + this.pushCardOnce(userId, rpcId, () => this.sendApprovalCard(userId, card)); } const gui = Promise.resolve() @@ -2349,8 +2955,7 @@ export class WeChatDSHBridge { this.pendingQuestions.set(userId, list); const userState = this.state.getUser(userId); if (userState?.sessionId === sessionId || this.shouldNotifyCrossSession(userId)) { - this.markCardPushed(userId, rpcId); - void this.sendQuestionCard(userId, card, questions).catch(() => {}); + this.pushCardOnce(userId, rpcId, () => this.sendQuestionCard(userId, card, questions)); } type QuestionWinner = @@ -2431,8 +3036,7 @@ export class WeChatDSHBridge { this.pendingApprovals.set(userId, list); const userState = this.state.getUser(userId); if (userState?.sessionId === sessionId || this.shouldNotifyCrossSession(userId)) { - this.markCardPushed(userId, frame.rpcId); - void this.sendApprovalCard(userId, card).catch(() => {}); + this.pushCardOnce(userId, frame.rpcId, () => this.sendApprovalCard(userId, card)); } break; } @@ -2479,8 +3083,7 @@ export class WeChatDSHBridge { this.pendingQuestions.set(userId, list); const userState = this.state.getUser(userId); if (userState?.sessionId === sessionId || this.shouldNotifyCrossSession(userId)) { - this.markCardPushed(userId, frame.rpcId); - void this.sendQuestionCard(userId, card, questions).catch(() => {}); + this.pushCardOnce(userId, frame.rpcId, () => this.sendQuestionCard(userId, card, questions)); } break; } @@ -2696,12 +3299,10 @@ export class WeChatDSHBridge { const unseenQuestions = questions.filter((c) => !this.isCardPushed(userId, c.rpcId)); if (unseenApprovals.length > 0 || unseenQuestions.length > 0) { for (const card of unseenApprovals) { - this.markCardPushed(userId, card.rpcId); - void this.sendApprovalCard(userId, card).catch(() => {}); + this.pushCardOnce(userId, card.rpcId, () => this.sendApprovalCard(userId, card)); } for (const card of unseenQuestions) { - this.markCardPushed(userId, card.rpcId); - void this.sendQuestionCard(userId, card, card.items).catch(() => {}); + this.pushCardOnce(userId, card.rpcId, () => this.sendQuestionCard(userId, card, card.items)); } } @@ -2714,12 +3315,10 @@ export class WeChatDSHBridge { const questions = (this.pendingQuestions.get(userId) ?? []).filter((c) => !this.isCardPushed(userId, c.rpcId)); if (approvals.length === 0 && questions.length === 0) return; for (const card of approvals) { - this.markCardPushed(userId, card.rpcId); - void this.sendApprovalCard(userId, card).catch(() => {}); + this.pushCardOnce(userId, card.rpcId, () => this.sendApprovalCard(userId, card)); } for (const card of questions) { - this.markCardPushed(userId, card.rpcId); - void this.sendQuestionCard(userId, card, card.items).catch(() => {}); + this.pushCardOnce(userId, card.rpcId, () => this.sendQuestionCard(userId, card, card.items)); } } @@ -2822,6 +3421,36 @@ export class WeChatDSHBridge { this.pushedCardRpcIds.set(userId, set); } + /** + * Push one card and claim its one-shot "already pushed" mark — in that + * order, and only when the card can actually leave the process. + * + * Why the order matters (S5): `sendReply` DROPS its payload while the bot is + * logged out or mid re-login (it does not park it), so marking first burns + * the one-shot on a delivery that never happened, and `isCardPushed` then + * blocks the re-push until the card times out — the user simply never sees + * the card. Reachability is therefore confirmed BEFORE the mark is taken, + * and the mark is rolled back if the send itself throws (the same + * mark-then-rollback shape `notifyCachePendingLocked` uses). A "parked" + * outcome is a success for this purpose: the card text is in the durable + * FIFO and will be flushed. + */ + private pushCardOnce(userId: string, rpcId: string, send: () => Promise): void { + if (this.interactionStopped) return; + if (!this.botReady() || this.tokenGiveUp) { + this.log( + `card ${rpcId} not marked as pushed: bot unreachable ` + + "(no token / -14 give-up), so a later attempt can still deliver it", + ); + return; + } + this.markCardPushed(userId, rpcId); + void send().catch((err) => { + this.unmarkCardPushed(userId, rpcId); + this.log(`card ${rpcId} push failed; pushed-mark rolled back so it can be re-sent: ${String(err)}`); + }); + } + /** Whether the card's full content was already pushed to WeChat for this user. */ private isCardPushed(userId: string, rpcId: string): boolean { return this.pushedCardRpcIds.get(userId)?.has(rpcId) ?? false; @@ -2946,6 +3575,16 @@ export class WeChatDSHBridge { ): Promise<{ accepted: boolean; reason?: string }> { if (entry.settled) return { accepted: false, reason: "not-pending" }; entry.settled = true; + // S4 audit: one line per decision actually applied (a duplicate attempt + // returns early above and is not a decision). + this.audit.record({ + event: "decision", + kind: "approval", + user: maskUserId(entry.userId), + session: maskUserId(entry.sessionId), + tool: entry.toolName, + decision: outcome, + }); if (entry.settleWechat) { entry.settleWechat(outcome); return { accepted: true }; @@ -3047,6 +3686,17 @@ export class WeChatDSHBridge { ): Promise<{ accepted: boolean; reason?: string }> { if (entry.settled) return { accepted: false, reason: "not-pending" }; entry.settled = true; + // S4 audit: the decision and its size, never the answer text (an answer + // can quote anything the agent asked about). + this.audit.record({ + event: "decision", + kind: "question", + user: maskUserId(entry.userId), + session: maskUserId(entry.sessionId), + items: entry.items.length, + decision: "answered", + chars: JSON.stringify(answer ?? "").length, + }); if (entry.settleWechat) { entry.settleWechat(answer); return { accepted: true }; @@ -3291,6 +3941,49 @@ export class WeChatDSHBridge { ); } + // ─── 打字合并 / inbound merge window (/merge) ─── + + /** + * `/merge` — view or switch the inbound merge window. Mirrors `/silent`: + * persisted to `config.json` (survives a re-scan), shown in the settings + * page, bypasses a pending card, appears in `/help` and in + * `LOCAL_COMMAND_NAMES` so a native command of the same name never shadows + * it. + * + * Turning it OFF flushes the buffer first: those are the user's own words + * and dropping them because they flipped a switch would silently swallow a + * message (the same rule `/final off` follows). + */ + private async handleMergeCommand(userId: string, cmd: MergeCommand): Promise { + if (cmd.mode === "on") { + this.persistEditable({ mergeEnabled: true }); + await this.sendReply( + userId, + "🧵 打字合并已开启:连续几条消息会在你停手后合并成一轮。\n" + + `静默 ${(this.mergeSilenceMs() / 1000).toFixed(1)}s / 最多 ${this.mergeMaxMsgs()} 条 / 最长 ${Math.round(this.mergeForcedReleaseMs() / 1000)}s 必发;` + + "消息末尾 `..` 表示还要接着说,`!!` 表示立刻提交(这两个后缀会被去掉)。图片/文件不参与合并,会先把已输入的文字发出去。", + ); + return; + } + if (cmd.mode === "off") { + const pending = this.pendingMergedCount(); + this.persistEditable({ mergeEnabled: false }); + if (pending > 0) await this.flushMergedText(userId, "command-off"); + await this.sendReply( + userId, + pending > 0 + ? `🧵 打字合并已关闭;已把暂存的 ${pending} 条消息作为一轮发出。之后每条消息各自一轮。` + : "🧵 打字合并已关闭:之后每条消息各自一轮。", + ); + return; + } + await this.sendReply( + userId, + `🧵 打字合并: ${this.mergeStatusText()}` + + "\n切换: /merge on|off(设置页「打字合并」同步,写入 config.json 后重新扫码仍保留)", + ); + } + // ─── Busy-Enter delivery behavior (/enter) ─── /** @@ -3481,7 +4174,9 @@ export class WeChatDSHBridge { scoped.map(async (record) => { if (this.lastActivityBySession.has(record.header.id)) return; const time = await this.ops.lastUserMessageTime(record.header.id, record.header.cwd, record.header); - if (time !== undefined) this.lastActivityBySession.set(record.header.id, time); + if (time !== undefined) { + this.boundedSet(this.lastActivityBySession, record.header.id, time, ACTIVITY_MAX_SESSIONS, "lastActivityBySession"); + } }), ); @@ -3605,7 +4300,7 @@ export class WeChatDSHBridge { const pin = pinnedIds.has(record.header.id) ? "📌 " : ""; const title = cleanSessionTitle(row?.title ?? record.header.id.slice(0, 12)); if (row?.lastUserMessageTime !== undefined) { - this.lastActivityBySession.set(record.header.id, row.lastUserMessageTime); + this.boundedSet(this.lastActivityBySession, record.header.id, row.lastUserMessageTime, ACTIVITY_MAX_SESSIONS, "lastActivityBySession"); } const when = this.formatRelativeTime(this.sessionActivityTime(record)); const presetId = row?.preset; @@ -4090,7 +4785,7 @@ export class WeChatDSHBridge { // Apply to the live agent (via agent/request) and as the new default. const selection = { provider: active!.provider!, model: active!.model!, reasoningEffort: matched.id }; if (agent) { - this.modelOverrides.set(agent.id, selection); + this.setModelOverride(agent.id, selection); } const saved = await this.ops.saveDefaultModel(selection); const lines = [`✅ 推理等级已切换: ${matched.name}(${matched.id})`]; @@ -4106,7 +4801,7 @@ export class WeChatDSHBridge { const override = this.modelOverrides.get(agent.id); if (override) { const { reasoningEffort: _dropped, ...rest } = override; - this.modelOverrides.set(agent.id, rest); + this.setModelOverride(agent.id, rest); } } const saved = await this.ops.saveDefaultModel({ provider: active!.provider!, model: active!.model! }); @@ -4291,7 +4986,7 @@ export class WeChatDSHBridge { ? { provider: matchedProvider.id, model: matchedModel.id, reasoningEffort: preservedEffort } : { provider: matchedProvider.id, model: matchedModel.id }; if (agent) { - this.modelOverrides.set(agent.id, selection); + this.setModelOverride(agent.id, selection); } const saved = await this.ops.saveDefaultModel(selection); const lines = [`✅ 已切换到模型: ${matchedProvider.id}/${matchedModel.id}`]; @@ -4398,7 +5093,37 @@ export class WeChatDSHBridge { await this.sendReply(userId, `⚠️ 未知权限预设: ${cmd.target}。可用: ${service.names.join(", ")}`); return; } + // S1: the default is GLOBAL and PERSISTENT — it is written into the + // `permission` settings document and applies to every session this + // DSH install creates from now on, WeChat-related or not. A chat + // message proving possession of a WeChat account must therefore not be + // able to raise it; a de-escalating write is still allowed. + if (!this.config.allowRemotePermissionEscalation && this.isEscalatingPreset(service, name)) { + this.audit.record({ + event: "perm-default-refused", + user: maskUserId(userId), + preset: name, + reason: "escalation", + }); + await this.sendReply( + userId, + [ + `⛔ 已拒绝:${this.describePermission(service, name)} 会放宽**全局**默认权限。`, + "该默认值写入 DSH 设置,对所有新会话生效(与微信无关),属于提权操作,只能在本机界面完成。", + "请打开 DSH 界面 → 设置 → 权限(Permission)→ 默认预设,选择该预设并保存。", + "会话级切换不受影响:/perm switch <名称> 只作用于当前会话。", + "如确实需要微信端修改全局默认,请在设置页开启「允许微信放宽全局默认权限」(配置键 allowRemotePermissionEscalation)。", + ].join("\n"), + ); + return; + } const saved = await this.ops.saveDefaultPermission(name); + this.audit.record({ + event: "perm-default-set", + user: maskUserId(userId), + preset: name, + ...(this.config.allowRemotePermissionEscalation ? { via: "allowRemotePermissionEscalation" } : {}), + }); const lines = [`✅ 新会话默认权限已设为: ${this.describePermission(service, name)}`]; if (saved) { lines.push("(已写入 DSH 设置,与 GUI 设置页同步,新会话生效)"); @@ -4458,6 +5183,36 @@ export class WeChatDSHBridge { } } + /** + * Whether a preset is an ESCALATION, read from the same knob bundle the + * host applies (`permissionPresets.resolve(name)` → `{ sandbox, approval }`, + * the schema of `@deepseek-ai/dsh-permission-presets`): + * + * - `sandbox === "danger-full-access"` — no filesystem confinement, or + * - `approval === "never"` — no approval prompt will ever be shown, + * regardless of what the sandbox refuses. + * + * Both knobs are read on purpose: the host ships `danger-full-access` as + * `{sandbox: danger-full-access, approval: never}` and `auto` as + * `{sandbox: danger-full-access, approval: ask}`, so keying on the sandbox + * alone would miss a config-declared preset that only drops approvals. + * + * A preset whose bundle cannot be resolved counts as escalating: an + * unreadable knob bundle must not be written globally just because we could + * not prove it is dangerous. + */ + private isEscalatingPreset( + service: { resolve(name: string): { sandbox: string; approval: string } }, + name: string, + ): boolean { + try { + const spec = service.resolve(name); + return spec.sandbox === "danger-full-access" || spec.approval === "never"; + } catch { + return true; + } + } + /** * Apply the user's model override to a frozen request config. Called from * the `agent/request` waterfall listener (registered in index.ts) for @@ -4486,6 +5241,21 @@ export class WeChatDSHBridge { }; } + /** + * Record one session's model override under a hard FIFO cap. + * + * The map is keyed by session id and nothing ever removed an entry, so a + * long-lived install with many sessions kept every override alive for the + * process lifetime. The cap bounds it; eviction is deterministic (oldest + * written first) and only affects sessions that have not had `/model` or + * `/reasoning` touched in the last {@link MODEL_OVERRIDE_MAX_SESSIONS} + * writes — for those, the model falls back to the persisted default, which + * `/model switch` writes in the same breath. + */ + private setModelOverride(agentId: string, override: ModelOverride): void { + this.boundedSet(this.modelOverrides, agentId, override, MODEL_OVERRIDE_MAX_SESSIONS, "modelOverrides"); + } + /** * Color marker for boolean toggles on /status. Each field has its own * semantic — `on` for 静默模式 means "we stop forwarding to WeChat" @@ -4564,6 +5334,16 @@ export class WeChatDSHBridge { : [`• 结论模式: ${this.paintBadge("off", "neutral")}`]), `• 微信提示词: ${this.paintBadge(this.config.surfacePromptEnabled ? "on" : "off", this.config.surfacePromptEnabled ? "positive" : "neutral")}`, `• 繁忙投递: ${this.ops.busyEnter() === "steer" ? this.paintBadge("steer(插话)", "positive") : this.paintBadge("queue(排队)", "neutral")}`, + // 打字合并 (F1) — shows the live parameters AND the buffered count, so + // the window is never a black box the user has to guess about. + `• 打字合并: ${this.mergeEnabled() ? this.paintBadge(this.mergeStatusText(), "positive") : this.paintBadge("off", "neutral")}`, + // Inbound allowlist (S3): the owner's own id is printed in full here — on + // purpose, because "never require the user to know their own id" — while + // logs only ever carry the masked form. + `• 入站白名单: ${this.describeAllowlist(user.userId)}`, + // Audit trail location: the trail exists for the operator, who has to be + // able to find it without reading the source. + `• 审计日志: ${this.audit.path()}(按 1 MiB 轮转为 .1)`, // 跨会话决策推送 on = extra delivery enabled (good); off = quiet (neutral). `• 跨会话决策推送: ${this.paintBadge(crossEffective, crossEffective === "on" ? "positive" : "neutral")}`, `• 跨会话任务完成提醒: ${this.paintBadge(this.notifyTaskEventsEnabled() ? "on" : "off", this.notifyTaskEventsEnabled() ? "positive" : "neutral")}`, @@ -4600,6 +5380,21 @@ export class WeChatDSHBridge { return lines.join("\n"); } + /** + * `/status` inbound-allowlist row (S3). When the list is empty the row says + * so explicitly — "first contact wins" is a security-relevant state and must + * be visible; when it is set, the bound peer's OWN id is printed in full so + * the owner never has to discover their id elsewhere to edit the list. + */ + private describeAllowlist(ownUserId: string): string { + const allowed = this.config.allowedUsers; + if (allowed.length === 0) { + return this.paintBadge("关(首位给你发消息的人会成为绑定对端)", "warning"); + } + const own = allowed.includes(ownUserId) ? " ◀ 当前绑定" : ""; + return `${allowed.join(", ")}${own}(其他人发消息会被忽略,不回复)`; + } + /** `/status` rows for unanswered cards, split into current vs other sessions. */ private formatPendingStatusLines(userId: string): string[] { const currentId = this.state.getUser(userId)?.sessionId; @@ -4728,6 +5523,91 @@ export class WeChatDSHBridge { // ─── send-wechat tool ─── + /** Effective outgoing-file size ceiling (bytes); malformed config falls back to the default. */ + private maxOutgoingFileBytes(): number { + const value = this.config.maxOutgoingFileBytes; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : defaultConfig().maxOutgoingFileBytes; + } + + /** Size of a file in bytes, or undefined when it cannot be stat'ed. */ + private async outgoingFileSize(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + return stat.isFile() ? stat.size : undefined; + } catch { + return undefined; + } + } + + /** + * Roots an outgoing file may live under: the working directory of the + * session that is ACTUALLY sending, plus every registered workspace. + * + * The sender is resolved from the calling agent id — never from the + * recipient fallback. Those are different questions: an unbound (GUI) + * session still sends to the first known WeChat user, and the file bound + * must be the CALLER's, otherwise any session could read any other + * session's workspace by relying on the recipient fallback. + */ + private async outgoingFileRoots(senderAgentId: string): Promise { + const roots = new Set(); + if (senderAgentId) { + try { + const live = this.ctx.get<{ get(id: string): Agent | undefined }>("agents")?.get(senderAgentId); + const liveCwd = live?.session?.header?.cwd; + if (typeof liveCwd === "string" && liveCwd) roots.add(liveCwd); + } catch { + // A host without the live header simply falls through to the roster. + } + const record = await this.ops.findSessionRecord(senderAgentId); + if (record?.header.cwd) roots.add(record.header.cwd); + const bound = this.userForAgent(senderAgentId); + if (bound?.cwd) roots.add(bound.cwd); + } + for (const workspace of this.ops.listWorkspaces()) { + if (workspace.path) roots.add(workspace.path); + } + return [...roots]; + } + + /** + * Validate one `send_wechat` file: it must exist, be a regular file, fit in + * {@link maxOutgoingFileBytes}, and — unless `allowAnyFilePath` is set — + * resolve inside one of {@link outgoingFileRoots}. + * + * @returns `undefined` when the file may be sent, else the refusal message + * the tool returns verbatim. + */ + private async guardOutgoingFile(senderAgentId: string, filePath: string): Promise { + let stat: fs.Stats; + try { + stat = await fs.promises.stat(filePath); + } catch { + return `file not found: ${filePath}`; + } + if (!stat.isFile()) return `not a regular file: ${filePath}`; + const max = this.maxOutgoingFileBytes(); + if (stat.size > max) { + return `file too large: ${filePath} is ${stat.size} bytes (limit ${max}; raise maxOutgoingFileBytes in the plugin settings)`; + } + if (this.config.allowAnyFilePath) return undefined; + // Canonicalize BOTH sides: a symlink inside the workspace pointing at + // /etc/shadow must not pass, and `..` must not escape. + const target = await canonicalPath(filePath); + const roots = await this.outgoingFileRoots(senderAgentId); + for (const root of roots) { + const canonicalRoot = await canonicalPath(root); + if (isPathInside(canonicalRoot, target)) return undefined; + } + const where = roots.length > 0 ? roots.join(", ") : "(no session cwd and no registered workspace)"; + return ( + `refused: ${filePath} is outside the calling session's workspace. ` + + `Allowed roots: ${where}. Move the file there, or set allowAnyFilePath=true in the plugin settings to allow any path.` + ); + } + /** * Handle the `send_wechat` tool: push text or a local file to a * WeChat user. The recipient is the user bound to the calling agent @@ -4742,6 +5622,12 @@ export class WeChatDSHBridge { * over delivery, and an error result would only tempt the model into * retrying (→ duplicate deliveries on flush). Only an unreadable * `file_path` returns `ok: false` immediately. + * + * S2: `file_path` is NOT an arbitrary-file-read primitive. The path is + * validated against the sending session's workspace (and every registered + * workspace) with a size ceiling before anything is read or queued; the + * check happens against the CALLER, not against the recipient fallback, so + * an unbound session cannot borrow another session's roots. */ async handleSendWeChat(agentId: string, args: { text?: string; file_path?: string }): Promise<{ ok: boolean; message: string }> { const user = this.resolveUserForAgent(agentId); @@ -4777,6 +5663,28 @@ export class WeChatDSHBridge { if (args.file_path) { const filePath = args.file_path; const fileName = filePath.split(/[\\/]/).pop() ?? "file"; + // S2: validate BEFORE reading/queueing anything. The bound is evaluated + // against the CALLING session (`agentId`), not against the recipient + // fallback resolved above. + const refusal = await this.guardOutgoingFile(agentId, filePath); + if (refusal) { + this.log(`send_wechat file refused: ${refusal}`); + this.audit.record({ + event: "send_wechat-file", + user: maskUserId(user.userId), + sender: maskUserId(agentId), + path: filePath, + result: "refused", + }); + return { ok: false, message: refusal }; + } + this.audit.record({ + event: "send_wechat-file", + user: maskUserId(user.userId), + sender: maskUserId(agentId), + path: filePath, + result: "allowed", + }); // Send images/videos with their native media type so WeChat renders // them inline; everything else goes as a file attachment. const mediaType = mediaTypeForFile(fileName); @@ -4786,6 +5694,14 @@ export class WeChatDSHBridge { if (this.tokenGiveUp) { return { ok: false, message: "WeChat bot token is invalid; re-scan the QR code" }; } + // Distinguish "grew past the cap between the guard and the read" from + // a genuinely unreadable path — the log line is precise, the tool + // result should be too. + const size = await this.outgoingFileSize(filePath); + const max = this.maxOutgoingFileBytes(); + if (size !== undefined && size > max) { + return { ok: false, message: `file too large: ${filePath} is ${size} bytes (limit ${max}; raise maxOutgoingFileBytes in the plugin settings)` }; + } return { ok: false, message: `file not found: ${filePath}` }; } return outcome === "sent" @@ -4834,10 +5750,35 @@ export class WeChatDSHBridge { : this.deliverOutboundLocked(userId, item, opts)); } + /** + * One outbound attempt, audited. Split from the body so EVERY caller — + * `deliverOutbound`, `sendReply`'s batch and `flushPendingLocked` — records + * exactly one line per attempt without sprinkling audit calls over a dozen + * early returns. Body contents are never recorded: kind, size and outcome + * only. + */ private async deliverOutboundLocked( userId: string, item: CachedMessage, opts?: { parkOnError?: boolean }, + ): Promise<"sent" | "cached" | "failed"> { + const outcome = await this.deliverOutboundLockedBody(userId, item, opts); + this.audit.record({ + event: "outbound", + user: maskUserId(userId), + kind: item.kind, + ...(item.kind === "text" ? { chars: item.text.length } : { file: item.fileName }), + result: outcome, + ...(outcome === "sent" ? { windowCount: this.wechatMsgCount } : {}), + ...(outcome === "cached" ? { queueLength: this.outboundCache.length } : {}), + }); + return outcome; + } + + private async deliverOutboundLockedBody( + userId: string, + item: CachedMessage, + opts?: { parkOnError?: boolean }, ): Promise<"sent" | "cached" | "failed"> { const parkOnError = opts?.parkOnError !== false; const token = this.token; @@ -4856,6 +5797,16 @@ export class WeChatDSHBridge { // Validate file paths before consuming budget or poisoning the durable queue. let buffer: Buffer | undefined; if (item.kind === "file") { + // Size ceiling at the READ site too, not only at the tool entry: a + // cached item outlives restarts (state.json) and a file can grow after + // it was queued. `stat` before `readFile` so an oversized file is never + // pulled into memory at all. + const size = await this.outgoingFileSize(item.filePath); + const max = this.maxOutgoingFileBytes(); + if (size !== undefined && size > max) { + this.log(`drop outbound file (${size} bytes > ${max} limit): ${item.fileName}`); + return "failed"; + } try { buffer = await fs.promises.readFile(item.filePath); } catch (err) { @@ -5093,6 +6044,15 @@ export class WeChatDSHBridge { }); } else { let buffer: Buffer; + const size = await this.outgoingFileSize(msg.filePath); + const max = this.maxOutgoingFileBytes(); + if (size !== undefined && size > max) { + this.log(`flush dropping oversized cached file (${size} bytes > ${max} limit): ${msg.fileName}`); + dropped++; + this.outboundCache.shift(); + this.persistOutboundState(userId); + continue; + } try { buffer = await fs.promises.readFile(msg.filePath); } catch (err) { @@ -5207,6 +6167,20 @@ async function canonicalPath(p: string): Promise { } } +/** + * Whether `target` is `root` itself or lives beneath it. Both paths are + * expected to be canonical (see {@link canonicalPath}); the comparison is + * segment-aware through `path.relative`, so `/work-evil` is NOT considered + * inside `/work` and a sibling `..` escape is rejected. `relative` returning + * an absolute path (different Windows drive) also fails the check. + */ +function isPathInside(root: string, target: string): boolean { + if (!root || !target) return false; + const rel = path.relative(root, target); + if (rel === "") return true; + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + /** * Pick the WeChat upload media type for a file name: images and videos use * their native types so the WeChat client renders them inline instead of as diff --git a/src/bridge/slash.ts b/src/bridge/slash.ts index c44e59c..b3cc02e 100644 --- a/src/bridge/slash.ts +++ b/src/bridge/slash.ts @@ -12,6 +12,7 @@ export type HelpCommand = { kind: "help" }; export type StatusCommand = { kind: "status" }; export type SilentCommand = { kind: "silent"; mode: "on" | "off" | "status" }; export type FinalCommand = { kind: "final"; mode: "on" | "off" | "status" }; +export type MergeCommand = { kind: "merge"; mode: "on" | "off" | "status" }; export type SurfaceCommand = { kind: "surface"; mode: "on" | "off" | "status" }; export type StopCommand = { kind: "stop" }; export type RejectQuestionCommand = { kind: "reject-question" }; @@ -28,6 +29,7 @@ export type SlashCommand = | StatusCommand | SilentCommand | FinalCommand + | MergeCommand | StopCommand | RejectQuestionCommand | RejectPermissionCommand; @@ -77,12 +79,29 @@ export function parseFinalCommand(text: string): FinalCommand | null { return { kind: "final", mode: m[1] as "on" | "off" | "status" }; } +/** + * Parse `/merge` (alias `/mg`) — query or toggle the inbound merge window + * (打字合并). Bare command defaults to status. + * + * The alias must not collide with an existing name: `/m` is NOT used (it + * would be the shortest but is not obviously "merge"), and `/mg` is free — + * `tests/merge-window.test.ts` asserts the whole alias table for collisions. + */ +export function parseMergeCommand(text: string): MergeCommand | null { + const trimmed = text.trim().toLowerCase(); + if (trimmed === "/merge" || trimmed === "/mg") { + return { kind: "merge", mode: "status" }; + } + const m = trimmed.match(/^\/(?:merge|mg)\s+(on|off|status)\s*$/); + if (!m) return null; + return { kind: "merge", mode: m[1] as "on" | "off" | "status" }; +} + /** * Parse `/surface` (alias `/wxprompt`) — query or toggle the WeChat * surface-prompt injection. Bare command defaults to status. The prompt * text itself is edited from the settings page, not this command. - */ -export function parseSurfaceCommand(text: string): SurfaceCommand | null { + */export function parseSurfaceCommand(text: string): SurfaceCommand | null { const trimmed = text.trim().toLowerCase(); if (trimmed === "/surface" || trimmed === "/wxprompt") { return { kind: "surface", mode: "status" }; @@ -668,6 +687,7 @@ export function isBypassSlashCommand(text: string): boolean { parseFlushCommand(text) || parseSilentCommand(text) !== null || parseFinalCommand(text) !== null || + parseMergeCommand(text) !== null || parseSurfaceCommand(text) !== null || parseStatusCommand(text) !== null || parseWorkspaceCommand(text) !== null || @@ -712,6 +732,8 @@ export const LOCAL_COMMAND_NAMES: ReadonlySet = new Set([ "sl", "final", "fo", + "merge", + "mg", "surface", "wxprompt", "stop", @@ -746,6 +768,7 @@ export function formatHelp( "• /enter (busy) — queue|steer|status 繁忙时消息投递(排队/插话,与 DSH 设置「繁忙时 Enter 键行为」同步)", "• /silent on|off (sl) — 开启仅每轮最终回复;关闭显示合并的思考与工具执行过程", "• /final on|off (fo) — 结论模式:跨轮次暂存,整条任务停下后只发一条最终结论(优先于 /silent)", + "• /merge on|off|status (mg) — 打字合并:连续多条微信消息合成一轮;末尾 `..` 继续追加、`!!` 立即提交", "• /flush (f) — 立即取回结论模式暂存的最终结论,不必等静默期", "• /surface on|off|status (wxprompt) — 微信渠道提示词注入开关(默认关;正文在设置页编辑)", "• /notify on|off|status (watch) — 跨会话决策推送:权限/提问卡整卡(默认关闭);/notify tasks on|off 跨会话任务完成提醒", diff --git a/src/client.js b/src/client.js index 5a33e1c..d785384 100644 --- a/src/client.js +++ b/src/client.js @@ -180,6 +180,14 @@ window.__ModuleLoader__.load({ surfacePrompt: r.body.config?.surfacePrompt ?? "", silent: !!(r.body.config?.silent ?? (firstUser && firstUser.silent)), finalOnly: !!r.body.config?.finalOnly, + mergeEnabled: !!r.body.config?.mergeEnabled, + mergeSilenceMs: String(r.body.config?.mergeSilenceMs ?? ""), + mergeMaxMsgs: String(r.body.config?.mergeMaxMsgs ?? ""), + mergeForcedReleaseMs: String(r.body.config?.mergeForcedReleaseMs ?? ""), + mergeSuffixTokens: !!r.body.config?.mergeSuffixTokens, + allowRemotePermissionEscalation: !!r.body.config?.allowRemotePermissionEscalation, + allowAnyFilePath: !!r.body.config?.allowAnyFilePath, + maxOutgoingFileBytes: String(r.body.config?.maxOutgoingFileBytes ?? ""), sendWechatRateLimitPerMinute: String(r.body.config?.sendWechatRateLimitPerMinute ?? ""), allowedUsers: (r.body.config?.allowedUsers ?? []).join("\n"), }); @@ -238,6 +246,17 @@ window.__ModuleLoader__.load({ notifyTaskEvents: !!form.notifyTaskEvents, silent: !!form.silent, finalOnly: !!form.finalOnly, + mergeEnabled: !!form.mergeEnabled, + mergeSuffixTokens: !!form.mergeSuffixTokens, + allowRemotePermissionEscalation: !!form.allowRemotePermissionEscalation, + allowAnyFilePath: !!form.allowAnyFilePath, + // Same blank-means-undefined rule as the rate limit: an + // empty box must leave the stored value alone instead of + // sending 0 (which is meaningful for mergeSilenceMs). + mergeSilenceMs: form.mergeSilenceMs.trim() === "" ? undefined : Number(form.mergeSilenceMs), + mergeMaxMsgs: form.mergeMaxMsgs.trim() === "" ? undefined : Number(form.mergeMaxMsgs), + mergeForcedReleaseMs: form.mergeForcedReleaseMs.trim() === "" ? undefined : Number(form.mergeForcedReleaseMs), + maxOutgoingFileBytes: form.maxOutgoingFileBytes.trim() === "" ? undefined : Number(form.maxOutgoingFileBytes), // An empty string must NOT become 0 (which means // "unlimited") — `Number("")` is 0, so the blank // case is mapped to undefined and the stored value @@ -353,8 +372,24 @@ window.__ModuleLoader__.load({ createElement("input", { value: form.sendWechatRateLimitPerMinute, onChange: set("sendWechatRateLimitPerMinute"), placeholder: "10" })), createElement("label", { className: "wide" }, createElement("span", { className: "wx_field_label" }, "allowedUsers", - createElement(HelpTip, { text: "入站白名单:每行一个微信 user id(也可用逗号分隔)。留空 = 现有行为(首个给你发消息的人成为绑定对端)。填了就只处理名单内的 id,其余在去重/建会话/命令分发之前被拦下并记日志,绝不回复。" })), + createElement(HelpTip, { text: "入站白名单:每行一个微信 user id(也可用逗号分隔)。留空 = 现有行为(首个给你发消息的人成为绑定对端)。填了就只处理名单内的 id,其余在去重/建会话/命令分发之前被拦下并记日志,绝不回复。升级时若本机已有绑定用户,会自动把该用户写入这里并在微信里提示一次(可在 /status 查看自己的 id)。" })), createElement("textarea", { value: form.allowedUsers, onChange: set("allowedUsers"), placeholder: "(留空 = 不限制)" })), + createElement("label", null, + createElement("span", { className: "wx_field_label" }, "mergeSilenceMs", + createElement(HelpTip, { text: "打字合并的静默窗口(毫秒,默认 3000):连续几条微信消息在这个间隔内到达时合并成一轮;0 = 关闭窗口(每条消息各自一轮,等同 /merge off)。" })), + createElement("input", { value: form.mergeSilenceMs, onChange: set("mergeSilenceMs"), placeholder: "3000" })), + createElement("label", null, + createElement("span", { className: "wx_field_label" }, "mergeMaxMsgs", + createElement(HelpTip, { text: "打字合并的硬上限(默认 10 条):达到即立刻合并发出,防止缓冲区无界增长。" })), + createElement("input", { value: form.mergeMaxMsgs, onChange: set("mergeMaxMsgs"), placeholder: "10" })), + createElement("label", null, + createElement("span", { className: "wx_field_label" }, "mergeForcedReleaseMs", + createElement(HelpTip, { text: "打字合并的强制放行上限(毫秒,默认 180000 = 3 分钟):无论窗口、`..` 暂挂还是上一批仍在投递,到点一定发出——宁可两句话一轮,也不让通道卡死。" })), + createElement("input", { value: form.mergeForcedReleaseMs, onChange: set("mergeForcedReleaseMs"), placeholder: "180000" })), + createElement("label", null, + createElement("span", { className: "wx_field_label" }, "maxOutgoingFileBytes", + createElement(HelpTip, { text: "send_wechat 发送文件的字节上限(默认 20971520 = 20 MiB)。超过的文件在读取之前就被拒绝。" })), + createElement("input", { value: form.maxOutgoingFileBytes, onChange: set("maxOutgoingFileBytes"), placeholder: "20971520" })), ) : null, createElement("div", { className: "wx_row", style: { marginTop: "4px" } }, createElement("label", { className: "wx_check" }, @@ -380,6 +415,26 @@ window.__ModuleLoader__.load({ createElement("span", null, "微信渠道提示词"), createElement(HelpTip, { text: "开启后,最近一条用户消息来自微信时会把下方提示词注入 agent 的 runtime context;GUI 发消息时自动隐藏。也可用微信 /surface on|off 切换。" }), ), + createElement("label", { className: "wx_check" }, + createElement("input", { type: "checkbox", checked: !!form?.mergeEnabled, onChange: (e) => setForm({ ...form, mergeEnabled: e.target.checked }) }), + createElement("span", null, "打字合并"), + createElement(HelpTip, { text: "默认开启:连续几条微信消息合并成一轮(一句话分几段发不再烧掉几轮)。消息末尾 `..` 表示还要接着说,`!!` 表示立刻提交;图片/文件不参与合并。关闭后每条消息各自一轮。微信端也可用 /merge on|off 切换(与这里同一个值)。" }), + ), + createElement("label", { className: "wx_check" }, + createElement("input", { type: "checkbox", checked: !!form?.mergeSuffixTokens, onChange: (e) => setForm({ ...form, mergeSuffixTokens: e.target.checked }) }), + createElement("span", null, "合并后缀 `..` / `!!`"), + createElement(HelpTip, { text: "是否识别消息末尾的 `..`(继续追加,暂不发出)与 `!!`(立刻提交)。开启时这两个后缀会从正文里去掉。关闭后它们只是普通文本。" }), + ), + createElement("label", { className: "wx_check" }, + createElement("input", { type: "checkbox", checked: !!form?.allowRemotePermissionEscalation, onChange: (e) => setForm({ ...form, allowRemotePermissionEscalation: e.target.checked }) }), + createElement("span", null, "允许微信放宽全局默认权限"), + createElement(HelpTip, { text: "默认关闭:微信里的 /perm default 只能写入不放宽权限的预设;danger-full-access 或审批为 never 的预设会被拒绝(全局默认写入 DSH 设置,对所有新会话生效,与微信无关,属于提权,请在本机设置页操作)。会话级 /perm switch 不受影响。仅在确实需要远程改全局默认时才开启。" }), + ), + createElement("label", { className: "wx_check" }, + createElement("input", { type: "checkbox", checked: !!form?.allowAnyFilePath, onChange: (e) => setForm({ ...form, allowAnyFilePath: e.target.checked }) }), + createElement("span", null, "允许 send_wechat 发送任意路径"), + createElement(HelpTip, { text: "默认关闭:send_wechat 只能发送调用会话工作目录或已注册工作区内的文件,其余路径一律拒绝(否则该工具就是任意文件读取原语)。仅在确实需要时才开启。" }), + ), (status?.users && status.users.length === 0) ? createElement("span", { className: "wx_meta" }, "(暂无绑定用户也可保存,重新扫码后仍然生效)") : null, ), form ? createElement("div", { className: "wx_form", style: { marginTop: "4px" } }, diff --git a/src/config-store.ts b/src/config-store.ts index c33d975..63c0cd6 100644 --- a/src/config-store.ts +++ b/src/config-store.ts @@ -8,6 +8,7 @@ import fs from "node:fs"; import path from "node:path"; import { defaultConfig, LEGACY_SURFACE_PROMPTS, type WeChatDSHConfig } from "./config.js"; +import { ensurePrivateDir, writePrivateFile } from "./utils/secure-fs.js"; export type EditableConfig = Pick< WeChatDSHConfig, @@ -27,6 +28,14 @@ export type EditableConfig = Pick< | "surfacePrompt" | "sendWechatRateLimitPerMinute" | "allowedUsers" + | "allowRemotePermissionEscalation" + | "allowAnyFilePath" + | "maxOutgoingFileBytes" + | "mergeEnabled" + | "mergeSilenceMs" + | "mergeMaxMsgs" + | "mergeSuffixTokens" + | "mergeForcedReleaseMs" >; export const EDITABLE_KEYS: Array = [ @@ -46,8 +55,49 @@ export const EDITABLE_KEYS: Array = [ "surfacePrompt", "sendWechatRateLimitPerMinute", "allowedUsers", + "allowRemotePermissionEscalation", + "allowAnyFilePath", + "maxOutgoingFileBytes", + "mergeEnabled", + "mergeSilenceMs", + "mergeMaxMsgs", + "mergeSuffixTokens", + "mergeForcedReleaseMs", ]; +/** Boolean config keys: a non-boolean from a hand-edited file would be truthy-by-accident. */ +const BOOLEAN_KEYS: ReadonlySet = new Set([ + "surfacePromptEnabled", + "silent", + "crossSessionNotify", + "notifyTaskEvents", + "finalOnly", + "allowRemotePermissionEscalation", + "allowAnyFilePath", + "mergeEnabled", + "mergeSuffixTokens", +]); + +/** + * Positive-number config keys. The guard rejects anything that cannot + * describe the value at all (non-numeric, non-finite, ≤ 0) so a malformed + * file cannot silently disable a bound or a timeout. + */ +const POSITIVE_NUMBER_KEYS: ReadonlySet = new Set([ + "finalOnlyIdleMs", + "finalOnlyCeilingMs", + "maxOutgoingFileBytes", + "mergeMaxMsgs", + "mergeForcedReleaseMs", +]); + +/** + * Non-negative number keys: `0` is meaningful and must survive the guard. + * For `mergeSilenceMs` it means "no merge window" — dispatch immediately, + * which is also the escape hatch tests and `merge-averse` installs use. + */ +const NON_NEGATIVE_NUMBER_KEYS: ReadonlySet = new Set(["mergeSilenceMs"]); + export class ConfigStore { private readonly filePath: string; private overrides: Partial = {}; @@ -81,6 +131,21 @@ export class ConfigStore { delete parsed.allowedUsers; } } + // Same reasoning for the typed switches this version added: a + // hand-edited `mergeEnabled: "no"` is a truthy string, and a + // `maxOutgoingFileBytes: "20"` would compare every file as too big. + // A malformed value is dropped, so the built-in default applies. + for (const key of Object.keys(parsed)) { + const value = (parsed as Record)[key]; + if (value === undefined) continue; + if (BOOLEAN_KEYS.has(key) && typeof value !== "boolean") { + delete (parsed as Record)[key]; + } else if (POSITIVE_NUMBER_KEYS.has(key) && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) { + delete (parsed as Record)[key]; + } else if (NON_NEGATIVE_NUMBER_KEYS.has(key) && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) { + delete (parsed as Record)[key]; + } + } return parsed; } } @@ -91,8 +156,10 @@ export class ConfigStore { } private save(): void { - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); - fs.writeFileSync(this.filePath, JSON.stringify(this.overrides, null, 2), "utf-8"); + ensurePrivateDir(path.dirname(this.filePath)); + // 0o600: this file names the storage layout and can hold a custom surface + // prompt, and (from this version) the inbound allowlist. + writePrivateFile(this.filePath, JSON.stringify(this.overrides, null, 2)); } /** Resolve the effective config: composition defaults overridden by stored values. */ @@ -112,8 +179,9 @@ export class ConfigStore { for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; if (!(EDITABLE_KEYS as string[]).includes(key)) continue; - if ((key === "surfacePromptEnabled" || key === "silent" || key === "crossSessionNotify" || key === "notifyTaskEvents" || key === "finalOnly") && typeof value !== "boolean") continue; - if ((key === "finalOnlyIdleMs" || key === "finalOnlyCeilingMs") && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) continue; + if (BOOLEAN_KEYS.has(key) && typeof value !== "boolean") continue; + if (POSITIVE_NUMBER_KEYS.has(key) && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) continue; + if (NON_NEGATIVE_NUMBER_KEYS.has(key) && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) continue; // 0 is meaningful here (unlimited), so the guard only rejects a value // that cannot describe a rate at all: non-numeric, non-finite or // negative. A negative limit would refuse EVERY push. diff --git a/src/config.ts b/src/config.ts index 9545239..9139b92 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,8 +63,69 @@ export interface WeChatDSHConfig { * historical single-peer behaviour: the first inbound user becomes the * bound peer. Non-empty means only those ids are processed — anyone else * is ignored with a log line, never answered. + * + * On upgrade the bridge SEEDS this list from the already bound peer when it + * is still empty (see `WeChatDSHBridge.seedAllowlistFromPeer`): before that, + * an install which had already been claimed by one user would hand ownership + * to whoever messaged first after the upgrade. A brand-new install (no bound + * peer yet) keeps first-contact-wins. */ allowedUsers: string[]; + /** + * Allow `/perm default ` from WeChat to raise the GLOBAL, persistent + * permission default (`permission.defaultPreset`, which applies to every + * future session of this DSH install, WeChat-related or not). + * + * Default `false`: a chat message may only write a default that does not + * escalate — a preset whose resolved sandbox is `danger-full-access` or + * whose approval policy is `never` is refused with a pointer to the DSH + * settings page. Session-scoped `/perm switch` is unaffected (it is not + * persistent), and set this to `true` to restore the pre-guard behaviour. + */ + allowRemotePermissionEscalation: boolean; + /** + * Allow `send_wechat` to read ANY absolute path on the host. + * + * Default `false`, which confines the tool to files inside the CALLING + * session's working directory or a registered workspace: without a bound, + * the tool is an arbitrary-file-read primitive for any agent (and for + * anything that can talk an agent into calling it). Set to `true` to + * restore the pre-bound behaviour. + */ + allowAnyFilePath: boolean; + /** + * Hard size ceiling for a file handed to `send_wechat` (bytes). A larger + * file is refused before it is read or queued, so an agent cannot turn the + * bridge into a memory/spool amplifier. Default 20 MiB. + */ + maxOutgoingFileBytes: number; + /** + * Inbound merge window (打字合并): consecutive WeChat text messages from the + * peer are buffered per chat and delivered as ONE prompt instead of one turn + * per message, so a thought typed as five bubbles costs one turn. + * + * Default `true` — THIS CHANGES THE PRE-0.12 BEHAVIOUR, where every WeChat + * message was its own turn. Turn it off from WeChat with `/merge off` or in + * the settings page. + */ + mergeEnabled: boolean; + /** Silence window (ms) after the last buffered message before the batch is delivered. `0` disables the window. */ + mergeSilenceMs: number; + /** Hard cap on buffered messages per batch — a defensive bound, never exceeded. */ + mergeMaxMsgs: number; + /** + * Suffix tokens inside the message text: a trailing `..` keeps the batch + * open (no silence flush), a trailing `!!` commits it immediately. Both are + * STRIPPED from the text when the feature is on. + */ + mergeSuffixTokens: boolean; + /** + * Forced release (ms): the hard ceiling that delivers a batch regardless of + * the silence window, a `..` hold, or a still-in-flight previous batch — + * "rather two messages in one turn than a permanently blocked channel". + * Default 3 minutes. + */ + mergeForcedReleaseMs: number; } /** Default WeChat surface prompt — injected as a runtime-context snapshot. */ @@ -105,5 +166,13 @@ export function defaultConfig(): WeChatDSHConfig { surfacePrompt: DEFAULT_SURFACE_PROMPT, sendWechatRateLimitPerMinute: 10, allowedUsers: [], + allowRemotePermissionEscalation: false, + allowAnyFilePath: false, + maxOutgoingFileBytes: 20 * 1024 * 1024, + mergeEnabled: true, + mergeSilenceMs: 3000, + mergeMaxMsgs: 10, + mergeSuffixTokens: true, + mergeForcedReleaseMs: 3 * 60_000, }; } diff --git a/src/dsh/types.ts b/src/dsh/types.ts index a9051f9..9898377 100644 --- a/src/dsh/types.ts +++ b/src/dsh/types.ts @@ -76,8 +76,13 @@ export interface SessionEventRecord { * hosts still expose `events`; both are accepted. */ export interface AgentSession { - /** Durable creation metadata: the recorded agent preset, if any. */ - header?: { id?: string; agentPreset?: string }; + /** + * Durable creation metadata: the recorded agent preset, if any, and the + * working directory the session was created with (`SessionHeader.cwd`). + * `cwd` is what the `send_wechat` file bound is evaluated against, so the + * field is declared here rather than reached for through a cast. + */ + header?: { id?: string; agentPreset?: string; cwd?: string }; /** Legacy in-memory log (pre-0.1.5). Prefer {@link snapshotEvents}. */ events?: readonly SessionEventRecord[]; /** diff --git a/src/index.ts b/src/index.ts index dffeba8..a4f085e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,14 @@ export interface PluginConfig { finalOnlyCeilingMs?: number; surfacePromptEnabled?: boolean; surfacePrompt?: string; + allowRemotePermissionEscalation?: boolean; + allowAnyFilePath?: boolean; + maxOutgoingFileBytes?: number; + mergeEnabled?: boolean; + mergeSilenceMs?: number; + mergeMaxMsgs?: number; + mergeSuffixTokens?: boolean; + mergeForcedReleaseMs?: number; } export function apply(ctx: unknown, rawConfig: PluginConfig = {}): () => Promise { @@ -141,7 +149,9 @@ export function apply(ctx: unknown, rawConfig: PluginConfig = {}): () => Promise "Send a text message or a local file to a WeChat user. " + "If the calling session is bound to a WeChat user, the message goes to that user; " + "otherwise it goes to the first known WeChat user (single-user deployments are the norm). " + - "Use this to proactively push results, confirmations, or files to WeChat.", + "Use this to proactively push results, confirmations, or files to WeChat. " + + "A file must live inside the calling session's working directory or a registered " + + "workspace, and must be under the configured size limit; anything else is refused.", parameters: { type: "object", properties: { @@ -151,7 +161,10 @@ export function apply(ctx: unknown, rawConfig: PluginConfig = {}): () => Promise }, file_path: { type: "string", - description: "Absolute path of a local file to send to WeChat.", + description: + "Path of a local file to send to WeChat. Must resolve inside the calling " + + "session's working directory or a registered workspace (unless the operator " + + "enabled allowAnyFilePath), and must fit maxOutgoingFileBytes.", }, }, }, diff --git a/src/state.ts b/src/state.ts index 8e8bd93..2c8451b 100644 --- a/src/state.ts +++ b/src/state.ts @@ -11,6 +11,7 @@ import fs from "node:fs"; import path from "node:path"; +import { ensurePrivateDir, writePrivateFile } from "./utils/secure-fs.js"; export interface UserState { /** The WeChat user id this state belongs to. */ @@ -101,7 +102,12 @@ function normalizeOutbound(value: unknown): OutboundState | undefined { version: 1, peerUserId: raw.peerUserId, messageCount: count, - queue: queue.slice(-MAX_OUTBOUND_QUEUE), + // The reload cap MUST match the runtime hard ceiling, not the soft cap: + // the runtime queue legally holds up to `MAX_OUTBOUND_QUEUE_HARD` (400) + // entries, so keeping only the last 100 here silently discarded items + // 101–400 of a full queue on every restart. `persistOutboundState` writes + // the whole bounded set, so a reload restores exactly what was saved. + queue: queue.slice(-MAX_OUTBOUND_QUEUE_HARD), }; } @@ -142,10 +148,12 @@ export class StateStore { } private save(): void { - fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + ensurePrivateDir(path.dirname(this.filePath)); const tempPath = `${this.filePath}.tmp-${process.pid}`; try { - fs.writeFileSync(tempPath, JSON.stringify(this.state, null, 2), "utf-8"); + // 0o600 on both the temp file and the renamed target: the state file + // carries the WeChat↔session mapping and the queued outbound bodies. + writePrivateFile(tempPath, JSON.stringify(this.state, null, 2)); fs.renameSync(tempPath, this.filePath); } catch (err) { try { diff --git a/src/utils/audit.ts b/src/utils/audit.ts new file mode 100644 index 0000000..7a1fae0 --- /dev/null +++ b/src/utils/audit.ts @@ -0,0 +1,126 @@ +/** + * Append-only audit trail for the bridge's security-relevant events. + * + * One JSON object per line in `/audit.jsonl`. What is recorded + * is deliberately shallow: WHO (a masked WeChat id), WHAT happened, and the + * SIZE of a payload — never the message body, never a token, never a full + * file content. The trail exists so an operator can answer "was a stranger + * turned away?", "who raised the global permission default?", "which file did + * the agent push to WeChat?" after the fact, without turning the log itself + * into a second copy of the conversation. + * + * Bounded by construction: the file is capped at {@link AUDIT_MAX_BYTES} and + * rotated to a single `.1` predecessor, so the sink can never grow + * without limit — the same rule the outbound queue follows ("a memory bound + * must be enforced by code that removes memory on every path; retry is never + * the bound"). Every rotation is logged loudly. + * + * Failure policy: auditing is best-effort and MUST NOT break the message + * path — a read-only storage directory would otherwise turn every send into a + * failure. The first failure is reported through the injected logger, then + * further failures are counted and reported only on the way back up (so a + * permanently broken sink does not flood the console). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { appendPrivateFile, chmodPrivate, ensurePrivateDir } from "./secure-fs.js"; + +/** Cap of one audit file before it is rotated. Keep the previous file too. */ +export const AUDIT_MAX_BYTES = 1 << 20; // 1 MiB +/** Longest single JSONL line kept — a runaway value must not eat the cap alone. */ +export const AUDIT_MAX_LINE_CHARS = 2000; + +export class AuditLog { + private readonly filePath: string; + private readonly previousPath: string; + private bytes = -1; + private failures = 0; + + constructor( + private readonly storageDir: string, + private readonly log: (message: string) => void, + /** Injectable for tests; defaults to the module constant. */ + private readonly maxBytes: number = AUDIT_MAX_BYTES, + ) { + this.filePath = path.join(storageDir, "audit.jsonl"); + this.previousPath = `${this.filePath}.1`; + } + + /** Absolute path of the live audit file (reported on failure by the caller's log line). */ + path(): string { + return this.filePath; + } + + /** Current on-disk size, or 0 when the file does not exist yet. */ + private currentBytes(): number { + if (this.bytes >= 0) return this.bytes; + try { + this.bytes = fs.statSync(this.filePath).size; + } catch { + this.bytes = 0; + } + return this.bytes; + } + + /** + * Append one event. Adds `time` (ISO-8601) to the record; the caller owns + * every other field and must already have masked identifiers and dropped + * bodies. + */ + record(event: Record): void { + let line: string; + try { + line = `${JSON.stringify({ time: new Date().toISOString(), ...event })}\n`; + } catch { + // A circular / non-serializable value must not break the caller. + this.fail("serialize"); + return; + } + if (line.length > AUDIT_MAX_LINE_CHARS) { + line = `${line.slice(0, AUDIT_MAX_LINE_CHARS - 2)}"}\n`; + } + try { + ensurePrivateDir(this.storageDir); + if (this.currentBytes() + line.length > this.maxBytes) this.rotate(); + appendPrivateFile(this.filePath, line); + this.bytes = this.currentBytes() + line.length; + if (this.failures > 0) { + this.log(`audit sink recovered after ${this.failures} failure(s)`); + this.failures = 0; + } + } catch (err) { + this.fail(`append: ${String(err)}`); + } + } + + /** + * Rename the live file to `.1`, replacing any previous rotation, and + * start a new one. Logged loudly: an audit trail that silently loses its + * history is worse than no trail at all. + */ + private rotate(): void { + try { + fs.renameSync(this.filePath, this.previousPath); + chmodPrivate(this.previousPath); + } catch (err) { + // Rename failed (e.g. the file vanished): truncate instead so the cap + // still holds. + this.log(`audit rotation failed, truncating instead: ${String(err)}`); + try { + fs.truncateSync(this.filePath, 0); + } catch { + // The append below will report whatever is really wrong. + } + } + this.log(`audit log reached ${this.maxBytes} bytes; rotated to ${path.basename(this.previousPath)}`); + this.bytes = 0; + } + + private fail(what: string): void { + this.failures += 1; + if (this.failures === 1) { + this.log(`audit ${what} failed; auditing is degraded (further failures are counted, not logged)`); + } + } +} diff --git a/src/utils/redact.ts b/src/utils/redact.ts index cd11a63..641d0e3 100644 --- a/src/utils/redact.ts +++ b/src/utils/redact.ts @@ -112,3 +112,22 @@ export function maskSecrets(text: string): string { } return maskBearerTokens(out); } + +/** + * Mask a WeChat user id for a LOG LINE. + * + * Logs are the one artifact that routinely leaves the machine (bug reports, + * CI transcripts, pasted terminal output), and a WeChat user id is a stable + * handle on a real person. The short head/tail are kept so an operator can + * still correlate two lines about the same sender, and a very short id is + * masked whole (there is nothing to keep that would not identify it). + * + * NOT used for text sent back to WeChat: the bound peer sees their own id in + * `/status` on purpose (S3 — the owner must never have to guess it), and an + * id must not be masked inside a message the user is supposed to read. + */ +export function maskUserId(userId: string): string { + if (!userId) return ""; + if (userId.length <= 4) return MASK; + return `${userId.slice(0, 2)}${MASK}${userId.slice(-2)}`; +} diff --git a/src/utils/secure-fs.ts b/src/utils/secure-fs.ts new file mode 100644 index 0000000..a27811c --- /dev/null +++ b/src/utils/secure-fs.ts @@ -0,0 +1,76 @@ +/** + * Filesystem hygiene for the plugin's own storage directory. + * + * Everything under `~/.dsh-wechat` is sensitive: the iLink bot token, the + * WeChat↔session mapping, the outbound queue, and the audit trail. The + * directory therefore gets `0o700` and every file `0o600`, applied + * unconditionally at creation time AND re-applied with an explicit `chmod` + * (a pre-existing file keeps its old, possibly world-readable, mode — + * `{ mode }` on `writeFileSync` only applies when the file is created). + * + * ⚠️ Windows: Node ignores the `mode` argument on Windows, and `chmod` + * there can only toggle the read-only bit. The hardening therefore relies on + * the inherited ACLs of `%USERPROFILE%` (only the user, `SYSTEM` and + * `Administrators` can read it) — documented in the README. On POSIX the + * modes above are real. + * + * Every helper here is best-effort about the CHMOD (a filesystem that does + * not support it must not break persistence) but NOT about the write itself: + * a caller that cannot persist must see the error. + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** Directory mode for the storage dir and its subdirectories. */ +export const PRIVATE_DIR_MODE = 0o700; +/** File mode for every file this plugin writes. */ +export const PRIVATE_FILE_MODE = 0o600; + +/** + * Create `dir` (recursively) with `0o700` and tighten an existing directory + * to the same mode. Never throws: a storage directory that cannot be created + * surfaces on the following write, where the caller already has error + * handling; failing here would turn plugin load into a crash. + */ +export function ensurePrivateDir(dir: string): void { + try { + fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }); + } catch { + return; + } + try { + fs.chmodSync(dir, PRIVATE_DIR_MODE); + } catch { + // Windows / unsupported filesystem: inherited ACLs apply. + } +} + +/** Tighten one existing file to `0o600`; silently ignores unsupported filesystems. */ +export function chmodPrivate(filePath: string): void { + try { + fs.chmodSync(filePath, PRIVATE_FILE_MODE); + } catch { + // Windows / unsupported filesystem. + } +} + +/** + * Write a file with `0o600`, creating its parent directory with `0o700`. + * `data` may be a string or a Buffer. + */ +export function writePrivateFile(filePath: string, data: string | Buffer): void { + ensurePrivateDir(path.dirname(filePath)); + fs.writeFileSync(filePath, data, { encoding: "utf-8", mode: PRIVATE_FILE_MODE }); + chmodPrivate(filePath); +} + +/** + * Append to a file with `0o600`, creating it (and its parent) private on + * first use. Used by the audit sink, which appends one line at a time. + */ +export function appendPrivateFile(filePath: string, data: string): void { + ensurePrivateDir(path.dirname(filePath)); + fs.appendFileSync(filePath, data, { encoding: "utf-8", mode: PRIVATE_FILE_MODE }); + chmodPrivate(filePath); +} diff --git a/src/weixin/auth.ts b/src/weixin/auth.ts index ba8afff..101a193 100644 --- a/src/weixin/auth.ts +++ b/src/weixin/auth.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import { getBotQrcode, getQrcodeStatus } from "./api.js"; +import { ensurePrivateDir, writePrivateFile } from "../utils/secure-fs.js"; export interface TokenData { token: string; @@ -35,9 +36,12 @@ export function loadToken(storageDir: string): TokenData | null { export function saveToken(storageDir: string, data: TokenData): void { const authDir = getAuthDir(storageDir); - fs.mkdirSync(authDir, { recursive: true }); + ensurePrivateDir(authDir); const tokenPath = getTokenPath(storageDir); - fs.writeFileSync(tokenPath, JSON.stringify(data, null, 2), "utf-8"); + // 0o600 (dir 0o700): this file IS the bot credential — anyone who can read + // it can send messages as the bot. On Windows Node ignores the mode and the + // inherited `%USERPROFILE%` ACLs apply (see src/utils/secure-fs.ts). + writePrivateFile(tokenPath, JSON.stringify(data, null, 2)); } export async function login(params: { diff --git a/src/weixin/monitor.ts b/src/weixin/monitor.ts index 5be40e2..9ceb764 100644 --- a/src/weixin/monitor.ts +++ b/src/weixin/monitor.ts @@ -11,6 +11,7 @@ import { notifyStart, isSessionTimeoutError, } from "./api.js"; +import { writePrivateFile } from "../utils/secure-fs.js"; import type { WeixinMessage, GetUpdatesResp } from "./types.js"; const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000; @@ -71,8 +72,10 @@ function loadSyncBuf(storageDir: string): string { } function saveSyncBuf(storageDir: string, buf: string): void { - fs.mkdirSync(storageDir, { recursive: true }); - fs.writeFileSync(getSyncBufPath(storageDir), JSON.stringify({ get_updates_buf: buf }), "utf-8"); + // The long-poll cursor is not a secret, but it lives in the same private + // directory as the token: one mode policy for every file the plugin writes + // (0o700 dir / 0o600 file; Windows relies on inherited ACLs). + writePrivateFile(getSyncBufPath(storageDir), JSON.stringify({ get_updates_buf: buf })); } /** Drop the long-poll cursor so a new bot session does not resume the old one. */ diff --git a/tests/bridge-core.test.ts b/tests/bridge-core.test.ts index 9b1275c..997209f 100644 --- a/tests/bridge-core.test.ts +++ b/tests/bridge-core.test.ts @@ -30,6 +30,7 @@ import { isBypassSlashCommand, } from "../src/bridge/slash.js"; import { StateStore } from "../src/state.js"; +import { MAX_OUTBOUND_QUEUE_HARD } from "../src/state.js"; import { createUserMessage } from "../src/dsh/messages.js"; describe("slash parsers", () => { @@ -388,7 +389,7 @@ describe("StateStore", () => { } }); - it("normalizes malformed outbound state and caps the one FIFO", () => { + it("normalizes malformed outbound state and keeps the whole bounded queue", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wechat-state-")); try { const queue = Array.from({ length: 105 }, (_, i) => ({ kind: "text", text: `m${i}` })); @@ -399,14 +400,35 @@ describe("StateStore", () => { }), "utf-8"); const outbound = new StateStore(dir).outbound(); expect(outbound?.messageCount).toBe(10); - expect(outbound?.queue).toHaveLength(100); - expect(outbound?.queue[0]).toEqual({ kind: "text", text: "m5" }); + // R3: the reload cap is the runtime HARD ceiling (400), not the 100-entry + // soft cap. Keeping only the last 100 silently discarded items 101–400 + // of a legitimately full queue on every restart. The malformed entry is + // still dropped, so all 105 valid items survive. + expect(outbound?.queue).toHaveLength(MAX_OUTBOUND_QUEUE_HARD > 105 ? 105 : MAX_OUTBOUND_QUEUE_HARD); + expect(outbound?.queue[0]).toEqual({ kind: "text", text: "m0" }); expect(outbound?.queue.at(-1)).toEqual({ kind: "text", text: "m104" }); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + it("still truncates a queue larger than the runtime hard ceiling", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wechat-state-")); + try { + const queue = Array.from({ length: MAX_OUTBOUND_QUEUE_HARD + 25 }, (_, i) => ({ kind: "text", text: `m${i}` })); + fs.writeFileSync(path.join(dir, "state.json"), JSON.stringify({ + users: {}, + outbound: { version: 1, peerUserId: "u1", messageCount: 1, queue }, + }), "utf-8"); + const outbound = new StateStore(dir).outbound(); + expect(outbound?.queue).toHaveLength(MAX_OUTBOUND_QUEUE_HARD); + expect(outbound?.queue[0]).toEqual({ kind: "text", text: "m25" }); + expect(outbound?.queue.at(-1)).toEqual({ kind: "text", text: `m${MAX_OUTBOUND_QUEUE_HARD + 24}` }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("clearUsers drops every persisted WeChat user", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wechat-state-")); try { diff --git a/tests/busy-enter.test.ts b/tests/busy-enter.test.ts index 874ddc4..e21e47d 100644 --- a/tests/busy-enter.test.ts +++ b/tests/busy-enter.test.ts @@ -111,6 +111,10 @@ function makeBridge( }; const cfg = defaultConfig(); cfg.storageDir = dir; + // This suite is about busy-Enter delivery, not about the inbound merge + // window: a 0ms window dispatches every message immediately, which is the + // one-turn-per-message behaviour these cases assert. + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", diff --git a/tests/client-panel-wiring.test.ts b/tests/client-panel-wiring.test.ts index c060bcc..7e9c9bc 100644 --- a/tests/client-panel-wiring.test.ts +++ b/tests/client-panel-wiring.test.ts @@ -34,6 +34,11 @@ const RENDERED_TOGGLES = [ "silent", "finalOnly", "surfacePromptEnabled", + // Hardening wave: the merge window and the two security switches. + "mergeEnabled", + "mergeSuffixTokens", + "allowRemotePermissionEscalation", + "allowAnyFilePath", ] as const; describe("settings panel ↔ host config wiring", () => { @@ -63,11 +68,28 @@ describe("settings panel ↔ host config wiring", () => { expect(client).toContain("/final on|off"); }); + it("the 打字合并 control points at the WeChat-side switch", () => { + expect(client).toContain("打字合并"); + expect(client).toContain("/merge on|off"); + // The suffix tokens are the reason the feature is usable at all; the + // panel has to document them. + expect(client).toContain("`..`"); + expect(client).toContain("`!!`"); + }); + // The two text/number fields have their own silent-failure modes, so they are // guarded the same way the checkboxes are: a field that is missing from the // form init renders empty, and one missing from the save payload sends // nothing while the panel still reports success. - it.each(["sendWechatRateLimitPerMinute", "allowedUsers"])( + it.each([ + "sendWechatRateLimitPerMinute", + "allowedUsers", + // Hardening wave: the merge parameters and the outgoing-file size cap. + "mergeSilenceMs", + "mergeMaxMsgs", + "mergeForcedReleaseMs", + "maxOutgoingFileBytes", + ])( "%s is initialised, rendered and saved", (key) => { expect(client).toContain(`r.body.config?.${key}`); diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index cfd6ef8..06fe5bf 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -318,4 +318,75 @@ describe("ConfigStore", () => { expect(cfg.sendWechatRateLimitPerMinute).toBe(10); expect(cfg.allowedUsers).toEqual([]); }); + + it("defaults the hardening wave: no escalation, no arbitrary paths, merge on", () => { + const cfg = defaultConfig(); + // Security defaults must be the restrictive ones. + expect(cfg.allowRemotePermissionEscalation).toBe(false); + expect(cfg.allowAnyFilePath).toBe(false); + expect(cfg.maxOutgoingFileBytes).toBe(20 * 1024 * 1024); + // 打字合并 is ON by default — the one default that changes existing + // behaviour (documented in README / CHANGELOG). + expect(cfg.mergeEnabled).toBe(true); + expect(cfg.mergeSilenceMs).toBe(3000); + expect(cfg.mergeMaxMsgs).toBe(10); + expect(cfg.mergeSuffixTokens).toBe(true); + expect(cfg.mergeForcedReleaseMs).toBe(180_000); + }); + + it("persists the new booleans and numbers with type guards", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wechat-cfg-")); + try { + const store = new ConfigStore(dir); + store.update({ + mergeEnabled: false, + mergeSuffixTokens: false, + allowRemotePermissionEscalation: true, + allowAnyFilePath: true, + mergeSilenceMs: 0, + mergeMaxMsgs: 3, + mergeForcedReleaseMs: 1000, + maxOutgoingFileBytes: 4096, + }); + const stored = new ConfigStore(dir).stored(); + expect(stored.mergeEnabled).toBe(false); + expect(stored.allowAnyFilePath).toBe(true); + // 0 = "no merge window" is meaningful and must survive. + expect(stored.mergeSilenceMs).toBe(0); + expect(stored.mergeMaxMsgs).toBe(3); + expect(stored.maxOutgoingFileBytes).toBe(4096); + + // Junk is refused rather than persisted. + store.update({ mergeEnabled: "yes" as never, mergeSilenceMs: -1 as never, maxOutgoingFileBytes: 0 as never }); + const after = new ConfigStore(dir).stored(); + expect(after.mergeEnabled).toBe(false); + expect(after.mergeSilenceMs).toBe(0); + expect(after.maxOutgoingFileBytes).toBe(4096); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("sanitises hand-edited hardening keys read straight from config.json", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wechat-cfg-")); + try { + fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify({ + mergeEnabled: "no", + allowAnyFilePath: 1, + maxOutgoingFileBytes: "4096", + mergeSilenceMs: null, + mergeMaxMsgs: 5, + }), "utf-8"); + const stored = new ConfigStore(dir).stored(); + // Malformed values are dropped so the built-in default applies; a + // truthy string must never switch a security default on. + expect(stored.mergeEnabled).toBeUndefined(); + expect(stored.allowAnyFilePath).toBeUndefined(); + expect(stored.maxOutgoingFileBytes).toBeUndefined(); + expect(stored.mergeSilenceMs).toBeUndefined(); + expect(stored.mergeMaxMsgs).toBe(5); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/corrupt-session.test.ts b/tests/corrupt-session.test.ts index 7395b86..f1f628c 100644 --- a/tests/corrupt-session.test.ts +++ b/tests/corrupt-session.test.ts @@ -133,6 +133,8 @@ describe("forwarding replaces a corrupt bound session", () => { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms) — see the note in the other fixture. + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", @@ -216,6 +218,9 @@ describe("/s new does not resume the bound session via native dispatch", () => { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms): the corrupt-session replacement must be visible + // on the very message that triggers it, not behind a silence timer. + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", diff --git a/tests/delivery-hardening.test.ts b/tests/delivery-hardening.test.ts new file mode 100644 index 0000000..25376ea --- /dev/null +++ b/tests/delivery-hardening.test.ts @@ -0,0 +1,367 @@ +/** + * Delivery hardening wave — S5, R1, R2, R3. + * + * Each case drives a real public entry and (where a lock is involved) asserts + * SETTLEMENT WITHIN A TIMEOUT: a direct-helper test cannot observe a + * serialization slot that never resolves. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +const sendTextMessage = vi.fn().mockResolvedValue(undefined); +const sendMediaMessage = vi.fn().mockResolvedValue(undefined); + +vi.mock("../src/weixin/send.js", () => ({ + sendTextMessage: (...args: unknown[]) => sendTextMessage(...args), + sendMediaMessage: (...args: unknown[]) => sendMediaMessage(...args), + splitText: (text: string, maxLen: number) => + text.length <= maxLen ? [text] : [text.slice(0, maxLen), text.slice(maxLen)], +})); + +vi.mock("../src/weixin/api.js", () => ({ + sendTyping: () => Promise.resolve(undefined), + getConfig: () => Promise.resolve({ typing_ticket: "tk" }), + isSessionTimeoutError: () => false, + isMessageLimitError: () => false, + isInvalidRequestError: () => false, +})); + +import { WeChatDSHBridge } from "../src/bridge/bridge.js"; +import { defaultConfig } from "../src/config.js"; +import { MessageType } from "../src/weixin/types.js"; + +const live: Array<{ stop(): Promise }> = []; + +function tmpDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +const agent = { + id: "wx-s1", + status: "idle" as const, + options: { provider: "deepseek", model: "deepseek-chat" }, + followup: () => {}, + steer: () => {}, + cancel: () => {}, + whenIdle: async () => {}, +}; + +function makeBridge(patch: Record = {}, ctxExtra: Record = {}) { + const dir = tmpDir("dsh-wx-resil-"); + const services: Record = { + agents: { + get: (id: string) => (id === "wx-s1" ? agent : undefined), + create: async () => ({ agent }), + resume: async () => ({ agent }), + list: () => [agent], + }, + ...ctxExtra, + }; + const bridge = new WeChatDSHBridge( + { get: (name: string) => services[name], on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0, ...patch }, + ); + (bridge as unknown as { token: unknown }).token = { + baseUrl: "https://gw", + token: "t", + accountId: "b1", + userId: "bot", + savedAt: "", + }; + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + live.push(bridge); + return { bridge, dir }; +} + +/** Race a promise against a deadline; resolves to "timeout" when it hangs. */ +async function within(p: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + p, + new Promise<"timeout">((resolve) => { timer = setTimeout(() => resolve("timeout"), ms); }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const asInbound = (bridge: unknown) => bridge as { handleMessage(m: unknown): Promise }; +const textMessage = (text: string, messageId?: number) => ({ + message_type: MessageType.USER, + from_user_id: "u1", + context_token: "tok", + ...(messageId !== undefined ? { message_id: messageId } : {}), + item_list: [{ type: 1, text_item: { text } }], +}); +const replies = () => sendTextMessage.mock.calls.map((c) => String(c[1])); + +beforeEach(() => { + vi.clearAllMocks(); + sendTextMessage.mockResolvedValue(undefined); + sendMediaMessage.mockResolvedValue(undefined); +}); + +afterEach(async () => { + vi.useRealTimers(); + for (const bridge of live.splice(0)) await bridge.stop().catch(() => {}); +}); + +// ─── S5: a card is marked pushed only when it can actually be pushed ─── + +function seedApprovalCard(bridge: unknown, rpcId = "rpc-1", toolName = "pwsh") { + (bridge as { handleMuxFrame(f: unknown): void }).handleMuxFrame({ + type: "server-request", + rpcId, + method: "approval/request", + payload: { type: "approval/requested", sessionId: "wx-s1", approvalId: `appr-${rpcId}`, toolName }, + }); +} + +describe("S5 — card reachability is confirmed before the pushed-mark is taken", () => { + it("does not claim the mark while the bot is logged out, and delivers on recovery", async () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + token: unknown; + isCardPushed(userId: string, rpcId: string): boolean; + }; + const savedToken = anyBridge.token; + anyBridge.token = null; // logged out: sendReply DROPS rather than parks + + seedApprovalCard(bridge); + expect(sendTextMessage).not.toHaveBeenCalled(); + // The fix under test: the one-shot mark is NOT taken for a delivery that + // never happened, so a later attempt can still push the card. + expect(anyBridge.isCardPushed("u1", "rpc-1")).toBe(false); + + // Recovery, then the normal "push every unseen card" path. + anyBridge.token = savedToken; + await asInbound(bridge).handleMessage(textMessage("/notify on")); + expect(replies().some((r) => r.includes("pwsh"))).toBe(true); + expect(anyBridge.isCardPushed("u1", "rpc-1")).toBe(true); + }); + + it("does not claim the mark after -14 give-up either", () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + tokenGiveUp: boolean; + isCardPushed(userId: string, rpcId: string): boolean; + }; + anyBridge.tokenGiveUp = true; + seedApprovalCard(bridge, "rpc-2"); + expect(anyBridge.isCardPushed("u1", "rpc-2")).toBe(false); + expect(sendTextMessage).not.toHaveBeenCalled(); + }); + + it("rolls the mark back when the push itself fails", async () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + isCardPushed(userId: string, rpcId: string): boolean; + }; + vi.spyOn(bridge as never, "sendApprovalCard" as never).mockRejectedValue(new Error("boom") as never); + seedApprovalCard(bridge, "rpc-3"); + await sleep(10); + expect(anyBridge.isCardPushed("u1", "rpc-3")).toBe(false); + }); + + it("still marks a card that WAS delivered", async () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { isCardPushed(userId: string, rpcId: string): boolean }; + seedApprovalCard(bridge, "rpc-4"); + await sleep(10); + expect(anyBridge.isCardPushed("u1", "rpc-4")).toBe(true); + expect(replies().some((r) => r.includes("pwsh"))).toBe(true); + }); +}); + +// ─── R1: the outbound slot fails loudly and cannot wedge the channel ─── + +describe("R1 — outbound serialization fails loudly, with an escape", () => { + it("a public entry called from inside the locked body is refused instead of deadlocking", async () => { + const { bridge } = makeBridge(); + let reentryError: unknown; + let outerSettled = false; + sendTextMessage.mockImplementation(async () => { + // The exact defect class: a public (lock-acquiring) entry reached from + // inside the body that already holds the slot. + try { + await (bridge as never as { sendReply(u: string, t: string): Promise }).sendReply("u1", "nested"); + } catch (err) { + reentryError = err; + } + }); + + const outcome = await within( + bridge.handleSendWeChat("wx-s1", { text: "outer" }), + 3000, + ).then((result) => { + outerSettled = result !== "timeout"; + return result; + }); + expect(outerSettled).toBe(true); + expect(outcome).not.toBe("timeout"); + expect((reentryError as Error)?.message).toMatch(/re-entered/); + + // The chain is still usable afterwards (no wedge, no poisoned tail). + sendTextMessage.mockResolvedValue(undefined); + const after = await within( + (bridge as never as { sendReply(u: string, t: string): Promise }).sendReply("u1", "after"), + 3000, + ); + expect(after).not.toBe("timeout"); + expect(replies()).toContain("after"); + }); + + it("a hung outbound operation cannot block every later send (bounded wait)", async () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + outboundSlotWaitMs: number; + sendReply(u: string, t: string): Promise; + }; + anyBridge.outboundSlotWaitMs = 100; + let call = 0; + sendTextMessage.mockImplementation(async () => { + call++; + if (call === 1) await new Promise(() => {}); // never settles + }); + + const logs: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.map(String).join(" "))); + try { + void anyBridge.sendReply("u1", "hung"); + await sleep(20); + const second = await within(anyBridge.sendReply("u1", "later"), 2000); + expect(second).not.toBe("timeout"); + } finally { + spy.mockRestore(); + } + expect(sendTextMessage).toHaveBeenCalledTimes(2); + expect(logs.some((l) => l.includes("outbound slot still held"))).toBe(true); + }); +}); + +// ─── R2: the per-session maps are bounded ─── + +describe("R2 — bounded per-session maps", () => { + it("caps lastAssistantTextBySession and evicts the oldest deterministically", () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + lastAssistantTextBySession: Map; + handleSessionEvent(sessionId: string, event: unknown): void; + }; + for (let i = 0; i < 70; i++) { + anyBridge.handleSessionEvent(`s-${i}`, { + type: "assistant/message", + seq: i, + time: Date.now(), + data: { turn: 1, step: 1, message: { content: [{ type: "text", text: `body-${i}` }] } }, + }); + } + const map = anyBridge.lastAssistantTextBySession; + expect(map.size).toBeLessThanOrEqual(64); + expect(map.has("s-0")).toBe(false); // oldest evicted + expect(map.get("s-69")).toBe("body-69"); // newest kept + }); + + it("caps lastActivityBySession while keeping the newest sessions", () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + lastActivityBySession: Map; + handleSessionEvent(sessionId: string, event: unknown): void; + }; + for (let i = 0; i < 520; i++) { + anyBridge.handleSessionEvent(`act-${i}`, { + type: "user/message", + seq: i, + time: 1_000 + i, + data: { source: { kind: "user" } }, + }); + } + const map = anyBridge.lastActivityBySession; + expect(map.size).toBeLessThanOrEqual(512); + expect(map.has("act-0")).toBe(false); + expect(map.get("act-519")).toBe(1_000 + 519); + }); + + it("expires a WeChat message-id mark that is never echoed (TTL), via the real inbound entry", async () => { + vi.useFakeTimers(); + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { wechatMessageIds: Map }; + await asInbound(bridge).handleMessage(textMessage("你好", 9_001)); + expect(anyBridge.wechatMessageIds.size).toBe(1); + await vi.advanceTimersByTimeAsync(10 * 60_000 + 5); + // The mark is one-shot with an armed release: an echo that never arrives + // must not pin the id for the process lifetime. + expect(anyBridge.wechatMessageIds.size).toBe(0); + }); + + it("caps wechatMessageIds as a hard ceiling above the TTL", () => { + const { bridge } = makeBridge(); + const anyBridge = bridge as unknown as { + wechatMessageIds: Map; + markWechatMessage(id: string): void; + }; + for (let i = 0; i < 600; i++) anyBridge.markWechatMessage(`id-${i}`); + expect(anyBridge.wechatMessageIds.size).toBeLessThanOrEqual(512); + expect(anyBridge.wechatMessageIds.has("id-0")).toBe(false); + expect(anyBridge.wechatMessageIds.has("id-599")).toBe(true); + }); + + it("caps modelOverrides and keeps the newest sessions' overrides", async () => { + const llm = { + listProviders: () => [{ id: "deepseek", name: "DeepSeek" }], + listModels: async () => [{ provider: "deepseek", id: "deepseek-chat", name: "Chat" }], + resolveModelInfo: async () => ({ reasoning: undefined }), + }; + // Each `sess-N` gets its own live agent, so the override is keyed by the + // session the command actually ran against. + const agents = { + get: (id: string) => (id.startsWith("sess-") ? { ...agent, id } : undefined), + create: async () => ({ agent }), + resume: async () => ({ agent }), + list: () => [agent], + }; + const { bridge } = makeBridge({}, { + llm, + agents, + agentDefaultModel: { currentSelection: () => undefined, saveSelection: async () => {} }, + }); + const anyBridge = bridge as unknown as { modelOverrides: Map }; + // Drive the real command so the cap is exercised through its actual writer. + for (let i = 0; i < 130; i++) { + (bridge as any).state.update("u1", { sessionId: `sess-${i}` }); + await asInbound(bridge).handleMessage(textMessage("/model switch deepseek/deepseek-chat")); + } + expect(anyBridge.modelOverrides.size).toBeLessThanOrEqual(128); + expect(anyBridge.modelOverrides.has("sess-0")).toBe(false); + expect(anyBridge.modelOverrides.has("sess-129")).toBe(true); + }); +}); + +// ─── R3: a restart restores the whole bounded queue ─── + +describe("R3 — state.json reload keeps the whole bounded outbound queue", () => { + it("restores 150 queued items instead of truncating to the last 100", () => { + const dir = tmpDir("dsh-wx-restore-"); + const queue = Array.from({ length: 150 }, (_, i) => ({ kind: "text", text: `queued-${i}` })); + fs.writeFileSync(path.join(dir, "state.json"), JSON.stringify({ + users: { u1: { userId: "u1", sessionId: "wx-s1", cwd: dir, silent: false, crossSessionNotify: "inherit", watchedSessions: [] } }, + outbound: { version: 1, peerUserId: "u1", messageCount: 3, queue }, + }), "utf-8"); + + const bridge = new WeChatDSHBridge( + { get: () => undefined, on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0 }, + ); + live.push(bridge); + const restored = (bridge as unknown as { outboundCache: Array<{ text?: string }> }).outboundCache; + expect(restored).toHaveLength(150); + expect(restored[0]?.text).toBe("queued-0"); + expect(restored.at(-1)?.text).toBe("queued-149"); + }); +}); diff --git a/tests/forwarding.test.ts b/tests/forwarding.test.ts index 86d6f20..0002c43 100644 --- a/tests/forwarding.test.ts +++ b/tests/forwarding.test.ts @@ -56,6 +56,9 @@ function makeBridge(agent: { agent: unknown; received: unknown[] }) { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms): this chain must stay observable per message — the + // merge window has its own suite (tests/merge-window.test.ts). + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "u", savedAt: "" }; return bridge; diff --git a/tests/issue-9-inbound.test.ts b/tests/issue-9-inbound.test.ts index 3b4bc47..e5506fd 100644 --- a/tests/issue-9-inbound.test.ts +++ b/tests/issue-9-inbound.test.ts @@ -53,7 +53,10 @@ function fixture(gate?: Promise) { }; const ctx = { get: (name: string) => name === "agents" ? service : undefined, on: () => () => {} } as BridgeContext; const dir = tempDir(); - const bridge = new WeChatDSHBridge(ctx, { ...defaultConfig(), storageDir: dir }); + // Merge window disabled (0ms): this suite asserts the inbound IDENTITY / + // replay semantics of one message at a time, which the merge window would + // otherwise defer behind its silence timer. + const bridge = new WeChatDSHBridge(ctx, { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0 }); bridges.push(bridge); const token = { baseUrl: "https://gw.invalid", token: "fake", accountId: "b1", userId: "u1", savedAt: "" }; const harness = bridge as unknown as { diff --git a/tests/merge-window.test.ts b/tests/merge-window.test.ts new file mode 100644 index 0000000..b58c315 --- /dev/null +++ b/tests/merge-window.test.ts @@ -0,0 +1,451 @@ +/** + * F1 — inbound merge window (打字合并). + * + * The window exists so one thought typed as five WeChat messages costs ONE + * turn. Every case here drives the real public inbound entry + * (`handleMessage`) and asserts what actually reached `agent.followup`, in + * order. The safety rules the feature must satisfy are asserted explicitly: + * + * - an OUT-OF-ORDER older message must not extend the silence timer; + * - the buffered count is capped; + * - a forced release always delivers, whatever the window/suffix state; + * - a message arriving while a batch is in flight is not swallowed; + * - non-text items are never merged (the pending text goes first). + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +const sendTextMessage = vi.fn().mockResolvedValue(undefined); +const sendMediaMessage = vi.fn().mockResolvedValue(undefined); + +vi.mock("../src/weixin/send.js", () => ({ + sendTextMessage: (...args: unknown[]) => sendTextMessage(...args), + sendMediaMessage: (...args: unknown[]) => sendMediaMessage(...args), + splitText: (text: string, maxLen: number) => + text.length <= maxLen ? [text] : [text.slice(0, maxLen), text.slice(maxLen)], +})); + +import { WeChatDSHBridge } from "../src/bridge/bridge.js"; +import { ConfigStore } from "../src/config-store.js"; +import { defaultConfig } from "../src/config.js"; +import { MessageType } from "../src/weixin/types.js"; +import { + formatHelp, + isBypassSlashCommand, + LOCAL_COMMAND_NAMES, + parseCommandName, + parseEnterCommand, + parseFinalCommand, + parseFlushCommand, + parseHelpCommand, + parseHistoryCommand, + parseMergeCommand, + parseModelCommand, + parseNextCommand, + parseNotifyCommand, + parsePermCommand, + parsePresetCommand, + parseReasoningCommand, + parseSessionCommand, + parseSilentCommand, + parseStatusCommand, + parseStopCommand, + parseSurfaceCommand, + parseWorkspaceCommand, +} from "../src/bridge/slash.js"; + +const live: Array<{ stop(): Promise }> = []; + +type Patch = Record; + +function makeBridge(patch: Patch = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wx-merge-")); + const received: Array<{ text: string; at: number }> = []; + const liveAgents = new Map(); + const agentsService = { + create: async (options: { sessionId: string }) => { + const agent = { + id: options.sessionId, + status: "idle", + options: { provider: "deepseek", model: "deepseek-chat" }, + followup: (m: { content: Array<{ text?: string }> }) => { + received.push({ text: m.content.map((b) => b.text ?? "").join(""), at: Date.now() }); + }, + steer: () => {}, + cancel: () => {}, + whenIdle: async () => {}, + }; + liveAgents.set(agent.id, agent); + return { agent }; + }, + resume: async () => undefined, + get: (id: string) => liveAgents.get(id), + list: () => [...liveAgents.values()], + }; + const cfg = { ...defaultConfig(), storageDir: dir, ...patch }; + const bridge = new WeChatDSHBridge( + { get: (name: string) => (name === "agents" ? agentsService : undefined), on: () => () => {} }, + cfg, + ); + (bridge as unknown as { token: unknown }).token = { + baseUrl: "https://gw", + token: "t", + accountId: "b1", + userId: "bot", + savedAt: "", + }; + live.push(bridge); + return { bridge, received, dir, cfg }; +} + +/** One inbound text message; `create_time_ms` drives the out-of-order guard. */ +function text(text: string, createTimeMs?: number) { + return { + message_type: MessageType.USER, + from_user_id: "u1", + context_token: "tok", + ...(createTimeMs !== undefined ? { create_time_ms: createTimeMs } : {}), + item_list: [{ type: 1, text_item: { text } }], + }; +} + +/** A text message carrying an image item — never merged. */ +function textWithImage(text: string) { + return { + message_type: MessageType.USER, + from_user_id: "u1", + context_token: "tok", + item_list: [ + { type: 1, text_item: { text } }, + { type: 2, image_item: {} }, + ], + }; +} + +const asInbound = (bridge: unknown) => bridge as { handleMessage(m: unknown): Promise }; +const texts = (received: Array<{ text: string }>) => received.map((r) => r.text); + +beforeEach(() => { + vi.clearAllMocks(); + sendTextMessage.mockResolvedValue(undefined); + sendMediaMessage.mockResolvedValue(undefined); +}); + +afterEach(async () => { + vi.useRealTimers(); + for (const bridge of live.splice(0)) await bridge.stop().catch(() => {}); +}); + +describe("merge window: basic batching", () => { + it("five messages inside the window become ONE prompt, joined in order", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 25 }); + const inbound = asInbound(bridge); + for (const part of ["第一句", "第二句", "第三句", "第四句", "第五句"]) { + await inbound.handleMessage(text(part)); + } + expect(received).toHaveLength(0); + // Generous margin over the 25ms window: CI timers can be late, and a slow + // machine must not turn "batched correctly" into a flake. + await new Promise((r) => setTimeout(r, 400)); + expect(texts(received)).toEqual(["第一句\n第二句\n第三句\n第四句\n第五句"]); + }); + + it("the message cap flushes immediately, as one prompt", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000, mergeMaxMsgs: 3 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("a")); + await inbound.handleMessage(text("b")); + expect(received).toHaveLength(0); + await inbound.handleMessage(text("c")); + expect(texts(received)).toEqual(["a\nb\nc"]); + }); + + it("a batch is delivered once and the buffer is drained (no write-only state)", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 20 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("一次")); + await new Promise((r) => setTimeout(r, 300)); + expect(texts(received)).toEqual(["一次"]); + await new Promise((r) => setTimeout(r, 200)); + expect(received).toHaveLength(1); + }); + + it("an out-of-order OLDER message does not extend the silence timer", async () => { + vi.useFakeTimers(); + const { bridge, received } = makeBridge({ mergeSilenceMs: 1000 }); + const inbound = asInbound(bridge); + + await inbound.handleMessage(text("新消息", 5_000)); + await vi.advanceTimersByTimeAsync(600); + // A late replay of an older message arrives: it is appended, but it must + // NOT push the deadline out (otherwise a replay could hold the batch open + // while the user waits for an answer). + await inbound.handleMessage(text("迟到的旧消息", 4_000)); + await vi.advanceTimersByTimeAsync(500); + expect(texts(received)).toEqual(["新消息\n迟到的旧消息"]); + }); + + it("a strictly newer message DOES extend the silence timer", async () => { + vi.useFakeTimers(); + const { bridge, received } = makeBridge({ mergeSilenceMs: 1000 }); + const inbound = asInbound(bridge); + + await inbound.handleMessage(text("first", 1_000)); + await vi.advanceTimersByTimeAsync(600); + await inbound.handleMessage(text("second", 2_000)); + await vi.advanceTimersByTimeAsync(600); + // 1200ms after the first message, but only 600ms after the second. + expect(received).toHaveLength(0); + await vi.advanceTimersByTimeAsync(500); + expect(texts(received)).toEqual(["first\nsecond"]); + }); +}); + +describe("merge window: suffix tokens", () => { + it("`!!` commits immediately and is stripped from the text", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("现在就说 !!")); + expect(texts(received)).toEqual(["现在就说"]); + }); + + it("`..` keeps the batch open past the silence window and is stripped", async () => { + vi.useFakeTimers(); + const { bridge, received } = makeBridge({ mergeSilenceMs: 500, mergeForcedReleaseMs: 60_000 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("还没说完 ..")); + await vi.advanceTimersByTimeAsync(5_000); + expect(received).toHaveLength(0); + await inbound.handleMessage(text("补充一句 !!")); + expect(texts(received)).toEqual(["还没说完\n补充一句"]); + }); + + it("a message that is ONLY the token keeps its literal text when nothing is buffered", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 25 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("!!")); + // Nothing to control: the token is ordinary text and is not swallowed. + await new Promise((r) => setTimeout(r, 300)); + expect(texts(received)).toEqual(["!!"]); + }); + + it("a bare `!!` commits the pending batch without polluting it", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("先说这句")); + expect(received).toHaveLength(0); + await inbound.handleMessage(text("!!")); + expect(texts(received)).toEqual(["先说这句"]); + }); + + it("mergeSuffixTokens=false leaves the tokens in the text", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000, mergeSuffixTokens: false }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("照原样 !!")); + expect(received).toHaveLength(0); + await inbound.handleMessage(text("继续")); + await inbound.handleMessage(text("x".repeat(1))); + // Cap not reached and no commit: still buffered, tokens intact. + expect(received).toHaveLength(0); + }); +}); + +describe("merge window: forced release and in-flight batches", () => { + it("the forced-release ceiling delivers even while a `..` hold is open", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000, mergeForcedReleaseMs: 40 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("一直没说 ..")); + expect(received).toHaveLength(0); + await new Promise((r) => setTimeout(r, 400)); + expect(texts(received)).toEqual(["一直没说"]); + }); + + it("a message arriving while a batch is in flight is not swallowed", async () => { + const { bridge } = makeBridge({ mergeSilenceMs: 20, mergeForcedReleaseMs: 60_000 }); + const dispatched: string[] = []; + let releaseFirst: () => void = () => {}; + const gate = new Promise((resolve) => { releaseFirst = resolve; }); + let first = true; + // The sink is stubbed so "in flight" is controllable; the buffering and + // flushing under test are the real ones (driven through handleMessage). + vi.spyOn(bridge as never, "forwardToAgent" as never).mockImplementation((async ( + _user: unknown, + msg: { item_list: Array<{ text_item?: { text?: string } }> }, + ) => { + if (first) { + first = false; + await gate; + } + dispatched.push(msg.item_list[0]?.text_item?.text ?? ""); + }) as never); + + void asInbound(bridge).handleMessage(text("第一批 !!")); + await new Promise((r) => setTimeout(r, 30)); + expect(dispatched).toEqual([]); + + // Arrives while the first dispatch is still in flight: it must be queued + // as a NEW batch, never merged into the delivered one and never dropped. + void asInbound(bridge).handleMessage(text("第二批 !!")); + await new Promise((r) => setTimeout(r, 30)); + releaseFirst(); + await new Promise((r) => setTimeout(r, 200)); + expect(dispatched).toEqual(["第一批", "第二批"]); + }); + + it("a batch blocked behind a wedged dispatch goes out after the bounded wait", async () => { + const { bridge } = makeBridge({ mergeSilenceMs: 20, mergeForcedReleaseMs: 60 }); + const dispatched: string[] = []; + // The first dispatch NEVER settles: exactly the "one hung operation must + // not block the channel" case the forced release exists for. + let first = true; + vi.spyOn(bridge as never, "forwardToAgent" as never).mockImplementation((async ( + _user: unknown, + msg: { item_list: Array<{ text_item?: { text?: string } }> }, + ) => { + if (first) { + first = false; + await new Promise(() => {}); + } + dispatched.push(msg.item_list[0]?.text_item?.text ?? ""); + }) as never); + + void asInbound(bridge).handleMessage(text("卡住的一批 !!")); + await new Promise((r) => setTimeout(r, 30)); + void asInbound(bridge).handleMessage(text("后面的一批 !!")); + + const deadline = Date.now() + 2000; + while (dispatched.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 20)); + } + expect(dispatched).toEqual(["后面的一批"]); + }); +}); + +describe("merge window: non-text items are never merged", () => { + it("an image mid-window flushes the pending text first, then goes as its own turn", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("先说的话")); + expect(received).toHaveLength(0); + await inbound.handleMessage(textWithImage("看这张图")); + expect(texts(received)).toEqual(["先说的话", "看这张图"]); + }); + + it("a media item with nothing buffered goes straight through", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 60_000 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(textWithImage("只有图")); + expect(texts(received)).toEqual(["只有图"]); + }); +}); + +describe("merge window: switch and persistence", () => { + it("mergeSilenceMs=0 disables the window (one turn per message)", async () => { + const { bridge, received } = makeBridge({ mergeSilenceMs: 0 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("一")); + await inbound.handleMessage(text("二")); + expect(texts(received)).toEqual(["一", "二"]); + }); + + it("/merge status reports the live parameters and the buffered count", async () => { + const { bridge } = makeBridge({ mergeSilenceMs: 60_000, mergeMaxMsgs: 4 }); + const inbound = asInbound(bridge); + await inbound.handleMessage(text("待合并")); + await inbound.handleMessage(text("/merge")); + const status = sendTextMessage.mock.calls.map((c) => String(c[1])).find((t) => t.includes("打字合并")); + expect(status).toBeTruthy(); + expect(status).toContain("上限 4 条"); + expect(status).toContain("暂存 1 条"); + }); + + it("/merge off flushes what it was holding and persists the switch", async () => { + const { bridge, received, cfg } = makeBridge({ mergeSilenceMs: 60_000 }); + const store = new ConfigStore(cfg.storageDir); + (bridge as unknown as { configStore?: unknown }).configStore = store; + const inbound = asInbound(bridge); + + await inbound.handleMessage(text("别丢掉我")); + expect(received).toHaveLength(0); + await inbound.handleMessage(text("/merge off")); + + // The held message was delivered as one turn BEFORE the confirmation. + expect(texts(received)).toEqual(["别丢掉我"]); + const replies = sendTextMessage.mock.calls.map((c) => String(c[1])); + expect(replies.some((r) => r.includes("打字合并已关闭"))).toBe(true); + expect(store.stored().mergeEnabled).toBe(false); + expect((bridge as unknown as { config: { mergeEnabled: boolean } }).config.mergeEnabled).toBe(false); + }); + + it("/merge on persists the switch", async () => { + const { bridge, cfg } = makeBridge({ mergeEnabled: false }); + const store = new ConfigStore(cfg.storageDir); + (bridge as unknown as { configStore?: unknown }).configStore = store; + await asInbound(bridge).handleMessage(text("/merge on")); + expect(store.stored().mergeEnabled).toBe(true); + expect((bridge as unknown as { config: { mergeEnabled: boolean } }).config.mergeEnabled).toBe(true); + const replies = sendTextMessage.mock.calls.map((c) => String(c[1])); + expect(replies.join("\n")).toContain("打字合并已开启"); + }); +}); + +describe("/merge is wired exactly like /silent (and collides with nothing)", () => { + it("parses bare / on / off / status and the /mg alias", () => { + expect(parseMergeCommand("/merge")).toEqual({ kind: "merge", mode: "status" }); + expect(parseMergeCommand("/mg")).toEqual({ kind: "merge", mode: "status" }); + expect(parseMergeCommand("/merge on")).toEqual({ kind: "merge", mode: "on" }); + expect(parseMergeCommand("/mg off")).toEqual({ kind: "merge", mode: "off" }); + expect(parseMergeCommand("/MERGE STATUS")).toEqual({ kind: "merge", mode: "status" }); + expect(parseMergeCommand("/merge maybe")).toBeNull(); + expect(parseMergeCommand("/mergeant")).toBeNull(); + }); + + it("no OTHER parser claims /merge or /mg (alias-collision guard)", () => { + const others: Array<[string, (text: string) => unknown]> = [ + ["help", parseHelpCommand], + ["status", parseStatusCommand], + ["silent", parseSilentCommand], + ["final", parseFinalCommand], + ["surface", parseSurfaceCommand], + ["stop", parseStopCommand], + ["notify", parseNotifyCommand], + ["next", parseNextCommand], + ["flush", parseFlushCommand], + ["workspace", parseWorkspaceCommand], + ["session", parseSessionCommand], + ["preset", parsePresetCommand], + ["model", parseModelCommand], + ["perm", parsePermCommand], + ["reasoning", parseReasoningCommand], + ["enter", parseEnterCommand], + ["history", parseHistoryCommand], + ]; + for (const input of ["/merge", "/merge on", "/merge off", "/mg", "/mg status"]) { + for (const [name, parser] of others) { + const result = parser(input); + const claimed = result !== null && result !== false && result !== undefined; + expect(claimed, `${name} must not claim ${input}`).toBe(false); + } + expect(parseMergeCommand(input)).not.toBeNull(); + expect(parseCommandName(input)).toBe(input.trim().split(" ")[0]!.slice(1)); + } + }); + + it("bypasses a pending card", () => { + expect(isBypassSlashCommand("/merge")).toBe(true); + expect(isBypassSlashCommand("/merge on")).toBe(true); + expect(isBypassSlashCommand("/mg off")).toBe(true); + }); + + it("is de-duplicated against native commands and documented in /help", () => { + expect(LOCAL_COMMAND_NAMES.has("merge")).toBe(true); + expect(LOCAL_COMMAND_NAMES.has("mg")).toBe(true); + const help = formatHelp(); + expect(help).toContain("/merge on|off|status (mg)"); + expect(help).toContain("`..`"); + expect(help).toContain("`!!`"); + }); +}); diff --git a/tests/native-command.test.ts b/tests/native-command.test.ts index 3083876..7466151 100644 --- a/tests/native-command.test.ts +++ b/tests/native-command.test.ts @@ -122,6 +122,9 @@ function makeBridge(opts: { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms): an unregistered `/foo bar` must reach the agent on + // the message that carried it (the merge window has its own suite). + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); // Skip the monitor / login flow: we only test handleMessage. (bridge as unknown as { token: unknown }).token = { diff --git a/tests/process-forwarding.test.ts b/tests/process-forwarding.test.ts index 6cef9c1..e1c8800 100644 --- a/tests/process-forwarding.test.ts +++ b/tests/process-forwarding.test.ts @@ -42,7 +42,9 @@ const texts = () => sendTextMessage.mock.calls.map((c) => String(c[1])); beforeEach(() => { vi.clearAllMocks(); sendTextMessage.mockReset().mockResolvedValue(undefined); dir = fs.mkdtempSync(path.join(os.tmpdir(), "wx-process-")); - bridge = new WeChatDSHBridge({ get: () => undefined, on: () => () => {} }, { ...defaultConfig(), storageDir: dir }); + // Merge window off (0ms): this suite drives logout/relogin transitions and + // needs every message to dispatch immediately. + bridge = new WeChatDSHBridge({ get: () => undefined, on: () => () => {} }, { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0 }); b().state.ensureUser("u1", "C:\\work"); b().state.update("u1", { sessionId: "s1" }); b().token = { baseUrl: "https://gw", token: "t" }; seq = 0; rev = 0; }); diff --git a/tests/push-governance.test.ts b/tests/push-governance.test.ts index 4d528cf..5387c7b 100644 --- a/tests/push-governance.test.ts +++ b/tests/push-governance.test.ts @@ -107,7 +107,11 @@ describe("send_wechat per-minute rate limit", () => { it("counts media pushes too — the tool call is what is limited", async () => { const bridge = makeBridge({ sendWechatRateLimitPerMinute: 1 }); - const tmp = path.join(os.tmpdir(), `dsh-wx-rate-${Date.now()}.txt`); + // A file the tool is allowed to read: inside the sending session's cwd + // (the S2 bound refuses anything else, which would confuse the limiter). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wx-rate-file-")); + (bridge as any).state.update("u1", { cwd: dir }); + const tmp = path.join(dir, `dsh-wx-rate-${Date.now()}.txt`); fs.writeFileSync(tmp, "payload"); try { const first = await bridge.handleSendWeChat("wx-s1", { file_path: tmp }); @@ -116,7 +120,7 @@ describe("send_wechat per-minute rate limit", () => { expect(second.ok).toBe(false); expect(sendMediaMessage).toHaveBeenCalledTimes(1); } finally { - fs.unlinkSync(tmp); + fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -209,7 +213,7 @@ describe("inbound allowlist", () => { expect(sendTextMessage).toHaveBeenCalled(); }); - it("logs the rejected id so the operator can see who was turned away", async () => { + it("logs the rejected id MASKED so the operator can correlate without leaking it", async () => { const logs: string[] = []; const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { logs.push(args.map(String).join(" ")); @@ -217,7 +221,10 @@ describe("inbound allowlist", () => { try { const bridge = makeBridge({ allowedUsers: ["u1"] }); await bridge.handleMessage(userText("让我进去", "intruder-42")); - expect(logs.some((l) => l.includes("intruder-42") && l.includes("allowedUsers"))).toBe(true); + // S4: the operator sees the rejection and can correlate it with other + // lines about the same sender, but the log never carries the raw id. + expect(logs.some((l) => l.includes("in***42") && l.includes("allowedUsers"))).toBe(true); + expect(logs.some((l) => l.includes("intruder-42"))).toBe(false); } finally { spy.mockRestore(); } diff --git a/tests/relogin-peer.test.ts b/tests/relogin-peer.test.ts index 5c4aa34..8860269 100644 --- a/tests/relogin-peer.test.ts +++ b/tests/relogin-peer.test.ts @@ -66,6 +66,9 @@ function makeBridge() { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms): peer binding must be observable on the first + // message after logout/relogin, not behind a silence timer. + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts new file mode 100644 index 0000000..4636a2b --- /dev/null +++ b/tests/security-hardening.test.ts @@ -0,0 +1,515 @@ +/** + * Security hardening wave — S1…S4. + * + * Every case drives a real public entry (`handleMessage` for the WeChat-side + * commands, `handleSendWeChat` for the tool, the bridge constructor for the + * upgrade migration) and asserts the OUTCOME a user could observe, not that a + * private helper was called. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +const sendTextMessage = vi.fn().mockResolvedValue(undefined); +const sendMediaMessage = vi.fn().mockResolvedValue(undefined); + +vi.mock("../src/weixin/send.js", () => ({ + sendTextMessage: (...args: unknown[]) => sendTextMessage(...args), + sendMediaMessage: (...args: unknown[]) => sendMediaMessage(...args), + splitText: (text: string, maxLen: number) => + text.length <= maxLen ? [text] : [text.slice(0, maxLen), text.slice(maxLen)], +})); + +import { WeChatDSHBridge } from "../src/bridge/bridge.js"; +import { ConfigStore } from "../src/config-store.js"; +import { defaultConfig } from "../src/config.js"; +import { StateStore } from "../src/state.js"; +import { MessageType } from "../src/weixin/types.js"; +import { AuditLog } from "../src/utils/audit.js"; +import { maskUserId } from "../src/utils/redact.js"; + +const live: Array<{ stop(): Promise }> = []; +const tmpDirs: string[] = []; + +function tmpDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(dir); + return dir; +} + +function textMessage(text: string, from = "u1") { + return { + message_type: MessageType.USER, + from_user_id: from, + context_token: "tok", + item_list: [{ type: 1, text_item: { text } }], + }; +} + +const asInbound = (bridge: unknown) => bridge as { handleMessage(m: unknown): Promise }; +const lastReply = () => String(sendTextMessage.mock.calls.at(-1)?.[1] ?? ""); +const replies = () => sendTextMessage.mock.calls.map((c) => String(c[1])); + +beforeEach(() => { + vi.clearAllMocks(); + sendTextMessage.mockResolvedValue(undefined); + sendMediaMessage.mockResolvedValue(undefined); +}); + +afterEach(async () => { + for (const bridge of live.splice(0)) await bridge.stop().catch(() => {}); + for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +// ─── S1: /perm default must not escalate the GLOBAL default from chat ─── + +const PERMISSION_PRESETS: Record = { + "read-only": { sandbox: "read-only", approval: "ask" }, + "workspace-write": { sandbox: "workspace-write", approval: "ask" }, + "danger-full-access": { sandbox: "danger-full-access", approval: "never" }, + // Config-declared preset that only drops approvals: the sandbox alone would + // not catch this one. + "approval-off": { sandbox: "workspace-write", approval: "never" }, +}; + +function permissionService() { + const names = Object.keys(PERMISSION_PRESETS); + return { + names, + defaultPreset: "workspace-write", + current: () => "workspace-write", + set: vi.fn(), + resolve: (name: string) => { + const spec = PERMISSION_PRESETS[name]; + if (!spec) throw new Error(`unknown preset ${name}`); + return { ...spec, name }; + }, + optionOf: (name: string) => ({ value: name, name }), + }; +} + +function makePermBridge(patch: Record = {}) { + const dir = tmpDir("dsh-wx-perm-"); + const service = permissionService(); + const settingsUpdate = vi.fn(async () => {}); + const agent = { id: "wx-s1", status: "idle", options: {}, followup: () => {}, steer: () => {}, cancel: () => {}, whenIdle: async () => {} }; + const services: Record = { + permissionPresets: service, + settings: { update: settingsUpdate, describe: () => [] }, + agents: { get: (id: string) => (id === "wx-s1" ? agent : undefined), create: async () => ({ agent }), resume: async () => ({ agent }), list: () => [agent] }, + }; + const bridge = new WeChatDSHBridge( + { get: (name: string) => services[name], on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0, ...patch }, + ); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "bot", savedAt: "" }; + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + live.push(bridge); + return { bridge, service, settingsUpdate }; +} + +describe("S1 — /perm default cannot raise the global default from a chat message", () => { + it("refuses danger-full-access and points at the DSH GUI settings page", async () => { + const { bridge, settingsUpdate } = makePermBridge(); + await asInbound(bridge).handleMessage(textMessage("/perm default danger-full-access")); + expect(settingsUpdate).not.toHaveBeenCalled(); + const reply = lastReply(); + expect(reply).toContain("已拒绝"); + expect(reply).toContain("设置"); + expect(reply).toContain("allowRemotePermissionEscalation"); + }); + + it("refuses a preset that only drops approvals (approval=never)", async () => { + const { bridge, settingsUpdate } = makePermBridge(); + await asInbound(bridge).handleMessage(textMessage("/perm default approval-off")); + expect(settingsUpdate).not.toHaveBeenCalled(); + expect(lastReply()).toContain("已拒绝"); + }); + + it("refuses by preset INDEX too (the guard is on the resolved preset, not the text)", async () => { + const { bridge, settingsUpdate } = makePermBridge(); + await asInbound(bridge).handleMessage(textMessage("/perm default 3")); + expect(settingsUpdate).not.toHaveBeenCalled(); + expect(lastReply()).toContain("已拒绝"); + }); + + it("still allows a NON-escalating default write", async () => { + const { bridge, settingsUpdate } = makePermBridge(); + await asInbound(bridge).handleMessage(textMessage("/perm default read-only")); + expect(settingsUpdate).toHaveBeenCalledWith("permission", { defaultPreset: "read-only" }); + expect(lastReply()).toContain("✅"); + }); + + it("leaves the session-scoped /perm switch untouched (it is not persistent)", async () => { + const { bridge, service, settingsUpdate } = makePermBridge(); + await asInbound(bridge).handleMessage(textMessage("/perm switch danger-full-access")); + expect(service.set).toHaveBeenCalledTimes(1); + expect(settingsUpdate).not.toHaveBeenCalled(); + expect(lastReply()).toContain("当前会话权限已切换"); + }); + + it("the escape hatch restores the previous behaviour when explicitly enabled", async () => { + const { bridge, settingsUpdate } = makePermBridge({ allowRemotePermissionEscalation: true }); + await asInbound(bridge).handleMessage(textMessage("/perm default danger-full-access")); + expect(settingsUpdate).toHaveBeenCalledWith("permission", { defaultPreset: "danger-full-access" }); + expect(lastReply()).toContain("✅"); + }); +}); + +// ─── S2: send_wechat is not an arbitrary-file-read primitive ─── + +function makeSendBridge(patch: Record = {}, workspaces: Array<{ path: string }> = []) { + const dir = tmpDir("dsh-wx-sendbound-"); + const services: Record = { + agents: { get: () => undefined, create: async () => undefined, resume: async () => undefined, list: () => [] }, + }; + if (workspaces.length > 0) { + services.workspaceRegistry = { + list: () => workspaces.map((w, i) => ({ id: `ws${i}`, title: "ws", sessionIds: [], createdAt: "", path: w.path })), + archivedSessionIds: [], + resolveByPath: async () => undefined, + }; + } + const bridge = new WeChatDSHBridge( + { get: (name: string) => services[name], on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0, ...patch }, + ); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "bot", savedAt: "" }; + live.push(bridge); + return { bridge, dir }; +} + +describe("S2 — send_wechat file bound", () => { + it("refuses a path outside the sending session's workspace", async () => { + const { bridge, dir } = makeSendBridge(); + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const outside = path.join(tmpDir("dsh-wx-outside-"), "secret.txt"); + fs.writeFileSync(outside, "top secret"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: outside }); + expect(result.ok).toBe(false); + expect(result.message).toContain("refused"); + expect(result.message).toContain("allowAnyFilePath"); + expect(sendMediaMessage).not.toHaveBeenCalled(); + }); + + it("allows a file inside the sending session's working directory", async () => { + const { bridge, dir } = makeSendBridge(); + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const inside = path.join(dir, "report.txt"); + fs.writeFileSync(inside, "ok"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: inside }); + expect(result.ok).toBe(true); + expect(sendMediaMessage).toHaveBeenCalledTimes(1); + }); + + it("allows a file inside a REGISTERED WORKSPACE", async () => { + const workspace = tmpDir("dsh-wx-ws-"); + const { bridge } = makeSendBridge({}, [{ path: workspace }]); + (bridge as any).state.ensureUser("u1", tmpDir("dsh-wx-home-")); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const inside = path.join(workspace, "artifact.bin"); + fs.writeFileSync(inside, "bytes"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: inside }); + expect(result.ok).toBe(true); + }); + + it("cannot escape through `..` inside an allowed root", async () => { + const { bridge, dir } = makeSendBridge(); + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const sneaky = path.join(dir, "..", "escape.txt"); + fs.writeFileSync(path.resolve(sneaky), "nope"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: sneaky }); + expect(result.ok).toBe(false); + expect(result.message).toContain("refused"); + }); + + it("evaluates the bound against the SENDING session, not the recipient fallback", async () => { + // u1 (bound to wx-s1) lives in `bound`; the file sits in `bound` too. The + // tool is called from an unrelated GUI session, and `resolveUserForAgent` + // still falls back to u1 as the RECIPIENT — but the file bound must be the + // caller's, so this must be refused. + const bound = tmpDir("dsh-wx-bound-"); + const { bridge } = makeSendBridge(); + (bridge as any).state.ensureUser("u1", bound); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const file = path.join(bound, "private.txt"); + fs.writeFileSync(file, "private"); + + const result = await bridge.handleSendWeChat("gui-session-xyz", { file_path: file }); + expect(result.ok).toBe(false); + expect(result.message).toContain("refused"); + }); + + it("refuses a file above maxOutgoingFileBytes", async () => { + const { bridge, dir } = makeSendBridge({ maxOutgoingFileBytes: 8 }); + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const big = path.join(dir, "big.bin"); + fs.writeFileSync(big, "0123456789"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: big }); + expect(result.ok).toBe(false); + expect(result.message).toContain("too large"); + expect(sendMediaMessage).not.toHaveBeenCalled(); + }); + + it("allowAnyFilePath=true restores the previous behaviour", async () => { + const { bridge } = makeSendBridge({ allowAnyFilePath: true }); + (bridge as any).state.ensureUser("u1", tmpDir("dsh-wx-any-")); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + const outside = path.join(tmpDir("dsh-wx-anyfile-"), "any.txt"); + fs.writeFileSync(outside, "ok"); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: outside }); + expect(result.ok).toBe(true); + }); + + it("still reports a missing file clearly", async () => { + const { bridge, dir } = makeSendBridge(); + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + + const result = await bridge.handleSendWeChat("wx-s1", { file_path: path.join(dir, "nope.txt") }); + expect(result.ok).toBe(false); + expect(result.message).toContain("file not found"); + }); +}); + +// ─── S3: seed the inbound allowlist on upgrade ─── + +function makeSeedBridge(opts: { users?: Array<{ id: string; sessionId: string }>; stored?: Record; freshConfig?: boolean }) { + const dir = tmpDir("dsh-wx-seed-"); + if (opts.users) { + const state = new StateStore(dir); + for (const user of opts.users) { + state.ensureUser(user.id, dir); + if (user.sessionId) state.update(user.id, { sessionId: user.sessionId }); + } + } + if (opts.stored) { + fs.writeFileSync(path.join(dir, "config.json"), JSON.stringify(opts.stored, null, 2), "utf-8"); + } + const store = new ConfigStore(dir); + const bridge = new WeChatDSHBridge( + { get: () => undefined, on: () => () => {} }, + { ...store.resolve({ ...defaultConfig(), storageDir: dir }), storageDir: dir, mergeSilenceMs: 0 }, + undefined, + store, + ); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "bot", savedAt: "" }; + live.push(bridge); + return { bridge, store, dir }; +} + +describe("S3 — allowlist seeding on upgrade", () => { + it("seeds allowedUsers from an already bound peer and persists it", () => { + const { store } = makeSeedBridge({ users: [{ id: "peer-7", sessionId: "session-abc" }] }); + expect(store.stored().allowedUsers).toEqual(["peer-7"]); + // Persisted, so the next start reads a non-empty list. + expect(new ConfigStore(store["filePath"] ? path.dirname(store["filePath"]) : "").stored().allowedUsers).toEqual(["peer-7"]); + }); + + it("locks a stranger out after seeding (the actual security outcome)", async () => { + const { bridge } = makeSeedBridge({ users: [{ id: "peer-7", sessionId: "session-abc" }] }); + await asInbound(bridge).handleMessage(textMessage("让我进去", "stranger-9")); + await new Promise((r) => setTimeout(r, 10)); + expect(sendTextMessage).not.toHaveBeenCalled(); + expect((bridge as unknown as { peerUserId: string | null }).peerUserId).toBeNull(); + }); + + it("shows the peer's own id in /status and notices it once", async () => { + const { bridge } = makeSeedBridge({ users: [{ id: "peer-7", sessionId: "session-abc" }] }); + // Everything here comes FROM the seeded peer: after seeding, a message from + // anyone else is dropped before it can produce a reply. + await asInbound(bridge).handleMessage(textMessage("/status", "peer-7")); + const reply = lastReply(); + expect(reply).toContain("入站白名单"); + expect(reply).toContain("peer-7"); + + // The seeding notice goes out once, on the peer's first inbound message. + expect(replies().some((r) => r.includes("升级提示") && r.includes("peer-7"))).toBe(true); + sendTextMessage.mockClear(); + await asInbound(bridge).handleMessage(textMessage("/status", "peer-7")); + expect(replies().some((r) => r.includes("升级提示"))).toBe(false); + }); + + it("does NOT seed on a brand-new install (first contact still wins)", async () => { + const { bridge, store } = makeSeedBridge({}); + expect(store.stored().allowedUsers).toBeUndefined(); + await asInbound(bridge).handleMessage(textMessage("你好", "brand-new-user")); + expect((bridge as unknown as { peerUserId: string | null }).peerUserId).toBe("brand-new-user"); + expect((bridge as unknown as { config: { allowedUsers: string[] } }).config.allowedUsers).toEqual([]); + }); + + it("never overwrites an explicitly stored empty list", () => { + const { store } = makeSeedBridge({ users: [{ id: "peer-7", sessionId: "session-abc" }], stored: { allowedUsers: [] } }); + expect(store.stored().allowedUsers).toEqual([]); + }); + + it("keeps an existing non-empty list untouched", () => { + const { store } = makeSeedBridge({ users: [{ id: "peer-7", sessionId: "session-abc" }], stored: { allowedUsers: ["someone-else"] } }); + expect(store.stored().allowedUsers).toEqual(["someone-else"]); + }); +}); + +// ─── S4: filesystem modes, masked logs, audit sink ─── + +const posix = process.platform !== "win32"; + +describe("S4 — private modes for the plugin's own files", () => { + it("writes state.json 0600 inside a 0700 storage dir", () => { + const dir = tmpDir("dsh-wx-modes-"); + const store = new StateStore(dir); + store.ensureUser("u1", dir); + if (posix) { + expect(fs.statSync(path.join(dir, "state.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + } else { + expect(fs.existsSync(path.join(dir, "state.json"))).toBe(true); + } + }); + + it("writes config.json 0600", () => { + const dir = tmpDir("dsh-wx-modes-"); + const store = new ConfigStore(dir); + store.update({ allowedUsers: ["u1"] }); + if (posix) expect(fs.statSync(path.join(dir, "config.json")).mode & 0o777).toBe(0o600); + }); + + it("writes auth/token.json 0600 inside a 0700 auth dir", async () => { + const { saveToken } = await import("../src/weixin/auth.js"); + const dir = tmpDir("dsh-wx-modes-"); + saveToken(dir, { token: "t", baseUrl: "https://gw", accountId: "a", userId: "u", savedAt: "" }); + expect(fs.existsSync(path.join(dir, "auth", "token.json"))).toBe(true); + if (posix) { + expect(fs.statSync(path.join(dir, "auth", "token.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(dir, "auth")).mode & 0o777).toBe(0o700); + } + }); +}); + +describe("S4 — the inbound log line carries no raw id and no raw body", () => { + it("masks the sender and runs the preview through maskSecrets", async () => { + const dir = tmpDir("dsh-wx-log-"); + const bridge = new WeChatDSHBridge( + { get: () => undefined, on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0 }, + ); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "bot", savedAt: "" }; + live.push(bridge); + + const logs: string[] = []; + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJvcGVyYXRvciJ9.c2lnbmF0dXJlLWhlcmU"; + const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.map(String).join(" "))); + try { + await asInbound(bridge).handleMessage(textMessage(jwt, "sender-1234")); + } finally { + spy.mockRestore(); + } + expect(logs.some((l) => l.includes("Message from"))).toBe(true); + expect(logs.some((l) => l.includes("sender-1234"))).toBe(false); + expect(logs.some((l) => l.includes("se***34"))).toBe(true); + expect(logs.some((l) => l.includes(jwt))).toBe(false); + expect(logs.some((l) => l.includes("***"))).toBe(true); + }); +}); + +describe("S4 — append-only audit sink", () => { + function readAudit(dir: string): Array> { + const file = path.join(dir, "audit.jsonl"); + if (!fs.existsSync(file)) return []; + return fs.readFileSync(file, "utf-8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + } + + it("records inbound, command, decision, send_wechat file and outbound events", async () => { + const dir = tmpDir("dsh-wx-audit-"); + const agent = { id: "wx-s1", status: "idle", options: {}, followup: () => {}, steer: () => {}, cancel: () => {}, whenIdle: async () => {} }; + const services: Record = { + agents: { get: (id: string) => (id === "wx-s1" ? agent : undefined), create: async () => ({ agent }), resume: async () => ({ agent }), list: () => [agent] }, + }; + const bridge = new WeChatDSHBridge( + { get: (name: string) => services[name], on: () => () => {} }, + { ...defaultConfig(), storageDir: dir, mergeSilenceMs: 0 }, + ); + (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "bot", savedAt: "" }; + (bridge as any).state.ensureUser("u1", dir); + (bridge as any).state.update("u1", { sessionId: "wx-s1" }); + live.push(bridge); + + // inbound + slash command + await asInbound(bridge).handleMessage(textMessage("/status")); + // approval card decision (seeded through the replay helper, answered from WeChat) + (bridge as unknown as { + handleMuxFrame(frame: unknown): void; + }).handleMuxFrame({ + type: "server-request", + rpcId: "rpc-1", + method: "approval/request", + payload: { type: "approval/requested", sessionId: "wx-s1", approvalId: "appr-1", toolName: "pwsh" }, + }); + await asInbound(bridge).handleMessage(textMessage("1")); + // send_wechat file + const file = path.join(dir, "out.txt"); + fs.writeFileSync(file, "payload"); + await bridge.handleSendWeChat("wx-s1", { file_path: file }); + + const events = readAudit(dir); + const kinds = events.map((e) => e.event); + expect(kinds).toContain("inbound"); + expect(kinds).toContain("command"); + expect(kinds).toContain("decision"); + expect(kinds).toContain("send_wechat-file"); + expect(kinds).toContain("outbound"); + + const inbound = events.find((e) => e.event === "inbound")!; + expect(inbound.user).toBe(maskUserId("u1")); + expect(typeof inbound.chars).toBe("number"); + // The body is NEVER recorded. + expect(JSON.stringify(inbound)).not.toContain("/status"); + const decision = events.find((e) => e.event === "decision")!; + expect(decision).toMatchObject({ kind: "approval", decision: "allowed-once", tool: "pwsh" }); + const fileEvent = events.find((e) => e.event === "send_wechat-file")!; + expect(fileEvent.result).toBe("allowed"); + expect(fileEvent.path).toBe(file); + const command = events.find((e) => e.event === "command")!; + expect(command.command).toBe("status"); + // Raw WeChat ids never reach the sink. + expect(fs.readFileSync(path.join(dir, "audit.jsonl"), "utf-8")).not.toContain("\"u1\""); + }); + + it("bounds itself by rotating at the cap and keeps the previous file", () => { + const dir = tmpDir("dsh-wx-audit-rot-"); + const logged: string[] = []; + const audit = new AuditLog(dir, (m) => logged.push(m), 400); + for (let i = 0; i < 40; i++) audit.record({ event: "inbound", user: "se***34", chars: i, pad: "x".repeat(20) }); + const live = fs.statSync(path.join(dir, "audit.jsonl")).size; + // The cap is a hard ceiling: the live file never exceeds it. + expect(live).toBeLessThanOrEqual(400); + expect(fs.existsSync(path.join(dir, "audit.jsonl.1"))).toBe(true); + expect(logged.some((l) => l.includes("rotated"))).toBe(true); + // The live file is a valid JSONL tail (the sink never emits a torn line). + const lines = fs.readFileSync(path.join(dir, "audit.jsonl"), "utf-8").trim().split("\n").filter(Boolean); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) expect(() => JSON.parse(line)).not.toThrow(); + // And the retained history is bounded to two files' worth, not 40 records. + const previous = fs.readFileSync(path.join(dir, "audit.jsonl.1"), "utf-8").trim().split("\n").filter(Boolean); + expect(lines.length + previous.length).toBeLessThan(40); + }); + + it("is created 0600", () => { + const dir = tmpDir("dsh-wx-audit-mode-"); + const audit = new AuditLog(dir, () => {}); + audit.record({ event: "inbound" }); + if (posix) expect(fs.statSync(path.join(dir, "audit.jsonl")).mode & 0o777).toBe(0o600); + }); +}); diff --git a/tests/send-wechat.test.ts b/tests/send-wechat.test.ts index 1895e3a..61a0a19 100644 --- a/tests/send-wechat.test.ts +++ b/tests/send-wechat.test.ts @@ -106,11 +106,14 @@ describe("send_wechat: bound session", () => { it("sends a file via sendMediaMessage when file_path is provided", async () => { const bridge = makeBridge(); setLoggedIn(bridge); - bridge.state.ensureUser("u1", "C:\\work"); + // The file must live inside the CALLING session's workspace: a path + // outside it is refused by the S2 bound (covered by the refusal cases). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wx-send-file-")); + bridge.state.ensureUser("u1", dir); bridge.state.update("u1", { sessionId: "wx-s1" }); bridge.wireContextToken = "ctx-1"; - const tmpFile = path.join(os.tmpdir(), `send-wechat-${Date.now()}.txt`); + const tmpFile = path.join(dir, `send-wechat-${Date.now()}.txt`); fs.writeFileSync(tmpFile, "payload"); try { @@ -122,7 +125,7 @@ describe("send_wechat: bound session", () => { expect(opts.fileName).toContain(".txt"); expect(sendTextMessage).not.toHaveBeenCalled(); } finally { - fs.unlinkSync(tmpFile); + fs.rmSync(dir, { recursive: true, force: true }); } }); }); @@ -429,7 +432,10 @@ describe("send_wechat: shared gateway budget & cache", () => { it("a cached image file re-flushes with its native IMAGE media type", async () => { const bridge = setup(); const b = asBudget(bridge); - const tmpPng = path.join(os.tmpdir(), `send-wechat-${Date.now()}.png`); + // The file must sit inside the sending session's workspace (S2 bound). + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-wx-send-img-")); + bridge.state.update("u1", { cwd: dir }); + const tmpPng = path.join(dir, `send-wechat-${Date.now()}.png`); fs.writeFileSync(tmpPng, "png-bytes"); try { b.wechatMsgCount = 10; @@ -447,7 +453,7 @@ describe("send_wechat: shared gateway budget & cache", () => { // Inline image on flush — not degraded to FILE(3). expect(mediaType).toBe(UploadMediaType.IMAGE); } finally { - fs.unlinkSync(tmpPng); + fs.rmSync(dir, { recursive: true, force: true }); } }); diff --git a/tests/surface-prompt.test.ts b/tests/surface-prompt.test.ts index b7a1e8a..aeef3e5 100644 --- a/tests/surface-prompt.test.ts +++ b/tests/surface-prompt.test.ts @@ -63,6 +63,9 @@ function makeBridge(agent: { agent: unknown; received: unknown[] }) { }; const cfg = defaultConfig(); cfg.storageDir = dir; + // Merge window off (0ms): these cases observe the message-source marker on + // the very message that produced it, not after a silence window. + cfg.mergeSilenceMs = 0; const bridge = new WeChatDSHBridge(ctx, cfg); (bridge as unknown as { token: unknown }).token = { baseUrl: "https://gw", token: "t", accountId: "b1", userId: "u", savedAt: "" }; return bridge; From 96f123844feadbb22f8e82032ab07f6c07ec0b7c Mon Sep 17 00:00:00 2001 From: PerryLink Date: Mon, 19 Oct 2026 13:37:54 +0800 Subject: [PATCH 2/2] chore(compat): support DSH 0.2.1-rc.2 Raise the enforced host range to >=0.2.1-0 <0.3.0-0 in engines.dsh and the @deepseek-ai/dsh peer; add 0.2.1-rc.2 to the admission matrix; drop the no-longer-claimed 0.2.0 line; bump to 0.11.1. No runtime API change was needed: 0.11.0 passes a real rc.2 host smoke. --- CHANGELOG.md | 6 ++++++ PUBLISH.md | 6 +++--- README.md | 8 ++++---- package-lock.json | 4 ++-- package.json | 6 +++--- tests/host-line.test.ts | 29 ++++++++++++++++------------- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a87b7..a6e5856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.11.1] - 2026-10-19 + +### Compatibility + +- **DSH 0.2.1-rc.2 adaptation.** The enforced host range is now `>=0.2.1-0 <0.3.0` in both `engines.dsh` and the `@deepseek-ai/dsh` peer; 0.2.1-rc.2 is explicitly in the admission matrix. The 0.2.0 line is no longer claimed. No runtime API change was required: `dsh-wechat@0.11.0` passes a real rc.2 host smoke (HTTP 200 + boot marker). + 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。 diff --git a/PUBLISH.md b/PUBLISH.md index f589999..72607ce 100644 --- a/PUBLISH.md +++ b/PUBLISH.md @@ -38,14 +38,14 @@ CHANGELOG.md 是 GitHub Release Notes 的数据源,以下情况**需要更新* 当前值(两处一致): ```json -"engines": { "dsh": ">=0.2.0-rc.2 <0.3.0-0" }, -"peerDependencies": { "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0-0" }, +"engines": { "dsh": ">=0.2.1-0 <0.3.0-0" }, +"peerDependencies": { "@deepseek-ai/dsh": ">=0.2.1-0 <0.3.0-0" }, "peerDependenciesMeta": { "@deepseek-ai/dsh": { "optional": true } } ``` 改这个范围时必须遵守四条,每条都有实测依据: -1. **必须带上界。** 只写 `>=0.2.0-rc.2` 会把 `0.3.0`、`1.0.0` 判为兼容(这正是旧值的缺陷)。 +1. **必须带上界。** 只写 `>=0.2.1-0` 会把 `0.3.0`、`1.0.0` 判为兼容(这正是旧值的缺陷)。 2. **上界必须是 `<0.3.0-0`,不能写 `<0.3.0`。** 宿主与市场都以 `{ includePrerelease: true }` 求值,`<0.3.0` 会放行 `0.3.0-rc.1`。 3. **peer 必须同时标 `peerDependenciesMeta.optional`。** 不加会**安装失败**:peer 被当成真实依赖解析,`@deepseek-ai/dsh` 是带 82 条 dependencies 的元包,会拉进 530 个包并撞上未发布包 `@deepseek-ai/dsh-type-meta` 的 npm 404。宿主门禁**不读** `peerDependenciesMeta`,所以标 `optional` 不影响强制力。 4. **不要改用前发布元组枚举。** 唯一消费者在 `includePrerelease` 语义下求值,单段 arm 已覆盖全部 0.2.x 前发布版;枚举永远补不全(每出现一个新的 0.2.x 元组都要再加一段)。 diff --git a/README.md b/README.md index daa9b7d..0938e9d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ DSH 设置页内扫码登录与连接配置。以静态 Cordis 插件交付, ## 安装(部署到 DSH profile) -当前版本要求 **DSH `>=0.2.0-rc.2`(且 `<0.3.0-0`)**。这条要求写了两处:`engines.dsh`(供插件市场展示,宿主不读)与 `peerDependencies["@deepseek-ai/dsh"]`(宿主**强制校验**——不匹配会跳过整个插件包)。低于 `0.2.0-rc.2` 或高于 `0.2.x` 的 DSH 会被拒绝加载。 +当前版本要求 **DSH `>=0.2.1-0`(且 `<0.3.0-0`)**。这条要求写了两处:`engines.dsh`(供插件市场展示,宿主不读)与 `peerDependencies["@deepseek-ai/dsh"]`(宿主**强制校验**——不匹配会跳过整个插件包)。低于 `0.2.1-0` 或高于 `0.2.x` 的 DSH 会被拒绝加载。 ### 方式一:设置页内直接安装(推荐) @@ -242,7 +242,7 @@ logout`),客户端零 `@deepseek-ai` 依赖。 ```bash npm install npm run build # tsc → dist/ -npm test # vitest(splitText/出站 Markdown 过滤/解析/帧处理/waterfall 竞速/状态存储/命令解析/超时恢复/状态颜色/历史截断/渠道提示词/结论模式/打字合并/脱敏/限流与白名单/权限提权闸门/send_wechat 文件边界/白名单播种/审计与文件权限/出站串行槽/有界缓存/dispose 语义/驱动宿主版本区间/DSH >=0.2.0-rc.2 适配) +npm test # vitest(splitText/出站 Markdown 过滤/解析/帧处理/waterfall 竞速/状态存储/命令解析/超时恢复/状态颜色/历史截断/渠道提示词/结论模式/打字合并/脱敏/限流与白名单/权限提权闸门/send_wechat 文件边界/白名单播种/审计与文件权限/出站串行槽/有界缓存/dispose 语义/驱动宿主版本区间/DSH >=0.2.1-0 适配) ``` ⚠️ **CI 跑在 Linux(`ubuntu-latest`)上,所以 Windows 上"全绿"不等于 CI 绿。** 已经踩过一次:一条断言把 Windows 形状的字面量 `C:\home\.dsh` 同时当输入和期望前缀,而实现会先对该根目录做 `path.resolve`——在 POSIX 上那是**相对路径**,会解析到 cwd 之下,于是 `built.startsWith("C:\home\.dsh/sessions")` 恒为 false,本地 Windows 必绿、CI 必红。 @@ -255,13 +255,13 @@ bash scripts/ci-local.sh # Linux / macOS / Windows 的 WSL 里都可用 它把检出复制到本机临时目录再 `npm ci && tsc --noEmit && vitest run`(与 CI 完全一致),因此不会用 Linux 版的 esbuild/rollup 覆盖你工作副本里的 `node_modules`。 -当前版本针对 **DeepSeek Harness `>=0.2.0-rc.2 <0.3.0-0`** 的宿主 API;仓库本身不依赖任何 `@deepseek-ai/dsh-*` 运行时包(`peerDependencies` 里的 `@deepseek-ai/dsh` 是**宿主契约声明**,不是依赖,并已标 `optional` 以免安装时把整个 harness 拖进 profile)。宿主版本要求写了两处、值完全一致: +当前版本针对 **DeepSeek Harness `>=0.2.1-0 <0.3.0-0`** 的宿主 API;仓库本身不依赖任何 `@deepseek-ai/dsh-*` 运行时包(`peerDependencies` 里的 `@deepseek-ai/dsh` 是**宿主契约声明**,不是依赖,并已标 `optional` 以免安装时把整个 harness 拖进 profile)。宿主版本要求写了两处、值完全一致: - `engines.dsh` —— **声明性**。宿主 `evaluatePluginCompatibility` 只校验 `peerDependencies`,**没有任何代码读 `engines`**;真正读它的是插件市场 `dshmarket`(顶层 `engines.dsh` 优先于嵌套 `dsh.engines.dsh`),且固定以 `{ includePrerelease: true }` 求值。 - `peerDependencies["@deepseek-ai/dsh"]` —— **强制**。不匹配时宿主会**跳过整个 `dsh-wechat` bundle**(`profile.ts` 抛错 → `skippedBundles`,并在每次启动时向 stderr 写一行 `skipping profile bundle`),插件在桌面端表现为消失。 - 该 peer 必须同时标 `peerDependenciesMeta.optional`。实测(`pnpm pack` 后 tarball 安装):不加 `optional` 会**安装失败**(`exit=1`)——peer 被当成真实依赖解析,`@deepseek-ai/dsh` 元包带 82 条 dependencies,会拉进 530 个包并撞上未发布包 `@deepseek-ai/dsh-type-meta` 的 npm 404;即使能解析,lockfile 也会从 28 行膨胀到 8199 行。加 `optional` 后 `exit=0`、lockfile 28 行。宿主门禁**不读** `peerDependenciesMeta`,所以强制力不受影响。 -范围只有一段 arm,因为前发布元组枚举并无必要:唯一消费者在 `includePrerelease` 语义下求值,一条 `>=0.2.0-rc.2 <0.3.0-0` 已放行全部 0.2.x 前发布版;而枚举永远补不全(每来一个新的 0.2.x 元组都得再加一条)。上界必须写成 `<0.3.0-0` 而非 `<0.3.0`——后者在 `includePrerelease` 下会放行 `0.3.0-rc.1`。`tests/host-line.test.ts` 用真实 `semver` 把这张准入/拒绝矩阵与两处声明的一致性钉死。 +范围只有一段 arm,因为前发布元组枚举并无必要:唯一消费者在 `includePrerelease` 语义下求值,一条 `>=0.2.1-0 <0.3.0-0` 已放行全部 0.2.x 前发布版;而枚举永远补不全(每来一个新的 0.2.x 元组都得再加一条)。上界必须写成 `<0.3.0-0` 而非 `<0.3.0`——后者在 `includePrerelease` 下会放行 `0.3.0-rc.1`。`tests/host-line.test.ts` 用真实 `semver` 把这张准入/拒绝矩阵与两处声明的一致性钉死。 > **与生态另一种写法的对比**:`@anysearch/anysearch-dsh` 不写 `engines.dsh`,而是对**具体子包**声明 `peerDependencies`(`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-web` 等)且大多用 `"*"`,其 `docs/dsh-compatibility.md` 明确说这是「默认放行新 DSH 版本、矩阵只记录已验证版本而非安装白名单」。两者取舍不同:`"*"` 换来「新宿主版本无需改 manifest」,代价是**放弃版本防护**(`*` 连 `99.0.0` 都放行);本仓选择精确区间,代价是 DSH 大版本升级时要主动更新声明。注意 anysearch 那套用 `"*"` 无法照搬到一个元包 peer 上——`@deepseek-ai/dsh` 是聚合包,解析它就会拉整棵依赖树。 diff --git a/package-lock.json b/package-lock.json index d01afe3..a6a1723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-wechat", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-wechat", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index 787b564..ad0e8f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-wechat", - "version": "0.11.0", + "version": "0.11.1", "description": "Bridge WeChat (iLink bot) to DeepSeek Harness (DSH)", "type": "module", "files": [ @@ -38,10 +38,10 @@ }, "engines": { "node": ">=20.0.0", - "dsh": ">=0.2.0-rc.2 <0.3.0-0" + "dsh": ">=0.2.1-0 <0.3.0-0" }, "peerDependencies": { - "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0-0" + "@deepseek-ai/dsh": ">=0.2.1-0 <0.3.0-0" }, "peerDependenciesMeta": { "@deepseek-ai/dsh": { diff --git a/tests/host-line.test.ts b/tests/host-line.test.ts index adb9cab..b38294b 100644 --- a/tests/host-line.test.ts +++ b/tests/host-line.test.ts @@ -23,7 +23,7 @@ * 3. `>=0.2.0-rc.2` alone — the real predecessor. Correct floor, but no * ceiling, so it admitted `0.3.0` and `1.0.0`. * - * The range is now ONE arm, `>=0.2.0-rc.2 <0.3.0-0`, used verbatim in both + * The range is now ONE arm, `>=0.2.1-0 <0.3.0-0`, used verbatim in both * fields. Per-tuple enumeration (as an earlier revision used) is unnecessary: * the only consumer evaluates with `includePrerelease: true`, where a single * arm already admits every 0.2.x prerelease — and enumeration could never be @@ -48,8 +48,8 @@ const PEER = manifest.peerDependencies?.["@deepseek-ai/dsh"] ?? ""; /** Versions the declaration must admit, under plain AND includePrerelease. */ const MUST_ADMIT = [ - "0.2.0-rc.2", - "0.2.0", + "0.2.1-0", + "0.2.1-rc.2", "0.2.1", "0.2.2", "0.2.3", @@ -63,8 +63,10 @@ const MUST_REJECT = [ "0.1.4", "0.1.5-rc.1", "0.1.9", - // Just below the floor on the same tuple. + // Below the floor: 0.2.0 and its rc line are no longer claimed. "0.2.0-rc.1", + "0.2.0-rc.2", + "0.2.0", // The unbounded predecessor admitted all of these; the ceiling must not. "0.3.0-0", "0.3.0-rc.1", @@ -94,17 +96,18 @@ describe("engines.dsh declares the host lines this plugin actually runs on", () ).toBe(false); }); - it("covers the 0.2.0-rc.2 floor the deployment targets", () => { - // The explicit requirement: at least 0.2.0-rc.2. - expect(semver.satisfies("0.2.0-rc.2", RANGE, { includePrerelease: true })).toBe(true); - expect(semver.satisfies("0.2.0-rc.2", RANGE)).toBe(true); + it("covers the 0.2.1-0 floor and the rc.2 target", () => { + // The explicit requirement: at least 0.2.1-0, including 0.2.1-rc.2. + expect(semver.satisfies("0.2.1-0", RANGE, { includePrerelease: true })).toBe(true); + expect(semver.satisfies("0.2.1-rc.2", RANGE, { includePrerelease: true })).toBe(true); + expect(semver.satisfies("0.2.1-rc.2", RANGE)).toBe(true); }); it("admits every 0.2.x prerelease under the consumer's semantics", () => { // `dshmarket` is the only reader of `engines.dsh`, and it always passes // `{ includePrerelease: true }` (lib/discovery-compatibility.js). A single // arm is therefore sufficient; no per-tuple enumeration is needed. - for (const version of ["0.2.1-alpha.1", "0.2.1-rc.1", "0.2.2-rc.1", "0.2.3-rc.1"]) { + for (const version of ["0.2.1-alpha.1", "0.2.1-rc.1", "0.2.1-rc.2", "0.2.2-rc.1", "0.2.3-rc.1"]) { expect( semver.satisfies(version, RANGE, { includePrerelease: true }), version, @@ -115,11 +118,11 @@ describe("engines.dsh declares the host lines this plugin actually runs on", () it("keeps the 0.3.0 line out even when prereleases are included", () => { // `<0.3.0` alone would admit `0.3.0-rc.1` under includePrerelease; the // `-0` fence post is what closes that hole. - expect(semver.satisfies("0.3.0-rc.1", ">=0.2.0-rc.2 <0.3.0", { includePrerelease: true })).toBe( + expect(semver.satisfies("0.3.0-rc.1", ">=0.2.1-0 <0.3.0", { includePrerelease: true })).toBe( true, ); expect( - semver.satisfies("0.3.0-rc.1", ">=0.2.0-rc.2 <0.3.0-0", { includePrerelease: true }), + semver.satisfies("0.3.0-rc.1", ">=0.2.1-0 <0.3.0-0", { includePrerelease: true }), ).toBe(false); }); }); @@ -132,10 +135,10 @@ describe("@deepseek-ai/dsh peer turns the declarative claim into an enforced one expect(PEER).toBe(RANGE); }); - it("admits the running host runtime (0.2.0-rc.2)", () => { + it("admits the running host runtime (0.2.1-rc.2)", () => { // The host evaluates peers with `{ includePrerelease: true }` // (`semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })`). - expect(semver.satisfies("0.2.0-rc.2", PEER, { includePrerelease: true })).toBe(true); + expect(semver.satisfies("0.2.1-rc.2", PEER, { includePrerelease: true })).toBe(true); }); it("is marked optional so installing it does not pull in the whole harness", () => {