Commit Graph

21 Commits

Author SHA1 Message Date
2f17f62871 feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)

`redeploy-gateway.sh` 用 mtime 比对源码与 dist:

    newer=$(find src -type f -newer dist/index.html)
    [ -n "$newer" ] && exit 1

mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。

2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。

## 为什么不是拆掉那道闸

2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。

本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:

    误拦的代价 = 多构建一次(几十秒)
    误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)

## 判定口径(deploy/web-comment-only.mjs)

对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。

- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。

## 判据(7 格)

`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。

**变异验证**:

    去掉注释分支(所有行算真改动)  → 红 7(部署会被那一行注释继续拦)
    isCodeChange 恒 false           → 红 7(把 2026-09-14 的静默失败放回来)

第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。

## 真实场景验证(不是只跑单测)

    把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
      ⇒ commentOnly=true,理由写明「差异仅为注释」
    临时往 sse.ts 追加一行真代码
      ⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
    bash deploy/redeploy-gateway.sh --dry-run
      ⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建

## 顺带

`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
2026-10-02 13:58:42 +08:00
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
2393703100 feat(deploy): 构建步打印**工作树清单** —— 把不可归因的 modified=true 换成有名字的清单
pi 2026-09-25 提的第三种处置(既不判红、也不只留一个 bool),我采纳。

## 为什么一个 bool 不够

`go build` 在脏树上**照样**写 `vcs.revision=<HEAD>`,只额外记 `vcs.modified=true`。
那个位只说"含未提交代码",**不说是谁的什么** —— 而本仓是**多 agent 并发**:
未提交改动通常**不是部署者的**,是别人的在飞工作。

⇒ 这不是理论,本仓**已经付过代价**(`check-deploy-drift` 判据⑥ 注释里那笔账):
  2026-09-14 `pool.mjs` 一行未提交的 `let missingSessionCount = 0;` 被"从脏工作区做的"
  快照原样带进生产,而当时判据① 报的是「逐字节一致」。

## 处置:打印清单,不判红

```
say "3. 构建二进制" 之后、go build 之前:
  git status --porcelain 非空 ⇒ warn "工作树不干净 —— 下面这份未提交清单会被**编进这次构建**" + 逐行打印
  否则                        ⇒ ok "工作树干净(构建件不含未提交代码)"
```

★ 位置贴紧构建步:`go build` 读的就是**此刻**的工作树,所以这份清单才对应"进了这份二进制的东西"。
★ 判红与否改变不了的事实:**清单有名字,bool 没有** —— 出事时能直接看出"带进去的是谁的那两个文件",
  而不只能回答"当时是脏的"。(本会话同族:一个位 vs 一份清单 = 不可归因 vs 可归因。)

## 同时给 ⑤b 记一条**边界**(避免下一个人把它补错)

`check-deploy-drift` 的 ⑤b **读不到构建时的工作树** —— 它读的是二进制内嵌位,
而它那里的 `git status` 问的是**此刻**的树(两者可能不同:构建完又提交了、别人的改动进来了)。
⇒ 注释里写明:清单归**部署脚本**(那里才是同一时刻),⑤b 只回答"含不含未提交代码",
  **别把 ⑤b 补成 `git status` 然后当成构建清单**。

## 验证

`bash -n` 通过;`--dry-run` rc=0 且不落地(只留 0 字节锁),清单在 dry-run 下照常打印(只读);
自检 63 项绿、`判据自检失败` 不出现。
2026-09-25 06:38:13 +08:00
b5989a94a1 feat(deploy): 按 ⑬′ 给 ⑤b 与 §7 的绿补**负向清单**(判据须写出它不覆盖哪几类)
pi 2026-09-25 提的 ⑬′:写"要验 X"时必须写清**动作覆盖到哪**,而"覆盖到哪"要写成
**负向清单**(不覆盖哪些)—— 因为正向范围可以含糊,负向清单必须逐条列,否则等于没写。
他给的量化形式:假绿区 = |R| − |C|(R=宣称范围,C=实际能判红的集合)。

这两个判据原先只在**红**的时候说话,**绿**时读者会把"绿"读成"这份二进制没问题" ——
而它们其实只答了一个问题:"内嵌 revision 是否等于 HEAD"。

    ⑤b note(绿时): …;本判据**不覆盖**: ①从脏树构建(含未提交代码,只披露不判红)
                                ②部署后被手工替换/修改(只看内嵌 revision)
    §7 ok(绿时):   已装二进制 = 当前 HEAD(xxxxxxxx)—— 不覆盖: 从脏树构建 / 部署后手工替换

★ 这条规矩**已被它自己验证过一次**:我 ⑤b 的 R\C 里那一格(`modified=true` 的脏树构建)
  **不是我想到的,是 pi 找出来的** —— 我写 ⑤b 时以为 R = C。写出负向清单这件事
  本身就会把那格逼出来。

自检新增一格 `★网关二进制:绿时 note 必须写负向清单(不覆盖哪几类)`,62 → **63 项**,
`判据自检失败` 不出现;`bash -n` 通过;`--dry-run` rc=0 且不落地。
2026-09-25 06:33:46 +08:00
55158e1666 fix(deploy): 补 ⑤b 的**假绿洞** revision==HEAD ≠ "就是那份提交"(pi 指出,我实测复现)
## 洞

树脏时 Go **照样**写 `vcs.revision=<HEAD>`,只在 `vcs.modified` 上留痕 ⇒
只比 revision 会把"**未提交的代码**"当成"就是那份提交"。

实测(/tmp/vm2:HEAD=2931050a,改一行**不提交**再 build):

    revision=2931050a  == HEAD   ⇒ 我 ⑤b 判**绿**
    但二进制里是未提交的那行代码          ⇒ **假绿** ✓ pi 对

线上那份正是 `vcs.modified=true` ⇒ 它**不对应任何已提交版本**。

## 修法:说清楚,但**不判红**

`revision == HEAD` 与"不含未提交代码"是**两个量**,不能拿前者冒充后者 ——
但脏树在本仓是**多 agent 并发编辑的常态**,判红就是造一条总在亮的判据。
★ 同一个事实在本仓**已有既定严重度**:`check-deploy-drift` 判据⑥

    // ⑥ 工作区干净度 —— **WARN,不参与退出码**。

⇒ 两处都改成 **WARN/note 披露,不改 ok / 不加 CHECK_FAIL**,
  与判据⑥ 同严重度 —— 否则同仓同一个量会有两套红绿。

    redeploy-gateway.sh §7:  modified=true ⇒ warn(不动 CHECK_FAIL)
    check-deploy-drift ⑤b:   revision 相同但 modified=true ⇒ 绿 + note 点明"含未提交代码"

## 判据自检

新增一格:`revision 相同但 modified=true ⇒ 绿,且 note 点明"含未提交代码"`。
自检 **61 → 62 项**,`判据自检失败` 不出现。

★ 这条是本判据**唯一**的假绿形状,所以它必须有一格专门钉住——
否则下一个人把 note 删了、或把 ok 改成 false,都没有判据会响。
2026-09-25 06:31:04 +08:00
2c34d6e368 fix(deploy): 占位行判据改用**行的年龄**(阈值 1 会把健康并发判成泄漏)+ 后置验证补 binrev==HEAD
两件都来自 pi 2026-09-25 的实测,我逐条复跑确认。

## ① `占位行 <= 1` 是阈值版假红(pi 127746b5,我复现)

`ClaimRelay`(INSERT 占位) 与 `BindRelayMail`(UPDATE 绑定) **不在一个事务里** ⇒
"同时有 2 个请求在飞"的正常形态**就是** 2 个占位行(t1 claim、t2 claim、t1 bind、t2 bind)。

    变异(/tmp/pl/t.db:清掉化石 + 插 2 个**合法在飞**占位)
      旧判据: PLACE=2 ⇒ rc=**1**  [FAIL] 占位行 <= 1     ← 把健康并发判成泄漏
      新判据: rc=**0**,打印"最老 = 0 分钟;其中 >10 分钟 = 0"

阈值 1 是"只有 1 个请求在飞的那天"的快照,与它要防的病(泄漏)**不正交**。

⇒ 与泄漏正交的判别量是**行的年龄**(pi 给的):正常占位行只活一个请求的时长,
  泄漏行活到**下一次请求之后**。年龄与并发度无关,也与"此刻是否恰好有请求"无关。
  阈值 10 分钟:远大于任何单请求(HTTP 有超时),远小于真实泄漏尺度(那行活了 12.7 天)。
  断言**上界**而非"==0"—— 0 是"恰好没请求在飞",不是健康的条件。

正反两面都验(只验一面会放过退化):
    健康并发 2 行 → rc=0 ✓      13 天泄漏 + 1 健康行 → rc=**1** ✓
  真库 → rc=1(那行确实 18259 分钟 ⇒ **真阳性**,非假红)
  并打印年龄 ⇒ 顺带答出"泄漏了多久"(旧判据只说"多了一行")。

## ② 抑制数丢了 keying(pi dd579a8e,我复核确认)

生成器键是 `(ag, prefix(k), r)`(**已含 agent**),但**序列化/打印时把 agent 丢了** ⇒
任何人引用这个数都会以为是"仅根"口径。实测两种 keying 全局差 6 封(61/37 vs 55/43,
6 组**全是跨 agent 合并**)⇒ 名字里不写,下一个人**必重犯**。

    文本: `抑制数 [keying=(agent,根) scope=全局]: …`
    JSON: `suppress_full_groups_keying_agent_root_scope_global`
  ★ 同时补 **scope**:本脚本报的是全局;session 侧同 keying 是另一个数(4/18)——
    同 keying 两个 scope 两个数,少写任一个都只能靠猜。
  ★ 只改名字,**数的值不动**(这是防再犯,不是纠错)。

## ③ 后置验证补 `binrev == HEAD`(pi 127746b5 §一,落在脚本而非只落检查器)

§7 那几项(服务 active / `/health` 200 / 无 panic / SSE 重连)**全都与代码版本无关** ⇒
**旧二进制跑这套清单会全绿**。2026-09-25 实证:线上是 09-19 的二进制而 §7 全过。

pi 建议落在 `redeploy-gateway.sh`,我采纳 —— 因为我昨天已把同一件事实现为
`check-deploy-drift` 判据 ⑤b,但**那个检查器没有任何自动入口**(要人手跑);
落在部署脚本里才能"装完当场就判"。

三态与 ⑤b 同口径:相等→绿 / 不等→**红**(就地回滚)/ 取不到任一侧→**不判**且必须说出来
(判红会让 `-buildvcs=false`、老构建、非 git 构建恒红)。

    实测逻辑: 线上 e8b260dd vs HEAD ⇒ 红 ✓;/nonexistent/binary ⇒ warn 不判 ✓

## 验证

`bash -n` 两文件通过;`recount-relay-counts.sh --json` 合法(23 键);
`criteria-hygiene.test.mjs` 7/7 通过(改名后仍满足 check-*.sh 命名约定)。
2026-09-25 06:27:09 +08:00
3c2b7d1385 fix(deploy): 还清欠账 redeploy-script-unguarded-steps —— 四类裸步骤接收退出码
出口是这条欠账**自己写的**到期条件("下一次改 `deploy/` 下任一脚本时"):
当天因修 `--dry-run` 落地写入而动了这个脚本,所以一并还。

## ① 前端同步三步

`rm -rf assets` / `rm -f index.html` / `cp -r dist` 原先调用点不接退出码,
而 `run()` 内部是 `eval`(脚本只有 `set -uo pipefail`,无 `-e`)⇒ 失败既不中断也不上报。
`cp` 那条尤其要紧:前端产物没拷进去 ⇒ go:embed 把**旧界面**打进二进制,而所有单测仍绿
(2026-09-14 踩过,见脚本内那段注释)。三处都改成 `|| { bad …; exit 2; }`。

## ② `systemctl stop`(本条欠账原文点名的"stop 失败而状态没人看")

加它的理由比原文**更强一层**:`systemctl start` 对**已在运行**的服务是 **no-op** ⇒
stop 没成功时后面那句 start 什么也不做,**旧进程继续跑旧代码**,而脚本一路走到
后置验证、报"部署成功"。也就是说,"部署脚本跑过了 ≠ 线上跑的是当前代码"这个第 2 类漂移
会被脚本**自己在内部**造出来 —— 与今天线上那件事(09-19 二进制跑了 6 天)同形,只是成因在脚本内。

## ③④ DRY_RUN 分支

am-sandbox 的 `go build`/`install`、以及 `install -d $PREFIX/bin` 与通知脚本 `install`
一并挪进 DRY_RUN 分支(上一提交 7a65b27 只修了后者的"会落地",这里补齐构建/安装两步)。

## 边界(如实记,避免读成"全脚本已无裸步骤")

只覆盖 `redeploy-gateway.sh`。`redeploy-plugin.sh` / `install.sh` 的同类步骤仍未加守卫 ——
那是另一条欠账 `deploy-interrupt-trap-other-scripts` 的范围。

## 验证

`bash -n` 通过;`--dry-run` rc=0 且不落地(只留 0 字节锁);
Go 侧 `TestDebtLedgerMatchesMeasurement` / `TestDebtSummaryReadsAuthoritativeLedger` 通过;
`node test/debt-visibility.test.mjs` 1/1 通过。
2026-09-25 06:22:09 +08:00
7a65b270bb fix(deploy)!: --dry-run 会真的写入安装根(建 bin/ + 装 21KB 脚本)
文件头把 `--dry-run` 定义为"只打印将执行的动作",但第 1 节那两条 `install`
**无守卫**,于是 dry-run 真的落地:

    AGENTMAIL_PREFIX=/tmp/fp2 bash deploy/redeploy-gateway.sh --dry-run
    ⇒ /tmp/fp2/bin/service-failure-notify.mjs   21453 字节   ← 不该出现
    ⇒ /tmp/fp2/bin/                             新建

`run()` 的既有口径是"dry-run 只打印",其它写操作都走它并因此被拦下;
这两条恰好是 2026-09-14 为修"通知脚本不装"(af42a08)而从 if 分支里**挪出来**的,
挪出来时没补守卫 —— 修 A 引入了 B。

同节 1b(am-sandbox)也一样:dry-run 会**真跑一次 go build + 19 项内核判据**
并 install。一并包进 DRY_RUN 分支。

## 判据

    rm -rf /tmp/fpX && mkdir -p /tmp/fpX
    AGENTMAIL_PREFIX=/tmp/fpX bash deploy/redeploy-gateway.sh --dry-run
    修前:残留 bin/ + bin/service-failure-notify.mjs(21453B) + .deploy.lock
    修后:残留 .deploy.lock(0 字节)  ← flock 载体,非"落地"

`bash -n` 通过;dry-run rc=0。

⚠️ 这与本仓"部署要一次做对"直接相关:`--dry-run` 是上线前的预览动作,
它若自己会写目标目录,就等于**在预览阶段动了生产**,而输出看起来完全正常。
2026-09-25 06:16:03 +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
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
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
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
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
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
ca96f77a4b fix(appearance): 浏览器里同步从来没跑起来(三处叠加)+ 部署链加"前端不得比源码旧"闸门
用户说「webui 你也没改呢」。查证:**部署是活的**(本地产物 = 线上产物、CSS 里壁纸
修复的规则都在、入口 `Cache-Control: no-cache`、资源哈希+immutable)——是我新加的
"外观存服务端"那套在**浏览器**里根本没生效。沿途挖出三处叠加缺陷 + 一处部署链真空子:

## ① 路径写成绝对 `/api/v1/...`(双前缀 ⇒ 404)

`resolveBase()` 解析出来的 base 已经含 `/api/v1`(默认就是它),既有调用者传的都是
`/me/mail/inbox` 这种**相对基地址**的形状。我写成 `/api/v1/me/appearance` ⇒ 实际请求
`/api/v1/api/v1/me/appearance` ⇒ 404。
**单测全绿却没抓住**:我只断言了方法、报文,没断言 URL。现在补了 URL 判据
(含"不得出现 /api/v1/api/v1"这条)。

## ② 浏览器密码登录只有 cookie、没有 Bearer ⇒ `currentAuth()` 直接短路

`currentAuth()` 原先要求 token 非空,而密码登录只建 cookie 会话(桌面端粘贴用户密钥
才设 Bearer)⇒ WebUI 里 `pull/push` 从来没跑过。已放宽为"只要有 base",并补了两条
判据(cookie 会话也要能拉、能推)。
("完全没有网关地址 ⇒ local-only"这条判据删掉了:`resolveBase()` 总有默认值,
那个状态到不了 —— 判据不量够不着的对象。)

## ③ 服务端"无记录"时拿默认值覆盖本地

首次启用同步时每个老用户都会中招:服务端回默认值(theme=system / bg=none),
客户端照着应用 ⇒ **用户已有的主题与本地壁纸被静默重置**。现在改为"以本地为准、
推上去认领",并补判据(含"有记录时以服务端为准"的反向对照)。

## ④ 部署链真空子:dist 比源码旧也能"同步成功"

改完源码忘了 `vite build`,`redeploy-gateway.sh` 照样把旧 dist 打进二进制 —— 这正是
①在线上一直没被发现的直接原因。现在部署脚本会比对 `src/**` 与 `dist/index.html`
的 mtime,旧了就 **FAIL** 并提示先 build。

## 顺带:我自己在真实账号上留的测试数据

线上 E2E 时我把 `theme=dark/bg=preset(dusk)/dim=35` PUT 到了 **jianf** 这个真实账号
(应该用测试账号)。已删掉那条记录(接口现在回 `saved:false`),配合 ③ 的修复,
用户本地那份外观会被认领上去而不会被覆盖。

## 验证

- 浏览器实测(自带无头 Chromium + 真实功能,非注入 CSS):
  `200 GET /api/v1/me/appearance` → `data-bg=on`、`dark=true`、本地缓存写入 ✓
- 三张对比图(自定义图片档 / 关背景 / 预设渐变)已随邮件发给用户
- 前端 253 条(含新增 URL 判据与 cookie 会话判据)、server 10 包、打包一致性全绿
2026-09-14 08:59:32 +08:00
51789ee72e deploy: 标准目录部署 —— 运行时不再依赖源码目录
用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
查证后有三处实证(都不是猜测):

1. ★ **失败通知钩子执行的是仓库里的脚本**
   (`/home/program/agentmail/deploy/service-failure-notify.mjs`,8 处引用:
   4 个 drop-in + zcode/zcode-mail-bridge 单元 + agentmail-failure-flush)。
   仓库一挪/一改名,故障通知就**静默失效** —— 而那条管线正是用来报告服务故障的。
2. **opencode-serve 的 cwd 就是源码目录**(`WorkingDirectory=/home/program/agentmail`)。
3. ★ **仓库里的 `deploy/*.service` 是旧的源码目录版本**(ExecStart 指向
   `/home/program/agentmail/plugins/...`),而机器上的已被改过 —— 也就是说
   **谁跑一次 install.sh 就会把部署退回源码目录**。drop-in 更是只存在于 /etc 里,
   仓库完全没有它们。

## 改动

- **唯一真相**:`deploy/systemd/` 镜像 systemd 目录结构,收进全部单元与 drop-in
  (8 个单元 + 12 个 drop-in),路径全部改到 `/opt`。
- 运行时脚本装到 **`/opt/agentmail/bin/service-failure-notify.mjs`**(自包含,
  无相对导入);`install.sh` 与 `redeploy-gateway.sh` 都会幂等地装它。
- opencode 的 cwd 改为 `/opt/agentmail`(与网关一致),已重启生效
  (`/proc/<pid>/cwd` 已核)。
- 删掉 `deploy/*.service` 的旧副本,避免两个真相。
- dsh 的 `cordis.patch.yml` 注释里的安装示例也改到标准位置(运行时用的是环境变量,
  那条注释是唯一残留)。

## 判据(`deploy/check-deploy-drift.mjs` 新增「标准目录部署」四条 + 自检)

① 任何 unit/drop-in 都不得引用源码目录;② 已安装单元与 `deploy/systemd/` 逐字节一致;
③ 通知脚本在标准位置且可执行;④ 各服务的 cwd/ExecStart 不在源码目录
(homeagent/dsh/zcode 是**别的产品**的标准位置,按白名单放行)。
自检两个样本:引用源码目录的必须红、干净样本必须绿(证明不是恒真)。

顺带修掉一处**真漂移**:仓库里 dsh 的 `dist/index.js` 落后于部署件(改了 src 没重建),
重建后 `check-deploy-drift` 报「四个宿主都在跑当前代码」。

## 复核

- `/etc/systemd/system/` 引用仓库:**0** 个文件;`/opt/agentmail` 下只剩旧二进制/备份里
  的构建路径(Go 嵌的源码路径,无害)与一条注释。
- 四个宿主都在跑当前代码;标准目录四项全绿。
- 全部服务 active,opencode/网关 cwd 均已在安装根下。
2026-09-14 08:38:33 +08:00
f9d757b5e5 chore: directory migration - gateway→server, web→client/electron 2026-09-08 19:16:35 +08:00
a101c2fada 停用/恢复文案说清密钥不会自动回来 + 原子部署脚本
## 起因

本会话踩到一次:浏览器测试点了 opencode 的「停用」按钮验证确认流程,
停用连带撤销全部密钥。插件从此拿着已撤销的密钥重试了 18 小时
(gateway 日志 2690 次 401),而 UI 只说了「已停用」。

恢复时同样没提示「密钥不会自动回来」,点完恢复以为就完事了。

## 文案

后端 `AdminSetAgentStatus`:
- 停用 detail 补一句「停用期间别人发信给它会收到 409」
- 恢复 detail 改成「停用时撤销的密钥不会自动回来 —— 必须在密钥面板
  重新签发一把并写进该插件的配置,否则它会一直拿旧密钥重试并被拒(401)」
- 恢复响应加 `needs_new_key: true` 字段,前端可据此做更强的提示

前端 QuotaPanel:恢复成功的 notice 不再是「已恢复」四个字,
把重新签发这一步说全;面板说明与按钮 title 同步。

## AdminDeleteAgent 两处修正

- 硬编码 `name == "jianf"` 改成按 `repo.IsHumanUser` 判定 —— 换管理员时
  硬编码会失效,而人类账号不该走 Agent 删除端点
- detail 原来说「日历事件已保留」,实际 `DeleteAgent` 把 active 事件置为
  cancelled(留着会由调度器一直触发,而发信人已不存在)。文案改成实际行为

## deploy/redeploy-gateway.sh(新)

日常改后端不必重跑 install.sh(它重装 npm 依赖、重写 systemd 单元、
重新生成 env)。这个脚本做手工 `stop → cp → start` 不做的四件事:

- `sqlite3 .backup` 备份数据库 + 立即 `PRAGMA integrity_check` 复核。
  不用 cp:WAL 模式下 cp 会拿到主库与 -wal 不同步的快照
- `install -m 0755` 原子替换二进制。install 本质是 rename,要么完整
  换掉要么原样不动;cp 是就地写入,中途失败会留下半截文件且旧的已被覆盖
- 旧二进制留档并打印可直接粘贴的回滚命令
- 后置验证清单:服务 active / /health 可达 / 近 2 分钟无 panic /
  SSE 重连计数。任一项不过**自动回滚**,不「先上着再修」

`--dry-run` 只打印动作,`--skip-tests` 急救用,`--skip-web` 跳过前端同步。
纪律来自 git-release-discipline skill 第五章。

## 验证

- 脚本 dry-run + 真实跑通一次:备份 integrity_check=ok、原子替换、
  9 个 SSE 客户端重连、验证四项全绿
- 停用/恢复文案线上实测;`DELETE /admin/agents/jianf` → 403「是人类用户」
- 端到端:jianf → opencode「部署脚本验收」→ 回信「部署验收 OK」
- 全量测试:gateway 全包 / web 176 / opencode 217 / pi 288 / dsh 241 /
  homeagent go ok;三方共用模块同源检查通过
2026-09-05 10:10:13 +08:00