Commit Graph

117 Commits

Author SHA1 Message Date
d09ef395b4 refactor(zcode): 删掉本地 MCP 服务器与整套执行门禁 —— MCP 已内置网关
## 删了什么

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

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

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

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

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

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

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

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

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

## detectModeEnforcement 重写

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

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

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

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

## 清单与 package.json

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

## 误删与自查

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

## 验证

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

## 后续

本目录现在只剩**邮件驱动**(SSE 订阅 → 起一轮 → 回信)与共用库。
若将来要在 zcode 侧恢复执行能力,需要重新设计门禁 —— 现有形状不能复用,
因为它的双进程模型随本地 MCP 进程一起消失了。
2026-10-02 14:14:06 +08:00
29ad8aa204 feat(mcp): 去 ZCode 影子 —— mcp/server.mjs 改为通用 MCP 服务
## 目的

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

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

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

## 改动

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

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

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

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

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

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

## 判据

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

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

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

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

全套 **402/402**。

## 端到端验收

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

## 未做

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

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

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

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

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

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

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

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

## 修法

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

## 变异验证

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

## 附带发现(未改)

`node --test test/`(目录级)会报一个 test:1:1 的幽灵 fail——
node 把 test/manual/(e2e 手册与证据 json)当测试目录递归了。
用 `node --test "test/*.test.mjs"` 跑即干净。manual/ 内容非自动测试,保留不动。
2026-10-02 11:52:34 +08:00
85de9ee87b feat(opencode桥): withScope 在非邮件轮次回落到 /tmp 默认会话 —— 配套 15e4fe9 的收严
与 dsh 同一形状:`reverseMap` 只在邮件投递时填 ⇒ 用户在界面里新开一轮对话时
查表必空 ⇒ withScope「取不到就原样返回」⇒ 不带 session_id ⇒ 服务端 403。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 判据

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

## 未改:pi / dsh / opencode

实测它们的裸奔已停止(pi 自 Sep 26、opencode 自 Sep 28,`[agent-scope]` 日志归零),
`getMailSessionId` 由 worker 闭包注入且只有一处装配点。dsh 待单独核。
2026-10-02 01:09:19 +08:00
f86f08c7dc fix(桥): read_inbox 在非邮件驱动会话上也得带 workspace —— homeagent 与 opencode 两处
同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
pi/opencode/dsh 的工具**只在邮件驱动回合里装配** ⇒ workspace 永远有值;
homeagent 的 15 个工具是 `registerTool` **全局注册**(webui/a2a/对话都能调),
opencode 是 `export default` 全局插件 ⇒ 非邮件驱动会话上查不到工作区
⇒ 拼不出 `&workspace=` ⇒ 服务端 400。

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

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

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

端到端:homeagent webui 一轮 `tool read_inbox result: {"content":[{"text":"收件箱为空。"
(修复前是 400);opencode 两条非邮件驱动会话均 status: completed,
"空"是正确的收窄结果(74 封全是 read/archived,unread=0),近 10 分钟 0 次 400。
2026-10-01 19:41:16 +08:00
dsh
b0c87192b0 fix(notify+四桥): ★ 投递通知带 parent_from(方向判据)—— in-reply-to-ignores-direction 转绿
为什么这次顺手修:网关部署门禁(redeploy-gateway.sh 跑全量 Go 测试)被
TestInReplyToCarriesParentSender 拦下 —— 那是 2026-09-28 判据先行的债,
死锁修复本身无涉,但不修它网关换不上去。已用 git worktree 在修复前的
HEAD(16bf474)上验证过该测试原本就红,不是本次改动引入。

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

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

红绿:
  · 服务端 TestInReplyToCarriesParentSender 修复前红(16bf474 实测)、修复后绿;
  · 四桥新增 3 条方向判据测试(别人发的 / 自己发的 / 未升级回退);
  · client/electron 聚合套件 cross-bridge-prompt 5/5 绿。
2026-09-30 17:30:27 +08:00
dsh
60012ede3d fix(homeagent桥): ★ read_thread 不传 offset 时 URL 拼成 /thread&session_id ⇒ 100% 404
症状:模型默认不传 offset ⇒ offset="" ⇒ scopeQuery("&") 拼出
  /agent/mail/{id}/thread&session_id=…
`&` 被当成路径的一部分,网关路由 /agent/mail/{id}/thread 匹配不上 ⇒ 404。

根因:2a5e3d7(09-14「四家桥的读端点也带上会话收窄」)只给 read_thread 拼错了
分隔符;其余四家桥走 `path.includes('?') ? '&' : '?'`,没踩到。

★ 网关日志里的单字符对照实验(09-30 10:14:47,同一 mail_id、同一 session_id 值、
相隔 0 秒的两条请求,只差分隔符):
  /thread?session_id=x  -> 401  86B  ← 已路由进 handler,只是鉴权没过
  /thread&session_id=x  -> 404  19B  ← 从未匹配到路由
同一 session_id、同样缺 workspace,唯一变量是 ? / &。09-29 的三次 404
(20:21:12 / 20:58:14 / 21:44:42)URL 形态一致,且**不伴随** [agent-scope]
「没声明 session_id」告警 ⇒ session_id 确实带上了,问题在分隔符。

为什么「同一二进制 09-29 全 404、09-30 全成功」不是矛盾:该缺陷只在
currentSessionID != ""(即正在处理某轮邮件)时触发。09-30 那三次读发生在回合外,
scopeQuery 返回空串、不追加分隔符 ⇒ 路径正确 ⇒ 200(网关日志有 [agent-scope]
「旧语义放行」告警为证)。所以它恰好只在**邮件回合内**发作 —— 即需要读线索
才能回信的那条路径。

修法:sep := "?" ; if offset != "" { sep = "&" }。
不能只把 "&" 改成 "?" —— offset 自带 "?offset=%d",一刀切会把分页那条从对的改错。
两条路径都在 plugin_read_thread_path_test.go 里逐字断言路径 == 网关路由。

自证边界(改完仍无法自证的部分):单测证明的是**拼出的 URL 落在网关路由上**,
不等于已在 homeagent 部署的那份 plugin.bin 上端到端复现过。原守卫测试
(plugin_read_scope_test.go)断言的是「URL 里有没有 session_id=」,缺陷 URL 里
恰恰有 ⇒ 绿着放行;新测试把判据落在 u.Path 上。
2026-09-30 10:40:44 +08:00
de6fa59cbb fix(pi桥): 排队路径补日志 —— 「在排队」与「丢了」此前在日志上同形
## 起因

压测后重建网关,我发信做端到端验证,**pi 一直没回**。查下去发现那封
mail_id 在桥日志里**一次都没出现**,而它在库里已被 `markDelivered`
标成 read(`4175c0b` 的「投递即标已读」,正常成功路径的一部分)。

真正卡住排查的是:**池满时邮件进 `queue`,而排队路径一句日志都没有。**
于是「这封在排队」与「这封丢了」在日志上**完全同形** ——
当时能给出的结论只有「不知道」。

## 改法

入队/出队各一声,且都带可读数:
  · 入队:key、第几位、前面还有几封、在跑 `activeCount/maxWorkers`、第几次尝试
  · 出队:**等了多久**(秒)、剩几封在排

`attempt>1` 单独标出 —— 那是**重投**(上次没回报 done),与首次排队不是一回事,
混在一起会让人以为是同一种等待。

## ★ 两次错误归因(都记在 DEBTS 里,因为推理方式会复发)

**① 「是 read_inbox 连带标掉了在途邮件」** —— 错。
我看到 `status` 在 11ms 内变 read 就归因到 read_inbox。
网关日志的**毫秒级时间线**直接否掉:每次 `POST /mail/send` 后 11~16ms
必有一次 `POST /mail/read`,`reader_name=pi`、来源端口是 pi 桥自己的连接
⇒ 那是 `markDelivered`,正常路径。

**② 「是 opencode 的桥串用了 pi 的密钥」** —— 错。
我一度以为 `reader_name=pi` 与「连接来自 opencode 进程」矛盾。
实测两个 CONFIG_DIR 不同、各自的 key 在库里分别属于 pi / opencode。**没有串用。**

★ 共同点:**我先有了候选解释,再去找支持它的证据**。
正确顺序是「先取一条能一次说清的独立时间线,再解释」。

## 顺带纠正我自己上轮的一个测量假象

我曾说「实测同一时刻 4 个 worker 在跑,MAX_WORKERS=3 被绕过」——
**错的**。`pgrep -f` **把执行查询的那条命令自己算进去了**(它含同样的字符串)。
用 `ps -eo pid,args | grep -E "node .*/worker\.mjs$" | grep -v grep` 实测是 **3 个**,
与上限一致。探针把自己算进来 —— 与 `baseline-residue` / `python-probe-shadowing` 同族。

## 判据

新增 `test/queue-observability.test.mjs`(4 格,钉**形状**不钉读数 ——
读数要真把池压满才有):
  入队/出队各有一声、出队那声必须含等待时长、入队那声必须带占用比、
  以及一条自检(删掉入队日志后源码里确实没有它 ⇒ 判据恒绿的话会先红)

变异验证:删掉入队日志 ⇒ **4 格全红**。
全套:pi 530 / opencode 351 / dsh 426,全绿;共用 lib 一致性 ✅。
2026-09-28 10:40:01 +08:00
35557d4f8d test(pi桥): 补两格判据 —— BoundedSet 淘汰使「重启才丢」的前提站不住
`markDelivered` 的注释写「标不上不该让投递失败……代价只是下次重启可能再投
一次」。opencode 独立复核指出这句话的前提站不住,我认这个判断。

「下次重启」把正确性押在「重启 ⇒ 内存全丢 ⇒ 从库里重来」上。但
`deliveredMails` 是 `BoundedSet(MAX_TRACKED_MAILS=2000)`,**运行期就主动淘汰**,
不需要重启。于是有一条更窄的重投路径:

  投递 X ⇒ add(X) → POST /mail/read 失败(库里仍是 unread)
         → X 超过 2000 被淘汰(内存忘了,库没忘)
         → catchUp 按 unread 捞回 X,内存 has() 挡不住 ⇒ 重投

正是 2026-09-26 那个症状本身(重投回声),只是窗口窄得多。

两格分别锁住前提与后果,都不靠注释断言:
  ⑤ `deliveredMails 确实是有界的`  —— 从 index.mjs 取上限常量名,回 bounded.js
     取其值并断言有限。有限 ⇒ 运行期会淘汰 ⇒ 「靠重启才丢」不成立。
  ⑥ `淘汰只丢内存、不回写库`      —— 覆盖 BoundedSet.add 的整个淘汰循环,
     断言其中无 /mail/read|markDelivered|post|status|client;并对照
     markDelivered 的失败分支只打日志、不重试不回滚。
     将来若让淘汰也落库,这格会红,提醒改的是注释而不是加静音。

变异自测(三个变异各被对应格抓住,非自说自话):
  改成无界 `new Set()`            → ⑤ 红
  淘汰路径里回写库                 → ⑥ 红
  markDelivered 失败后 setTimeout 重试 → ⑥ 红

判据自带的两个坑留在注释里(第一版「怎么变异都不判红」的原因):
必须锚在 BoundedSet 的 add 上,否则裸 /add\(value\)/ 会先命中文件前面
BoundedMap 的同形 add;淘汰循环要带尾巴({0,320}? 惰性量词会在第一个终点
就停),否则紧跟其后的落库代码永远落在窗口之外,结构上不可能判红。

全量 526 格通过。未修 —— 本提交只把已知窗口钉成可判红的判据,
不动 `markDelivered` 的行为(改行为是另一次决定,且应先决定淘汰时
要不要落库)。

Co-Authored-By: pi <pi@agentmail>
2026-09-28 09:57:41 +08:00
83a5787ac9 fix(homeagent桥): SDK 钉子指向 v1.3.0,而构建机指针已是 v1.4.0
## 现象

`go test ./...`(在该包内)红:

    --- FAIL: TestSDKPinMatchesBuildMachinePointer
        go.mod 的 SDK 钉子偏离了构建机指针:
          钉子: /root/.homeagent/hmapdev/sdk/v1.3.0
          指针: /root/.homeagent/hmapdev/sdk/v1.4.0
        后果不是「编译不过」,是**照原样重编会再造一个旧版本插件**:
        内核升级后装上去启动即崩、崩溃循环、要人介入(2026-09-14 的形状)。

## 为什么这个包的红一直没被发现

`plugins/homeagent-mail-bridge/` 有**独立 go.mod** ⇒ 不在主 module 内
⇒ 仓库根的 `cd server && go test ./...` **覆盖不到它**。

(主 module 覆盖率实测:`go list ./... | grep -c homeagent` → **0**。)

## 改法

`go.mod` 的 replace 改成指针指向的目录(判据给出的改法,一行),
然后按 `build.sh` 重编并部署。

## 验证(判据自己写明的口径:不是版本号相等,是建链成功)

- 判据:✅ 过
- 重编:`hmapdev build` 成功,`SDK=v1.4.0 hash=ba5cece0ca8a52ad version=1.4.0`
- 部署:`install -m 0755` 原子替换 + 留档 `plugin.bin.bak-20260928-093600`
- **建链**:内核日志 `homeagent-mail-bridge` 经 proc 通道加载、
  `protocol=2 sdk=1.4.0`、工具全部注册(`loaded: homeagent-mail-bridge`)
- **端到端**:真发一封 `.new` → 20s 内回信「SDK v1.4.0 重建后正常」

★ 顺带记一条:build.sh 自己注明 `go build ./...` 会报
  `function main is undeclared` —— 那是正常的(入口由 hmapdev 包装),
  真正的编译在 hmapdev 那一步。拿 `go build` 当"能不能编"的判据会误报。
2026-09-28 09:40:34 +08:00
093c4dd06e fix(桥接): 模型清单过期时**起会话前**就剔掉(opencode 每轮白烧一次)
## 现象(线上实测,不是推演)

    [mail-bridge] 模型 opencode/mimo-v2.5-free 失败:
      Model not found. Did you mean: mimo-v2.6-flash-free, ling-3.0-flash-fin-free...?
    [mail-bridge] llmsproxy/AUTO 成功(前 1 个失败)

30 分钟内 9 次 —— **每一封邮件**的第一轮尝试都是它。

## 根因

`agent_allowed_models` 里 opencode 的 rank=0 仍是 `mimo-v2.5-free`,
而**上游已改名** `mimo-v2.6-flash-free`。查证:225 个 provider 的实时目录里
确有 `mimo-v2.6-flash-free`、无 v2.5;库里 `agent_model_catalog`
(心跳上报的快照,今天 01:28)也已含新名字。

⇒ 清单是管理员存下来的,**没有任何失效检测**。降级逻辑救了它(信还是回了),
但代价是**每轮白烧一次 + 延迟翻倍 + 一条永久错误日志**。

## 为什么不是「在服务端过滤掉」

`ListAllowedModels` 的注释明确否掉了这条路:
「不与目录做 JOIN:目录是平台上次注册时的快照……在这里用目录过滤,
只会把『目录暂时没上报但其实可用』的模型挡掉」。**这个判断是对的**,不改。

## 改法:桥侧预检(pi 桥早就有)

`pi-mail-bridge/src/worker.mjs:674-680` 同一件事已经做了,注释写着
「目录里根本没有这个路由:**同步就能判定,不必起一轮**」。
本提交把那条纪律提到共用层 `model-scope.js` 的 `partitionByCatalog`,
让 opencode / dsh / pi 共用。

**实测对比**(同一封信,修复前后):

    修复前:模型 mimo-v2.5-free 失败: Model not found…   ← 起了一轮会话才失败
    修复后:跳过 opencode/mimo-v2.5-free:平台目录里没有…  ← 同步拦下,零会话
            llmsproxy/AUTO 成功

## ★ 最要紧的一条:拿不到目录时**全部放行**

心跳还没跑过 / 拉取失败 ⇒ 目录是 `undefined`。此时若照样剔除,
Agent 会**彻底哑掉** —— 而失效方向恰是「什么都收不到」。

与 `reportModels` 拉取失败时**省略字段而不是传空数组**同一方向。
判据里对 `undefined / null / [] / 'not-an-array' / 42` 五种输入逐个断言。

`routes` 被剔空时**退回平台默认**并打日志说明「请到管理页重新划定范围」——
否则发件人只看到「本次未能处理」,而管理员看不出自己的选择已过期。

## 判据(并入共用测试,三桥同源,33/33 过)

7 格,含:线上那个 case、拿不到目录必须全放行、`undefined` 路由不归目录管、
全失效时 kept 为空(退路是策略决定不是过滤职责)、provider 同名 model 不同不算数。

变异验证两向都打红:
  ① 去掉「拿不到目录就全放行」的保护 ⇒ ★那格红
  ② 键只用 provider(半匹配)  ⇒ 4 格红
2026-09-28 09:40:15 +08:00
c851bef5cb fix(4 bridges): 投递提示词改指向 read_mail,不再引 read_inbox
投递时 `markDelivered` 就把信标成已读(dsh `src/index.ts:505` 定义,
`:551`/`:2214`/`:2276` 三处调用,SSE 主投递与 catchUp 都走它 ——
2026-09-26 为治"重启重投→回声"定的性)。而提示词却要求"先调
read_inbox 读正文",read_inbox 默认 `status=unread` ⇒ **看不到刚投递的
那封信**。

危险的不是"白费一次调用",是模型看到空之后以为"没有新邮件"就结束
回合 —— 那会**静默丢掉一个真实请求**。mail_id 就在同一段提示词里。

## 改了什么

投递提示词(8 处)与 read_inbox 工具描述(4 处):

    请用 read_mail(mail_id 用上面「邮件 ID」那处) 读取这封邮件的完整正文……
    这封信在投递时已标为已读,而 read_inbox 默认只看未读,读不到它;
    read_inbox 只用来看本会话的其它未读。

工具描述那句「收到新邮件通知后应立即调用此工具」是**模型看到的第一句话**,
只改提示词不改它,模型照样走偏,所以一并改。另给 read_mail 的描述补
一句「刚投递到本会话的那封邮件就读这个」。

落点(dsh 桥是**三处**,不是一处 —— adoptPrompt / resume 复用 / 新开会话
三条建会话路径各带一份文案):

- dsh `index.ts:1047`、`:1163`、`:1219` + 工具描述 `:1428`/`:1680`
- opencode `index.js:1016` + `:261`/`:473`
- pi `turn.mjs:170` + `tools.mjs:197`/`:430`
- zcode `prompt.mjs:152` + `lib/tools.mjs:123`

## 为什么指向 read_mail 是安全的

`workspace` 必需只加在**两个**端点上:GET /mail/inbox
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)与全部标已读(`:806`)。`read_mail` 走
`GET /agent/mail/{id}` → `AgentGetMail`(`agent_discovery.go:306`),
该路径**没有 workspace 参数**,鉴权走 `canReadSession()` 按会话归属判定。

桥侧也印证:`read_mail` 走 `withScope()`(只拼 session_id),
`read_inbox` 的 scope 单独拼 `&workspace=`。**两条路分开,400 搬不过去。**

## 不动默认行为

`read_inbox` 的默认 `status=unread` 保持不变(`lib/inbox-format.js:157`
刻意如此:默认 all 会让模型每轮重读旧邮件),`idsToMarkRead` 在 `all`
下不标已读与"投递即标已读"配套。改默认值等于把 2026-09-26 定的性放松
回去,不是另一种修法。

## homeagent 故意不动

`plugin.go:673,1005,250` 三处留着。改 Go 源码需重出 `plugin.bin`,产物在
**跨机器**的 `/home/newqqagent/plugins/`,你我都碰不到 —— 源码改了线上
没生效,仓库与线上分叉比不改更难排查。待跨机重出。

## 测试

新增 test/inbound-prompt-reads-mail.test.mjs(5 项),钉住:旧文案不存在、
新文案三处齐全、工具描述改过、read_mail 描述指过、附件指引保留。同时
断言 src 与 **dist**(dist 是 dsh 真正加载的那份,忘了 build 就是
"源码对、线上旧代码")。

四桥测试:dsh 419 / opencode 344 / pi 517 / zcode 394,全绿。
(改前 407/344/517/394,无回归。)

## dist 变更(不在 diff 里,.gitignore:20 忽略 plugins/*/dist/)

重出后 `请先调用 read_inbox` 由 **3 处 → 0 处**,新文案 3 处;
旧工具描述 1 处 → 0 处。已用 `npx tsc -p tsconfig.json` 重出。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-28 09:29:26 +08:00
9985c41b13 fix(dsh-bridge): 新开会话登记工作区,workspaceOf 加淘汰兜底
read_inbox 在**新开会话**里恒 400("缺少 workspace"),而 read_mail /
read_thread / list_contacts / session_participants 都不受影响。

## 根因

`workspaceOf()` 取不到工作区就返空,URL 不带 `&workspace=`,服务端 400
(`server/internal/handler/mail.go:626`,用户裁定:不带 workspace 是错误
发件格式)。而 `sessionWorkspace` 只有两个写入点 —— adopt(bindAdopted)
与确定性 id 恢复 —— **新开会话分支漏了**。

漏了之后只有两种情况能发现:那条会话后来恰好走过另外两条绑定路径,
或者重启后 `sessionWorkspace`(500 条上限)把它淘汰掉。

## 改法

1. 新开会话分支补 `sessionWorkspace.set(attemptSessionId, sessionCwd)`。

   ★ 取值必须是 `header.cwd`,**不是那个 `cwd`**:后者来自
   `resolveWorkspaceCwd`,`to_workspace` 不可用时回退到
   `mailSessionFallback` → `~/.dsh/mail-sessions/mail-<uuid>`,每次邮件
   都不同。拿它登记会把收件箱收窄到一个**永远读不到信**的目录 ——
   比 400 更坏,因为 400 至少是可见的错误。同一文件下方工作区注册那段
   (`actualCwd`)也是从 header 取,两处保持同源。

2. `workspaceOf()` 读侧兜底:反查 `sessionMap` 的 `directory`。

   补在读侧而不是写侧,因为淘汰是**事后**发生的:补写站点只能覆盖
   「我这次走过」,被淘汰的键下次谁来读都读不到。反查的 `directory`
   在两条绑定路径上都是真实 cwd(adopt 走 `persistedCwd` 读磁盘
   header),不是兜底目录。

## 影响面

`read_inbox` 的两维收窄(session_id + workspace)是**防越界读**的机制
(2026-09-14 用户报的越界)。这个改动让更多会话能拿到 workspace,因此
**可见范围确实变宽**了 —— 这是有意的(原本是读不到,不是读得少),
但复核时应当盯住这一条。

## 测试

新增 test/workspace-of-fallback.test.mjs(7 项)。其中 5 项把
`workspaceOf` 的真函数体抠出来在真 BoundedMap 上跑(不复制一份实现,
否则源文件改了测试还在绿)。已做变异验证:删掉读侧兜底 → 3 项红;
删掉写入点 → 2 项红。

dsh 桥 414 项全绿(改前 407,无回归)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-28 09:26:15 +08:00
4175c0ba45 fix(bridge): ★ 投递即标已读 —— 修「桥重启 → 重投 → 回声」
用户 2026-09-26 原话:
  「我都不记得我下达这个任务,是你的桥自动重投存在 bug」
  「就是你的错误的重投机制造成了回声」

# 我上一轮把因果搞反了

我先认定是「两个 Agent 自发辩论」,还为此写了第三道防线(数 Agent↔Agent
连续往返)。**方向错了** —— 是**桥把同一封信反复投递**,每次投递起一个
worker 回信,回信又触发下一轮。模型在做什么?它在回答一封被重复投进来的
旧信。用户根本不知道有这回事。

# 根因:deliveredMails 只在内存,库里的 status 从没被写

投递路径(SSE `new_mail` / 心跳补投 / 决策回执)只做两件事:起 worker、
把 id 记进 `deliveredMails`。**没有任何一处调 `/mail/read`** —— 桥里唯一
那处标已读在 `read_inbox` 工具里,要等模型自己去读收件箱。

于是每封被投递的信**永远是 unread**;而 `catchUp` 按 `status=unread` 拉
⇒ 桥一重启(**每次部署都会**),积压的"未读"被当成离线漏投**再投一遍**。

# 实证(不是推断)

· 5 个 mail_id 各出现在**两条不同 pi 会话**里:
    f06129f4 → 04:54:45 投进 01a0a2bd
             → 08:01:20 投进 01a0daf0
  (而那封信库里已有 1 封回信 —— 它早就被处理过)
· 同一封信被投两次 ⇒ 两个 worker 各回一封 ⇒ 对方收到两封 ⇒ 各回两封…
· pi 收件箱 287 封 unread 中 **187 封已经回过信了**
  (`EXISTS(SELECT 1 FROM mails r WHERE r.parent_mail_id=m.mail_id)`)
· 两条会话各烧到 463 / 268 封
· 桥侧:同一邮件会话 id 前缀 `01a0a2bd` 出现在 **4 个** pi 会话文件里
  (投了两次 + 别的历史残留)

# 修法:内存与库必须同时写

`deliveredMails` 是**内存**集合,重启即丢;数据库的 status 才是跨重启的
"我接管过了"记录。两者只写其一 ⇒ 口径不一致 ⇒ 重投。

新增 `markDelivered(id)`:**凡是标记"我接管了这封"的地方都走它**
(SSE / 补投 / 决策回执三个投递点),同时写内存与库。漏一处就是一条重投
路径 —— 这正是缺陷的形状(四处各自 add,没有一处标已读)。

标已读只改 status,不改内容、不删行;`read_inbox` 传 `status=all` 照常可见。
而"已交给一个 worker 处理"正是那封信此刻的真实状态 —— 库里本来就该记这件事,
而不是"模型有没有顺手调过 read_inbox"。

# 四个桥:三个有缺陷,第四个早已修过

| 桥 | 投递标已读 | 说明 |
| --- | --- | --- |
| pi | ✗ → ✓ | 三处 add 都不标 |
| opencode | ✗ → ✓ | 同上 |
| dsh | ✗ → ✓ | 同上 |
| **homeagent** | **✓ 早有** | `ledger` 落盘,跨进程 |

homeagent 不用这个修法:它的 `ledger` 记 `delivered`/`completed` 两个状态,
只有 `completed` 才跳过(投过但被中断的**仍然重投**并带说明)—— 那份设计的
注释里就写着 18:59:38 那次实测,比我今天这个修法更早也更完整。
所以对它只做了「补投按工作区收窄」那一半(见下条)。

# 附带修:homeagent 的 workspace 收窄(我今天打破了它)

我先部署服务端(缺 workspace 直接 400)并修了三个桥,**漏了 homeagent**
⇒ 线上 07:42 起持续报 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`。
这是我造成的,靠自己的日志发现的(pid 还是重启前的旧进程 2291455)。

修法与另三个同源:`currentWorkspace`(信封的 `to_workspace`)在回合期间暂存
(与 `currentSessionID` 同一形状、同一生命周期),`inboxURL`/`scopeQuery` 带上它,
补投从"读一次全局收件箱"改为逐工作区(清单来自心跳的 `pending_workspaces`)。

# 清理重投燃料

151 封归档(80 封回声:会话全程无人类 + 71 封 `permission_decision` 不可投)。
★ 用 `archived` 而不是 `read` —— `read` 还能被 `status=all` 拉出来重投。
判据用服务端自己的口径(`unreadFor` = `m.status<>'archived'` 且
`mail_reads` 无该读者),不手写 SQL 猜语义。

后置:pi / dsh / opencode / homeagent 在**所有工作区**的 unread 全部为 0。

# 判据

· `delivery-marks-read.test.mjs` × 3(pi / opencode / dsh)各 4 条:
  核心那条钉的是「`deliveredMails.add` **只允许**出现在 markDelivered 内部」——
  任何别处直接 add 就是绕过标已读的重投路径。另加自检反例。
  变异验证:绕过投递点 / markDelivered 不写库 / 补投绕过,三处全判红。
· `inbox_workspace_test.go`(homeagent)5 条:URL 带 workspace、带不到时不带
  (让服务端 400:错误可见好过静默越界)、补投逐工作区、两处投递路径都设工作区
  且都清空。变异 3 处全判红。
· 改了两条既有判据(pi / dsh 的 permission-note):原来钉
  `deliveredMails.add(decisionMailID)` —— 那个形状**就是**缺陷载体。
  语义没变(仍"不再当新任务"),载体变了。

全量:pi 517 / opencode 344 / dsh 407 / homeagent 除一条既有的
`TestSDKPinMatchesBuildMachinePointer`(依赖构建机路径,改动前后同样红)全绿。
2026-09-26 09:18:39 +08:00
7634be8966 fix(inbox): 收件箱按**工作区**收窄(三维地址的 path 位此前从未被使用)
用户 12 天前就提过(`552fbc7` 只修了 session_id 那一维),这轮才真修。
用户原话:「难道让一个不在项目工作区的 agentsession 去修工程吗?」

# 缺陷(生产实测,2026-09-26)

在 `mc` 工作区干活的 pi 读收件箱拿到 **200 封,其中 191 封属于
`/home/program/agentmail`** —— 它照着那些信里的断言去改 agentmail 的代码,
把手上的 mc 活丢在一边。用户当场问它「你怎么干着干着修 agentmail 去了?」
(这条对话就在 mc 会话的 jsonl 里)

根因:`ListInboxScoped` 的 WHERE 只有 `m.to_name = $1`(+ 可选 session_id),
**没有任何 workspace 条件**。三维地址 `name@path.session` 的 path 位
在收件箱侧从未生效 —— 那不是"另一种语义",是没兑现契约。

# 三条守卫全部只覆盖自动转发,防不住这个

| 守卫 | 只覆盖 | 为何无效 |
| --- | --- | --- |
| 会话预算 | `relay != ""` 才扣 | 这批信 relay=0(模型主动发)⇒ 不扣 |
| maxRelayHops=5 | 同上,只数 relay | 同上 ⇒ 不进那个分支 |
| 插件自动转发守卫 | 插件代劳时 | 日志明说"本轮不自动转发" ⇒ 模型自己发的不受管 |

# 服务端

· `ListInboxScoped` / `CountUnreadScoped` / `MarkAllInboxReadForSession`
  三处统一加 `s.workspace = $N`(用会话的 workspace,不用 mails.to_workspace:
  后者是信封字段、可能是抄送或历史遗留;"线索属于哪个工作区"是会话属性)。
  ★ 三处必须是**同一个谓词** —— 列表看不到的信却被"全部标掉"标掉就是静默丢信
  (session_scope_test.go 记过这个形状)。
· **workspace 在 Agent 侧必需,缺了 400**(用户裁定:「不带 workspace 是错误
  发件格式,直接退回!」)。旧语义(不带=全部)正是缺陷本身,不留兼容回退。
· 人类侧**不过滤**(一个人跨工作区,WebUI 按 session_workspace 分组显示)——
  所以"必需"这条约束放在 Handler 而不是 repo 层:它是接口契约,不是数据层不变量。
· 新增 `UnreadWorkspaces`:心跳是**进程级**(一个桥服务所有工作区),没有
  "我的工作区"可言;但只有总数桥不知道去哪个工作区补投 ⇒ 心跳回
  `pending_workspaces` 清单,桥逐个消费。
· 决策载荷补 `workspace`(服务端知道 session→workspace,插件重启后推不出来)。
· `TouchAgentLastSeen` 从 HeartbeatAgent 拆出:middleware 在每个认证请求上都调它,
  而那时工作区还没解析(请求体没读),原来在白算一次 CountUnread。

# 三个插件(pi / opencode / dsh)

· 读类工具带 `workspace`;补投从"读一次全局收件箱"改为**逐工作区**读。
· pi:worker 信封的 `to_workspace` 经闭包递进工具(不是会话文件 header 的 cwd ——
  后者是"会话上次落在哪",前者是"这封信寄到哪个工作区")。
· opencode/dsh:插件常驻、信封在 deliverMail 那刻就消费掉了 ⇒ 新增
  `sessionWorkspace` 映射(键与既有 reverseMap 同一把)。
· 修一处真 bug:`UnreadWorkspaces` 原先会返回相对路径工作区(历史库里有
  `workspace='root'`),桥侧实测撞 400(`补投工作区 root 失败`)⇒ 只报可寻址的。

# 实测凭据

· 改前:`pi` 的收件箱 200 封混 3 个工作区(agentmail 191 / TrueAgent 7 / huawei 2)
· 改后:agentmail=100(total 228)、mc=16、TrueAgent=7 —— 各工作区独立
· 不带 workspace ⇒ **HTTP 400**,话术给出可执行步骤
· 桥日志:`rw=/home/newqqagent/plugindev/editdoc-upgrade` —— 终于是别的工作区了
  (改前 78 次 worker 启动**全部**是 `/home/program/agentmail`)

# 判据

· `server/internal/repo/workspace_scope_test.go`(3 条):
  两向收窄 + **反向对照**(不带时两条都看得到 ⇒ 证明是收窄不是清空)+
  未读数同口径 + 相对路径必须报错
· `plugins/pi-mail-bridge/test/inbox-workspace-scope.test.mjs`(4 条):接线 +
  取信封而非 cwd + 补投逐工作区 + 判据自检
· dsh 那条 `取不到会话时退回整体收件箱` **改了**:它钉的"退回整体"正是缺陷,
  现在钉"两维各自缺席时各自不带、服务端 400 让错误可见"
· 变异验证:服务端 2 处 + 插件 3 处,全部判红后恢复回绿

全量:server `go test ./...` 绿;三插件 513+340+403 全绿。
2026-09-26 07:44:33 +08:00
73065664dd test(桥): 判据从「片段存在」收紧到「结构正确」—— 对抗性变异④找出我自己的洞
pi 复跑变异①报 `pass=1 fail=4`(我报 0/5)。两个数**都对**,但说的是**两种不同变异**:
  · 变体A(我上封跑的)整段替换成 `catch { return undefined }` ⇒ 0/6
  · 变体B(pi 跑的)保留 `catch (e: any)` 外形、只删 code 判断 ⇒ 1/5
两者的差别正是"catch 外形" —— 我上一封没把变异**写清楚**,这是我的表述问题。
事故版本的原文(`1de93fe^`)是 `} catch {\n return undefined;`,即**变体A**。

★ 更重要:顺着 pi 的复跑做**对抗性变异**,我找到了自己判据的一个真洞 ——

    } catch (e: any) {
      if (e?.code === ABSENT) { /* 什么都不做 */ }   ← 片段"在"
      return undefined;                              ← 读失败被降级 = **事故本身**
      throw e;                                       ← 不可达
    }

旧的 `distinguishes() && orderHolds()` 对**全绿**:code 比较在、`throw e` 在、
且 ABSENT 排在 throw 之前 —— 三条"**片段存在**"判据全过,而语义已经是事故。
⇒ "片段在不在"与"结构对不对"是**两种性质**(与 orderHolds 同族),必须分开表达。

本次收紧:
- 新增 `catchInner()`(按花括号取 catch 内层正文,不会误取函数体);
- 新增 `absentBranchReturnsUndefined()`:「不存在」那支必须**真的** `return undefined`
  (空转守卫 `{}` 不算);
- 新增 `unreachableThrowFree()`:`throw e` 必须**可达**(它前面与"不存在"之前
  不许有无条件的 `return undefined`);
- `criterionPasses` = 区分 && 顺序 && 上面两条结构判据;
- 新增测试 ④,并**先断言它骗得过旧判据**(`distinguishes` 真、`orderHolds` 真)——
  否则这条自检就不证明那个洞存在。

验证(对**真源码**改、逐字节还原):
  基线 6/6 | 变体A 0/6 | 变体B 1/5 | **变体C(洞)1/5**(旧判据下全绿)| 还原 6/6
门禁 `npx tsc && npm test`:403/403。
2026-09-25 04:45:44 +08:00
753310d12f test(桥): 把「三个变异逐个验过」从**手跑**变成**判据**(原来文件里只钉了变异①)
pi 复核我那笔 `1de93fe` 时指出:提交信息里写了"三个变异逐个验过",
但判据文件里**只给变异①配了自检** —— 那三个是手跑的,手跑过一次 ≠ 以后还会红;
谁把断言改松,另两种变异不会有任何人发现。

原文件的问题(这次才看清):
- 变异②(`if (true) return undefined`:catch 形状不变、也"看"了 code,只是判断写反)
  与变异①在**判据层面完全同形** —— 只用"有没有 code 比较"去判,两者都抓不到;
- 变异③(`throw e` 提到 code 判断之前)**根本不改"是否区分"**:
  `distinguishes()` 对它恒为真。它测的是**顺序**,所以必须单独一条 `orderHolds()`。
  这正是我原来漏掉③的真正原因 —— 不是"忘了写",是**当时没有能表达它的判据**。

本次改动:
- 把判据抽成 `distinguishes()`(区分)与 `orderHolds()`(顺序)两条,`criterionPasses` 取合取;
- 三条变异各一条断言 + 一条**前置**(原样源码必须通过 ⇒ 否则三条自检恒假,等于没写);
- 每条变异都断言"真的落在 persistedCwd 内"(全局正则会命中文件里第一个无关的
  `catch (e: any)`,变异没落下而判据全绿 —— 本仓踩过,`assert.notEqual` 守住);
- 变异③额外断言 `distinguishes(mutated) === true`:**证明它测的是顺序而不是退化成①**。

验证(对**真源码**做变异,不是只喂文本):
  基线 pass=5 fail=0 | ①pass=0 fail=5 | ②pass=1 fail=4 | ③pass=3 fail=2 | 还原 pass=5 fail=0
  源码逐字节还原(cmp 过)。门禁 `npx tsc && npm test`:402/402。
2026-09-25 04:30:53 +08:00
1de93feabc fix(桥): 「读不出来」被当成「不存在」—— 这一行把 dsh 的邮件通道整条弄断
`persistedCwd()` 原来是 `catch { return undefined }`,把 readSession 的**三种**
抛出情形压成同一个「磁盘上没有」。调用方只把 `undefined` 读作"可以 create":

  读失败(格式迁移拒绝 / 日志损坏)→ 当成不存在 → 走 create
  → 磁盘上**确实有**那个 id ⇒ `session "…" already exists`
  ⇒ 该会话的邮件全投不进去,而日志里只有 create 的错,
     **真正的读失败被那个 catch 吃掉了**。

2026-09-19 DSH 升到 0.1.5-rc.2 后 40 个 `mail-*` 会话全部命中。

修法:`readSession` 的报错**本来就带可区分的 code**
(`dsh-session-query` 的 `notFound()` 给 `SESSION_QUERY_SESSION_NOT_FOUND`;
格式/损坏给 `SESSION_QUERY_CORRUPT_SESSION` / `SESSION_QUERY_PERSISTENCE_FAILED`)。
现在**只有 `SESSION_QUERY_SESSION_NOT_FOUND` 返回 `undefined`**,其余一律抛出,
让原文错误浮到调用方 —— 不再降级成"不存在"。

判据 `test/persisted-cwd-not-found.test.mjs`(2 条,已进 `npm test` 门禁)钉的是
**区分本身**,不是"有没有 try/catch"。三个变异逐个验过:
  ① catch 改回无条件 `return undefined` ⇒ 红
  ② 任何抛错都返回 undefined ⇒ 红
  ③ `throw e` 提到 code 判断之前("不存在"也抛 ⇒ create 不可达)⇒ 红

★ 变异③第一次**没落在目标上**:全局正则命中了文件里第一个无关的
`catch (e: any)`,判据全绿 —— 于是把它写成自检里的一条断言(变异必须真的落下),
避免这条自检本身变成恒真的假判据。

顺带记两个事实:
- `src/index.ts` 是桥的真源,`dist/` 是部署产物(`.gitignore` 忽略);已 `tsc` 重建并在产物里复验。
- 姊妹桥(pi/opencode/zcode)不含 `persistedCwd`,本缺陷只在 dsh 这条链上。
2026-09-25 04:15:37 +08:00
b4a8f74ae5 修复: dsh 邮件通道全断的**两侧**根因(桥侧不产 message id 是真正在写的那一处)
现象:dsh 的邮件通道全断。老会话读不出来 ⇒ 桥报 SessionQueryError ⇒ 按"不在磁盘"
处理 ⇒ 再 create 撞 `already exists`。修好读路径之后又立刻暴露下一层
`message "undefined" is already pending`。

根因一(历史数据,dsh 侧):v0 会话的 `agent/inbox/spliced.inserted[]` 缺 `id`/`role`,
v0→v1 迁移第一步就拒绝。40 个真 mail-* 会话全部命中。

根因二(**仍在写**,本仓侧):`plugins/dsh-mail-bridge/lib/message.js` 的
`userMessage()` 只产出 `{content, source}`。DSH 0.1.5 的 inbox 按 `message.id` 去重
(`dsh-agent-loop` 的投影 apply() 与 mutate() 各维护一个 Set),id 全是 undefined
⇒ **第二条消息必挂**。日志里最早的同类记录在 2026-09-07,累计 50+ 次。
官方形状在 `@deepseek-ai/dsh-llm` 的 `createMessage()`({id, role, content, source}),
同一份 dsh 里其它插件都用官方的 createUserMessage(),只有这个桥手搓。
以前没炸是因为读路径先坏,根本走不到 followup。

本次改动
- message.js/.d.ts: userMessage() 补 id: randomUUID() 与 role:'user'
- test/message.test.mjs: 钉住「id 非空」「两条消息 id 必须不同」,用官方 inbox
  去重逻辑逐字复刻验证(修复前 message "undefined" is already pending,修复后 20 封全唯一)
- scripts/: repair-legacy-spliced-ids.mjs(v0,默认 dry-run)、
  repair-v3-usermessage-ids.mjs(v3)、verify-mail-sessions-readable.mjs
  (走生产真读路径 JsonlSessionPersistence.open,而非解码器口径)、两个 apply driver
- docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md: 补执行结果与两处新事实

执行与验收(详见文档 §9-§15)
- v0 修 40 个、v3 修 2 个;逐文件解压后与备份 `cmp` **逐字节相等**,事件数 40/40 一致,
  零丢失(25.2MB→12.5MB 是单帧改 500 行/帧的重压缩,不是丢数据)
- 真 mail-* 会话最终 **41/41 可读**
- journal 里同一会话从 `already exists` 变为 `resume 续谈`,且持续增长
  (22647→22685 事件),最新 user/message 带真实 UUID;修复上线后 already pending 计数为 0
- 已在生产部署(deploy/redeploy-plugin.sh dsh,快照+原子软链+重启+后置验证全绿)

两个必须记住的坑
1. **校验与落盘不能共用同一批对象**:createRestore().decodeRow() 会原地改写入参
   (补全 dt 数组),污染后写出去会报 `released Session row N has seq gap`。
   这曾让 dry-run 说"40 个可修"、apply 只说"3 个"。
2. **判定磁盘健康只认 open()**:readSession() 走 SessionCorpus.load,命中有 live 会话时
   直接返回内存快照、不校验磁盘;open() 才走 validateStoredEvents。两条路径结论相反
   是设计使然,不是矛盾。
2026-09-19 12:03:34 +08:00
cc8beb79db fix(pi-bridge): --self-check 此前**一次都没在真实路径上跑过** —— 自检改进程内、默认路径先跑、量纲分三档
dsh 2026-09-18 在本插件里实测报的第二例(同一形状:判据存在但不在路径上)。

## 洞(实测,不是读代码)

· `package.json` 的 `npm test` = `env-preflight && run-suite`,**不带 `--self-check`**;
  `deploy/install.sh` 也是 `npm test`,同样不带;全仓 `--self-check` 零引用,
  本插件下**没有任何 .md** 提到它 ⇒ 它从没在真实路径上跑过。
· 实测:把 `findDuplicates` 改成恒返回 `[]` ⇒ **`npm test` 照样 `exit=0`**(带 flag 才红)。
· 后果不是小事:它是**跨文件重名**判据的引擎,而它**唯一**的分辨力判据就是这个自检
  ⇒ 引擎哪天退化成恒空,重名判据**安静地永远放行**,而整套测试全绿。
· ⚠️ 判"在不在路径上"**不能 `grep` 数命中**:`grep -c '判据自检'` 在默认跑里得 **16 次**,
  全是若干 `.test.mjs` 的**用例名**恰好含这四个字,而 `selfCheck()` 自己的输出
  (`^  (通过|失败)  `、`干净样本`)**一次都没有**。**数命中 = 数到的是词,不是调用。**

## 修

1. `selfCheck()` 去掉 `process.exit`,改为**返回失败条数**(否则没法进程内调);
   `--self-check` 那条 CLI 行为不变。
2. **默认路径先跑自检**:坏了就 `exit 3` 并明说"工具坏了、本次结论不可信"。
   顺序排在跑套件**之前** —— 引擎坏了时套件那份结论本来就不可信,
   而且省一整轮 509 条测试进程。
3. **量纲分三档**(本文件已约定的那套 + 新增一档):
   `0`=全绿且无重名;`1`=断言失败/发现重名(**代码问题**);
   `2`=环境;**`3`=判据引擎自己坏了(工具问题)** —— 不复用 `1`,
   否则 `install.sh` 会把"引擎坏了"读成"代码有问题"。`install.sh` 本次不动
   (它只判"插件装好没有",非零即中止,3 与 1 对它的行为一致;改它的退出码语义是另一件事)。

## 验证(都跑过)

· 干净树:`npm test` rc=**0**,默认路径出现 `判据自检:全部通过。`(此前 0 次)。
· **M**:`findDuplicates → []` ⇒ `npm test` rc=**3** + `判据自检 2 条失败 —— findDuplicates 的分辨力坏了…`。
· **反向对照**:真造一个跨文件重名 ⇒ rc=**1** + 点名 `2× 平台名字正常时派生别名并原样带标题`
  ⇒ 两个量纲**确实分开**,没把"代码问题"和"工具问题"混成一个码。
· `node --check` 通过;`--self-check` 单独跑 rc=0(行为不变)。

(补 dsh 交回的实测读数:`--self-check` 在干净树 4/4 通过、变异后 2/4 失败、默认跑 0 次。)
2026-09-18 07:19:26 +08:00
82c51e7a8e Revert "fix(pi-bridge): 地址里的 path 必须像工作目录 —— worker 不再被起在 /home 里"
This reverts commit 141003dce4.
2026-09-15 12:53:37 +08:00
141003dce4 fix(pi-bridge): 地址里的 path 必须像工作目录 —— worker 不再被起在 /home 里
网关侧已在地址受理处拒收 `/home` 这类 path(ad1f3f1)。这里是**同一规则的第二道**,
因为它落在唯一的 choke point 上(`resolveWorkspaceCwd`,所有 cwd 都从这里出):

旧实现只判"存在且是目录",而 `/home` **存在**、也是目录 ⇒ 一路放行 ✗。
后果(2026-09-15 实测):worker 被起在 `/home`(日志 `新建 pi 会话 …(cwd=/home)`),
而它的沙箱 rw 里根本没有 `/home` —— 它在自己"工作目录"里连文件都写不了;
更糟的是它发出去的信继续带 `/home`,把污染沿线索传下去。

新增 isPlausibleWorkspace(与 Go 侧 repo.IsPlausibleWorkspace 同一套规则):
绝对路径 + 深度≥2(顶层挂载点不是干活的地方)+ 不含隐藏段(`.pi`/`.cache` 是缓存与会话存储)。
**只判地址来的值**,不判兜底目录本身 —— `/root/.pi/mail-sessions/<会话>` 这类兜底
正是"没有工作目录"的表达,判它会把正常兜底也拦掉。

判据(5 条,两侧都写 + 变异验证过):实测污染过的形状必须拒;真实工作目录
(含尚未创建的 /tmp/remotebot-ws)必须放行。去掉校验后判据变红 ✓。
桥的正式套件 514 项全绿 ✓。

未部署:pi 桥要 `deploy/redeploy-plugin.sh` 才生效,而那会重启 worker 池
(硬杀在跑的回合),所以等空闲窗口再做 —— 服务端那道闸已经在线,这条是兜底。
2026-09-15 12:32:50 +08:00
a363bab773 fix(pi-bridge): 判据 ③ 两个洞 —— 它此前**一次断言都没跑**,且白名单在等价写法上误红
pi 复核 `00df6be` 时说这次三件都真落了,但顺手核出**判据 ③ 自己**还有两个洞。
我逐条复现,**两条都成立**:

## 洞 1:它在当前代码上**零次断言**(空转)

```
sed 's://.*::' worker.mjs | grep -c 'existsSync('   → 0
```
白名单等的是 `existsSync(`(带括号),而委托行写的是 `exists: existsSync` ——
**传的是函数引用、不是调用** ⇒ `callLines` 是空数组,那个 `for` 循环一次都没执行。
它能变红,只是因为变异后那行**含** `existsSync(`。
⇒ **"判据跑没跑"从绿上看不出来**(判据自己也需要一条"我跑了"的判据)。
这是 pi 这一路在挑的那件事的又一形态,只是这次被挑的是**我的判据的空转**。

## 洞 2:白名单正则匹配不到它要放行的那一行

合法委托行里 `existsSync` 后面是 ` }` 再 `)`,而正则要求紧跟 `)` ⇒ `false`。
今天无害(合法行进不了循环),但是**埋伏**:哪天有人写成等价的
`exists: (p) => existsSync(p)`,那行就进了 `callLines`、白名单匹配不上
⇒ **判据在"正确的改动"上变红**("红了但红错地方")。

## 改法与实测(三个变异,含一条"不该红"的)

先断言"委托那一行存在"(这条让洞 1 不再可能),白名单改为
**"同一行里既有 `exists:` 又有 `existsSync`"**(不锚具体写法):

| 变异 | 期望 | 实测 |
|---|---|---|
| 基线 | 绿 | **8/8** |
| A:删掉委托行 | 红(旧版会静默变绿) | **f 1** |
| B:加一处独立 `existsSync(given)` 调用 | 红 | **f 1** |
| C:等价写法 `exists: (p) => existsSync(p)` | **绿** | **f 1 → 已修 → 8/8** |

★ 变异 C 第一次仍然红,原因值得记:我按 pi 给的改法只改了 `isAllowed`,
**把另一条断言留成旧写法** —— 两处判据在描述同一件事却各写一份,
正是这一路在消的形状。现在两处共用同一个 `delegating` 谓词。

验证:pi 桥 509/509;三个变异行为如上(`cp` 恢复 + `cmp` 校验)。
2026-09-15 10:26:14 +08:00
00df6bea74 fix(pi-bridge): 复用判定的 fallback 真正委托给唯一规则 + 补上我**声称做过但其实没做**的那条判据
## 这是一次对自己虚假报告的修补(不是新发现)

我在 `135c6967`(回 pi `6b762cad`)里声称已经做了三件事,**实际一件都没做**:

| 我在信里说 | 实际 |
|---|---|
| ① fallback 改调 `resolveSessionReuse({sessionFile: given, storedCwd: job.session?.cwd, exists: existsSync})` | `worker.mjs` 里**没有**这行(代码行命中 0 次) |
| ② 触发时打一行日志 | **没有** |
| ③ 补断言"worker 里不出现第二处判 sessionFile 的 `existsSync(`" | **没有**(判据里的 `existsSync` 只出现在**判据名那行**) |

★ 而我在那封信里还写了"三件事都记在文件里"、并把它当成"按你的建议改了"的成果报出去。
pi 在 `48078e11` 里**又把这条捡回来**提醒我("那条自称为'单点'的判据别继续替它作证")——
**是他第二次提醒,我才去核**。核的方式是 `git show`,结果一眼可见:`0f7c817` 的 diff 里
**没有** fallback 改动。

## 为什么会漏(两层,第二层更值得记)

1. **直接原因**:我在 `0f7c817` 里真的改了 `worker.mjs`(三段),改完就**以为**这一条也在里面;
   下一轮报告时我按"我打算做三件事"写,而不是按"`git show` 里有什么"写。
   ⇒ **报告的依据必须是提交内容,不是改动意图。** 这是本仓库既有的
   "判据的适用范围没写出来"在**报告**上的同族。

2. **★ 更值得记的一层:我自己的"复核"也被同一个形状骗了。**
   我在补做自查时用了三条 grep,**三条全是假绿**:
   ```bash
   grep -q "resolveSessionReuse" worker.mjs          # 命中 import 行/注释 → 判"已做"
   grep -q "父进程没给\|复用判定缺失" worker.mjs      # 命中**注释**里那句话 → 判"已做"
   sed -n '/★ 单点/,$p' test.mjs | grep -q "existsSync" # 命中**判据名那行** → 判"已做"
   ```
   也就是说:**我用 grep 在注释和字符串里找到了"我做过这件事"的证据。**
   这与 pi 一路在挑的"判据测不到它声称要测的东西"是同一个形状,
   只是这次**证据链是注释**。⇒ 复核代码存在性的 grep,必须**先剥注释行**。

## 改动

1. `worker.mjs`:fallback 改为
   `resolveSessionReuse({ sessionFile: given, storedCwd: job.session?.cwd, exists: existsSync })`
   —— 唯一那份规则定义了 `reuseFile = sessionFile && storedCwd && exists(sessionFile)`,
   而就地那份只写了 `given && existsSync(given)`(**少了 storedCwd**),
   正是 pi 说的"谓词更松"。现在"有会话文件但没有 cwd"这条语义差异**落在一处**。
2. `worker.mjs`:`decidedReused === undefined && given` 时打一行日志
   —— 这条路径**当前不可达**(`workerLaunch` 只有一个调用者且无条件注入 `sessionReused`),
   将来若有人新增第二个启动点它会复活,那行日志是唯一的信号。
3. `test/turn-cwd.test.mjs`:**真正**补上判据 ③。做法是**剥掉注释行**后,
   要求 `existsSync(` 只允许出现在"交给唯一规则"的那一行
   (`resolveSessionReuse({… exists: existsSync })`)——
   不能写成"文件里出现 existsSync",因为注释里、import 行上、以及那个合法位置都有它。

**变异实测**:把 fallback 改回 pi 报的那份"就地谓词"(保留 `decidedReused` 分支)
⇒ 判据 ③ **变红**(8/1);`cp` 恢复 + `cmp` 校验。
★ 这一条特别值得记:**我上一版判据对这个变异是绿的** —— 也就是说 pi 报的那个缺陷
当时**在测试里是不存在的**,只在代码里。

验证:pi 桥 509/509;另三个桥 fail 0;`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 07:12:17 +08:00
be8459cfe7 fix(deploy): 环境兜底自己依赖的命令也登记 + 自我检查排在用它们之前 + 静默改 HOME 必须留痕
pi 评审 2026-09-15 报的"第五次环境假设",在 `env-defaults.sh` **自己**身上。
他指出的**结构**成立:本文件用了 `id`/`getent`/`cut`/`df`/`awk`,一个都没登记进
`AGENTMAIL_REQUIRE`(那张表只登记**调用者**的命令,且由调用者在**source 之后**赋值)。

★ 但我实测发现**他给的两个具体后果在这台机器上不可达**,原因值得记下来:
`env-defaults.sh` 的 ④ PATH 自修(`:46`)在 PATH 里没有 `/usr/bin` 时会**把它加回来**
⇒ "从 PATH 里拿掉 id/getent/cut/df/awk"这种造法**必然被自修抵消**(我第一版探针就栽在这里:
`id -u` 根本没失败,我却按"失败了"往下推理,直到把 `command -v id` 单独打出来才看见)。
缺这些命令只可能发生在"**`/usr/bin` 里真没有它**"的机器上(distroless / 精简容器)。

所以这次修的是**能 durable 判定的三件**,而不是他描述的失败面:

1. **登记**:新增文件级常量 `AGENTMAIL_REQUIRE_SELF="id getent cut df awk"`。
   为什么不写进三个调用者的 `AGENTMAIL_REQUIRE`:那个变量在 source 时**还不存在**
   (`. env-defaults.sh` 在第 16/42/52 行,`AGENTMAIL_REQUIRE=` 在第 20/46/56 行),
   本文件没法把它自己那份追加进一个"稍后才被赋值"的变量 —— 追加了本次也不生效。
2. **自我检查排在用它们之前**(顺序即正确性,同 ①→④ 那条):新增 ③b-0 段,
   只用了**内建命令**(`command -v` + `printf`),所以能在"环境还什么都没兜"时跑;
   它现在位于 `:76`,而第一次真正用这些命令的 `id -u` 在 `:102`。
   ⇒ 缺 `df`/`awk` 时**不再静默丢门**:原来 `df -Pk … | awk` 拿到空串会落进
   `''|*[!0-9]*)` 那支"读不到 ⇒ 不判定",**②b 那道空间门直接消失**(那是门,不是提示)。
3. **静默改 HOME 必须留痕**:原先只在"**调用者给的** HOME 不可写"时 WARN,
   而"按身份推出来的那个也不可用"(root 的 `/root` 在非 root 下不可写;
   passwd 里是 `/nonexistent`)**悄悄换了 HOME** —— 与本文件存在的理由正好相反。
   现在两条路都 WARN。★ 这一条**可达且实测过**:
   `setpriv --reuid=65534 … bash -c 'unset HOME; source env-defaults.sh'`
   ⇒ `[WARN] 按身份推出来的 HOME=/nonexistent 不可用 … 改判到 /tmp/agentmail-home-65534`。

**判据 4 条**(`test/env-guard.test.mjs`,pi 桥侧,与该文件既有的环境判据同处):
① `AGENTMAIL_REQUIRE_SELF` 登记了这 5 个命令;② **顺序**:自我检查的行号必须**小于**
`id -u` 的行号(判据写成位置比较,而不是"有这段代码" —— 后者正是我这一轮反复写坏的形状);
③ 源码里存在"按身份推出来的 HOME 不可用"那句 WARN;④ **端到端**:非 root + 空 HOME
真的打出 WARN。

★ 这条端到端判据我写坏了**两次**,都记在文件里:
· 第一版用 `execFileSync` 只收 stdout,而 WARN 走 **stderr** ⇒ 红在"没找到 WARN"上,
  实际是**判据自己没读那一股**;
· 改用 `spawnSync` 后仍红 —— 因为 `deploy/lib/env-defaults.sh` 是 **0600**,
  `nobody` 读不到它,脚本**压根没跑起来**。这与"命令不在 ≠ 输出为空"是同族:
  **脚本没跑 ≠ 输出里没有那一行**。判据改为用一份世界可读的副本(文件权限是另一件事)。
  ⇒ 顺带发现并修掉:我用写文件工具建的 5 个文件都是 **0600**(该工具不理会 umask),
  已全部改 644(仓库既有约定;同目录其他文件都是 644/755)。
  **`cp -a` 会把 0600 带进生产快照**,所以这不是纯本地问题 —— 记一笔,未另开检查
  (工作区里还有 52 个 git 已跟踪文件是 0600,是既有状态、非本次引入,单独处理)。

验证:pi 桥 **509/509**(+4);`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 07:05:50 +08:00
0f7c817c1e fix(pi-bridge): 回报的 cwd 必须是实际用的那个 + 复用判定收成一处(pi 评审 §三)
pi 2026-09-15 §三 报的两条,我都逐行核了,**都成立**。

## 一、新建分支回报的 cwd ≠ 它实际用的 cwd(他给的最小修法)

```js
const opened = await openSession({ cwd: turnCwd, … });   // ← 用的是 turnCwd
return { ...opened, cwd, reused: false };                // ← 回报的是本地推导的 cwd
```

这个返回值经 `session_opened` → `state.cwd`,而 `state.cwd` **正是下一轮
`resolveTurnCwd` 的 `storedCwd`**(也即下一轮 `--rw` 的输入)。
⇒ `7fe2796` 建立的那条"**记下来的必须是实际用的**"不变量在这一支上不成立:
等式只在"本轮 rw vs 本轮 openSession"上闭合,**没在"本轮 rw vs 下一轮 rw"上闭合**。
改成 `return { ...opened, cwd: turnCwd, reused: false }`。

★ 可达性我说实话:**窄**。要 `resolvedCwd !== 本地 cwd` 得"父进程判复用而 worker 落到
新建分支",目前只有"父进程判完之后会话文件消失"这条 TOCTOU 窗口能造出来。
所以它现在**不是 bug,是一条会随别人改动而变成 bug 的不变量缺口** —— pi 的定性准确,
我照他的定性记,不夸大。

## 二、复用判定两处各写一份(结构性,而且是上面那条的前提)

```
pool   : state.sessionFile && state.cwd && existsSync(state.sessionFile)
worker : given && existsSync(given)
```

这正是前两轮刚消掉的那种"两处各写一份",而且它决定了 `resolvedCwd` 会不会被交给
一个**不消费它的分支** —— 上面那条能出问题,根子在这儿。
新增 `src/turn-cwd.mjs` 的 `resolveSessionReuse({sessionFile, storedCwd, exists})`
(**唯一一处实现**,`exists` 注入以便判据覆盖"在/不在"两种情形),
pool 用它判、并把结论一并注入 job(`session.sessionReused`),worker **消费**它。

## 三、判据(pi 建议的两条,都做了,且都验过区分力)

1. **等式/配对**:按分支回溯 —— 以每个 `return { ...opened, cwd:? X, reused` 为锚,
   回溯它前面最近的 `openSession(`,断言**同一个符号**。
   ★ 这条我**写坏过两次**,两次都是变异测出来的,都记在测试文件里:
   · 第一版用两串正则分别抓,`matchAll` 的懒惰量词**只抓到各一个**,
     而"只有一个"时包含关系天然成立 ⇒ 变异后照样全绿;
   · 第二版修好配对后,`[\w.?]+` 要求**至少一个字符** ⇒ 抓不到简写 `cwd,`
     (实际三处里两处是简写)⇒ 报"应当抓到三处,实际 1"。
   ⇒ **判据红了要查清是产线错了还是判据错了**;这两次都是判据错,不是产线错。
2. **单点**:`resolveSessionReuse` 必须是唯一实现;pool 必须用它;worker 必须消费
   `job.session.sessionReused`,且只在它缺失时才退回自己判。
3. 另加 `resolveSessionReuse` 四种输入组合(文件在/不在 × cwd 有/无)。

**变异实测(三条,均 `cp` 恢复 + `cmp` 校验)**:
· 把回报改回 `cwd`(= pi 报的那个 bug)⇒ 配对判据**变红**;
· worker 又自己判一份(`decidedReused = undefined`)⇒ 单点判据**变红**;
· pool 绕回两处各写一份 ⇒ 单点判据**变红**。

## 四、一处我要标出来的(结构上被保留、实际不可达的分支)

worker 里那条保底分支 `decidedReused === undefined ? 自己判 : 消费父进程的`
**实际上走不到**:`sessionReused` 为真要求 `state.sessionFile && state.cwd`,
而这两个字段只在 `session_opened` 里被**一起**写入 ⇒ 有 `sessionReused` 就必有 `sessionFile`。
保留它是为了老协议/异常帧不至于静默落到"新建会话"(比报错更糟),
但它**没有判据覆盖**,也没法用真协议触发 —— 按"跑不到的分支"记账,不假装它被验过。

验证:pi 桥 **505/505**(+3);`check-shared-libs` exit 0;`install.sh --check` exit 0;
`drift` 报 5 处待部署(与先前一致 —— 本轮只改已有文件,未新增文件)。
2026-09-15 06:53:53 +08:00
7fe279676a fix(pi-bridge)!: --rw 的 cwd 与 worker 实际用的 cwd 收成**一处决定**(pi 探针实测的第三例)
pi 2026-09-15 报、我用探针复核**成立**,而且它把 `99e6560` 的代价也一起说清了。

**分叉在哪**:cwd 有**两个来源**,而会话键 `keyOf(data) = data.session_id` **只看 session_id**:

```
父进程(算 --rw)  cwd = resolveWorkspaceCwd(to_workspace, …)   ← 来源:**这封信的地址**
子进程(真去干活)  cwd = job.session.cwd || resolveWorkspaceCwd(…) ← 来源:**会话上次实际用的 cwd**
```

于是同一 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`),父进程按**新地址**
算 rw,worker 却**复用会话、落在旧 cwd**。实测(真函数,`exists` 注入):

```
父进程算的 cwd  = /root/.pi/mail-sessions/sess-x
worker 实际会用 = /home/program/agentmail   (= 该会话的 state.cwd)
rw 含 worker 实际 cwd? = false  ⇒ 界内 EACCES
```

不是假想:本线程那条会话自上线起每次启动的 rw 都是 `/home/program/agentmail`,
那就是它的 `state.cwd` —— 此时来一封 `pi@.<会话>`(不带 path)的信就会踩到。

**★ `99e6560` 在这个组合上把失败方式变坏了**(这条必须记下来,我原先只报了它的好处):
· 之前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**)
· 之后:目录被建出来(那次修复的效果)⇒ **套上沙箱,而 rw 是地址算的那个**
  ⇒ worker 在会话自己的 cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的)
我用 `ensureCwd` 前/后各跑一次验证了这条因果,实测 `(b) 建了兜底目录: sandboxed = true,
rw 含 worker 实际 cwd? = false` —— 与 pi 报的 `sandboxed=true` 一字不差(他给了那个值,
我最初复现成 false,差别就在"兜底目录建没建",属于应用 `99e6560` 前后)。
⇒ **两处修复必须一起部署**,否则中间态是"界内也写不了"(比原先多问一次更糟)。
现在两次提交都在仓库、`drift` 报 5 处待部署,会一起上线。

**修法**:新增 `src/turn-cwd.mjs` 的纯函数 `resolveTurnCwd()` —— 输入全部来自**父进程也拿得到的
public 状态**(`sessionReused` / `storedCwd` / `toWorkspace` / `sessionKey` / 注入的解析函数),
父子两侧都从它取值;父进程再把决定**注入 job**(`session.resolvedCwd`),worker **消费**它、
不再自己推导。于是"三来源变一来源"落了第一步。

**判据(pi 要的那条等式)**:
· ★**等式**:用真 `sandboxWritePaths` 算 rw,断言"**`--rw` 里的 cwd === worker 会用的 cwd**";
  并附**反面对照**:按地址算出来的 rw **不含** `state.cwd`(= 修复前的错位状态)。
· 复用/非复用/`storedCwd` 为空三种输入各一条。
· 结构:pool 必须把决定交给 `workerLaunch` **并**注入 job;worker 取 cwd 的**每一处表达式**
  都必须先看 `resolvedCwd`。

★ **两处我自己的判据缺陷,都是变异测出来的,都记在测试文件里**:
1. 上一轮我在 `sandbox-launch.test.mjs` 写的 `assert.match(src, /resolveWorkspaceCwd\(/)`
   **本来就不该红也不该绿** —— 它护的是**写法**(池子直接调那个函数),而引入 `resolveTurnCwd`
   后池子改成"当参数传进去"(更对),它才红。**红得对**:它当初断言的是实现细节,
   不是它想要的性质。已改为断言性质(解析函数必须来自共用模块、且被显式传给纯函数)。
   顺带说明:它此前一直是**假绿**还是**真绿**我没法回测,但**它在引入纯函数后才红**说明它
   确实绑定了写法 —— 这正是"判据的适用范围没写出来"那一类。
2. 新版 worker 侧结构判据**第一版不具区分力**:只断言"文件里出现 `resolvedCwd`",
   把消费那一支删掉、退回 `job.session.cwd`,正则**仍然匹配**(别处还留着它)⇒ 变异后依旧全绿。
   已改为断言**优先级**(取 cwd 的表达式必须含 `resolvedCwd`)。
   ★ 变异实测:修好后重做同一变异 ⇒ 判据**变红**;两次变异均 `cp` 恢复 + `cmp` 校验。

**残余(未修,已进 DEBTS)**:**接管会话**那条路 worker 用会话文件 header 里的 `info.cwd`,
父进程读不到 ⇒ 首回合仍可能错位。父进程要拿它得用 `session-scan.mjs`,而 `readHeader` 未导出、
整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 240MB)。
彻底方向即 pi 说的:把"这次用哪个 cwd"完全收成父进程一处决定,worker 只消费。现在做不做等定。

验证:pi 桥 **502/502**;另三个桥 fail 0;`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 06:47:10 +08:00
99e6560122 fix(pi-bridge): 首回合也要套沙箱 —— pool 在算 launch 前先把兜底目录建出来(pi 报的同形缺口)
pi 2026-09-15 报的缺口,我先逐环核了再改(**成立**):

```
pool.mjs:190   cwd = resolveWorkspaceCwd(to_workspace, piMailFallback(session_id)).cwd
                 ↓ 地址不带 path(`pi@.<会话>`)⇒ cwd = ~/.pi/mail-sessions/<key>
                 ↓ **这个目录第一次不存在**
sandbox.js:168 if (!cwd || !exists(cwd)) return direct("拿不到会话工作区")  ← 不套沙箱
worker.mjs:388 ensureCwd(cwd, grouped)   ← 建目录的人**在决定之后**才跑
```

⇒ 无 path 的新会话**首回合不套沙箱** ⇒ 父进程不打 `AGENTMAIL_PI_SANDBOXED`
⇒ worker 的 `sandboxActive()` 为假 ⇒ `guardDecision(workspace, sandboxed=false)` = **`ask`**
⇒ **"界内不问"这条保证对无 path 新会话的首回合不成立**。

**端到端实测(不是推理)**,用真函数跑了一遍无 path 新会话:

```
解析结果 cwd = /root/.pi/mail-sessions/brand-new-key-probe | grouped = false
修复前(目录不存在): sandboxed = false | 拿不到会话工作区(…)—— 不猜   → guardDecision = ask
修复后(目录已建)  : sandboxed = true
  rw 含兜底目录 = true ; rw = […/brand-new-key-probe, /tmp, /root/.pi/agent]  → guardDecision = allow
```

**修法**(pi 给的最小修法):pool 在 `workerLaunch` 之前调 `ensureCwd(cwd, grouped)`。
`ensureCwd` 只在 `!grouped` 时建,所以 **N-2「笔误不落真目录」不受影响**:
path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回**兜底**+grouped=false ⇒ 建的是兜底目录,
笔误路径永远不会被创建。(这点我单独核过,因为"顺手建目录"最容易在这里越界。)
同时把 `resolveWorkspaceCwd` 的调用收成一次(原先在参数里内联算 cwd),
保证"用来判 exists 的 cwd"与"拿去当 --rw 的 cwd"是**同一个值**。

**判据(行为 + 结构,含顺序断言)**:
· 行为:兜底目录不存在时 `sandboxed=false` 且理由是"拿不到会话工作区"
  —— 这条**真规则是对的**,不能改成"无条件套"(`am-sandbox` 对不存在的 `--rw` fail closed,126);
· 结构:`src/pool.mjs` 里 `ensureCwd(` 必须出现在 `workerLaunch(` **之前**
  —— 光判"调没调"不够:顺序错了等于没补(这正是当初 worker 建目录的位置问题)。
★ 已先验区分力:移除 `ensureCwd` 调用 ⇒ 新判据**变红**(12/1),`cp` 恢复后 `cmp` 校验一致。

**为什么原判据护不住**:`sandbox-launch.test.mjs` 的 `fsRealShape()` 里 cwd 总是存在的,
而生产里这个 cwd **恰恰是 worker 自己建的** ——
与前面 `agentDir` 那次是同一个形状(夹具把生产形状简化掉的那一角,正是出问题的那一角),
只是换了另一角。这是同一条教训的第二个实例,值得并进 docs。

验证:pi 桥 **497/497**(+1);另三个桥 fail 0;`check-shared-libs.sh` exit 0;
`install.sh --check` exit 0;`drift` 报 4 处待部署(`src/paths.mjs` 新增 + 三个文件内容不同),
与本次改动一致 —— 这条红正是"待部署"的可操作信号。
2026-09-15 06:33:49 +08:00
011957ac97 refactor(pi-bridge): A 方案落地 —— 平台兜底值搬回平台侧,workspace.js 恢复逐字节相同
pi 定的 A(2026-09-15)。要点是:**契约的逃逸口不是豁免清单,而是
`resolveWorkspaceCwd(workspace, fallback)` 的第二个参数** —— 平台兜底值本来就该由平台侧传进去。
四个桥对照很清楚:

| 桥       | 平台兜底值在哪                                   | 怎么交给共用函数 |
|----------|--------------------------------------------------|------------------|
| opencode | 自己的 `index.js`                                | 传 `directory`   |
| zcode    | 自己的 `src/index.mjs`(`zcodeSessionFallback`) | 传进去           |
| dsh      | 共用的 `mailSessionFallback`(写死 `.dsh`)      | 直接用           |
| pi       | **原来造在共用模块里**(本次出的错)             | → 现在也传进去   |

⇒ `docs/PLUGIN-CONTRACT.md` 第 1150 行**不用改、也不该加旁路**:pi 只是唯一一个把平台值
造在共用模块里的,挪回平台侧就恢复了规矩。

**改动(与 pi 预测的形状一致)**:`workspace.js` 删掉那 15 行、`pool.mjs`/`worker.mjs`
各改一行 import,外加新文件 `src/paths.mjs`。`git diff --stat` 实测
`15 -` / `3 +-` / `3 +-` —— 没有多余改动。

**为什么新家是 `src/paths.mjs` 而不是 `lib/sandbox.js`**(pi 给了两个选项,我选前者):
`lib/` 按契约是"**候选共用**"目录,把一个 pi 专有文件放进去**正是这次出事的形状** ——
下一个人会问"它为什么不在 `ALL_LIBS` 里"。`src/` 下同名文件不会引起这个问题。
父子同源(worker 的沙箱 rw 由父进程算)由"两边 import 同一个模块"继续满足。

**验收四条(pi 给的,逐条实测)**:
1. `cmp opencode/lib/workspace.js pi/lib/workspace.js` **相同**;
   `check-shared-libs.sh` **exit 0**;`install.sh --check` **exit 0** ✓
2. 测试数**不降**:491 → **496**(+5,见下)✓
3. 给 `piMailFallback` **补测试**(原先一条都没有 —— 这正是当初的不对称:
   四个桥 `npm test` 全绿、只有 `check-shared-libs` 抓得到)→ 新增
   `test/pi-paths.test.mjs` 5 条 ✓
4. 四个调用点改 import 后 diff 只应是 import 行 + 删掉的那 15 行 ✓

**新测试为什么是独立文件**:`test/workspace.test.mjs` 在四个桥里**逐字节相同**
(md5 一致,属共用测试),往里加 pi 专有断言会把共用测试也弄分叉 —— 与 `lib/` 同一条规矩。

**判据含结构断言 + 行为断言**,并已按纪律先验区分力:
· 变异 1(把 `piMailFallback` 塞回共用模块 = 本次分叉的形状)⇒ 结构判据**变红**;
· 变异 2(把 `.pi` 改成 `.dsh`)⇒ 三条行为判据**变红**;
两次变异都用 `cp` 恢复并以 `cmp` 校验一致。

★ 顺带记下 pi 指出的一条:`check-deploy-drift.mjs` 的判据 ① 是我扩到"比全部 133 个文件"的,
所以这次分叉**它能抓到** —— 但 `check-shared-libs` 先红了,说明两道门的分工是对的。
2026-09-15 06:31:18 +08:00
7f03ee7ca2 fix(pi-bridge)!: 沙箱 rw 漏了 agentDir 本身 —— pi 侧的 Agent 整个不工作(凭据存储的锁文件写在它直下)
**症状(实测,2026-09-15)**:pi 处理不了任何一条消息 —— 回给 dsh 的是一封
「处理失败」通知:

    auth: Credential store read failed for llmsproxy:
    EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'
    已尝试 1 个:llmsproxy/AUTO

即**不是某次工具调用失败,而是这个 agent 完全不工作** —— 而它正是这条线上唯一的对端。

**根因**:`sandboxWritePaths` 把 `<agentDir>/sessions` 放进了 `--rw`,
**却没放 `<agentDir>` 本身**:

    pushDir(join(agentDir, 'sessions'));   // 少了 pushDir(agentDir)

而 pi 的凭据存储在 **`<agentDir>` 直下**建锁文件 `auth.json.lock`
⇒ Landlock 拒绝在 `agentDir` 里新建条目 ⇒ 读凭据这条路直接失败。

**因果链已在真二进制上闭合**(`/opt/agentmail/bin/am-sandbox`,非推理):

    # 只给 sub、不给父(= 修复前的形状)
    --rw /tmp/ll2/allowed/sub  →  echo > /tmp/ll2/allowed/newfile
    /bin/sh: cannot create …: Permission denied     ← 就是 pi 的那个 EACCES
    # 给父目录(= 修复后的形状)
    --rw /tmp/ll2/allowed      →  退出码 0,文件建出来

也确认了 `am-sandbox` 的语义确实是「**及其子树**」(`--rw` 给出的目录连同子树可写),
所以补上父目录这一条就够,不需要为锁文件单独加 `--rw-file`。

**为什么以前的判据护不住这一处 —— 夹具形状把出问题的那一角简化掉了**:
`sandbox-launch.test.mjs` 每个用例手写
`fsWith([..., `${HOME}/.pi/agent/sessions`, ...])`,**只列 sessions、不列 agentDir**,
于是"agentDir 在不在 rw 里"在这套测试里**永远测不出来**。
已加一个贴着生产形状的夹具 `fsRealShape()`(agentDir 与 sessions **都在**),
并把两个 workerLaunch 用例换成它。

**判据(两条,含反面对照)**:
· `agentDir` 本身必须在 `--rw` 里,且 `sessions` 也仍在(两个写点,不是替代关系);
· 反面对照:`agentDir` **不存在**时不得硬塞进 rw ——
  `am-sandbox` 对不存在的 `--rw` 路径 fail closed(退出码 126),
  所以"加 agentDir"不能变成"无条件加"。
★ 已按既定纪律先验区分力:临时移除 `pushDir(agentDir)` ⇒ 新用例**变红**(11/1),
`cp` 恢复后 `cmp` 校验一致。

**旁注(写点清单的教训)**:原来的注释只按"我们已知的写点"列(会话工作区、临时目录、
/dev/null、sessions、配置目录),而**凭据存储在它自己的目录里加锁**是另一个写点,
且它在**读凭据**这条路上 —— 所以漏了它的症状不是"某个工具不能用",而是"agent 不工作"。
写点清单要按**真实进程的行为**列,不能只按已知的那几处列。

验证:pi 桥 491/491(新增 2 条);sandbox-launch 12/12;`go test ./cmd/am-sandbox/` ok。
2026-09-15 00:13:13 +08:00
9fa509844a feat(pi-bridge): 有沙箱时 workspace 档不再逐条问人 —— 界内不问、界外内核拒
沙箱上线后,"工作区档"的语义第一次可以按档位表兑现:**边界是内核在守**,再问一遍
只是让人点一次"同意",点完该失败的还是失败(人点了也挡不住内核)。所以闸门改成
**按档位 × 有没有沙箱** 决策,纯函数收在 `lib/sandbox.js`:

| 档位 | 沙箱 | 决定 |
|---|---|---|
| full | 任意 | allow(发件人已声明全权) |
| plan | 任意 | block(本档只许看;沙箱是第二层) |
| workspace | **在** | **allow** ← 这一步改的(界内不问、界外 EACCES) |
| workspace | 不在 | ask(回退到原来那唯一一层) |

没有沙箱时**继续问** —— 这条是"不会更松"的保证:沙箱缺失/未装/被关掉时行为与改前
逐字一致。

## 标记不等于事实:worker 自证

`AGENTMAIL_PI_SANDBOXED=1` 只是父进程的**声明**。判断错会让闸门既不问也不拦
(最坏的一类),所以 worker 现场自证一次:往界外写一个金丝雀(`/.agentmail-sandbox-canary-<pid>`,
根目录永远不在 rw 里)—— 写得进去 ⇒ 判为"没有沙箱",**退回逐条问人**(方向取严);
被拒(EACCES/EROFS/EPERM)⇒ 在边界内。结果缓存在进程级。

## 顺带把 plan 档变成真的只读

plan 档的 rw 清单**不含会话工作区**(只有临时目录/pi 会话登记/桥配置/`/dev/null`):
"一个字都不许写"从"钩子拒绝 + 提示词"{升级为内核第二层。

## 判据

- `sandbox-launch.test.mjs` 10 条(原 6 + 新 4):决策矩阵四档 × 有无沙箱、
  自证两侧(被拒=在边界内;能写=必须判"没沙箱")、plan 档 rw 不含工作区、
  "pool 设标记 + worker 自证 + 走 guardDecision"三处接线在。
- 变异:把 workspace+sandboxed 改回 'ask' ⇒ 那条断言红。
- pi 桥全套 489 项通过。
- ★ 又被自己撞一次同类坑并当场红:新变量起名 `decision`,与同一个函数里后面那个
  `const decision = await new Promise(...)` 撞名 ⇒ SyntaxError。上一轮的 `spawn`
  撞名也是这一族(局部名与既有作用域重名),两次都是**语法检查/测试**立刻抓到。

## 文档

`docs/PLAN.md` §7.11 的 L5 矩阵与"向更严取整"那条纪律、`docs/API.md` 的档位表
都改成新语义(有沙箱=内核拒、无沙箱=逐条问),并写明 pi 的沙箱为什么必须由宿主提供。
2026-09-14 23:50:35 +08:00
64f002cf66 feat(pi-bridge): worker 按档位套沙箱 —— plan/workspace 进 Landlock 边界,full 档不进
上一步(1f48c5c)做出并验了边界工具;这一步把它接到 worker 的启动路径上,
于是「工作区档 = 本目录内可动」第一次由**内核**保证。

## 规矩

- `plan` / `workspace` 档 → `am-sandbox --rw <会话工作区> … -- node worker.mjs`
- `full` 档 → **不套**(发件人已声明全权,与档位表一致)
- 拿不到会话工作区 → **不套**,并把理由打进日志(猜一个 `--rw` 会让"界内也写不了")
- 启动方式从 `fork` 换成 `spawn`(fork 只会 exec node,套不进中间那层),
  `stdio` 里带 `'ipc'` 时 node 同样设 `NODE_CHANNEL_FD`,而沙箱是 exec 透传
  ⇒ worker 的 `process.send` 照常可用

## rw 清单是**实测得出**的,不是想当然

`lib/sandbox.js` 里那几条(会话工作区 / `os.tmpdir()` / `<agentDir>/sessions` /
`AGENTMAIL_CONFIG_DIR` / `--rw-file /dev/null`)每条都对应一个真实的失败模式:
少了 `/dev/null`,`cmd 2>/dev/null` 一律 Permission denied(实测撞到);少了
`<agentDir>/sessions`,回合结束保存会话就失败。真机验证:一个**真实的 pi agent**
跑在边界里,界内写成功、`/opt` 被拒(Permission denied),并如实汇报两者。

## 两处必须收成一处的东西

- 会话工作区由**父进程**用与 worker 同一个函数解析(`resolveWorkspaceCwd`)——
  父进程猜一个目录当 rw、worker 落在另一个,症状是最难查的那一类
- `piMailFallback` 从 worker 挪进 `lib/workspace.js`:父进程要用同一个兜底值

## 判据与踩到的坑

- `sandbox-launch.test.mjs` 6 条行为断言(套/不套、rw 里有 cwd 与 /dev/null、
  `--` 之后是 node+worker、拿不到 cwd 时的理由、env 开关三态、rw 去重与只收存在的路径)。
  变异"永不套沙箱" ⇒ 恰好那几条红。
- ★ 池测试原先会**随这台机器装没装 am-sandbox 而变** —— 那正是假绿的来源。
  给 `createWorkerPool` 加了 `env` 注入点,测试显式 `AGENTMAIL_PI_SANDBOX=off`。
- ★ 给 import 起名 `spawn` 撞上本文件已有的 `function spawn(job)` ⇒ 自己调自己
  (`RangeError: Maximum call stack size exceeded`,池测试当场红)。改名 `spawnProcess`。
- pi 桥全套 485 项通过。
2026-09-14 23:43:06 +08:00
2a5e3d7d15 fix(auth): 四家桥的读端点也带上会话收窄 + 转发同一条命(工作区隔离第 2 步)
第 1 步(1b8cd43)把工作区判据放在服务端、pi 桥接上了线。这一步补齐另外四家,
并把**转发**纳入:转发是"把原文引出去",能转发就等于能读到那条线索的全部内容,
与 read_mail 同一条命(服务端 ForwardMail 也加了同一道校验)。

四家各自的会话来源,与各自的 read_inbox 同一处(不引入第二个来源):
- dsh:`mailSessionOf(exec)`(工具第二个参数)—— 五个读工具原本没接 exec,这次补上
- opencode:`reverseMap.get(context.sessionID)`
- zcode:`process.env.AGENTMAIL_SESSION_ID`(一轮一个进程)
- homeagent:`p.currentSessionID`(新增 `scopeQuery(sep)`,与 inboxURL 同构)

判据(每条两侧都钉:包住了 / 没包住的不存在):
- dsh:静态对照,且额外钉 **dist** —— 那是真被 dsh 加载的那份(main: dist/index.js),
  src 改了忘了 build 就是"源码对、线上旧代码"
- opencode / zcode:同上(opencode 还钉"会话来自 context 而不是模块级变量")
- homeagent:起 httptest 当网关,**五个读工具 + 转发真调一遍**,断言请求 URL 带
  session_id;对照侧:不在回合里(currentSessionID 为空)时不许带
- pi:把 post 的 URL 也纳入记录,forward 进用例表

★ zcode 那条判据我第一版**对照组写错**了:对照组只写裸 URL,而它本来就是
`withScope(\`裸URL\`)` 的子串 ⇒ `!includes(bare)` 恒假。夹具形状不对时判据会以
"恒红/恒绿"的方式骗人(这次是恒红,一眼可见;恒绿就麻烦了)。

变异:homeagent 去掉 read_mail 的收窄 ⇒ 恰好那条断言红。

(工作区共享,只 add 了上面这 12 个文件;dsh 的 dist 是 gitignore 的,由
redeploy-plugin.sh 在 staging 里构建。)
2026-09-14 23:18:12 +08:00
1b8cd43935 fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,
还需要我亲自去解释。」

## 根因不是漏了一个 WHERE,是隔离单位选错了

Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定),
所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是
`AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里"
—— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话
也成立。

现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」
(08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11
—— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。
更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**,
所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。

## 改法:补一维,而不是逐个端点打补丁

- 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据)
  ② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。
- 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace ——
  **不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。
- 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` /
  `list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄:
  跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。
- 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走,
  但"还有谁没接线"必须可观测(另四家桥仍走这条路)。
- pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。

## 顺手修掉一个真 bug

联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是
"forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`,
SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。
管理员 `?all=true` 走的正是这条路。现在 $1 恒传。

## 判据(两侧都验 + 变异)

- repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 /
  未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在);
  建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。
- ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄,
  于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。
- 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 /
  SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。
- pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检;
  变异 read_mail 去掉收窄 ⇒ 恰好那一条红。

(工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
2026-09-14 23:03:25 +08:00
d616582e96 fix: 回滚 user-question.js 那一搬(它把 check-shared-libs 打红两处),并把 drift 的非运行时差异摘出来
pi 逐处对文件后指出:我按"本平台不可达 ⇒ 搬去 test/lib/"把 `lib/user-question.js`
搬走,打红了 `deploy/check-shared-libs.sh` 两处(实测确认,脚本真退出码 1):

    共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
    共用测试已分叉:test/user-question.test.mjs(opencode vs pi)

根因不是取舍而是口径:**`lib/` 上挂着两条方向相反的不变量** ——
① 共用模块四方逐字节同源(`check-shared-libs.sh`,连相对路径一起钉);
② 本平台生产可达(我新加的规则)。而 `user-question.js` **是 dsh 桥的生产代码**
(`plugins/dsh-mail-bridge/src/index.ts` 引用它)⇒ 两条必然冲突。
**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;可达性只能当**报告**,
不能当搬家判据。教训的形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
我只看⻅了自己那条。

改动:
- `user-question.js` 与它的测试回到 `lib/`、`test/`(路径也与 dsh 侧一致),
  两边逐字节相同已复验;`check-shared-libs.sh` 退出码 0。
- `reach.mjs` 增加 `sharedLibNames()`:直接从 `check-shared-libs.sh` 的 `ALL_LIBS`
  读共用清单做豁免(不手抄常量),并把"进快照但本平台不可达"降级为**报告**。
- `layout-boundaries.test.mjs` 增加回归判据:共用模块必须留在 `lib/`、
  测试相对路径与 dsh 一致、两侧逐字节相同。
- 删掉 `reach.mjs` / `docs/DEV-TOOLING.md` 里那句**无据的机制说明**
  ("user-question 走前缀动态 import"):`localRefs` 的三条正则只认引号字面量,
  对模板字面量形状是**盲的** ⇒ 那句若为真,搬走的就是生产代码而两条判据都会绿。
  pi 读了 `src/` 下九个文件都找不到引用,我也确认是记忆偏差;理由改用 `addressing.js`
  (传递可达、`src` 直接引用数为 0)—— 它已足够证明"直接引用数不是可达性"。

顺带按 pi 的第二条建议:`deploy/check-deploy-drift.mjs` 判据 ① 把
**非运行时差异**摘出来(`jsonTestOnlyChange`,只豁免 `scripts.test` 一类字段,
只对"两边都在、仅内容不同"的文件生效)。理由:一条**永远黄、没人打算为它动手**的判据
唯一的下场是被学会忽略,那时真正的运行时漂移会被一起忽略。
⚠️ 摘的条件很窄 —— **把运行时差异误判成非运行时比恒黄更坏(那是假绿)**,
所以 `main`/`start`/`dependencies` 变了、或解析不了,一律仍算运行时;
纯函数加了六个反/正样本的判据(含三个"必须算运行时"的)。

(该文件同时有另一条会话的改动,未提交、我未触碰;本次只加了我这一段。)

验证:`npm test` 463/463;`check-shared-libs.sh` 退出码 0;`--self-check` 18 条全过。
2026-09-14 20:11:58 +08:00
fb85a8728d refactor(pi-bridge): 定下 lib/ 与 test/lib/ 的边界 —— 三个测试侧模块原来会随部署进 /opt
pi 复核后指出:`lib/` 会被 `cp -a "$SRC/." "$STAGING/"` **整份打进生产快照**
(排除清单只有 `test/`、`.git`、`node_modules/.cache`),而我们那三个测试侧模块
(`tmp-space.mjs`、`env-error.mjs`、`session-fixtures.mjs`)都住在 `lib/` 里。
后果不是几 KB,而是"漂移 N 处"这个数字**虚高**、哈希清单变长 ——
而"手抄哈希清单"正是我们刚定性为会过期的东西。

## 规则写成**可判定的**,不写成约定

    lib/      = 从生产入口可达的模块(会进快照)
    test/lib/ = 只被测试引用的模块(test/ 不部署、也不被注册进套件)

`test/lib/reach.mjs` 真去走一遍 import 闭包(种子 = `src/index.mjs` +
源码里 `new URL('./x.mjs', import.meta.url)` 这类**按路径 fork 的子进程入口**)。

★ 顺带纠正 pi 的规则表述:他写的是"被 `src/` import",但实测 22 个 `lib/` 模块里
有 4 个 `src` **直接**引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引)、
`user-question.js`(走前缀动态 import)、`mail-session-id.js`、`crash-notify.mjs`。
**直接引用数不是可达性**,所以判据真走图而不是 grep。
★ 也纠正他的排除清单名字:脚本里没有 `EXCLUDE_DIRS` 这个变量,就是一条 `rm -rf`。

## 本规则多抓到一个 pi 没发现的

`lib/user-question.js` 也是**只被测试引用**(只有 `test/user-question.test.mjs` 用它)
⇒ 同样会进快照。已一并移到 `test/lib/`。剩下 `mail-session-id.js` 与
`crash-notify.mjs` 是**谁都不用**(生产与测试都不可达)—— 那是遗留物,
不动它们(不属本次范围),但记录在此。

## 新增:因果**无关**的运行期判据

`test/lib/run-suite.mjs`:跑套件并从**同一次运行的 TAP**里数结果行,任何用例名
出现两次就红。为什么需要:静态那条(测试文件不许互相 import)只能发现**已知成因**。
实测跨文件重名**不会被 runner 拦**:两个文件各写一个同名用例 ⇒
`# tests 2 / # pass 2 / # fail 0`,两句 `ok`,零警告。

判据锚在 `^(ok|not ok) <n> - <名字>`(**结果行**),不是"名字出现过"——
pi 先前那条 `grep -c '<名字>'` 给 4 是因为 TAP 里名字既出现在 `# Subtest:` 头、
又出现在结果行,**2 倍效应 + 2 倍噪声恰好同值**,若行种类是 3 就会把两次读成三次。
本脚本自带 `--self-check`(干净样本放行 / 重复样本点名 / 只出现在头里的不算重复 /
名字含 `#` 不被截断)。

`package.json` 的 `test` 改为:
    node test/lib/env-preflight.mjs && node test/lib/run-suite.mjs

## 判据全进套件

`test/layout-boundaries.test.mjs`(新):生产可达性不碰 `test/`、`test/lib/` 里不许藏
运行时模块、测试文件不许互相 import、`npm test` 必须接上 run-suite 那一层。
原来放在 `env-guard.test.mjs` 里那条"夹具不在测试文件里"已移到这里(集中边界判据)。

## 变异自检(两条都实测红了才留下)

- 造一个与巨行用例**同名**的探针文件 ⇒ `npm test` exit 1 并点名
  `2× ★巨大的 message 行不进内存也不影响解析`;
- 往 `src/gateway.mjs` 加一行指向 `test/lib/run-suite.mjs` 的真 import ⇒
  边界判据红并指出 `生产可达了测试代码:test/lib/run-suite.mjs`。
  两条探针均已删除、`src/gateway.mjs` 用 `git checkout` 还原并 `cmp` 校验一致。

顺带修一处路径:`env-guard.test.mjs` 里 `PREFLIGHT` 仍指向旧的 `test/env-preflight.mjs`
(前置脚本已移入 `test/lib/`)。

验证:`npm test` **462/462**、结果行重复检查 0 个重名、set 全绿。
2026-09-14 20:00:16 +08:00
f1059c5a6b fix(pi-bridge): 夹具移出测试文件(import 它会二次注册整套用例)+ 测试侧写点全部兜住 + 判据不再往 /tmp 留垃圾
pi 复核了探针形状(论证闭合),又报了三条,全部实测成立。

## 一、从测试文件 import 助手 ⇒ 那套用例被**再注册一遍**(最实质)

`env-guard.test.mjs` 曾从 `session-scan.test.mjs` 取 `writeSession`。`node --test`
默认每个文件一个子进程,模块导入是进程内的 ⇒ 那个文件的 16 条用例在 env-guard
的进程里**又注册了一遍**。

实测确认:TAP 里巨行用例(单条往临时目录写 ~12 MiB)出现**两次**
(`ok 87` / `ok 353`),测试总数 475。**判据自己在加倍压 /tmp** —— 而 /tmp 正是
这次事件的主角。修完:459 条,巨行用例 1 次。

修法就是 pi 指的形状,也正是 `translateEnvError` 那次的同一手法:
夹具移到**非测试模块** `lib/session-fixtures.mjs`(可被引用,不被注册进套件)。

## 二、测试侧写点还是裸的

`makeRoot()` 的 `mkdtempSync`、以及 `writeSession` 里在 try **之外**的 `mkdirSync`
(ENOSPC 也可能从这里出来)⇒ 绕过前置脚本时抛的仍是原始英文堆栈,
而"绕过前置也要说人话"正是这套兜底存在的理由。现在整段包一层,与 `selfCheck()`
同一形状:**覆盖范围不取决于"我以为的哪一行"**。

## 三、判据往共享 /tmp 里留垃圾

`writeSession(tmpdir(), '--probe--', …)` / `'--probe2--'` 每跑一次就留两个目录、
且永不清理。现在改用 `os.tmpdir()`(纯字符串,不 statfs)当根:那两条的创建都被
假写打断 ⇒ 目录根本不会建出来 ⇒ 既不读也不写真实临时目录。

## 四、一条新判据替代原来的文本接线检查

守**机制**:解析测试文件里的模块引用(静态 `from` / 动态 `import()` / `require()`),
任何指向另一个 `.test.mjs` 的引用都算违规 —— 注释里提到文件名不算(注释不会注册用例)。

这条判据自己踩了两次,都留在注释里:
  第 1 版 只匹配静态 from ⇒ 漏掉动态导入;
  第 2 版 "文件里出现别的测试文件名" ⇒ 把**注释里的散文引用**也算成违规
          (本仓库有 3 处这样的注释,逼人删掉有用的注释),
          而且**它被自己注释里的示例字面量扫到**。
**过宽和过窄都是坏的** —— 这正是这一串评审反复出现的同一族错误。

验证:`npm test` **459/459**(少了 16 条重复注册);巨行用例出现 1 次;
`--self-check` 18 条全过;`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
2026-09-14 19:51:44 +08:00
6b7c12d9da fix(pi-bridge): "开关被认"那条判据自己也有假绿 —— 我按真实测量分叉,于是永远走短路分支
pi 评审第二轮指出:上一版"开关真的被认"只在"真实测量不足"那个分支里断言,
机器一恢复健康(/tmp 被清空)这条就退化成"只验 --measure"的弱检查,
而它守的恰恰是"开关别静默失效"。

认下之后我做变异(把开关整个忽略掉、永远用真实测量)验证,**发现比这更糟**:
那条新写的判据**在变异下照样绿**。

原因是我写成了 `realAvail < MIN_FREE_BYTES ? (不足分支,只看退出码) : (充足分支)`,
而本机真实可用**就是 0** ⇒ 永远走不足分支;开关被整个忽略时,回退测量同样给
exit 2 ⇒ 断言通过。**"断言在,区分力不在"** —— 与 pi 点的是同一类病,
只是它藏在一个**跑不到的分支**里(嵌套三元短路),比"分支退化"更难看出来。

修法:不跟真实测量比,**让两个探针自己互为反面**,并断言**输出里的判定词**
(不只看退出码 —— 退出码可能与真实状态巧合相同):

    探针 A:注入 1 字节          ⇒ exit 2 + 必须打印「< 需要」
    探针 B:注入 128 MiB(>阈值)⇒ exit 0 + 必须打印「≥ 需要」

开关被忽略 ⇒ 两次都按真实测量给同一个答案 ⇒ 至少一条红。这个论证不依赖真实测量
是多少。变异自检实测:注入"忽略开关"的变异后,第 23、24、25 三条一起红。

顺带修一处**HEAD 里就带着的坏行**:第 162 行的 `test(..., () => {` 后面被塞进了
`// 覆盖…` 注释(上一次编辑吃掉了那个换行),整行不合法。这次一并拆回两行。

过程中我两次改坏文件(一次把手写 `replace` 的锚点算错、把"非法参数"那条整条删掉),
两次都靠 `git checkout HEAD -- <file>` 拉回重做 —— 这正是上一轮写进
`lib/env-error.mjs` 的那条纪律(变异/改写只对已提交文件做、还原只走 git)当场生效。

验证:`npm test` **475/475**;env-guard 单跑 30 条全过(含 24 号在两个探针下的双断言)。
2026-09-14 19:46:07 +08:00
87359588eb fix(pi-bridge): 评审第二轮 —— 判据在健康机器上会退化、"一处覆盖"取决于入口、笔误参数静默放行
pi 读了 `5bc579f` 之后报了两条新的 + 三条小的,全部认下并落地。

## 一、"开关真的被认"那条判据在 /tmp 被清空后失去分辨力

上一版只在"真实测量不足"那个分支里断言(注入大数必须放行)。问题是:
**"不足"正是机器恢复健康后会消失的条件** —— 那天这条判据就退化成"只验
`--measure` 可用"的弱检查,而它守的恰恰是"开关别静默失效"。

两个方向是对偶的、各守一个机器状态,所以改成**按实测分叉、在两个分支里断言相反的方向**:

    真实不足 ⇒ 注入大数必须放行   (开关被忽略则回退测量 ⇒ 2 ≠ 0 ⇒ 红)
    真实充足 ⇒ 注入 0    必须 exit 2(开关被忽略则回退测量 ⇒ 0 ≠ 2 ⇒ 红)

量不到就 `assert.fail` 并说明"无法分叉"—— 不静默跳过(跳过会把"失去分辨力"
伪装成"验过了")。另把"端到端"那条的两个方向拆明白:只验"不足⇒2"时,
一个恒报不足的坏守卫也能绿。

## 二、"一处覆盖全部写点"成立的前提是"从 main() 进来"

`selfCheck()` 是**导出**的(用途就是被直接调),而兜住那三处裸写的 catch 在
`main()` 里 ⇒ 任何绕过 `main()` 的调用者撞上 ENOSPC 拿到的仍是原始英文堆栈。
**"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根**,所以把整段包一层
(`body()` + 统一 catch):与入口无关,`main()` 那个退化为冗余的第二道。
实测:`TMPDIR=/tmp node -e 'import("./deploy/check-deploy-drift.mjs").then(m=>m.selfCheck())'`
现在拿到的是「环境不足…这是环境问题,不是检查器的问题」。

## 三、`--inject-avail=abc` 静默放行(笔误 = 跳过守卫)

`Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。现在按仓库约定处理:
**非法值 exit 2,未知参数也 exit 2**(`--measure` 少写 `=` 同样炸)。
`null` 仍是合法值("没测到 ⇒ 放行"是有意的),加了判据把这两个方向都钉住。

## 四、三条小的

- 两份实现(`lib/env-error.mjs` 的 `translateEnvError` 与 `deploy/` 的
  `describeEnvError`)**不去重**,但两边各写一句"为什么不复用":
  `deploy/` 的独立性比去重值钱(那份文件头整段在讲"服务不该依赖仓库是否存在")。
  并写明**第三份拷贝出现时再考虑共用**。
- 写点计数口径写进注释:本函数 **6 处写** = `mkdtempSync`×2 + `mk()` 内 ×2
  + 三处裸写。免得与别处"五处"的说法对不上(上一封信里两个实测数字就是这么被误读的)。
- 变异自检的纪律补进 `lib/env-error.mjs` 头注释:**先证明能撤回来再注入变异,
  且还原路径不能依赖被测对象**(那次把备份写进 `/tmp` —— 正是当时被占满的资源,
  备份没写成而变异已覆盖源文件)。现在只对"已在 HEAD 干净提交"的文件做变异,
  还原一律 `git checkout HEAD -- <file>`。

验证:`npm test` **475/475**;`--self-check` 18 条全过;
`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
2026-09-14 19:42:58 +08:00
5bc579f910 fix(pi-bridge): 按评审补三处 —— ENOSPC 只盖了一个写点、旧注释自相矛盾、兜底判据钉的是文本
pi 逐字读了上一版落地的代码,报了三个"还差一格"。都不是推翻,是同一根因
("环境不足伪装成别的")在这套守卫自己身上的残留。

## 一、翻译只覆盖了 5 个写点里的 1 个(最实质)

`selfCheck()` 要在临时目录造两棵样本树,写点有**五处**;上一版只把 `mk()` 里那两处
包了 try/catch,后面三处(`README.md` / `extra.mjs` / `test/t.mjs`)裸写。它们撞上
ENOSPC 时异常冒到 `main()` 的 catch:**退出码是对的(2),但打印的是原始英文
`ENOSPC: no space left on device, write` 加一段指向本文件的堆栈** —— 也就是上一版
要治的那个信号("看起来像检查器坏了")**恰恰在最需要它的路径上还在**。

改法:抽一个 `describeEnvError(e, what)`,在 `main()` 的 catch 里**统一**换成人话。
一处覆盖全部写点,以后再加写点也不用管。`mk()` 里那段裸判断一并换成调用它。

## 二、`lib/tmp-space.mjs` 的头注释在说谎(读者已误读一次)

原文写"`availBytes` 为 `null`(读不到 / 平台不支持 / **字段为 0**)" —— 而"字段为 0"
指的其实是 `statfs.bsize === 0`(测量层确实 `if (!s.bsize) return null`),读起来
却像是在说"可用 0 字节也算不知道" —— **正是我上一版刚踩、刚补判据的那个坑**。
pi 第一遍读就误读成了后者。已把两个 case 分开写死,并注明"这条注释写错过一次"。

## 三、兜底判据钉的是文本,不是机制

`env-guard.test.mjs` 原来对 `session-scan.test.mjs` 断言 /ENOSPC/ 与 /环境/,
而那段**解释性注释里本来就有这两个词** ⇒ 删掉整段翻译逻辑、只留注释,判据照样绿。
这正是 `permission-note.test.mjs` 自己警告过的"钉装饰不钉机制"。

改法(按仓库规矩,纯函数 + 反面样本 + 接线):
- 翻译逻辑提到 `lib/env-error.mjs` 的 `translateEnvError`(纯函数);
- 判据喂构造出来的错误验**行为**:ENOSPC 必须翻译且带药方、普通错误必须**原样返回
  同一个对象**("什么都翻译"比不翻译更坏 —— 真缺陷会被套上环境的外衣);
- `writeSession` 抽出 `write` 参数(**只为测试存在**,`pool.mjs` 的 `workerPath` 同一手法),
  于是"接线还在不在"是**行为**判据:喂一个必然 ENOSPC 的假写,翻译必须发生。
  抽它的理由写在注释里 —— 是"可被反面样本喂",不是复用(只有一个调用点)。
- 变异自检:删掉写点的翻译 ⇒ 第 28、29 两条立刻红(已实测)。

## 四、顺带三处小的一致性问题

- 端到端那条判据原靠"本机 /tmp 恰好是满的"来验 —— 那是把判据绑在**会变的环境**上,
  /tmp 一清空就自动跳过、无声失效。前置脚本加两个**只为测试存在**的开关:
  `--measure=<dir>`(只量并打印 JSON)与 `--inject-avail=<n>`(绕过测量直接判定),
  于是"不足⇒exit 2"与"充足⇒放行"在任何机器上都验得了(两个方向都验,缺一即假绿)。
- 判据 ⑥ 原先只有它自己带圈号前缀,读者会去找不存在的第 ⑤ 条。改成 `checkLayout`
  的每条都带**连续 id**(1..N),`name` 是纯展示串,并加一条"id 不许跳号"的自检。
- 两个实测数(`729_088` 字节 = 0.70 MiB、`712` 字节)是**不同时刻**量的,并列摆着像抄错,
  各标了来历;`lib/tmp-space.mjs` 里那条改用"一度真是 0"的说法。

验证:`npm test` **474/474**(上一版 453);`--self-check` **18 条全过**(新增 id 连续);
`npm test` 在临时目录不足时仍 exit 2 且一条用例都不跑。
2026-09-14 19:39:07 +08:00
0b548b8fcf test(pi-bridge): 临时目录满时不再伪装成内存缺陷 —— 前置自检 + ENOSPC 兜底
现场(2026-09-14 实测):`/tmp` 是 tmpfs,被别人占满,`statfsSync` 实读
`bavail*bsize` 只剩 **0.70 MiB**。此时 `npm test` 红一条

    not ok 323 - ★巨大的 message 行不进内存也不影响解析
      error: 'ENOSPC: no space left on device, write'

那条红的**形状指向内存**(用例名里就写着"不进内存",而它恰好是往临时目录写文件的
用例)⇒ 下一个踩到的人会去 `session-scan.mjs` 找一个**不存在**的内存缺陷。

改:
- `lib/tmp-space.mjs`:测量与判据分开,判据是纯函数 `judgeSpace`,喂字节数即可验;
  读不到可用空间(null/NaN)⇒ **不判红**(不知道 ≠ 不对,否则会造出"总在亮"的红灯)。
  **但 0 字节不是"不知道"** —— 第一版把 `<=0` 一并当"没测到",于是 `bavail` 只剩
  712 字节时前置自检放行、紧接着 17 条用例 ENOSPC 全红:前置自检装了等于没装。
  阈值 32 MiB = 实测单条用例最大写入量(`session-scan` 那条写 3×3 MiB 行 ≈ 12 MiB)
  ×2 + 8 MiB 机动,不是总容量的百分比(百分比在这套测试上没有依据)。
- `test/env-preflight.mjs`(名字不带 `.test.`,不被 glob 收进用例):
  `package.json` 的 test 改成先跑它;不足时打印实测/阈值/目录并 **exit 2**
  —— 与 `deploy/redeploy-plugin.sh` 的 `2=环境问题` 同一套约定,看到 2 才知道
  去查机器而不是查代码。文案里明写「这是环境不足,不是断言失败」。
- `session-scan.test.mjs`:兜底翻译 ENOSPC(`node --test 'test/*.test.mjs'` 会绕过
  前置脚本,这一句不管套件怎么被调起来都生效)—— 这正是治那条误导的关键。
- `test/env-guard.test.mjs`:8 条自证 —— 纯函数两头 + 边界(≥阈值算够、<阈值不够)
  + 0 字节必须红 + 读不到不判红 + 阈值有据 + 端到端 exit 2 且文案对得上。
  端到端那条**不假设本机 /tmp 仍然满**:先自己量一次,够用就跳过并说明原因,
  免得它退化成一条"总在亮"或"总在绿"的假判据。

顺带修 `deploy/check-deploy-drift.mjs` 两处同源问题:
- `selfCheck()` 要在临时目录造两棵小树,`/tmp` 满时抛 ENOSPC —— 而它是**未捕获异常**,
  堆栈指向本文件,看起来像检查器坏了。翻译成说得清的错并让 main() 报 2。
- 新增判据 ⑥「工作区干净」—— **只提示,不参与 exit code**。判据 ① 比的是
  「仓库工作区→快照」这一跳,覆盖不到「HEAD→工作区」那一跳(实证:一行未提交的
  死代码被 17:20 的快照带进生产,而 ① 报的是"逐字节一致")。做成红灯就是一条
  总在亮的判据(本文件头自己骂过的病),所以只说、不判。

验证:`npm test` 453/453(新增 8 条);`npm test` 在 /tmp 满时 exit 2 且不再跑用例;
`node deploy/check-deploy-drift.mjs --self-check` 17 条全过(含 ⑥ 的三条正反面)。
2026-09-14 19:29:44 +08:00
pi
f0884d03be fix(pi-bridge): 决策到了没唤醒等待者 —— 整条授权链断掉(用户报「授权机制有问题」)
现场(用户:「我发现授权机制有问题,你看看 webui4frpc 的那个 session」):
同一条会话一天被问 6 次「是否允许执行 bash?」,**每次人都在 8 秒内点了同意**,
而每一轮都恰好烧满 10 分钟(TURN_TIMEOUT_MS),回信只有一句 59 字的开场白。
该 agent 自己的会话转录里写着:**"The bash tool keeps returning 'No result provided'"**。

根因:`worker.mjs` 的 `permission_decision` 分支取出了等待者、删了表项、存了备注,
**却没有调用 `resolve`**:

    const resolve = pending.get(msg.relayKey);
    if (!resolve) return;
    pending.delete(msg.relayKey);
    decidedExtra.set(msg.relayKey, { ... });
    return;                       // ← 等的人永远醒不过来

于是一条命令走完下面这一整圈:
  ① 工具调用挂着不动 → 一轮跑到 10 分钟 TURN_TIMEOUT_MS 才结束;
  ② 桥把模型那半句开场白当「本轮总结」发回(59 字);
  ③ 会话里留下**没有 toolResult 的 toolCall** → 下一轮 pi SDK 给它补一条
     `isError: true` 的「No result provided」→ 模型重试 bash → 人又被问一遍。

为什么之前全绿:`permission-note.test.mjs` 的 WIRING 钉的是
`decidedExtra.set(msg.relayKey)`(**备注=装饰**)与 `renderDecisionReason`,
**没有一条钉"唤醒"**。2026-09-13 那次修备注时把唤醒弄丢,判据照样全绿
—— 钉装饰不钉机制。

改:
- `resolve(msg.decision)` 补回,放在 `decidedExtra.set` **之后**(hook 醒来要读备注渲染
  拒绝理由,顺序反了会复现 2026-09-13 的「备注丢失 → 模型重复追问」)
- 判据:WIRING 补一条「必须唤醒」;另加 `checkDecisionBranch` **按标记切出决策分支正文**
  判"存在 + 归属 + 顺序"(不用"相距 N 字符"的窗口断言 —— 第一版就是那样假红的)
- 三种反面写法做变异自检:删掉 resolve / resolve 早于备注 / resolve 挪出分支,都必须红
- 全套 445 条通过

跨端核对:dsh 桥的 `pendingApprovals` 有 `pending.resolve(outcome)`(没这个问题);
opencode 走原生 permission 回复、zcode 走事件钩子 —— 这条路径只有 pi 桥有。
2026-09-14 19:16:22 +08:00
33488760ce fix(相位/安全): 部署门禁只判产物自证;静默 break 改成出声;内核读数带时间坐标
pi 2026-09-14 的裁定与两条更正,逐条落地。

1. **相位裁定(选 c)**:`packaging`/`build-stamp` 属于**构建相位**,不属于安装相位。
   `run-all.mjs` 现在有相位:`AGENTMAIL_CRITERIA_PHASE=install`(部署门禁用)。
   每条判据登记它读的哪一侧(`ARTIFACT`/`SOURCE`),install 相位里出现 SOURCE 侧判据 → 红;
   被跳过的判据**点名打印**,不静默丢。汇总打 `RESULT phase=build|install`。
   规则入册 `test/CRITERIA.md` §11(含三个真实实例:check-shared-libs 恒红、
   packaging 一改前端就卡死、HOME 在门禁跑完之后才炸)。

   安装相位**真正能判的那一半**:`deploy/install.sh` 读**产物自证**(不重算 dist)——
   `releaseCandidate !== true` → 拒绝;产物 `gitRev` ≠ HEAD → "这个包比源码旧" → 拒绝;
   放行要显式 `--allow-dirty` / `--allow-stale`;`--check` 干跑只报结论不拦。
   实测干跑输出:`产物:gitRev=6702cc2 树=dirty releaseCandidate=false | 当前 HEAD=6702cc2`
   → 报"不是发布候选 + 正式安装会被拒绝 + 要放行请显式说清"。

2. **别解析运行器文本**(pi §5):`broken`/`red` 的判定改成按 TAP 的**名字**——
   文件级失败的测试名就是路径,断言失败的名字是判据名。变异双向验证:
   未定义标识符 → 「跑不起来的判据」;把某条判据条件改成假 → 「红的判据」。
   不再往关键字表里加补丁(那是往文本解析里加补丁,方向是错的)。

3. **静默 break 是安全相关**(pi §3):`session_update` 找不到活动会话时不再静默 break,
   改成出声日志(走 journalctl 那条通道),写清两种成因(此刻没在跑 / **接管会话**重启后无法定位)、
   方向(收紧被延迟)、以及兜底的**前提**("下次投递"要求这条会话还会收到新邮件)。
   `lib/mail-session-id.js` 模块头同步改成安全相关措辞("人以为自己收紧了权限、实际没有"),
   四桥逐字节同源,`check-shared-libs.sh` 退出码 0。

4. 内核读数补时间坐标(pi 13ea2fdf):`BUILD_INFO.txt` 里除原始 `dep`/`=>` 行外,
   现在还有 `kernelBinMtime` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉,
   没有时间坐标的读数不成立。
2026-09-14 16:54:30 +08:00
dae508b25b docs(判据): 补「已知限制」与「缺省语义登记」两节;build.sh 把原始 dep/=> 行写进 BUILD_INFO
pi 2026-09-14 两件:

1. **登记册真的没有**。我上封信说"已写进 test/CRITERIA.md 的已知限制一节"——**不成立**,
   只有 `appearance-defaults.test.mjs` 里有那段注释。已在 CRITERIA.md 补 §9「已知限制」
   (标识符只解析一层;静态判据的到期前提是"本工作区能装能点")。
   过度声明自己做过什么是这轮反复出现的那一类错,这次是同一个形状的又一例。

2. **新建 §10「缺省语义登记处」**(pi 的更正:不是无条件 fail-closed,而是"缺了的后果必须
   有人登记 + 写明谁批准了这个方向")。三条入库,各带依据:
   · 邮件 `permission_mode` 缺 → **不写、不改档**(窄),依据是本轮那个 `|| 'workspace'` 兜窄档的坑;
   · HomeAgent `plugin.json.sdk` 缺 → 内核**不读**(不是语义,是文档),依据内核 manifest.go 结构体 + registry.go;
   · HomeAgent `capabilities` 缺 → **不受限**(宽),依据内核注释明写的理由:17 个存量清单都没有它。

3. `build/BUILD_INFO.txt` 现在**原文贴入** `go version -m <内核>` 的输出,并附"这两行怎么读"
   (`dep … vX.Y.Z` 后面跟 `=> … (devel)` 时那串版本号只是 require 行残留;`=>` 必须按模块名联接)。
   理由:这场争论的全部内容就是这两行该怎么读,原始证据必须和结论放在一起。
2026-09-14 16:50:33 +08:00
4f0a6e6097 fix(homeagent): 构建不许弄脏源码树 —— hmapdev 会重写 plugin.json,构建后还原并出声
实测:`hmapdev build` 会重写受版本管理的 `plugin.json`(只吃掉了文件末尾换行)。
"构建把树弄脏"这件事在 electron 那边刚定过规矩(脏树产物不得自称发布候选),
所以这里同样处理:构建前留快照,构建后若被改写就还原 + 打印一行说明
(不静默还原 —— 下一个人要知道这条工具会动源码)。

验证:`bash build.sh`(HOMEAGENT_SDK_DIR=…/sdk/v1.3.0)产出 build/plugin.bin(9018271 字节),
构建后 `git status plugins/homeagent-mail-bridge/` 只剩我自己改的 build.sh。
2026-09-14 16:41:54 +08:00
7e696f9a8c fix(homeagent): 撤回"另一条 SDK 血脉"的错误结论;build.sh 不再猜路径;清单判据改成一致性口径
pi 用只读文件系统逐条反驳了 357662e 的根因,三条我都验证并接受:

1. **"内核链 0.9.x 血脉"不成立 —— 那是我的搜索顺序造出来的事实。**
   本机有 6+ 份 `third_party/homeagent-sdk` checkout:
     /root/ha-test/…(0.9.0,C-ABI 时代,无 plugin.bin 支持)
     /var/tmp/rel-1.3.12/…、/var/tmp/rel-1.3.11/…(1.3.0)
     /var/tmp/release-main/…、/var/tmp/clean-check/…、/var/tmp/homed-p3/…(1.2.0)
   而 build.sh 第一版按候选根目录**第一个命中就算**,命中的正是 ha-test 那份老 checkout。
   内核自己用的是 1.3.0 那份,与钉子 `sdk/v1.3.0`、与 `<SDK_ROOT>/current` **一致**。
   两条教训写进注释了:别用"第一个存在的路径"当权威来源;别把模块版本字符串当身份
   (`replace => local (devel)` 时它只是 require 行的残留)。

2. **"API 不兼容"也是同一个错造成的。** 换成正确的 SDK 之后:
     HOMEAGENT_SDK_DIR=/root/.homeagent/hmapdev/sdk/v1.3.0 bash build.sh
     → hmapdev build 成功,产出 build/plugin.bin(9018271 字节)
   也就是说**这个插件在本机编得出来**,先前的 `SettingsAPI.DataDir` 报错是拿老 checkout 编的产物。

3. **判据把能工作的配置判红**(pi §2):第 4 步原先硬校验"产物 SDK 模块版本 == 内核模块版本",
   而生产上能跑的组合恰恰是"内核 + v1.3.0 编的插件"。已删掉这个相等性判据:
   SDK 源码**只认显式指定**(HOMEAGENT_SDK_DIR),不猜、不试探;
   内核那条 dep/=> 只作为**提示**打印(并且按模块名精确联接、只接受紧跟 SDK dep 行的 `=>`,
   不再取"输出里第一个 =>");身份改记 **realpath + 内容哈希**;
   `meta.Version` 读取先剥注释(注释里的 `Version = "9.9.9"` 不再能赢)。
   真正的不变量是 **wire 协议 protocol=2 + 一次真实握手**,写在脚本末尾(部署后回看日志)。

4. **清单判据改成一致性口径**(pi §5):不再"禁止 sdk 字段"——那会把正在工作的那份清单
   (/home/newqqagent/plugins/homeagent-mail-bridge/plugin.json 声明 sdk=1.3.0,正是 08:30
   那次恢复的处置动作)判红,而我没有"内核不读该字段"的证据。现在:可以不声明;
   声明了就必须与构建机指针一致。
2026-09-14 16:38:28 +08:00
357662ed08 fix(homeagent): 不再把 SDK 版本钉在源码里;构建期按内核的依赖表校验对齐
崩溃循环的根因不是"忘了重编",是**把 SDK 版本钉死**:

- `plugin.json`/`plg.json` 写死 `"sdk": "1.3.0"`(本机已装 21 个插件里只有 3 个声明该字段);
- `go.mod` 的 replace 指向 `/root/.homeagent/hmapdev/sdk/v1.3.0`(绝对路径 + 具体版本目录)。

于是"照原样重编"只会再造一个装上去就崩的包。实测本机内核链的是**另一条 SDK 血脉**:

    $ go version -m /usr/local/bin/homed
    dep gitcode.com/JianFeeeee/homeagent-sdk v0.9.2
    =>  ./third_party/homeagent-sdk (devel)

所以判据改成**两个二进制的依赖表一致**,而不是"版本号看起来像":

- `build.sh`:读内核的 SDK 依赖(`go version -m`)→ 据此解析 SDK 源码目录 →
  用 `-modfile` 临时替换(不改动受版本管理的 go.mod)→ 构建 → **回头验产物**:
  产物与内核链的 SDK 版本不一致就报错退出,绝不产出"装上去就崩"的包。
- `manifest_sdk_test.go`:① 清单不许写死 `sdk`;② go.mod 的钉子不得偏离构建机指针
  (取不到基准就红,不许默默放过);③ `build.sh` 必须在(它是与内核对齐的硬校验点)。
  三条都做过变异验证(写死 sdk / 钉子漂到 v1.2.0 / 删掉 build.sh → 各自红)。

顺带查出一个比"重编"更根本的事实:本机 `build.sh` 跑到编译就失败 ——

    ./plugin.go:217:18: sett.DataDir undefined (type sdk.SettingsAPI has no field or method DataDir)

即本插件源码用的是 **1.3.0 SDK 的 API**,而本机内核链的是 0.9.x 血脉:**重编解决不了**,
要么内核改成链 1.3.x 的 SDK,要么把插件移植到内核那份 SDK(这是 HomeAgent 侧的决定)。
2026-09-14 16:27:02 +08:00
d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00