|
|
46fa7fa729
|
feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)
用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持
多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。
所以落地成:
· internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状;
HMS 只是第一个实现(internal/push/hms.go)
· 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商,
凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖
· 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退);
单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动
· push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点;
没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送)
· notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道
共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信
HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage;
payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true,
单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。
实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。
判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** ——
过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖)
并补掉。Go 全量测试与 go vet 干净。
★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名,
签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。
详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
|
2026-09-15 11:21:00 +08:00 |
|
|
|
b7dc9e90e6
|
fix(deploy): 权限政策按**文件类型分岔**开药方 —— 我上一版把 release-linux.sh 的执行位改掉了
## 我在上一个提交里弄坏了一个脚本
`client/electron/scripts/release-linux.sh` 原本是 **711**:
group/other 读位缺失 ⇒ 本判据判红(**判得对**),但我给的药方是无脑 `chmod 644`
⇒ **去掉执行位** —— 把"读位缺失"换成"不能执行",**比原来更坏**,
而判据随后报"通过",工作区显示这个文件被改(`M`)。是我在提交前扫 `git status` 时
看见那一行 `M client/electron/scripts/release-linux.sh` 才发现的 —— **不是判据发现的**。
已恢复 755(`git diff` 干净,因为它 git 记录是 100755)。
★ 教训:**一条判据说"这个文件被改错了"时,药方必须按文件类型分岔**;
只有一个药方(`chmod 644`)就会把另一类文件改坏,而它同样报"已修好"。
这与"判据的适用范围没写出来"同族,只是这次代价落在了**修的动作**上,不是读的动作上。
## 改法
可执行与否按 **git 记录的那个位**(`git ls-files -s` 的 `100755`)判,
不按当前文件系统权限 —— 否则"执行位被谁弄丢了"会被这条判据**追认**成正常。
三条区分力实测(都当场恢复 + `cmp` 校验):
1. 非可执行文件改 0600 ⇒ 红,文案"权限过严";
2. 可执行文件丢掉执行位 ⇒ 红,文案"**注意别去掉执行位**";
3. 可执行文件 711(= `release-linux.sh` 原来的形态)⇒ 红 —— 即**最初那次判红是对的**,
错的是药方。
另外把计数循环改成读 `git ls-files -s` 一次(不再在循环里对每个文件调
`git ls-files --error-unmatch`,也不再让计数落在管道子 shell 里)。
验证:`check-file-modes.sh` 正常态 exit 0;三条变异行为如上;
`git status` 只剩本文件本身的改动。
|
2026-09-15 10:27:20 +08:00 |
|
|
|
15df621045
|
feat(deploy): 权限位两条判据 —— 政策(源文件不严于 0644)+ 一致性(副本=仓库)
pi 评审 2026-09-15 §三 给了决定:**开,但拆两条**。理由我采纳并写进注释:
合成的结果会是"看起来覆盖了、其实只覆盖一半"。
## 为什么要这两条(实测实例,不是设想)
写文件的工具**不理会 umask**(umask 022,它建的仍是 0600),而
`deploy/redeploy-plugin.sh` 用 `cp -a "$SRC/."` 打快照 ⇒ **0600 会进生产**。
实测:快照里 `src/paths.mjs`/`src/turn-cwd.mjs` 是 0600、仓库 0644,
而 **`cmp` 五个 same、① 报"逐字节一致"** —— 两头都不报警:
`collectFiles` 只把**内容**做 sha256,`check-deploy-drift` 只在脚本上判可执行位。
## 两条的分工(别合成一条)
- `deploy/check-file-modes.sh` = **政策**:源文件不得比 0644 更严。
**不看快照** ⇒ 能抓"两边都 0600",而一致性那条永远抓不到(两边一致 ⇒ 恒绿)。
- `check-deploy-drift.mjs` 新增 **①b**(`collectModes`/`diffModes`)= **一致性**:
部署副本的权限 = 仓库那一份。抓不到"两边都错"。
## 落地
**政策侧**:新脚本判「已跟踪文件里 group/other 任一读位缺失」。
实测判出 **53 个**(含 `plugins/pi-mail-bridge/package.json`、`src/naming.mjs`、
`client/harmony/.../*.ets`、`docs/GUI-PLAN-HARMONY.md` 等)—— 全部 `chmod 644` 修掉,
现在 exit 0。**区分力实测**:把 `deploy/check-shared-libs.sh` 临时改 0600 ⇒ 判据红并点名它,
恢复后绿;0755 的可执行脚本**不**被判红(有读位,不是"更严")。
★ 也修正了我先前的一处过报:那 53 个里有凭据类命名的文件吗 —— **0 个**(先查了才批量改)。
**一致性侧**:①b 一上线就抓到**真实的**、**先于本次改动**存在的漂移:
`plugins/*-mail-bridge/lib/permission-grants.{js,d.ts}`、`rename-proposal.{js,d.ts}`
在三个宿主的快照里是 **600**、仓库是 **644**(pi 宿主 1 处、dsh 4 处、opencode 2 处)。
⇒ 这条缝**一直存在**,只是此前没有任何判据看着它。
**处理**:不单独 redeploy 去"洗"权限(那要重启 pi 宿主,为权限位重启服务不值得),
**下一次正常部署顺带修好** —— 这是记账,不是新欠账。
①b 现在是**失败**态(真实不一致),所以 `drift` 整表 exit 1;这与"① 全过"并存是对的,
两条量的是不同的东西。
## 自检
给 `collectModes`/`diffModes` 加了 4 条自检(`--self-check`),四条一起写,
因为"能发现差异"单独一条会被一个**恒判"都不同"**的坏实现骗过:
① 权限相同不得误报;② 内容一致但 0600 vs 0644 必须被发现(连数值一起断言);
③ 权限差异不污染 ① 的内容判据;④ 仅一侧存在的文件不算权限漂移(归 ① 的文件集判据)。
★ 其中两条我**第一版写错了**并当场修掉,都记在注释里:
- 用了 `lib/x.mjs` 做样本,而上面 `mk(b,'DIFFERENT')` 已把它改成内容不同
⇒ ③ 红在**内容**上,而它想验的是"权限不污染内容";改用一对独立的内容相同文件。
- 夹具真实创建的是 `lib/x.mjs` 而不是我以为的 `lib/same.mjs`(ENOENT 才发现)。
验证:`--self-check` 全绿;pi 桥 509/509;`check-shared-libs` exit 0;
`check-file-modes.sh` exit 0;`install.sh --check` exit 0。
|
2026-09-15 10:26:23 +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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
9965ee2204
|
chore(deploy): 清理补两类残留(数据库/附件备份集、会话清理前备份)+ 在线库硬闸门 + 两侧自检
- 备份集:同一 <ts> 的库+附件包一起进出窗口;按文件名时间戳排序(mtime 被访问改过)
- 在线库永不入删除清单:del() 里 readlink -f 比对,命中即 FATAL 退出
- 每个插件只剩 current 时报「无回滚目标」(只报告,不造快照)
- --self-check 16 项:干净样本该删的删/该留的留/在线库不动;坏样本(备份指向在线库)必须拒跑;
外加「自检没有碰生产根」的前后指纹比对
- 事故记录写进 DEV-TOOLING.md:自检传了 ROOT 而脚本读 AGENTMAIL_ROOT ⇒ --apply 打到生产根;
半对不对更难认(PRUNE_TMP_DIR 那一路是对的),是 bash -x 露的马脚
|
2026-09-14 17:50:29 +08:00 |
|
|
|
33488760ce
|
fix(相位/安全): 部署门禁只判产物自证;静默 break 改成出声;内核读数带时间坐标
pi 2026-09-14 的裁定与两条更正,逐条落地。
1. **相位裁定(选 c)**:`packaging`/`build-stamp` 属于**构建相位**,不属于安装相位。
`run-all.mjs` 现在有相位:`AGENTMAIL_CRITERIA_PHASE=install`(部署门禁用)。
每条判据登记它读的哪一侧(`ARTIFACT`/`SOURCE`),install 相位里出现 SOURCE 侧判据 → 红;
被跳过的判据**点名打印**,不静默丢。汇总打 `RESULT phase=build|install`。
规则入册 `test/CRITERIA.md` §11(含三个真实实例:check-shared-libs 恒红、
packaging 一改前端就卡死、HOME 在门禁跑完之后才炸)。
安装相位**真正能判的那一半**:`deploy/install.sh` 读**产物自证**(不重算 dist)——
`releaseCandidate !== true` → 拒绝;产物 `gitRev` ≠ HEAD → "这个包比源码旧" → 拒绝;
放行要显式 `--allow-dirty` / `--allow-stale`;`--check` 干跑只报结论不拦。
实测干跑输出:`产物:gitRev=6702cc2 树=dirty releaseCandidate=false | 当前 HEAD=6702cc2`
→ 报"不是发布候选 + 正式安装会被拒绝 + 要放行请显式说清"。
2. **别解析运行器文本**(pi §5):`broken`/`red` 的判定改成按 TAP 的**名字**——
文件级失败的测试名就是路径,断言失败的名字是判据名。变异双向验证:
未定义标识符 → 「跑不起来的判据」;把某条判据条件改成假 → 「红的判据」。
不再往关键字表里加补丁(那是往文本解析里加补丁,方向是错的)。
3. **静默 break 是安全相关**(pi §3):`session_update` 找不到活动会话时不再静默 break,
改成出声日志(走 journalctl 那条通道),写清两种成因(此刻没在跑 / **接管会话**重启后无法定位)、
方向(收紧被延迟)、以及兜底的**前提**("下次投递"要求这条会话还会收到新邮件)。
`lib/mail-session-id.js` 模块头同步改成安全相关措辞("人以为自己收紧了权限、实际没有"),
四桥逐字节同源,`check-shared-libs.sh` 退出码 0。
4. 内核读数补时间坐标(pi 13ea2fdf):`BUILD_INFO.txt` 里除原始 `dep`/`=>` 行外,
现在还有 `kernelBinMtime` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉,
没有时间坐标的读数不成立。
|
2026-09-14 16:54:30 +08:00 |
|
|
|
f27ad31c91
|
fix(判据/部署): 探针不再假设清单穷尽;broken≠red;到期报文自带"要放行什么";部署加 --check 干跑
pi 2026-09-14(两封)提的六条,能做的都做了。
1. **`[Empty]` 不是"没有设备",是第三态**(pi §1)。改了,而且不是改成"一律红",
是**去证服务端健康**:探针现在读 `/proc/<pid>/cmdline` 找 `hdc -m`(server 模式),
服务端在 → 空集才是可判的"确实没有目标"(false);服务端找不到 → `unknown`(红)。
本机实测:`hdc -m -s ::ffff:127.0.0.1:8710` 在跑 → 空集可信。
这条用机制而不是用嘴回答"我检查过了"。
2. **硬编码候选清单**(pi §2):候选来源写清(`/opt/huawei/command-line-tools` 是文档安装根),
`hdc` 也走 PATH;**工具链根在、里面却没有 hdc → `unknown`**("装了但长得不一样"不是"没装")。
这与 build.sh 那次"第一个命中就算"是同一形状 —— 今天各咬一次。
3. **前提改成"本工作区能装能点"**(pi §2):原前提"设备存在"会让 5 条判据在**我修不了**的时候同时红
(模拟器要写 /run、/var/log)。现在前提是工作区能力,`need` 逐条写清,
并且**到期报文会把这些门槛打出来** —— 第一次真红不能被当成噪音消掉。
4. **broken ≠ red**(pi §3):跑不起来(语言级崩痕:SyntaxError/ReferenceError/…)单列
"跑不起来的判据(N)—— 不是红,也不算过",红是"判据说不成立",broken 是"判据没说话"。
变异验证:注入未定义标识符 → 报 broken ✓;绿基线 → exit 0 ✓。
第一版我用"输出里有没有 `not ok`"判,当场误判(node:test 把导入期 ReferenceError 也报成 `not ok`),
已改成语言级崩痕 —— 判据自己的第一版就得被现实修一次。
5. **标签要有消费点**(pi §4):`release-linux.sh --release` 遇脏树**拒绝**(`--allow-dirty` 才放行),
不加参数是自用打包(只出声)。"出声≠拒绝"这条说得对。
6. **`deploy/install.sh --check` 干跑**(pi §6):跑全部门禁、不写系统目录,末尾列出正式安装会写什么、
需要哪里的权限。干跑立刻抓到两个真缺陷:
· `set -u` 下 `$HOME` 未设 → `HOME: unbound variable`(cron/env -i/某些 sudo 下就是没有),
而它发生在**所有门禁跑完之后**——最贵的位置(这轮第三次同形状,前两次在 homeagent build.sh)。
· **packaging 这条门在部署路径上永远过不去**:install.sh 先 `npm test`(含 packaging),
而 packaging 要求"安装包里的 dist == 当前 dist",部署路径却不重新打包 →
前端一改,install.sh 就卡在这条门上(第二条"挂在部署路径上却恒红"的门,第一条是 check-shared-libs)。
这条需要决定:部署路径要么重新打包、要么把 packaging 排除在部署门禁外。**我没有擅自改口径。**
|
2026-09-14 16:49:08 +08:00 |
|
|
|
f9b849dcfc
|
chore(deploy): 测试会话清理脚本(可复现、默认干跑、带备份与校验)
用户(2026-09-14):「清理一下」。库里积了 102 个会话 / 1038 封邮件,
其中 76 个是测试会话(gui-lab 专用测试 agent 发的 + drill-/e2e3-/冒烟/演练类)。
为什么不手敲 DELETE:
① 只看「收件箱里看得见的那几条」会漏掉大头 —— `ListInbox` 整体排除
`status='archived'`(repo.go),归档的测试会话在界面上根本不出现,
但在库里、在配额统计里都还在。所以按**来源 agent + 别名前缀**判定,
不按「看得见看不见」。
② 直接 DELETE sessions 会留孤儿:mails/mail_reads/relayed_mails/attachments/
permission_requests 都指向它们(只有 attachments 有 ON DELETE CASCADE),
必须子表先删、且在同一个事务里。
③ 附件文件按 sha256 分片存放且**可能被多条记录共用**,所以只能在删完行之后
逐个确认「已无人引用」再删文件。
校验那一步断言的是「**没有新增**外键孤儿」,不是「零孤儿」:
实测库里本来就有存量孤儿(agent_keys 指向已不存在的 user、3 封回复的
parent_mail_id 悬空),断言零孤儿会永远红 —— 下一个人就会当它坏了而忽略它。
本次实删:会话 76 / 邮件 290 / 已读标记 60 / 附件 80 / 转发 81 / 权限请求 63,
附件文件 22 个;foreign_key_check 与删除前逐条一致(无新增孤儿)。
另跑 prune-deploy-artifacts.sh --apply 释放 980MB 部署残留(/opt/agentmail 1.3G → 306M)。
|
2026-09-14 15:51:40 +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 |
|
|
|
69b1887eae
|
chore(deploy): 清理部署残留(1.0GB)+ 把清理写成可复跑脚本
用户说「清理一下」。实测 /opt/agentmail 累计 1.3GB:
- 网关旧二进制 33 份 × 24MB ≈ 790MB(每次 redeploy 留一份,从 09-05 起没清过)
- 插件快照:opencode 6 份 339MB、dsh 7 份 195MB、zcode 16 份、pi 9 份
- /tmp 里的部署前数据库备份 10 份(tmpfs,占内存)
新增 `deploy/prune-deploy-artifacts.sh`(可复跑,不手敲 rm),两条硬规矩:
① **保留回滚窗口**:网关留最新 3 份、每个插件留最新 3 份快照,`current` 永远保留
(发布纪律要求有回滚目标,所以不是全清);
② ★ **绝不删正在使用的快照**:扫 /proc 的 cmdline 与 cwd,命中就跳过。
执行结果:**/opt/agentmail 1305MB → 302MB(释放 1003MB)**,7 个服务全部 active,
`current` 指向未变,`check-deploy-drift` 仍报「四个宿主都在跑当前代码」。
## 过程中的一处自纠
"在用"闸门第一版有**自我匹配**缺陷:我的测试命令把路径写在命令行里,于是扫 /proc 时
扫到了自己 ⇒ 对不存在的路径也判"在用"(与 `pkill -f` 杀掉自己那条命令同一类)。
已改为排除本进程及其祖先链,并**换正确方式重测**(路径从文件读、不进 cmdline):
在用快照判"在用" ✓、不存在的路径不误判 ✓ —— 判据两侧都验过才敢执行删除。
文档:docs/DEV-TOOLING.md 记了用法与那两条规矩。
|
2026-09-14 08:43:54 +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 |
|
|
|
2922fb711f
|
fix(deploy): 故障通知的三处缺陷 —— 死信目录、隔夜补投、失败无原因
起因:用户邮箱里「[dsh] 桥服务异常终止」反复出现。查下来有两层。
**第一层:崩溃本身(已修,是历史)**
dsh 在 2026-09-12 11:40 起崩溃循环,根因是当时那次插件快照切换后
`/root/.dsh/profiles/web/node_modules/dsh-mail-bridge/cordis.patch.yml` 不存在
(`failed to read overlay … ENOENT`)→ 起不来 → systemd 反复重启。
现在快照里该文件在、dsh `NRestarts=0`、今天 0 次失败。
**第二层:通知管线本身坏了(本次修)**
1. ★ **死信目录**:`zcode.service`(应用单元)既没有 `AGENTMAIL_AGENT_NAME` 也没有
密钥,报告就落进 `unknown-agent/` —— 而 flush **只读自己那个 Agent 的目录**,
于是 25 份 zcode 崩溃告警永久投不出去。修法是两件事:
· `resolveIdentity()`:身份按「单元 env → 单元名推导 → /etc/agentmail/<agent>.env」
解析;`zcode.service`→zcode、`pi-mail-bridge.service`→pi……
· 支持 `AGENTMAIL_AGENT_SECRET`:zcode 只配了 secret 没有 key,而脚本原先只认
Bearer key ⇒ 就算目录对了也发不出去(网关的 AgentAuth 两种都认)。
2. ★ **隔夜补投**:补投原先只在**同一个单元**的下次 `ExecStartPost` 跑,于是
网关不可达时攒下的报告要等到那个服务自己重启才补投 —— 实测 4 封 Sep-12 的
告警在 Sep-13 10:35 才到。现在新增 `agentmail-failure-flush.timer`(每 10 分钟
`--flush-all`),它遍历所有 Agent 目录、按目录名逐个解析身份后补投。
过时报告还会在主题与正文上标 **「补投:这是 N 分钟前的故障报告,不代表现在仍在
故障」**(原先正文里只有昨天的时间戳,读起来像刚崩)。
3. **补投失败只报数不报因**:`catch { failed++ }` → 日志只有 `spool: sent=0 failed=4`,
没人知道卡在哪。现在每条失败都带回原因(HTTP 状态码/网关不可达/身份未配置)。
4. 顺带两处准确性问题:
· 主题写**单元名**而不是笼统的「桥服务」—— 25 封标题写着"桥服务异常终止",
实际崩的是 zcode **应用**单元,照标题去查桥方向就错了。
· `created_at_ms` 是我们的元数据,但 `/mail/send` 是**严格解码**的(实测 400
不认识的字段)→ 发送前剥离,线格式保持干净。
判据:新增 `deploy/service-failure-notify.test.mjs`(12 条,含假网关做真实投递、
严格解码断言、补投标记的正反两向)。端到端验证:模拟"没有身份的 zcode.service
崩溃"——修复前落 `unknown-agent/` 永久死信;现在**真的投出去了**,且
`from_name=zcode`、主题 `[zcode] zcode.service 异常终止 #…`。
积压清理:27 份(dsh 2 + unknown-agent 25)全部来自 09-12 那两轮崩溃、事件已在
邮箱与 journal 里出现过,按**不再补投**处理(避免把隔夜告警灌进邮箱),
原始文件留档 /root/gotmp/failure-spool-backlog-20260913.tar.gz(600),
spool 目录留 README.md 说明。
|
2026-09-13 11:06:01 +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 |
|
|
|
a00cbf36fc
|
feat(zcode): yolo + 自有工具面 + 我们自己的执行门禁(headless 真正能干活了)
按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。
## 为什么必须走这条路(实测,不是推断)
MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。
于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。
## 新增
- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
**不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。
## 修掉三个真缺陷(都是实测撞出来的)
1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
(实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。
## 自报改成如实
detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。
## 验证
- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
(「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
|
2026-09-12 19:05:54 +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 |
|
|
|
c5e1d562eb
|
feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。
## --mode 是必传的(不传等于关掉授权系统)
ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。
档位映射(依据是 CLI 产物里的规则表,不是猜):
plan → --mode plan (mode.plan.nonReadOnly:非只读一律拒)
workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
full → --mode yolo (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。
## 一轮怎么跑
node <zcode.cjs> --prompt <提示词> --output-format stream-json \
--cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N
用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。
## 回信策略(与另三桥同源)
- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
收件箱里两封说同一件事的邮件,311 与 342 字节)。
## 两处健壮性(都是实现时自己发现的真问题)
- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
(unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
而既没有驱动看着它、也没有本地界面看着它。
## 自报强制力只声明得出来的事
驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。
## 验证
- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
反向验证:让 workspace.js 分叉会被抓住)
## 我自己写错并被测试抓出来的三处(值得记)
1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
→ 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
|
2026-09-12 14:58:36 +08:00 |
|
|
|
4b129b5f49
|
chore(deploy): 同源清单纳入 ZCode 授权桥用到的 4 个共用模块
permission-mode / relay-key / permission-grants / sse-client 的
实现与测试一并同步 —— 档位语义与决策判定若分叉,「同意」在 ZCode 上
会悄悄变成另一种意思。
|
2026-09-12 14:10:44 +08:00 |
|
|
|
e0e6f86d94
|
feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。
本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。
协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。
工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。
两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)
入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。
顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。
验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
|
2026-09-12 13:47:51 +08:00 |
|
|
|
9204f019a1
|
fix(deploy): 故障告警按 invocation 分会话 —— 崩溃循环时告警不再被护栏挡下
# 问题(实测)
服务反复崩溃时,故障告警**送不出去**。
链路:告警邮件带 `relay:"summary"`(走免预算通道),而服务端按
`AutoAliasFor(收件人, 主题)` 派生会话别名 —— **主题相同即同一条会话**。于是崩溃
循环下多封告警全落进同一条会话,而网关对会话内**连续**的中继邮件有
`maxRelayHops=5` 的硬上限(防两个 Agent 互相唤醒的正当护栏)→ 第 6 封起返回 403:
POST /api/v1/mail/send ... - 403 245B (网关 NRestarts=0,一直在正常服务)
报告只能落 spool,等下次 ExecStartPost `--flush` 才补投。实测 dsh 崩溃循环
(我自己的部署缺陷所致)时的六条告警全部 spooled —— 而**服务反复崩溃正是最需要
告警送达的时刻**。
# 修法
主题带上本次启动的 `INVOCATION_ID` 短号:`[dsh] 桥服务异常终止 #5ce6a845`。
主题变了,别名与会话随之分开,每次崩溃各自可投递。
**护栏不削弱**:它针对的是 agent↔agent 互相唤醒,不是同一个人收多条故障通知。
副作用是崩溃循环产生多条会话而非一条线程 —— 对"服务在崩"这件事,分开计数比合并成
一条更容易发现问题。
`INVOCATION_ID` 缺失时退回 `Date.now()+randomUUID()` 的哈希:宁可每次不同,
也不能因为拿不到标识而退回"主题相同 → 告警又被挡下"。
# 验证(三条,含两个反向对照)
不同 invocation → 主题各不相同(每次崩溃各自成会话)
无 invocation(兜底) → 每次仍各不相同(不会退回同主题)
同一 invocation 重报 → 主题相同(一次崩溃仍是一条会话,不刷屏)
注:第二次验证第一次跑时"失败",是因为**我的测试假设错了** —— 父环境里本来就继承了
`INVOCATION_ID`,两次跑用的是同一个 id。用 `env -u INVOCATION_ID` 才真正走到兜底分支。
|
2026-09-12 11:56:48 +08:00 |
|
|
|
c9209e4adc
|
fix(deploy): 失败通知不再把所有失败都说成「Gateway 不可达」
# 误导的来源
`reportFailure` 只在 `result.sent` 上分叉,而 sent 为假同时覆盖「fetch 抛」「HTTP 4xx/5xx」,
于是两种情况都打印同一句话。
# 实测代价
切换插件时 dsh 进了崩溃循环(我自己的部署缺陷所致),通知脚本连打六条
「Gateway 不可达」并写进 spool —— 而网关**一直在正常服务**(NRestarts=0),
日志里真实响应是 **HTTP 403**:
POST http://127.0.0.1:8180/api/v1/mail/send ... - 403 245B
403 的原因是同一会话连续中继邮件撞上防「两个 Agent 互相唤醒」的跳数上限
(`maxRelayHops=5`):告警邮件的会话别名由主题派生,崩溃循环下六封落进同一条会话。
一句话把人送去查网络,而问题在策略层 —— **故障通知本身给出误导性诊断,
是「静默失败」的另一种形态**。
# 修法
新增 `describeSendError`:`Gateway HTTP <status>: <body>` 归类为「网关可达,但返回
HTTP xxx(附响应体)」;只有 fetch 本身失败 / AbortError 才说「不可达」/「超时」。
双向验证(真实走代码路径,都不实际发信):
网关指向关闭端口 → 「网关不可达:fetch failed」
真网关 + 坏密钥 → 「网关可达,但返回 HTTP 401:{"error":"密钥无效"}」
|
2026-09-12 11:52:52 +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 |
|
|
|
b374ce1f20
|
fix(plugins): 附件 id 归一 —— 修 opencode「做完全部活却发不出附件」
# 现象(全功能演练抓到,根因来自 opencode 自己的内部记录)
opencode 把四个步骤全做完了(2× download_attachment、read 读到内容、
upload_attachment 成功),却在最后一步卡死:`send_mail` 连续 **6 次**失败,
然后放弃整个任务,自述为「attachment_ids 参数有框架级序列化 bug」。
真实形状(从 opencode 的 part 表里取出的原始输入):
input.attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]" ← 字符串
error: 字段 "attachment_ids" 类型不对:期望 string 数组,收到 string
模型把数组写成了 **JSON 字符串**,桥原样转发,服务端的严格解码器按契约拒收。
# 修在哪一层
**不在服务端放宽。** 那个「严格」是刻意的,挡的是字段名拼错、结构写错这类真
错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 200)。
**在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠,而适配器的职责就是把
不可靠的输入归一成契约要求的形状。对模型宽容、对服务端严格 —— 这与
homeagent 那个 Go 插件里的 `stringList` 是同一个判断(那边注释写着「也接受
单个字符串……拒绝它只会换来一次重试,而意图毫无歧义」)。
接受的形状:数组 / JSON 数组字符串 / 单个 id / 逗号或空白分隔 / 混进 null
与数字时丢掉坏的保留好的。空串与 null 一并丢掉,与服务端
`parseAttachmentIDs` 保持一致。
# 三桥同源
新增共用模块 `lib/attachment-ids.js` + 同名测试,已加入
`deploy/check-shared-libs.sh` 的两个清单(实现与测试都必须逐字节相同 ——
只同步实现不同步测试,等于允许一侧偷偷放宽约定)。
三份 md5 一致,检查脚本通过。
# 测试
`test/attachment-ids.test.mjs` 17 条,三桥各一份。含**反向对照**:把 JSON 字符串
分支去掉后必须变红(实测 3 条失败)—— 否则这条判据就是空转,事故会复发。
全量:pi 401 / dsh 361 / opencode 312,0 失败。
|
2026-09-12 11:20:13 +08:00 |
|
|
|
19a3161ee4
|
feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。
新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。
密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
1. 进程环境
2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
因此身份一致
3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
(兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。
不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。
为什么不会重复注册(读 SDK 实现确认,并用探针实测):
resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
命令行 -e 传入的扩展。
探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。
deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。
验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
|
2026-09-11 11:32:47 +08:00 |
|
|
|
f91efd2d8d
|
feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。
修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。
- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
(label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
approval 路径不变(回归测试覆盖)。
测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
|
2026-09-11 10:44:18 +08:00 |
|