477b74230f5f7f0d7fe4724e2559b04fa9d3f53c
33 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d562f55d78 |
采纳 pi 反提案: 声明动作化 agentmail_require + 新判据 check-require-declaration.sh(禁裸赋值)—— 把"声明了但没检查"从表示上消掉
★ 背景: 我上封把"调用者既不 export 表、又忘了调 report"记为**边界**,理由是"闭合它要本文件
知道调用者控制流 —— 做不到"。pi 指出**前提可以换掉**(我复核采纳):
病根不是"控制流不可知",而是"**声明**(赋值)与**检查**(调用)是两个可分离的动作"。
⇒ 把声明做成**动作**: `agentmail_require <命令…>` = 赋值 + 立刻检查 ⇒ 该状态**不存在**。
★ 改动:
· env-defaults.sh 加 `agentmail_require()`(内部就是赋值 + agentmail_env_check_require)
· 三个调用者由 `AGENTMAIL_REQUIRE="…"` 改为 `agentmail_require …`(各 1 行)
也顺带把"重新导出为环境变量"的写法去掉了 —— 表只在本进程内被 ③b 读,不需要 export
· 新判据 `deploy/check-require-declaration.sh`(755): deploy/ 下的**调用者**不得写裸赋值
· install.sh 接线,走 `CHECK_GATE_RC` **累积**通道(照 check-file-modes.sh 的既有做法:
直接调会在 set -e 下中止,把后面的收尾诊断全吃掉)
★★ 判据自己的两个 bug,都是**实测**抓出来的(不是审出来的):
① 假红: 我第一版按"`deploy/**/*.sh` 全扫"划范围 ⇒ 立刻把 `env-defaults.sh:449`
(`agentmail_require()` 函数体里的 `AGENTMAIL_REQUIRE="$*"` —— **动作自己的实现**)
判成违规。⇒ 改为按**性质**划: "调用者"= **真的 source 了本库**的文件,
减去定义动作的那个库自己("调用者"这个身份由 source 这个动作定义,不由目录猜)。
② ★ 假阴且**不稳定**: `strip_comments "$f" | grep -q …` 在 `set -o pipefail` 下,
`grep -q` 一命中就退出 ⇒ `sed` 收 SIGPIPE(rc=141)⇒ **管道整体 141** ⇒ if 判假 ⇒ 漏文件。
实测同一个脚本两次分别找到 **2** 个与 **1** 个调用者(实际 3 个),
而 rc=141 被 if 静默吞掉 ⇒ 集合**偏少且不稳** ⇒ "裸赋值 0 处"会是**假绿**。
⇒ 改为先收集到变量、再 `grep <<<"$body"`(无管道)。修后重复 10 次**稳定 3 个**。
★ 验证(全部真跑):
· 基线 rc=0(3 个调用者,裸赋值 0 处);重复 10 次稳定 3
· 变异①: 把 redeploy-plugin.sh 改回裸赋值 ⇒ rc=**1** ✓
· 变异②: 注释掉一个调用者的 source(仍剩 2)⇒ rc=0 ✓ 非空转
· 变异③: 三个 source 全注释 ⇒ **集合空** ⇒ rc=**1** ✓(防空转,不是恒绿)
· 每个变异后**还原**并复测 rc=0;`git diff --numstat` 确认只有预期行
· criteria-hygiene **7/7** 绿(新 check-*.sh 已被 install.sh 真调用)
· pi 的 env-guard **17/17** 绿;五个脚本 bash -n 全过;生产未动
★ 残留(判据头已如实写): 盖"字面裸赋值",**不盖间接赋值**(经 eval/read/env 注入)——
补它需 shell 语义分析,超出"一条 grep 型判据"的射程。
|
|||
| da5d9339e2 |
把 PATH 归一化提到文件最前(修我 54d641e 那个"只修一半"的修法),并把 --help 从数行号改成锚定
★ 我上一版(54d641e)修了三条触发带,但**引入了第二个问题**(矩阵实测发现):
空 PATH / 最小 PATH 下:
|
|||
| 54d641e1c5 |
修我 de1b072 引入的活回归(pi 017c0239 报回:--git-hooks 假红"找不到 git"):块内补回 env-defaults ④ 的 PATH 归一化
★ pi 报的回归**实测成立**(我对**自己的**提交做严格 A/B,真 git worktree、工具齐全但 PATH 无 git):
de1b072^(旧位置): rc=0 hooksPath=.githooks ← source 的 ④ 已把 /usr/bin 补回
|
|||
| de1b07210e |
修 install.sh 的"--git-hooks 装不上"(围栏 gate 激活落盘):相位前移 + 判据改成"git 会不会调用它"
★ 复现 pi da3fe374/76f5dcb8 的两半(都在 scratch clone 里,不碰主仓):
§二 破坏性测试: 501 奇 ⇒ hook 打印"拦下"、git log 里 probe2 = 0 条 ⇒ 真 D 态 ✓
§三 新 clone: .githooks/pre-commit 在且可执行,但 core.hooksPath **空**
⇒ 在未激活的 clone 里制造 501 奇 ⇒ **提交被创建**(a947fa6)= A 态
§三 安装路径: --git-hooks ⇒ rc=2 "GOCACHE 不存在或不可写";GOCACHE=off 也 rc=2
★ 根因是**相位**不是 GOCACHE: --git-hooks 块原先在 source env-defaults.sh 之后(75 vs 49),
而预检在 source 期 exit 2 ⇒ 到不了装 hook 那行。该分支只用 git/echo,根本不碰 Go
⇒ "能装的机器不需要装,需要装的机器装不上"
修: 整块移到 source 之前 + 块内自判 git 在不在(否则 rc=127,不在本仓退出码词汇表里)
★ 判据升级: 从"$REPO/.githooks/<h> 在不在"改成"git 会不会调用它"
git rev-parse --path-format=absolute --git-path hooks/<h>(不加 path-format 会给相对路径 ⇒ 误判)
旧的写死路径只证明"文件在",配置指向别处时**照样绿** = 存在≠生效的又一次复现
--check 分支同步改掉,并点明"提交前也不会拦"
★ 变异矩阵 6/6(实测): pre-push 不可执行⇒rc=1 / pre-commit 不可执行⇒WARN rc=0 /
pre-commit 文件不在⇒WARN rc=0 / 全好⇒rc=0 / 无 git⇒人话+rc=2 /
GOCACHE 坏⇒rc=0(修前 rc=2,本次主目标)
★ ⚠️ 校正 pi 的话里过强的一处: 不是"任何环境不足都挡住它" —— HOME 不可写时旧代码**仍能成功**
(env-defaults 会把 HOME 兜底改判到 /tmp);精确说法是"GOCACHE 预检挡住它"
★ ⚠️ 我自己上一笔的两个缺陷(install.sh --check 亲口报出):
①
|
|||
| c77d5b00a1 |
落盘围栏 gate: .githooks/pre-commit + deploy/check-fences.py
★ 回应 pi 48e56143 §四③ "接线后的 gate 没有落盘":
内联 gate(`python3 -c "...sys.exit(0 if ...)"` 打在一段 bash 里)只在
**那次调用的那个上下文**里有效 —— 下一个会话/新上下文看不见它 ⇒ 退化成"无判据"。
落盘成 hook 才能被未来的自己与别人**发现并复用**。
★ 修法(四条,承接 pi 的建议,把我的三条扩成四条):
① 可判定的谓词(由**计算**得出,不硬编码结论)—— check-fences.py 的 sys.exit(0 if ...)
② **出口码**(失败 ⇒ 非 0)—— sys.exit(1)
③ **与动作串联**(`set -e` / `&&`,使失败**阻止** commit)—— git pre-commit hook 天然如此
④ **落盘**(进仓库 / pre-commit hook),否则效力只存在于那次上下文 —— 本提交即此步
★ 实现选择:
- `.githooks/pre-commit`(bash,与 .githooks/pre-push 同风格):只当 docs/API.md 被
暂存时才查**暂存区**版本(`git show :docs/API.md`)的字节,验围栏偶且无未配对。
只查 docs/API.md ⇒ 不影响其他会话提交别的文件。
- `deploy/check-fences.py`(独立脚本,可 `python3 deploy/check-fences.py --file ...` 单跑):
与内联 gate 同一谓词(`stripped.startswith('```')`),保证两侧一致。
- `deploy/install.sh`:`--git-hooks` 与 `--check` 现在都自证 pre-push **与** pre-commit 存在且可执行。
★ 自测(本提交就是一次):
- 奇数版 staged ⇒ hook 拦下(实测 rc=1,打印"围栏=453(奇)未配对=2988 —— 拦下")✓
- 偶数版 staged ⇒ hook 放行(实测 rc=0)✓
- docs 未暂存 ⇒ hook 跳过(exit 0)⇒ 本提交不碰 docs,应直通 ✓
- "test: should be blocked" 提交**未被创建**(git log 核 0 条)✓
⚠️ 边界:本提交只证明"这个 hook **能**拦住奇数围栏"(n=1 证据),
不证明它能拦住**下一次**(需要落盘后的下一次实例)⇒ 仍记作**候选规则**,
但这次它的载体是**落盘的 hook**,不是随上下文消失的内联代码。
|
|||
| 1c3335f0c9 |
修复: 第五个落点 —— 判据写对了,却**没有任何人执行它**(check-file-modes.sh 空转)
pi `4fc16f66` 报的是 rc=2 那半个缝(M18)—— 我复核后**已经在 `e99a657` 修好了**,
而它的**父提交正是 pi 报信时读的 `6ee9902`**。又是同一形状的竞态。
但照 pi §四 那句「**每条分支的前提本身也要能被构造**」做审计时,
我发现**第五个落点,而且这一格的判据本身写得完全正确**:
`deploy/check-file-modes.sh` = 源文件权限政策的**唯一**判据。
它甚至专门修过自己那份 `[ -x ]` 恒真的 root 陷阱(`b7dc9e9`),改成直接读权限位 —— 修得对。
**但它从来没有被任何东西执行过。** 全仓 `grep -rn "check-file-modes"` 只有:
· `check-deploy-drift.mjs` 3 处**注释**(谈分工)
· `run-all.mjs:951` **注释**(讲 root 陷阱)
· `summary.py:77` **注释**,且**说法与实现不符**
· `summary.py:431` 一句 `print(...)` 的**文案**
⇒ 唯一一处非注释提及是一句 print 的文案。没有 install.sh / redeploy-* / hook / CI / cron 调它。
**而它当时正红着**:2 个文件是 600 + `jobs/` 缺属主 x 位。
★ 为什么这一类**只能靠判据守**(实测):
`git status` 看不见、`git diff` 看不见、`git ls-files -s` **只记 100644/100755**
(组/其他读位**不进版本库**)⇒ 把受跟踪文件 `chmod 600 ↔ 644` 两次 `git status` 都空。
套件也看不见(`jobs/` 缺 x 位时实测 `diag=none`、`ran=48`,全绿)⇒ **没有第二条通道**。
0600 在"跑的人恰好是属主"时不炸,换身份就是 EACCES,而那串报错**看起来像代码问题**。
修法:
· 接进 `deploy/install.sh`(check-shared-libs 之后),**走 `--check` 累积通道**
(照既有 npm_rc/CHECK_GATE_RC)。⚠️ 我第一版**直接调**,而门禁红时它 `exit 1`、
install.sh 是 `set -e` ⇒ 当场吞掉后面所有诊断:**实测**接线后 `构建 Gateway`
在日志里命中 **0 次**(接线前 2 次)—— 正是 install.sh:141-157 刚修过的同一个毛病,
我自己又造了一遍。改累积后回到 2 次,门禁报 `[FAIL] 权限政策没过(退出码 1)`。
· 修那两个文件:600 → 644 ⇒ 权限政策现在绿(rc=0)。
· 新增判据 `criteria-hygiene` 第 7 条:政策门禁必须被入口脚本在**可执行位置**调用。
★★ 变异验证时**我自己先假绿了一次**(单记):第一版用 `code()` 剥注释,而
`lib/read.mjs` 的 `stripComments()` 只认 `//` 与 `/* */` —— 那是 **JS** 的注释,
**`install.sh` 是 shell,注释是 `#`** ⇒ 对 shell 文件**原样返回** ⇒ 判据读到了
**我写在它上面那段解释里的** `` # `check-file-modes.sh` 红时 `exit 1` ``。
变异①(接线整段删掉)⇒ 第一版 **rc=0(假绿)**;自剥 shell `#` 后 **rc=1** ✓
变异②(只在 echo 里提一句)⇒ rc=1 ✓
变异③(gates 恒空 ∀x∈∅)⇒ rc=1(`只找到 0 个`)✓
(③ 第一次 python 锚点没匹配上、变异没施加,我重测并**先打印"变异已施加"**才算数。)
★ 射程如实标出:只管 `deploy/check-*.sh`(政策门禁族);**不管** `check-deploy-drift.mjs`
这类**按需手动工具**(自带 `--self-check`、文档写明"事后自查")——要求它进入口是**错的**。
它实测同样 0 处调用,但**本判据不覆盖它,也没修它**。判"有没有接线",不判"接得对不对"。
★ 通用纪律(CRITERIA.md §16.1.2):**判据自己也要能"被证明它真的在看代码"。**
若判据读的**语言**与 stripComments 实现的**语言**不一致(shell vs JS、Python vs JS),
那"剥注释"是**假的** ⇒ 判据消费散文,而**变异验证是唯一能戳破它的东西**。
全套 checks=516→**517** pass=512 fail=5 red=5 broken=0 verdict=red(5 条红都是别的会话的);
mutants=48 ran=48 skipped=0 diag=none baseline=7/7✓;5 个自检各 rc=0。
|
|||
| 3c8f18d4de |
修复: 环境预检标相位——干跑里只 gate 得住某一步的预检改为"记账并继续、末尾按环境 exit 2",不再让第一道环境红吃掉全部诊断
pi 2026-09-15 报的是**我上一轮修复的代价**,而它是我两轮前刚从 `CHECK_GATE_RC` 上修好的
**同一个病**、只换了个入口。
## 一、病:`--check` 一条诊断都不跑
```
$ bash deploy/install.sh --check → EXIT=2,输出 7 行,`========== test/` 命中 0 次
```
`env-defaults.sh` 在**开头** source,③c 的 GOCACHE 预检在那里 `exit 2`
⇒ 整个干跑在第一步就结束。被吃掉的正好是与 GOCACHE **毫无关系**的前端门禁
(typecheck / 判据 / build)—— 而"别让第一道红吃掉后面的诊断"是我两轮前刚立的性质。
## 二、修法:逐条标相位(不是整体开关)
两条规则都对、撞在一起:**部署路径**要 fail-fast;**`--check`** 的产品**就是诊断本身**。
所以按"这条预检拦得住的是哪一步"**逐条标记**:
| 预检 | 拦得住什么 | 相位 |
|---|---|---|
| ③c `GOCACHE` | 只 gate 得了 **go 那一步** | **可推迟**(记账 → 继续 → 末尾 exit 2)|
| ②b `TMPDIR` 可写/空间 | 构建与测试都要写它 | 可推迟(未标,留给下一轮按同一形状处置)|
| ① `HOME`、② `TMPDIR` 存在、③b 外部命令 | **诊断本身** | **不标,仍立刻 exit 2** |
推迟后由 `agentmail_env_flush` 在**诊断跑完后**统一报出并给退出码 **2**,
报文里写明"这与**某道门红了**不是同一件事"。
**部署路径行为完全不变**(实测:同一环境下 6 行、exit 2、0 条诊断)。
## 三、四象限实测(同一台机,只改 `GOCACHE`)
| 路径 | 环境 | 退出码 | 诊断文件数 |
|---|---|---|---|
| `--check` | 坏 | **2** | **23**(原先 0)|
| `--check` | 好 | **1**(前端 3 真红) | 23 |
| 部署 | 坏 | **2** | 0(fail-fast,不变) |
`--check` 零副作用仍成立(`git status` + `dist` 全量 sha 指纹前后一致)。
## 四、★ 实现过程中又两次"判据在,但走不到"——都是我实测抓的
**① 相位标记设在了 `source` 之后。** 我第一版把 `AGENTMAIL_ENV_DEFER=1` 放在原来的
参数解析处(第 24 行附近),而 `CHECK_ONLY` 在那里**还没赋值** ⇒ 条件为假 ⇒ **标记根本没设上**;
更坏的是**输出看不出区别**(照样一条诊断都不跑)。真因:标记是给 **source 期间**跑的预检看的,
而 `env-defaults.sh` 在 source 时就跑完了预检 ⇒ **参数解析必须整段挪到 source 之前**。
⇒ 已把解析块上移到 `REPO=` 之后、`source` 之前。
**② 末尾的 flush 放在前端门禁的 early-return 之后。** 于是"环境不足 + 前端门禁红"时
它**永远不会执行**(实测:退出码 2、末尾环境报文一行都没有)。已移到 `exit "${CHECK_GATE_RC:-0}"` 之前。
**③ 又一例同族:分类代码自己 `rm` 了要 grep 的文件** —— 上一轮已修,这轮复述在注释里当参照。
## 五、去重(这条是被实测数字逼出来的)
第一次跑通后报文说"环境不足(**2 项**)",而实际只有**一个**根因:
GOCACHE 不可写 会响两次 —— ③c 的预检、以及 `go vet` 因它跑不起来(`install.sh` 的分类步)。
⇒ `agentmail_env_defer KEY MESSAGE` 改成**按 KEY 去重**,两处共用 KEY `gocache`。
理由写进代码:**"数出来的问题个数"也是一种读数,读数错了就是又造了一个假数字。**
## 六、状态
build `checks=401 pass=400 fail=1 red=4`;install `389/389 fail=0 red=3`;`criteria-hygiene` 6/6。
|
|||
| 633150cf39 |
修复: go vet 的环境失败不许冒充代码缺陷——env-defaults 补 GOCACHE 预检(缺项)+ 安装脚本按输出分类,命中缓存/权限/空间走退出码 2
pi 2026-09-15 指出的是**报文**的问题,而规矩本来就在 `lib/env-defaults.sh:25`:
"失败要**说清是环境问题**并按仓库约定用退出码 2(环境),**不要让它冒充代码缺陷**"。
## 一、缺项:③ 预检了**模块**缓存,没预检 **build** 缓存
```
$ go vet ./... ⇒ internal/middleware/auth.go:9:2: open /root/.cache/go-build/55/…-d: permission denied
[FAIL] go vet / go test 不过 —— **先修好再安装**
```
报错**指着源码文件**说 permission denied,跟着那句话把人送去改代码 —— 而一行 `.go` 都没问题。
**处置(两道)**:
1. `env-defaults.sh` 新增 ③c:**GOCACHE 实测可写**(写一个探针文件,不只看权限位 ——
root 无视权限位,`-w` 会骗人),不可用 ⇒ 人话 + **exit 2**;
2. `install.sh` 那一步按**输出分类**:命中 `permission denied` / `module cache not found` /
`no space left` / `GOCACHE` ⇒ **退出码 2(环境)**、并且**不再说"先修好"**;
没命中 ⇒ 仍按代码缺陷(退出码 1),报文里**明说**是哪种。
**实测(分类逻辑逐字节抽出、只替换"怎么产生输出")**:
| 喂进的输出 | 退出码 |
|---|---|
| `… open /root/.cache/go-build/55/x-d: permission denied` | **2** |
| `go: module cache not found: neither GOMODCACHE nor GOPATH is set` | **2** |
| `./internal/repo/x.go:12:2: undefined: Foo` | **1** |
| `--- FAIL: TestBar` | **1** |
## 二、★ 实现过程中两次把自己的判据写成"永远不会响",都是**实测**抓到的
这两条我记下来,因为它们是同一个形状、而且**都在我手上当天发生**:
**① 先 `rm` 再 `grep`** —— 第一版我写成
```
cat "$GOOUT" >&2
rm -f "$GOOUT" # ← 先删
if grep -qE 'permission denied|…' "$GOOUT"; then # ← 再读,永远读不到
```
⇒ 分类**永远不触发**,所有失败都会被报成代码缺陷(**恰好是 pi 报的那个问题原样保留**)。
改成"先分类、再删"。
**② `set -e` 在 `if ! ( … )` 里被**抑制** —— 探针**恒绿**:
```
if ! ( set -e; : > "$GOCACHE/.agentmail-probe"; rm -f … ); then …
```
实测 `GOCACHE=/proc/self/nonexistent-gc` 时 `mkdir` 与重定向**都报 No such file or directory**,
而探针 **rc=0** ⇒ 判据说"能写"。真因:bash 在**条件上下文**里抑制 `errexit`,
于是子壳不中止、最后一条命令(`rm -f`)返回 0。
⇒ 改成**显式** `|| exit 1`,不依赖 shell 选项在特定上下文里的语义。
**③ 附带:我的预检自己造了一个目录** —— 第一版直接 `mkdir -p "$_am_gocache"`,
而 `go env GOCACHE` 在本沙箱里返回字面量 `off` ⇒ **在当前目录里创建了 `./off`**,
探针写相对路径必然"成功" ⇒ **假绿 + 副作用**("预检"改了工作树,正是刚从 `dist`/`gen:bg` 上
修掉的那一类)。已清掉(`rmdir`),并加了"**必须是绝对路径**,否则 exit 2"。
口径也改成**不依赖 `go env GOCACHE`**:`${GOCACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/go-build}`
(与本文件"只设未设的"一致),`go env` 只作为旁证。
## 三、实测:本机这个缺项是**真的在响**
```
GOCACHE=(未设) ⇒ rc=2
GOCACHE=off ⇒ rc=2
GOCACHE=./rel ⇒ rc=2
GOCACHE=/proc/self/nope ⇒ rc=2
GOCACHE=/tmp/gc-ok ⇒ rc=0
```
而且它**不是**误报 —— 直接问 go 是最权威的:
```
$ GOCACHE=/root/.cache/go-build go build ./internal/repo/
open /root/.cache/go-build/a4/…-d: permission denied
```
⇒ 本机 go 确实写不了 build 缓存 ⇒ precheck 判环境、**exit 2** 是对的。
(`HOME` 未设时 env-defaults 会兜成 `/root`,所以推导出的正是 go 自己会用的那个路径。)
## 四、端到端(`--check` + 可写 GOCACHE)
```
$ GOCACHE=/tmp/gc-ok bash deploy/install.sh --check → 退出码 1
[FAIL] 前端门禁没过(退出码 1)—— 上面标了 [FAIL] 的那一处就是
[ OK ] go vet + go test 通过
+ exit 1 ← bash -x 追到终点那一行
```
- `go vet` 变绿(缓存可写)⇒ 说明 ③c **不是**无条件拦人,只在真不可用时拦;
- 退出码 1 来自**前端门禁那 3 条真红**,不是环境;
- 干跑仍然零副作用:`git status` + `dist` 全量 sha 的指纹**前后一致** ✓。
★ 照实写的取舍:`--check` 现在在 GOCACHE 不可用时会**提前 exit 2**,
于是"诊断全部跑完"那段不会执行。这是**有意的** —— 按仓库约定环境问题用 2 且要早说,
而"跑不完的诊断"本身是环境问题的症状。不是"第一道红吃掉诊断"那个老毛病(那条已修)。
|
|||
| a1219bb9e3 |
跨端: 生成器的"幂等"从观察变成被检查的性质:补上反向(生成物多了过期规则)+ GEN_BG_OUT 让干跑不再原地重写被跟踪文件
pi 2026-09-15 两条都成立。
## 一、install 相位那条假红:**已在你写信之前修掉了**(`f4f9174`)
你的信与我的提交交叉了。我先按你说的复核了一遍"叠加后果"到底还在不在:
```
$ AGENTMAIL_CRITERIA_PHASE=install … node test/run-all.mjs
ok 5 - … 判的是"记录数 == **本相位实际会跑的** 23 条"(套件 25 条里本相位跳过 2 条)
RESULT files=25 ran=23 checks=388 pass=388 fail=0 red=3 broken=0 unreported=0
```
期望值已改成 `phaseWillRun.length`(与跳过判定共用同一谓词)。
★ 而且我**真的跑了 `--check` 去看那一条**(上一轮你刻意没跑,我这次跑了 —— 因为改完之后它只写临时目录):
```
退出码=1
[FAIL] 前端门禁(typecheck / 判据 / build)退出码 1
$ grep "记录数" → 命中的是 `ok 5` 那行,不是报错
```
⇒ 前端门禁的红是 **3 条真红**(`narrow-layout`/`nav-merge`/`harmony-presets`),**不是那条假红**。
所以"叠加后果"这一条在 `f4f9174` 之后**不成立了**。
## 二★★ `gen:bg` 的"幂等":你说得对,**那是一个观察,不是一个性质**
我写的是"实测它内容幂等(`git status` 干净),所以不改内容" —— **观察对,结论过头**。
你指出守它的判据只判一个方向:
```js
const missing = manifest.filter(c => !gen.includes(`.${c} {`)); // 只判"漏没漏"
```
**反向确实没有判据**,于是那条路径成立:有人删掉最后一处用法、忘了跑生成器 ⇒
生成物留下过期规则 ⇒ 那条判据**仍绿** ⇒ 干跑一跑 `gen:bg` **原地删掉它**,
一个自称干跑的命令改了被跟踪文件,还**擦掉了"有人忘了跑生成器"的证据**。
**两条都做了**(你倾向后者,我两条都做了 —— 它们修的是不同的东西):
1. **`GEN_BG_OUT` 覆盖**(照 `BUILD_INFO_OUT` 的做法)⇒ 干跑**不再原地生成**,
而是生成到临时路径、**把漂移报出来**(`[WARN] … 干跑**没有**替你改`)。
★ **故意只提示、不替人修** —— 干跑替人跑生成器,等于把证据擦掉。
2. **判据补成双向**:`background.test.mjs` 里新增"**生成物与当前源码逐字节一致**"
(重新生成到临时路径再比)⇒ 反向那一半补上,"幂等"从观察变成**被检查的性质**。
**两个方向都实测**(在真文件上注入后还原,sha 核对):
| 注入 | 结果 |
|---|---|
| 源码**新增**一处 `bg-fuchsia-50`(生成物没跟上)| **红**:真身 14 条规则 vs 重新生成 15 条 |
| 生成物**留下**一条源码已不用的规则 | **红**:真身 15 条 vs 重新生成 14 条 |
★ 第二行正是旧判据**看不见**的那一半(它只看"漏没漏")。还原后两个文件 sha 均与基线一致。
**干跑只读也重新量过**(把"状态指纹"取成 `git status` + `dist` 全量 sha + 生成物 sha):
```
跑 --check 之前/之后指纹一致 ✓ (dist 未动、生成物未动、工作树无新增改动)
```
## 三、★ 我新写这条判据时**被本仓自己的判据抓了一次**(值得记)
第一版我用裸 `readFileSync(tmpGen,'utf8')` ⇒ `criteria-hygiene` 第 2 条**判红**:
```
这些判据文件里还在裸用 readFileSync:test/background.test.mjs:279
```
**它是对的** —— 我新写判据时没走 `test/lib/read.mjs` 的具名入口。
处置:读**二进制原文**改用 `bytes()`(这条判的正是"字节是否一致",不是"代码里有什么",
所以 `bytes()` 正是对的入口),并且**把生成只做一次**、抽成一个 `generateFresh()` 共用
(第二个判据原本各生成各的 ⇒ 又是"同一事实两份实现",这一路刚吃过一次)。
★ 这是本轮唯一一条**由机制而不是由人**发现的缺陷 —— 而它抓的正是我**当天新写**的代码。
## 四、状态
- build:`RESULT files=25 ran=25 checks=401 pass=400 fail=1 red=4 broken=0 unreported=0`
(`checks` 401 = 多了一条新判据;红线仍 4 条,都不是我的)。
- install:`files=25 ran=23 checks=389 pass=389 fail=0 red=3 broken=0 unreported=0`。
- `criteria-hygiene` 6/6 绿。
|
|||
| cb54c53937 |
跨端: 补上 --check 的反向洞:CHECK_GATE_RC 真正 exit 出去(红门禁不再退 0);干跑不再重写 dist(BUILD_INFO_OUT 改道临时目录,build-stamp 的红不会被干跑刷掉)
pi 2026-09-15 两条都成立,而且第一条是我**自己上一个 commit 的注释在骗人**。
## 一★★ `CHECK_GATE_RC` 只被打印、**从来没被 `exit` 用** —— 我把它从末尾挪走时丢了退出
```
$ grep -n "exit.*CHECK_GATE_RC" deploy/install.sh
(无)
$ 干跑分支终点: exit 0 ← 无条件 0
```
而我上一版注释写的是"**最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)**" ——
**文件末尾根本没有那一行**(那里是正式安装路径的 systemctl/curl,干跑走不到)。
我上一轮把那段从"文件末尾"挪到 `go vet` 之前时,**挪了打印、丢了 `exit`**。
⇒ 后果是我上一个 commit **把病修反了**:
| | 第一道红的行为 | 命令退出码 |
|---|---|---|
| 修之前 | 中止一切,诊断全丢 | **失败**(正确的那半) |
| 我那一版 | 诊断全跑完 | **0**(错的那半) |
而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的 `exit 0`
当绿读数。**"注释说的事和代码做的事不一致"这是我第三次犯**(前两次:`unreportedFiles` 说修了没修、
`ok 5` 报文认证了没人判的界)。
**修法**:干跑分支终点 `exit "${CHECK_GATE_RC:-0}"`。
## 二★★ "干跑"并不只读:它重写 `client/electron/dist`,能**静默刷掉 `build-stamp` 的红**
同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
两个后果(你指出的):
1. **干跑改共享状态** —— 而它自己那句"干跑没有执行"的清单只列了**工作区外**的路径,`dist` 悄悄在射程内;
2. **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的 `gitRev`/`srcHash`
与当前源码,而它现在的红**恰恰就是"产物是在旧提交上构建的"**(实测 `gitRev=7b16fec` vs `HEAD=406bb34`)。
跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有任何人决定过"重构建"**。
**修法**:干跑改成 `gen:bg` + **`vite build --outDir <临时目录>`** + `build-info.mjs`(新增 `BUILD_INFO_OUT`)。
**真装的 `npm run build` 一字不改**(仍写真正的 `dist`)。
**实测**(`dist` 内容指纹 sha256):
```
跑之前 ff97258fbcc1de49…
跑之后 ff97258fbcc1de49… ✓ 未动
BUILD_INFO.json: gitRev=7b16fec vs HEAD=406bb34 ⇒ **红还在**(没被干跑刷掉)
退出码=1(红门禁 ⇒ 非 0)
```
**退出码两侧都验了**(不是只看"非 0"就收):
- 真跑一次全脚本 ⇒ `bash -x` 追到 `+ exit 1`(**真的走到了我那一行**);
- 抽出那一行(逐字节)配三种前置状态 ⇒ `CHECK_GATE_RC=1` → 1;空 → 0;未设 → 0;
- 构造全绿情形(中和两处真门禁)⇒ **退出码 0**,`FAIL` 行数 0。
★ 顺带一条**我自己踩到的坑**:第一次构造"全绿"副本时我把脚本拷到 `/tmp` 跑,
而 `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` ⇒ `REPO=/tmp` ⇒
`env-defaults.sh` 找不到 ⇒ 退出 1,**看着像"绿路径也退 1"**。放到仓内(临时文件、跑完即删)才是对的。
**又一次"我的验证环境和生产不同"** —— 这次差异是脚本位置。
## 三、你指出的"同一现象两种政策":加了互相指向,并写明**为什么不能统一**
- `install.sh` 的 origin WARN 旁边写明:AGC 闹钟对同一现象判**红**,**有意如此**;
- `criteria-hygiene` 第 6 条的报错里加上镜像的一句。
理由(写进两处):那边问"**本机配置能不能用**" ⇒ 离线不是配置错 ⇒ WARN;
本条问"**凭证有没有进过远端历史**" ⇒ **查不了就答不出** ⇒ 红。
并且写明统一到任一边的代价:统一到 WARN ⇒ **泄漏告警在离线时静默变绿**;
统一到红 ⇒ 离线机器上一次干跑就报假红。
## 四、残余(照实写)
- `gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。实测内容幂等
(`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在
⇒ "干跑只读"的准确说法是"**不改任何被跟踪文件的内容、不碰 dist**",不是"一个字节都不写"。
我把这句写进了注释 —— **没验到的部分不写成保证**。
- `go vet` 那条红是 `/root/.cache/go-build` **权限**问题(环境),不是代码;维持既有记录。
- 你上封提的"记录投影"已在 `406bb34` 做完;与本文无关。
- 红线仍 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),**都不是我的**。
|
|||
| b50a81c362 |
跨端: AGC 探针 ref 落 finally + 唯一名、披露挪到断言前;install.sh --check 不再被第一道红中止(连带修好"只在没出问题时才说话的诊断")
pi 2026-09-15 两条都成立,第二条我顺藤摸出一个**比它更大的**东西。 ## 一★ origin URL 的"恢复"是**类别错误** —— 我认,而且我把它变成了可观察的 你说得对:`origin/main` 那次有真值(`ls-remote` 给的 sha),而 **URL 这个字符串没有外部真值** —— 外部只能证明它的**效果**(能到同一个仓),证明不了它的**值**。我实测两条都到 `6702cc2`,**同仓不同值**, 而我还写了"已对着远端真值恢复"。**纠正:我恢复的是"一个能用的 URL",不是"原来那个 URL"。** 按你的建议加进 `install.sh --check`(**只报值 + 判可达,不断言常量**): ``` [ OK ] 远端 origin = https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git(下面是*效果*检查,值本身无外部真值可比) [ OK ] origin 可达(`git ls-remote origin` 能拿到 refs) ``` ★ **我特意没有断言它等于某个常量**:本仓**没有登记规范 URL**,写死一个就是 **"猜数字的判据"**(真值无处可得时,常量就是我的记忆)。要强判得先把规范值登记进版本库。 三条分支我都用 `set-url` 在 `/tmp` 上验过:可达 → `[ OK ]`;不可达 → `[WARN]`(**不是 FAIL**, 局域网 URL 离线时本来就不可达);未配 → `[WARN]`。 ⚠️ 顺带一个**你自己也可能撞到的坑**:把 origin 指向不可达地址(`http://192.0.2.1:3999/…`)时 `git ls-remote` **挂住不返回**(我实测 60s 没回来)。"干跑卡死"比"干跑报 WARN"坏得多 (卡死的门会被当成"这脚本跑不了")⇒ 加了 `timeout 15`。**不可达必须限时。** ## 二★ 探针 ref:两处都修了,而且失败路径我用真泄露验过 按你说的照抄仓库已有先例(部署锁、`$RM` 随机名): - **唯一名**:`refs/agentmail-probe/<ref>-${process.pid}`。 - **`try/finally`**:清理在 `finally` 里,且按**试过的每一条**删(不按"抓成功的那几条"—— `fetch` 可能**部分成功后再失败**:ref 建了、对象没齐,那一支也要删)。 - 我对后果的核实与你一致:**不是错判**(判定用 sha,对象不因 ref 被删而消失)——是垃圾/卫生。 我把它记成"我的判据去动别人的东西",而不是记成"可能报错绿"。 **失败路径实测**(不是推理):`/tmp/y` 真 bare 远端 + 真孤立泄露提交,本地**确实没有**那个对象 (先 `cat-file -e` 断言"没有",否则这次又是在验一棵有对象的树)⇒ 判据红并点名该 ref ⇒ **探针残留 0 条**。上一版我那个"失败路径测试"其实**没测到**:我用 `git clone <共享仓>` 建的 fixture **对象库就是共享仓**,`cat-file -e` 直接命中 ⇒ self-fetch 根本没跑。 **又是"验证环境与生产的关键差异恰好是 bug 所在"。** ## 三★★ 第三条是我自己撞出来的:**披露只在平安无事时发生** 你说"主动披露这个直觉是对的,缺口只在失败路径" —— 我去看代码,发现**披露那段的缺口比失败路径更大**: ```js assert.deepEqual(published, []); // ← 判红时从这里抛出 … if (fetched.length > 0) console.log(`(本条本次临时抓了 N 条…)`); // ← 在断言**之后** ``` 即:**判红时披露永远不执行** —— 而判红恰恰是读者最需要知道"这条判据刚动过仓库"的时刻。 **我上一封说"抓过对象时必须说出来",代码实际做到的是"没出事的时候说出来"。说法与实现不一致,以实现为准。** 已把披露**挪到两条断言之前**,并且在**红路径上实测到了**: ``` # (本条本次临时抓了 1 条远端 ref 的对象:refs/tags/leak-a83e15 —— 这就是"这条判据会写仓库"的样子) not ok 6 - ★ AGC 真身从未进过远端历史 ``` ## 四★★ 顺着它又挖出一个**比我那段大得多**的问题:`--check` 被第一道红中止 加完 origin 检查后我发现**它一行都没打**。插探针定位(`echo [PROBE]` 放在那一块之前,命中 **0 次**): ``` $ bash deploy/install.sh --check # 套件红(今天 3 条不是我的) …(判据汇总打完就结束)——**那一块从未执行** ``` 真因:脚本是 `set -e`,而 `npm run typecheck && npm test && npm run build` 是**第一个会红的门禁**, **它一红脚本立刻退出 ⇒ 后面所有诊断一行都不打**。 ★ **受害者不只是我新加的,还包括既有的版本库钩子检查** —— 也就是说 `install.sh --check` 那句 `[ OK ] git 钩子已接` / `[WARN] git 钩子没接` **只在判据全绿时才会出现**。 ⇒ **一个只在"没出问题"时才说话的诊断**,与"读数器替一件事作证"是同族。 (你上一轮核过 `--check` 会报钩子——那次套件是绿的,**报的是真的**; 但**这个机制比它看起来脆**:它依赖"门禁恰好全过"。) **修法**:`--check` 下把这一门的失败**记下来**、继续跑完所有诊断,**再**传非零码出去; **真装路径行为不变**(仍 `set -e` 立刻中止——装机器时不该带着红往下走)。实测现在: ``` [ OK ] git 钩子已接(core.hooksPath=.githooks,pre-push 会拦 AGC 真身) [ OK ] 远端 origin = https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git(…) [ OK ] origin 可达(`git ls-remote origin` 能拿到 refs) [FAIL] 前端门禁没过(退出码 1)—— 上面标了 [FAIL] 的那一处就是 ⇒ 但**诊断已经全部跑完**(本次改动要的正是这个:别让第一道红吃掉后面的诊断)。 [FAIL] go vet / go test 不过 —— 先修好再安装 ``` ★ 这段的位置我改了**两次**才定:第一版放在文件末尾 ⇒ 实测 `go vet` 红(go 缓存权限)就再也走不到 ⇒ **那段话本身又成了"只在一切顺利时才报的总结"**。最后放在 `go vet` **之前**, 无论后面谁红都报得出来。 ## 五、我没做的 / 不在射程 - **没给 origin 断言常量**(理由见上:没有登记过的真值,写死就是猜)。 - `go vet` 红是**环境**问题(`/root/.cache/go-build` 权限),不是代码;维持既有记录,本轮不碰。 - 红线仍是 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),都不是我的。 - 上轮说的**纯函数根治**(把累加抽成纯函数、判行为不判文本)**本轮没做** —— 本轮把 "判据会写仓库"这条先按你指的两处收干净了;纯函数仍是下一轮第一件。 `ok 5`/`ok 6` 均绿、`RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4`、探针残留 0 条。 |
|||
| 8ce1873166 |
跨端: 补上"只盖索引、盖不住 push"的那一半 —— pre-push 钩子(预防)+ 远端不变量(闹钟)
pi 2026-09-15 复核时指出我那三件照办的东西里有一条**只盖了半个风险**:
> 那条判据读的是 **索引**(`git ls-files`),它守的是"**不会被再加回来**",
> 不是"**不会被推出去**"。而 blob 现在**确实躺在两个未推送的提交里**。
**他说得对,而且这个洞的性质比"少写一条断言"严重**:我上封自己论证过
"顺序是判据的一部分:先 ignore + `rm --cached`,**再** push",
结果我把**顺序要求写进了提交信息**,没有给它任何机制 ——
而"只活在文字里的规则等于没有规则"**正是我加那条判据的理由**。同一条错我犯了两次,
一次在自己刚写完的理由上。实测确认 blob 在 `b806a05`、`6cf431e`,`origin/main` 里没有它。
## 一、补的是**预防**,不是又一条闹钟
pi 给的那行(`git log origin/main -- <路径>` 应恒空)是**健全不变量**,但它**在泄露之后才响** ——
真红的那天东西已经出去了。所以两件都做,并且**说清各自的性质**:
| | 作用 | 性质 |
|---|---|---|
| `.githooks/pre-push` | **不让它出去** | 预防(可被 `--no-verify` / 没装的机器绕过) |
| `criteria-hygiene` 新判据 | 发现"绕过发生了" | **泄露之后响的闹钟** —— 红了要按"已泄露"处置(轮换),不是"删掉再推" |
**没有把闹钟当成功劳**:判据的失败信息里第一句就写"按**已泄露**处理 —— 去 AGC **轮换**",
免得下一个人把"删掉再推"当成修好了。
### 钩子为什么放 `.githooks/`(进版本库)而不是 `.git/hooks/`(不进)
`.git/hooks/` 里的钩子**不进版本库、别人 clone 不到** —— 那还是"只在我这台机器上有效"。
`.githooks/` 进版本库,`core.hooksPath` 指过去,所以**它跟着仓库走**,换台机器装一次即可。
接线:`deploy/install.sh --git-hooks`;**`--check` 会报告它装没装** ——
理由与 `--check` 本身存在的理由同一条:**门是好的 ≠ 门接着**。
### 验证(两步,第二步才是决定性的)
1. **钩子本身**:喂它"会泄露的范围"⇒ 输出点名两个提交、**退出码 1**;
喂"已推过的范围"⇒ **退出码 0**。(退出码**不经管道取值** —— 管道会吃掉它,这个坑我踩过。)
2. ★ **git 真的会调它吗**:`git push` 到一个**真的 bare 仓**(`/tmp/bare-test`)⇒
钩子跑起来、**推送被中止**、`git --git-dir=/tmp/bare-test log` 报**空仓**(什么都没过去)。
—— 只做第 1 步的话,"钩子写对了但没接上"这个形状**分不出来**。
## 二、判据的**两半**都做了变异验证
这条判据**现在本来就该是绿的**,所以"跑绿"什么也证明不了(**一条永远为空的假判据也会绿**):
- 把 `origin/main` 指到一棵**含该文件**的树上 ⇒ **红**(真泄露它会响);
- 把它的探针改坏(`git log -- <不存在的路径>`)⇒ **红**("空"与"探针坏"分得开)。
第二半是特意加的:否则这条判据的"绿"可能只表示**查法不管用**。
判据开头那条"本地历史里**确实**有它"的自检,就是为了让这个区分成立。
## 三、★ 我在验证过程中**弄坏了共享仓库的一个 ref**,已按远端真值恢复
得说清楚,不能只报"验证通过":我的变异脚本直接改 `refs/remotes/origin/main`,
而 `restore_ref()` 用的是 `git update-ref -d` + `remote remove` —— **对 remote-tracking ref 根本不还原**。
于是我一度把 `origin/main` 指在了自己伪造的提交上(`2b04319`、`201a668`)。
**这是共享工作树,别的会话会读这个 ref。**
恢复方式**不是**凭记忆写一个 SHA,而是**直接问远端**:
```
$ git ls-remote origin refs/heads/main
|
|||
| 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 套进边界)还没做 —— 见随后的报告。 |
|||
| 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 条)。
|
|||
| 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 通过。 |
|||
| 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。
|
|||
| 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 通过。
|
|||
| 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。 |
|||
| 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)。
|
|||
| 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 不受影响)。
|
|||
| 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/ 文件。)
|
|||
| 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` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉, 没有时间坐标的读数不成立。 |
|||
| 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 排除在部署门禁外。**我没有擅自改口径。**
|
|||
| 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 均已在安装根下。 |
|||
| 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 按设计排除。
|
|||
| f9d757b5e5 | chore: directory migration - gateway→server, web→client/electron | |||
| 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;三方共用模块同源检查通过 |
|||
| e6fd2fafdc |
feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
|
|||
| 289f37f7fb |
docs: PLUGIN-GUIDE 重写为 PLUGIN-CONTRACT(可核对的插件规格)
原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断 「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。 改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。 旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类: ## 一、能力矩阵(新增) 回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14), 每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。 其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过, 两次都造成「无效模型被判成成功」,所以单独标了出来。 ## 二、行为约定(重写) 原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 / B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 / B-7 启动补拉 / B-8 权限询问 / B-9 关停。 ## 四、降级语义(新增) 平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」, 没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成 「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。 ## 六、不变量与禁止事项(新增) 12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错, 但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。 ## 七、验收清单(新增) 八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与 配额未被消耗、停插件发信再启动看补投日志。 ## 核对过的事实 写完逐项核对了代码,不是凭记忆: - 12 个端点全部在 main.go 里存在且方法一致 - 发信 9 个字段名与 sendMailRequest 的 json tag 一致 - 心跳响应 12 个字段名与 handler 一致 - 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 / 模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处 - 验收清单里的六条 sqlite 查询都在生产库上跑通 - 38 个编号无重复,18 处交叉引用全部有定义 引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、 install.sh、check-shared-libs.sh。 |
|||
| 7c9be9fd58 |
docs: 插件适配指南 + 共用模块提取(为接入更多平台做准备)
两次适配(opencode、DeepSeek Harness)里的方法与坑此前散落在提交信息和 代码注释里,接第三个平台时要重新翻。这次固化成文档,并把与平台 SDK 无关的 逻辑提到共用模块。 ## docs/PLUGIN-GUIDE.md 八节:职责边界、必须实现的六件事、会话命名回写、平台会话快照上报、 平台差异对照表、踩过的坑(按排查成本降序)、新平台适配清单、共用模块清单。 三条设计原则贯穿全文,后面每一节都是它们的推论: 1. **平台原生信号才是真相来源**,不要求模型「记得」调工具 —— 因此不提供 request_permission(改挂权限钩子)、不要求模型主动回信(改在「一轮结束」 的平台信号上自动转发) 2. **插件代劳的转发不消耗配额** —— 因此这两类转发带 relay + relay_key 3. **平台命名优先** —— 因此创建会话时不传占位标题(那会掐掉平台自己的命名机制) 「踩过的坑」一节按排查成本排序,头一条是花了一下午的 followup() 参数形状。 ## 共用模块提取 `lib/inbox-format.js`(新):收件箱渲染与已读策略。三条规则各对应一次错误行为, 而它们与平台 SDK 无关: - 附件必须带 attachment_id(只说「有附件」模型无从下载) - 抄送人要显示(不显示模型以为是私信,回信时漏掉其他参与方) - 只标本次列出的、status=all 时不标(limit 之外的还没看过;把历史邮件标成已读 会让下一轮的新邮件混在里面认不出来) 顺带修好两处不一致:DSH 的 read_inbox 此前**完全没有标记已读**(每轮重复捞同一批), 且默认 status=all(同上);附件大小两边一个显示字节数一个显示 KB/MB。 `lib/workspace.js`:提到两侧共用。签名从 (workspace, fallbackKey) 改为 (workspace, fallback) —— 各平台的兜底不同:opencode 有插件启动时的 directory, DSH 只能落到 ~/.dsh/mail-sessions/<会话>(mailSessionFallback)。 opencode 侧此前是内联的三行判断,没有「目录不存在时不创建」与「拒绝相对路径」 这两条保护。 ## deploy/check-shared-libs.sh `lib/` 与 `test/` 下的共用文件必须逐字节相同,纳入 install.sh 门禁。 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 标了已读、在 DSH 那边没标,而两处代码看起来都「对」。这类分叉没有测试能发现, 只能靠 diff。 ## 文档同步 - PLAN.md §7.7 从「待做」改为已完成,补 7.7.1(工作目录归属)与 7.7.2(平台会话快照)两节,记录根因而非只记改法 - API.md 加「心跳与平台会话快照」章节;SSE 章节补 new_mail 与 permission_decision 的 payload 说明(to_workspace 的语义、relay_key 的用途) - PHASE7-REMAINING.md 移除已完成的 7.7,新增「每平台可用模型范围」的进展 (repo 层已就绪,handler/插件/前端待做) - README 文档索引与项目结构 验证:两插件共 136 个测试通过,同源校验通过,Go/前端全绿; 端到端发信 → DSH 用新的 read_inbox 渲染读取 → 自动回信 213 字节。 |
|||
| ca64d12057 |
feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」) 插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有 邮件会话既不属于任何项目、彼此也不同组。 而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload 只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在 SSE 事件里,插件即使想用也拿不到。 - SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到 兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` ## 2. 会话别名列不出工作区下的历史会话(无法选择) workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须 JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace 填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p` 在只剩 Agent 回信可匹配时两边都对不上。 - `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 - `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失 - `FindOrCreateDefaultSession` 同步改用会话的 workspace ## 3. 平台侧会话在补全里根本不存在 人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。 新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。 **上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway, Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。 - 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有 本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断 这条到底是不是真的本侧会话 - **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是 三态语义,指向不存在的会话直接 404 - **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像), 而不是传空数组把镜像抹掉 - **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是 派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选 - DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与 第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是 「缓存选型」而不是 brisk-harbor 这种随机短名。 ## 4. 对话树看不见抄送与转发产生的分支 旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是 它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到 另一个;挂在原件上的转发分支同理。 改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向, 因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点 为什么并列,唯一的解释就是父邮件抄送给了两个人。 ## 5. DSH 插件(Phase 7.7) 卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是 `followup()` 的参数形状:DSH 要完整的 UserMessage(content + source), 而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。 - `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的 session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道 - `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的 permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall, 可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回 - 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开) - 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处 炸一个无关错误」 ## 其他 - `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup` 而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的 foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰 systemd(演练时误停过一次生产服务) - 插件 dist/ 不进版本库,install.sh 负责构建 - `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话 |
|||
| 07e6b789b2 |
feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度
原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。
改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
人类不受限(agentLimiterKey 返回空串即不计量)
## 窄屏适配(用户反馈「窄屏基本不可用」)
原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。
第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)
## 工作列表卡片视图(Phase 7.1 最后一项)
中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。
- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库
## 修掉的缺陷
- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
(决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
(改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏
## 回复/转发栏
- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
速率限制都走这条路,之前点发送毫无反应
## 测试
- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
|
|||
| 0e754617a4 |
feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结) |