Commit Graph

110 Commits

Author SHA1 Message Date
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
429149e118 chore(format): 撤销误入提交的整体重排,并关闭本仓库的格式化器
# 发生了什么

pi-lens 内置「安全格式化」:它会自动安装 biome 并对**编辑过的文件**跑
`biome format --write`。本机原先没有任何 biome 配置,于是 biome 用它自己的
默认值 —— tab 缩进 + 双引号 —— 把文件整体重写。

我在 19a3161 那次提交里用了 `git add -A`,把这批与功能无关的重排一起扫了进去:
约 7000 行改动散落在 20 个文件上,使那次提交无法审查,还掩盖了
server/internal/handler/permission.go 的一处删行(实为文件末尾空行,无代码丢失)。

# 为什么是「关掉」而不是「配置成我们的风格」

试过把缩进/引号/lineWidth 全部对齐本仓库习惯(biome.json + space/2/single/
lineWidth 120):`biome format --write` 仍然改动 17 个文件。原因是本仓库从未按
biome 的规则排版过 —— 注释按语义换行、数组与调用按可读性手工折行,
这些无法由格式化器还原。也就是说只要格式化器开着,每次编辑都会产生与内容无关的
大面积 diff,把真正的改动埋掉。

因此 biome.jsonc 里 formatter 与 linter 都关闭:本仓库的静态检查由
tsc / go vet / tree-sitter / ast-grep 与各自测试套件承担,不引入会改动无关行的
自动修复。

(pi-lens 这一版把 format 服务的 enabled 硬编码为 true,没有配置开关,
所以只能在仓库侧用 biome 配置让它不动文件;已验证 `biome format --write`
对这些文件零改动。)

# 本提交内容

把 19a3161 里除「有意改动」外的 20 个文件还原到重排前的样子。
19a3161 中真正有意的改动是 deploy/install.sh 的扩展注册与
plugins/pi-mail-bridge/extension/index.ts 新文件,两者原样保留。

验证:Go 全量、三桥插件(320/362/409)、前端 196 全绿;
`biome format --write` 对还原后的文件零改动。
2026-09-11 12:03:51 +08:00
19a3161ee4 feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
2026-09-11 11:32:47 +08:00
1692c615c6 fix(dsh): session_update 在插件重启后仍能定位活会话(权限热更新不再静默失效)
sessionMap 是纯内存表,插件重启后为空。而 session_update(人在 WebUI 改权限
档位)原来只查这张表 —— 于是「插件刚重启 + 那条会话还没收到新邮件」时,
那条更新被静默忽略:人在界面上把 full 改回 workspace,DSH 运行时仍按 full 执行。
人以为自己收紧了权限,实际上没有。

邮件新建的会话 id 是确定性的(mail-<邮件会话 id>,模型降级重试时带 -r<i>),
所以重启后可以按前缀在活会话里定位,并顺手把 sessionMap/reverseMap 补回去
—— 不补的话这条会话后续的自动转发也会一起失效。

- lib/mail-session-id.js(dsh 专用,9 例测试):id 派生与匹配的纯函数。
  放宽成前缀匹配会把 mail-abcdef 误判成 mail-abc 的会话;非数字后缀
  (-retry / -rx)也不匹配,避免误改别的会话的档位。
- 接管会话(adopted)仍是例外:其 DSH 会话 id 由平台生成、推不出来,
  重启后无法热更新 —— 已知取舍;下次投递会按邮件里的 permission_mode 重设。
- tsconfig 清理:allowJs 原先写在根层(无效位置),移入 compilerOptions 后
  会让 lib/*.js 进入 TS 程序并违反 rootDir;这些模块已有 .d.ts 提供类型,
  该选项本就不需要,直接移除。

测试:dsh 358(新增 9)/ opencode 316 / pi 405 全绿。
2026-09-11 10:47:30 +08:00
f91efd2d8d feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
2026-09-11 10:44:18 +08:00
c401eb2da2 fix(bridges): SSE 跨分片保帧 + Last-Event-ID;pi worker 有界重投;systemd 故障上报;清理误提交二进制
三个平台桥原本各自手写 SSE 解析,有两个共同的静默丢事件缺陷:
  1. evt/data 是每次 read() 的局部变量 —— TCP 把一帧
     'event: x\ndata: {...}\n\n' 切在换行处时,前半段的 event 名被丢掉、
     后半段只剩 data,整帧静默丢弃。表现为「新邮件偶尔收不到」
     「权限决策点了没反应」,日志里一个字都没有。
  2. 重连不带 Last-Event-ID —— 断线期间的事件留在服务端 per-agent 环形
     缓冲里永远回放不出来(pi 与 homeagent 已正确使用,DSH/opencode 没有)。

修法:抽出共用 lib/sse-client.js(三桥逐字节同源,check-shared-libs 校验),
把「跨 chunk 保帧状态」与「Last-Event-ID 断点续传」写对一次。pi 桥的
gateway.mjs 也改为复用同一实现(保留 reconfigure 时清断点的语义)。

pi worker 丢任务:worker 未回报 done 就退出(SIGKILL/OOM/崩溃)时,
主进程原来只记一行日志就 pump() —— 那封邮件永远没有回音。改为按
1s/2s 退避有界重投(默认 3 次),到上限记「放弃」并可观测。

systemd 故障上报:四个宿主服务接入 service-failure-notify.mjs 的
ExecStopPost/--report 与 ExecStartPost/--flush。进程内 uncaughtException
捕获不了 SIGKILL/OOM,只能由 systemd 统一覆盖。正常 stop/restart 不发信。

仓库卫生:server/server(24MB 构建产物,f9d757b 误提交)移出版本库。

测试:opencode 302 / dsh 335 / pi 391 全绿(新增 12 例 SSE 帧解析 +
2 例 worker 重投);Go 全量通过;四平台重启后在线且无错误。
2026-09-11 10:27:41 +08:00
f9d757b5e5 chore: directory migration - gateway→server, web→client/electron 2026-09-08 19:16:35 +08:00
fd9f99a3f9 UI 修复: 回复页(ReplyBar)加往返预算配置入口
jianf 反馈:回复页面/对话页面/发件页面无法配置对话预算。
- 发件页(ComposePage):已有 maxRounds 输入框 
- 对话页(MailView 会话头部):已有 BudgetEditor 
- 回复页(ReplyBar):缺失 ← 本轮补上

ReplyBar 底部按钮区加预算徽标(点击展开编辑):
- 显示 已用/上限 来回 或「预算不限」,用尽时红色
- 点击 → 数字输入 + 保存/取消
- 复用 useSessionStore.setBudget(同一会话)
- 177 测试全过,已部署
2026-09-07 18:15:26 +08:00
83d3c2a51a docs: HMS-PUSH-PLAN.md 华为统一推送服务端集成方案 2026-09-07 17:00:06 +08:00
7278fcdf9a docs: MULTI-ACCOUNT-PLAN.md 多账号架构方案 v0.1(单一事实源)
dsh+pi 共同遵守的多账号设计文档:
- 账号数据结构(displayName/gateway/token/username)
- 聚合/单独视图交互(账号选择器)
- 发信账号选择
- SSE 每账号一连接架构
- 配置页 UI
- Gateway API 映射
- 安全要点(已审计)
2026-09-07 16:57:55 +08:00
fe96e197d3 pi-ele Phase 2: token/API 基地址注入 —— web/src 零改动成为独立桌面客户端
- main.cjs: 从 env 读 AGENTMAIL_GATEWAY_URL / AGENTMAIL_TOKEN(USER_KEY),
  通过 additionalArguments 传给 preload;API_BASE = <gateway>/api/v1
- preload.cjs: 从 process.argv 读注入参数,contextBridge 暴露
  window.__AGENTMAIL_API_BASE__ / __AGENTMAIL_TOKEN__
  → web/src 的 config.ts 自动读取,client.ts/sse.ts 零改动
- 效果:Electron 启动后 api.me() 带 Bearer 返回用户,直接进入主界面
  (无登录页),收件箱等功能立即可用

验证(xvfb headless):
- 注入链路:渲染进程拿到 apiBase=192.168.2.60:8180 + token 
- 端到端:gui-lab key 注入 → 界面呈现 收件/授权/发件/日历/联系 导航 + 空收件箱,
  无登录表单(inputCount=0, hasLogin=false)
2026-09-07 07:49:30 +08:00
dc5b9e440f pi-ele Phase 1: Electron 骨架(主进程+托盘+preload,可复用 web/src)
- web/electron/main.cjs: BrowserWindow 加载 dist/index.html(prod)或 vite dev server
  托盘(进托盘/退出/单击恢复),关闭按钮隐藏到托盘而非退出(用户要求)
- web/electron/preload.cjs: contextBridge 暴露 gatewayUrl/versions/platform
- web/package.json: 加 main 指向 main.cjs、dev:electron / build:win / build:linux 脚本、
  electron 44.2.0 + electron-builder 26.15.3 依赖、build 配置(NSIS/AppImage/deb)
- .gitignore: /release/

验证(headless + xvfb):
- Electron 加载 Gateway WebUI 成功(title=AgentMail, body 渲染)
- Tray API 可用,BrowserWindow 正常
- prod 模式真实 main.cjs 无报错
2026-09-07 07:46:07 +08:00
bf32369ebd docs/API.md: 补权限档位端点与字段(dsh 反馈的文档缺口)
dsh 在鸿蒙计划反馈中指出 API.md 未收录权限档位相关:
- PUT /sessions/{id}/permission 端点
- GET /sessions/{id} 返回的 permission_mode/permission_enforcement
- 发信请求体的 permission_mode 字段
- new_mail SSE 事件的档位字段

补:
- 会话端点清单加 PUT /sessions/{id}/permission
- 会话对象字段表(permission_mode 三档 + permission_enforcement native/advisory)
- 新增「权限档位」节:三档语义、新建/续谈均可设定、Agent 继承约束
- 发信请求体 JSON 示例加 permission_mode + 说明(两种情形都生效)
- new_mail SSE payload 示例补档位字段(含 from_human/to_human/in_reply_to)

注:后端只有 PUT 没有 GET /sessions/{id}/permission,文档已注明读取走会话详情。
2026-09-07 07:32:00 +08:00
dsh
314c3224ce docs: 鸿蒙计划补权限档位(plan/workspace/full)与SSE档位字段(据 pi API 梳理) 2026-09-07 07:28:39 +08:00
dsh
9cdf4b9e3f docs: 鸿蒙客户端(ArkUI)构筑计划 GUI-PLAN-HARMONY.md 2026-09-07 07:26:04 +08:00
89a4784c10 opencode+dsh: 空回复兜底 —— idle 但无 assistant 文本时回失败通知
缺口:模型 idle(轮次正常结束)但没有产出任何 assistant 文本时,
两个插件此前静默 return —— 发件人等不到任何回复也得不到交代。
(模型「全部失败」已有 renderFailureReport 兜底,但「跑完却没产出」
不等于失败,走不到那条。)

补:
- opencode relaySummary: if (!last) → 发一封「处理失败:无回复文本」通知
- dsh agent/status idle: if (!lastText) → 同款通知
- 都走 relay:summary 免配额 + relay_key 幂等
- 都只给人类来信发(Agent 间不自动转发)
2026-09-07 07:05:45 +08:00
be9f46cf73 dsh: resume 续谈也挂载 standard preset —— 修复旧会话没有文件工具
根因:startAgent 的 resume 分支 setup: undefined,注释说
「resume 从磁盘恢复,工具已在」—— 实际上工具是通过 setup 回调
presets.mount 注册的,resume 不传 setup 就没有任何文件工具。
presets.mount 修复之前创建的旧会话(setup:undefined 时代)从此
没有 read/write/edit/bash/glob/grep,用户反馈「dsh 无法看到工作区文件」。

修复:把 presets.mount 抽成 setupPreset 函数,create 与 resume 共用。
resume 恢复的是会话历史,不是工具注册 —— 两者必须都挂。

实测:旧会话 318f0703 resume 续谈后 setup 回调触发、mount 成功,
模型用 glob 列出工作区文件、read 读取、write/edit 可写,完整回复。
2026-09-07 06:31:19 +08:00
c440df6537 修复:收件箱 ListInbox 漏算 to_human 导致人类地址被拼上 workspace/session
根因:用户从收件箱点开邮件看到 。
收件箱路径 GET /me/mail/inbox 走 repo.ListInbox,它的 SQL 只算了
from_human,漏了 to_human(EXISTS users 子查询)—— 于是返回的
mail.to_human 恒为 false,前端 participantAddress 把人类 jianf 当成
Agent 拼成三段地址。

修复:ListInbox SQL 补 to_human 子查询 + Scan 补 &m.ToHuman。
这是 C 部分「人/Agent 区分」遗漏的最后一条路径(其余 mails 读路径
GetMailByID/GetSessionMails/GetSessionMailByID/ListSentBy 均已填)。

验证:GET /me/mail/inbox 返回 to_human: True,前端正确渲染 jianf。
2026-09-07 06:17:25 +08:00
7e9327a78b 发件页档位每次发信都能改(含续谈已有会话)
- me.go: SendMail 续谈时若显式传 permission_mode,也更新该会话档位
  (人是权限的源头,可以任改三档,不受继承约束)
- ComposePage: 权限档位按钮始终可用(去 disabled/opacity),不再只限 .new
- hint 文案:新建→'Agent 在这类任务里被允许动手的程度',
  续谈→'改了即刻生效(该会话的档位会更新)'
- 默认 workspace(不再从空字符串开始)
- sendMail 始终传 permission_mode(不再只限 isNewSession)
2026-09-06 22:00:33 +08:00
1759666d38 chore: L2 验证脚本 2026-09-06 21:37:13 +08:00
da67aae2dd PLAN.md: P5 前端档位选择器完成 2026-09-06 21:25:12 +08:00
be61724314 P5 前端:权限档位选择器 + 卡片徽标 + 对话页改档
前端完整实现:
- types: Session/HumanSession/Mail/Contact 加 permission_mode + permission_enforcement 字段
- api/client: sendMail 支持 permission_mode 参数;新增 updateSessionPermission API
- PermissionChip 组件:plan=蓝/只读, workspace=绿/目录内, full=橙/全权
  native 实心点=平台强制, advisory 空心点=仅提示;hover 显示 tooltip
- ComposePage: 新建会话时显示三档按钮(plan/workspace/full),传入 sendMail
- MailView: 会话头部加 PermissionEditor(点击徽标展开三档选择,点保存调 API 改档)
- WorkCard: 卡片底部与 BudgetChip 并排显示 PermissionChip(compact 模式)
- sessionStore: 新增 setPermissionMode action
- 177 前端测试全过,gateway 8 包全绿,已部署
2026-09-06 21:24:41 +08:00