Commit Graph

298 Commits

Author SHA1 Message Date
18c9aef219 chore(deploy): 会话归档脚本(判据 / 备份 / 回滚真跑一遍)—— 用户「清理过时的请求与邮件」
判据:active 且 > IDLE_HOURS 小时无动静,KEEP_IDS 保护人的在用来话。
归档(不删、不碰 updated_at);备份 .backup + integrity;id 清单落文件。
回滚脚本生成用**带引号 heredoc**,且把回滚在**副本库上真跑一遍**才算有回滚路径 ——
第一版用 awk printf 拼 IN 列表拼出 \"''id2\"(引号错位),判据当场抓住(rc=0 但
active 没回到 16);改成 bash 循环拼后副本库实测 12 条复活、active 回到 16。
本次落地:16 → 4 条 active,12 条过时会话(349 封信、76 条权限问询)离开收件箱。
2026-09-15 07:00:35 +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
f8fe14d124 docs(debts): 登记 pi-bridge-adopt-cwd-mismatch —— 沙箱 rw 与 worker cwd 的第三个来源(接管路径)未对齐,且这类错位没有任何一层提示 2026-09-15 06:47:24 +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
28a6bd282a docs(dev-tooling): 第 21 条 —— 夹具把生产形状简化掉的那一角(一天内第二个实例:agentDir 与首回合 cwd) 2026-09-15 06:33:54 +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
1f48c5c4ff feat(sandbox): am-sandbox —— 给命令套 Landlock 内核边界(界内可写、界外 EACCES)
「工作区档」此前名不副实:档位表写「本目录内可动、越界要问人」,而 pi 这一路只能按
**工具名**判(bash/write/edit 一律问人),因为命令的影响范围无法从文本静态判定
(`cd /工作区 && rm -rf /opt/x` 以"进工作区"开头)。pi 自己**故意**不内置沙箱
(docs/security.md §No Built-in Sandbox:进程内的部分沙箱会被误解成安全边界,
"Real isolation needs to come from the operating system or a container boundary")——
dsh 是把沙箱放进自己的运行时;这个二进制补的是 pi 这一侧的**宿主**部分。

## 语义

写:只有 `--rw` 列出的目录(含子树)与 `--rw-file` 列出的文件可写,其余写操作一律
EACCES(内核判)。读与执行不限制 —— Landlock 只做白名单式加法,要连读都挡住得靠
容器/挂载命名空间。套不上边界时 **fail closed**(退出码 126,不执行命令):静默裸跑
会让"档位=workspace"变成谎话,而谎话比做不到更危险。

## 判据

- 行为 `deploy/check-sandbox.sh`:19 项全绿 —— 界内可写/子目录递归/rename+删除、
  读界外、执行、写 /dev/null;界外新建/建目录/覆盖/删除/删目录/跨边界 rename/
  软链接逃逸/子进程继承;**对照**(不套边界时那些"必须被拒"的命令必须成功,否则
  判据可能只是环境本来就只读);fail-closed 那条(rw 不存在 ⇒ 126 且命令未执行)。
- 算法 `cmd/am-sandbox` 单测:按 ABI 逐位裁剪、文件目标不得带目录类权限、
  allowed ⊂ handled、参数解析。

## 过程中撞到两个"看着能过"的坑

1. `--rw-file` 任意文件 → add_rule **EINVAL**:文件目标只能用文件类权限
   (MAKE_*/REMOVE_*/REFER 是目录类),而错误信息只有 "invalid argument",
   看不出是权限位不匹配。单测里"文件权限必须是 handled 的子集"就是拦它的。
2. ★ 判据自己翻车:`"$SB" … 2>&1 | grep -q "Permission denied"` 在
   `set -o pipefail` 下 —— **grep 命中即退出 → 左侧 EPIPE 失败 → 整条管道判失败**
   ⇒ 7 条"界外必须被拒"全被判成"没被拒"。改成先收输出再匹配,顺带打印真实输出。

## 已知边界(写进文件头 + 判据输出留痕,不假装没有)

- 元数据(chmod/chown/utimes)不受 Landlock 管辖:界外文件**内容**改不了,
  但**模式位**能改
- 网络出向不受限
- 界内**已存在**的、指向界外文件的硬链,经界内路径写入会改到界外(路径式沙箱固有)
- 本机 ABI=2(无 TRUNCATE);实测 `truncate` 越界仍被拒(coreutils 先 open 写 ⇒
  WRITE_FILE 挡住),所以那个缺口比纸面上窄
- **读不限制** ⇒ 以 root 跑的 agent 仍能读整机(含 /etc/agentmail/*.env)。沙箱在这里
  的价值是"界外留不下脚印",不是"拿不到东西" —— 要后者得上容器

## 接线

`redeploy-gateway.sh` / `install.sh` 在**边界判据真跑绿了**之后才装到
`/opt/agentmail/bin/am-sandbox`(装一个套不上边界的工具等于发空头支票)。
pi 桥的接线(按档位把 worker 套进边界)还没做 —— 见随后的报告。
2026-09-14 23:33:11 +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
4c2bf26c42 fix(deploy): flock 没登记进 AGENTMAIL_REQUIRE("缺命令"被报成"另一个部署在跑")+ 中断 trap + 两条欠账入册
**1. 自指缺口:新能力带的新依赖没登记回表(pi 抓到)**
我加"同时性"那一列时引入了 `flock`,**却没把 `flock` 加进三个脚本的 `AGENTMAIL_REQUIRE`**。
后果实测:
    PATH 里没有 flock ⇒ `flock: command not found`(127)⇒ `! flock` 为真
    ⇒ 打印"**另一个部署正在跑(锁被占用)**"
退出码事后是对的(2),但**诊断是错的** —— 而照着它做的是"等另一个部署结束":**永远等不到**。
三个脚本各加一个词;并在 `env-defaults.sh` 的 ③b 注释里写明这条规矩
(**新增任何外部命令时回到 `AGENTMAIL_REQUIRE` 登记**)与这个实例。
→ docs 第 19 条:「表与被表的东西不同步」。

**2. 第六列候选:中断(信号)—— 已按 pi 的建议修 `redeploy-gateway.sh`**
原子 `mv` 修的是"半截二进制",**没修"服务停着而脚本死了"**:
第 250 行 stop 与第 267 行 start 之间被外部信号打断(Ctrl-C、宿主杀进程、会话回收、OOM)
⇒ 脚本直接退出、**服务留在停止状态而什么也不说** ⇒ "邮件全停 + 无人告知"。
已加 `trap … INT TERM HUP`:进窗口前置位 `_SERVICE_STOPPED`,出窗口复位并摘 trap;
**trap 只在"确实还停着"时才动手**(否则会多起一次服务);回滚分支也维护该标志。
**用 stub `systemctl` + 探针真喂过四个分支**:
    stopped=1 + SIGINT ⇒ 调了 `systemctl start`、退出码 130、打印点名
    stopped=0 + SIGINT ⇒ **没有**调用 start(不误起)
    (探针里两次 harness 自身的错也一并记下:`sed`/`awk` 的区间端点选错,
      把 `trap -` 也取进来,导致"trap 没生效"的假象 —— 是探针错,不是代码错。)
★ 顺带修掉自己写的一处:`printf '… $SERVICE …'` 用**单引号**包裹 ⇒ `$SERVICE` **不展开**,
原样打出字面量(探针里实测看到)。改双引号传参。这类"消息里有变量但没展开"会让读者
以为服务名真叫 `$SERVICE`。

**3. `install -d -m` 对已存在目录的行为:实测会改(pi 的疑问)**
    mkdir -p 建 755 → `install -d -m 0700 <同一目录>` → **700**
所以**下一次部署就会收紧** `/opt/agentmail/data` 与 `/etc/agentmail`,不需要额外的
`chmod 0700` 动作,也不必为此单开一次"人按一下"。
(我按这条如实回报,因为 pi 说过"若不会改就需要显式 chmod,且安全意义比
`user-question.js` 高" —— 结论是不需要。)

**4. 两条欠账入 `docs/DEBTS.json`(按 pi 的界线:只修新机制自己引入且会误报的缺口)**
· `deploy-space-prefix-fs`:空间列只铺了 `$TMPDIR`,没铺 `$PREFIX` 所在文件系统
  (属"列内没铺满",不是新列)。
· `deploy-interrupt-trap-other-scripts`:trap 只在 `redeploy-gateway.sh`;
  `install.sh`/`redeploy-plugin.sh` 被打断同样会留半成品(没有"服务停着"那种后果,故低优先)。
→ docs 第 20 条同时记下 trap 这条纪律与它的可喂判据写法。

验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0;
check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过;DEBTS.json 有效(13 条)。
2026-09-14 21:26:51 +08:00
1056b22cbd fix(deploy)!: install 不是 rename(头部那句"原子"论断不成立,实测半截二进制)+ 加并发锁 + journalctl 抽成可喂函数 + data/ 权限
pi 的四条,逐条实测:

**1. `install(1)` 不是 rename —— 而那句话是整节设计的理由**
头部原话:"install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动"。
按他给的命令实测 `strace … install -m 0755 /bin/true /tmp/t`:
  目标不存在:`openat(t, O_WRONLY|O_CREAT|O_EXCL)`
  目标已存在:`unlinkat(t, 0)` → `openat(… O_CREAT|O_EXCL)` → 写入
**全程没有 rename/renameat**。即复制路径,**旧文件在新文件写完整之前就没了**。
中途失败实证:`ulimit -f 1` ⇒ 退出码 **153**(SIGXFSZ),目标变成 **1024 字节截断 ELF**,
原 14 字节内容**已被销毁** —— 正是本段前半句写的风险,`install` 并不免疫。
(第一次测时我把退出码经管道取到了 `head` 的 0 —— 正是 docs 第 6 条那个坑,重测才拿到 153。)

★ 他补的第二个坑也确认:`/tmp` 与 `/opt/agentmail` **不同文件系统**
(实测设备号 40 vs 2049)⇒ 就算换成 `mv` 也不原子(跨 fs 退化成 copy+unlink)。
已改成真原子三步:**目标同目录**暂存 → `install`(动的是"还没人用的名字")→ 一次 `mv -f`。
对照 `redeploy-plugin.sh` 的 `mv "$STAGING" "$SNAP"` 是**真原子**(同 fs)——
同一仓库原先两套"原子切换",一套真、一套名义上的。

**2. 缺并发锁(环境前提表的第五列:同时性)**
原表(变量/命令/空间/身份)漏了这一类,而它不是假设:工作区是多 agent 共用的。
两个部署同时跑 ⇒ 各自 stop(一次失败、状态没人看)→ 两次写同一目标(配合上面那条 ⇒
真能留半截)→ 两次后置验证互相把对方的"验证不过"当自己结论 → 谁回滚不确定。
三个脚本都加 `flock`(**不是**"检查锁文件存在",那本身有竞态)。
实测:同一把锁上第二个进程 `flock -n` 失败;脚本形态下 `install.sh` 的 `--check` 不建锁
(干跑只读、且刻意允许无写权限运行)。

**3. journalctl 抽成可喂函数 —— 并且他对我那次"复现"的更正成立**
他说我复现的是**"空输出"支**,不是"读不到"支。实测确认:
`journalctl -u 不存在的-unit` 退出码 **0** ⇒ 我测到的是 else 分支。
已抽成 `am_scan_logs <unit> <since> <pattern> [命令]`,输出三态
`clean`/`hit`/`unreadable:<码>`(与既有 `describeEnvError`、`judgeRestart` 同一做法:
把能被样本喂的部分抽出来)。**用 `/bin/false`、`/bin/true`、假"输出含 panic"的脚本
三个样本喂过**(不碰生产):`unreadable:1` / `clean` / `hit` —— 三条支路现在都有覆盖。
判据也随之分开:**"命令不在"由 `AGENTMAIL_REQUIRE` 兜、"命令在但读不到"由这个函数兜**,
两列在代码里分开,而不只是注释里分开。

**4. 低优先项里 umask 那条是真问题(我原来以为可忽略)**
实测 `install -d` 权限位受 umask 影响;而本机生产 `/opt/agentmail/data` = **755**、
`agentmail.db` = **644**(全局可读),同一脚本里 `agent-config`/`pi-config` 却是显式 `-m 0700`
—— 同一脚本两套口径,而那个库里是全部往来邮件。
已改:`install -d -m 0700 "$PREFIX/data"`、`-m 0700 "$ETC"`(密码与密钥)。
(现有生产权限不在本次改动范围,属部署后生效。)

**顺带修一处我自己的口径不一致**:`install.sh` 的 root 检查用 `exit 1`(判据失败),
而另外两个脚本与 `env-defaults.sh` 的"环境不足"都用 **2** —— 调用者无法据此区分
"该重跑"还是"该修代码"。已统一为 2。

★ 加锁过程中我自己连踩三次"复制粘贴的上下文假设"(都已修,并记进 docs 第 18 条):
`install.sh` 没有 `bad()` ⇒ 127;`redeploy-plugin.sh` 没有 `$PREFIX`(用 `$DEST_ROOT`)⇒
`unbound variable`;`install.sh --check` 无写权限 ⇒ 建锁 `Permission denied` 又变 127。
**同一份代码搬到另一个脚本里,能引用的变量和函数是不一样的。**

验证:install.sh --check exit 0;npm test exit 0;prune 自检 22/22;drift 自检 35/0;
check-shared-libs exit 0;全部 deploy 脚本 bash -n 通过。
2026-09-14 21:19:46 +08:00
8e3b04a267 fix(deploy): TMPDIR 只判"未设"(同文件里 HOME 判了可写)+ 环境自足漏了"命令"(journalctl 两处是假绿)
pi 给了"第五次"的两条线索,都在我读得到的地方,逐条实测确认后修完:

**1. TMPDIR 与 HOME 不同规则(就在同一个文件里)**
① `HOME` 那边写了两条规则:`mkdir -p` 对**已存在的不可写目录会返回成功** ⇒ 必须单独判 `-w`;
判据落在"能不能写"不落在"路径像不像"。**同一条规则没落到 ② `TMPDIR` 上** ——
而 ENOSPC 正是这条链的元老问题(四次史里第 3 条就是 TMPDIR)。两种失败形状:
已给但**不可写**(EACCES)、可写但**已满**(`-w` 抓不到,要的是**空间**判定)。
已补 `-d` + `-w` + 可用空间(`df -Pk`,读不到⇒**不据此判定**;`0` 是**真的没有**);
不足 ⇒ 人话 + exit 2。**不 import** 插件那份 `test/lib/tmp-space.mjs`:
`deploy/` 侧要能独立分发,为去重引进平台代码不划算(按既定理由,写最小版本)。
实测 `TMPDIR=/root/nope` ⇒ `[FAIL] 环境不足:TMPDIR=… 不存在或不可写` + 退出码 2。

**2. 环境自足只覆盖"变量",没覆盖"命令" —— 其中 journalctl 两处是假绿**
   这节的要害是 pi 给的那句判据,我认:**"命令不在"必须走 2/红 + 人话;
   "命令在但输出为空"才是判定结果。** 原先两处把两者压成同一个字符串 `"0"`:
   journalctl 失败(被 `2>/dev/null` 吞掉)⇒ grep 读空 ⇒ `fc="0"` ⇒ **打印"无 panic/fatal"**。
   实测复现:`journalctl -u 不存在的-unit | grep -icE 'panic'` ⇒ `fc=[0]`。
   `sse` 那条同形、后果更坏:**把"读不到日志"归因成"插件没连上"**,让人去查密钥。
   ⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 1),
   所以不能靠管道状态区分 —— 必须**先取输出、成功后再过滤**,命令存在性另做前提检查。
   改法:两处都改成"先取日志、看退出码";读不到 ⇒ `warn` 明说"读不到、无法据此判断"
   (既不假绿也不假红)。并给三个脚本加 `AGENTMAIL_REQUIRE` 前提检查
   (缺一个 ⇒ exit 2 + 人话),与四次史的处理**同形**,只是对象从变量换成命令。
   实测:`AGENTMAIL_REQUIRE` 里放不存在的命令 ⇒ 退出码 2。

**3. 顺带修 pi 点到的两处同族问题**
· `install.sh` 的 `HEAD_REV="$(git … rev-parse --short HEAD)"`:`set -e` 下失败**直接中止**
  (实测退出码 127、无翻译);而且 HEAD_REV 为空会让下一句报
  "这个包比源码旧:产物 gitRev=… ≠ HEAD=" —— **把"这里不是 git 仓库"说成"产物过期"**。
  已改成显式判失败 + 明说"读不到当前 HEAD,跳过新旧比对"。
· 同块第 94 行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 条件**永远为真**
  ("判据的形式在、区分力不在")。已改成显式计算、去掉 `|| true`。

docs 补两条纪律:15「"命令不在" ≠ "命令在但输出为空"」(含 PIPESTATUS 分不开的实测)、
16「一条规则写了,要检查它是否落到了所有同类对象上」。

验证:install.sh --check 空环境 exit 0、正常 exit 0;TMPDIR 不可写 exit 2;
npm test exit 0;prune 自检 22/22;drift 自检 35/0;check-shared-libs exit 0。
2026-09-14 21:08:48 +08:00
3be824849d fix(deploy): env-defaults 的顺序错(③ 的探测依赖 ④ 的产物)+ 非 root 的 HOME 陷阱 + 兜底行可见 + 补 root 断言
pi 读了新加的 `deploy/lib/env-defaults.sh`(三个 source 点他都确认对),指出三条,逐一实测后处理:

**1. 顺序错(真错,而且正好落在它自己要消的那类假设上)**
③ 用 `command -v go` 探测,而 `command -v` **走 PATH**;④ 才修 PATH ⇒
**在 ④ 要修的那个环境里(PATH 为空),③ 的探测必然失败**,紧接着 ④ 把 PATH 装上、
后面的步骤**又能**找到 go —— **探测结论与实际可用性相反**。
实测(`env -i`):顺序翻转前 `go` 在 ③ 处找不到、④ 之后 `/usr/bin/go` 就在了。
已把 PATH 那段**挪到最前**,并在文件头写明顺序是**正确性而不是风格**。
(pi 自己也说了严重度:今天对 go 大概率无影响,因为 ① 已兜住 HOME、现代 go 会从 `$HOME/go`
自推缓存 —— 他把它当形状问题提,这个判断我认;一条探测所依赖的东西正是同文件后面要修的东西,
这正是本文件存在的理由。)

**2. `HOME=/root` 在非 root 调用者手里会把环境问题变成代码问题**
`redeploy-gateway.sh` 原先没有 EUID 断言(只有 `install.sh` 有),于是"非 root + 空 HOME"
会拿到 `HOME=/root`,接着 `go build`/`npm ci` 往 `/root/go`、`/root/.npm` 写 ⇒ **EACCES**,
而那串报错看起来是代码/工程问题 —— 正是本文件要消的东西。
已按身份分叉 + **验证可写**(判据落在"能不能写",不落在"路径长得像不像"),
兜底落到 `${TMPDIR:-/tmp}/agentmail-home-$(id -u)`;连一处可写的都找不到 ⇒ exit 2 + 人话。

★ **顺着他的思路又实测出第二个口子**:`HOME` **已给**但不可写时,上面只判"未设"就放行 ——
后果与空 HOME 完全相同,只是入参不同(`sudo -E`、从 root shell 继承、容器挂错)。
`mkdir -p` 对**已存在的不可写目录会返回成功**,所以必须单独判 `-w`。
实测 `setpriv --reuid=65534 env -i HOME=/root` ⇒ `touch $HOME/probe` 被拒。
已覆盖"已给但不可写",并**先说清再改判**(`[WARN] 调用者给的 HOME=… 不可写;改判到 …`),
不静默换目录 —— 静默换会让"东西写到哪去了"变成谜。
五种场景实测(全空 / 环境齐 / 非 root+空 / 非 root+不可写 HOME / root+可写):全部符合预期。

**3. "某条脚本忘了 source"没有信号** ⇒ 采纳
`AGENTMAIL_ENV_DEFAULTS` 只是被 export、值不进正常输出 ⇒ 谁把 `source` 删了,
输出与"环境本来就齐"**完全同形**(又是"看起来在兜、其实没兜")。
新增 `agentmail_env_report()`,三个脚本各自打一行(兜了哪些 / "(无 —— 调用者已提供全部)";
忘了 source 就没有这一行)。实测三个脚本在空环境下各自都打出来了 ——
这也把验收从"一条脚本"变成"三条各自可读"。

**4. 顺带补 `redeploy-gateway.sh` 的 root 断言**
它要往 `$PREFIX`(默认 /opt/agentmail)写,非 root 必然失败在写权限上,
而报错来自 `install`/`cp`、看起来像工程问题。用退出码 **2**(环境/权限),口径与 env-defaults 一致。
实测非 root 下:`[FAIL] 环境不足:本脚本要写 /opt/agentmail,需要 root。` 退出码 2。

验证:install.sh --check 空环境 exit 0、正常环境 exit 0;npm test exit 0;
prune 自检 22/22;drift 自检 35/0;check-shared-libs exit 0;四个脚本 bash -n 通过。
2026-09-14 21:01:19 +08:00
743e397916 fix(prune)!: 构建暂存那段清理**一直是空转的**(ls -1t 对目录打 路径: 头)+ del() 失败分支补齐覆盖
## 那个真 bug:`ls -1t <多个目录>` 不打裸名字

追 pi 的 shim 建议时撞出来的,与 shim 无关 —— 是我为了给它造样本才发现的:

    ls -1t /tmp/agentmail-gateway-build-*     # 这些是**目录**(mktemp -d 造的)
    /tmp/…-20260101-000000:
    agentmail-gateway
    /tmp/…-20260105-000000:
    agentmail-gateway

`ls -1t` 收到**多个目录参数**时会列出**每个目录的内容**并打 `路径:` 头 ——
于是 `mapfile` 拿到的全是 `…000000:` / `agentmail-gateway` 这类行,都不是文件名
⇒ `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳 ⇒ 每条都判
"文件名无时间戳,判定不了" ⇒ **一个都不删**。
也就是说本段注释里写的那个问题("每次部署留下一个 24MB")**从来没被清理过**。
修法:`ls -1dt`(`-d` 让目录自身作为条目,不打头)。

**为什么一直没人发现**:自检夹具用 `: >` 造的是**普通文件**,而生产是**有内容的目录**。
夹具形状与生产不一致 ⇒ 夹具自己认了错形状,而判据 197 行又只按**文件名**判
"窗口内的还在、窗口外的不在",于是判据也认了。这是 docs 第 13 条那一族。
→ 夹具已改成真目录 + 里面放 `agentmail-gateway`;**改完立刻变红**("干净样本:/tmp 构建暂存
只留窗口内那 1 份"失败),证明夹具现在真的有分辨力,然后加 `-d` 转绿。

顺带确认:其余三处 `ls -1t`(`agentmail-gateway.bak-*`、`pre-deploy-*.db`、`pre-prune-*.db`)
glob 到的是**文件**,不受影响;插件快照那处(第 294 行)本来就已经写了 `-1dt`。
生产现场实测:`/tmp` 下确实还躺着 1 份 24MB 暂存没被收掉。

## `del()` 的失败分支:采纳 pi 的"让 rm 自己失败"

他指出的第三条路(我原先只想到 immutable 与注入点)是对的:本机以 root 跑、权限拦不住;
`unshare -r` 被拒(`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。
**改机器的权限**不如**让 rm 失败**。

实现上走了 `RM="${RM:-rm}"` 而不是 PATH shim,理由:`in_use` 会把命令行里含该路径的进程
判成"在用",而自检必须把路径写在命令行上 —— 实测评 PATH shim 时确实被 `in_use` 挡掉、
`del()` 根本没被调用(那次"测试通过"是假的)。`$RM` 默认就是 `rm`,生产行为逐字不变。

自检新增 4 条(并通过变异确认有区分力:去掉失败判定 ⇒ 强断言变红):
退出码 2、必须打出 `[FAIL] 删不掉`、不许出现收尾汇总、那条路径必须还在。

★ 变异还暴露出一条**弱断言**:单看"退出码 = 2"在变异后**照样通过**(脚本别处也有退 2 的路径)
—— 已在注释里注明它弱、区分力来自另两条,没有让它冒充证据。

★ 顺手修掉一处自指的措辞:我原先在报错里抄了收尾汇总的原话("已删除 N 项"),
于是 `grep -c '已删除'` 命中**这句报错自己** ⇒ "有没有虚报成功"这个检查把自己的措辞
当成了证据。改写成不含该字面量的说法(与 `grep -c 用例名` 是同一族:判据锚在元文本上)。

## pi 的另两点

· `diffSummary` 带 `ctx` 时**会读文件**(判 `scripts.test` 要读两侧原文),不带是纯内存比较
  —— 已写进函数头,免得以后有人当纯函数用而在大树上意外吃到 I/O。
· "自检样本不独立"的三种形态(位置选择器 / 共享夹具状态泄漏 / 探针无分辨力)
  **合成 docs 第 13 条**(修法同一个:显式命名 + 显式复位),并把上面"夹具形状必须与生产
  一致"作为配套一条写进同一条 —— 今天的真 bug 正是它。

验证:prune 自检 22/22、干跑 exit 0;npm test exit 0;drift 自检 35/0;check-shared-libs exit 0。
2026-09-14 20:53:54 +08:00
7eec311756 fix(deploy): C 扩到全文件(133/0 闭合)+ ① 加 realpath 判据 + ②b 明说"恒等" + 环境自足收成一处
pi 这一封四个实质点,逐个实测后处理:

**C. 口径扩到全部文件**(他给的是算术,不是口味,我认):
原先按后缀取(`.conf/.service/.timer/.bak*`),我说的"零违规就不扩"是把口味当论证。
他把成本量化了:差集极小 ⇒ 多读几次文件(几十 KB),而收益是那个 `0` 从
**"有范围的 0"**(只对我划的圈成立)变成**"闭合的 0"**(对整棵 /etc/systemd 成立)。
他还补了一句我没想到的:这条判据只报**内容里含仓库路径**的文件,
所以含仓库串的 `.dpkg-old`/`~`/无后缀文件**恰恰都是真信号**(过期的旧真相),
不是噪声 —— 我先前"二进制会变成噪声"的担心本来就不成立。
**验收实测:比了 133 个文件(全部,不筛后缀)、命中 0。**
另按他要求把"零违规"这个前提写进注释,并说明"红/WARN 拆分"为什么推迟
(零违规时拆分是重构不是修 bug;出现第一个非白名单命中时再决定分档)。

**反例 1(②b 对 pi 是跑不到的分支)**:确认。pi 的依赖是全局包软链
(`-> /usr/lib/node_modules/@earendil-works/pi-coding-agent`),`cp -a` 保留软链
⇒ 两侧 realpath 到**同一个 inode**(实测 `statSync(a).ino === statSync(b).ino`)
⇒ 版本集合按构造相等 ⇒ **②b 对 pi 永远不会红**。这正是本文件自己列过的第三种形态
(断言在、区分力不在),比"没写判据"更坏因为它看起来是绿的。
已改:两侧 realpath 相同时**明说"恒等、区分力为零"**并指出它真正覆盖谁(有 vendored 树的宿主),
不再报"版本集合一致"这种让人误以为验过的措辞。自检加了这一条。

**反例 2(① 的 realpath 盲区)**:确认,形状真实且三条判据全都看不见 ——
①只 grep 内容(仓库那份 unit 文本里没有仓库字面量)、②比内容(live 就是 repo 那个 inode,
必然"一致")、④只查固定名单。已加 realpath 判据:被检文件 realpath 落在仓库里 ⇒ 红,
与内容无关。自检加**正反两面**(内容干净但指向仓库 ⇒ 红;指向仓库外 ⇒ 不许红,
否则这条判据恒红)。实测:本机 `/etc/systemd/system` 下 0 条指向仓库的软链
(即这个 0 现在才是闭合的)。

**反例 3(环境假设第四次 ⇒ 建议收成一处)**:采纳。四次的形态一模一样
(HOME ⇒ 又一次 HOME ⇒ TMPDIR ⇒ GOMODCACHE/GOPATH),每次"再加一个预检"只挡已知那一个。
新增 `deploy/lib/env-defaults.sh`:一处给全 HOME/TMPDIR/GOMODCACHE(GOPATH)/PATH,
只设**未设**的变量,注释里写明四次历史与"否则第五次一定会来";
三个部署脚本开头 source 它;**删掉**我上一轮加的那个分散 go 预检。
实测:在 `HOME`/`TMPDIR`/`GOPATH`/`GOMODCACHE` **全空**的环境里
`bash deploy/install.sh --check` **exit 0**(go vet + go test 自己站起来),
兜住的变量会在 `AGENTMAIL_ENV_DEFAULTS` 里说明。

docs 补两条纪律:13「锚点必须一一对应 —— 连'文件名'都会骗你」(E 的探针教训)、
14「退出码也有量纲」(--self-check 的退出码不是自检的结论)。

验证:npm test exit 0;check-shared-libs exit 0;drift --self-check **35/0**;
prune 干跑 exit 0;install.sh --check 空环境 exit 0。
2026-09-14 20:42:54 +08:00
d47035aaff docs(dev-tooling): 补三条 —— 布局改动的回滚账、PIPESTATUS 与 pipefail 是判据的一部分、判据输出要自证比完全部对象
第 10 条是 pi 提的:把 lib/user-question.js 搬到 test/lib/ 的收益是'快照更干净',
但**回滚它会让快照立刻多出一处运行时漂移** —— 这类改动的账里没有'回滚'这一行。
第 11 条把管道纪律写清取法,并点明四个 deploy 脚本的 set -o pipefail 不是风格而是判据。
第 12 条来自实测:show_diff 的 cmp|head 在 set -e 下让脚本当场中止,
只报第一个对象、退出码却恰好还是 1 —— 判定对、证据被截断。
2026-09-14 20:35:10 +08:00
42f01c7478 fix: 三处"判定对、但信号假"——豁免的自检覆盖、rm 失败仍报"已删除"、show_diff 截断证据
pi 通读后逐处指出,三条都实测复现:

**1. `jsonTestOnlyChange`/`testOnlyDrift`/`runtimeOnly` 没有任何自检碰过。**
他的质疑成立:我上一封说"配了六个样本",那六个样本是**开发时的内联脚本、没进文件**
(他读了全文,找不到——我核了,确实没有)。而这段逻辑是文件里**唯一一处"把红变成绿"
的代码**,也是唯一没有判据的代码,失效方向恰好是"比恒黄更坏"那个(假绿)。
已补 **4 条走真路径的样本**(真临时树 + `diffSummary(d,{repoDir,snapDir})`):
★只差 `scripts.test` ⇒ 不算运行时漂移且必须说出豁免了哪条键;
★`scripts.start` 变了 ⇒ 必须算运行时;★解析不了 ⇒ 不许豁免;★不传 ctx ⇒ 不豁免。

★ 写样本时被一对**同义不同数**的字段绊住:断言 `runtimeDrift === 0` 却得到 1 ——
`diffSummary` 报的是**原始**检测数,而 `checkHost` 自己又减了一遍豁免。
判据自己产出两个矛盾口径,与"注释里两组矛盾的写点计数"同族 ⇒ **统一**:
`runtimeDrift` = 减掉豁免后的结论,被豁免的只出现在 `testOnly` 里;
`checkHost` 删掉自算的 `runtimeOnly`。不传 ctx 时行为与旧版逐字相同。

**2. `prune-deploy-artifacts.sh` 的 `del()`:`rm` 的结果没人看。**
脚本是 `set -uo pipefail`(**无 -e**)⇒ `rm -rf` 失败(权限/只读挂载/immutable)后
照样打"删除 …(NMB)"、收尾汇总"**已删除 N 项,释放约 X MB**"
——**一次失败之后仍产出成功措辞**(与 `redeploy-plugin.sh` 里"被拒还打 [ OK ]"同族)。
已改:判失败即报错 `exit 2`;`removed`/`freed_kb` **只在成功后累加**;并加抽验
(`rm` 报成功但路径还在 ⇒ 报错),不信单一退出码。
⚠️ **这一条的失败路径我没能实测**:本机以 root 跑,权限拦不住 `rm`;
`unshare -r` 建只读挂载被拒(`/proc/self/uid_map: Permission denied`);
tmpfs 无 `chattr +i`。要覆盖得在带 CAP_LINUX_IMMUTABLE 的环境用 immutable 文件,
或给 `del()` 加 `RM` 注入点。**我没有把"改过"说成"验过"。**

**3. `check-shared-libs.sh` 的 `show_diff` 被 `set -e` 当场中止。**
`cmp` 有差异返回 1 ⇒ `pipefail` 让函数返回 1 ⇒ 独立调用触发 `set -e` ⇒ **脚本当场死**。
实测(脚本级,fixture 树里造两处分叉:pi 与 zcode):
    旧版:报告 1 处分叉、收尾汇总 0 次
    新版:报告 2 处分叉、收尾汇总 0 次(收尾那句本来只在成功时打,见下)
两者退出码都是 1(判定一直是对的)—— 所以**光量退出码看不见这个 bug**,
正是 pi 说的"判定对、证据被截断"。而且旧版连 `fail=1` 都执行不到:
退出码 1 是 `set -e` 给的。加 `|| true` 后遍历跑完。
2026-09-14 20:35:01 +08:00
7ec45b8d45 fix(prune): 备份集"同生共死"在用跳过时失效 —— 原来只跳一个文件,兄弟照删
pi 评审 2026-09-14 指出(实测确认):`in_use "$f"` 命中就 `continue`,
只跳过**那一个**文件,同一 `<ts>` 的其余成员照删 ⇒ 备份集被拆开,
与这段自己的注释("拆开留没有意义")相反。

**在删除那一支(`i >= KEEP_DB_BACKUPS`)后果比 pi 说的更难看**:
正在被使用的那个 `in_use` 留了下来,而同一时间戳的 `-wal`/`-shm` 照删 ——
**"留下"的那份要配上被删掉的兄弟才是完整的一套**,所以留下的是**残的**。
(保留那一支的后果轻一些:只拆集,不丢在用文件。)

改法:先算一次整集的在用状态(`set_in_use`),整集一起跳过并报
"★跳过整集(有成员正在被使用)<ts>",`skipped` 按整集成员数计。

干跑实测照常(本机当前 0 项待删、0 项跳过);`bash -n` 通过。
2026-09-14 20:28:50 +08:00
56b9028579 fix(deploy): 三个工具统一 AGENTMAIL_PREFIX + go 环境预检(别把环境问题报成代码问题)
pi 的 G 项,两处都实测确认:

1. **`PREFIX` 三套写法**:`redeploy-gateway.sh` 与 `reset-demo.sh` 都是
   `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有 `install.sh` 写死 `/opt/agentmail`
   ⇒ 谁设了那个变量,install 装到 A、redeploy 看 B、drift-check 看 C。
   实际不止一处:`install.sh` 里另有 3 处**绕过自己的 `$PREFIX`** 硬编码
   (`agent-config`、`pi-config`、`bin/` 的 install),`redeploy-gateway.sh` 里也有 2 处
   (它自己有 `$PREFIX` 却绕过)。全部收回 `$PREFIX`;`install.sh` 改为尊重
   `${AGENTMAIL_PREFIX:-/opt/agentmail}`。`install.sh` 里 `/opt/agentmail` 现在只剩
   注释与默认值两处。
   ⚠️ `check-deploy-drift.mjs` 仍是硬编码——**没有改**:它是独立工具、不 source 任何脚本,
   要它认前缀得先定义"配置从哪来",那是设计题不是一行改动。(已在此留档。)

2. **`--check` 相位新增的 go 门禁会产出误导信号**:我实测(沙箱里没有 `HOME`/`GOPATH`)
   它报 `go: module cache not found: neither GOMODCACHE nor GOPATH is set`,
   然后被我说成"go vet / go test 不过 —— **先修好再安装**" ——
   **把环境问题报成代码问题**,而那正好诱导人去 sudo 建目录
   (GOPATH 会落到 `${GOPATH:-$HOME/go}/pkg/mod`)。
   加一句预检:`go env GOMODCACHE` 为空 ⇒ 明说"环境不足"并 **exit 2**(环境约定),
   不再冒充代码缺陷。实测:沙箱里现在报"环境不足…药方:带上有 HOME 的环境重跑"、退出码 2;
   带上 `HOME=/root` 后 `go vet` + `go test` **通过**(退出码 0)。
2026-09-14 20:27:26 +08:00
44fdfb3ed7 fix(deploy-drift): 判据 ① 的覆盖面写进 note(0 必须带上可证伪范围)+ 记录 C 的取舍 + docs 补 4 条纪律
pi 的 C 项(`.bak` 那次修法"把圈往外挪了一格,还是圈")我**实测后决定不改**,理由留档:
- 这一格现在**零违规**:`/etc/systemd/system` 133 个文件里**没有任何一个**含仓库路径,
  包括现存的 4 个 `.bak`(`dsh-lan.service.bak-20260903-081410`、
  `pi-bridge.service.bak-13010-20260814`、`pi-bridge.service.bak-20260814`、
  `pi-web-sessiond.service.bak-20260814`)—— 全干净;
- pi 提到的 `zcode.service.bak-20260912-145744` **他读时已经 ENOENT**(他自己写了),
  也就是说我引他那句话时依据的文件**已经不存在**;
- 扩到"每个普通文件都读"在当前只会引入噪声(二进制/dpkg 数据库类),换不到真信号。
⇒ 改为**把覆盖面写进 note**("比了 129 个 … 文件")——
   `0` 只有在"它能被证伪的范围"写明之后才是结论,这正是这条判据当初缺的那句话。
   等真出现一个非白名单后缀的违规再改,那时我们就有实例了。

docs/DEV-TOOLING.md 补 4 条纪律(编号 7-10,原第 7 条顺延):
7 「注入点会把该抓的 bug 藏起来」(含位置选择器 `bad[0]` 的同类);
8 「看起来在比、其实没比」要当一条自查(本轮出现三次:路径错/空目录/符号链接),
  且**绿的时候也要留下覆盖范围的证据**;
9 **变异之前先提交**(我未提交就变异 + `git checkout` 还原,把自己的改动冲掉);
10 **注释里的数字无法被判据守住**(同文件三处说法三个数、式子加起来还是错的)。

自检全过;实跑 ① 报"比了 129 个文件"。
2026-09-14 20:25:24 +08:00
a0ba10341c docs(deploy-drift): 删掉两组互相矛盾的"写点计数",改写成口径 + 理由
pi 逐处数出来的(我复核确认):同一份文件里
- `describeEnvError` 头注释写"写点有**五处**";
- `selfCheck` 头注释写"本函数共 **6 处写** = mkdtempSync×2 + writeFileSync×2 + 三处裸写"
  —— **这条自己列的式子 2+2+3 加起来是 7**。

三处说法三个数,**没有一个等于实际站点数**(实际是 mkdtempSync×2 + mkdirSync×2 +
writeFileSync×6 = **10 处**;函数体后来又长了,数字只会更旧)。

**现在一个数字都不写。** 理由与本仓库既有纪律同源("不要为此引入手抄的期望用例数
常量 —— 手抄常量会过期"):注释里的数字**无法被判据守住**,改代码时没人会回来改它们,
而"两组数字互相矛盾"比"没有数字"更糟 —— 它让读者以为有人数过。
实例就在这份文件里:依赖树夹具(第 1009 行附近)后来又加了 3 处写,谁也没回头改注释。

要判"覆盖是否完整"只能靠**机制**:整段 try/catch 保证不论哪一处抛 ENOSPC 都被翻译成
人话 —— 覆盖范围不取决于入口、也不取决于处数。这一点写进注释了。
2026-09-14 20:24:49 +08:00
ebb1040559 fix(install): go vet / go test 提到 --check 相位 —— 原措辞"所有会红的门禁都跑过了"与自己下一段矛盾
pi 读出来的(实测确认):干跑末尾写"**所有会红的门禁都跑过了**(前端 typecheck/test/build、
共用模块同源、各插件测试、插件构建)",而紧接着那段就把 `go vet + go test` 列进
"下面这些步骤干跑**没有执行**" —— **那两条正是会红的门禁**。
两段话自相矛盾,而读者只会读那句加粗的结论。

采用 pi 倾向的方案①(把它提进 `--check` 相位)而不是只改措辞,理由:
这两道门**最容易在别人的机器上红**(Go 版本、模块缓存、平台),
而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。
它们不写系统目录(只写 go 缓存),放进这个相位没有副作用。

顺带把措辞改准:结论句改成"**除了"构建 Gateway 二进制"之外**,所有会红的门禁都跑过了"。

实测(本机,`HOME=/root GOPATH=/root/go`):`go vet ./...` 与 `go test ./...` 都是
**真退出码 0**(gateway 各包全绿)⇒ 新增的这一相位不会造成"一跑就红"。
⚠️ 我的沙箱里 `$HOME` 为空 ⇒ 直接跑 go 会报
`go: module cache not found: neither GOMODCACHE nor GOPATH is set` ——
**这是环境问题不是代码问题**(`/root/go/pkg/mod` 存在,正式走 root 不受影响)。
2026-09-14 20:24:09 +08:00
af42a08fbf fix(deploy): 故障通知脚本的安装原来在 if 分支里(--skip-web 就不装)+ 判据 ③ 只验"在不在"
pi 读代码抓到的两处,实测确认:

1. `redeploy-gateway.sh` 的 `install … service-failure-notify.mjs` 夹在
   `if [ "$SYNC_WEB" = 1 ] && [ -d …/dist/assets ]` 里(在 `[ OK ] 前端产物比源码新`
   之后、`else` 之前)⇒ **`--skip-web`、或 `dist/assets` 不存在时运行时脚本根本不装**。
   它跟前端产物没有任何关系,只是恰好被写进了同一支。后果:改了仓库里的通知脚本、
   用 `--skip-web` 部署 ⇒ **生产还是旧的那份**。
   已移出 if/else(并加 `install -d` / 安装失败的判失败 + exit 2)。

2. 判据 ③ 原先只判 `<isFile> && 有执行位`,不判**是哪一份** ⇒ 上一条的后果全绿。
   现在比内容(与仓库 `deploy/service-failure-notify.mjs` 逐字节),
   读不到仓库那份时**报"比不了"并判红**,不许当成"一致"。
   —— 判据 ② 对 unit 本来就是比内容的,③ 该同形。

自检新增一条:★通知脚本内容与仓库不一致 ⇒ 必须红。
(探针第一版按**文件名**判两侧,而两条路径的文件名相同
(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/service-failure-notify.mjs`)
⇒ 两边返回同一个串、探针自己没分辨力。改成按哪一侧区分。)

现状实测:仓库与装机两份 sha256 相同(496 行),所以这是**盲区而非事故** ——
pi 的措辞准确。自检 28 条全过;实跑 ③ 报"内容与仓库一致"。
2026-09-14 20:23:26 +08:00
3b5c51c41e fix(deploy-drift): 补上 EXCLUDE_DIRS 挖掉的依赖树洞(②b)+ 写明排除口径表
pi 逐条对过 `EXCLUDE_*` 与 `redeploy-plugin.sh`,最要紧的是 `node_modules` 那格:
部署脚本**把依赖拷进快照**("依赖必须进快照:仓库外没有 node_modules 可借"),
而本文件整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。
这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿。

新增判据 ②b:读锁文件里的**版本集合**做签名(一个文件、24ms、实测 165 个包)。
选它而不是逐文件比:抓的是"依赖树漂移"这个真实风险,又不会被 `node_modules`
里的缓存噪声乱报(真的逐文件比 `node_modules` 不现实)。
- 一致 ⇒ 绿并说出比了几个包;版本变了 ⇒ 红;**一侧没有 ⇒ 也红**(不许当"未比"放过)。

过程中连踩两个同形状的坑,都记在代码里:
1. 依赖是**符号链接**(`@earendil-works/pi-coding-agent -> /usr/lib/node_modules/…`),
   第一版只挑 `isDirectory()` ⇒ 空手而归 ⇒ 恒报"两边都没有依赖树,未比"
   —— **又是"看起来在比、其实没比"**;
2. 我把 `repoUnits`/`liveUnits`(systemd 目录)传给了找锁文件的函数,
   于是永远找不到 ⇒ 同一个形状再来一次。
⇒ 教训:一条判据如果**只能靠真文件系统喂**,它就没法被自检;
`findDepLock` 因此改成只走注入面,自检样本把它真正走一遍。

另外:
- 排除口径写成**表**(对齐/未对齐各自说明),不再只是"逐条对齐"一句话;
- `dist.old` 加进 `EXCLUDE_SUFFIX`(部署脚本会 `rm -rf` 它,仓库里若有会造成**永久假红**);
- `coverage` 保留排除但写明"仓库里当前不存在,无实际影响,保留是为了不假装对齐";
- `.cache`/`*.log`/`.DS_Store` 三处**方向相反**(本文件比脚本更宽)写明为已知取舍。

自检新增三条:★依赖树一致 ⇒ 绿且说出包数、★版本变了 ⇒ 必须红、★一侧没有 ⇒ 必须红。
自检 25 条全过;实跑 ②b 报"165 个包,版本集合一致"。
2026-09-14 20:22:12 +08:00
5e38914cac fix(deploy-drift): 判据 ② 是假绿 —— 它比的目录不存在(一个文件都没比过,却报"一致")
pi 逐行读出来的(我实测确认):默认路径写成
`new URL('../systemd', import.meta.url).pathname`,而 `import.meta.url` 在 `deploy/` 下
⇒ 解析成 `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`)。
`readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉 ⇒ `drift` 恒空
⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致",而且它参与退出码。**

这是第四种形态的标本(**边界没说出口 ⇒ 报了个自己都不知道是假的 0**),
而且**自检接不住它**:`layoutSelfCheck()` 每个样本都显式注入 `repoUnits:'/repo/systemd'`
⇒ 真实默认值从没被任何样本走过 ⇒ 写错了自检也 100% 绿。
**注入点把该抓的 bug 藏起来了** —— 同族里这是最难看的一层。

四处一起修:
1. 路径改成 `join(HERE, 'systemd')`,并**导出** `DEFAULT_REPO_UNITS` 让自检能断言默认值本身;
2. `catch` 不再静默:目录读不到 ⇒ **判红**("比不了"不许伪装成"一致");
   **空目录也判红** —— 真实目录有 22 个文件,不可能是空的(这条是被自检逼出来的:
   注入的 readdir 对未知目录返回 `[]`,于是"不存在"能伪装成"空",我第一版修法又栽成恒绿);
3. **反向也判**:机器上有、仓库里没有的(原先永远不报 —— 那正是"仓库里的是旧的、
   机器上的是新的"的另一半)。**口径必须收窄**:`/etc/systemd/system` 下绝大多数是
   系统自带 unit 与 enable 出的软链(实测 111 个),全报等于没报 ⇒ 只报本仓库自己那套、
   跳过软链 ⇒ 现在 0 个;
4. 绿的时候 note 带上覆盖范围("比了 22 个文件,全部一致(目录:…)")——
   空 note 无法区分"一致"和"没比过",而那正是这条判据原先的样子。

自检新增三条判据:★默认单元目录存在、★默认单元目录不是仓库根下那个不存在的 systemd/、
★单元目录读不到 ⇒ 必须判红。另把 `bad[0]`/`badBak[0]` 这类**位置选择器**改成
按名字取(`byName`)—— 位置选择器是另一种"注入点藏 bug":插一条新检查就会改变语义。

**变异确认**(这次先提交、再变异):把默认路径改回 `../systemd` ⇒ 判据 ② **红**,
note 精确报 `仓库单元目录读不到:/home/program/agentmail/systemd(ENOENT)`。
⚠️ 上一次做这个变异时我在**未提交**状态下用 `git checkout HEAD --` 还原,
把自己的改动一起冲掉了 —— 这正是我自己写进 DEV-TOOLING 的那条纪律,我又踩了一次。
这次先 commit 再变异。

验证:`--self-check` 全过;实跑 ② 由"空 note 恒绿"变成"比了 22 个文件,全部一致"。
2026-09-14 20:19:30 +08:00
a1ed4ea1a5 docs(dev-tooling): 变异纪律补两条 —— 判据退出码不许经管道取值(tail 改写了量纲)+ 新判据上线先找相反的不变量
第 6 条是我自己刚踩的:`bash check-shared-libs.sh 2>&1 | tail -25; echo $?` 拿到的是
tail 的退出码,于是那条脚本的红(真值 1)被我报成 0 —— 只因为失败信息走 stderr 没进管道
才露出来。第 7 条是这轮最贵的一课:新的'不可达就搬走'与既有的'共用模块逐字节同源'
方向相反,我只看⻅了自己那条,差点让下次部署静默删掉 dsh 桥的生产模块。
2026-09-14 20:14:47 +08:00
fbdfdcc81d fix(deploy): staging 拷贝失败必须当场 exit 2(这次实测打出了假的 [ OK ])+ 登记同类未堵点
一次在沙箱里跑的 `bash deploy/redeploy-plugin.sh pi`(本意是修生产缺文件)被权限拒绝,
结果暴露出脚本自己的一个假绿:

    mkdir: cannot create directory '…/.20260914-201204.staging': Permission denied
    cp: cannot create directory '…' : Permission denied
      [ OK ] 已拷入 node_modules(生产不借用仓库的依赖)      ← 一个字节都没拷
      [FAIL] staging 里没有入口 src/index.mjs

**一段输出里两个互相矛盾的信号,而且 OK 在前** —— 这正是本仓库反复记录的那个病
("从失败里产出一份看着正常的报告"),这次是部署脚本自己得上了。

改动:`mkdir`/`cp` 当场判失败并按约定 exit 2(环境/权限问题,不是检查问题);
`node_modules` 那一份**拷失败就不再打 OK**(依赖进不了 staging 会在重启时才炸,
而那时旧版已经被换掉了)。实测同一路径:现在**立刻** exit 2、零个假 `[ OK ]`。

成功路径未受影响(脚本其余部分与 `[ OK ]`/`[FAIL]` 约定不变)。

`docs/DEBTS.json` 登记同类未堵点 `redeploy-script-unguarded-steps`:
`deploy/redeploy-gateway.sh:84` 的 `run "cp -r …"` 无守卫,而该脚本只有
`set -uo pipefail`(**无 `-e`**)、`run()` 内部 `eval` 的失败既不中断也不被调用点接收
⇒ 前端产物没拷进去也继续往下走。**没有当场改那个文件**:另一条会话正在改它,
改它等于把冲突塞给一条我看不见的路径(`install.sh` 已有 `set -euo pipefail`,无需处理)。
登记的 `due` 就是"下次改任一 deploy 脚本时"。

验证:`npm test` 463/463、`check-shared-libs.sh` exit 0、`--self-check` 全过、`bash -n` OK。
2026-09-14 20:13:57 +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
2d936893f5 fix(deploy): /tmp 占满把部署自己卡死了 —— 收构建暂存 + 构建带 -trimpath + 判据 ⑤
起因是用户让「清理一下」那批带仓库路径的残留。照着清理策略走时撞上更大的事实:
本机 /tmp 是 9.8G 的 tmpfs,**已 100% 满、可用 0 字节**,我自己的 `go build` 当场
ENOSPC 失败 —— 而部署的第一步就是构建。

## 1 谁把 /tmp 占满的(agentmail 自己的那份)

`redeploy-gateway.sh` 把网关构建到 /tmp 再 install 过去(为了原子替换),**用完没人删**:
每次部署留一个 24MB,实测 7 份 / 162MB。加上电子打包的中间物(squashfs-root 283MB、
pkgcheck/deb 291MB)、go-build-agentmail 缓存 172MB、4 个孤儿 go-build 工作目录 50MB
—— agentmail 名下约 960MB。另有别的产品的 /tmp/gocache 4.6G(TrueAgent 的
rebuild-plugins.sh 里 `export GOCACHE=/tmp/gocache`),不是本项目的,没动。

- prune-deploy-artifacts.sh 新增一类「构建暂存」,窗口 KEEP_BUILD_STAGES=1
  (正常路径下部署脚本自己会收,留下的只可能是失败那次,正好留现场)。
  自检 +1 项、变异验证过(把删除改成永不删 → 恰好那一项红)。
- 本次实际收:删除 8 项 / 释放 164MB(另加手动清 623MB 不可再生的中间物)。
- redeploy-gateway.sh 成功分支上收掉 $STAGE;失败/回滚分支**不删**(要留现场)。

## 2 残留里还藏着两处「旧真相」

- /etc/systemd/system/zcode.service.bak-20260912-145744(+ 同一次改动的
  zcode.service.d/10-dbus.conf.bak-…)里躺着 /home/program/agentmail/deploy/
  service-failure-notify.mjs —— 就是我上一封报「/etc/systemd 引用仓库 = 0 个文件」时
  **判据自己划掉了的那一类**(walk 里 `!name.includes('.bak')`)。已删(在线单元与
  deploy/systemd/ 逐字节一致,sha256 核对过),另外 4 个是别的产品的,没动。
- 判据 ① 因此放宽到含 .bak,并补了坏样本(.bak 里引用仓库路径必须判红)。
  上一封那句「0 个文件」的边界现在写进判据里了 —— 边界不说出口,就等于报了个假的 0。

## 3 -trimpath:标准目录部署只做了一半

Go 默认把源文件绝对路径编进二进制。对照实验(同一份源码、同一个 go,只差标志):
带 -trimpath 0 处,不带 57 处 —— 而 19:05 那次部署产出的
/opt/agentmail/agentmail-gateway 里就有 57 处 /home/program/agentmail/…。
依赖确实没了,但**源仓库位置还印在产物上**。两个构建点都加上 -trimpath,
并新增判据 ⑤(已安装二进制不得含源码路径,两侧样本都验)。

判据 ⑤ 现在**是红的**,这是存量产物的实情:磁盘上那份要等下一次
redeploy-gateway.sh 才会被换掉。我没替它单独重启网关 —— 会掐断正在跑的会话。

(工作区是多会话共用的,本次只 add 了上面这 4 个 deploy/ 文件。)
2026-09-14 20:06:19 +08:00
6b1cca0fe4 docs(dev-tooling): 加一条提交纪律 —— 多 agent 共用工作区,提交前先看 git status 里有没有别人的文件
这个工作区是多条会话共用的:2026-09-14 20:02~20:04 另一条会话正在改
deploy/install.sh、deploy/prune-deploy-artifacts.sh、deploy/redeploy-gateway.sh
(-trimpath 那组加固),而我同时在同一个仓库里连续提交。
`git add -A` 会把别人没写完、没审过的改动一起做进我的提交,且从 git log 看不出归属。
规矩:显式列出要提交的路径;提交信息里不把别人的改动算成自己的。

(本次三次 pi-bridge 提交都是显式路径,事后用 git show --stat 核对过没有混入。)
2026-09-14 20:05:15 +08:00
e49f0a8f6a docs(dev-tooling): 写点三处**不同的入口假设**、过宽过窄、因果无关判据、锚点、lib/ 边界 + 一条被自己纠正的观察
按 pi 的两条建议补:

1. "漏过三次"改成**列出三次的位置与各自不同的入口假设**(他指出的问题:原写法
   会让读者以为"同一处错了三次"):① `writeSession` 只包 `writeFileSync`;
   ② `mk()` 的 `mkdtempSync` 在 try 之外;③ 兜底层放在 `main()` 而 `selfCheck()` 是导出的。
   三次都概括成一句:**"我以为的入口/哪一行"决定了覆盖范围**。
2. "判据只能发现已知成因"作为实例加进同节,并配对写出**因果无关**的运行期判据
   (`test/lib/run-suite.mjs` 数结果行 / 跨文件重名实测不被拦)。

新补四条(本轮新踩出来的):
- **判据过宽和过窄都是坏的**(那条 import 判据自己同时踩过两边);
- **判据的锚点必须与结论一一对应**(重名检查锚结果行 vs 锚"名字出现过",
  噪声与效应恰好同阶 ⇒ 4 看起来还能解释);
- **`lib/` 与 `test/lib/` 的边界**(部署脚本是 `cp -a` + `rm -rf test`,
  没有 `EXCLUDE_DIRS` 这种变量;★ 别写成"被 src/ 直接 import"—— 直接引用数不是可达性);
- **附一节"被自己的结论半路纠正的观察"**:那条 `result IS NULL` 的挂起请求看着像
  授权链缺口,实际是网关按设计处理的(`PermissionWaitWindow = 10min` + 推导出的
  "失效时刻")。教训写成"先查完'是否已由另一层按设计处理掉'再下结论"——
  这类误判会以"我发现了新问题"的语气传播,比沉默更贵。
2026-09-14 20:00:52 +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
d735e674e1 docs(dev-tooling): 判据纪律那节 —— 三种"看起来验过了"的失效形态 + 变异纪律 + 两条实现纪律
pi 复核后提出的收尾:跨文件的教训现在没有容器(实证散在三个文件的头注释里)。
先 `ls docs/` 看过:`docs/DEV-TOOLING.md` 已经有"三条判据,以及它们各自防的那个错"
那一节,主题完全相同 —— 所以**并进去而不是新开一篇**(这是 pi 明确要求的形状)。

写的是**可迁移**的那部分,实证不复述、只给索引:

- 三种失效形态:**钉装饰**(断言落在注释上)、**分支退化**(绑在会变的环境上)、
  **跑不到的分支**(断言在、区分力不在 —— 短路是无声的,比退化更狠);
- 变异纪律 5 条:先证明能还原再注入、只对已干净提交的文件做、还原路径不得依赖被测资源、
  判据不得用被测物证明自己、别只看过滤后的输出(也**不要**为此引入手抄的期望数常量);
- 两条实现纪律:测试文件之间不许互相 import(会二次注册整套用例,实测 475→459);
  写点覆盖范围不得取决于入口(这条在这套代码里被漏过三次);
  以及"判据过宽和过窄都是坏的"—— 那条"测试文件互相引用"判据自己同时踩过两边。
- 索引指出:`check-deploy-drift.mjs` 的运行输出**就是**部署状态的判据来源,
  **不要**抄成哈希清单往外发(发给 jianf 的那份在两次提交后就作废了)。
2026-09-14 19:52:41 +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
5c22daf51b fix(deploy-drift): 结论区那行命令跑不起来(node → bash)+ 写明判据 ① 的量纲
两处,都在 `deploy/check-deploy-drift.mjs`:

1. 结论区打印的修复命令是 `node deploy/redeploy-plugin.sh <host>`,
   而它是 **bash** 脚本 —— 照抄的人会拿到
   `SyntaxError: Invalid or unexpected token`(脚本第 2 行 `#` 被当 JS 解析)。
   这个坑的源头就是这一行:pi 在 2026-09-14 那轮部署请求里抄的就是它打印的输出,
   抄错不是阅读问题,是判据自己印错了。改成 `bash …`。

2. 判据 ① 的注释写明它比的是**仓库工作区当前内容**,不是 git HEAD。
   实证(同一天):`plugins/pi-mail-bridge/src/pool.mjs` 里一行未提交的
   `let missingSessionCount = 0;`(模块顶层、无人读)被 17:20 那次
   **从脏工作区做的**快照原样带进了生产,而判据 ① 当时报的是「逐字节一致」——
   工作区与快照确实一致,只是两者都不等于 HEAD。
   判据没错,是量纲只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳;
   想连那一跳一起判得单独比 `git diff --stat`。

(那条死代码本身已由 pi 的补丁 A 从工作区删掉,pool.mjs 工作区已回到 HEAD 内容;
下次部署后 `current/src/pool.mjs` 里的它才消失 —— 在此之前判据 ① 会因此报一处
pool.mjs 漂移,那是"待部署",不是新问题。)
2026-09-14 19:24: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
pi
5544edabaf fix(webui): 窄屏「我的」页补上圆角(与日历那次同一个根因,另一条分支)
用户(2026-09-14):「webui的『我的』页面,还是没有圆角」。

根因不在 AccountPage —— 在 App.tsx 的**分支**:没有列表栏的页面(我的/管理/写信)
走 `<div className="flex-1 min-h-0 flex">{main}</div>`,而 `.narrow-shell > *` 那条
圆角正落在这一层上。它是透明的、且 overflow: visible —— 有底色的是它里面的页面
根节点(`… bg-white`),内层直角从透明外壳里原样戳出来。
这与日历那次(`.narrow-stack` / `.cal-panes`)是同一条教训的**第三处**:
圆角必须给到**有底色的那一层**。

实测(390x844,真浏览器读渲染像素):
  改前 面板 radius=0px,四角像素 255,255,255(纯白=方角)
  改后 面板 radius=14px,四角像素 = 页面底色 249,250,251(被切掉)
  宽屏 1280 一直是好的(对照:与收件箱详情栏的四角剖面逐像素一致)

改法:那一层挂 `narrow-solo`,圆角写在 `.narrow-solo > *`(有底色的那层),
**不写 overflow:hidden**(这层下面的子元素自己就是滚动容器,与 app-shell 那段同一个坑)。
横屏紧凑的两栏(`narrow-duo`)照 `cal-panes` 只给**外沿**:首元素左两角、末元素右两角。

判据 narrow-layout 新增 3 条(规则与 JSX 按类名接得上 / 不写 overflow:hidden /
两栏只给外沿);变异自检:把 `narrow-solo` 类名去掉,判据立刻红。
已部署(d3f6f3b·0914-1904);.deb 与 dist 同批重打。
2026-09-14 19:07:28 +08:00
pi
d3f6f3baf6 跨端: 鸿蒙日历写侧(新建/编辑/删除/暂停恢复)+ 表单不再是一面滚轮墙
用户:「是的去吧」(批准上一封列出的下一步)。

- CalendarApi:createEvent / updateEvent / deleteEvent(POST /calendar/events、
  PUT/DELETE /calendar/events/{id});新建与编辑发**同一份** CalendarEventInput
  —— 服务端两个端点同形(CreateCalendarEvent 里那个 Status 字段的注释就写着这句),
  拆两份会在严格解码下 400
- CalendarPage:写侧表单(新建与编辑共用一份)、二次确认删除、暂停/恢复走 update
  (调度器只触发 active;没有暂停就只能删掉重加,而那会丢 event_id 与历史)
- 服务端 400 文案原样显示(收件地址无法解析:…)—— 建事件时就校验地址,
  吞成保存失败等于让人猜,而猜的代价是以为设好了、实际永远发不出去
- 空标题/空收件人在本地就挡住,不发注定 400 的请求
- 提交的是**时间戳**(isoTimestampOfLocal),不是日期键;编辑回填走 localDateOf
- ★ 表单形状:日期/时间选择器合起来 ~1400px,展开着放会把标题与收件人挤出屏幕 ——
  改成一行摘要 + 点开展开(默认收起)。这不是美观问题:展开态下保存在屏幕外,
  要跨过一整面滚轮才够得着
- 判据 +7(端点/载荷/前置校验/文案/二次确认/时间戳往返/变异自检),23 条全绿
- 真机实测(hvigorw + hdc + uitest 逐字段输入 + 逐键点击,每一步都回库核对):
  建 → 库里 event_time 是 2026-09-28T01:00Z(= 本地 09:00 整,默认值正确)、
  暂停 → status=paused、删除第一下只变确认删除(库里还在)、第二下才真删(库里 0 条)
2026-09-14 18:57:53 +08:00
pi
94ba4b9c58 跨端: 鸿蒙端功能同步第一步——日历(只读月视图)上架,入口进底部导航
用户:「要给鸿蒙端做功能同步」。按 API 面盘点(WebUI 62 个 API 函数 vs 鸿蒙 38 个),
最大的用户面缺口是**日历**:纯逻辑(model/Calendar.ts)与判据早就在,一直没页面。

新增:
- api/CalendarApi.ets:GET /calendar/events?from=&to=(与 WebUI 同参;区间按**网格**取,
  不是月首月末 —— 首尾格子会显示邻月,只查当月会让那些格子永远空着)
- pages/CalendarPage.ets:月网格(翻月/回今天)、点某天看当天日程、事件点、今天/选中两态、
  加载失败说出来。**没做**:写侧(增删改)、农历重复、.ics、滑动翻页 —— 逐条写在文件头
- model/Calendar.ts:补 localIsoOf / hhmmAtOffset / deviceOffsetMinutes(偏移是入参 ⇒ 三时区可真跑)
- model/Models.ets:CalendarEvent / CalendarListResponse(字段对齐服务端 JSON)
- NavItems:加「日历」,底部成为 通信/日历/联系人 三项(与 WebUI 同序)
- MainPage:日历是**常驻 pane**(visibility 控制),首次可见才拉数据;today 走
  @Prop @Watch(visible) 在 pane 变可见时重算 ⇒ 结算欠账 calendar-today-recompute
  (DEBTS 15 笔 → 14 笔,余额里不再计这一笔)

判据:harmony-calendar 新增 6 条(网格/表头同源、事件归日走 localIsoOf、三时区钟点、
today 重算路径、翻月走 addMonths、变异自检);harmony-nav ② 分派与 ④ 让位跟着改成结构性判据
(④ 原来那个 400 字符窗口一加 pane 就红 —— 窗口式判据的又一次现身);harmony-logic 两处
「只剩两个平级页签」跟着改成三项。

真机实测(harmony-emu + hvigorw assembleHap + hdc install + uitest click + dumpLayout):
9 月网格星期对齐(周一起始,2026-09-01 落在「二」列)、事件点恰好在有日程的那 6 天
(11/17/18/24/25/30)、点 09-17 列出当天两条日程且钟点是本地时间(DB 里 02:20Z/08:30Z
→ 界面 10:20/16:30)。
2026-09-14 18:38:51 +08:00
pi
79e591aa8b 跨端: 底部导航选中态——图标也变色(鸿蒙侧补齐),选中态只换颜色
用户:「ui更新同步到鸿蒙端」+「选中对应的文字和图标变色即可」。

WebUI(上一提交):删掉背景块与顶部指示条,只留颜色。
鸿蒙侧:本来就没有背景/指示条(选中态从没用形状表达过),缺的是**图标那一半** ——
MainPage.NavItem 里只有 label 上了 fontColor,图标一直保持默认色,看着像选中了一半。

- MainPage.NavItem:图标补 fontColor,与 label 过同一个三元式
- 判据 harmony-nav ⑤ + 真变异自检(删掉图标那行 fontColor,⑤ 立刻红)
- 自检本身也修了一处窗口式判据:原来用 [\s\S]{0,80} 找 fontColor,
  把下一行 label 的也圈了进来,自检自己假绿
- 模拟器实测(harmony-emu + hvigorw + hdc + uitest):同一字形在两项之间颜色随选中**对调**
  (核心色 81,121,187 选中 vs 135,168,217 未选中)
- 提交归属:本提交同时动 harmony 与 electron,按仓库判据自报家门(跨端:)
2026-09-14 18:38:33 +08:00
12294835da fix(余额): 圆角差异条目补两侧数值 + 策略现在就定(设备只做验证、不做决策)
pi 2026-09-14 两条。

1. **别让设备当"以谁为准"的决策者**:这个工作区起不了模拟器,把决策挂在设备上
   就是一笔**永远不还**的账。观测能告的只是"并排看是否感知不一致",而策略现在就能写 ——
   已写进条目(提案,待人追认):**语义配对成立 ⇒ 数值按两端各自成立(WebUI 的 rem 基准
   vs 鸿蒙 vp/系统档),默认以 WebUI 为准**;只有鸿蒙平台规范明确要求系统档时才反向。
   设备**只做验证,不做决策**。`kind` 从 env 改回 **scope**:这里等的是"有人拍一下",
   不是"环境到位";并写明**实现者不能自己拍**(同 `unknown-preset-approval`)。

2. **补两个数**(没有数就既不能裁定也不能验证):
   · WebUI:`0.875rem`=14px / `0.5rem`=8px;
   · 鸿蒙:**本工作区读不到数值** —— `toolchains/id_defined.json` 该条目只有 name/type,
     SDK 的 `ets-loader/sysResource.js` 只有**资源 ID 125829709 / 125829702**,
     实体值在**系统资源包**里(编译进 resources.index / 随设备)。
   这个"只能读到 ID、读不到值"的事实写进条目,免得下一个人以为去查一下就有。
2026-09-14 17:52:15 +08:00
pi
59b2575838 fix(webui): 底部导航选中态只换颜色(去掉背景块与顶部指示条)
用户:「同时底部导航栏选中对应的文字和图标变色即可」。

- NarrowNav:删掉 bg-blue-400 顶部指示条
- index.css:.narrow-nav .nav-item[data-active='true'] 背景置 transparent——
  只作用在底部导航;宽屏侧栏是 48px 竖条、没有文字标签,那块底色是它唯一的选中线索
- 图标本就是 stroke=currentColor,所以跟着文字色走(判据钉住这一点)
- 判据 nav-merge ④ + 变异自检:把 bg-blue-400 加回去,④ 立刻红
2026-09-14 17:50:34 +08:00