Commit Graph

10 Commits

Author SHA1 Message Date
d562f55d78 采纳 pi 反提案: 声明动作化 agentmail_require + 新判据 check-require-declaration.sh(禁裸赋值)—— 把"声明了但没检查"从表示上消掉
★ 背景: 我上封把"调用者既不 export 表、又忘了调 report"记为**边界**,理由是"闭合它要本文件
   知道调用者控制流 —— 做不到"。pi 指出**前提可以换掉**(我复核采纳):
     病根不是"控制流不可知",而是"**声明**(赋值)与**检查**(调用)是两个可分离的动作"。
   ⇒ 把声明做成**动作**: `agentmail_require <命令…>` = 赋值 + 立刻检查 ⇒ 该状态**不存在**。
★ 改动:
   · env-defaults.sh 加 `agentmail_require()`(内部就是赋值 + agentmail_env_check_require)
   · 三个调用者由 `AGENTMAIL_REQUIRE="…"` 改为 `agentmail_require …`(各 1 行)
     也顺带把"重新导出为环境变量"的写法去掉了 —— 表只在本进程内被 ③b 读,不需要 export
   · 新判据 `deploy/check-require-declaration.sh`(755): deploy/ 下的**调用者**不得写裸赋值
   · install.sh 接线,走 `CHECK_GATE_RC` **累积**通道(照 check-file-modes.sh 的既有做法:
     直接调会在 set -e 下中止,把后面的收尾诊断全吃掉)

★★ 判据自己的两个 bug,都是**实测**抓出来的(不是审出来的):
   ① 假红: 我第一版按"`deploy/**/*.sh` 全扫"划范围 ⇒ 立刻把 `env-defaults.sh:449`
      (`agentmail_require()` 函数体里的 `AGENTMAIL_REQUIRE="$*"` —— **动作自己的实现**)
      判成违规。⇒ 改为按**性质**划: "调用者"= **真的 source 了本库**的文件,
      减去定义动作的那个库自己("调用者"这个身份由 source 这个动作定义,不由目录猜)。
   ② ★ 假阴且**不稳定**: `strip_comments "$f" | grep -q …` 在 `set -o pipefail` 下,
      `grep -q` 一命中就退出 ⇒ `sed` 收 SIGPIPE(rc=141)⇒ **管道整体 141** ⇒ if 判假 ⇒ 漏文件。
      实测同一个脚本两次分别找到 **2** 个与 **1** 个调用者(实际 3 个),
      而 rc=141 被 if 静默吞掉 ⇒ 集合**偏少且不稳** ⇒ "裸赋值 0 处"会是**假绿**。
      ⇒ 改为先收集到变量、再 `grep <<<"$body"`(无管道)。修后重复 10 次**稳定 3 个**。

★ 验证(全部真跑):
   · 基线 rc=0(3 个调用者,裸赋值 0 处);重复 10 次稳定 3
   · 变异①: 把 redeploy-plugin.sh 改回裸赋值 ⇒ rc=**1** ✓
   · 变异②: 注释掉一个调用者的 source(仍剩 2)⇒ rc=0 ✓ 非空转
   · 变异③: 三个 source 全注释 ⇒ **集合空** ⇒ rc=**1** ✓(防空转,不是恒绿)
   · 每个变异后**还原**并复测 rc=0;`git diff --numstat` 确认只有预期行
   · criteria-hygiene **7/7** 绿(新 check-*.sh 已被 install.sh 真调用)
   · pi 的 env-guard **17/17** 绿;五个脚本 bash -n 全过;生产未动
★ 残留(判据头已如实写): 盖"字面裸赋值",**不盖间接赋值**(经 eval/read/env 注入)——
   补它需 shell 语义分析,超出"一条 grep 型判据"的射程。
2026-09-25 06:51:25 +08:00
e65f41b31b ★ 修本文件自己的大假绿: ③b 的 AGENTMAIL_REQUIRE 检查**只在 source 期跑**,而三个调用者都是**先 source 后赋值** ⇒ 那张表从未被检查过
★ 缺陷(pi b6e24606 实测报回,dsh 独立复核成立):
     install.sh          source 131 / 赋值 135
     redeploy-gateway.sh source  42 / 赋值  46
     redeploy-plugin.sh  source  52 / 赋值  56
   ⇒ source 那一刻表是**空的** ⇒ `if [ -n "${AGENTMAIL_REQUIRE:-}" ]` 直接跳过 ⇒ 整张表从未生效
★ dsh 独立 A/B(同一台同一 shell):
     AGENTMAIL_REQUIRE=__no_such_cmd . env-defaults.sh  ⇒ rc=2(检查本身是好的)
     . env-defaults.sh; AGENTMAIL_REQUIRE=__no_such_cmd ⇒ rc=0(**漏过**)✓ 与 pi 一致
★ 这正是本文件头反复记的那件事落在**本文件自己**身上: **判据在,但走不到**
   —— 与我 60d59f9 造出一个走不到的判据是同一形状,只是这次是"判据在库文件里、而调用相位错开"
★ 修法: 把检查提成 `agentmail_env_check_require()`,**两处都挂**:
     · source 期一次 —— 接"表由**环境**提供"(父进程 export / CI 注入)
     · `agentmail_env_report()` 内再一次 —— 接"表由**调用者**赋值"(本仓三个都是这种)
   不选"改三个调用者的相位": 要改三处,且下一个新调用者还会犯同样的错。
★ ⚠️ 残留缺口(如实申报,不写成"已修好一切"): 若某调用者**既不 export 表、又忘了调
   `agentmail_env_report`**,两次都不跑 ⇒ 仍会漏。闭合它要求本文件知道调用者的控制流,做不到。
★ 验证:
   · A/B 三向: 赋值在 source 后+调 report ⇒ rc=2 ✓/赋值在 source 前 ⇒ rc=2 ✓/命令都在 ⇒ rc=0(非恒红)✓
   · 变异: 删掉 report 体内那次复检 ⇒ rc=0 **退回漏过** ⇒ 证明那次调用**承重** ✓
   · 真调用者端到端: 就地在 redeploy-gateway.sh 表里注入 `__no_such_cmd_zz`
     ⇒ 实跑 rc=2 且报出该名字 ✓;还原后 `缺少命令` 命中 0 ✓(已确认工作区干净)
   · pi 的 env-guard 17/17 绿;四个脚本 bash -n 全过;生产未动
2026-09-25 06:42:32 +08:00
eb5c4aa1b4 修 env-defaults.sh 自己的一个假绿: HOME 可用性判据从 [ -w ](权限位)改成**真写探针** —— 与我自己上封那个错同一形状
★ 缺陷(我在认自己 `[ -w ]` 假绿时顺带查出来的,就在我的 scope 里):
   196 行 `mkdir -p "$_am_home" && [ -w "$_am_home" ]`
   179 行 `... || [ ! -w "$_am_home" ]`
   两处都只看**权限位**。uid=0 时对任何 root 属主目录都返真,而**实际拦截可能在另一层**
   (本会话每次 shell 都带 `landlock-run: partial enforcement`)。本机实测:
       [ -w /root ]          ⇒ true    / touch /root/x          ⇒ Permission denied
       [ -w /opt/agentmail ] ⇒ true    / touch /opt/agentmail/x ⇒ Permission denied
   ⇒ 而且 `mkdir -p` 对**已存在**的目录不写任何东西(rc=0),所以它**接不住**这个假绿
     —— 文件 177 行原本就写了"mkdir -p 会返回成功,所以必须再单独判",但"那个单独判"选错了层。

★ 后果正撞在本文件存在的理由上(**误指对象**):
   HOME 空 + uid=0 ⇒ `_am_home=/root` 被误判可用 ⇒ HOME=/root 放行
   ⇒ ② 段取 `$HOME/.cache/go-build` 当 GOCACHE ⇒ 那里用的是**真写探针**(297 行)⇒ 失败
   ⇒ 报出「**GOCACHE** 不可写」,而根因是 **HOME** 不可写
   ⇒ 实测该路径可复现(`env -u HOME bash -c 'source deploy/lib/env-defaults.sh'`)

★ 修法: 加 `_am_can_write()` —— **真去写一个探针文件**(与 297 行同一办法),
   179/196 两处都用它。**必须两处一起改**: 196 的 else 是 `exit 2`(不是 fallback),
   只改 196 会让 /root 走到 `exit 2` 而不是改判。
   ★ 顺带把 WARN 的括注从"(非 root / passwd 里没有可用目录)"改成
     "(已实测真写探针;uid=%s)" —— 原括注是**按 uid 猜的原因**,而判据已不按 uid 判,
     uid=0 也会走到这里。**不报没验过的原因**。
   ⚠️ 前半句 `按身份推出来的 HOME=%s 不可用` **一个字都没动** —— 它是
     `plugins/pi-mail-bridge/test/env-guard.test.mjs:268` 的 assert.match 锚
     (我第一版把它改成了"写不进去",**实测把 pi 的测试弄红了 16/17**,已改回)。

★ 变异测试: 把 `_am_can_write` 改成恒真 ⇒ **被抓住**,且复现的正是上面那条误指
   (HOME 空时不再改判 ⇒ 直接报「GOCACHE 不可写」)✓
★ 反空真: 探针对 /tmp、/home/program/agentmail 返真,对 /root、/opt/agentmail、
   /nonexistent、空串返假 ⇒ 有区分力(非恒真非恒红)✓
★ 验证: pi 的 env-guard 17/17 绿、criteria-hygiene 7/7 绿、三处 source 者 bash -n 全过、
   install.sh/redeploy-*.sh 未改。**生产未动**。
2026-09-25 06:33:23 +08:00
3c8f18d4de 修复: 环境预检标相位——干跑里只 gate 得住某一步的预检改为"记账并继续、末尾按环境 exit 2",不再让第一道环境红吃掉全部诊断
pi 2026-09-15 报的是**我上一轮修复的代价**,而它是我两轮前刚从 `CHECK_GATE_RC` 上修好的
**同一个病**、只换了个入口。

## 一、病:`--check` 一条诊断都不跑

```
$ bash deploy/install.sh --check   → EXIT=2,输出 7 行,`========== test/` 命中 0 次
```

`env-defaults.sh` 在**开头** source,③c 的 GOCACHE 预检在那里 `exit 2`
⇒ 整个干跑在第一步就结束。被吃掉的正好是与 GOCACHE **毫无关系**的前端门禁
(typecheck / 判据 / build)—— 而"别让第一道红吃掉后面的诊断"是我两轮前刚立的性质。

## 二、修法:逐条标相位(不是整体开关)

两条规则都对、撞在一起:**部署路径**要 fail-fast;**`--check`** 的产品**就是诊断本身**。
所以按"这条预检拦得住的是哪一步"**逐条标记**:

| 预检 | 拦得住什么 | 相位 |
|---|---|---|
| ③c `GOCACHE` | 只 gate 得了 **go 那一步** | **可推迟**(记账 → 继续 → 末尾 exit 2)|
| ②b `TMPDIR` 可写/空间 | 构建与测试都要写它 | 可推迟(未标,留给下一轮按同一形状处置)|
| ① `HOME`、② `TMPDIR` 存在、③b 外部命令 | **诊断本身** | **不标,仍立刻 exit 2** |

推迟后由 `agentmail_env_flush` 在**诊断跑完后**统一报出并给退出码 **2**,
报文里写明"这与**某道门红了**不是同一件事"。
**部署路径行为完全不变**(实测:同一环境下 6 行、exit 2、0 条诊断)。

## 三、四象限实测(同一台机,只改 `GOCACHE`)

| 路径 | 环境 | 退出码 | 诊断文件数 |
|---|---|---|---|
| `--check` | 坏 | **2** | **23**(原先 0)|
| `--check` | 好 | **1**(前端 3 真红) | 23 |
| 部署 | 坏 | **2** | 0(fail-fast,不变) |

`--check` 零副作用仍成立(`git status` + `dist` 全量 sha 指纹前后一致)。

## 四、★ 实现过程中又两次"判据在,但走不到"——都是我实测抓的

**① 相位标记设在了 `source` 之后。** 我第一版把 `AGENTMAIL_ENV_DEFER=1` 放在原来的
参数解析处(第 24 行附近),而 `CHECK_ONLY` 在那里**还没赋值** ⇒ 条件为假 ⇒ **标记根本没设上**;
更坏的是**输出看不出区别**(照样一条诊断都不跑)。真因:标记是给 **source 期间**跑的预检看的,
而 `env-defaults.sh` 在 source 时就跑完了预检 ⇒ **参数解析必须整段挪到 source 之前**。
⇒ 已把解析块上移到 `REPO=` 之后、`source` 之前。

**② 末尾的 flush 放在前端门禁的 early-return 之后。** 于是"环境不足 + 前端门禁红"时
它**永远不会执行**(实测:退出码 2、末尾环境报文一行都没有)。已移到 `exit "${CHECK_GATE_RC:-0}"` 之前。

**③ 又一例同族:分类代码自己 `rm` 了要 grep 的文件** —— 上一轮已修,这轮复述在注释里当参照。

## 五、去重(这条是被实测数字逼出来的)

第一次跑通后报文说"环境不足(**2 项**)",而实际只有**一个**根因:
GOCACHE 不可写 会响两次 —— ③c 的预检、以及 `go vet` 因它跑不起来(`install.sh` 的分类步)。
⇒ `agentmail_env_defer KEY MESSAGE` 改成**按 KEY 去重**,两处共用 KEY `gocache`。
理由写进代码:**"数出来的问题个数"也是一种读数,读数错了就是又造了一个假数字。**

## 六、状态

build `checks=401 pass=400 fail=1 red=4`;install `389/389 fail=0 red=3`;`criteria-hygiene` 6/6。
2026-09-15 14:12:48 +08:00
633150cf39 修复: go vet 的环境失败不许冒充代码缺陷——env-defaults 补 GOCACHE 预检(缺项)+ 安装脚本按输出分类,命中缓存/权限/空间走退出码 2
pi 2026-09-15 指出的是**报文**的问题,而规矩本来就在 `lib/env-defaults.sh:25`:
"失败要**说清是环境问题**并按仓库约定用退出码 2(环境),**不要让它冒充代码缺陷**"。

## 一、缺项:③ 预检了**模块**缓存,没预检 **build** 缓存

```
$ go vet ./...   ⇒ internal/middleware/auth.go:9:2: open /root/.cache/go-build/55/…-d: permission denied
                   [FAIL] go vet / go test 不过 —— **先修好再安装**
```

报错**指着源码文件**说 permission denied,跟着那句话把人送去改代码 —— 而一行 `.go` 都没问题。

**处置(两道)**:

1. `env-defaults.sh` 新增 ③c:**GOCACHE 实测可写**(写一个探针文件,不只看权限位 ——
   root 无视权限位,`-w` 会骗人),不可用 ⇒ 人话 + **exit 2**;
2. `install.sh` 那一步按**输出分类**:命中 `permission denied` / `module cache not found` /
   `no space left` / `GOCACHE` ⇒ **退出码 2(环境)**、并且**不再说"先修好"**;
   没命中 ⇒ 仍按代码缺陷(退出码 1),报文里**明说**是哪种。

**实测(分类逻辑逐字节抽出、只替换"怎么产生输出")**:

| 喂进的输出 | 退出码 |
|---|---|
| `… open /root/.cache/go-build/55/x-d: permission denied` | **2** |
| `go: module cache not found: neither GOMODCACHE nor GOPATH is set` | **2** |
| `./internal/repo/x.go:12:2: undefined: Foo` | **1** |
| `--- FAIL: TestBar` | **1** |

## 二、★ 实现过程中两次把自己的判据写成"永远不会响",都是**实测**抓到的

这两条我记下来,因为它们是同一个形状、而且**都在我手上当天发生**:

**① 先 `rm` 再 `grep`** —— 第一版我写成

```
cat "$GOOUT" >&2
rm -f "$GOOUT"                              # ← 先删
if grep -qE 'permission denied|…' "$GOOUT"; then   # ← 再读,永远读不到
```

⇒ 分类**永远不触发**,所有失败都会被报成代码缺陷(**恰好是 pi 报的那个问题原样保留**)。
改成"先分类、再删"。

**② `set -e` 在 `if ! ( … )` 里被**抑制** —— 探针**恒绿**:

```
if ! ( set -e; : > "$GOCACHE/.agentmail-probe"; rm -f … ); then …
```

实测 `GOCACHE=/proc/self/nonexistent-gc` 时 `mkdir` 与重定向**都报 No such file or directory**,
而探针 **rc=0** ⇒ 判据说"能写"。真因:bash 在**条件上下文**里抑制 `errexit`,
于是子壳不中止、最后一条命令(`rm -f`)返回 0。
⇒ 改成**显式** `|| exit 1`,不依赖 shell 选项在特定上下文里的语义。

**③ 附带:我的预检自己造了一个目录** —— 第一版直接 `mkdir -p "$_am_gocache"`,
而 `go env GOCACHE` 在本沙箱里返回字面量 `off` ⇒ **在当前目录里创建了 `./off`**,
探针写相对路径必然"成功" ⇒ **假绿 + 副作用**("预检"改了工作树,正是刚从 `dist`/`gen:bg` 上
修掉的那一类)。已清掉(`rmdir`),并加了"**必须是绝对路径**,否则 exit 2"。
口径也改成**不依赖 `go env GOCACHE`**:`${GOCACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/go-build}`
(与本文件"只设未设的"一致),`go env` 只作为旁证。

## 三、实测:本机这个缺项是**真的在响**

```
GOCACHE=(未设)  ⇒ rc=2
GOCACHE=off       ⇒ rc=2
GOCACHE=./rel     ⇒ rc=2
GOCACHE=/proc/self/nope ⇒ rc=2
GOCACHE=/tmp/gc-ok ⇒ rc=0
```

而且它**不是**误报 —— 直接问 go 是最权威的:

```
$ GOCACHE=/root/.cache/go-build go build ./internal/repo/
open /root/.cache/go-build/a4/…-d: permission denied
```

⇒ 本机 go 确实写不了 build 缓存 ⇒ precheck 判环境、**exit 2** 是对的。
(`HOME` 未设时 env-defaults 会兜成 `/root`,所以推导出的正是 go 自己会用的那个路径。)

## 四、端到端(`--check` + 可写 GOCACHE)

```
$ GOCACHE=/tmp/gc-ok bash deploy/install.sh --check      → 退出码 1
    [FAIL] 前端门禁没过(退出码 1)—— 上面标了 [FAIL] 的那一处就是
    [ OK ] go vet + go test 通过
    + exit 1                     ← bash -x 追到终点那一行
```

- `go vet` 变绿(缓存可写)⇒ 说明 ③c **不是**无条件拦人,只在真不可用时拦;
- 退出码 1 来自**前端门禁那 3 条真红**,不是环境;
- 干跑仍然零副作用:`git status` + `dist` 全量 sha 的指纹**前后一致** ✓。

★ 照实写的取舍:`--check` 现在在 GOCACHE 不可用时会**提前 exit 2**,
于是"诊断全部跑完"那段不会执行。这是**有意的** —— 按仓库约定环境问题用 2 且要早说,
而"跑不完的诊断"本身是环境问题的症状。不是"第一道红吃掉诊断"那个老毛病(那条已修)。
2026-09-15 14:03: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
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
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
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