|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
630b5bfdd7
|
fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。
## 1)pi 的续谈失败不回失败信(实测缺口)
模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。
修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。
**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。
顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。
## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策
网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。
- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
`DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
(各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。
## 3)新增接线断言(带判据自检)
`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。
三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。
而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。
## 4)部署脚本:zcode 分支现在会重启驱动
`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。
## 复查
四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。
另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
|
2026-09-12 23:13:48 +08:00 |
|
|
|
b53afd4523
|
chore(deploy): 部署漂移检查器 —— 快照是不是还等于仓库、进程到底在跑哪份代码
「部署脚本跑过了」不等于「线上跑的是当前代码」。三种各自独立的漂移,谁都不会
在意,直到出问题时才发现线上是几天前的行为:
1. 仓库改了、快照没重新部署(快照是独立副本)
2. **软链切了、进程没重启** —— `current` 只是个符号链接,切换它不会重载
已在跑的进程,进程持有的是启动那一刻加载进内存的代码
3. 单元/配置改了、没 daemon-reload 或没重装
`node deploy/check-deploy-drift.mjs`(`--self-check` / `--json`)把三件事变成
可判定的:逐字节比内容(**不用 diff** —— 本机 PATH 上那个是鸿蒙工具链里的,
对内容不同的文件仍返回 0)、读进程真实 argv、比进程启动时刻与软链切换时刻。
## 判据必须按宿主真实的加载方式分开写(这是踩出来的)
第一版对四个宿主用同一套判据(「单元 ExecStart 指向 current」+「进程 argv 里有
快照路径」),结果 **opencode 与 dsh 双双假红**:它们的插件由**宿主进程**加载,
argv 里永远只有宿主自己的可执行文件。永假条件在检查器里表现为「稳定的红灯」,
人会学会忽略它。
| 宿主 | 谁加载 | 从哪里读 | 切换后要重启吗 |
|---|---|---|---|
| pi / zcode | 自己的进程 | unit 的 ExecStart | 要(启动时加载) |
| dsh | dsh 宿主 | profile `link:` → `node_modules` 软链 | 要(服务启动时) |
| opencode | opencode 宿主 | `opencode.jsonc` 的 `plugin:` | 不要(会话创建时惰加载) |
## 判据自检也修了一次
第一版自检「宿主表:运行时加载的宿主必须标记需要重启」写的是
`h.restartOnSwitch !== false`,而 `undefined` 也被放过 —— 于是 pi/zcode 的
`restartOnSwitch` 缺省成 undefined、落进「惰加载」分支、判据**永真**。
现在这条判据抽成纯函数 `judgeRestart`,自检用**行为用例**盖住:
旧进程 + 启动时加载的宿主必须判红、惰加载的宿主不判红、切换后启动的一律放行、
读不到启动时刻时不据此判红、容差内不判红。
## 扰动实验(在真实对象上证明判据会红)
- 改一个仓库运行文件 → pi 的「① 运行文件与仓库一致」变红,恢复即绿 ✓
- 只把 pi 的软链 mtime 拨到刚刚(模拟「切了没重启」)→ 「④」变红并指出原因 ✓
- 把 dsh 与 opencode 的软链都拨动 → **dsh 红、opencode 绿**(惰加载该保持绿)✓
全部拨动均已在实验后还原并复核为绿。
## 当前结论
四个宿主都在跑当前代码(pi/opencode/dsh 的快照自 11:45 起未变,其间无提交
动过它们的运行文件;zcode 是 18:57 的快照)。「三桥加载规范化」的切换本身
早已完成,缺的是这条可复跑的判据 —— 补齐了。
另外修了 `redeploy-plugin.sh` 的一个静默问题:`usage()` 按**行号范围**截取头部
注释(`sed -n '2,40p'`),我这次的注释把退出码那段挤到 40 行之后,`--help`
就少了半页。已同步范围并就地记下这个陷阱。
|
2026-09-12 20:39:22 +08:00 |
|
|
|
6a7356ebe7
|
fix(zcode): 软链部署下入口静默不执行 + 部署脚本支持 zcode 快照
## 缺陷:入口判断不解析软链 → 生产形态下 main() 从不执行
`mcp/server.mjs` 与 `src/index.mjs` 都这么判断是否被直接执行:
import.meta.url === `file://${process.argv[1]}`
而 ESM 的 `import.meta.url` 是**解析过软链的真实路径**,argv 是命令行里写的那个。
生产布局是软链(`/opt/agentmail/plugins/<name>/current` → 时间戳目录),
于是两者不等、`main()` 从不执行:**没有输出、没有报错、退出码 0**。
它是被部署脚本的后置验证抓到的:第一次从快照起 MCP 服务器时
「握手 0 个工具、stderr 一个字都没有」,而同一个文件从仓库路径跑完全正常
(仓库路径没有软链)。这类缺陷只在部署形态下出现,本地怎么试都对;
表现(静默成功)又与「功能没被调用」一模一样。
修法:新增 `lib/is-main.mjs`,**两侧都 realpath** 后比较(只归一 != 「同一文件」)。
单测含目录软链、文件软链、文件不存在(保守判否,避免被 import 时误跑一遍)。
## 部署脚本:引入 HOST 概念,四个插件一条部署路径
pi/opencode/dsh 的宿主是 systemd 单元,zcode 的宿主是 ZCode 应用本身 ——
没有我们的单元可重启。于是:
- `HOST=systemd`:重启单元 + 看网关库里有没有新心跳(原有判据)
- `HOST=zcode`:**从快照起一次 MCP 服务器并走完握手**,这与 ZCode 加载插件
走的是同一份入口代码;另查 `plugins list` 报告的路径是不是 current
后置验证带**判据自检**:握手函数对坏路径必须返回 0,否则判据本身失效就拒绝通过。
计数用 `grep -o | wc -l` 而不是 `grep -c` —— 每条响应只占一行,tools/list 的
11 个工具名全在同一行,用 -c 会得到 2,把好快照判失败(实测踩过)。
## 生产已切到快照
`/opt/agentmail/plugins/zcode-mail-bridge/current → 20260912-150547`,
`~/.zcode/cli/config.json` 的 `plugins.dirs` 已指向 current,
`zcode plugins list` 报的路径就是快照路径。仓库不再是生产代码。
验证:单测 325/325;快照握手 12 个 name 字段;官方 __zcode-plugin-host 从快照
启动正常;钩子从快照跑通 409 → block;坏路径能被握手判据发现(反向对照)。
|
2026-09-12 15:06:49 +08:00 |
|
|
|
cbe5ef5cec
|
fix(deploy): 快照改整包复制 + 存活判据改看网关心跳 —— 修两个会毁掉生产的缺陷
切换三个桥时这两个缺陷都真的触发了,记下来避免重犯。
# 缺陷一:白名单拷贝漏文件 → 服务直接起不来
第一版 staging 用白名单:`package.json lib src index.js dist`,漏掉了 dsh 的
`cordis.patch.yml`(dsh 读它做 overlay 配置)。后果不是「少个文件」而是**服务崩溃循环**:
Error: dsh: failed to read overlay .../dsh-mail-bridge/cordis.patch.yml: ENOENT
白名单的失败模式天生如此:**默认不带**,漏一个就等启动时炸,而那时旧版本已经被换掉。
改为整包复制 + 剔除明确不需要的(`test/`、`.git`、`node_modules/.cache`、`*.log`)。
# 缺陷二:存活判据写成了某一个平台特有的措辞
第一版后置验证 grep 日志里的「已接入」。那句话**只有 pi 与 opencode 会打印**,
dsh 启动时只输出 `dsh web: http://127.0.0.1:3080` —— 于是**一次成功的 dsh 部署
被判成失败**,脚本按设计回滚……回滚到了缺 cordis.patch.yml 的坏快照,
把 dsh 推进崩溃循环。
改为查网关库(平台无关,且是真的端到端):
SELECT count(*) FROM agents WHERE agent_name='$PLUGIN' AND last_seen > '$RESTART_AT'
**时刻必须用 UTC**:`agents.last_seen` 是 UTC(CURRENT_TIMESTAMP 语义),
而 `date` 默认给本地时间 —— 拿 11:44 去比 03:44 会永远为假,于是每次部署都被判成
「桥没连上」并回滚,**门禁主动破坏生产**。已用 `date -u`。
判据双向验证过:以「3 分钟前」为重启时刻 → 命中 1;以未来时刻 → 命中 0。
# 本次切换结果
pi /opt/agentmail/plugins/pi-mail-bridge/current/src/index.mjs
opencode /opt/agentmail/plugins/opencode-mail-bridge/current/index.js
dsh /opt/agentmail/plugins/dsh-mail-bridge/current/dist/index.js
三处配置已迁移(备份在 /root/config-backups/pre-snapshot-20260912-113826/):
pi 的 unit、opencode.jsonc 的 plugin 项、dsh profile 的 link:(含 pnpm install 重建软链)。
验证:三桥进程**打开仓库文件数均为 0**;快照与仓库文件 inode 不同(独立副本);
四个 Agent 心跳新鲜;dsh 读 cordis.patch.yml 走的就是该软链(坏快照时它起不来,
好快照时它 active —— 这条是最硬的证据)。
|
2026-09-12 11:47:32 +08:00 |
|
|
|
0549975555
|
feat(deploy): 插件规范化部署 —— 生产跑仓库外快照,可回滚
# 问题(实测的加载形态)
pi systemd: node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
opencode opencode.jsonc: "file:///home/program/agentmail/plugins/opencode-mail-bridge"
dsh profiles/web/package.json: "dsh-mail-bridge": "link:/home/program/.../dsh-mail-bridge"
三个桥跑的都是**仓库工作区**。于是:一次编辑 + 重启就是上线(没有构建、没有评审、
没有版本);**没有回滚目标**(网关有 `.bak-<时间戳>`,三个桥一个都没有);
`git checkout` / `git stash` / 半成品编辑会静默改变线上行为;仓库同时兼作构建目录
(`dist/`、`node_modules/` 都在里面)。
对照:homeagent 插件本来就是这个规范形态(跑 `/home/newqqagent/plugins/
homeagent-mail-bridge/plugin.bin` 部署副本)—— 所以这里不是发明新办法,
而是把已有的那个形态推广到三个 JS 桥。
# 本提交只交付工具与门禁,**未切换生产**
`deploy/redeploy-plugin.sh <pi|opencode|dsh> [--stage-only]`:
1 前置断言 → 2 staging(仓库外)→ 3 门禁 → 4 原子切换 `ln -sfn <ts> current`
→ 5 重启 → 6 后置验证 → 任一步失败即切回 `.prev` 并重启
后置验证不只看 `systemctl is-active`:桥可能进程活着却没连上 Gateway
(密钥失效、Gateway 未起、依赖在惰加载时才暴露),所以**必须以日志出现
「已接入」为准**,45 秒轮询。
需要编译的插件(dsh)**产出到 staging**,不写仓库的 `dist/`:先在仓库构建再拷贝
会有两个问题 —— 失败的构建也会 emit(tsc 默认 `noEmitOnError=false`)导致仓库产物
被半成品覆盖;以及生产产物与工作区之间多一条看不见的耦合。
# 依赖门禁:不能用 require.resolve
`deploy/check-plugin-snapshot.mjs` 从入口递归收集静态 import/export/动态
import/require 的说明符并逐个解析。
**判据用 ESM 而非 CJS 解析**——这是实测教训:`@earendil-works/pi-coding-agent`
的 `exports` 只声明 `"import"`,`require.resolve` 抛 ERR_PACKAGE_PATH_NOT_EXPORTED,
而插件是 `import` 它的、实际毫无问题。用错 API 会让门禁**报假缺陷**,
而假缺陷比不检查更糟(会让人去修一个没坏的东西)。
也不能只比对 `package.json` 的 dependencies:pi 的 dependencies 是 `{}`,
而它 import 了 `@earendil-works/pi-coding-agent` —— 声明是假的,只查声明等于空跑。
# 已验证(干跑,未切换、未重启)
pi 20/20 说明符可解析 opencode 23/23 dsh 26/26
dsh 经 tsc 构建到 staging 后通过
反向验证:拿掉一个依赖 → 门禁 rc=1 并点名该说明符(不是空转)。
|
2026-09-12 11:36:25 +08:00 |
|