Commit Graph

131 Commits

Author SHA1 Message Date
773acd079f harden(migrate): 抄送回填取切换时刻改 CAST(TEXT) —— PG 的 timestamptz 会被扫成 time.Time
判据只在 SQLite 上跑过:把 MIN(read_at) 扫进 sql.NullString 在 PG 上依赖驱动返回类型,
不可靠。改成 CAST(... AS TEXT) 再解析,并把 PG 的文本形态(+00 时区后缀)加进可识别的
layout 列表。认不出来时退回现在= 把当下已有的已读邮件全算作迁移前 —— 首次升级时
正是对的,且有一次标记守着不会反复跑。
2026-09-13 16:22:00 +08:00
2e5d84330b fix(gateway): 已读迁移对抄送方保持行为不变 —— 我上一版迁移把桥的补投判据放大了
上一提交(1619399)把已读改成按读者记录后,回填只把历史 `status='read'` 记到**主收件人**
名下 —— 对抄送方等于"突然多出一批未读旧邮件"。这不是理论风险,**当天就在野外发生了**:

  opencode 桥(部署后 46 分钟):
    16:06:48 [mail-bridge] 已接入 http://127.0.0.1:8180,身份 opencode(密钥认证)
    16:06:49 [mail-bridge] 补投 2 封离线期间的邮件(共 2 封未读)
  → 它对 05:42 那封「打个招呼」**又回了两次信**(08:07:21Z / 08:08:37Z)

即桥的 `pending_mails = CountUnread` 因迁移变大 ⇒ 桥一重启就把旧信当漏投重放并再次回信。
两个人工探针当时都只覆盖主收件人,恰好绕过这个面("同一封被多人共享"的坑,
判据必须站到每个收件人各自的位置上)。

修法(`backfillMailReadsCC`):迁移前的邮件(`created_at <` 切换时刻)凡 `status='read'`,
给它的**所有收件人**(主 + 抄送)各补一行 —— 与旧模型下"所有人看到的都是已读"完全一致;
迁移后的邮件一律不碰(那条界线是判据核心:越界就会把"某个人读过"错写成"所有收件人都读过")。
切换时刻:迁移时写进 `app_meta(read_model_switchover_at)`;老库没有这个键时退化成
`MIN(mail_reads.read_at)`(那张表的第一笔写入就是回填批次)。

判据 `internal/db/migrate_reads_test.go`:迁移前的老邮件必须补到抄送方、**迁移后的不能碰**、
重复执行不重复插。扰动验证:去掉时间界线 → 判据红(补记 2 行,期望 1)。

实测收口:
- 迁移日志「再给 4 个抄送方补记历史已读」;"抄送方仍算未读(已读邮件)" 计数 **0**。
- **重放反证**:重启 opencode / pi 的桥 → 无"补投"行、3 分钟内 0 封新邮件 ✓
  (对比修复前 opencode 重启即补投并回信)。
- 清掉那 2 封由这次迁移产生的误回信(happy-pixel 回到 6 封)。
- 全量 server 10 包 + client/electron vitest 239 + 五 Agent 演练 20/20 全绿。

教训:**语义迁移必须让"可观测状态"保持不变**,新语义只对迁移后新增的对象生效 ——
否则用户会看到一批凭空冒出来的未读,而下游(这里是桥的补投)会把它当真实信号动作。
2026-09-13 16:18:37 +08:00
1619399470 fix(gateway): 已读改为**按读者**记录 —— 修掉"别人读掉,我就看不到"
用户报的那句 dsh 自述("收件箱列表未展示它,直接按 mail_id 读取成功")不是插件问题,
是网关的已读模型:`mails.status` 是**邮件级**的一个列,任何收件人读掉,对所有收件人
(含抄送)都变成已读 —— 全库没有任何按人记录已读的表,我查过 schema 与迁移文件。

实测复现(两个人类用户、一封共享邮件,排除 Agent 干扰):
  gui-lab 读掉 → gui-lab 未读清空(应当)→ **jianf 的未读也没了**(错误)
  而 jianf 的 `status=all` 里仍在 ⇒ 是已读语义问题,不是送达问题。
线上那封信正是这个形状:`jianf → dsh` 抄送 pi/opencode/zcode/homeagent,**pi 最先
回复(= 它读过了)** ⇒ 这封对 dsh 也变成 read ⇒ dsh 的 `read_inbox`(默认 unread)
返回空 ⇒ 它只能按提示词里的 mail_id 兜。

三个受害面:① Agent 的 `read_inbox` 拿不到信(换一个不兜的模型就变成"正文是空的");
② 人类的未读被抄送的 Agent 读掉;③ ★ 桥的补投判据 `pending_mails = CountUnread` 归零
⇒ SSE 漏过或进程重启时那封信**不再补投**(静默丢信)。

改动:
- 新表 `mail_reads(mail_id, reader_name, read_at)`,未读 = 这张表里没有该读者的行。
- 判据收敛到一处(repo 的 `unreadFor` / `readStateFor`),六处读写点全部改用它:
  单封已读、批量标已读、权限决策(只记**决策人**)、`ListInbox`(过滤 + 返回的
  status 都按读者算)、`CountUnread`、`CountUnreadInSession`、会话列表未读计数。
- 一次性回填补历史:`mails.status='read'` 记到**主收件人**名下(唯一可用的推断),
  用 `app_meta` 里的标记守住 —— 不能每次启动都跑,那会把"某抄送方读过"按主收件人
  写成已读,正是这次要修的错。实测:`done rows=207`。
- `mails.status` 保留为"有人读过 / 已归档"的冗余列,**不再是判据**。

★ 顺带挖出并修掉一个真 bug:`CountUnreadInSession` 用的是 PG 专有语法
(`cc_list @> $3::jsonb`),而线上是 SQLite ⇒ 那条 SQL **语法错误**
(`unrecognized token: "@"`),调用点又是 `unread, _ :=`(吞错)⇒
**会话列表的未读数一直是 0**。现已改用仓库既有的方言助手 `db.CCHas`。
实测:happy-pixel 会话现在 `unread_count=5`(修复前恒 0)。

判据:新增 `internal/repo/readstate_test.go`(5 条:按读者未读、会话内计数、
批量标已读、归档对所有人可见性、权限决策只记决策人)。
**扰动验证**:把 `unreadFor` 退回旧语义 → 4 条判据全红;恢复 → 绿。
全量 server 10 包全绿。文档同步:API.md 的「标记已读」段 + PLUGIN-CONTRACT 的 T-1.4。

线上复验:同一受控实验 —— gui-lab 读掉后,**jianf 的未读仍在且 status=unread** 
2026-09-13 14:25:44 +08:00
49ec22f916 fix(pi): 等人点头的 worker 不再占并发额度,也不会被硬超时杀掉
实测(今天全 Agent 演练时撞到的):pi 的 `maxWorkers=3` 被**三个正在等人工授权**的
worker 吃满,于是新邮件只能排队 —— 而人可能十分钟后才看邮箱。清掉卡住的请求后队列
立即排空,机制本身没错,错的是"等待"被当成了"在干活"。

两处改动(`src/pool.mjs`):

1. **等待期间让出并发额度**。worker 发 `permission_pending` 时把它的 entry 标为
   parked,`pump()` 只数"在干活"的(`activeCount()`)。停放另有上限 `maxParked`
   (默认 5,防止内存无界:每 worker 约 140MB);超出后仍占额度并打日志说明。
   决策到达(`routePermission`)时解除停放,回到额度里。

2. **等待期间暂停硬超时**。硬超时的用途是回收**卡死**的进程,而等人点头不是卡死:
   停放时清掉计时器,决策到达后重新起一个完整窗口。否则 worker 会在人还在读邮件时
   被 SIGKILL —— 那次工具调用直接消失,人后来批了也没人接(这类"批了没反应"的现象
   与此吻合)。

判据(`test/pool.test.mjs` 新增 3 条):
  · ★ 等待授权的 worker 让出额度:另一个会话的邮件必须能开跑
  · ★ 等人点头期间(800ms > 300ms 硬超时)不得被强杀,且恢复后能跑完
  · 停放有上限:超出后仍占额度(不无限超发)
**扰动验证**:整块回退到 HEAD → 3 条全红;修复后 3/3。全量 pi 套件 420/420。

已部署(快照 20260913-131216,桥重启并重新心跳)。
2026-09-13 13:14:56 +08:00
35f71d5144 test(webui): 全流程演练的两处判据修正(同主题请求的定位、工作区目录)
第三次跑通 **37/37(0 失败 0 无法判定)**,含真 Agent 闭环:写信带附件 →
pi 请求 bash 授权 → **在界面点同意** → 决策落库 → pi 回信把附件原样带回
(sha256 一致)→ 回信出现在收件箱。

两处都是**判据/前置条件**的问题,不是产品缺陷,但都会伪装成产品故障:

1. 授权页上同一 Agent 的请求主题**一模一样**("是否允许执行 bash?")。原先按主题
   `.first()` 选行,于是点到了**别的会话那条旧请求**上 —— 网关回 `expired` 警告,
   界面看起来"点了但没生效"。现在按**本轮会话别名**定位分组容器
   (`header.locator('xpath=..')`,DOM 探针确认头与请求行同容器),且**只在折叠时**
   才点头按钮(已展开时再点会把它折起来,那正是第二次又落到别人行上的原因)。

2. **工作区寻址里的 path 必须是已存在的目录**,否则桥按设计回退到自己的兜底目录
   (`~/.pi/mail-sessions/<会话>`),产物就落在别处而不是我指定的目录。脚本现在
   先 mkdir —— zcode 那次也栽在同一条规则上。

顺带记录一个**产品层面值得决策**的现象:pi 的 worker 池上限是 3,而**"等人点头"的
worker 占着池子**。我上一轮的误点让 3 个 worker 全卡在等决策上 → 池满 → 新邮件排队
(设计如此:满载排队不丢信),于是我 300s 等不到回信。清掉卡住的请求后队列**立即
排空**、排队那封信被取出并起了一轮。也就是说:人在开会时,同一个 Agent 接不了新活。
是否把等待授权的 worker 挪出池子(或单独给额度),需要你定。
2026-09-13 12:06:40 +08:00
2922fb711f fix(deploy): 故障通知的三处缺陷 —— 死信目录、隔夜补投、失败无原因
起因:用户邮箱里「[dsh] 桥服务异常终止」反复出现。查下来有两层。

**第一层:崩溃本身(已修,是历史)**
dsh 在 2026-09-12 11:40 起崩溃循环,根因是当时那次插件快照切换后
`/root/.dsh/profiles/web/node_modules/dsh-mail-bridge/cordis.patch.yml` 不存在
(`failed to read overlay … ENOENT`)→ 起不来 → systemd 反复重启。
现在快照里该文件在、dsh `NRestarts=0`、今天 0 次失败。

**第二层:通知管线本身坏了(本次修)**

1. ★ **死信目录**:`zcode.service`(应用单元)既没有 `AGENTMAIL_AGENT_NAME` 也没有
   密钥,报告就落进 `unknown-agent/` —— 而 flush **只读自己那个 Agent 的目录**,
   于是 25 份 zcode 崩溃告警永久投不出去。修法是两件事:
   · `resolveIdentity()`:身份按「单元 env → 单元名推导 → /etc/agentmail/<agent>.env」
     解析;`zcode.service`→zcode、`pi-mail-bridge.service`→pi……
   · 支持 `AGENTMAIL_AGENT_SECRET`:zcode 只配了 secret 没有 key,而脚本原先只认
     Bearer key ⇒ 就算目录对了也发不出去(网关的 AgentAuth 两种都认)。

2. ★ **隔夜补投**:补投原先只在**同一个单元**的下次 `ExecStartPost` 跑,于是
   网关不可达时攒下的报告要等到那个服务自己重启才补投 —— 实测 4 封 Sep-12 的
   告警在 Sep-13 10:35 才到。现在新增 `agentmail-failure-flush.timer`(每 10 分钟
   `--flush-all`),它遍历所有 Agent 目录、按目录名逐个解析身份后补投。
   过时报告还会在主题与正文上标 **「补投:这是 N 分钟前的故障报告,不代表现在仍在
   故障」**(原先正文里只有昨天的时间戳,读起来像刚崩)。

3. **补投失败只报数不报因**:`catch { failed++ }` → 日志只有 `spool: sent=0 failed=4`,
   没人知道卡在哪。现在每条失败都带回原因(HTTP 状态码/网关不可达/身份未配置)。

4. 顺带两处准确性问题:
   · 主题写**单元名**而不是笼统的「桥服务」—— 25 封标题写着"桥服务异常终止",
     实际崩的是 zcode **应用**单元,照标题去查桥方向就错了。
   · `created_at_ms` 是我们的元数据,但 `/mail/send` 是**严格解码**的(实测 400
     不认识的字段)→ 发送前剥离,线格式保持干净。

判据:新增 `deploy/service-failure-notify.test.mjs`(12 条,含假网关做真实投递、
严格解码断言、补投标记的正反两向)。端到端验证:模拟"没有身份的 zcode.service
崩溃"——修复前落 `unknown-agent/` 永久死信;现在**真的投出去了**,且
`from_name=zcode`、主题 `[zcode] zcode.service 异常终止 #…`。

积压清理:27 份(dsh 2 + unknown-agent 25)全部来自 09-12 那两轮崩溃、事件已在
邮箱与 journal 里出现过,按**不再补投**处理(避免把隔夜告警灌进邮箱),
原始文件留档 /root/gotmp/failure-spool-backlog-20260913.tar.gz(600),
spool 目录留 README.md 说明。
2026-09-13 11:06:01 +08:00
77699e216b fix(webui): 新到的授权请求藏在折叠分组里 —— 徽标动了,内容看不见
用户报告:"我点到授权界面,才更新显示授权请求"。

先排除了推送本身:实测徽标是**实时**更新的(gui-lab 授权 7→8、jianf 1→2,
都没导航)。问题在内容:`PermissionList` 的展开状态
`const openSet = expanded ?? new Set(autoOpen)` —— 一旦手动点过一次,
`expanded` 就冻结成"点的那一刻"的快照,此后新到的待决请求落在一个折叠的分组里:
徽标数字变了,正文却看不见,直到离开再回到授权页(组件重挂载、`expanded`
回到 null、默认展开重算)才出现。

这违反代码自己的设计意图(注释写着「有待决策请求的会话默认展开:那些是在等人
动手的,藏起来等于没解决问题」)。

修法:加一条**状态迁移**判据 —— 新出现的待决邮件(`sessionId:mailId`)让它所在
的会话自动展开。用 mail_id 而不是"会话有没有待决"作判据,是因为实测撞到的正是
"会话早就有待决、用户把它折叠了,之后又来了一条";而用户在那之后再手动折叠同一
条不会被弹开(没有新 mail_id)。

验证:
  · 真浏览器复现:授权 2 → 3 而新请求正文不可见,重进页面才可见(复现成功)
  · 新增 `test/components/PermissionList-autopen.test.tsx`(3 条,含反向对照)
  · 扰动验证:撤掉修复 → 2 条目标判据红、对照判据仍绿;恢复 → 3/3
  · 部署后同一探针复验:折叠状态下新请求**立刻可见**,不再需要重进页面
  · 前端 239 测试全绿;桌面重打包与 WebUI 同源(index-jaRgHqX2.js)

顺带修掉一个**更严重的缺陷**(在做「用 zcode 写个网页」时被 agent 自己报出来的):

  fix(plugins): zcode 的 read_mail 永远返回空正文

agent 回信原话:「read_mail 返回的正文是空的,收件箱预览在「点击计数…」处被截断」
—— 它因此只看到前两条要求,写出来的页面漏了第 3 条(生成时间)。

根因在 `lib/inbox-format.js` 的渲染端:

    const body = m?.body_preview || m?.body || '';
    lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);

zcode 的 read_mail 用 `bodyLimit = 0` 表示"要全文"(HTTP 侧 `?body_limit=0`
也确实是这个语义,服务端返回了完整正文),但这里 `slice(0, 0)` 把正文渲染成
**空字符串** ⇒ 模型永远读不到全文,只能看收件箱里那段预览。

修法:`bodyLimit <= 0` 视为不截断;不截断时优先取 `body`(单封接口可能同时带
`body_preview`,那是短的那个)。四份副本逐字节同源(`check-shared-libs.sh`
通过),每个桥各加 2 条判据:0 = 不截断、不截断时优先全文。
扰动验证:退回旧写法 → 2 条红。

端到端验证:让 zcode 读全文并原样回报最后一行(一个随机标记)。
修复后它精确回出 `最后一行标记:ZTOKEN-2c7561fd` ✓ —— 修复前这不可能。

四家桥都已重新部署到新快照(pi/opencode/dsh/zcode),部署漂移检查:
「四个宿主都在跑当前代码」。测试基线:pi 417 / opencode 323 / dsh 372 / zcode 382。
2026-09-13 10:39:26 +08:00
f243355bd4 test(webui): 全流程演练(真浏览器 + 真后端 + 真 Agent 闭环)
test/manual/webui-full-flow.mjs —— 37 项判据,实测 37/37 全绿、0 失败
0 无法判定。覆盖:登录(含错密码负向对照)→ 收件箱 → 打开详情 → 写信带
附件 → **真 Agent 闭环**(Agent 请求 bash 授权 → 在界面批准 → 决策落库 →
Agent 继续 → 回信把附件原样带回,sha256 一致)→ 授权页 → 多账号添加/切换
→ 主题与背景(含自定义图片)刷新后保持 → 窄屏 → 全程无预期外 4xx/5xx。

判据要点(这轮踩过的坑都在里面):
- 授权邮件**不在收件箱列表里**(MailList 明确把 permission_request 滤到
  「授权」导航项)——在收件箱里找它必然"找不到同意按钮",看着像 UI 缺陷。
- 决策按钮必须用**精确文本**:`has-text("同意")` 会命中"已同意"/"一直同意",
  `.first()` 可能点到别处,表现是"点了但库里没有任何决策"(像后端失灵)。
- 一次「同意」只放行**一条**命令,多步任务会一条接一条地问(实测 pi 连问
  两次)——所以脚本首次用「同意」、之后用「一直同意」,两种选项都验到。
- 未登录时前端要问一次 `/auth/me`,那时 401 是**正常回答**;登录之后再 401
  才是会话丢失。判据按时间点区分,而不是无条件放过这个端点。
- 故意用错密码造的 401 也要断言"确实产生了",否则"被拒"那条可能是假绿。

顺带清掉本轮留下的测试数据:6 条待决权限决策为拒绝(剩 0),13 个测试会话
归档 12 个(余下 1 个属他人会话,服务端 403「无权归档他人的会话」——这是
对的,没绕过去)。
2026-09-13 09:30:44 +08:00
5804ba4f63 fix(webui): 自定义背景完全不可用 —— 「图片」档进不去
现象:点「图片」后背景反而被关掉,上传控件永远不出现 ⇒ 自定义图片在 UI 上
完全不可达(用户看到的正是"自定义背景不正常")。

根因:`normalizeBackground` 把「kind=image 但还没有图片数据」折叠成 `none`
(这条判据本身是对的 —— 读盘时那确实是脏数据),但 store 的 `commit()` 每次
patch 都要过一遍它,于是 `setKind('image')` 这一瞬间就被折叠回去;而上传控件
只在 `kind === 'image'` 下渲染 ⇒ 鸡生蛋问题,用户永远走不到选文件那一步。

修法:给归一化加 `keepEmptyImage`。
  - 读盘(`readStored`)保持严格:空图片状态是脏数据,退回 none。
  - 交互(`commit`)保留瞬态:允许"已选图片档、还没挑文件"这个中间状态存在。
静止态的不变量没有放松,放松的只是正在选图的那一瞬间;`applyBackground` 对
空图片本来就不铺开(不会出现 `url("")`)。

判据(都验过"修复前会红"):
  · 新增 `test/components/BackgroundPicker.test.tsx` —— 点「图片」后选文件控件
    必须出现、选完图背景必须亮、失败必须说原因、选「无」必须能关掉。
    **扰动验证**:把修复撤掉 → 组件 3 红 + store 1 红;恢复 → 22 全绿。
  · `test/stores/background.test.ts` 补 2 条:交互进入图片档要留住 /
    瞬态落盘后重读必须退回 none。

线上验证(真浏览器,部署后):线上 bundle 换成 index-CSFGa8wa.js 后 ——
点「图片」→ `kind=image` 保持 → 上传 16KB 小图与 9MB 大图都成功
(大图压缩到 1790KB)→ 刷新后仍在 → 全程无页面错误。

顺带:应用内**从未出现**过品牌图标。`BrandMarkIcon` 只用在登录页与首启页
(都是登录前界面),登录后的日常界面里一处都没有。侧栏顶端加上品牌标记
(点它回收件箱)。favicon 那条链本来就是好的(3 个 link 都 200、类型正确、
图标内容正确),已在验证中确认。
2026-09-13 08:55:55 +08:00
085e6384a0 test(electron): 多账号真实验收脚本(真起打包产物 + 两个真实账号)
test/manual/multi-account-verify.mjs:预置 accounts.json 后起打包产物,
判据落在**网络层**而不是"列表里有多少行" —— 收件箱按会话分组且默认折叠,
DOM 行数 ≠ 邮件数,用行数当判据只会得到一条随折叠状态变化的假绿/假红。

21 项判据,实测 21/21:
- 聚合 = 每个可用账号各一次收件箱请求、**各带自己的令牌**(抓请求头确认没串号)
- 徽标的存在与否是单/聚合视图的确定性差异(单账号视图不该有徽标)
- 切到单账号只问那一个账号、且带的是它的令牌
- 添加流程:无效密钥当场拒绝(不写进列表)、有效密钥写入并落盘
- 落盘 accounts.json 权限 600、内容与界面一致

★ 修一处**判据自伤**:无效密钥那步故意发 401,最初我把它算成"页面错误"导致
假红;改成"断言这个 401 确实是本步造出来的"(直接过滤 401 会把真实的鉴权故障
一起放过去)。
2026-09-13 06:38:58 +08:00
addde97600 feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与
写信账号切换留作下一轮)。

- `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并),
  16 条测试钉住每条判据(含反向对照)。
- 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+
  0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与
  http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。
- 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的
  令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。
- ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的
  服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。
- ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表
  少一整份邮件而界面看起来完全正常。
- `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它
  (`client.ts` 的 `const BASE` 快照已改成每次读)。
- UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页
  「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。
- 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、
  typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00
c7cb88d9aa chore(security): 轮换 verify-l2.sh 里那把曾进 git 历史的 user key
旧值(jianf 的 user key,1759666 起在历史里)已从库里删除,实测
`/auth/me` → **401**,即暴露在历史中的那串现在无效。新值只存在
/root/gotmp/verify-l2-token.txt(600),脚本 6/6 通过。

- 只删当前行**不等于**作废:git 历史里的密钥必须靠轮换才能失效。
- 轮换前先核对过使用面:全仓库只有这一处,/root 下的脚本与运行进程环境里都没有。
- 生产库里临时建的留档表已 DROP,那行旧记录挪到
  /root/gotmp/user-keys-rotation-backup-20260912.json(600)。
- 轮换过程本身也踩了一个小坑:sqlite3 CLI 里没有 gen_random_uuid()
  (那是网关 Go 驱动注册的函数),插入必须显式给 key_id —— 而当时旧行已经删掉了,
  于是出现了一小段「无可用 key」的空档(立刻补上并双向验证过)。
2026-09-12 23:58:10 +08:00
33e44074e7 fix(tooling): pi-lens 的项目防护其实一直没生效(JSON 里带注释 → 整份被忽略)
## 现场

跑 `pi --help` 时有一行不起眼的警告:

    [pi-lens] ignoring invalid project config …/.pi-lens.json:
    Expected double-quoted property name in JSON at position 60

`.pi-lens.json` 是 `d50c55d` 专门加的项目级防护(关掉 pi-lens 的格式化与
autofix),但它的说明写成了 `//` 注释 —— 读 pi-lens 的源码确认:

    PROJECT_CONFIG_BASENAMES = ['.pi-lens.json', 'pi-lens.json'];  // 没有 .jsonc
    raw = JSON.parse(text);                                        // 不去注释
    // 解析失败 → 打一行警告,然后**整份忽略**

所以「已经关掉了」这个结论是**错的**,防护从落地那天起就没生效过。
而它要防的是两次已发生的真实损失:一次是 `19a3161` 把约 7000 行 biome 重排
扫进功能提交(无法审查、还掩盖了一处删行),一次是 prettier 改坏
`client/electron/index.html` 并打破 `test/theme.test.mjs` 的两条断言。

失败方式是静默的:进程照常跑,只有启动时一行警告 —— 而我正是在
`pi --help` 的输出里才看见它。

## 修

- `.pi-lens.json` 改为**严格 JSON**(只有 `$schema` / `format` / `autofix`;
  `$comment` 也不行 —— pi-lens 校验未知键,会为它刷一条
  「unknown key … ignored」的警告,而噪声会训练人忽略告警)。
- 理由移到 `docs/DEV-TOOLING.md`(含 pi-lens 的源码依据、两次事故、
  以及「为什么不是把格式化器配成本仓库风格」——试过,仍改 17 个文件)。
- `biome.jsonc`(允许注释)里加指针:它只是两道闸中的一道,真正的总开关是
  `.pi-lens.json`,而那份**必须是严格 JSON**。
- 验证:`pi --help` 现在 **0 行** pi-lens 输出;反向对照(故意塞回一行注释)
  会重新出现 `ignoring invalid project config`。

## 顺带:仓库里有一个活的凭据

`scripts/verify-l2.sh` 把**管理员 user key 硬编码**在文件里(`1759666` 起就在
git 历史里)。实测它**仍然有效**(`/auth/me` → 200),属于用户 `jianf`。

- 已改为从 `VERIFY_L2_TOKEN` 或 `/root/gotmp/verify-l2-token.txt`(600)读取,
  两者都没有时**报错退出**(反向对照验过:EXIT=2,不静默跑);
- 跑通一次确认可用(6 通过 0 失败)。

**但删掉当前这行不能把它从历史里拿掉** —— 要真正作废必须轮换那把 key。
这需要你定(它是你账号的密钥,可能还有别的工具在用),见提交后的说明。
2026-09-12 23:49:55 +08:00
2e28696e74 fix(pi): 模型选择落到「宿主默认」是静默的,而且日志把它说成「平台默认」
## 现场

pi 每封来信都报 `模型 (平台默认) 失败: 402: Insufficient Balance`。
**把它读成「平台的模型没钱了」是错的** —— 真相是:

- `modelAttemptOrder(范围, env默认)` 在「平台没划范围 **且** env 没指定」时
  返回 `[undefined]`,语义是「交给宿主 SDK 用它自己的默认模型」;
- pi.env 里 `AGENTMAIL_REPLY_PROVIDER/MODEL` **都是空的**,而
  **opencode.env 里钉了 `llmsproxy`/`AUTO`** —— 这就是为什么其它三桥通、只有 pi 不通;
- 于是落到了宿主的默认:`/root/.pi/agent/settings.json` 的
  `defaultProvider: deepseek` + `defaultModel: deepseek-v4-flash`
  —— **直连 DeepSeek 云**(不是本地代理),那边的余额是零;
- 而那个名字连本地代理的目录里都没有(目录是 `deepseek-v4.1-flash`),
  所以即使指对了代理也会 403。

## 修

1. **配置**(`/etc/agentmail/pi.env`):按 opencode 的约定钉上
   `llmsproxy` + `AUTO`,并在注释里写明「留空的语义是交给宿主默认,平台管不着」
   —— 这个语义本身就是坑。
2. **代码**:把那句 `(平台默认)` 改成 `宿主默认(平台未指定模型)`,
   并在「平台未指定模型」时**显式告警**一次。一句话的日志差别决定了排查方向:
   「平台默认」把人引向平台配置,「宿主默认」直接指向 `~/.pi/agent/settings.json`。
3. **断言**:`modelAttemptOrder` 的 `[undefined]` 语义 + 「源码里不能把宿主默认
   写成平台默认」(只看字符串字面量,免得注释里的解释也被禁掉)。

## 验证

- 配置前:`env | grep -i zcode|agentmail` 那条待决请求被拒(它会把 worker 环境里的
  `AGENTMAIL_AGENT_KEY` 打进模型上下文);顺带清扫 9 条早前实验遗留的待决请求。
- 配置后真发一封进 pi 的**已有会话**:6 秒内收到回信,标记原样返回 ✓
- 四桥漂移检查全通过;pi 415 测试全绿;「平台未指定模型」告警在生产日志里出现 0 次
  (说明配置确实齐了)。

## 仍然待定(需要你定)

`/root/.pi/agent/settings.json` 的宿主默认 **仍指向 `deepseek/deepseek-v4-flash`**。
它影响**交互式 pi**(人工开着 pi 干活时用的就是它),而且那个模型名不在本地代理目录里。
桥这条路已经绕开它了,但要不要把宿主默认也改成 `llmsproxy/AUTO`
(与 opencode 一致)需要你拍板 —— 那会改变交互式会话的行为。
2026-09-12 23:31:44 +08:00
630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00
b53afd4523 chore(deploy): 部署漂移检查器 —— 快照是不是还等于仓库、进程到底在跑哪份代码
「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会
在意,直到出问题时才发现线上是几天前的行为:

  1. 仓库改了、快照没重新部署(快照是独立副本)
  2. **软链切了、进程没重启** —— `current` 只是个符号链接,切换它不会重载
     已在跑的进程,进程持有的是启动那一刻加载进内存的代码
  3. 单元/配置改了、没 daemon-reload 或没重装

`node deploy/check-deploy-drift.mjs`(`--self-check` / `--json`)把三件事变成
可判定的:逐字节比内容(**不用 diff** —— 本机 PATH 上那个是鸿蒙工具链里的,
对内容不同的文件仍返回 0)、读进程真实 argv、比进程启动时刻与软链切换时刻。

## 判据必须按宿主真实的加载方式分开写(这是踩出来的)

第一版对四个宿主用同一套判据(「单元 ExecStart 指向 current」+「进程 argv 里有
快照路径」),结果 **opencode 与 dsh 双双假红**:它们的插件由**宿主进程**加载,
argv 里永远只有宿主自己的可执行文件。永假条件在检查器里表现为「稳定的红灯」,
人会学会忽略它。

| 宿主 | 谁加载 | 从哪里读 | 切换后要重启吗 |
|---|---|---|---|
| pi / zcode | 自己的进程 | unit 的 ExecStart | 要(启动时加载) |
| dsh | dsh 宿主 | profile `link:` → `node_modules` 软链 | 要(服务启动时) |
| opencode | opencode 宿主 | `opencode.jsonc` 的 `plugin:` | 不要(会话创建时惰加载) |

## 判据自检也修了一次

第一版自检「宿主表:运行时加载的宿主必须标记需要重启」写的是
`h.restartOnSwitch !== false`,而 `undefined` 也被放过 —— 于是 pi/zcode 的
`restartOnSwitch` 缺省成 undefined、落进「惰加载」分支、判据**永真**。
现在这条判据抽成纯函数 `judgeRestart`,自检用**行为用例**盖住:
旧进程 + 启动时加载的宿主必须判红、惰加载的宿主不判红、切换后启动的一律放行、
读不到启动时刻时不据此判红、容差内不判红。

## 扰动实验(在真实对象上证明判据会红)

- 改一个仓库运行文件 → pi 的「① 运行文件与仓库一致」变红,恢复即绿 ✓
- 只把 pi 的软链 mtime 拨到刚刚(模拟「切了没重启」)→ 「④」变红并指出原因 ✓
- 把 dsh 与 opencode 的软链都拨动 → **dsh 红、opencode 绿**(惰加载该保持绿)✓
  全部拨动均已在实验后还原并复核为绿。

## 当前结论

四个宿主都在跑当前代码(pi/opencode/dsh 的快照自 11:45 起未变,其间无提交
动过它们的运行文件;zcode 是 18:57 的快照)。「三桥加载规范化」的切换本身
早已完成,缺的是这条可复跑的判据 —— 补齐了。

另外修了 `redeploy-plugin.sh` 的一个静默问题:`usage()` 按**行号范围**截取头部
注释(`sed -n '2,40p'`),我这次的注释把退出码那段挤到 40 行之后,`--help`
就少了半页。已同步范围并就地记下这个陷阱。
2026-09-12 20:39:22 +08:00
8b2206ed53 fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录
Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
2026-09-12 20:25:00 +08:00
d015d3694c test(zcode): 把门禁判决实验收进仓库(test/manual/gate-e2e.py)
它是「yolo + 自有工具面 + 我们自己的门禁」这个姿态**唯一**的决定性验证:
批了→命令真执行(比对文件内容,不只看回信);拒了→命令真没执行(文件不存在)
**且回信把成因说成「人拒绝」而不是「超时」**;同会话第三次调用仍产生新请求
并在获批后执行(幂等键按调用唯一)。之前只放在 /root 下,会随环境丢弃。

顺手修两处会骗人的东西:

1. **不要用 `python3 run.py | tee log` 再取 `$?`** —— 那是 tee 的退出码(恒 0)。
   实测踩过:脚本自己打印 5/6(有失败),后台任务通知却报 exit-code 0,
   日志里那句 `EXIT=0` 完全是噪声。现在脚本把**自己的** exit_code 写进证据文件,
   README 也改成 `set -o pipefail` 的调用方式。
   (同源问题第三次:PIPESTATUS 在 dash 下报错、`go build | head` 假绿、这次是 tee。)
2. **备注文字不再显示在通过项旁边** —— 通过的判据曾挂着「很可能又被当成重复请求
   丢弃了」这种失败提示,会把「全绿」读成「有问题」。

已从仓库路径连跑三次 13/13(不同标记),确认收进仓库后仍可用。
2026-09-12 19:11:58 +08:00
a00cbf36fc feat(zcode): yolo + 自有工具面 + 我们自己的执行门禁(headless 真正能干活了)
按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。

## 为什么必须走这条路(实测,不是推断)

MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。

于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。

## 新增

- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
  三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
  不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
  暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
  不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
  默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
  opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
  平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
  且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
  可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
  模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
  **不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
  最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
  产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
  会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。

## 修掉三个真缺陷(都是实测撞出来的)

1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
   网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
   不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
   砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
   错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
   并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
   而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
   (实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
   timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
   所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
   留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
   替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
   其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。

## 自报改成如实

detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。

## 验证

- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
  shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
  「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
  「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
  (「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
  批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
  且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
  并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
  备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
  驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
2026-09-12 19:05:54 +08:00
10ff50e899 feat(homeagent): 输出通道改造 —— 通道名 email + 注入通道对齐 + 附件走路径
修的是内核的交付判定与我们对不上的问题。

## 根因

内核判断「这一轮到底有没有交付」**只认工具名前缀** `output_send__*`
(internal/agent/core/process.go 的 isOutputDeliveryTool)。而 `send_mail`
是个普通工具,长得和 read_inbox 没有区别 —— 模型用它发完信,内核不知道
回复已经交付,照常补一句「请根据以上工具结果继续。」。模型把这句话读成
「还要再做一步」,而它手里唯一能做的「一步」往往又是再发一封信。
这个自我强化循环在 QQ 上有过真实事故(单轮 34 次 output_send__qq、514 秒)。

三处改动,缺一不可:

1. **通道名 `homeagent` → `email`**(按投递介质命名,与内核自带的
   qq/webui/cli/acp/a2a 一致),内核据此拼出 `output_send__email`。
   能力位声明必须与实现一致:原先声明了 CapFile 而 handler 不读 `type`,
   于是任何 type=file 都会静默变成一封「把路径当正文」的邮件 ——
   声明一个做不到的能力比不声明更糟(调用方无从发现)。现在 caps=7
   且真的实现 text/file/image。

2. **注入来信时用通道名,不再用插件名**。内核的约定是「用回复该走的通道名
   注入」:webui 传 "webui",clawhubadapter 传它自己的通道名。我们原先两个
   参数都传 `homeagent-mail-bridge` —— 那个名字**不是一个已注册的通道**,
   于是注入来源、模型被告知要用的通道名、实际注册的通道名三者对不上。

3. **提示词补上平台特有的投递指引**(channel.go 的 deliveryHint)。
   共用的 replyInstruction 对人来信说的是「回信不用你自己发,插件会替你转发」
   —— 那句在 dsh/opencode/pi 上完全正确,在 HomeAgent 上却与内核的 persona
   (「必须显式调用 output_send__{通道名}」)相反。实测:模型听我们的、
   返回纯文本、插件兜底转发,邮件确实到了,但内核不知道已交付。
   现在两条口径对齐:优先走通道,兜底 relay 仍然生效且**不会重复发**
   (通道 handler 先 noteExplicitChannelSend,自动 relay 据此让位)。

同时按新纪律在 plg.json 声明 `sdk: "1.2.0"`;工具链据此同步了 go.mod 的
require/replace(它自己写的,含 store 里的绝对路径 —— 换机器跑一次
hmapdev build 会重新同步)。

## 验证(真模型、真网关、真邮件)

进程构建:`hmapdev build` 报「SDK 1.2.0(项目声明 sdk=1.2.0)」、
子进程模式(协议 2);go vet 干净、go test 通过。

部署:经内核自己的 pluginmgr(POST 127.0.0.1:9876/plugins,overwrite=true)
0.1.0 → 0.2.1 → 0.2.2,每次 config_kept=true;重启后 loaded/alive/心跳齐全,
插件日志「注册完成(15 个工具 + 1 个输出通道)」。

行为端到端(两封真邮件):
- 让模型列出通道 → 回信里列出 `email | text / file / image`,与注册的
  caps=7 及描述逐字一致
- 让模型「原样重复标记」→ 内核日志 `executing tool: output_send__email`,
  回流**恰好 1 封**,插件日志「模型已自行回复,跳过自动 relay」
- 让模型建文件并以 type=file 发送 → `cmd_run` → `output_send__email_help`
  → `output_send__email`,附件 chan-file-*.txt(15 字节)原样到达,relay 让位

这两条正是改造的两个动因:内核认得交付(循环被切断),以及附件走
「一个本地路径字符串」而不是 `attachment_ids=[...]` 数组
(opencode 上模型把数组写成 JSON 字符串、连试 6 次放弃整个任务的那个失败模式,
在结构上就不存在了)。
2026-09-12 17:38:32 +08:00
3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00
42816b4d1e fix(zcode): 真模型跑通后发现的三处缺陷(register / 工具活动日志 / SSE 关停)
真模型端到端(场景 A 通过:6893 事件、175 秒、530 字回信)把三处只有真跑才
暴露的问题照了出来:

1. **register 调不通**:驱动按 pi 的客户端 API 写了 `client.register()`,
   而本插件的 GatewayClient 没有这个方法 —— 靠此前手工注册过才没暴露。
   补上后才发现第二个坑:`/agent/register` 的认证与其它接口**不同**,
   它只认 `Authorization: Bearer` 或 **body 里的 `secret`**,不认 `X-Agent-Secret`
   头(其它接口认)。实测报错:
     HTTP 400 需要 Authorization: Bearer <密钥> 或 body 里的 secret
   所以没密钥时把 secret 放进 body。

2. **一轮 6893 条事件,日志里什么也看不见**:邮件驱动的会话没有界面,
   「模型正在干什么」只能来自日志,否则一个五分钟的回合与一个卡死的回合
   在外部完全一样。新增 `describeRunEvent`,只记工具调用与权限事件
   (全记等于没有日志),并由 runTurn 通过 onEvent 逐个交出来。

3. **关停没真断 SSE**:驱动调的是 `client.stopSSE?.()`,而客户端没有这个方法
   (`?.` 让它静默变成空操作)。改成持有 createSSEClient 的句柄并在关停时 stop。

验证:单元 325/325、授权桥 e2e 5/5、驱动 e2e(桩)7/7、快照握手 12 项。
2026-09-12 16:16:37 +08:00
6a7356ebe7 fix(zcode): 软链部署下入口静默不执行 + 部署脚本支持 zcode 快照
## 缺陷:入口判断不解析软链 → 生产形态下 main() 从不执行

`mcp/server.mjs` 与 `src/index.mjs` 都这么判断是否被直接执行:

    import.meta.url === `file://${process.argv[1]}`

而 ESM 的 `import.meta.url` 是**解析过软链的真实路径**,argv 是命令行里写的那个。
生产布局是软链(`/opt/agentmail/plugins/<name>/current` → 时间戳目录),
于是两者不等、`main()` 从不执行:**没有输出、没有报错、退出码 0**。

它是被部署脚本的后置验证抓到的:第一次从快照起 MCP 服务器时
「握手 0 个工具、stderr 一个字都没有」,而同一个文件从仓库路径跑完全正常
(仓库路径没有软链)。这类缺陷只在部署形态下出现,本地怎么试都对;
表现(静默成功)又与「功能没被调用」一模一样。

修法:新增 `lib/is-main.mjs`,**两侧都 realpath** 后比较(只归一 != 「同一文件」)。
单测含目录软链、文件软链、文件不存在(保守判否,避免被 import 时误跑一遍)。

## 部署脚本:引入 HOST 概念,四个插件一条部署路径

pi/opencode/dsh 的宿主是 systemd 单元,zcode 的宿主是 ZCode 应用本身 ——
没有我们的单元可重启。于是:

- `HOST=systemd`:重启单元 + 看网关库里有没有新心跳(原有判据)
- `HOST=zcode`:**从快照起一次 MCP 服务器并走完握手**,这与 ZCode 加载插件
  走的是同一份入口代码;另查 `plugins list` 报告的路径是不是 current

后置验证带**判据自检**:握手函数对坏路径必须返回 0,否则判据本身失效就拒绝通过。
计数用 `grep -o | wc -l` 而不是 `grep -c` —— 每条响应只占一行,tools/list 的
11 个工具名全在同一行,用 -c 会得到 2,把好快照判失败(实测踩过)。

## 生产已切到快照

`/opt/agentmail/plugins/zcode-mail-bridge/current → 20260912-150547`,
`~/.zcode/cli/config.json` 的 `plugins.dirs` 已指向 current,
`zcode plugins list` 报的路径就是快照路径。仓库不再是生产代码。

验证:单测 325/325;快照握手 12 个 name 字段;官方 __zcode-plugin-host 从快照
启动正常;钩子从快照跑通 409 → block;坏路径能被握手判据发现(反向对照)。
2026-09-12 15:06:49 +08:00
c5e1d562eb feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。

## --mode 是必传的(不传等于关掉授权系统)

ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。

档位映射(依据是 CLI 产物里的规则表,不是猜):
  plan → --mode plan    (mode.plan.nonReadOnly:非只读一律拒)
  workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
  full → --mode yolo    (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。

## 一轮怎么跑

  node <zcode.cjs> --prompt <提示词> --output-format stream-json \
       --cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N

用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。

## 回信策略(与另三桥同源)

- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
  「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
  CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
  刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
  对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
  与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
  收件箱里两封说同一件事的邮件,311 与 342 字节)。

## 两处健壮性(都是实现时自己发现的真问题)

- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
  而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
  现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
  (unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
  而既没有驱动看着它、也没有本地界面看着它。

## 自报强制力只声明得出来的事

驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。

## 验证

- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
  继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
  SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
  stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
  反向验证:让 workspace.js 分叉会被抓住)

## 我自己写错并被测试抓出来的三处(值得记)

1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
   报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
   → 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
   必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
   同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
   另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
2026-09-12 14:58:36 +08:00
4b129b5f49 chore(deploy): 同源清单纳入 ZCode 授权桥用到的 4 个共用模块
permission-mode / relay-key / permission-grants / sse-client 的
实现与测试一并同步 —— 档位语义与决策判定若分叉,「同意」在 ZCode 上
会悄悄变成另一种意思。
2026-09-12 14:10:44 +08:00
c774904c0c feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人
第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。

钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
  多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
  (MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)

档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。

钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。

fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。

「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。

共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。

验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
  同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
  plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
  非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
  mcp: plugin:agentmail:agentmail

我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
   只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
   最后超时。第一版还把这个超时误报成「拒绝路径通过」。
   现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
   验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
2026-09-12 14:09:10 +08:00
e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
2026-09-12 13:47:51 +08:00
9204f019a1 fix(deploy): 故障告警按 invocation 分会话 —— 崩溃循环时告警不再被护栏挡下
# 问题(实测)

服务反复崩溃时,故障告警**送不出去**。

链路:告警邮件带 `relay:"summary"`(走免预算通道),而服务端按
`AutoAliasFor(收件人, 主题)` 派生会话别名 —— **主题相同即同一条会话**。于是崩溃
循环下多封告警全落进同一条会话,而网关对会话内**连续**的中继邮件有
`maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起返回 403:

    POST /api/v1/mail/send ... - 403 245B        (网关 NRestarts=0,一直在正常服务)

报告只能落 spool,等下次 ExecStartPost `--flush` 才补投。实测 dsh 崩溃循环
(我自己的部署缺陷所致)时的六条告警全部 spooled —— 而**服务反复崩溃正是最需要
告警送达的时刻**。

# 修法

主题带上本次启动的 `INVOCATION_ID` 短号:`[dsh] 桥服务异常终止 #5ce6a845`。
主题变了,别名与会话随之分开,每次崩溃各自可投递。

**护栏不削弱**:它针对的是 agent↔agent 互相唤醒,不是同一个人收多条故障通知。
副作用是崩溃循环产生多条会话而非一条线程 —— 对"服务在崩"这件事,分开计数比合并成
一条更容易发现问题。

`INVOCATION_ID` 缺失时退回 `Date.now()+randomUUID()` 的哈希:宁可每次不同,
也不能因为拿不到标识而退回"主题相同 → 告警又被挡下"。

# 验证(三条,含两个反向对照)

  不同 invocation      → 主题各不相同(每次崩溃各自成会话)
  无 invocation(兜底) → 每次仍各不相同(不会退回同主题)
  同一 invocation 重报 → 主题相同(一次崩溃仍是一条会话,不刷屏)

注:第二次验证第一次跑时"失败",是因为**我的测试假设错了** —— 父环境里本来就继承了
`INVOCATION_ID`,两次跑用的是同一个 id。用 `env -u INVOCATION_ID` 才真正走到兜底分支。
2026-09-12 11:56:48 +08:00
c9209e4adc fix(deploy): 失败通知不再把所有失败都说成「Gateway 不可达」
# 误导的来源

`reportFailure` 只在 `result.sent` 上分叉,而 sent 为假同时覆盖「fetch 抛」「HTTP 4xx/5xx」,
于是两种情况都打印同一句话。

# 实测代价

切换插件时 dsh 进了崩溃循环(我自己的部署缺陷所致),通知脚本连打六条
「Gateway 不可达」并写进 spool —— 而网关**一直在正常服务**(NRestarts=0),
日志里真实响应是 **HTTP 403**:

    POST http://127.0.0.1:8180/api/v1/mail/send ... - 403 245B

403 的原因是同一会话连续中继邮件撞上防「两个 Agent 互相唤醒」的跳数上限
(`maxRelayHops=5`):告警邮件的会话别名由主题派生,崩溃循环下六封落进同一条会话。

一句话把人送去查网络,而问题在策略层 —— **故障通知本身给出误导性诊断,
是「静默失败」的另一种形态**。

# 修法

新增 `describeSendError`:`Gateway HTTP <status>: <body>` 归类为「网关可达,但返回
HTTP xxx(附响应体)」;只有 fetch 本身失败 / AbortError 才说「不可达」/「超时」。

双向验证(真实走代码路径,都不实际发信):
  网关指向关闭端口     → 「网关不可达:fetch failed」
  真网关 + 坏密钥      → 「网关可达,但返回 HTTP 401:{"error":"密钥无效"}」
2026-09-12 11:52:52 +08:00
cbe5ef5cec fix(deploy): 快照改整包复制 + 存活判据改看网关心跳 —— 修两个会毁掉生产的缺陷
切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。

# 缺陷一:白名单拷贝漏文件 → 服务直接起不来

第一版 staging 用白名单:`package.json lib src index.js dist`,漏掉了 dsh 的
`cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是「少个文件」而是**服务崩溃循环**:

    Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT

白名单的失败模式天生如此:**默认不带**,漏一个就等启动时炸,而那时旧版本已经被换掉。
改为整包复制 + 剔除明确不需要的(`test/`、`.git`、`node_modules/.cache`、`*.log`)。

# 缺陷二:存活判据写成了某一个平台特有的措辞

第一版后置验证 grep 日志里的「已接入」。那句话**只有 pi 与 opencode 会打印**,
dsh 启动时只输出 `dsh web: http://127.0.0.1:3080` —— 于是**一次成功的 dsh 部署
被判成失败**,脚本按设计回滚……回滚到了缺 cordis.patch.yml 的坏快照,
把 dsh 推进崩溃循环。

改为查网关库(平台无关,且是真的端到端):

    SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT'

**时刻必须用 UTC**:`agents.last_seen` 是 UTC(CURRENT_TIMESTAMP 语义),
而 `date` 默认给本地时间 —— 拿 11:44 去比 03:44 会永远为假,于是每次部署都被判成
「桥没连上」并回滚,**门禁主动破坏生产**。已用 `date -u`。

判据双向验证过:以「3 分钟前」为重启时刻 → 命中 1;以未来时刻 → 命中 0。

# 本次切换结果

  pi       /opt/agentmail/plugins/pi-mail-bridge/current/src/index.mjs
  opencode /opt/agentmail/plugins/opencode-mail-bridge/current/index.js
  dsh      /opt/agentmail/plugins/dsh-mail-bridge/current/dist/index.js

三处配置已迁移(备份在 /root/config-backups/pre-snapshot-20260912-113826/):
pi 的 unit、opencode.jsonc 的 plugin 项、dsh profile 的 link:(含 pnpm install 重建软链)。

验证:三桥进程**打开仓库文件数均为 0**;快照与仓库文件 inode 不同(独立副本);
四个 Agent 心跳新鲜;dsh 读 cordis.patch.yml 走的就是该软链(坏快照时它起不来,
好快照时它 active —— 这条是最硬的证据)。
2026-09-12 11:47:32 +08:00
0549975555 feat(deploy): 插件规范化部署 —— 生产跑仓库外快照,可回滚
# 问题(实测的加载形态)

    pi        systemd: node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
    opencode  opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
    dsh       profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"

三个桥跑的都是**仓库工作区**。于是:一次编辑 + 重启就是上线(没有构建、没有评审、
没有版本);**没有回滚目标**(网关有 `.bak-<时间戳>`,三个桥一个都没有);
`git checkout` / `git stash` / 半成品编辑会静默改变线上行为;仓库同时兼作构建目录
(`dist/`、`node_modules/` 都在里面)。

对照:homeagent 插件本来就是这个规范形态(跑 `/home/newqqagent/plugins/
homeagent-mail-bridge/plugin.bin` 部署副本)—— 所以这里不是发明新办法,
而是把已有的那个形态推广到三个 JS 桥。

# 本提交只交付工具与门禁,**未切换生产**

`deploy/redeploy-plugin.sh <pi|opencode|dsh> [--stage-only]`:

  1 前置断言 → 2 staging(仓库外)→ 3 门禁 → 4 原子切换 `ln -sfn <ts> current`
  → 5 重启 → 6 后置验证 → 任一步失败即切回 `.prev` 并重启

后置验证不只看 `systemctl is-active`:桥可能进程活着却没连上 Gateway
(密钥失效、Gateway 未起、依赖在惰加载时才暴露),所以**必须以日志出现
「已接入」为准**,45 秒轮询。

需要编译的插件(dsh)**产出到 staging**,不写仓库的 `dist/`:先在仓库构建再拷贝
会有两个问题 —— 失败的构建也会 emit(tsc 默认 `noEmitOnError=false`)导致仓库产物
被半成品覆盖;以及生产产物与工作区之间多一条看不见的耦合。

# 依赖门禁:不能用 require.resolve

`deploy/check-plugin-snapshot.mjs` 从入口递归收集静态 import/export/动态
import/require 的说明符并逐个解析。

**判据用 ESM 而非 CJS 解析**——这是实测教训:`@earendil-works/pi-coding-agent`
的 `exports` 只声明 `"import"`,`require.resolve` 抛 ERR_PACKAGE_PATH_NOT_EXPORTED,
而插件是 `import` 它的、实际毫无问题。用错 API 会让门禁**报假缺陷**,
而假缺陷比不检查更糟(会让人去修一个没坏的东西)。

也不能只比对 `package.json` 的 dependencies:pi 的 dependencies 是 `{}`,
而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的,只查声明等于空跑。

# 已验证(干跑,未切换、未重启)

  pi       20/20 说明符可解析          opencode 23/23          dsh 26/26
  dsh 经 tsc 构建到 staging 后通过

反向验证:拿掉一个依赖 → 门禁 rc=1 并点名该说明符(不是空转)。
2026-09-12 11:36:25 +08:00
4b1946ccb4 fix(dsh): 补共用库类型声明 + 测试前强制构建 —— 堵住两个会静默通过的通道
# 1. 缺 .d.ts 导致构建失败(上一提交引入)

`lib/attachment-ids.js` 是上一提交新增的共用库,但没配 `.d.ts`。dsh 桥走
TypeScript 编译,于是:

    src/index.ts(65,40): error TS7016: Could not find a declaration file for
    module '../lib/attachment-ids.js'

pi 与 opencode 不做类型检查,所以只有 dsh 会在这里红 —— 很容易被当成偶发放过。
补上声明,类型刻意写成 `unknown`:这个函数的全部意义就是接收**不可靠的输入**,
用 `string[]` 收窄签名会让人误以为调用方本来就该给对形状。

# 2. 测试从 dist/ 导入,却不先构建 → 可以测到改动前的旧产物

dsh 的测试有两类导入:`../lib/*.js`(直连源码)与 `../dist/index.js`(编译产物)。
test 脚本原本是纯 `node --test`,**不先构建**。于是「改 src → npm test 全绿」
完全可能只验证了旧 dist —— 实测就踩到了:提交前那次 361 通过跑的是改动前的产物,
`lib` 本身有覆盖(attachment-ids.test.mjs 直连 lib),但**入口的接线没被测到**。

加 `pretest: tsc -p tsconfig.json`(npm 会在 test 前自动执行)。

反向验证:往 src 注入一个类型错误后 `npm test` **EXIT=2**(构建先失败),
而不是拿旧 dist 蒙过去;恢复后 361 通过 / 0 失败。
2026-09-12 11:36:03 +08:00
b374ce1f20 fix(plugins): 附件 id 归一 —— 修 opencode「做完全部活却发不出附件」
# 现象(全功能演练抓到,根因来自 opencode 自己的内部记录)

opencode 把四个步骤全做完了(2× download_attachment、read 读到内容、
upload_attachment 成功),却在最后一步卡死:`send_mail` 连续 **6 次**失败,
然后放弃整个任务,自述为「attachment_ids 参数有框架级序列化 bug」。

真实形状(从 opencode 的 part 表里取出的原始输入):

    input.attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]"   ← 字符串
    error: 字段 "attachment_ids" 类型不对:期望 string 数组,收到 string

模型把数组写成了 **JSON 字符串**,桥原样转发,服务端的严格解码器按契约拒收。

# 修在哪一层

**不在服务端放宽。** 那个「严格」是刻意的,挡的是字段名拼错、结构写错这类真
错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 200)。

**在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠,而适配器的职责就是把
不可靠的输入归一成契约要求的形状。对模型宽容、对服务端严格 —— 这与
homeagent 那个 Go 插件里的 `stringList` 是同一个判断(那边注释写着「也接受
单个字符串……拒绝它只会换来一次重试,而意图毫无歧义」)。

接受的形状:数组 / JSON 数组字符串 / 单个 id / 逗号或空白分隔 / 混进 null
与数字时丢掉坏的保留好的。空串与 null 一并丢掉,与服务端
`parseAttachmentIDs` 保持一致。

# 三桥同源

新增共用模块 `lib/attachment-ids.js` + 同名测试,已加入
`deploy/check-shared-libs.sh` 的两个清单(实现与测试都必须逐字节相同 ——
只同步实现不同步测试,等于允许一侧偷偷放宽约定)。
三份 md5 一致,检查脚本通过。

# 测试

`test/attachment-ids.test.mjs` 17 条,三桥各一份。含**反向对照**:把 JSON 字符串
分支去掉后必须变红(实测 3 条失败)—— 否则这条判据就是空转,事故会复发。

全量:pi 401 / dsh 361 / opencode 312,0 失败。
2026-09-12 11:20:13 +08:00
0b5fabfd49 fix(repo): /api/v1/agents 带出 mode_enforcement —— 列表接口静默少了一个事实
# 现象

`/api/v1/agents` 对全部四个 Agent 返回 `mode_enforcement: ""`,
而库里四个值一直是正确的(pi=native / dsh=partial / opencode=advisory /
homeagent=advisory)。这是全功能演练 Phase A 的前置检查抓到的。

# 根因

`ListAgents` 的 SELECT 里没有 `mode_enforcement`,Scan 自然也没扫它。

这与「SELECT 加了列但没加进 Scan」(列数不匹配,直接报错)**不是**一回事:
少取一列不报任何错,只是安静地少一个事实。而「这个 Agent 到底能不能真的拦住
危险操作」是使用者在派活前必须知道的事 —— 缺了它,advisory 档会被当成 native
档用。

# 修法

SELECT 补上 `COALESCE(NULLIF(mode_enforcement, ''), 'advisory')`,与
`permission_mode.go` 同一套兜底(那里也这么写,说明空串确实可能出现)。

# 测试

`repo/agent_mode_list_test.go`:三档各一条 + 一条空串(脏数据),断言**值真的
传出来**而不是「函数没报错」;另加反向对照确认行真的被查到了(避免一个都没查到
也能过)。

反向验证过判据非空转:把修复退掉,测试立刻失败。
2026-09-12 11:19:52 +08:00
a696b2a141 fix(handler): 回信的 to_workspace 从会话 workspace 继承 —— 修「每封邮件多一条会话」
# 现象(生产实测)

在 DSH 界面上观察到的:每处理一封邮件就多出一条独立会话。

# 根因

`to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,而它取的是
**地址里的 path 位**。Agent 之间的回信、以及人在对话页点回复,地址里通常没有
path 位 —— 平台下发的 `reply_address` 就是这个形状(`FormatAddress(replyTo, "", alias)`)。

空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在一个
不同的空目录**里;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件
就多出一条未分组会话」,而模型在那个空目录里什么项目文件也看不到。

实测取证:
  - 线上 5 个兜底目录 `~/.dsh/mail-sessions/mail-*` **全部是空的**(0 条目)
  - 全天 journalctl 里**没有任何**相关告警(代码用的是 `ctx.logger.warn`,
    而同一文件别处明确写着 DSH 的 logger 不进 journalctl)→ 完全静默
  - 走兜底的那条会话(8e982e96)里,`dsh → opencode` 那封 `to_workspace` 有值,
    而 `opencode → dsh` 的回信 **to_workspace 全为空** —— 而该会话自身的
    `sessions.workspace` 一直是有值的

# 修法

会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来就是
回给**那条会话**的,而那条会话知道自己属于哪个项目。规则抽成纯函数
`resolveToWorkspace(addrPath, sessionWorkspace, toIsHuman)`:

  1. 地址里写了 path → 照用(人的明确意图优先)
  2. 没写且收件方是**人** → 保持空(人没有工作目录;填了前端会拼出
     `gui-lab@/path.别名` 这种错地址,ToHuman 字段就是为此加的)
  3. 没写且收件方是 Agent → 用会话的 workspace

**改的是 `to`,不是只改建库那一行**:同一个值还进投递载荷(`to_workspace` /
`self_address`)。改一处另一处不改,会出现「API 读到的与插件推到的不是同一个
目录」——那正是本项目一直在治的静默不一致。

`notify/mail.go` 只加了一段注释说明 reply_address 的 path 位为何**刻意留空**
(它的语义是「**发件人**该在哪儿干活」),免得后人以为那是漏填。

# 测试

- `handler/toworkspace_test.go`:6 条规则用例 + 1 条**反向对照**
  (固定其他输入只翻转 toIsHuman,要求结果必须不同 —— 防止该参数被忽略后
  「给人也填 path」静默回归)
- `repo/session_workspace_test.go`:锁住**列名与真实 schema**。这个查询读不到时
  按设计返回空串,与「这条会话没有工作目录」无法区分 → 列名写错的功能表现是
  「看起来还在跑,只是工作目录永远继承不到」
2026-09-12 11:19:19 +08:00
c19eea5e3c feat(webui): 真正动可见层的现代化 —— 字号、层次、间距、分隔线
# 起因:上一轮的「现代化」基本不算现代化

用户指出「我说的是 webui 现代化」。回看上一轮,我交付的其实是**底层改进**:
圆角加大一档、自定义滚动条、焦点环、过渡、reduce-motion、令牌与可访问性。
这些都对,但**可见变化几乎只有圆角** —— 界面看起来还是老样子。

实测数据确认了「老」在哪:

  text-xs(12px)  172 处   ← 被当正文用
  text-[10px]     84 处
  text-[11px]     68 处
  text-[9px]      19 处   ← 现代显示器上基本读不了
  text-sm(14px)   69 处
  text-base(16px)  4 处

  57 处 border-b + 21 处 border-r,其中 67 条是 border-gray-200 的硬灰线

  阴影:全站共 10 处,且全是 Tailwind 系统默认档;
        index.css 里 --shadow-1/2/3 三个语义令牌**定义了但零处使用**

所以真正的病因是三条:**字太小、层次为零、硬线切分**。

# 改动

## 1. 字号体系抬一档(tailwind.config.js)

不用 Tailwind 默认档,重定为:

  3xs 11px(角标下限,取代 9/10px 魔法数字)
  2xs 12px(元信息,取代 11px)
  xs  13px(次要正文,原 12px —— 拿它当正文的地方自动变舒适)
  sm  14px(正文)
  base 15px

并把 171 处裸 px 类名(text-[9px]/[10px]/[11px])统一换成令牌 ——
顺带消除魔法数字。行高一起给:小档位 1.35/1.45,正文 1.55,
只放大字号不放行高会把密排列表顶得很难看。

## 2. 把层次接出来(原本是死代码)

tailwind.config.js 新增 boxShadow 映射 `--shadow-1/2/3` + 新增
`--shadow-panel`(横向偏移 + 大扩散,竖向几乎不偏移,否则全高面板像浮在半空)。

用于:列表面板(lg:shadow-panel,**同时去掉 border-r 硬线**)、
登录/初始化卡片(shadow-sm → shadow-2 + 去硬边框)、
地址自动补全下拉(shadow-lg → shadow-2)、窄屏滑入详情面板
(shadow-2xl → shadow-3 + 去 border-l)、主题分段控件的选中滑块。

深色下层次比浅色更难感知,所以 --shadow-panel 在深色里更实一些;
深色里靠边框分组几乎看不见,层次是**唯一**有效的分组手段。

## 3. 分隔线软化(改令牌而不是改 67 处类名)

`--c-gray-200` 浅色 229 231 235 → 234 236 241,深色 44 49 59 → 39 43 52。
改在令牌上,67 条边框 + 8 处底色一次性生效且不会漏。

**刻意没有一起调 gray-300**:它同时是滚动条滑块色,调淡会让滑块更难看见。

## 4. 配比放宽(列表行的呼吸感)

MailList:行内距 px-3 py-2.5 → px-3.5 py-3,列表 gap space-y-0.5 → space-y-1,
表头 py-3 → py-3.5。未读主题字重 medium → semibold,已读 gray-500 → gray-600。

## 5. 量出来的两个真实对比度缺陷(不是估算)

新增 `test/manual/modernization-verify.mjs`,用真实渲染做四条判据。
它量出浅色下两个 WCAG AA 不达标(阈值 4.5:1):

  - 会话别名 `text-blue-500` 白底 3.68:1(别名在 mail list / thread / mailview
    共 4 处,都是 11px 小字)→ 改 blue-600/700,达 5.17:1
  - 时间戳 `text-gray-400` 压在选中行淡蓝底 `bg-blue-50` 上 4.44:1

第二个的**根因是调色板缺一档**:浅色下 `--c-gray-400` 与 `--c-gray-500`
完全相同(都是 107 114 128),于是「比次要文字再深一档的中间色」根本不存在,
时间戳无处可退。拉开 gray-500 → 90 98 112(5.65:1),并把 5 个列表组件的
行内元信息(19 处)从 gray-400 提到 gray-500。

# 验证

- typecheck 干净
- 前端全量 `npm test` EXIT=0(markdown-xss / narrow-layout / theme 30 /
  background 15 / vitest 216)
- **真实渲染** `modernization-verify.mjs`:浅色 8/8、深色 8/8,判据含
  最小字号 ≥ 11px(改造前 9px)、邮件正文 ≥ 14px、列表面板真有 box-shadow、
  gray-200 是软化值、40 处正文对比度全部达标

# 我自己的三处错(都被这次的度量拦下)

1. **判据量错对象**:第一版拿「收件箱列表」要求 40% 元素 ≥13px,量出 39.7%
   判失败 —— 而收件箱本质是元信息密集区,发件人/时间/别名本来就该小。
   改成量真正该达标的**邮件正文**(≥14px)。
2. **探针忽略 alpha**:`parseRgb` 把 `rgba(239,246,255,0.4)` 的 alpha 丢掉当实色,
   于是把淡蓝底当纯蓝算出 4.44:1 的假缺陷。改为按画家算法合成整条背景链。
3. **config 注释换算写错**:3xs 注释写 10px,0.6875rem 其实是 11px。
2026-09-12 09:54:31 +08:00
0f379a2ca0 fix(static): 给前端加缓存策略,修掉「换了新前端但用户仍看到旧界面」
# 起因

用户问「webui 更新了吗」。实测三个入口(本机 / LAN / 公网 mail.jianfgit.xyz)
服务的都是同一份新构建(`index-DUb2s9Ly.css`,DOM 里有 `.app-backdrop`,
`--radius-card` 已生效)—— **确实已更新**。但响应头显示:

    HTTP/1.1 200 OK
    Content-Type: text/html; charset=utf-8
    Vary: Origin
    (没有 Cache-Control)

入口页没有任何缓存指令 → 浏览器走启发式缓存,可能长期使用旧的 HTML。
而 Vite 给资源按内容加哈希,**新构建生成新文件名**:旧 HTML 引用旧文件名,
于是整站被钉死在那一代资源上。这类故障没有任何报错,只有人肉硬刷新才能发现,
而且每次部署都会重演一次。

# 修法:区分两类资源,而不是一刀切

  - **入口页 `no-cache`**(不是 `no-store`):可以落盘,但每次必须先回源确认。
    它只有 ~2KB,回源代价可忽略,而它决定了用户拿到哪一代资源。
  - **带内容哈希的 `/assets/*` 永久缓存**(`max-age=31536000, immutable`):
    内容变了文件名就变,不存在「缓存了旧内容」的问题,连回源都不需要。
  - **不带哈希的资源 `no-cache`**:`STATIC_DIR` 指向开发目录时文件名可能没有哈希,
    给它们 immutable 会让改动永远不生效 —— 那比缓存旧资源更难查。

哈希判据(`-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$`)刻意**不宽松**:只有真正像
Vite 产出的内容哈希才配 immutable。`short-ab12.css` 这种(哈希不足 8 位,
更像版本号或缩写)按无哈希处理。

# 测试

`internal/static/cache_test.go` 10 条路径判据 + 3 条响应头断言,含两组
**反向对照**:
  - 带哈希 → immutable,无哈希 → no-cache(证明判据有区分力,不是恒真)
  - 入口页必须是 `no-cache` 而**不是** `no-store`(后者连磁盘缓存都不用,
    每次全量重取)

# 验证

- `go vet` 干净;`go test ./... -count=1` 全量通过(新增 internal/static 用例)
- 部署后线上实测三处响应头:
  - `/` → `Cache-Control: no-cache`
  - `/assets/index-DUb2s9Ly.css` → `public, max-age=31536000, immutable`
  - `/assets/agentmail.svg`(无哈希)→ `no-cache`
2026-09-12 09:13:56 +08:00
0997d441af docs(build): 安装包嵌的是前端快照 —— 前端改动后必须重打,并给出自查命令 2026-09-12 08:10:27 +08:00
84c1d749cd feat(webui): 自定义背景 + 外观现代化;修正实心按钮白字在深色下的对比度
# 自定义背景(新功能)

三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。

**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(50–300)在深色下本来就是暗的(见 .dark 与 theme.test.mjs 第 19 条),
于是浅色得到柔和 pastel、深色得到低沉暗调,不需要维护两套渐变,也不会
出现「深色模式下原样落下浅色渐变」这类绕过主题变量的错误。

图片路径的关键取舍:
- **先压缩再存**。手机直出照片 4–8MB,而 localStorage 配额约 5MB,直接写会抛
  异常,用户看到的是「选了图片没反应」。等比缩到最长边 2560px、转 JPEG;
  仍超限则再缩一档;再不行就**明确拒绝并说明原因**(不是静默失败)。
- 失败一律返回 `{ok:false, reason}` 并渲染成 `role="alert"`。

# 背景层为什么不放进主题 store

主题(light/dark/system)是必须全局一致的语义;背景是纯装饰偏好,取值空间
与主题毫无关系。混在一起会让「跟随系统」的实现被背景字段淹没。

# 背景层实现在 CSS,不改 27 个组件

按 Tailwind 生成的实际类名统一接管:背景开启时让出不透明的页面底
(body / bg-gray-50 / bg-slate-100 → 透明),并把卡片(bg-white)与框架
(bg-chrome-800/900)变成半透明 + 背景模糊。

逐个组件加 class 必然漏 —— 漏掉的那块就是一张不透明卡片浮在背景上。
这段 CSS **刻意放在所有 @layer 之外**:它要覆盖的正是 utilities 生成的
`.bg-white`,写进 @layer components 会被 utilities 压过去(静默失效),
而分层 CSS 恒输给未分层 CSS,这是唯一稳定可靠的位置。

**chrome-600/700 刻意保持不透明**:它们不是大面板,而是导航项与 15px 的
计数徽标。真实渲染量得半透明会把徽标上的数字压到 4.46:1,低于 AA 4.5 ——
小控件的可读性优先于装饰效果(已用脚本量出,见下)。

# 「跟随系统」的可见性

三态本来就已实现(system 为默认值 + matchMedia 监听)。这次做的是让它可被
发现与信任:选择器改成分段控件(role=radiogroup + aria-checked),说明文案
写清「跟随系统会随系统的深色开关自动切换」,并保留单选按钮入口的
「当前跟随系统:深色/浅色」提示。

# 外观现代化

- **圆角整体调大一档**(默认 0.25→0.5rem)。原值是几年前的紧凑风格,
  在宽屏桌面应用上偏硬。只改比例尺,200 处圆角一次性刷新,不产生
  「新组件大圆角、旧组件小圆角」的断层。
- 语义化圆角令牌:`rounded-card` / `rounded-control`(数值档位答的是「多大」,
  这两个名字答的是「用在哪」)。
- 自定义滚动条(桌面应用里常驻可见,系统默认样式偏旧)。
- 键盘焦点环(`:focus-visible`,仅键盘导航时出现;可访问性硬要求)。
- 交互元素统一过渡;并尊重 `prefers-reduced-motion`。

# 顺带修正两处真实问题(都由真实渲染量出,不是估算)

1. **实心按钮白字在深色下 4.46:1,低于 AA**。
   深色 `--c-on-accent` 是「近白」244 246 250(为了不刺眼),而结构检查第 23
   条只拿**浅色**的纯白 255 去算 → 4.83 通过。**测试存在盲区**:
   同一个实心底,白字换暗一点点就越过 AA 线。导航未读徽标「12」正是这个组合。

   两处都修:把第 23 条改成**两种模式的 on-accent 都算**(闭合盲区),
   并把深色 on-accent 抬到 250 250 252(4.65:1,仍非纯白,保留原初衷)。

2. **theme.test.mjs 切颜色块的方式很脆**:它用 `indexOf('.dark')` 切片,于是在
   :root 的注释里写一句带点的选择器写法就会把浅色块提前截断(我加注释时
   真的踩到了,第 8 条假失败)。更危险的是反向情形:块被截短后变量集合变小,
   「覆盖齐全」这类断言可能**真空通过**。改为所有块切分都基于**剥注释后**的文本。

# 测试

- 新增 `test/background.test.mjs`(15 条结构检查):遮罩两主题各一份、
  背景层必须负 z-index(0 会盖住界面)、背景开启时必须让出页面底、
  玻璃化只在 data-bg=on 下、悬停态一并接管、预设复用调色板变量、
  图片上限与失败原因存在、尊重 reduced-motion 等。
- 新增 `test/stores/background.test.ts`(16 条):脏数据归一化(未知预设、
  kind=image 却无图、越界数值)、CSS 变量写入与清理成对(残留 --bg-image 会
  让「关掉背景」后仍显示旧图)、localStorage 抛异常不打断操作。
- 新增 `test/manual/background-verify.mjs`:连真实 Chromium 验收**渲染结果**
  (背景层是否真的可见、玻璃化的计算样式、正文在背景之上是否仍达 WCAG AA、
  自动模式在**不刷新**页面时跟随系统切换、显式选择不被系统覆盖)。
  它拦住了上面两个真问题,也拦住了我自己两次写错的判据。

# 验证

- typecheck 干净
- 主题 30/30、背景 15/15、vitest 216/216(新增 16)
- 真实渲染验收 23/23(AGENTMAIL_DIST 注入本地构建 + 活 Gateway,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
2026-09-12 08:02:30 +08:00
dc7bf57ceb fix(calendar): 农历提醒按本地公历日推进,修正凌晨跨 UTC 日期错一天
# 现象

全量服务端测试稳定失败:

    --- FAIL: TestStaleLunarRecurringDoesNotFlood
        calendar_test.go:869: 农历日从 21 变成 20

不是随机失败,也不是测试写错 —— 是产品逻辑的真实缺陷。

# 根因

SQLite 的 DSN 带 `_timezone=UTC`(为了让 `expires_at > NOW()` 这类字符串比较
同一时间轴,见 db.sqliteDSN 的注释)。因此从库里 Scan 出来的 `event_time` 是
UTC 时刻的表示。

对公历重复规则,这无关紧要 —— `AddDate` 操作的是同一时刻的另一种表示。
但**农历换算直接读取 Year/Month/Day**:

    本地 2025-09-12 07:00 (+0800) → 存库 → 读出 UTC 2025-09-11 23:00
    农历(本地) = 七月廿一        → 农历(UTC 字段) = 七月二十   ← 少一天

后果:在本地时间 0:00–8:00(+0800)创建的农历提醒,之后每次推进都按前一天
计算,日期永久偏一天;而且只有等到下一次该提醒时才暴露,没有任何报错。

# 修法

在 `AdvanceRecurrence` 里,仅对两条农历规则把 event_time 转回 `time.Local`
再交给 `NextOccurrence`。

只转农历规则而不是无条件转:公历规则不需要,且 UTC 与 Local 表示同一时刻,
`AddDate` 在两者上结果相同 —— 无条件转会掩盖「DSN 时间是 UTC」这个事实,
让后来者更难判断该在哪一层做时区处理。

# 测试

新增 `TestAdvanceRecurrenceLunarUsesLocalCalendarDay`,用**固定日期**
(2025-09-12 07:00 本地)而不是 `time.Now()`,因此任何时刻跑都稳定;
并且它先断言测试前提成立:

  - 库里读回的时刻确实与输入跨了不同公历日
  - 直接按 UTC 字段做农历换算确实会得到不同的农历日

前提不成立就直接 Fatal —— 否则这个用例可能在某个时区/时段下变成永远通过的
空壳(那正是它要防的那类假绿)。

# 验证

- 新用例与原有的两条农历用例 ×10 连跑全绿(`-count=10`)
- `go vet ./...` 干净;`go test ./... -count=1` 全量通过
- 修复前该用例 5/5 失败,修复后 10/10 通过
2026-09-12 08:01:59 +08:00
dacf6c0e1f feat(client): 打通 Electron 安装包打包,并留下构建文档
# 背景

Electron 桌面安装包一直没打出来过(`release/` 为空,只有 web bundle)。
这次把它跑通,并验证了产物本身而不只是"文件存在"。

# 改动

**package.json 补齐 electron-builder 需要的元数据**(缺哪个就会让某个 target
直接失败,而报错不一定指向字段本身):

| 字段 | 位置 | 不填的后果 |
|---|---|---|
| `description` | 根 | deb 描述为空 |
| `author`(含 email) | 根 | deb 缺 maintainer |
| `homepage` | 根 | **deb 直接失败**:`Please specify project homepage` |
| `desktopName` | 根 | 窗口无法与 .desktop 关联(缺 `StartupWMClass`) |
| `linux.syncDesktopName` | `build.linux` | 同上 |
| `linux.synopsis` | `build.linux` | deb 描述只有一行 |

**新增 `client/electron/BUILD.md`**:记录构建命令、本机两个坑(见下)、
元数据清单、两个产物的实质区别、以及验证产物的方法。

# 两个产物(已在 release/,被 .gitignore 排除)

- `AgentMail-0.1.0.AppImage` 122MB,有效 x86-64 ELF、可执行位已设
- `agentmail-web_0.1.0_amd64.deb` 100MB,Maintainer/Homepage/Depends/两行 Description 齐全

**实测的实质区别(不是猜测,来自解包对照)**:

| | AppImage | deb |
|---|---|---|
| `.desktop` Exec | `AppRun --no-sandbox %U` | `/opt/AgentMail/agentmail-web %U` |
| Chromium 沙箱 | **禁用**(squashfs 无法保留 setuid 的 chrome-sandbox) | **启用**(postinst 按能力 `0755` 或 `4755`,并装 AppArmor 配置) |
| 卸载 | 删文件 | postrm 清理 alternatives / AppArmor / desktop-mime 库 |

结论写进文档:**对外分发优先 deb**。

# 验证(不只查存在性)

- AppImage:`--appimage-extract` 解包成功;`resources/app.asar` 8.5MB;
  asar 清单里 `/dist/index.html`、`/dist/assets/{index,CalendarView}.js`、
  `/dist/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}`、
  `/electron/{main,preload}.cjs` 齐全;`index.html` 里 DOCTYPE 仍是大写
  (即格式化器修复也进了包)
- deb:`dpkg-deb --info/--contents` 核对元数据与布局;7 档图标尺寸齐全;
  读 postinst 确认沙箱策略与 AppArmor 安装

# 环境坑(已写进 BUILD.md)

**本网络下 `SHASUMS256.txt` 不可达**(直连 20s 超时、走代理也失败),
而 electron 构件本身 0.8s 就拿到(HTTP 206)。`@electron/get` 即使命中缓存
也会取校验文件 → 构建卡 10 分钟后失败。用命令行覆盖绕过:

    npx electron-builder --linux -c.electronDownload.isVerifyChecksum=false

**刻意不写进 package.json** —— 那会让今后每次构建都跳过完整性校验。一次性绕过
网络限制不该固化成永久弱化的默认值。代价已写进文档(关校验后可被篡改镜像在
TLS 之外替换构件)。

# 待用户确认的占位值

- `author.email` 用了仓库自身的 git 身份 `jianf@noreply.localhost` —— 容器占位邮箱,
  **不是真实联系地址**。项目里没有可用的真实邮箱,我没有编造一个。
- `homepage` 用 git remote 的唯一真实地址(内网 Gitea `192.168.2.106:3000`)。

两者对外分发前都应替换。
2026-09-12 01:17:49 +08:00
a60ab66a40 refactor(plugins): 删掉「摆了一套权限规则却没人调用、且形状没人能消费」的死代码
# 问题

`lib/permission-mode.js` 里的 `opencodePermissions(mode)` 实现完整、注释详实
(含 6 条实测结论)、还有 9 条测试把行为钉住;opencode 的 `index.js` 第 45 行
确实 import 了它 —— 然后**全文件再没有第二次出现**。

静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为
「opencode 的 plan 档由这套规则拦着」。实际 opencode 是 advisory。

比 9.17(给了按钮不实现)更深一层:那只是没实现,这个是**看起来像实现**。

# 它不只是没接上,而是接不上

查 1.18.29 的 SDK 类型定义,三条都排除:

  SessionCreateData.body 只有 { parentID, title };query 只有 { directory }
      → 会话级根本没有 permission,也没有 agent
  permission 只存在于 Config / AgentConfig,且是 map 形状
      → { edit, bash, webfetch, doom_loop, external_directory } → ask|allow|deny
  Permission 事件(permission.updated 的 properties)没有 action 字段
      → { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }

而函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**,
并且 deny 了 `task`(本版本 permission 的合法键里没有 task)。

所以不是「加一行调用就生效」,而是**输出没有任何消费者**。

# 为什么 opencode 的 per-session 强制做不到(如实说明)

Config / AgentConfig 是配置文件级(全局或项目级)。邮件桥若靠改配置给某条会话
加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件
把人身兼的其他工作一起禁掉,不可接受。

opencode 目前唯一的拦截路径是「它自己先问 → 桥转发 → 服务端按档位 409 →
桥当场 block」,**前提是它的配置恰好是 ask**;若配置直接 allow,桥连
permission.updated 都看不到。这就是它只能是 advisory 的原因,不是缺工作量。

# 改动

- 删除 `opencodePermissions` 及其 doc 注释、`OpencodePermissionRule` 接口声明
  (三份共用库同时改,改后 md5 仍逐字节相同)
- 删除 opencode/index.js 里那行未使用的 import
- 删除 9 条针对该函数的断言(三桥各 9 条)
- **6 条实测结论没有丢** —— 搬进 `docs/PLUGIN-CONTRACT.md` 新增的 §9.18,
  连同上面那三条类型证据与「为什么 per-session 做不到」

删掉而非保留,是因为留下的就是陷阱:函数存在、注释写着「实测过」、
测试还全绿,唯一缺的是调用点 —— 下一个人会以为档位在这里被强制。

# 验证

- `deploy/check-shared-libs.sh` → 共用模块三方同源
- 三桥 `npm test` 全绿:pi 400 / dsh 360 / opencode 311,0 失败
  (删除前 pi 409 / opencode 320,各 −9 即被删断言;dsh 另有并发提交新增测试,
  净 −9 后为 360)
- `node --check` 四个改动文件全过;ESM 动态 import 该 lib 成功,
  导出里已无 `opencodePermissions`,index.js 只引用仍存在的符号
- 本改动不影响运行时行为(删的是一个从未执行的函数与一个未使用的 import)
2026-09-11 23:59:24 +08:00
4e32dd3145 fix(permission): Agent 不能把审批指派给与任务无关的人
# 漏洞

`POST /permission/request` 的 `to` 字段由 Agent 自由填写,服务端只检查
「这个名字是不是一个合法的人类用户」:

    decider := req.To
    if isHuman, _ := repo.IsHumanUser(ctx, decider); !isHuman { …回落… }

于是任何 Agent 都能把「是否允许执行 bash」这类危险操作的审批丢给**任意一个
与这条任务无关的人**(例如管理员)。被点名的人看到一封没有上下文的待办,
只能凭猜点头或拒绝。

这跟同一份代码里的另一段注释直接冲突。那段在论证为什么不把权限转给管理员:

    管理员对这条 Agent 链的上下文一无所知,既不知道这个 bash 命令在做什么,
    也不知道拒绝后 Agent 该怎么绕过去。

这个理由同样适用于「Agent 自己点名一个无关的人」—— 而且更弱:至少管理员还能
查日志,一个随机被点名的用户连从哪查都不知道。两处都指向同一条规则:
**权限应当追溯到最初分配任务的人**,也就是这条线索上的人。

这是静态审计发现的四项之一。当时三桥实测都不传 `to`,所以是潜在面而非活跃
漏洞 —— 但 `to` 是公开的 Agent API 字段,第三方插件照着文档填就会踩上。

# 修法

新增 `repo.IsHumanOnSessionThread(ctx, sessionID, name)`:人类身份 **且**
(会话 owner 或在这条会话的某封邮件里出现过)。

两个来源缺一不可,各有实测场景:
  - **只要参与方**会漏掉「会话由 Agent 建立、owner 由平台指派」的会话 ——
    那种 owner 可能一封邮件都没收发过,只看邮件会把合法 owner 判成外人,
    于是每次审批都回落到线索上随便一个人类。
  - **只要 owner** 会漏掉「人在别人的会话里被抄送进来说了话」这种正常协作。

采信与否的处置是**丢弃提示而不是报错**:`to` 只是一个偏好,丢弃后常规解析仍会
给出一个合法人类(owner 或线索上最近的人),实在没有就是既有的 409 —— 无论哪条
分支,都不会把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个
任务,而它并没有做错什么。因为丢弃是静默的,所以**必须留下日志**:

    [permission] 忽略不属于本线索的决策人 "jianf"(会话 …, 由 pi 指定)—— 改走常规解析

# 测试

补了这条路径此前**完全缺失**的两层覆盖(审计发现:决策路径
RequestPermission/DecidePermission/ListPendingPermissions 都没有测试):

- `repo/threadhuman_test.go`:白名单的六种输入(线索上发信/收信的人类、没发过
  邮件的 owner、线索外的存在用户、不存在的名字、空串、抄送方),每条都写清
  为什么期望这个结果。
- `handler/permission_request_test.go`:**真实 HTTP 层**跑 `RequestPermission`,
  断言响应里的 decider 与库里那封权限邮件的 to_name。repo 层 helper 正确但
  handler 漏调一次,漏洞就会回来,所以必须有端到端这一层。含纯 Agent 链的
  fail-closed 断言(不得退回管理员)。

# 验证

- `go test ./... -count=1` 全绿;`go vet` 干净;`gofmt` 差异行数与改动前完全
  相同(8 行,既有的一处空行)—— 即本次改动零新增格式问题
- 真机(workspace 档会话,owner=gui-lab,线索参与者 gui-lab+pi):
  - `to=jianf`(线索外人类)→ decider=gui-lab,库中 to_name=gui-lab,
    日志有忽略记录
  - `to=gui-lab`(线索内)→ 采纳
  - `to=pi`(Agent)→ 忽略,回落 owner
- 已部署(redeploy-gateway.sh 自动项全绿)
2026-09-11 23:22:53 +08:00
f11415834a test(dsh-mail-bridge): 断言真实注册的工具都带 type:object
上一个 commit 只断言了编译函数的输出;那个测试对一个**运行期** bug 是无效的:
bug 的本质是 defineTool 在 ESM 下静默降级,源码看着没问题、注册出去的是裸映射。

所以这里跑**真实的 apply()**,用假 ctx 截下 ctx.tools.register 的入参逐个断言 ——
源码怎么写都不算数,注册出去的东西才算数。

要点:
- 工具注册包在 ctx.effect() 里,假 ctx 必须真的执行该回调
- effect 里会起 SSE,故放子进程跑并在拿到结果后主动退出
- 断言前先确认注册数 >= 11,避免在空集合上假通过

实测该测试能抓住修复前的形态(parameters 顶层键就是 gateway_url/key_token,
没有 type/properties),并单独覆盖被上游拒过的 connect_to_server。
2026-09-11 22:28:47 +08:00
e9df62a565 fix(dsh-mail-bridge): ESM 下 defineTool 静默降级导致工具 schema 非法
现象:dsh 经 llmsproxy AUTO 走到 gozen 时 400:
  Invalid schema for function 'connect_to_server':
  schema must be a JSON Schema of 'type: "object"', got 'type: null'.

根因:插件 package.json 是 "type": "module"(ESM),而源码用裸
require.resolve / require 载入 @deepseek-ai/dsh-tools。ESM 里 require
是 undefined,require.resolve 抛 ReferenceError,被 catch { return opts; }
**静默吞掉** —— defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**
注册:

    { gateway_url: {type:'string'}, key_token: {type:'string'} }   // 

而不是合法形态:

    { type:'object', properties:{ gateway_url:…, key_token:… } }    // 

后果波及全部 11 个 mail-bridge 工具。严格的上游直接 400(实测 OpenCode Go),
宽松的(Claude 系)不校验 —— 所以只在特定 AUTO 档位暴露,表现为「某个模型
突然不能用了」。

修法:
1. 用 createRequire(import.meta.url) 取得合法的 require(ESM 标准做法)。
2. 兜底也必须产出**合法** schema —— 新增 parametersToJsonSchema,在
   dsh-tools 不可用时自己编译:required:true 提升到根级 required 数组
   (JSON Schema 不允许属性自带 required),type:object 必补。
   绝不再静默把裸映射发出去 —— 那比直接报错更难查。

实测 11 个 mail-bridge 工具全部产出合法 schema,包括真机被拒的那个
connect_to_server。测试 test/tool-schema.test.mjs 覆盖:裸映射编译、
required 提升、数组 items、空 spec、被上游拒过的实际工具。
2026-09-11 22:12:01 +08:00
bbddee26b9 feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
2026-09-11 22:02:25 +08:00
d50c55da4f fix(format): 关掉 pi-lens 的项目级自动改写;修复它被 prettier 改坏的 index.html
# 我上次的修复只做了一半

上一次(429149e)我加了根 biome.jsonc,把 biome 的 formatter 关掉,并撤销了它造成的
约 7000 行重排。那时我以为问题解决了 —— **没有**。

pi-lens 编辑文件后会「安全格式化」它,格式化器是**按扩展名各自挑**的
(FORMATTER_POLICY_BY_EXTENSION)。仓库里没有任何格式化器配置时它走 smart-default 回退:

    .ts/.tsx/.js/.mjs/.json/.css → biome
    .html                        → prettier      ← 这一条我漏了

biome.jsonc 挡得住 biome,对 prettier 完全无效。于是 a404cba(图标提交)里我改了
index.html 加 favicon,prettier 顺手把它改写了:

    -<!DOCTYPE html>          +<!doctype html>
    -classList.add('dark')    +classList.add("dark")     ← 单引号变双引号
    -<meta name="viewport" …> +多行折行

而 test/theme.test.mjs 恰好逐字符断言那一段里是 `classList.add('dark')`(单引号),
两条断言当场变红。**更糟的是我没看见**:验证时我用 `grep -E 'Test Files|Tests |FAIL'`
过滤输出,而这个测试的摘要是中文的(「主题:28 通过,2 失败」),被整行滤掉了,
于是我在提交信息里写了「196 项测试全绿」—— 一句不成立的结论。

(顺带厘清两起事故的元凶不同:19a3161 那次是 **biome**(tab 缩进是它的默认),
a404cba 这次是 **prettier**。我先前把它们当成同一个问题。)

# 根因级修法

不再逐个格式化器打补丁,而是关掉 pi-lens 对本仓库的改写路径。
项目级 `.pi-lens.json` 支持 format.enabled / autofix.enabled(文档
docs/settings.md 的 mutation controls),由当前目录向上查找,覆盖整个仓库:

    {"format": {"enabled": false}, "autofix": {"enabled": false}}

autofix 一并关掉:它同样会在与本次改动无关的行上动手。

biome.jsonc 保留(纵深防御,且它顺带关掉了 biome 的自动修复)。

# 验证

- 修好 index.html:DOCTYPE 恢复大写、viewport 回单行、内联脚本回单引号,
  与 19a3161^(未受污染的原版)逐字节一致,只多出 favicon 四行。
  实验证据:把原始版喂给 prettier,输出与 a404cba 里的版本**逐字节相同** —— 确认元凶。
- `node test/theme.test.mjs` → 主题:30 通过,exit 0
- 加注释后再编辑一次 index.html:单引号与 DOCTYPE 均未被改写(改写路径已断)
- 顺带在注释里写明「引号别统一成双引号,theme.test.mjs 逐字符断言」,防止后人"清理"
2026-09-11 22:01:56 +08:00
a404cbad54 feat(branding): 确定项目图标,并接入 Web / Electron / HarmonyOS
# 唯一源

`client/electron/src/icons/agentmail.svg` 是图标唯一源(24×24 视图框,`currentColor`
跟随文字色)。此前各端用的都是占位物:Electron 的窗口/托盘指向一个**不存在**的
`src/icons/tray-icon.png`(`nativeImage` 拿到空图,托盘不可见),
HarmonyOS 的 `startIcon/foreground/background` 是 1×1 PNG,
Web 端根本没有 favicon。

# 为什么带生成脚本

PNG/ICO 是二进制的,换一次配色要重出十几个尺寸,手工做必然出现
「Web 是旧的、Harmony 是新的」这种不一致,而且没人能复核。
`generate.py` 只认上面那一份源,所有变体都由它推导(本机无 rsvg/ImageMagick,
用 cairosvg + Pillow)。改图标只需改源文件再跑一次脚本。

# 各端产物

- **Web**:`public/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}` + `index.html` 引用。
  放 `assets/` 下而非根目录,是因为 Gateway 只把 `/assets/*` 与 `/` 交给静态处理器
  (`server/cmd/server/main.go`),放根下会 404。已实测本机与 LAN 均 200。
- **Electron**:应用图标 `icon.png`(512) / `icon.ico`(16–256) / 各尺寸 PNG /
  托盘 `tray-icon.png`(32),`package.json` 里 `win.icon` 与 `linux.icon` 指过去。
  托盘用品牌色字形而非白色 —— 浅色面板下白色会消失。
- **HarmonyOS**:`startIcon.png`(512) 用完整应用图标(启动页底色浅 `#FFF` /
  深 `#000`,白底蓝图标两套都立得住);分层图标的 `background` 是品牌色整块、
  `foreground` 是白色字形并留 12% 安全区,避免被系统圆角裁掉。
- **应用内**:新增 `BrandMarkIcon`(fill 型,与现有描边图标集不同族),
  替换登录页与初始化页品牌位的占位 `MailboxIcon`;后者已无引用,一并删除。

图标色 `#2563eb` 与门户 Dashy 主题主色一致。

# 验证

- 前端 typecheck 与 196 项测试全绿;`npm run build` 产物含三个图标文件
- Gateway 重新部署后 `/assets/{agentmail.svg,favicon.ico,apple-touch-icon.png}`
  在本机与 `192.168.2.60:8180` 都返回 200,Content-Type 正确
- 所有 PNG/ICO 用 Pillow 复核尺寸与 alpha 边界(合成失败会表现为全透明,
  已用 getbbox 排除)
2026-09-11 15:49:41 +08:00
4186ad4784 fix(setup): 首个管理员的创建只允许 Gateway 本机
# 之前的缺口

`POST /api/v1/setup/admin` 是公开路由,唯一的门是「系统还没有任何用户」。
Gateway 监听 `*:8180`,于是局域网里任何人可以绕开 nginx 直接调它。
本机已初始化时它只回 409,所以这条是纵深防御;但在**尚未初始化**的部署上,
它是「谁先提交谁成为管理员」——一个可被抢注的管理员入口。

# 为什么不是收紧监听地址

`.106` 上的反代(公网 `mail.jianfgit.xyz`)与本机鸿蒙客户端都直连
`192.168.2.60:8180`,把监听收到 127.0.0.1 会把这两条入口一起切断。
缺口在端点本身,不在监听面,所以只收紧端点。

# 改动

- 新增 `middleware.LocalOnly`:只有真实 TCP 对端为回环地址才放行。
- 新增 `middleware.CapturePeerAddress`,**注册在 `chimw.RealIP` 之前**。
  RealIP 会信任 `X-Forwarded-For` 并改写 `RemoteAddr`,直接读它等于让外部
  调用者用一个请求头冒充本机;所以先存原始连接地址,安全判断只认那份。
- `SetupAdmin` 的注释同步:本机限制在路由层,`NeedsSetup` 保留为第二道防线。
- 测试(`localonly_test.go`)按生产中间件顺序组装链,覆盖:
  IPv4/IPv6 回环放行、局网拒绝、**伪造 X-Forwarded-For 仍拒绝**、
  非法地址拒绝,以及未装 CapturePeerAddress 时 fail closed。

# 验证

- 回环 `/setup/admin` → 409(进入处理器,系统已初始化)
- LAN `/setup/admin` → 403
- LAN + `X-Forwarded-For: 127.0.0.1` → 403
- LAN `/setup/status` → 200(登录页判断是否显示向导仍正常)
- 登录 + 收件箱 → 200;Go 全量测试与 vet 通过;已部署,四桥/SSE 正常
2026-09-11 15:49:26 +08:00
4050827e5c feat(permission): 新增第三种强制力 partial —— DSH 如实自报,不再冒充 native
# 问题

审计发现 DSH 自报 mode_enforcement=native,而实测它的 Landlock 沙箱受内核 ABI
版本限制、拦截覆盖不完整(PLAN.md L5 自己写的就是 dsh = Landlock partial)。

只有 native / advisory 两个取值时,这个平台无论标哪个都是在说假话:
  - 标 native → 人会以为 plan 档是硬保证,把它当安全边界依赖;
  - 标 advisory → 又低估了它(确实在拦),而「平台无法强制」会让模型
    在本可依赖的边界上过度保守。
多一个取值比多说一句假话便宜。

# 改动

- Go models:EnforcementPartial = "partial",ValidEnforcement 接受它;
  NormalizeEnforcement 对显式自报值一律原样保留(partial 降级到任一极端都是假话),
  未知值仍然 fail-closed 到 advisory。
- 三桥共用 lib/permission-mode.js(逐字节同源):ENFORCE_PARTIAL +
  modeBriefing 三态措辞。partial 版必须同时做到两件事:
  说清「覆盖不完整」,并收回 native 那句「都会被平台拦下」的承诺
  —— 否则模型会以为越界一定被拦,于是不必自己小心。
- 前端 PermissionChip:三个点形区分(实心 / 靶心 / 空心)+ 三套 tooltip 文案;
  认不出的强制力按 advisory(与后端同方向)。
- DSH 插件心跳改报 partial。
- 顺带修正活跃 DSH 会话的历史快照:那批 native 是插件当时的**误报**,
  不是能力变化,因此把 status<>'archived' 的 dsh 会话改为 partial;
  归档会话按设计保留(不重写已结束的历史)。改前已 sqlite3 .backup 备份。

# 验证

- agents.mode_enforcement:dsh 由 native 变为 partial(心跳生效)
- Go 全量、三桥插件 320/362/409、前端 196 全绿(新增 PermissionChip 11 例)
- 三桥共用模块同源校验通过
- 关键判据:partial 的措辞与 native/advisory 两两不同,且不含「无法强制」
2026-09-11 12:04:06 +08:00