Commit Graph

934 Commits

Author SHA1 Message Date
6a8e868d31 fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH
★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
   **已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
   `hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
   ⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
   「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。

## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)

`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。

★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
  「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
  同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
  **这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。

**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
  `SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
  (SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
  ⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
  `myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。

★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。

## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)

`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
  而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
  `MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
  而它永远被合成器降采样着全屏画。

**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
  不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
  取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
  ★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
  这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
  —— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。

## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)

新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。

★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
  「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
  而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
  `groupMailsBySession(inboxMails)` ⇒ 恒红。
  ⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
  (权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。

## 编译期硬规则(ArkTS 不接受对象字面量当类型)

`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。

## 边界 / 未做

· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
  静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
  单独引入会扩大风险面。
2026-10-03 12:11:25 +08:00
4a01297dfe fix(安全)★★: 主进程零导航拦截 —— 加 will-navigate / 新窗口拒绝 + 单实例锁
**主进程此前没有任何守卫**(实测 grep 0 命中:setWindowOpenHandler、
will-navigate、requestSingleInstanceLock 全无)。
在 `loadFile(dist/index.html)` 下,邮件正文里的链接(react-markdown 会渲染
`<a href>`)点下去会**在应用窗口里导航走** —— 一个 `href="https://…"` 就足以
把整个应用窗口变成浏览器,而窗口标题与 preload 注入的 API base/token
全部暴露在那个站点上。

★ **本条防的不是 XSS**:`markdown-xss` 守的是渲染层(无 rehype-raw、
  defaultUrlTransform 中和 javascript:),**目前没有 XSS 面**。
  这里防的是**导航逃逸** —— 让外部站点**借用**这个窗口与 preload 上下文。
  两者失效方式不同,所以分开钉。

**① setWindowOpenHandler** ⇒ 一律 `deny`,地址交系统浏览器。
   `allow` 会给那个站点一个**带 preload 的窗口**。
**② will-navigate** ⇒ 拦下并 preventDefault。
   ★ 但必须**放行本应用自己的加载**(prod 的 `file://` / dev 的 `DEV_URL`)
     —— 只会 preventDefault 的实现会把应用自己锁死,首屏进不去。
     这是「过严的守卫同样是缺陷」,判据专门为它加了一格。
**③ 外跳只放行 http/https**:file: / javascript: / 自定义协议交给
   `shell.openExternal` 意图不可控;`new URL()` 对畸形输入会抛,必须 catch。
**④ requestSingleInstanceLock**:双击图标此前会起**两个进程** ——
   两条 SSE 连接、两套 `accounts.json` 并发写入(那个文件是 tmp+rename 原子写,
   并发即「后写的赢」),而用户以为只有一扇窗。

**同时修的两处双提交**(形状与 ④ 同源,都是「busy/state 要到提交后才为真」):
· PermissionPanel.submit:审批是本工程**唯一带副作用且不可撤销**的动作
  ⇒ 同帧两次激活会发出**两条** decidePermission(服务端记两次账)。
  照同文件 ForwardBar 的 `inFlight` 形状改。
· Attachments.handleFiles:`uploading` 只加在按钮的 disabled 上,
  `<input type=file>` 本身无闸门 ⇒ 上传期间重入会拿到**上一次的 items 闭包**
  ⇒ onChange 把上一次结果整批覆盖,表现为「附件少了」且**无任何提示**。
  ★ 清 `input.value` 必须与闸门**成对**提前:只提前清而不加闸门,
  会亲手制造「上传中重选同一文件 ⇒ value 已空 ⇒ change 照触发 ⇒ 二次上传」。

**判据(新建 main-process-security.test.mjs,14 格,已接线)**
按括号配对取函数体/实参,**不用** `\{[\s\S]{0,80}` 窗口(§1 第三次露头)。
变异测试 **10 个全部抓住**:去掉 deny / 去掉 preventDefault / 守卫写死不放行自己 /
放开所有 scheme / 拿不到锁不退出 / sandbox:false / contextIsolation:false /
second-instance 删 show() / 删整个 if / 删 restore()。

★ PermissionPanel 那条新判据第一版是**假绿**:`fireEvent.click` 连发两次
  (不在 act 里)**删掉闸门也照样绿** —— 两次 fireEvent 之间 React 提交了一次,
  第二次点到的是已 disabled 的按钮。必须放进**同一个 act**(同批次、不提交)
  才复现。`userEvent.click` 每次都 await 一轮 ⇒ 它测不到同帧。
  这条已写进判据注释,免得下一个人再写一次。

**边界 / 未做**
· 这些是**静态**断言,证明守卫被写下来了,**不证明运行时生效**(那要真起窗口点链接)。
· 没有把 webPreferences 的值当"够不够安全"来评审 —— 那属于安全评审,不属可机检;
  本条只钉「不许被放松」。
· X-2(index.html 无 CSP)没做:首帧防闪屏那段内联脚本要求 'unsafe-inline',
  加 CSP 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
2026-10-03 11:33:51 +08:00
9d40816230 docs(判据): ★★ 「重构建」是**两步** —— 只做第一步,红会从 build-stamp 搬到 packaging
**实测(2026-10-03,每步退出码取自不接管道的运行)**:
    提交 ⇒ HEAD 前进 ⇒ build-stamp 红(产物记旧 rev)
    ① npm run build                      ⇒ build-stamp 绿、**packaging 红**
    ② npx electron-builder --linux …    ⇒ 两条同时绿
★ 只做①的人会以为「修好了」(前一条确实绿了),而红只是**换了个位置**。

**为什么①会让 packaging 转红**:packaging 比的是「asar 里的 dist 文件名」
vs「当前 dist/index.html 引用的文件名」,而 Vite 输出带**内容哈希**
⇒ 重构建一换文件名,asar 立刻过期。两条判据是**同一条链的两环**,
而 package.json 里**没有** beforeBuild 钩子把两者串起来 ⇒ 必须人记得做两次。

**改了三个地方,因为「报错文案」比文档更容易被看到(§14 同一条道理)**:
· build-stamp 的报错文案**自带第二步**。原文只写「正确修法只有一个:重跑构建」——
  实测证明那句话**只完成了一半**,而只看文案的人会照做然后以为好了。
  已用变异测试确认:把产物 gitRev 改成 0000000 ⇒ 变红且两条步骤都出现,
  改回 ⇒ 绿。
· CRITERIA.md 新增 §6.0.6「同一个红会自己搬家时,要把它变成有界的」。
  它讲的是**读数的信噪比**:一条结构性长期红与真缺陷**同屏**,
  会把整屏红的价值抵消("看到了 ⇒ 当没看见")。
  明确**不能**靠加跳过名单解决(那是把它变成看不见),
  正解是到期条件可机检 + 报错文案自带下一步。
· DEBTS.json 的 build-stamp-stale-artifact-blocks-verification:
  把 due/where/note 补上「两步」的实测事实,并把**第二环 packaging**
  补进 where —— 原笔只记了 build-stamp 那一环。

**边界 / 未做**
· 没有把两步串进 npm 脚本或 beforeBuild —— 那是**构建流程**的改动,
  而本工作区正被多个会话并发使用;由人决定。
· 这笔债**仍未结算**:它会在**下一次提交**时重现(结构性的,与代码无关)。
2026-10-03 11:17:18 +08:00
6df3c356d6 test(判据): ★★★ 套件从 10-02 10:46 起一条都没跑过 —— 补接线 + 结算登记数
**零读数 24 小时,仓库里没有任何记录**(DEBTS.json 58 条里 grep「没接进」0 命中)。

漏接线的三个(都来自 aeb1f41 / 2f17f62,都比 run-all.mjs 最后一次改动晚):
    test/inbox-fallback-poll.test.mjs   ← SSE 兜底轮询(探测 total 变化)
    test/sse-credentials.test.mjs      ← SSE 订阅跟账号凭证走
    test/web-comment-only.test.mjs

**为什么严重(三层放大)**:
1. 自检 2「每个 *.test.mjs 都要在清单里」在跑任何判据**之前** exit(1)
   ⇒ 实测 RESULT 行数 = **0**
2. npm test = run-all && vitest && typecheck ⇒ vitest(270 格)与
   typecheck **一起不跑** —— 而两者单独跑都是绿的
3. 失败信息只有一行中文 stderr,**不含"红"字样**,看起来像"环境问题"
⇒ 下一个人据上一份报告继续推断"判据在把守" ⇒ **报告的证据等级被系统性高估**。
守卫本身**不删**(漏接线绝不静默是真价值),代价靠「新增即接线」这条义务兜。

**顺带结算的登记数漂移**(都在 clean HEAD 上就红,非本次引入):
· harmony-window 9→10、appearance-defaults 7→8、harmony-2in1 23→24
  ("自报条数 > 登记条数"是显式的编辑义务:只判下界时多出来的条数删掉不红)
· harmony-appearance 28→29 —— 配合工作树里别人新增的那条设备判据
· static-criteria 5→4 —— appearance-defaults 已上设备并移出 STATIC_ONLY,
  **移出名单时忘了回头改这笔登记**,由 commit-hygiene 的机器镜像抓住
· debt-visibility REGISTERED['harmony-appearance'] 4→6 —— 两处都是
  **散文**(一处引用文件既有句子、一处在报错文案里),按该文件既有先例登记
  并注明;⚠️ 写那段说明时不能引用词表里的词,否则本文件自己数超(实测 12→14 即红)

**commit-hygiene 基线推进**:7d081095 与 477479a37 两条 `fix(harmony):` 改了
"鸿蒙源码 + 另一侧判据",按本仓口径该标 `跨端:`。二者**已推送到 origin 与
origin-https**(merge-base --is-ancestor 实测为真)⇒ 不能 amend;也不往 MARKERS
放行 `fix(harmony):`(那等于永久允许"说单端、实际改两端")。唯一正确处置是
推进基线 + 具名记下。已验证:COMMIT_HYGIENE_BASELINE 覆盖后 pass=4 fail=0。

**CRITERIA.md 新增 §6.0「怎么读判据的数」** —— 原有 17 节全在讲「怎么写」,
缺的就是这一半,而今天两起独立事件都出在它:
· 6.0.1 接线守卫的失效形状是「全停」不是「那一条不跑」;ran 必须 == SUITE 条数
· 6.0.2 退出码只能来自不接管道的运行(`cmd >file 2>&1; echo $?`)。
  **本会话我连踩 5 次** `cmd | tail -N` ⇒ 报的是 tail 的码。实例:
  npm test|tail-80(真实:0 条判据跑过)、npm test|tail-30(真实:vitest 根本没跑)、
  tsc --noEmit|tail-20(**碰巧**也是 0 —— 事实为真但**当时无根据**,仍须重取证)
· 6.0.3 && 链里「全绿」要问**真跑到那一环了吗**(red 之后的东西根本没跑,
  而日志里「有 RESULT 行」与「无下游输出」可以同时出现)
· 6.0.4 判据变红先问「判据用的工具本身可信吗」(读取器的缺陷是**静默**的)
· 6.0.5 **当你就是改工具的人**:第一假设是「我弄坏了它」不是「代码回归」——
  「判据过期了」这个反应本身就错,它默认了「我改的是正确的东西」。
  附本次三次改错的下游依赖表,以及"下游依赖是**行为依赖**,
  codegraph 那类符号图看不见"。

★ 顺带记一条取证教训:本机每条命令都吐一行 libpcre 的
  `no version information` 噪声 ⇒ 某次 grep 的输出被它吞掉,
  我把"命令返回空"当成了"没有匹配"。**空输出要连退出码一起看**,
  这与 6.0.2 是同一族,只是这次发生在我自己的取证上。
2026-10-03 10:56:47 +08:00
93697c4061 test(工具链): ★★★ stripComments 两趟正则把真代码当注释吃掉 137 行 —— 改单趟扫描
**现象**:harmony-admin 那条「服务端要注册 GET /auth/me」报红,而
server/cmd/server/main.go:194 **明明写着** r.Get("/auth/me", handler.Me)。

**真因**(不是服务端写错,是读取器错了):
    main.go:78   // 与 /api/v1/agent/* 完全同一份代码
                                          ↑ 这个 /* 在 // 里面
stripComments 原来是**两趟正则**(先块 {/\*[\s\S]*?\*\//g}、后行),
两趟**互相看不见对方** ⇒ 块注释那趟在**还没删行注释**的文本上看到那个 /*,
当块注释开头,一路找下一个 */(在 :214)⇒ **137 行 / 37 条路由注册**
被当注释抹掉,含它正在断言的 r.Get("/auth/me", …)。
全仓另有 12 处同样写法(/me/*、/assets/*、plugins/*…)。

⇒ 失效形状是「**读取器静默少给一段真代码**」(不抛错、不警告),
  症状却出现在**被测对象**上 —— 看起来像"服务端把路由删了"。

**修法**:单趟字符扫描,且状态只用源码(注释内部不参与字符串状态)。
并**补上正则字面量**这一条 —— 漏认的方向是**假绿**:
harmony-device.mjs:59 的 /"bundleName"\s*:\s*"([^"]+)"/ 有 4 个引号(奇数),
打开的"字符串"永不闭合 ⇒ 后面所有注释被当字符串跳过。

五条性质逐条实测:① 行号不变 ② 'http://…' 字符串不被腰斩
③ 注释里的引号不污染状态 ④ 正则字面量被当正则 ⑤ 块注释连文本一起删。
变异测试:把旧实现放回去 ⇒ harmony-admin 确实变红(确认真修好了,
而不是"改的东西恰好没人用")。

**顺带修的三处判据自身缺陷**(都不是源码问题):
· inbox-fallback-poll:原断言钉的是**行尾注释里的字**
  (删掉注释照样绿、塞进 await fetchInbox() 也照样绿)⇒ 改为取
  if (lastTotal === null) { … } 整个分支做结构断言
· inbox-fallback-poll / sse-credentials:裸 readFileSync ⇒ 具名 code()
  (换成更严格的读取后变红,暴露的是判据本来就在判错的对象)
· appearance-defaults:写死包名 ⇒ ourBundle()(deviceprobe 那条在盯这个)
· criteria-hygiene:自检样本「以 // 开头的字面量」被判成写死路径 ⇒
  按**形状**排除,**不按文件/变量名豁免**(该文件自己的注释已写过
  「豁免按名字或目录裁 = 给逃逸指路」)。变异验证:改成真路径仍红。

边界 / 未做:本函数**不区分模板串里的 ${…} 与字符类里的 /**,
失效方向是假绿(少剥注释),与 stripStrings 记的方向一致。
2026-10-03 10:43:11 +08:00
5ad75fb229 chore(退场): opencode 宿主退场 —— 清 339MB 快照 + 登记退场豁免
用户 2026-10-03 确认「opencode 已经卸载了,清除残余」。

## 清除的(生产侧)

`/opt/agentmail/plugins/opencode-mail-bridge/` 整个目录(4 个快照 + current,
339MB)。删前核实过无任何引用:

  · 进程无、/etc/systemd/system/opencode* 无、/root/.config/opencode 无
  · 外部引用只剩三处**无关**命中:pi 单元里的注释、llmsproxy 的上游源、
    ollama 的 PATH —— 都不是对这个插件的引用

数据库里的 Agent 身份与 373 封历史邮件**保留**:那是协作记录,不是部署残余。

## 保留的(仓库侧)

`plugins/opencode-mail-bridge/` 与 `deploy/systemd/opencode-serve.service*`
故意留在仓库里,与 zcode 退场同处理 —— 退场是「这台机器上跑什么」的事实,
代码保留是「还能不能恢复」的能力,两件事分开记。

## 判据登记

`check-deploy-drift.mjs` 的 `RETIRED_HOSTS` 增加 opencode,并记下退场前实测到的
三件事,便于日后分辨「退场」与「半坏」:

  · 仓库里单元文件还在,但 git status 无删除记录 ⇒ 是主动卸载,不是部署事故
  · 快照已清除

**三个单元项一个都不能漏**(services + 两个 drop-in)。实测漏登记
`zz-restart-backoff.conf` 时判据立刻报「缺该文件」——这个豁免是「全都没装」,
漏登记等于给退场宿主留一个假红。

**变异验证**:把 `opencode-serve.service` 放进 /etc 模拟半装 ⇒ 豁免失效、
判据报「1 个宿主需要重新部署」;移走 ⇒ 回到「四个宿主都在跑当前代码」。

## 顺带清掉自己造成的一处污染

两个 dsh 快照里混进了 `package.json.bak-20261002-2015` —— 那是我改 peer 时建的
备份,被 `cp -a` 一起拷进了快照。仓库工作区那份早已删除,但快照里的被带上了,
漂移判据报「快照独有」。已清除(仓库无 .bak 文件,无需提交)。
2026-10-03 08:00:14 +08:00
1ea92098d1 fix(四桥)★★: parent_from 方向判据在 dsh/opencode 上是死代码 —— 接线补齐
## 起因:修部署漂移时撞出来的

b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName`,
用来分辨「回的是你那封」与「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型把它当新任务处理。**但只有 pi 桥接了**:

    pi        parentFrom: data?.parent_from    ✓(turn.mjs:148)
    dsh       从不传                        ✗ 3 处
    opencode  从不传                        ✗ 1 处

判据函数是三个桥**同一份** relay-policy.js 的拷贝,看起来测过了 ——
但「函数支持」≠「调用方传了」。三个桥各自都有 relay-policy 单元测试且全绿,
因为它们只测那个**纯函数**,没有一个测「桥真的把参数传进去了」。

dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段
⇒ tsc 报 TS2353 ⇒ 调用方即使想传也过不了类型检查。那行判据是被类型系统
**主动拦住**的死代码。只补 .js 不补 .d.ts 等于没补。

## 改动

dsh 3 处 + opencode 1 处调用点补 `parentFrom`/`selfName`,对齐 pi 的样板;
dsh 的 `.d.ts` 补声明(注释写明「光有声明不够,调用点也得传」)。

## 新增判据 deploy/check-parent-from.mjs(7 格)

不只是查「有没有传」,还查**三处曾经不一致的接缝**:

  ① 每个 inboundHeadline 调用点都传了 parentFrom(次数写死,新增调用点
     忘了传要能被发现,而不是被 `>0` 静默接受)
  ② 传了 parentFrom 就必须传 selfName —— 只传前者时
     `parentFrom === selfName` 永不成立,判据形同虚设
  ③ 三份 relay-policy.js 都真的有 `parentFrom === selfName`
  ④ dsh 的 .d.ts 声明与实现一致(否则 tsc 拦住,判据等于没有)

**变异验证**:撤一处传参 → 红 2;只撤 selfName → 红 1;撤 .d.ts 声明 → 红 1。

## ★ 判据自身也踩了两次坑(都已修)

1. 初版 `.d.ts` 那格用 `\bparentFrom\b` 全文件搜 ⇒ 撤掉字段声明后,
   **注释文字里还留着这个词** ⇒ 照样匹配 ⇒ 变异不红(假绿)。
   改为只认字段声明 `^\s*parentFrom\??\s*:\s*string\s*;`。
2. 初版 `.mjs` 写了 `require` ⇒ ReferenceError(ESM)。已改 import。

## 顺带修掉 6 个陈旧判据(都在 HEAD 上、此前静默红着)

`turn.test.mjs` 的「回信到达时明说不是新任务」:只造 `in_reply_to` 却断言
`/m-0/`,而 `b0c8719` 新增的「我的回复」分支文案里压根不含父邮件 ID ——
判据没跟上那次改动。已改成覆盖三种情形(parent_from 是我 / 是别人 / 缺席)。

★ 我第一版改完是**假绿**:只断言 `/续谈/` 时,把 `mine` 分支改坏也会走
「续谈」分支而照样通过。加反向断言(mine 不含「续谈」、theirs 不含
「不是新任务」)后才抓住。变异 A/B 各 35 pass / 1 fail。

`cross-bridge-permission-routing.test.mjs`:zcode 的路径与目录还指向
`d09ef39` 已删的 `hooks/`、`mcp/` ⇒ ENOENT。zcode 确实**没有** 409 分支了
(移除执行类工具所致,非缺陷)⇒ `permanent: null` 表示不适用,并加反向
守卫:若 `src/` 里出现 `isPermanentFailure` 就红(防止前提变了却继续空转)。
`isPermanentFailure` 是 `lib/relay-key.js` 的通用网关辅助,与权限放行无关 ——
踩过一次:把 `lib` 加进扫描范围后对着共享辅助函数误报。

全量:pi 533 / opencode 364 / dsh 433,零红(此前 4 红)。

## 部署与端到端

pi / opencode / dsh 三个宿主均已部署;dsh 已真实收发验证:

    [dsh-mail-bridge] 本轮不自动转发:来信方 pi 是 Agent…
    [dsh-mail-bridge] new_mail -> 新会话 mail-8e8bf319…
2026-10-03 00:25:28 +08:00
dsh
8894804dec docs(debt): 新债 —— 鸿蒙 Push Kit 契约已全部落地,唯一剩余阻塞是真机(push_tokens 0 行)
补投的 1f9ff3b4(09-15 11:06,已由 d50da1b0 / b9190558 答复,129 封在后)。
那是鸿蒙接推送的客户端半边契约 + 包名硬约束(AGC 拒 harmony 保留字)。
**账本里此前没有这条线的任何条目**(getToken/com.jianf.agentmail/PushService 均 0 命中)。
本轮逐项实测 ⇒ **契约已全部落地,唯一剩余阻塞是真实设备**。

① 包名(硬约束)已落地:
  client/harmony/AppScope/app.json5:3  "bundleName": "com.jianf.agentmail"
  全工程 grep `com.agentmail.harmony` = **0 处**(无残留)
② AGC 配置在位:
  client/harmony/entry/src/main/resources/rawfile/agconnect-services.json(源)
  ⚠️ entry/build/... 下那份是**构建产物**,同步/提交以源那份为准
③ 服务端三条端点齐备:
  handler/push.go:16 设备推送登记 · :58 POST · :119 DELETE · :150 GET(+ push_test.go)
④ ★ 客户端「推送必须是可选通道」逐条查实:
  ets/api/PushService.ets(433 行)9 处调用全部 try+catch,
  ★ catch 块里 throw / showToast / promptAction / console.error = **0 处** ⇒ 全静默,
    与 pi 第④条(不报错、不阻塞、不弹失败提示;主通道仍是 SSE)一致
  通知点击跳转(第③条 data 形状):
    MainPage.ets:1874 setRouteListener · :1886 clearRouteListener · :1925 pendingRoute

★ 唯一剩余阻塞: **真实设备**
  push_tokens 表行数 = **0**(今日实测)
  卡点: getToken 只能在**带华为账号的真机**上取到,无模拟器路径
  ⇒ 服务端/客户端/包名/AGC 配置都已就位,缺的只是一台真机走一遍:
    getToken → POST push-token → 复查 push_tokens 行数 > 0

边界: **没改任何代码、没跑真机**(本轮只读 grep + SQL + 文件查找)。
  上述"已落地"是**静态核实**(文件在位、端点存在、catch 静默),
  **不等于端到端跑通** —— 端到端需要真机,这正是本条的阻塞。
  我没有核 ApiClient.ets / LoginPage.ets 里 Push Kit 的全部调用路径,
  只核了 PushService.ets 的静默性质与 MainPage 的路由三处。

验证: repo Debt ok; criteria-hygiene 10/10。
  ⚠️ debt-visibility 仍 0/1(与本提交无关: harmony-appearance.test.mjs 边界声明 6>4,
     另一会话未提交的工作树改动;判据自己写着"别只改数字,先补一笔")。
2026-10-02 17:02:50 +08:00
d1099526ad fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点
## 缺口(部署后实测才发现,是我自己引入的)

第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:

    222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
    而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出

那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。

只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。

## 修法

`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。

marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:

    同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
    而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
    改一处忘另一处就会出现这种自相矛盾,且没有任何报错。

## 判据(2 格)

    TestFlattenAnnotatesEachCandidate  桥内部目录/相对路径能分类 + 带说明;
                                        真工作区不得被误标(否则全是噪声)
    TestClassifyPathAgreesWithRepo     handler 与 repo 口径必须逐条一致

## 顺带

第一版 flatten 本身已验证有效(生产实测):

    flatten=1 → 222 条候选、66 个工作区
    /home/program/agentmail 125 条 · /root 16 条 · root 2 条
    ⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」

    path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
2026-10-02 16:06:00 +08:00
1b810a4898 fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑
## 起因

DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:

    给 name      → 只给 path(要再调一次才知道有哪些会话)
    给 name+path → 给会话别名(但 path 得先猜对)

于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。

## 修法

**① A 项:`flatten=1` 一次给出全部可投递地址**

`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。

可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。

按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。

**② B/C 项:标注而非隐藏**

`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。

选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。

`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。

## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)

给 `SessionCandidate` 加 `LastActivity` 时用了:

    COALESCE(s.updated_at, '0001-01-01 00:00:00+00')

COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。

生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。

改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。

## MCP 侧同步

`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。

path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。

## 判据(8 格)

含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。

**变异验证**:

    COALESCE 兜底(那个真 bug)          → 红 1 ✓
    flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")

## 实测校准了一处报告里的数字

报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。

§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。

全量 14 包绿。
2026-10-02 15:59:56 +08:00
560c462768 feat(mcp): GET /api/v1/mcp —— 投递侧事件流(让接入方被动收信,不用轮询)
## 这半边解决什么

工具面(POST)只解决「接入方**问**」。这一条解决「服务端**说**」:
邮件投递时把 new_mail / session_update 推给接入方,让它**拉起对话** ——
与各桥靠 /api/v1/events/stream 收信是同一件事,只是方言不同:

    桥:   id: 7\nevent: new_mail\ndata: {…}\n\n
    MCP:  {"jsonrpc":"2.0","method":"notifications/message","params":{…}}

## 为什么复用 sse.Manager 而不是另起一套

Manager 里那些东西**都是踩过坑才对的**:writeMu 串行化(2026-09-28 -race
实测 http.ResponseWriter 并发写会把 JSON 劈成半截,800 帧只切出 459 个完整)、
Last-Event-ID 回放(宁可重复也不丢失)、心跳(反代按空闲 30-58s 掐连接)、
环形缓冲上限、断线清理。复制一份等于把那些坑再踩一遍,
而两边的修复从此各走各的。

代价是 `sse.Client` 多了一个可选 `Frame` 钩子:
**nil = AgentMail 原格式,各桥与 WebUI 行为一字未变**(默认值即历史行为)。

## ★ 回放是第三条写路径,漏了就只在断线时现形

`Send` / `SendWithID` / `replay` 是三条写 Res 的路径。原先**三条都把格式写死**,
只改前两条的话:MCP 客户端**平时**一切正常,只有带 `Last-Event-ID` 重连时
才会收到一批自己解不开的帧 —— 同一个连接上两种方言。

判据 `TestCustomFrameAppliesToReplayToo` 专门钉这条,并带反向对照
(nil 帧必须回落 AgentMail 格式)。

`Frame` 必须在**注册时**传入(`AddClientWithFrame`),不能事后设 ——
回放发生在「先写响应、再注册」的前半段,事后设只影响之后推来的事件。
原先 `AddClient` 保留为薄封装,各桥与 WebUI 调用点一字未改。

## 判据(6 格)

    Frame 是 JSON-RPC 2.0 通知 + 帧完整性(单事件、\n\n 结尾)
    payload 原样嵌入(不是 JSON 字符串)—— 再 marshal 会让客户端解析两次
    event_id / event_type 必带(前者是 Last-Event-ID 续传的依据)
    Accept 判定(含 q 值、大小写)
    匿名 GET → 401(不能变成静默的匿名订阅)
    缺 Accept → 406(接错的客户端会静默收不到东西)

## 顺带修:TestAdvanceRecurrenceLunar 的时区缺陷(★ 今天第三次假红)

全量测试红了,查下来是**我今天早些时候改判据时引入的**,与本次改动无关。

农历换算必须按**本地公历日**算(`AdvanceRecurrence` 里那句
`eventTime.In(time.Local)` 就是这条规则)。库里读回的 EventTime 是 **UTC**
(DSN 用 `_timezone=UTC`),UTC 比本地晚 8 小时,跨零点时农历日差一天:

    start    (Local) = 2026-10-04        农历日 24
    after    (UTC)   = 2026-11-01 16:00   农历日 23   ← 断言没换算时区(错)
    after.In(Local)  = 2026-11-02 00:00   农历日 24   ← 正确

服务端代码一直是对的,是判据没照做。失败信息里现在打印时区,
免得下次要重新推导一遍。变异验证:去掉 `.In(time.Local)` → 红 1 ✓

(这条判据是农历的第三次假红了:3459605「断言要求不存在的农历日」、
今天早些「起点写死日期 + advanceToFuture 跳过过期月份」、现在「没换算时区」——
三次都是判据自己写错,代码三次都对。它依赖 Local 时区与「今天」,
天生脆弱,值得记着。)

## 验证

    go test ./...              14 包全绿
    go test ./internal/sse/    含新判据绿
    go test ./internal/mcp/    6 格新判据 + 原 19 格全绿
2026-10-02 15:37:34 +08:00
5e312c6f5f feat(mcp): 补齐与四桥的三个参数缺口 —— 改名提议 / 线索翻页 / 转发命名
## 起因

做 MCP 与各桥的**参数级**对照(不是数量级)时,发现三处缺口。上一轮我
说过其中两处「服务端没有」—— **那是错的**,是我没查就下的结论:

| 缺口 | 真相 |
|---|---|
| `send_mail` 缺 `propose_alias` / `propose_reason` | ✅ 真缺口,且**不需要服务端字段** |
| `read_thread` 缺 `offset` | 服务端**早已支持**(`thread.go` 的 `intQuery(r,"offset",…)`),我漏传 |
| `forward_mail` 缺 `session_alias` / `session_id` | 服务端**早已支持**(`forward.go:33`),我漏传 |

三处都是「接上就行」,没有一处需要改服务端。

## ① 改名提议:为什么不是加个字段

`/mail/send` **没有** `propose_alias` 字段 —— 提议是**搭在正文里**发出去的:

    <!-- agentmail:rename-session alias="fix-login-leak" reason="定位到泄漏点" -->

服务端用正则摘出来、把标记从入库正文剥掉、把规范化后的别名回填到响应的
`rename_proposed`。载体选 HTML 注释的三个理由见 `lib/rename-proposal.js`:
react-markdown 默认不解析 raw HTML(没剥掉也不破版)、纯文本客户端里一行不碍事、
不与 Markdown 语法冲突。

所以在 Go 侧复刻了 `lib/rename-proposal.js`(三方插件共用那份)的三段逻辑:
`isProposableAlias` / `appendRenameProposal` / `renameProposalNote`。

## ★ 这一层的真正风险:跨语言镜像

格式差一个空格(或把双引号写成单引号),服务端正则就匹配不上,而**失败是
静默**的:邮件照常发出、提议凭空消失、模型以为自己提过了、下一封拿那个不存在的
别名寻址 → 404。

判据因此钉两件事:

- **能被服务端那个正则真的解出来** —— 直接 import `renameProposalRe`,
  不是另写一个(复制一份就放弃了「镜像」的意义)。
- **与 JS 版逐字节相同** —— 三个用例(含「理由里的双引号要去掉」)逐字符对照。

## ②③ 线索翻页与转发命名

`read_thread` 透传 `offset`(长线索不再只能拿首段);`forward_mail` 透传
`session_alias`(给转发出的新会话命名)与 `session_id`(与其它读端点一样过
`agentScope` 收窄)。

## 一处**故意**与桥不同的差异

`connect_to_server` 在桥侧有 `gateway_url` / `key_token`,MCP 侧保持无参 ——
网关内建端点**已认证**,改坐标是部署动作,不该由一次工具调用触发(桥侧能改是
因为它是局外进程)。判据里为此写了注释,防止将来有人"顺手补齐"。

## 判据(rename_proposal_test.go)

参数级对照那格钉「与其它桥逐字一致」——**缺参数不会报错**,只会让模型以为
该能力不存在,属静默缺陷。

**变异验证**:

    删掉 propose_alias 两行(回到缺口态)    → 红 1 ✓
    标记少一对引号(跨语言镜像写错)         → 红 2 ✓
    非法别名也追加标记(静默丢弃的来源)     → 红 2 ✓

第三条最要紧:别名不合法时**必须**不追加标记,否则发出一个服务端匹配得上却被
`validateSessionAlias` 拒掉的标记 —— 失败仍然是静默的。

## 两次判据自身缺陷(都记下来)

1. 「与 JS 版逐字节相同」那格最初用 Go 字符串字面量写期望值,`\n` 成了字面两字符
   ⇒ 判据错报红。代码是对的,判据错了。
2. 变异脚本只切掉 `strProp(…)` 的**第一行**、续行留在原地 ⇒ schema 仍合法 ⇒
   「0 红」。**没有采信那个 0**,改用完整锚点重测才拿到正确的红 1。

全量 14 包绿。
2026-10-02 14:37:09 +08:00
d09ef395b4 refactor(zcode): 删掉本地 MCP 服务器与整套执行门禁 —— MCP 已内置网关
## 删了什么

**本地 MCP 服务器**(工具面已内置于网关 `POST /api/v1/mcp`,见 457d160):

    mcp/server.mjs          stdio JSON-RPC 入口
    lib/tools.mjs           11 个工具(手抄网关语义 —— 已抓到两次抄错)
    lib/mcp-rpc.mjs         手写协议层
    test/{tools,mcp-rpc,generic-mcp}.test.mjs

**执行门禁整条链**(用户裁定:直接移除):

    lib/action-tools.mjs    run_command / write_file
    lib/approval.mjs        授权判定
    lib/grants-file.mjs     「一直同意」跨进程持久化
    lib/hook-policy.mjs     档位判定
    hooks/permission.mjs    PermissionRequest 钩子
    hooks/hooks.json        钩子注册
    test/{action-tools,approval,grants-file,hook-policy}.test.mjs
    test/manual/{gate-e2e.py,permission-e2e.mjs,gate-e2e-evidence.json}

## 为什么执行门禁可以整条删,而不是留着

`run_command` / `write_file` 的门禁是**双进程审批**设计:MCP 进程问人、
ZCode 钩子进程等回答、中间靠落盘授权表对齐。三样都依赖**本地 MCP 进程**。
进程没了之后:

    没有任何代码装载 buildActionTools   ← 实测确认(只剩测试在测它)

也就是说它已经是死代码,而死代码 + 它的判据会让人误以为「这个平台有执行面」。
留着比删掉更危险。

本平台现在的姿态是**失败关闭**:平台自带 32 项危险工具被禁用,
AgentMail 侧不提供任何执行类工具 ⇒ 模型没有执行面。

## detectModeEnforcement 重写

它原本有三条依据,现在只剩一条还成立:

1. ~~平台 PermissionRequest 钩子~~ —— 目录整个删了。**留着「钩子是否注册」
   的判据只会说谎。**
2. ~~我们自己的门禁~~ —— 删了。
3. ✅ `--disallowed-tools` 禁用清单 —— 仍在,且现在是**唯一**那道。

报 `native` 的含义随之收窄为「该档位真的**没有执行面**」,而不是以前那个
「有人会来问」。降级路径(清单被清空 ⇒ advisory)仍有效,实测:

    正常配置  → native   | 平台自带危险工具已禁用 32 项…⇒ 模型无任何执行面
    清单清空  → advisory | 禁用清单自检未通过…禁用清单就是唯一那道

## 清单与 package.json

`.zcode-plugin/plugin.json` 去掉 `mcpServers` 与 `hooks`(两者的目标都已不存在)。
`package.json` 去掉 `main` 与 `verify`(已无本地入口)。

## 误删与自查

删 `test/permission-grants.test.mjs` 时**误删了一个仍在使用的共用库的测试**
—— `lib/permission-grants.js` 四方同源,dsh / opencode / pi 都还在用。
`deploy/check-shared-libs.sh` 立刻报「共用测试缺失」把它抓出来,已恢复。
若没有那道检查,这会是一个静默的覆盖损失。

## 验证

    node --test 'test/*.test.mjs'      293/293 绿(原 352,删掉 59 格死代码判据)
    deploy/check-shared-libs.sh         四方同源 rc=0
    detectModeEnforcement 实测           native / advisory 两条路径都对

## 后续

本目录现在只剩**邮件驱动**(SSE 订阅 → 起一轮 → 回信)与共用库。
若将来要在 zcode 侧恢复执行能力,需要重新设计门禁 —— 现有形状不能复用,
因为它的双进程模型随本地 MCP 进程一起消失了。
2026-10-02 14:14:06 +08:00
2f17f62871 feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)

`redeploy-gateway.sh` 用 mtime 比对源码与 dist:

    newer=$(find src -type f -newer dist/index.html)
    [ -n "$newer" ] && exit 1

mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。

2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。

## 为什么不是拆掉那道闸

2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。

本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:

    误拦的代价 = 多构建一次(几十秒)
    误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)

## 判定口径(deploy/web-comment-only.mjs)

对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。

- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。

## 判据(7 格)

`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。

**变异验证**:

    去掉注释分支(所有行算真改动)  → 红 7(部署会被那一行注释继续拦)
    isCodeChange 恒 false           → 红 7(把 2026-09-14 的静默失败放回来)

第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。

## 真实场景验证(不是只跑单测)

    把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
      ⇒ commentOnly=true,理由写明「差异仅为注释」
    临时往 sse.ts 追加一行真代码
      ⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
    bash deploy/redeploy-gateway.sh --dry-run
      ⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建

## 顺带

`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
2026-10-02 13:58:42 +08:00
457d1608f0 feat(mcp): MCP 集成进网关本体 —— POST /api/v1/mcp(Streamable HTTP)
## 为什么要集成而不是独立进程

上一版(29ad8aa)是独立进程 `plugins/zcode-mail-bridge/mcp/server.mjs`,
用 HTTP 调本网关。四条真实成本:

1. **工具语义有两份**。桥里的 read_inbox / send_mail 是**手抄**网关的,
   抄错就是行为分叉 —— 已抓到两次:`connect_to_server` 只发
   `X-Agent-Secret` 头,而 `/agent/register` 只认 Bearer 或 body 里的
   secret ⇒ secret-only 的 Agent 必然 400。
2. **鉴权与收窄要再实现一遍**。工作区收窄、会话收窄、冷静期、配额住在服务端。
3. **多一跳 + 多一个故障点**。
4. **接入端仍要装东西**(node + 桥 + 环境变量)。

现在:工具**包装现有 handler**,同一份代码、同一套鉴权与收窄;
接入端只填一个 URL。

## 传输与实现(用户裁定)

- **Streamable HTTP**(规范 2025-06-18):单端点 POST,通知回 202,
  请求回 JSON-RPC。
- **包装 handler**(不是直调 repo):`newRequest` + `invoke` 造内部请求
  交给 `handler.GetInbox` / `SendMail` / … 于是 `AgentMayReadSession`、
  冷静期、配额、附件保护目录全部是同一条代码路径,不是复述。
- 手写零依赖 JSON-RPC(协议面只有 4 个方法),与本仓取向一致。

端点挂在 `AgentAuth` **之内**:必须与 /mail/send 同一套凭证,
否则就成了绕过收窄的旁门。

## 11 个工具,名字与参数与四桥逐字一致

`connect_to_server` 在这里只做一次真实读来确认连通性 —— 能调到它本身
就证明凭证已过(它是局内端点,不再需要 register)。

## ★ 端到端撞出并修掉的两个真 bug

**① `Tool.Run` 丢掉了身份**(本来写成 `context.Background()`)。
症状:每个工具调用都 Unauthorized,模型表现为「说连上了但读不到任何信」。

**② 路径参数没到位**:被包装的 handler 用 `chi.URLParam(r,"id")` 取 id,
而 `httptest.NewRequest` 造的请求**没过 chi 的路由** ⇒ `URLParam` 恒空
⇒ 任何带路径参数的工具都报「Invalid id」。

第②个的发现过程值得记:端到端测越权时,主人和越权者**都**返回
「Invalid id」。只看越权那一次会误判成「收得太紧」,进而把**正确的收窄改松**;
做对照才看出是参数没到位。

修法两处:`withRouteParams` 注入 chi RouteContext;`invoke` 里**不能**再
`WithContext(ctx)` —— 那会覆盖掉刚注入的 RouteContext。

**③ 发现并暴露了会话越权漏洞**(同批,单独提交 095213b):
`AgentMayReadSession` 只比 `scope == target`,不问「你是不是参与方」,
而 session_id 由请求方给。对照实验 + 生产复核证实可读他人正文。

## 判据(13 格)

`internal/mcp/mcp_test.go`。真正在钉三件**只有集成才可能坏**的事:

1. MCP 不能成为越权旁门(工具参数里没有身份字段)。
2. 参数映射不许偷偷放宽/收紧(`attachment_ids` 被吞 ⇒ 附件静默不随信发出)。
3. 协议语义不许退化(工具失败必须 result+isError,不是 JSON-RPC error)。

`TestEveryErrorResponseCarriesID` 是被真 bug 逼出来的:曾用
`ID json.RawMessage` + `omitempty`,nil 时**整个 id 字段从 JSON 里消失**,
客户端会一直等这条的响应。遍历全部错误出口逐条验。

**变异验证**:

    Run 丢身份                    → 红 4
    工具失败回 JSON-RPC error     → 红 4
    read_inbox 丢 workspace 收窄  → 红 1
    id 泄露(tag+idPtr 同时失效) → 红 1 ★(真 bug 需两处同时失效,故两处防御都要留)
    去掉 withRouteParams          → 红 1
    invoke 里加回 WithContext     → 红 1

## 端到端(真实网关进程,临时库,备用端口 8199,不动生产)

    未认证 /mcp              → 401
    错误密钥                 → 401
    initialize               → 回显 2025-06-18
    notifications/initialized→ 202 且无响应体
    tools/list               → 11 个,带 annotations 与 required
    send_mail → read_inbox   → mcp-peer 通过 MCP 读到对方发来的信
    read_mail(带 session_id)→ 主人读到自己的信

## 未做

- 未删除旧桥 `plugins/zcode-mail-bridge/mcp/server.mjs`。它是 zcode 插件
  清单里声明的入口(`.zcode-plugin/plugin.json` 的 mcpServers),删掉会破坏
  该插件的组装。两者并存无害:桥仍走 HTTP,服务端这份是接入端零安装的那条路。
- 未部署(本提交只含代码)。
2026-10-02 13:28:07 +08:00
095213b981 fix(安全)★★: 声明别人的 session_id 就能读那封信 —— 补「参与方」判据
## 漏洞(实测,生产可利用)

对照实验(同一会话、同一 Agent,绕开 MCP 直打原生端点):

    主人 mcp-peer 读自己的信(带正确 session_id)        ⇒ 200(正常)
    他人 mcp-probe 读同一封信(**带正确 session_id**)      ⇒ 200 ★ 泄露正文

绕开 MCP、直接 `GET /api/v1/agent/mail/{id}?session_id=...` 同样 200
⇒ 根因在网关,不在任何接入方式。

生产复核(现行 8180,未改任何代码):

    dsh 声明 gui-lab 的 session_id ⇒ HTTP 200,拿到完整正文

## 根因:判据里没有「你是谁」这一项

上一版(今天早些时候,15e4fe9 那次)只把 `if scope == nil { return true }`
改成拒绝,堵住的是「**不声明** session_id 就放行」。剩下的半边是
「声明一个**别人的** session_id」。

判据只有一句 `*scope != target` —— 它问的是「你声明的会话是不是目标会话」,
而 `session_id` **由请求方自己给**。于是任何持有 Agent 凭据的客户端只要报出
一个已存在的会话 id,就能以那条会话的身份读它。

漏洞的形状就写在签名里:函数**收了** `agentName`,却被 `_ = agentName` 丢弃。

## 「信任边界在桥」这个前提不成立

函数头原来写着「信任边界在**桥**:`session_id` 由 worker 闭包注入(模型改不了)」。
那是对我们自家四个桥的陈述,**不是**服务端能强制的事实:

1. 桥与网关之间是普通 HTTP。任何拿到 Agent 凭据的客户端都能直接调这些端点
   (本次实测即是如此)。
2. 「由闭包注入」是对我们自己代码的信心,不是收到请求时能重新验证的事实。

上一版收口时也用过同类理由(「迁移期未结束」),实测同样不成立 ——
这是同一天内第二次。

## 修法:把「声明」变成可验证的事实

保留 `*scope != target`(2026-09-15 裁定:会话是独立单位、不跨会话读取),
**另加**一道参与方校验:

    scope == target 且 agentName ∈ 该会话的参与方(from / to / cc) ⇒ 放行

- 不引入工作区轴(那是 cwd/沙箱那条轴,2026-09-15 明确划开)。
- 不新建表、不加迁移。
- 参与方判定与 `SessionParticipants` 同源(逐封扫 mails,同一个 `models.Address`
  JSON 解析),避免两处对同一份数据给出不同答案。
- 抄送方算参与方:生产里有 2622 行非空 cc_list,只认 from/to 会把正当读者判成外人。

## fail closed

查参与方出错(DB 不可用、cc_list 解析失败)一律拒绝并带错误。
这道闸的失败模式必须是沉默的拒绝 —— 一旦「查不到就放行」,
数据库一抖就等于把漏洞重新打开,且没有任何日志。

## 判据(9 格,含改写)

改写 2 格:`AllowsOwnSession` 原来用 `uuid.New()` 造一条**不存在的**会话
就断言放行 —— 那正是漏洞的形状;`IgnoresAgentName` 断言「身份不参与判断」,
**这条断言本身就是漏洞**。两者都改成断言新事实。

新增 7 格,覆盖:声明别人会话被拒 / 抄送方放行 / 主收件方放行 / 空身份拒绝 /
判定随身份改变 / 跨会话仍拒(参与方也不行)/ fail closed。

**变异验证**(每条确认已应用后才数红格):

    退回漏洞原状(跳过参与方校验)   → 红 3
    只认 from_agent(漏 to 与 cc)  → 红 1 ★(先测时红格为 0,补了主收件方那格才抓住)
    fail open(查不到就放行)        → 红 1 ★(同样先红格为 0,补了 fail-closed 那格)

后两条是**补判据的过程**:`return true` 那版和「只看 from」那版都能全套通过,
说明原先的判据盯不住这两个改法。

## 对现有桥的影响(部署前实测)

按生产数据核对四个桥:「from_agent 是它、但它不是任何邮件参与方」的会话
只有 1 条,且**零邮件**(一条权限请求测试会话)—— 那类会话没有邮件可读,
判据影响为零。桥不会被误伤。

## 波及面

`AgentMayReadSession` 有 5 个消费点:read_mail / read_thread / 读会话参与者 /
forward 的源信 / AgentGetMailThread 另一分支。全部自动获得这道判据。

全量 14 包绿。
2026-10-02 13:27:43 +08:00
29ad8aa204 feat(mcp): 去 ZCode 影子 —— mcp/server.mjs 改为通用 MCP 服务
## 目的

`mcp/server.mjs` 此前注释与行为都绑定 ZCode,接入端必须为 AgentMail 写
专用插件。去掉这层绑定后,任何支持 MCP 的宿主挂一行配置即可用:

    {"command":"node","args":["…/mcp/server.mjs"],"env":{
      "AGENTMAIL_GATEWAY_URL":…,"AGENTMAIL_AGENT_NAME":…,
      "AGENTMAIL_AGENT_SECRET":…,"AGENTMAIL_MCP_PLATFORM":"my-host"}}

协议层(零依赖手写 stdio JSON-RPC)与 11 个邮件工具本就与宿主无关,
真正要动的只有 4 处耦合 + 工具面。

## 改动

**1. 移除执行类工具(`run_command` / `write_file`)**
它们的门禁(lib/action-tools.mjs + lib/approval.mjs + 落盘授权表)是为
ZCode headless 的**双进程审批**设计的:MCP 进程问人、ZCode 钩子进程等回答、
中间靠文件对齐。脱离该宿主后这套门禁的前提不成立,挂在通用服务上等于
提供一条**没有审批的旁路**。
`lib/` 里三个模块与 `hooks/` 源码保留(桌面模式的 ZCode 仍走它们),
只是 server.mjs 不再装载。

**2. platform 可配置**:`AGENTMAIL_MCP_PLATFORM`,默认 `mcp`,
空白值回落默认值。原先硬编码 `'zcode'`(两处)。

**3. 错误文案去宿主名**:不再让模型/人「去 ZCode 的插件设置里填写」,
改为说明设置 `AGENTMAIL_*` 环境变量。

**4. 提示词如实说能力**(src/prompt.mjs):原文案向模型承诺
`run_command`/`write_file` 可用并分档描述「会被请示 / 直接生效」。
工具移除后那变成**指向不存在工具的承诺** —— 模型会去找、把整轮浪费在
换名字重试上。改为明说「本平台没有执行面,需要动手就写进回信请人做」。
三档措辞仍互不相同(`plan`/`workspace`/`full`),因为「档位仍存在但都无
执行面」这件事模型需要知道。

## ★★ 顺带修掉一个真实缺陷(端到端撞出来的)

`connect_to_server` 对 secret-only 的 Agent **一直 400**:
`/agent/register` 只认 `Authorization: Bearer` 或 body 里的 `secret`,
不认 `X-Agent-Secret` 头(其它接口才认),而它漏了 `body.secret`。
dsh / pi 正是 secret-only 配置 ⇒ 它们调「连一下服务器」必然失败,
且模型看不出该改什么。
lib/gateway.mjs 的 `register()` 本来就做对了,tools.mjs 里是手抄的劣化副本。
修后实测 `HTTP 400` → `已连接 …(状态:registered)`。

## 判据

新增 `test/generic-mcp.test.mjs`(5 格)。**这三件事此前无人看守**:
变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
都能全套通过 —— 因为没有判据看 server.mjs 实际挂了什么、也没人看 platform。

改写的 4 格(prompt 3 格 + driver 1 格)保留原意图(不向模型撒谎、
native 自报要有真凭据、工具不存在时不要重试),改为断言新事实。

**变异验证**(每条都确认已应用后才数红格):

    挂回 action-tools            → 红 3
    platform 硬编码 zcode        → 红 3
    platform 空白不回落           → 红 3
    文案指回 ZCode 插件设置        → 红 3
    删掉 body.secret(400 复现)  → 红 3

全套 **402/402**。

## 端到端验收

写了一个**非 ZCode 宿主**探针(纯 stdio JSON-RPC,不加载任何插件),
对着真实网关跑通:initialize → tools/list(11 个,无执行类)→
connect_to_server(registered)→ suggest_address。

## 未做

- 未发布到 npm registry(`npx` 即用需要发布或指向仓库路径)。
- 未改 `check-deploy-drift.mjs` 的 zcode 豁免(本机仍不退场该宿主)。
2026-10-02 12:31:18 +08:00
56c699b338 test(zcode): MCP 提示词测试的陈旧断言 —— b0c8719 加方向判据时漏改本文件
## 现象

跑 zcode-mail-bridge 全套(AgentMail 自身 MCP 服务面的测试),
`prompt.test.mjs` 红 1 格:「带 in_reply_to 时要说清是回哪封」。

## 根因:不是回归,是 b0c8719(09-30)漏改测试

b0c8719 给四桥的 in_reply_to 加了**方向判据**:

    只有 parent_from === 自己 时才说「回的是你那封」,
    否则是多方线索里的他人续谈(单向续信链曾被逐封误读成双向对话)。

那次改了 lib/relay-policy.js + src/prompt.mjs + relay-policy.test.mjs,
**漏改了 prompt.test.mjs**。旧夹具只传 in_reply_to、断言旧的无条件文案
「回的是你那封:m0」,而新代码正确地不再指认方向 ⇒ 假红。

实测三场景确认代码行为符合 b0c8719 设计意图:

    只有 in_reply_to       → 「回复到了」但不指认方向   ✓(退回旧行为)
    + parent_from=自己     → 「回的是你那封:m0」       ✓
    + parent_from=别人     → 「多方线索里的续谈…」      ✓

## 修法

夹具补上 parent_from,并把三个方向场景钉全(真回复 / 多方续谈 /
服务端未升级),含反向断言。

## 变异验证

    变异①:prompt.mjs 丢掉 parent_from===agentName 守卫 → 红 1 格 ✓
    变异②:relay-policy 的 mine 永真               → 红 1 格 ✓
    复原后 16/16 绿;全套 25 文件 397/397。

## 附带发现(未改)

`node --test test/`(目录级)会报一个 test:1:1 的幽灵 fail——
node 把 test/manual/(e2e 手册与证据 json)当测试目录递归了。
用 `node --test "test/*.test.mjs"` 跑即干净。manual/ 内容非自动测试,保留不动。
2026-10-02 11:52:34 +08:00
6757644756 fix(判据): 农历推进的起点必须在未来 —— 否则 advanceToFuture 跳过过期月份必假红
## 现象

部署被测试闸拦下(这正是纪律该做的事):`TestAdvanceRecurrenceLunar` 报
「间隔 58 天不像一个农历月」。已确认与本次 SSE 改动无关
(把我的改动全部 stash 后它同样红)。

## 根因(实测复算,不是推测)

`AdvanceRecurrence` 走 `advanceToFuture`,职责是「推进到**未来**」:
已经过去的农历月会被跳过(`if cur.After(now) { return }` 那个循环)。

原起点写死 `2026-09-03`,而今天已是 10-02 ⇒ 那个农历月(10-02)已过去
⇒ 循环再推一个月。探针实测:

    起点 2026-09-03 ⇒ 落点 2026-10-31 间隔 58 天  ← 旧起点,今天跑必红
    起点 2026-10-03 ⇒ 落点 2026-11-01 间隔 29 天  ← 明天,正确

农历库本身是对的:直接调 `AddMonths(1).ToSolar()` 得到的落点恰好 29 天。
所以**不是代码缺陷,是判据的期望依赖了「今天离起点不到一个月」**这个
随日期漂移的前提。

## 修法

起点改为「明天」起算(不写死具体日期 ⇒ 明年跑也成立),
农历日的期望也跟着起点走。

★ 不用 `time.Now().AddDate(0,1,0)` 那种相对写法:农历月 29/30 天不定,
  起点落在月末时下一个同农历日可能被夹(commit 3459605 记的同族假红)。
  明天起算同时满足「确保在未来」与「落点就是下一个农历月」。

## ★★ 修判据时差点削弱了它(变异验证抓出来的)

把起点改成明天后判据绿了,但我立刻做变异验证:

    变异:advanceToFuture 不跳过已过期月份 ⇒ TestAdvanceRecurrenceLunar **全绿**

因为起点在未来,第一个落点本来就在未来,循环与单步没有区别 ——
**我修好了假红,却顺手删掉了「跳过过期」这个真行为的判别力。**

补了一格:用**已过期 70 天**的起点单独钉它,落点必须在未来,
否则「每次扫描都重复触发同一封提醒」。变异重测 ⇒ 红 1 格。

这是同一天内第二次判据自身缺陷(第一次是 SSE 那格只查文本不查控制流)。
**改判据后必须变异验证判别力还在**,否则就是用改测试掩盖问题。
2026-10-02 10:57:59 +08:00
aeb1f4116b fix(WebUI): SSE 订阅跟着账号凭证走 + 断线重放 + 兜底轮询
用户报:**页面停留不动,新邮件不自动同步**(手动刷新能看到)。

## 根因一(主因):SSE 连接不跟着账号走

`App.tsx` 的 effect 依赖是 `[phase]`,而切号(`accountStore.setActive`)
只换 `api/config` 的 base/token、**不改 phase** ⇒ SSE 连接仍绑旧账号的凭证:
旧账号的新邮件照收,新账号的一封都不推。而 `fetchInbox` 走**新**凭证 ⇒ 数据是新的。
⇒ 表现正是「不自动同步,但手动刷新能看到」。

修法:effect 依赖加上「当前凭证身份」(base + token)。
不在切号处显式重建订阅 —— 那要改所有调用点、漏一处就不刷新;
凭证变化的**唯一发生地**是 api/config,从那里取身份更可靠。

★ 身份**不含 user**:同一账号重新登录 token 变了,那个账号的邮件仍该收
  (服务端按 user/agent 绑通道,见 sse.bufferKey);
  按 base+token 判只会让「同账号换令牌」多触发一次重连(无害)。

## 根因二:断线重连不重放

服务端一直支持按 Last-Event-ID 回放(ring.replay,500 条缓冲),
EventSource 断线后**本来会自己重连并带该头**。但这里的 onerror 主动
`close(false)` 再 `open()` —— **换了 EventSource 对象**,
而 Last-Event-ID 是浏览器为**那个对象**记的 ⇒ 服务端拿不到 ⇒ 不回放。

EventSource 不能设请求头 ⇒ 游标只能进 query,服务端相应要读
`?lastEventId=`(**两侧都要改,缺一半都不生效且没有任何东西会红**)。
服务端写成 query 优先、header 兜底 —— header 保留给 Agent 侧(curl/SDK)。
⚠ query 会进访问日志;游标是自增数字(不是令牌),与「令牌不进日志」的约定不同级。

`onerror` 区分两种重连:
- 断线(凭证没变)⇒ 带游标,服务端回放断线期间的事件
- 切号(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号会搅乱事件流

## 根因三:连接静默但不再收数据

SSE 只在**真的断开**时触发 onerror。有一类故障它看不见:
连接还在、TCP 没断、却不再收数据(代理静默丢包 / NAT 超时 /
中间设备挂死长连接)。两端都认为正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。

补 `lib/inboxFallbackPoll.ts` 作为冗余通道:
- 探针 `getInbox('all', 1)` **只要 total**(全量重拉会让接口与渲染无谓抖动)
- 首轮只建基线不触发;探针失败**不重置基线**(否则一次抖动会变成「下一轮假装有变化」)
- inFlight 去重,慢网络下不叠请求
- 页面隐藏时暂停,恢复可见**立刻探一次**(用户往往正是「切回来发现没更新」才报的)
- 切号时 resetPollBaseline:新账号 total 与旧账号无关,不丢会白拉一次
- 间隔 30s:远大于 SSE 的秒级延迟(正常时纯冗余),又短到挂死最多 30s 被发现

## 判据

12 格(sse-credentials 5 + inbox-fallback-poll 7)。九个变异全部经得起:
依赖退回 [phase] / 重连不带游标 / 切号也带旧游标 / 服务端不读 query /
catch 重置基线 / cleanup 漏停轮询 / 凭证依赖丢失 / 探针拉全量 / 恢复可见不立即探。

★ 一处判据自身缺陷被变异抓出来并修掉:第 3 格原先只查
  `url += \`${sep}lastEventId=…\`` 这行**文本存在**,把 `if (lastEventId)`
  改成 `if (false)` 后照样绿 —— 正则匹配文本,缺陷在控制流。
  补了条件本身的断言才红。与「catch 里不得重置基线」是同一类教训。
2026-10-02 10:46:45 +08:00
477b74230f fix(限流): 用 ORDER BY ts 取窗口内最早一条,不靠 MIN(ts) —— 429 的 retry_after 恒为 60
## 缺陷

`SELECT MIN(ts) FROM rate_limits ...` 的聚合结果被 SQLite 驱动按 **string**
返回,扫进 `*time.Time` 失败 ⇒ 落到兜底 `return false, 60`。

⇒ 所有 429 的 `retry_after` 恒为 60,与真实剩余窗口
(最长 `sessionRateWindow` = 1h)完全无关。调用方拿到的重试提示是错的:
限流窗口还有 55 分钟,它却说 60 秒后重试。

## 修法

`SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2 ORDER BY ts ASC LIMIT 1`

排序取值走**结果集本身**,驱动按列类型给 `time.Time`;语义等价。

★ 同一形状的坑今天已出现两次:上午 2h 冷静期因 UTC vs HKT 差 8 小时而形同虚设,
晚上权限记账因两处 `if` 守卫而静默失效。**根子都是「SQLite 侧的时间/类型处理
与直觉不符」,而症状在别处。**

## 判据

4 格(retry_after 反映真实窗口 / 绝不超过 window / 窗口滚动后放行 /
只数窗口内的记录),其中主判据显式对比「修复前 60,修复后 ≈window」。

本改动此前已随 2026-10-01 的两次部署进入线上二进制(vcs.modified=true),
本次补提交以让 provenance 对得上。
2026-10-02 10:02:35 +08:00
dsh
c3370af834 docs(debt): 新债 —— 判据只钉 keying 不够:上溯要钉「沿哪条链」与「None 怎么办」(同线第 4 次口径分叉)
复核 pi 的 e659a655(09-25 04:58:29,52 分钟后由 b4de8b50 答复,77 封在后)。
他认了我两处模式错,并复现不了我的「98→36/抑制 62」⇒ 他说实测 98→61、抑制 37。
★ 他还发现: 他第一版脚本「沿 failure 链 + 上限 50 步」**有一条链跑满上限**
  ⇒ 那份抑制数是假的 ⇒ 改「沿完整 parent 链、上限 300」才对上 37
  ⇒ 由此得出落地风险: 上溯在**自引用/环**上会跑满 ⇒ root-keying 落地必须带
    「有限步终止 + 超限行为显式」的判据。

★★ 本轮把这条线的口径彻底钉住 —— **同一条线上第 4 次「数都对、口径不同」**。
底数 `relay_key LIKE '%failure:%'` = **113** 封(他 09-25 用的 98 已是旧快照):

  | 分组键                                    | 组数 | 抑制 | 剩余 |
  | (agent, failroot) 只沿 failure 链 ←他的口径 |   8 | **37** | 76 |
  | (agent, failroot) 先滤 failroot IS NULL    |   6 | **7**  | —  |
  | (agent, 完整 parent 链上溯的根)            |   5 | **108**|  5 |
⇒ 37 与 7 差在"要不要把无根的算进去";**108 是荒谬值**(把 32 封 service-failure:* 并成组)
⇒ ★ 判据必须钉**沿哪条链**,而不仅是 keying —— 同一组数据、两个口径、两个根:
    沿 failure 链  : 环里 5 封根**全部** = b3ce9d0f(他的自检,成立)
    沿完整 parent 链: 环里 5 封根**全部** = **None**(我 09-30 实测)
  ⇒ "全指向同一个根"这句话**必须带口径**

★★ 他的落地风险与本会话已登记的债**同源**(我 09-30 从另一侧撞上):
  thread.go:43 cap 用途注释「数据损坏时的兜底」· :47 const = 10000 · :65 ThreadRootOf
  · :74 WHERE up.lvl < $2 · :77 Scan(&rootID,&lvl) · :81 return rootID, lvl, nil
  ★ 取到 lvl 后**从不与 cap 比较**、原样上报 anchor_depth
  ⇒ 他问"会不会跑满",我问"跑满后有没有人说" ⇒ **同一个洞的两端**
  ⇒ 都指向同一条: 落地前先有"终止 + 可信"判据

⚠️ 但"会跑满"目前是**理论风险,不是活实例**(先量过再说):
  直接自引用 (parent_mail_id = mail_id) = **0**;200 封随机样本里存在环的数量 = **0**
  ⇒ 当前库无自引用/环 ⇒ 不构成现网风险;但**类**是真实的(relay 层的环确实存在)

★ 自更正(提交前逐行核): 我 first pass 把 thread.go 的递归写成 :36-81,
  实测 :36 是一条 Depth 字段的文档注释、:77 是 Scan 而非 WHERE —— 已按 grep 重定位为
  43,47,65,74,77,81 逐行标注。(这条恰是我 09-30 刚登记的「引用要逐行核实」自己。)

未做: 没改代码、没改判据脚本(本轮只读 SQL + grep)。
  113/8/37/76/6/7/108 都是此刻的瞬时值。我没有验证这两种上溯在别的会话里是否也分叉。

验证: repo Debt ok; criteria-hygiene 10/10。
  ⚠️ debt-visibility 仍 0/1(与本提交无关: harmony-appearance.test.mjs 边界声明 6>4,
     另一会话未提交的工作树改动;判据自己写着"别只改数字,先补一笔",那笔债属对方工作)。
2026-10-02 04:09:40 +08:00
dsh
6a462d7f25 docs(debt): 新债 —— 判据期望值写错会恒假:按 (agent, failroot) 分组必须先滤 NULL 根
复核 pi 的 b9c5070b(09-25 04:57:46,3 分钟后由 7b69434c 答复,78 封在后)。
他的增量: "你 §三 那个『16 封去重到 7(抑制 9)』我复跑对不上 ⇒ 实测 98 里抑制 6 封(5 组)",
并警告 **那是判据期望值,写错会让判据恒假、然后被误读成『修法无效』**。

★ 本轮实测出正确算法 —— 三个人的数都『不算错』,差别全在**口径**:
  pi 09-25                : 98 总 / 5 组 / 抑制 6
  我 09-25(§三)          : 16→7 / 抑制 9   ← 把 homeagent 那 16 封当**全量**
  我 09-30 初测            : 113 总 / 8 组 / 抑制 **37**  ← ★ 没滤 NULL
  我 09-30 口径修正后      : 113 总 / 6 组 / 抑制 **7**  ← 正确

★ 根因(要害): 按 (agent, failroot) 分组时**没先滤掉 failroot IS NULL**
  那 32 封的 relay_key 形状 = `service-failure:c78f1daac57c4ea08f` 之类
  ⇒ **既不含 UUID、也不是任何 mail_id** ⇒ 是**服务级失败报告**,与具体邮件无关
  ⇒ 按 (agent, NULL) 分组把**32 封彼此无关的报告**并成 2 个假重复组
  113 − 32 = 81 封有根 ⇒ 重复 6 组 ⇒ 抑制 Σ(n−1) = **7**
  我那个 37 的完整算术: NULL 组 {dsh:26, homeagent:6} ⇒ (26−1)+(6−1) = 25+5 = 30,
  30 + 7(有根部分真实抑制)= **37**
★ 这正是我自己立的那条: 报合并/去重前**先问操作数是否真在被减数里** ——
  `root=NULL` 根本不是一个有效的根,它是把不相干的东西并成一组的产物。

★ pi 那条最硬的印证逐字成立:
  agent=dsh root=b3ce9d0f 同键 **3** 封 = e43496ed / 84900edd / 41ea9a15
  ⇒ 正是 f76025c9 那条环里 dsh 在 hop1/hop3/hop5 回来三次 ⇒ 收敛成 1 封(抑制 2)
  ⇒ 机制成立: 环在两方交替 ⇒ 同 agent 会回来 ⇒ PK 撞车 ⇒ 环断在 **hop3**
  其余 5 组各 2 封: pi/f39fd424 · zcode/b3ce9d0f · zcode/85624acd ·
  homeagent/85624acd · opencode/9e9d0f50

判据该写成(pi 建议 + 本轮修正):
  ① (dsh, model-failure:root) 出现 3 次 ⇒ 收敛成 1 封、环在 hop3 断
  ② 全量 113 ⇒ **先滤 failroot IS NULL(滤掉 32 封 service-* 无根报告)**
     ⇒ 剩 81 封 ⇒ 6 组 / 抑制 **7** 封
     ⚠️ 不是 6/5(09-25 快照),也不是 9(子集当全量)
  ③ ★ 期望值必须等于**本口径**的实测值,否则恒假后被误读成"修法无效"

★ 自更正(提交前复核): 我第一版把 37 简写成 `25 + 5 + 有根`,读起来像漏一步;
  已写全为 25+5=30、30+7=37(算术本来就对,是表述会让人以为算错)。

未做: 没改代码、没改判据脚本(本轮只读 SQL)。
  113/32/81/6/7 都是此刻的瞬时值。service-failure:* 那 32 封的产生条件我没查。
  ⚠️ 「滤 NULL」是否就是约定口径该由谁定我不知道 —— 它只是三者中唯一能让
  「service-* 报告不参与邮件级去重」成立的读法。

验证: repo Debt ok; criteria-hygiene 10/10。
  ⚠️ debt-visibility 仍 0/1(与本提交无关: harmony-appearance.test.mjs 边界声明 6>4,
     那是另一会话未提交的工作树改动,判据自己写着"别只改数字,先补一笔")。
2026-10-02 04:07:40 +08:00
dsh
a28403aeab docs(debt): 更新 —— 第⑤条判据的否证清单逐条实测,并澄清"要不要整链抑制"(不需要)
复核 pi 的 cd04c2b3(09-25 04:57:05,37 分钟后由 d99834a3 答复,79 封在后)。
他认下"修法①不能用"(环上 5 跳 kind 全是 summary ⇒ 跳过 summary 会放行那个环),
并给了一张四条修法死因的判决表,第⑤条"回查 relayed_mails"写的是**暂未发现反例**。

★ 本轮把「暂未发现反例」升级为**四个否证方向逐条实测**(不只验最易验的那个):
  命中判据 = 10 封
    D1 被指向那封【自身非 failure 键】⇒ 误杀正常转发 : **0** ✓
    D2 被指向那封【mails 表已不存在】⇒ 回查空漏判   : **0** ✓
    D3 判据与标题不一致                            : **0** ✓
    D4 链长 >1 层(本封自己也是上一份报告的报告)   : **10/10** ⚠️

★★ D4 澄清了 pi 判决表那条未决项 —— **不需要整链抑制机制**:
  10 封链长实测 2/2/5/2/3/3/4/3/2/4 ⇒ **最长 5 层**
  我一度以为"链长>1 ⇒ 只抑制一跳会漏、要整链砍" —— **这个推断是错的**:
  判据是**逐跳独立**的,每一跳各自回查**自己**的 target,而链上**每一跳**都命中
  ⇒ 链在生成过程中就被**逐跳**砍断,根本长不起来
  ⇒ 那 10 封是**判据未上线时的历史遗留**(正是它要消灭的东西)
  ⇒ pi 判决表的备注「需与『环整体抑制还是只抑制一跳』配套决定」**不成立**
★ 这正是 ⑬′ 的用处: 只跑 D1 我会漏掉 D4,而 D4 才是要去澄清"要不要整链机制"的那一条。

★ 复核 pi 的数据更正,方向对但**他自己那数也已是旧快照**:
  他说 summary=422 / permission=138(我 09-25 报 419/138)
  此刻实测: summary=**455** / permission=**137**
  ⇒ 我引的确实是旧快照 ✓(他更正成立);但 422 现在也不是现值了
  ⇒ 且 permission 从 138 **降到 137**,与我上轮查到的"那批测试夹具数据成组删除"
     (zcode 的 8f056b73 权限请求不在了)**吻合** —— 第三次应验"引用必须带时刻"。

未做: 没改代码(本轮只读 SQL)。该位仍未落地(落点在桥侧)。
  10 封与四个 D 都是此刻的瞬时值。

验证: repo Debt ok; criteria-hygiene 10/10。
  ⚠️ debt-visibility **仍 0/1 失败**,原因与本提交无关:
     harmony-appearance.test.mjs 边界声明 6 > 登记 4 —— 那是**另一会话未提交**的工作树改动
     (+183 行),该判据自己写着"别只改数字,先补一笔";那笔债的内容属对方工作,我不代填。
2026-10-02 04:05:45 +08:00
e0f7f2477c 修复: 验证脚本在 dsh 0.2.0 下直接崩掉(ERR_MODULE_NOT_FOUND),且漏认 v4 会话文件
升级到 0.2.0-rc.2 后 `verify-mail-sessions-readable.mjs` **一条都验不了**:
  1) 依赖不再嵌在 `<dsh>/node_modules/@deepseek-ai/`,而是平铺到
     `/usr/lib/node_modules/@deepseek-ai/` ⇒ import 直接 ERR_MODULE_NOT_FOUND;
  2) 会话文件新增 **v4**(`session.v4.jsonl.zstd`),walk 只认 v0+v3
     ⇒ 本条线索的 `mail-f8f9a840`(v3+v4 共存、**无 v0**)会被**整目录漏掉**。

★ 关键点:这两种失败都长得像"会话不可读",但**都不是**。
  1 是脚本自己崩了(连候选数都出不来);2 是**漏扫**(少算而不是算错)。
  —— "工具报错"与"数据坏"必须分开,否则会把脚本的年龄当成磁盘的病情。

## 实测(0.2.0-rc.2 修好后)

  --prefix mail-          : 候选 55  可读 55  不可读 0     <- 邮件通道全绿
  mail-f8f9a840(.new 线索): 可读,1623 events
  mail-d042cc4c(老线索)  : 可读,56012 events
  全盘 /root/.dsh/sessions : 候选 148 可读 119 不可读 29

那 29 个不可读**全部**是既有的独立缺陷
(`subagent/descriptor ... unsupported descriptor version 2`,去重后仅此一种),
**没有一个是 mail-*** ⇒ 与邮件通道无关,仍不建议混进同一个 repair。

修法:先探两个候选根再 import(不靠报错"感觉"哪个对),
并把 v4 加进 SESSION_FILES。
2026-10-02 04:05:04 +08:00
dsh
79f1188fe1 docs(debt): 新债 —— 『报告的报告』环的抑制位在元数据里(回查 relayed_mails,网关不用改)
复核 pi 的 2053c8db(09-25 04:53:14,20 分钟后由 148a4220/27fd0135 答复,81 封在后)。

pi 找出的位(已逐跳实测成立):
  载荷里没有 relay 身份(notify/mail.go grep relay = **0**、mail.go:39 的 RelayKey
  只用于入站校验不下发)⇒ 载荷加字段要改网关。
  ★ 但元数据里已经完整存在: 本封 relay_key 的最后一段 = 被指向那封的 mail_id
  判据「该封 ∈ relayed_mails」⇒ 这是报告的报告 ⇒ 抑制。网关契约一个字不用改。
  实测那条环(f76025c9 本身已查不到,但环上 5 跳都在):
    第 1 跳 e43496ed target=b3ce9d0f 在 relayed_mails? **False** ← 根,回报合法
    第 2 跳起 2ffb7dbb / 84900edd / b3789a21 / 41ea9a15 全部 **True** ⇒ 该抑制

★★ 本轮修正 pi 一处口径误读 —— 他说"标题启发式漏失远超 30%",**真值口径下不成立**:
  failure 类 relay = **113**(口径须是 '%failure:%' 与 '%-failure:%' 的**并集**)
  真值(该抑制,元数据判据)= **10** 封;其中标题含『处理失败』的 = **10/10** ⇒ **漏 0 封**
  标题判据认出的总数 = **59** 封
⇒ ★ **两个判据答的不是同一个问题**:
     标题判据认「这封**是**失败报告」      ⇒ 59 封 ⇒ 真报告,**该发**
     元数据判据认「这封**在报告**一份报告」 ⇒ 10 封 ⇒ **该抑制**
  ⇒ 用标题去抑制会**误杀 59 封真报告**。pi 的位在**正确性**上更硬(不依赖标题、不误杀),
    在**召回**上与标题持平(他未附口径,故其"漏得多"一句在真值下不成立)。
★ 这也是我自己预判错的地方: 我以为标题会漏掉环,实测它一层层叠加 `处理失败:` 全认得出来。

修法要点: 桥侧拿 data.mail_id 查一次 relayed_mails;且抑制必须在 **ClaimRelay 之前**,
  否则又落一个占位行(永占幂等键)。

★ 自更正(提交前逐数复核发现): 我先写"只写 '%failure:%' 会漏 97 个"——
  实测只命中 24 个(homeagent:failure:),连字符族共 **89** 个 ⇒ 应为"漏 89 个"。已改。

未做: 没改代码(本轮只读 SQL)。该位**未落地**——落点在桥侧
  plugins/*-mail-bridge/src/index.ts,且要与"环整体抑制还是只抑制一跳"配套决定。
  10 / 59 / 113 都是此刻的瞬时值。我没有核别的 session 上标题是否也 10/10。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-02 04:03:24 +08:00
85de9ee87b feat(opencode桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
与 dsh 同一形状:`reverseMap` 只在邮件投递时填 ⇒ 用户在界面里新开一轮对话时
查表必空 ⇒ withScope「取不到就原样返回」⇒ 不带 session_id ⇒ 服务端 403。

实测它至今只裸奔过 1 次(2026-09-28),但**形状在**,且旧兜底的服务端放行已作废。
一致性比实测频率重要:留着一个已知失效的分支,下次有人读它会以为那是可用的。

回落答案只能问服务端 —— opencode 自己的 session id 与 AgentMail 会话 uuid
没有映射。★ 这与 `workspace` 那一维**不同**:那一维可以从 `Session.directory`
问出来(见 workspace-probe.test.mjs,同一天修的),所以这一维只能走默认会话。

装配期 fire-and-forget 问一次(不 await):它的失败后果只是那次读信 403,
不该拖住注册流程(而 dsh 那边是 await,因为 dsh 的注册路径本来就顺序执行)。
这个差异是有意的,不是抄漏。

判据 5 格。三个变异各红 3 格(交叉命中,说明各自在钉不同东西)。
全量 364/364 绿(359 + 5)。
2026-10-02 01:15:58 +08:00
86e05e7027 feat(dsh桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
`reverseMap` 只在邮件投递时填 ⇒ 在 dsh 界面里直接对话时 `mailSessionOf(exec)` 为空
⇒ withScope「取不到就原样返回」⇒ 请求不带 session_id ⇒ 服务端 403
(AgentMayReadSession 收严后,2026-09-15 的迁移期放行已作废)。

不改的后果很具体:**在 dsh 界面里读不到任何信**。

回落答案只能向服务端问 —— dsh 侧给不出 session_id:
opencode 有宿主 session id 可反查(`sessionWorkspace`),pi 有 worker 闭包,
而 dsh 的 exec 里只有 dsh 自己的会话 id,与 AgentMail 会话 uuid 无映射。
这与 workspace 不同(workspace 能从目录/cwd 得到)。

装配期问一次(`loadDefaultSession`),不塞进 withScope:
withScope 是取值函数,IO 混进来就变成「拼 URL 时顺带发 HTTP」,
判据也无法用裸对象驱动(同 homeagent 那次的教训)。

问不到就当没有:不带 session_id → 服务端 403(可见的错误),
而不是静默放行成越权。

判据 4 格(接线形状 + 常量一致性 + 不得伪造 id + 失败要留日志)。
三个变异各红 3 格(判据之间交叉命中,说明它们各自都在钉不同的东西)。
tsc --noEmit 通过;桥全量 429/429 绿。
2026-10-02 01:12:35 +08:00
de6b91516a feat(默认会话): 非邮件轮次用 /tmp 默认会话作合法 session_id —— 配套 15e4fe9 的收严
`15e4fe9` 让未声明 session_id 的读信一律 403,而 homeagent 的工具**全局可调** ⇒
对话里自主调 read_mail/read_thread 时 `currentSessionID` 为空 ⇒ 403。
不能因此让「非邮件轮次读信」这个能力消失(它是 10-01 那个 read_inbox 修复的
用户可见部分),所以给它一个合法声明。

## 关键约束:workspace 能回落 cwd,session_id 不能

`session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它 ⇒ 只能问服务端。
落点选 `/tmp`:非邮件轮次没有真实工作目录,而 /tmp 是中性落点(不属于任何真实
项目,不会把项目邮件混进来),且满足 `UnreadWorkspaces` 的 `workspace LIKE '/%'`
(能被寻址补投)。

## 服务端:`GET /api/v1/agent/session/default`

**复用**已有的默认会话语义(`FindOrCreateDefaultSession`,8 个测试覆盖),
只把它开放成可查询形状 —— 不新造概念。

★ 第一版调 `FindOrCreateDefaultSessionCreated`,判据当场报**每次都新建**
(连问两次得到两个不同 UUID)。根因:那个函数的复用条件含
`EXISTS (SELECT 1 FROM mails …)`,空会话不满足 ⇒ 永远「没找到可复用」。
改「先查后建」仍不够。想深一层:**根本不该建** —— 非邮件轮次若 `name@/tmp`
一封都没通过,收件箱本来就该是空的,不需要一条 id 才能表达「空」。
⇒ 改成**纯只读**:没通信过就返回 `session_id: null`。
GET 有副作用是坏味道,它会被桥每轮调一次。

同时把匹配 SQL 抽成 `defaultSessionMatchSQL` 共享常量:`FindExisting` 与
`FindOrCreate` 必须给出**同一个**答案,否则「查到的默认会话」与「发信落进去的
会话」会静默分叉(各写一份 SQL 的话,改一边不会红)。

## 桥(homeagent):effectiveSessionID = 信封 → 默认会话

⚠ 取值函数**不发请求**。我第一版把 HTTP 塞进 `effectiveSessionID`,
`&Plugin{}` 构造的测试当场 nil panic,且 scopeQuery 变成「拼 URL 时顺带发请求」。
IO 移到装配期 `register()` 里的 `ensureDefaultSession()`。

⚠ `client == nil` 时**不标记已问** —— 那不是「答案是空」而是「还没资格问」,
标了会永久缓存空值。而 register() 里就会调它,真的会在插件加载阶段崩。

## 判据

服务端 6 格(含★「不是万能钥匙」:拿默认会话 id 去读别人的会话仍须 403 ——
少了这格,这个端点就是「声明一个合法会话然后读遍全场」的后门)。
homeagent 6 格。
三个变异各红 1 格:退回旧的整体放弃 / 未就绪也标记 / 默认落点与服务端不一致。

## 未改:pi / dsh / opencode

实测它们的裸奔已停止(pi 自 Sep 26、opencode 自 Sep 28,`[agent-scope]` 日志归零),
`getMailSessionId` 由 worker 闭包注入且只有一处装配点。dsh 待单独核。
2026-10-02 01:09:19 +08:00
15e4fe9203 fix(安全): 未声明 session_id 不再放行 —— 实测任意 agent 可读全部邮件正文
## 漏洞(亲自实测,不是读码推断)

用 dsh 的密钥、不带任何 session_id,逐个 GET `/api/v1/agent/mail/{id}`:

    20 封别人的信(收件方 pi / homeagent / opencode,分属
    /home/program/TrueAgent 等不同工作区)⇒ **20 封全部 200,拿到完整正文**,0 拒绝。

对照(证明闸本身没坏,只有一个缺口):

    带自己参与的 session_id 读别人的信 ⇒ 403   ← 闸有效
    不带 session_id                    ⇒ 200   ← 漏洞

根因是 `AgentMayReadSession` 的一个分支:`if scope == nil { return true }`。
该函数 2026-09-15 的注释写明「首次接线前的旧语义(未声明 scope)保持放行」——
那是**迁移期妥协**,不是设计。

## 为什么「迁移期」已经结束(实测数据推翻了当初的假设)

当初假设「未接线的桥/脚本/浏览器会走这里,等接完就收口」。而
`[agent-scope]` 警告日志累计 203 次,按调用方拆开:

    homeagent 125 / dsh 40 / pi 37 / opencode 1 / 其它 0

⇒ **202/203 来自四个桥自己**,集中在 `/api/v1/agent/mail/{id}`(pi 24 次)、
读会话参与者、`/mail/{id}/forward`。
不是「少数旧客户端没接线」,而是**主力客户端在裸奔**,而放行恰好把它们全漏过去。

日志抓手已完成使命:它精确指出了「谁还没带」,答案就是所有人。

## 为什么不能靠「补齐调用方」收口

要同时改四个桥(pi 的 `getMailSessionId` 有 `= () => ''` 的默认值,
忘注入就是静默空串 ⇒ 回到裸奔)。**默认放行与默认拒绝的差别就在这里:
前者的失败模式是沉默的。** 任何一处漏了 = 静默越权,且没有任何东西会红。

## 判据

`grep -rln AgentMayReadSession --include=*_test.go` ⇒ 修改前**零覆盖**。
一个决定安全边界的函数没有任何判据,这就是妥协能活到今天的原因。
新增 4 格:未声明须拒 / 声明且相等须放行 / 跨会话须拒 / 判定不随 agentName 变
(后者钉住 2026-09-15 裁定「每个 session 是独立『用户』」,防有人顺手加按 agent 的仲裁)。
两个变异都经得起:恢复放行、reason 改成 handler 不认识的值,各红一格。

reason 复用既有的 `not-your-session`:新增 reason 不同步改 handler 的 switch
就会把 403 变成 500;复用后 canReadSession 的 self 为空时,文案自然表达
「你还没声明自己在哪条会话」。

验证:13 包全绿 + `-race` 干净。
2026-10-02 00:34:13 +08:00
b2a0a42c91 docs(harmony): ★★ 推送根因定位到"**签错了证书**",不是"debug 证书不被接受"
平板复测抓到一条上一轮漏掉的华为侧日志,它把根因从推断变成实测:

    E cloudinterfaceauth/AuthService: cert finger empty, clientId: 2039846327155747840
    I PushService: push token 取不到:code=1000900010 Illegal application identity

★ `cert finger empty` = 设备报上来的是**空指纹**,不是"指纹对不上"。
  与 `bm dump` 完全吻合:appSignType=none、signatureKey=""。
  ⇒ 包里根本没有应用签名身份。

三个指纹实测对比(本机 openssl 算出):
  build-profile.json5 的 certpath → DF:21:A3:C0:…:DF:3A:37  CN=Huawei CBG Root CA G2
  .p7b 内嵌 development-cert    → FD:89:AC:53:…:FC:9D:09  CN=靳睿(…)\,Development
⇒ 工程签的是 **CA 根证书**,profile 绑的是**开发者应用证书**,两者根本不是同一张。

profile 侧其余要素已逐项排除为无关:bundle-name 对、type=debug、validity 未过期、
平板 UDID(bm get -u)逐字出现在 device-ids 里。

★★ 顺带记下走不通的那条路,免得下次重走:
  把 certpath 改成 profile 内嵌的单张 dev cert → hvigor 报
  `11013004 Profile cert must a cert chain`。
  **certpath 要的是一条链**,而那张 dev cert 的签发者
  `Huawei CBG Developer Relations CA G2` 本机没有(5 个 p7b 里都没有,
  material/ 里也没有任何证书)。
  ⇒ 给用户要材料时要说准:要**能构成链的整套**,单张 .cer 装不上;
    最省事是 DevEco 里 Project Structure → Signing Configs 直接同步签名。

服务端侧本轮复验仍正常:与 hms.go 同形(不带 scope)请求华为换到
access_token,长度 104、有效期 3600s ⇒ 断点确实只在设备侧签名。

build-profile.json5 的 certpath 保持原值并加了注释:改成单张 dev cert 会编译不过,
留着编不过的值只会连应用都装不上。
2026-10-02 00:32:21 +08:00
1d7b018734 fix(权限): 决策人��空时不再静默跳过已读记账 —— 那让权限邮件永远显示未读
## 形状

`DecidePermission` 的函数头承诺「权限邮件对他(决策人)也应当变成已读」,
但那两段记账都被 `if decider != ""` 包着。而**未读判据是 mail_reads**
(`unreadFor` / `readStateFor`),不是行级那列。⇒ decider 为空时:

    mails.status = 'read'      (全局冗余列改了)
    mail_reads   —— 没有这一行  (事实表没记)

    ⇒ 收件人在收件箱里**永远看到这封未读**,尽管它早已决策完。

## 线上实证

13 封这样的历史邮件,收件人均为 jianf,2026-09-08 / 09-13,主题全是
「权限请求: 请求执行 bash」—— 每一次都已被人类决策过(同意/拒绝)。

## 为什么今天 HTTP 路径触发不了

`permission.go` 会沿会话树上溯找人类(`NearestHumanInThread`),
HTTP 层传的也是 `user.Username`。但 repo 层是**所有调用方的门**,
`MarkMailRead` 早就为同一件事挡了(reader 是必填语义),只挡它一个等于漏网。
静默跳过正是 `if decider != ""` 的写法问题:它把「不知道谁读的」记成「不用记」。

## 同族核对(不是只看这一处)

全 internal/ 树扫 `UPDATE mails SET status ... 'read'`,共 4 处,
逐处核对其前置 45 行是否有 mail_reads 记账:
  MarkMailRead ✓ / MarkMailsReadFor ✓ / MarkAllInboxReadForSession ✓ / DecidePermission ✗
⇒ 唯一缺口就是这一处。事实表本身干净:无空读者、无孤儿行。

## 判据

新增 2 格:空 decider 报错 / 决策后 mail_reads 必有记录且派生读数为 read。
两个变异都经得起(去掉报错守卫、去掉 mail_reads 写入,各红一格)。

## 顺带记一个判据的坑

`mail_status_readers_test.go` 的 readRe 是**纯文本**匹配 `mails.status`,
会把我注释里提到该列名也算成「新增一处读取」,导致那条清册判据变红。
已在注释里避开那个写法并写明原因 —— 改这块代码时若它突然变红,先看是不是注释。
2026-10-02 00:15:02 +08:00
62b7c94c6e test(harmony): ★★ appearance-defaults 上设备 ⇒ 落盘键真的带账号段,并结算出 STATIC_ONLY
到期前提(设备可用)已成立 ⇒ 这条欠账必须当场还,而不是继续躺在
STATIC_ONLY 的点名范围里(与 harmony-admin 那次同一处置)。

新增设备判据(test 8):
  拉起应用 → find 定位真实的 agentmail_appearance 落盘位置(不写死路径:
  haps 层级会变,两处都见过)→ cat 出 XML → 从**落盘的键**里取账号段,
  断言非空且像个 id。

★ 为什么不从 /me 拿账号再比对键:那样掺进了"当前登录的是谁",
  验的就不是"键里有账号"了。键本身是唯一可靠的观察点。

红绿已在真机(MRDI-W00)上验过,且验的是**该补的洞**:
  把设备上的键改成 key="appearance." ⇒ 本条红("账号段是空的 ⇒
  全局键退化,换账号会串味"),而原有的静态判据 2 **仍然绿**
  —— 正好证明静态层看不见这个形状,这层是必需的。

★★ 顺带修一个**真假红**(本条自己撞到的):平板息屏后再跑时,
  launchOurApp 返回 false,原来那句硬 assert 直接红 —— 而设备锁着
  跟外观缓存毫无关系,且只有人能解(10106102,developer mode
  不能自动解锁)。改为走"忙"那条路:K 轮内礼貌跳过、连续超 K 轮
  仍变红(并说清"先分清是锁屏还是真坏了"),不许无界礼貌。
  判据去红一个与自己无关、且无人能自动修复的环境状态,会把到期闸的
  名声搞坏——真出事时没人当真。

结算:移出 STATIC_ONLY,登记进 SETTLED_FROM_STATIC(闸 ii 要出示行为层
绿跑记录 .tmp/harmony-behavioral-ran.json,本机已留下 appearance-cache-key-on-device)。

真机实测:# pass 8 # fail 0
2026-10-02 00:14:01 +08:00
dsh
2af8d13d0e docs(debt): 新债 —— 引代码时名字不跟载体走(无运行时反馈)+ 坐标会漂,实测须带时刻
复核 pi 的 542f4e08(09-25 05:52:22,10 分钟后由 902f1d2c 答复,61 封在后)。

⑩(他的增量): 引代码(变量名/形参/字段)时必须在**被引文件里实测它出现**;
  0 次 ⇒ 是另一个作用域的同名/近名。触发实例(他实测):
    permission.go:397 的实参是 **mailID**;relay.go:78 形参也叫 mailID;
    parentMailID 在 permission.go 出现 **0** 次(只在 mail.go:10 / me.go:4)
    ⇒ 他"语义对、名字错",而错的正是他自己刚立的"名字不跟载体走"(载体是他自己的信)

★ 机制(他补的那格,也是本条最值的一句):
  数据版的错**下一步现形**(拿错 id 去查,结果不对 ⇒ 复跑就暴露)
  代码版**不会** —— 错名**只是一个字符串**,不参与执行、不产生输出差异
  ⇒ **没有任何运行时反馈** ⇒ 只能靠静态核对(grep 那个名字)

★★ 本轮复核(2026-09-30)发现第三维: 名字仍对,但**所引的那一行已不对**
  parentMailID in permission.go = **0** ✓   relay.go:78 形参仍是 mailID ✓
  relay.go:81 仍是 WHERE mail_id = $1 ✓      ⇒ 语义结论全部仍成立
  但 permission.go:397 **现在**是 `}`,:334 现在是 DecidePermission 的声明本身
⇒ ⇒ ⑩ 说"实测它出现"**不够** —— 实测还必须**带取数时刻**,否则**行号**也会骗你
  与 ⑧ 合并读: ⑩ = 标识符存在性;⑧ = 该读数的时刻与载体版本
  这与 doc-line-ref-decidepermission-drifted-33(文档引行号漂)是**同一格**,
  只是这次落在**代码互引**上 ⇒ 那条的 where 里"DecidePermission 现 in 334"也已开始过期

区分于已有条目(别当重复):
  criterion-action-upper-bound 那格问"**够不够得着**"(跨包可见性,编译问题)
  本条问"**名字对不对**"(语义/作用域)+ "**坐标新不新**"(时刻)

★ 自更正(提交前复核脚本自己指出的): 我先写"漂 63 行"(397−334),
  复核时发现**这两个是不同符号的行,相减没有意义** ⇒ 已改成不给伪精度的说法:
  能确定的只是「**那一行已经不对了**」,不给"漂多少"。
  —— 这正是本条自己的纪律: 宁可少给精度,不给伪精度。

未做: 没改代码(本轮只读 sed/grep/SQL)。
  我没有去查 permission.go 里真正的 RelayKeyForMail 调用现在在哪一行
  (只确认 pi 那两行已漂、语义结论不变)⇒ 本条不提供新坐标。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-01 21:13:46 +08:00
dsh
ddfe314176 docs(debt): 新债 —— 类由「字符串形状」冒充机制(④′ 三栏定稿)+ 一个反直觉实测
复核 pi 的 44dccaee(09-25 05:48:04,3 分 51 秒后由 9b71532b 答复,66 封在后)。
他收了我"撤回类由机制划(kind !== 'failure')",并把 ④′ 三栏定稿。

④′(本条内容): 类要由一个**真的存在且可判**的机制来划;
  若只能用字符串匹配近似 ⇒ 那是**示例级**判据,用时必须申报三件:
  ① 匹配的是什么**形状** ②**可能漏什么** ③依赖哪个**命名巧合**

★ 我否掉了他想当 ④′ 标准例子的那个示范,因为它不成立:
  他说「把 homeagent:failure: 改写成 failure:homeagent: 会得到不同的类」——
  但 %failure% 是**子串**匹配,换序不改变命中。实测(09-25 与 09-30 各一次):
    LIKE '%failure%'  = 98 / **113**      LIKE '%failure:%' = 98 / **113**
    ⇒ 两者**逐次完全相同** ⇒ 换词序不改变类
  真正可验的依赖是**"那个词有没有被写进键里"**:
    全表 557/592、三前缀 82/89;若 homeagent 那族不叫 failure ⇒ 类 474/**503**
  而"分隔符不同"(homeagent 用 :、另三个用 -)**也无关** —— 两种模式都命中 113。
⇒ ④′③ 栏应写: 依赖"某个词出现在键里",**不依赖词序、不依赖分隔符**。

★ 这条债的价值在它的**错法**: 用"字符串形状"冒充"机制"
  —— 形状看起来比例子更一般 ⇒ **比"用示例划类"更难识破**
  (那个至少知道自己不一般)。原句"kind !== 'failure'"实测是**空真**
  (kind 只有 summary|permission 两值、kind='failure' 0 行 ⇒ 照字面执行得几乎全表)。

本轮数字全部带日期(⑨/⑧ 的应用: 同一数字换了所指):
  全表 592 · %failure% 113 · 三前缀 89 · 真判据(已绑定) 479 · 示范值 503

★ 自更正一处(提交前逐字核发现): 我在 where 里写"mail.go 的 $relay"——
  `$relay` 是**抄 09-25 的旧坐标**,实测 mail.go 里 0 命中(那是 SQL 占位符 $3)。
  真实写路径逐字核为: permission.go:93 传字面量 "permission"、
  mail.go:520 传变量 `relay`(种类声明在 mail.go:39)。已改。

未做: 没改代码、没改判据脚本(本轮只读 SQL + grep)。
  "failure 这个词被写进去"依赖当前命名 ⇒ 它记的是**方法**(怎么验依赖),不是结论。
  ⑧(非单调载体上不许用大小互推)本轮**未单独登记** —— 等它第一次真被违反时再定,
  避免登记未被现实打到的规则。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-01 21:10:33 +08:00
dsh
1b6b75f6b0 docs(debt): 更新 —— 占位行 M 已归零(含存量)、pi 的测试残留校账、以及一次无据可查的成组删除
复核 pi 的 18ac26c2(09-25 05:28:26,已由 5973a479 答复,68 封在后)。
他那封的增量是**再校一档**: "未绑定那 1 行"是对的,但"测试残留 = 那 1 行"不完整。

★ 我按他的匹配式实测,发现**两个数今天都不成立**:
  「未绑定」(mail_id IS NULL)          = **0**(他 09-25 说 1;我 09-30 早也测到 1)
  「测试残留」(键名形状匹配)          = **1**(他 09-25 说 2)且这 1 行**已绑定**
  已绑定 ∧ kind≠failure               = **592**(他 09-25 说 458);纯业务 = **591**
⇒ 方向仍成立(测试残留形状的行确实进类统计、其中有 1 行真的发出去过),
  但所有具体数字都是 09-25 的**瞬时值** ⇒ 引用必须带日期与口径
  (同 `03adbf14` 已立的"同一数字换了所指")。

★★ 顺带查出两件更要紧的:

① 我 09-30 那条 relay-placeholder 条目里写的"M=1、修前化石"**已过期** —— 已更新。
   证据强度一栏现在分两段读: 早(1 小时窗口、M=1、新增 0,弱证据)/ 晚(M=0)。

② ★ 那批测试夹具数据是**成组删除**的,而本库**没有审计/历史表**:
   `relay_key like 'no-such-session%'` 现在命中 **0**(整类没了)
   与之配对的 zcode 权限请求邮件 `8f056b73-…` 在 mails 表里**也不存在**
   而 relayed_mails **总行数仍是 592** ⇒ 删旧增新**互相抵消**,从总数看不出来
   sqlite_master 里 %audit%/%history%/%log% 唯一命中 agent_model_catalog(与 relay 无关)
   ⇒ **谁删的、依据什么删的,事后无从查证**
   —— 与 history-rewrite-undisclosed-citations-dangle 同一族: 不可复核的删除

★ 自更正: 我第一版写"它被清了",写完即查发现机制说错了(不止那一行,是成组),
  已按实测改写。

未做: 没改代码、没动库(只读 SQL + sqlite_master 查询)。
  592 是此刻的值,是瞬时量不是断言。
  我没有查是谁删的(无据可查,这正是本条的内容)。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-01 21:04:35 +08:00
dsh
f4123d4091 docs(debt): 新债 —— HARMONY-ALIGN-PLAN.md:225 的行号引用已漂 33 行,指向无关代码
复核 pi 的 f74da994(09-25 04:59:03,已由 ae7e83b6 答复,77 封在后)。
他那格记法: 引用代码位置要**同时给唯一标识**(函数名 / relay_key 字面量 / 块首注释),
行号只作辅助 —— 同族「判身份用唯一键,不用位置」。

已落实的一处(复核确认在位):
  server/internal/handler/permission.go 的注释从 `permission.go:112` 改成
  「函数名 RequestPermission + 错误字符串 Invalid session_id」,行号删掉;
  理由**已写进注释**(permission_relay_release_test.go:32-37),并实测记录了
  "112 行现在变成本注释的上游注释文本" ⇒ 防下一个人补回行号 ✓

★ 缺口: docs/HARMONY-ALIGN-PLAN.md:225 仍用裸行号,且已漂得更远
  文档声称 `server/internal/handler/permission.go:301` 是 DecidePermission
  实测 301 行 = `"subject": mailSubjectFor(kind, req.Question),`(构造响应体的字段,
    与 DecidePermission 无关);`func DecidePermission` 现在在 **334**
  漂移轨迹: e07e3bf 时 301≈该函数文档注释 → 09-25 我测得 318(漂 17)
            → 今天 334(又漂 16,累计 **33**)
  ⇒ 已不是"漂移",是**指向了别的东西**

★ 这一条本身是那格记法的**自反例**:
  我 09-25 推迟它的理由是"那是另一个 writer 的文档,由那条线定";
  本轮核 git status --porcelain docs/HARMONY-ALIGN-PLAN.md = **干净**(无人在改)
  ⇒ 那个理由**已不成立**,剩下的只是没做。
  我用"位置/归属"当理由推迟了"改成唯一标识"这件事 —— 而那正是我认领的规矩。

弱形式判据抓不到(09-25 已实测,沿用): 112 <= 478、301 <= 478 都在界内
  ⇒ 行号还在,只是指向了别的符号 ⇒ 只有"唯一标识 + 实测"能判。

待查清单(未核,可能对可能错): grep "permission.go:[0-9]" 命中 **6 处**,
  其中 permission_relay_release_test.go:32 是**已修样本**(故意引用 112 讲故事,不是待改项)
  ⇒ 真正待查 **5 处**: sse/manager.go:115、docs/reviews/harmony-client-review.md:101、
    docs/HARMONY-ALIGN-PLAN.md:225、docs/API.md:4147、docs/API.md:4490
  每处要判"它指的是不是它声称的那个符号",不是一律替换。
  ⚠️ docs/API.md 与 docs/reviews/* 是并发写入文件,改前须确认无人同时在改。

未做: 没有改任何文件(本轮只读: sed/grep/git status)。
  漂移 33 行是今天此刻的值,是瞬时量不是断言。
  我只核了 09-25 亲手记下"已核实为错"的那一处,其余 4 处未核。

★ 自更正两处(提交前逐行复核发现): ①我先写 301 行是 "session_id" 那一行,
  逐行实测是 mailSubjectFor 那行 —— 相邻行记错了,已改; ②"待查 5 处"实为命中 6 处
  减 1 处已修样本,已改。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-01 21:02:16 +08:00
dsh
7d08109583 fix(harmony): ★★ 顶部文案用 windowDecor 判 2in1 ⇒ 平板上浮在系统状态栏上(真机实测)
用户:「不是没显示,而是顶部与三键和状态栏重合,我觉得只应该在 2in1」。

## 根因:一个**假信号**

顶部那行文案(一言 + 签名,`TopbarStore`)的渲染条件写的是

    if (this.topbarTexts().length > 0 && this.windowInsets.windowDecor > 0)

本意"有装饰带才显示",在模拟器(真 2in1)上一直成立。**真机平板把它证伪了**:

    实测 insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37
    param get const.product.devicetype = tablet

`getWindowDecorHeight()` 在平板上**照样返回 37vp** —— 官方给的是"全屏悬浮态
固定 37vp"这个下限值,它衡量的是"系统浮层厚度",**不是"有没有三键区"**。
平板没有三键区,但 windowDecor 恒为 37 ⇒ 条件成立 ⇒ 文案渲染出来,
位置又按"三键在右边"算(`.height(this.windowInsets.windowDecor)`),
于是整行浮在**系统状态栏**上,与时钟/电量重叠。

## 改法

条件换成形态真值:

    if (this.topbarTexts().length > 0 && deviceInfo.deviceType === '2in1')

`deviceInfo` 取自 `@kit.BasicServicesKit`,与 `EntryAbility.ets:310` 判 2in1
用的是同一个来源 —— 形态判据全仓只此一处口径,不让两处各判一套。

`.height(this.windowInsets.windowDecor)` 保留不动:2in1 上它是对的,
非 2in1 上整块不渲染、根本走不到(`harmony-2in1` 那条仍钉着它)。

## 判据

`harmony-2in1.test.mjs` 新增一条:顶部文案必须挂在 `deviceType === '2in1'` 上,
且**不许再出现 `windowInsets.windowDecor` 与 0 的比较**(那是假信号)。
红绿已验:退回 `windowDecor > 0` ⇒ 红;恢复 ⇒ 绿。

★ 这条判据第一版也犯了同类错:用 `prose`(含注释)会把**我自己写进注释里的**
  旧写法 `windowDecor > 0` 判红。改用 `lib/read.mjs` 的 `code()`(读时剥注释)。
  与上一条(`harmony-window` 判据 10)同形——同一天犯两次,已成惯例性陷阱。

## 环境

`devecocli` 的 npm 包在本会话中途被卸载(`/usr/bin/devecocli` 成断链),
hvigor 直跑缺它注入的 SDK 环境 ⇒ 三个 SDK 路径都报 "SDK component missing"。
已 `npm i -g @deveco/deveco-cli` 装回(6s,252 包),`devecocli build` 恢复可用。

## 真机验证

装 `entry-default-signed.hap`(26.0.0 Beta2,debug 签名)后截图:
顶部只剩系统状态栏(18:34 / 浏览器 / 信号 / 电量),其下是干净的壁纸带,
再下方才是页签条「收件箱 20 / 发件箱 / 授权1」—— 重叠消失。

## 回归

`run-all.mjs`:files=35 checks=584 pass=580 fail=2 skip=2 red=4。
与本改动前的基线(checks=583 fail=2 red=3)比,**fail/red 未增加**:
那 2 个 fail 是设备判据并行争用(单独跑全绿),3 个 red 是「静态判据到期」
的既有债务(5 个文件在真机出现后被判到期,本轮未处理)。
2026-10-01 20:37:19 +08:00
2fd18ba134 fix(回路): 人类经 /me/mail/send 插话也要解锁 —— 端到端实测抓到的缺口
上一提交(4d8165f)的 403 文案写着「若要立刻恢复,请由人类在会话里插一句话」,
但**那句话是假的**。

## 实测证据

拿真实用户登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话:

    HTTP 200  {"mail_id":"68eda0c3-…"}    ← 人类的信进去了(这是对的)
    session_agent_locks 锁行数: 1          ← ★ 锁还在

`MeSendMail` 有自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**
(main.go 里 UserAuth 组下单独注册)。所以解锁只写在 `SendMail` 里是不够的:
人类插了话,锁仍要等满 2 小时。

## 为什么单测没抓到

`TestHumanPostClearsCooldownImmediately` 走的是 Agent 侧 handler,
证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。
判据钉的是 A 路径、生产走的是 B 路径 —— 这个形状本仓已遇到三次
(opencode 的 withScope、homeagent 的 InjectInputSync、这次)。

## 修法

把解锁放在 `SetSessionOwner` 旁边 —— 那是「人类参与这条会话」的权威落点,
比在每个调用点各写一遍可靠(漏一处就又是一句假承诺)。
解锁失败只记日志不阻断:人的来信优先入库,代价只是那把锁到期自消(保守方向)。

判据:新增 `TestHumanSendPathAlsoClearsCooldown`,走真实 `middleware.UserAuth`
+ 真实 user_sessions 行。变异(撤掉解锁)后该格变红。

踩到的两个测试夹具坑(都记在判据注释里):
- 直接调 handler 会 401 —— 生产上这条路由在 UserAuth 中间件后面
- UserAuth → SessionToken 读 config.C.CookieName,而测试里 config.C 是 nil ⇒ panic
2026-10-01 20:19:47 +08:00
f86f08c7dc fix(桥): read_inbox 在非邮件驱动会话上也得带 workspace —— homeagent 与 opencode 两处
同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
pi/opencode/dsh 的工具**只在邮件驱动回合里装配** ⇒ workspace 永远有值;
homeagent 的 15 个工具是 `registerTool` **全局注册**(webui/a2a/对话都能调),
opencode 是 `export default` 全局插件 ⇒ 非邮件驱动会话上查不到工作区
⇒ 拼不出 `&workspace=` ⇒ 服务端 400。

实测:homeagent 2026-10-01 14:32:26 一次真实失败,近 24h **3 失败 / 0 成功**。
opencode 那次是**静默**失败(工具返回错误文本,模型照走)⇒ 线上 0 次报错
不代表没问题,是靠两桥的架构差异推出来的,不是靠日志。

修法不同(各自的可用信号不同):
- homeagent: `effectiveWorkspace()` = 信封 → **回落到进程 cwd**。
  cwd 是对的默认值:homed 按调用上下文以子进程拉起插件,实测对话中那个桥
  cwd=/home/program/agentmail,而从插件目录拉起的那个是 plugins/...。
  ⚠ 只是默认不是保证(库里 17 个工作区),收窄语义不变。
- opencode: handler 拿得到 `context.sessionID`,而 opencode `Session`
  **带 directory**(types.gen.d.ts 的 `export type Session` 可见),
  且 `client.session.get` 本桥已在用 ⇒ 现场问权威值,不猜也不另存一份。
  readInboxTool 是模块级常量,故新增 hostClient 在 init 时捕获。

部署:homeagent 首次走正规 hmap 路径(解包 + cp manifest + install -m 0755,
skill §5 四步全绿)。判据:homeagent 新增 4 格 + 修正 3 条失效的既有断言
(补 cwd 回落使其旧前提失效:整串相等 vs 分片包含、"q[1:] 不能有 &" vs 合法分隔符、
"没工作区就不带"vs"带的是不是真值"——判据失败时先判断是判据错了还是行为错了)。
opencode 新增 5 格。全量:server 全绿 + race 干净;opencode 桥 359/359。

端到端:homeagent webui 一轮 `tool read_inbox result: {"content":[{"text":"收件箱为空。"
(修复前是 400);opencode 两条非邮件驱动会话均 status: completed,
"空"是正确的收窄结果(74 封全是 read/archived,unread=0),近 10 分钟 0 次 400。
2026-10-01 19:41:16 +08:00
4d8165fde9 fix(回路): Agent↔Agent 加 2h 冷静期;冷却期内 relay 不自动重投递
缺口:maxAgentPingPong=8 撞闸后**计数永不回落** —— 只有人类插话才归零。
旧文案把出路指向「请由人类插一句话」,而那条线索上常常**根本没有人类**
(2026-10-01 报告:agent 一封都发不出,且那条线索无人在场)。

修法(两条要求分别落地):
① 2h 恢复机制:撞闸即写入 session_agent_locks(落库,内存态一重启就"恢复",
   且多副本各算各的);到期自动放行,人类插话立刻解锁(优先于到期)。
② 锁定期间的邮件不自动重投递:冷却期内 relay 直接 403 丢弃 ——
   **不排队、不占幂等键、不入库**。排队会在 2h 后一次性灌回去,
   那等于把刚压住的回路换个更糟的形状放出来。

实测踩到的三个坑(都被判据抓住):
- `VALUES ($1,$2,$2,...)` 让 until_at 复用 locked_at 的 $2 ⇒ 锁诞生即过期
- driver 以 UTC 扫回 DATETIME,而 time.Now() 是本地(HKT+8) ⇒ 差 8h > 2h 的一半 ⇒ 锁形同虚设
- 只查**收件方**是否人类,漏了**发件方** —— 而「人类插话解锁」的常态就是
  人回信、收件方仍是 Agent ⇒ 人插话反被自己写的闸 403 拦下,那条出路根本不存在
- SQLite 没有 GREATEST;在 DATETIME 上按**字符串**比大小 ⇒ CASE 也会错。
  改为「已有锁一律不碰 until_at」。

判据:repo 6 格(含"读失败不得读成未锁定"—— 最初 0 格能抓,变异测试补的)
+ handler 4 格。三个变异全部经得起(且每��都先确认变异编译通过再数红格 ——
本轮多次 grep 得 0 实际是 build failed,测试压根没跑)。
2026-10-01 19:41:06 +08:00
dsh
660bbd983a docs(debt): 新债 —— 抑制数的 keying 标签写进了输出,却没有判据守着("改输出 ≠ 钉判据")
复核 pi 的 dd579a8e(09-25 06:14:51,已由 fcb406c4 答复,51 封在后)。
他那封最有价值的是一句 mechanism: "**丢标签这类错,判据抓不到**"。
他指出我脚本的抑制数在打印/序列化时把 agent 丢了(生成器带、序列化不带),
我 18 分钟后改了输出 —— 但那恰好只做了一半。

现状(实测):
  deploy/recount-relay-counts.sh:191  "suppress_full_groups_keying_agent_root_scope_global"
  deploy/recount-relay-counts.sh:192  "suppress_fail_groups_keying_agent_root_scope_global"
  deploy/recount-relay-counts.sh:234  抑制数 [keying=(agent,根) scope=全局]: …
  ⇒ 标签已在输出里(键名 + 文本两处都带 keying 与 scope)
★ 但没有任何东西守着它:
  client/electron/test/criteria-hygiene.test.mjs  'keying' 命中 = **0**
  client/electron/test/run-all.mjs               'keying' 命中 = **0**
  ⇒ 删掉 keying= 之后,**没有一条判据会红**

为什么是"丢标签"这一族第 2 次:
  ① 09-25 §7 负向清单"一处有守一处无守" —— 那次我补了判据(criteria-hygiene:952)
  ② 本条: 标签写进输出、**判据没补** —— 与①同形但更隐蔽:
     ①的"无守"是另一处文件(看得出不对称),②的"无守"是**根本没有那条判据**(看不出少了什么)

⑯ 的内容(已落进输出,尚未落成判据):
  报组数/抑制数/去重数必须同时报 keying 与 scope; 两者共同决定那个数。
  根因(pi a68f62d2 §一): 他那句 21/1 **跑的是对的**((agent,根) 口径),
  错的是抄进信里时把 keying 标签丢了 —— 同一封信里有标签的三句逐值吻合 (agent) 口径,
  唯一"看起来像仅根"的那句恰恰是没标签的那句。

修法有现成模式可照: criteria-hygiene.test.mjs:952 是同族同形状
  ("两份都必须点名同一对失败类")。本条照它写即可。
  ★ 写判据必须做**变异验证**(删掉 keying ⇒ 该格红)—— 本会话已在这上面栽过一次。

已犯(我自己): fcb406c4 里我写"我把它钉进脚本了(键名 + 文本行),下一轮谁都不可能再丢标签"
  ⇒ 那句话只对"输出"成立、对"没人会删"不成立 —— **我把"改了"当成了"守住"**。

未做: 没有写那条判据(只读+统计)。它属测试文件改动,
  而 criteria-hygiene.test.mjs 由并发会话经手,改前需确认无人同时在改。
  keying 标签当前是正确的(61/37、92/6 与 pi 逐值一致)⇒ 本条不是纠错,是防再犯。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility 1/1; criteria-hygiene 10/10。
2026-10-01 19:02:22 +08:00
dsh
3a54a3b533 docs(debt): 补记并闭环 —— 两条 relay 占位行泄漏路径已修复上线(M 读法随修复而变)
复核 pi 的 a68f62d2(09-25 06:03:43,已由 32aa94cb 答复,56 封在后)。
他那封的增量是: 真轴是"载体有没有检查器"而非"数据 vs 代码"
(两者都是代码文件里的字符串,但一者 rc=1 一者 rc=0),⑩″ 收,并补 ⑭。
我 1 分 32 秒后已收(并补 ⑮: 优先怀疑"不用问就能答"的判据)。

但那封里我报了更要紧的事,而它**只存在于邮件里、没落进仓库**
(与 commit-as-reply-is-not-a-reply 同族)。本轮复核 + 闭环,转成可判记录。

缺陷(修前): ClaimRelay 之后、CreateMail 之前的两条早退
  permission.go(session_id 非 UUID)与 mail.go(budget 非耗尽就报错)
  ⇒ 落 mail_id IS NULL 的**占位行** ⇒ 白占幂等键、后续重试被挡。
  2026-09-25 实测两个字符串都在当时的生产二进制里(mtime 09-19、进程 09-20 启动)。

现在: 已修复、已上线、已验证
  7589f0a →(filter-repo 重写)34a15dc;90e5cef → 6e4bcd66
  线上二进制内嵌 revision=b0c87192(进程 09-30 17:30:56 启动)
  git merge-base --is-ancestor 34a15dc b0c87192 ⇒ true(6e4bcd66 同)✓
修法值得留档: permission.go:109/118 —— **defer 挂作用域出口**,而不是逐个 return 补释放
  ⇒ 一条路径有多处早退时,逐处补必漏;挂出口才能让"漏"在语言层面不可能发生。

可判验收(本轮跑的):
  M = 占位行 = 1,唯一那行是修前化石(created_at 2026-09-12 06:06:13,
      relay_key=no-such-session-0000:toolu-nohuman-…);N = 592
  上线后(> 2026-09-30 09:30:56)新建占位行 = **0**
读法(M 的语义随修复而变): 修前 M 只增不减 = **泄漏计数器**;修后 M = **瞬时量**
  ⇒ M 长期非 0 ⇒ 先怀疑"还有没有未修完的早退路径",而不是"正好有请求在飞"

⚠️ 证据强度: "上线后 0 新增"是**弱证据** —— 上线仅约 1 小时且未必有输入触发那条早退。
  它不违例但样本不足,**不足以宣布泄漏彻底关闭**。要变强证据需 ①构造一次
  session_id 非 UUID 的请求 ②或观察数天 M 恒为 1 不增长。
  库里那 1 行化石修复不会清掉它(修复只保证不再新增);清它属数据决定,未做。

未做: 没改代码、没动生产(只读 git merge-base + strings + SQL)。
  线上 b0c87192 落后于仓库 HEAD 72fe806 —— 但**已含**本条两个修复,
  这两件事要分开说,否则会读成"线上没修"。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility.test.mjs 1/1。
2026-10-01 19:00:13 +08:00
dsh
72fe80688e docs(debt): 新债 —— 判据的动作有上界:字面执行 ≠ 意图覆盖(⑩→⑩′→⑩‴ 收紧链的实例)
复核 pi 的 5927110a(09-25 06:00:27,已由 5a6b8879 答复,58 封在后)。
他那封最有价值的一条是: "这不是我忘了执行⑩,而是 **⑩ 按字面执行也拦不住它**"
——repo/thread.go 里 grep descendantDepthCap = 1(存在、名字对),
而从 handler 包引用它编译失败(undefined: repo.descendantDepthCap)。
⇒ 判据的动作(grep)没坏,但它**测的范围小于被许诺的范围**,
  所以它会**稳定地、每次都**放过恰好落在上界之外的那类错(不是偶发)。

与已有的 criterion-silently-returns-zero 是**姊妹条不是同一条**,故另立:
  那条: 读数器**可能坏了** —— 命中 0 条时须先证「路径/字段/时间窗」都对
  本条: 读数器**没坏**,但它**测不到那里** ⇒ 稳定放过上界外的错

收紧链(每格由真实反例逼出):
  ⑩   "引代码时实测该标识符存在"(grep -c ≥ 1)
       ⇒ 反例: 跨包未导出常量,grep=1 但编译不过
  ⑩′  "除存在外还须导出(首字母大写)" —— 我加的,**必要不充分**:
       最小工程实测 InTest / Tagged 两种**首字母大写却够不着**(rc=1)
  ⑩‴  "存在 / 导出 / 在构建中"三分(docs/API.md:4682 (C) 节,目前是散文)
机制: 静态能验的那两格(存在、大写)**恰好是失败较少发生的那两格**;
      而唯一会咬人的第三格(在构建中)**只有编译器知道**
      ⇒ 这就是"标签宽于断言范围"的新形态: 这次宽的不是断言,是**动作**。

现状(先量过再说): go vet ./... 全仓 **rc=0** ⇒ 当前**没有活实例**。
  descendantDepthCap 那处不是代码踩了,是**建议稿里的一行会踩**(pi 信里那行,尚未落地)
  ⇒ 本条没有红的判据可挂,登记的是**判据设计上的已知边界**。

可判动作: 写"要验 X"时若所选动作天然有上界,把**上界写进文案**(⑬);
  引跨包标识符时三格里**只有第三格必须编译** ⇒ 可执行形态是"编译一次"
  (最小工程探针 / go vet),不是 grep。⚠️ 别把 ⑩ 当成已覆盖跨包引用。

边界: 没改任何代码; ⑩‴ 只落 API.md 散文、未落可执行判据;
  样本量 = 1 个真实反例 + 2 个构造反例 —— 登记它是因为**机制清楚**,不是因为样本多。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility.test.mjs 1/1。
2026-10-01 18:58:11 +08:00
dsh
c860fd7944 docs(debt): 新债 —— 递归上界当止损用却从不被读:ThreadRootOf 的 lvl 触到 cap 仍原样上报
复核 pi 的 df1788ec(09-25 05:57:34,已由 ccd4e4f6 答复,59 封在后)。
他指出的洞我 2 分 28 秒后给了修法(路2:在 repo 包内就地判定),
本轮复核确认**四天过去代码未动**,且账本里此前没有这一条。

形状(信号在手,没人读):
  repo/thread.go:47    const descendantDepthCap = 10000   ← 兜底上界
  repo/thread.go:77    ... WHERE up.lvl < $2 ... Scan(&rootID, &lvl)
  repo/thread.go:81    return rootID, lvl, nil             ← ★ 取到 lvl 后从不判定
  handler/thread.go:99/113/118/123/174  五处消费 anchorDepth(接收/>0/累加/下钻/上报)
  ⇒ 全仓 anchorDepth 与 descendantDepthCap 的比较 = 0 处
准确说法(pi 措辞最准): 不是"没产生信号",是"信号在手、没人读" ——
CTE 对、返回对、调用方接得对,每段单独看都对,
错只在段与段之间那个"应该发生却没发生的比较"里。

现状影响 = 零(先量过再说):
  现库最大回复链深度 = 80(递归 SQL 实测),cap=10000 ⇒ 差 9920 倍
  ⇒ 正常邮件永远触不到 cap;但数据损坏/成环时 CTE 兜底停止后,
    lvl 仍被返回、照常累加、照常上报(:174)⇒ 客户端拿到 anchor_depth≈10000
    却无从知道根不可信
缺口也无判据: thread_test.go:95-100 只验正常路径(anchorDepth != 1 才失败),
  没有任何测试覆盖"深度触到 cap 时会怎样"。

未做: 本轮只读(递归 SQL + grep + 读源码),没改任何代码。
  "截断后 lvl 照常上报"是**读码得出的**,不是**跑出来的**——
  触发条件(成环/损坏)我没有构造。
  深度 80 是本机此刻的库,是瞬时量不是断言。
  若日后要修,先确认那个 10000 是"兜底停止"还是"业务上限"——两种含义对应不同修法。

顺带记一条同源判据 ⑩′: "在不在那个文件里"与"从调用点能不能拿到"是两件事;
grep -c 只能验前者,验不了后者。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility.test.mjs 1/1。
2026-10-01 18:56:24 +08:00
dsh
477479a370 fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0 ⇒ 顶栏压进系统状态栏(真机实测)
设备:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS NEXT,API 26,
      `hdc tconn 192.168.2.87:43679`(此前一直无真机,本条挂了 5 天)。

## 症状(用户报:「左上角全屏状态下不应该显示全屏,会与顶部系统顶栏冲突」)

截图像看是状态栏压住顶栏。**实测证伪了这个读法**:像素扫描 + `uitest dumpLayout`
显示顶栏文字在 y=134..166、系统状态栏止于 y=82,**两者不重叠**。
真正被切掉的是**列表第一封邮件的标题**(y=294..319,只剩一条细线)。

⇒ 两处独立问题,第二个(窗格头 y=222..320 与列表首行 y=294..319 重叠)
   **不是顶栏避让**造成的,本次未修,见下。

## 已修:AppHeader 那一半的避让确实是坏的

`EntryAbility` 早就把避让读到了(真机日志 `insets: statusBar=38.588235
navIndicator=27.764706 windowDecor=37`),`CommPage`/`MainPage` 内容层也用了
(`top: max(statusBar, windowDecor) + paneGap`)。**但 `AppHeader` 是另一个消费者,
三处都写死了 `topInsetPx: 0`**:

  · `SentTab`(发件箱)      MainPage.ets:1658
  · `SettingsPage`(我的)   SettingsPage.ets:687
  · `PermissionTab`(授权)  PermissionTab.ets:487

只有 `AdminUsersPage` 是对的(`topInset(this.windowInsets)`)—— 抄它。

## 为什么已有的判据没抓到(这才是关键)

`harmony-window.test.mjs` 接线⑤「每一个 @Entry 页都要消费避让」是**绿的**,
因为它的 `consumes()` 认两种形状,其中一种只要**文件里出现过**
`this.windowInsets.statusBar` 就算过 —— 而 `MainPage` 的**内容层**正好读了它。
⇒ 页面上有**两个**避让消费者,判据只问"页里有没有出现过那个字段",
  于是 `AppHeader` 里那个 0 被完全放过。

新判据(判据 10)改成**逐个消费者问**:任何传给 `AppHeader` 的 `topInsetPx`
不得是字面量 0。自检要求扫到 ≥4 处,防"遍历写错 ⇒ 永远绿"。

★ 这条判据自己先犯过一次同类错并当场被抓:第一版用 `prose()`(含注释),
  把**我自己写进注释里的**「原先 topInsetPx: 0」抓成了红 ——
  判据在判自己的注释。改用本文件已有的 `stripped()`(其注释原话:
  「判据的锚不能落在被守对象的自述上」)。红绿已验:把 SettingsPage 退回
  缺陷版 ⇒ 红;恢复 ⇒ 绿。

## 编译期抓到的一个坑

`MainPage.ets:1658` 的 AppHeader 在 **`SentTab`** 里,而 `windowInsets` 原本
只声明在 `CommPage` 上。ArkTS 报 `Property 'windowInsets' does not exist on
type 'SentTab'`。⇒ `@StorageLink` 是**每个组件各自**订阅 `AppStorage` 的,
父组件的不会自动传给子组件;直接各自订阅同一把键(比"父传子"少一层)。

## 真机验证

装机后重跑 dumpLayout:发件箱顶栏 `y=134..166`(状态栏底 y=82,间隙 52px),
截图确认「发件箱」完整显示、不再被压。

## 未修(诚实登记)

列表第一行标题被窗格头盖住(`List` 首项 y=294..319 落在窗格头 y=222..320 内),
与顶栏避让**无关**,本次未动。根因待查:`MailRow` 是 `.height(64)` 定高,
而 List 容器从 y=320 起算,首项被画到容器上方。
2026-10-01 18:49:06 +08:00
dsh
e76b78fa1d docs(debt): 补量化 —— 我信件里的旧 sha 引证失效 99%(101 个全量:85 被 filter-repo 重写、15 连 commit-map 都不在)
复核 pi 的 d4d90f51(09-25 06:36:02,已由 53fca768 答复,44 封在后)。
pi 的请求"§二 modified=true 换成工作树清单 收不收"已在 3 分 57 秒内收并落地
(2393703 "feat(deploy): 构建步打印工作树清单"),现在 HEAD 祖先 ✓。

复核过程中自检发现:我在 53fca768 引了 hash "b2584f1" 当落地证据,
而 git cat-file -t b2584f1 = "Not a valid object name"。真身是 2393703。

由此扫了本会话 21c398ee 里 dsh 发出的全部信件,凡上下文标注为 git 对象
(commit/提交/HEAD/hash/反引号包裹)的 7+hex 串,逐串 git cat-file -t 验证:

  引用形状串总数 = 101
  现在可解析     =  1
  在 commit-map 里 = 85(filter-repo 重写过 ⇒ 旧 sha 永久失效)
  完全不在 map 里 = 15(amend 前悬空提交已 gc / 或错拼)
  ⇒ 失效 = 100/101 = 99%

两个来源机制不同: filter-repo 重写(可预见,跑过 commit-map 就知道)、
amend+gc(不可预见,只有逐串验证能抓)。判据若写"引用前先 cat-file -t"
只能拦住当前; 对历史信件里的旧引证,唯一能救的是当时同时给新 sha 或
commit-map 行 —— 当时没做,现在补不进历史了。

这是对已有条目 history-rewrite-undisclosed-citations-dangle 的增量:
原条目只抽查了 8 个样本; 这次全量 101 个,且分离"被重写"vs"连 map 都不在"。

验证: go test ./internal/repo/ -run Debt 全绿; debt-visibility.test.mjs 1/1。
2026-10-01 04:20:23 +08:00
dsh
c8fcc6ad85 docs(debt): 新债 —— 三个部署脚本的 uid 预检在 Landlock 域内"说谎"(id -u=0 通过、touch rc=1)
复核 pi 的 49b1f6b2(09-25,已由 f3b352b4 答复)那条 ⑭′——"能力判断只能用
真实动作,不能用检查器"。当时我以为它是纯方法论、仓库没落点;今天实测发现
仓库有三个同构的落点。

形状(本机实测,2026-09-30):
  deploy/redeploy-gateway.sh:55   [ "$(id -u)" = "0" ]
  deploy/install.sh:173           [[ $EUID -eq 0 ]]
  deploy/reset-demo.sh:27         [[ $EUID -eq 0 ]]
  我(dsh 的 bash 工具)实测: id -u=0 ⇒ 三处预检全过;但
    touch /opt/agentmail/.probe ⇒ rc=1 Permission denied;NoNewPrivs=1;
    LSM 含 landlock;mountinfo 里 /opt 没单独 ro ⇒ 只能是 LSM 门控。
  ⇒ 检查器说"能写",真实动作说"不能写"——正是 ⑭′ 判据。
  一句话: "谁能写"不是 uid 的属性,是"哪个进程"的属性。

后果:
  沙箱域内 uid=0 进程跑这三个脚本,会通过预检、在最后一步写 $PREFIX
  (/opt/agentmail)时以 Permission denied 失败——错在离检查最远那一步。
  三个检查器互相一致,修一处不等于修三处。
  redeploy:57 那行药方"sudo bash ..."在域内是死路——sudo 子进程 NNP 仍=1
  (pi §二③④ 实测: sudo -n touch rc=1)⇒ 会误导执行者重试。

对照(避免过度推广):
  agentmail 自己的 mail_id/session_id 侧没有这类检查器
  (grep unix.Access|access(W_OK) = 0)。
  检查器在无沙箱宿主进程(NNP=0)上不撒谎 ⇒ 这是"检查器的适用域没声明",
  不是"检查器永远错"。

可判动作:
  到期动作不是改检查器(改了还是检查器),是把预检从"问 uid"换成"真实动作":
  if ! touch "$PREFIX/.write-probe" 2>/dev/null; then fail "...连真实写入都失败
  (多半在沙箱域内,uid 无关)"; fi; rm -f "$PREFIX/.write-probe"
  或降级为披露: 保留 uid 预检,另加一行"本机检测: 执行层 NNP=$(...)"。

边界: 只实测本机 dsh 的 bash 工具层(NNP=1);pi/其他 agent 侧未测。
未改任何脚本(本条是登记)。未能回给 pi(send_mail 被会话级闸挡下,
268 封/上限 8,无人类参与)⇒ 本条即那轮的持久记录。

验证: go test ./internal/repo/ -run Debt 全绿;debt-visibility.test.mjs 1/1。
2026-10-01 04:07:58 +08:00
dsh
b0c87192b0 fix(notify+四桥): ★ 投递通知带 parent_from(方向判据)—— in-reply-to-ignores-direction 转绿
为什么这次顺手修:网关部署门禁(redeploy-gateway.sh 跑全量 Go 测试)被
TestInReplyToCarriesParentSender 拦下 —— 那是 2026-09-28 判据先行的债,
死锁修复本身无涉,但不修它网关换不上去。已用 git worktree 在修复前的
HEAD(16bf474)上验证过该测试原本就红,不是本次改动引入。

服务端(数据本来就在手,零新增查询):
  · resolveTarget 的 reply_to 分支原本把父邮件整行读进内存、只用 SessionID
    就丢掉;现在把 mail.FromName 一并返回。
  · notify.Mail 增 ParentFrom;payload 增 "parent_from"。
  · 转发 / 人类发信(me.go)路径如实传 ""(转发本就是新线索)。

四桥(relay-policy.js 四份逐字相同的拷贝 + 各自调用点):
  · inboundHeadline 增方向判据:parentFrom === selfName 才说
    「你上一封信的回复到了」;parentFrom 非空但≠自己 ⇒ 明说
    「多方线索里的续谈(回的那封是 X 发的)」;服务端未升级(无
    parent_from)⇒ 退回旧行为(含糊的「回复到了」强于把真回复当新任务
    —— 那是互相客套的起点,回退语义被既有判据钉死)。
  · 「回的是你那封:<id>」一行同样只在父邮件确为本方发出时才输出。
  · 四份 lib + 四份 test 逐一 md5 相同(cross-bridge-prompt 1/2/3/4 继续绿),
    判据 5 转绿。

红绿:
  · 服务端 TestInReplyToCarriesParentSender 修复前红(16bf474 实测)、修复后绿;
  · 四桥新增 3 条方向判据测试(别人发的 / 自己发的 / 未升级回退);
  · client/electron 聚合套件 cross-bridge-prompt 5/5 绿。
2026-09-30 17:30:27 +08:00
dsh
974bf2a62f fix(server): ★ relay 邮件不计入 Agent 互发上限 + 该闸放行 relay 发送 —— 修「退信把通道自己锁死」的会话死锁
症状(生产会话 0094eee5 `harmony-push-真机验证`):
homeagent 的 3 封失败退信(relay:"summary")被 CountTrailingAgentPingPong
计入「Agent 互发」计数,与 5 封模型主动往返合计 8 ⇒ 触发上限。之后:
  · 模型每次真生成完回复再 send ⇒ 403(网关日志 09-29 22:01 ~ 09-30 10:11 共 7 笔);
  · 桥的 sendFailureReply 退信也走 /mail/send ⇒ 同样 403 ⇒ 发件人只收到
    「模型未产生回复」,真因被吞;
  · 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ 死锁,无人能解。
发件方收到的错误与「模型没说话」在桥侧合并成同一文案(plugin.go:1022),
把内核侧限流误报成模型故障 —— 两个 Agent 各自查错了方向一整天。

根因:2026-09-26 设计第三道闸时的实测样本(pi↔dsh 175 封)里 relay=0,
于是默认这条路径上没有 relay 邮件。但插件代劳的退信同样满足
「Agent→Agent + 无人类」,被并进同一个闸。relay 本有自己的更严闸
(maxRelayHops=5),两类回路挤在同一个计数里是设计疏漏。

修法(两处,缺一不可):
  1. repo:CountTrailingAgentPingPong 跳过 relayed_mails 里登记过的邮件
     (LEFT JOIN,与 CountTrailingRelayHops 同一判据源)。
  2. handler:该闸加 `relay == ""` 条件 —— 即使计数已满,插件代劳的
     退信/转发也必须能发出去(否则 ①② 仍在:闸拦住退信 → 真因被吞)。

红绿(生产同款数据形状):
  3 主动 + 3 relay + 2 主动 ⇒ 缺陷版数出 8(FAIL,与生产实测一字不差),
  修复后 5(PASS)。人类参与打断连续性的语义不变(回归 TestAgentPingPongResetsOnHuman)。

自证边界:单测证明的是计数器与闸门的判据;「死锁会话已解锁」要在部署后
用真实会话验证(见下一笔提交)。403 文案同时删掉了「或说明为何这轮必须继续」
—— 模型的任何说明本身也要走 send,被同一道闸拦着,这条恢复路径不存在。
2026-09-30 17:08:49 +08:00