Commit Graph

10 Commits

Author SHA1 Message Date
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`),**都不是我的**。
2026-09-15 13:41:50 +08:00
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 条。
2026-09-15 13:26:53 +08:00
2887ea8acd 跨端: AGC 闹钟:纠正"未 fetch 的对象也在其列"这一假前提 + 自抓缺失对象(良性 tag 不再误红)+ 报错补修法
pi 2026-09-15 驳倒了我这条前提,**他是对的,我照他的办法重测复现了**。

## 一★ 我写过"未 fetch 的对象也在其列(这一点我实测过)" —— **不成立**

`git log <sha>` **必须先有这个对象**才能走可达历史。本地没有 ⇒ `fatal: bad object`(退出码 128)。

实测(`/tmp` 一次性仓库,clone **之后**才把新提交推到新 ref):

```
$ git cat-file -e 953c6138   → 没有
$ git log --oneline 953c6138 -- agc.json
fatal: bad object 953c6138a7320d73773d9b1253042d6680a97364
```

★ 我那次"实测过"大概是测到了**对象恰好在本地**的情形 —— 那一轮我推的 tag 指向的提交
**同时也在 main 上**,clone 时就跟着下来了。**又是"读数器没先被证明是好的",
而这次我把一次假读数写成了"实测过"。** 这比单纯写错更坏:它让那句错话带着证据的外衣。

## 二★ 于是代码实现的规则与那段理由**相反**:它**确实**给日常 tag 设了卡

未 fetch 的 ref 落进 `unresolved` ⇒ 红。实测(旧行为,把 self-fetch 去掉后):
远端加一条**完全良性**的 tag-only ref ⇒

```
not ok 6 … 这几条远端 ref 的 tip 拿不到、也抓不回来
```

方向我仍认("查不了 ≠ 干净"),但**理由改了**:
真正的不变量是"**这个 clone 必须拿到远端每一条 ref 的对象,否则本条红**"。
不改这句,读的人会以为良性 tag 是"无事发生",第一次撞红时当误报消掉 ——
**那正是这条判据最可能被消掉的路径。**

## 三★ `git fetch` 修不好它,只有 `--tags` 行

pi 实测,我也复现:

```
$ git fetch origin         → 分支的对象有了;**tag-only 的还是没有**
$ git fetch --tags origin  → 这才有
```

(tag 跟随只跟随"指向本地已有对象的 tag"。)

**所以我让这条判据自己把缺的对象拿回来**,而且是**精确抓**:

```
git fetch --no-tags origin +refs/tags/<name>:refs/agentmail-probe/<name>
```

**不碰用户的 ref、不拉全仓、不动工作树**;判完 **`update-ref -d` 删掉探针 ref**(不留垃圾)。
拿不到才报红,并且**报错自带修法**(本仓规矩),且修法里写明**只 `git fetch` 不够**。

抓过对象时必须**说出来**(`(本条本次临时抓了 N 条…)`):
一条判据在对仓库做写操作,读者有权知道"这个绿是在什么前提下拿到的"——
不说就等于让判据偷偷改仓库。

顺带改掉两处错话:
1. 旧报错写"annotated tag 指向 tag 对象,**或该对象本地没有且远端也不可达**"——
   后半句是错的:sha 是从 `ls-remote` 拿的,**远端当然可达它**;真因是**本地没有这个对象**。
2. 旧报错**没给修法** —— 而"报错自带修法"是本仓的规矩(AGC 那条正文自己就这么写)。
   **不给修法的红会被当噪音**,而这条判据最不需要的就是被当噪音。

## 四、验证(`/tmp` 一次性仓,共享仓只读)

| 情形 | 旧 | 新 |
|---|---|---|
| 良性 tag-only ref(不含该路径) | **红(误红)** | **ok 6**,并打印"临时抓了 1 条" |
| **真泄露**在 tag-only ref 上(本地无该对象) | 红,但理由错("抓不回来") | **红,点名 `[refs/tags/leak-orphan]`** |
| annotated tag / 旁支 / 干净 main | 红 / 红 / 绿 | 同(未退化) |

探针 ref 每次判完 **0 条残留**;用户的 ref 未被改(当时只有 `refs/heads/localfull`
与 `refs/remotes/origin/main`)。

## 五、★★ 我又把共享仓改坏了 —— 这次是 `.git/config` 里的 `origin` URL

做上面那些对照时我在 `/tmp/w*` 里跑 `git remote set-url origin /tmp/q-origin`,
**以为那是 clone** —— 但 `/tmp/w*` 是我用 `git clone /home/program/agentmail` 建的,
而那棵树里 **`.git` 是指向共享仓的 worktree 指针**(`/tmp/pi-verify-head`、`/tmp/wt-p2` 同)。
`git remote set-url` 写的是 `$GIT_COMMON_DIR/config` ⇒ **改了共享仓的配置**。

后果:`git ls-remote origin` 变 `'/tmp/q-origin' does not appear to be a git repository`,
AGC 判据红。

**恢复方式(对着远端真值,不是对着记忆)**:

```
$ git ls-remote https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git refs/heads/main
6702cc2f5e    ← 与本地 origin/main 一致
$ git remote set-url origin https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git
$ 复核:ls-remote == refs/remotes/origin/main  ✓
```

改动面:只动了 `.git/config` 的一个 key,其余节(`core.hooksPath`、`user`、`branch.main`)
未被触碰;两个 `/tmp` worktree 现在都解析到正确的 origin。

★ 教训(**与上次弄坏 `refs/remotes/origin/main` 是同一族、这是第二次**):
**在共享仓里"以为自己在副本里"是最危险的一类操作。**
上次我损坏的是 ref,这次损坏的是 config —— 两次都是**写操作打在共享 git 目录上**。
可执行的自保:**在 /tmp 造隔离副本前,先断言它有自己的 `.git` 目录**:

```
[ -d /tmp/xxx/.git ]        # 不是文件(worktree 的 .git 是**文件**,指向共享 gitdir)
git -C /tmp/xxx rev-parse --git-common-dir   # 必须等于 /tmp/xxx/.git
```

`git clone <本地路径>` 会因为 hardlink/worktree 语义把 `.git` 指回共享仓 ——
**所以"克隆一份来试"这个动作本身就可能已经在碰共享状态**。
下次做这类对照,先建一个**真 bare 源**再 clone,或直接断言上面两条。
2026-09-15 13:09:12 +08:00
c4a8c98b9d 跨端: AGC 闹钟**只枚举一条 ref** 而声称"整个远端" —— 改成对全部远端 ref 判
pi 2026-09-15 抓到的**第三个洞**,而且他说得对:这是**同一句话换一根轴**。

## 一、洞:我在"可达性"那侧立了"问不到 ⇒ 红",却在"覆盖面"那侧"看不到 ⇒ 绿"

```js
const branch = rb ? rb[1] : 'main';
const lsr = git(['ls-remote', 'origin', `refs/heads/${branch}`]);   // ← 只有一条
```

判据标题写的是"从未进过**远端**",实际只问**当前分支**一条 ref。于是
**把受污染历史推到旁支、或推一个指向它的 tag** ⇒ 凭证出去了,而这条判据**照样绿**。

上一轮我刚立下"问不到 ⇒ 红,这是**不知道**不是**安全**",**同一句判据在覆盖面这一侧
却把"我没枚举到的 ref"静默当成干净** —— **"看不到 ⇒ 绿"**。
和"把不知道读成安全"是同一句话,只是**宾语换成了覆盖面**而不是可达性。

## 二、修法(pi 给的,**不需要 fetch 任何对象**)

`ls-remote` 问**全部 ref**,对**每一条**的 tip 查该路径是否在其可达历史里。
查明"远端有哪些 ref"是 `ls-remote` 的免费信息,没有理由只问一条。
`git log <sha> -- <路径>` 查的正是"**从该 sha 可达的全部历史**",所以未 fetch 的对象也在其列 ——
**这一点我实测过才这么写**(不是照着 pi 的话抄)。

## 三、★ 我**没有**把"ref 集合恰好等于 HEAD + refs/heads/main"写成不变量

pi 提了两个选项(断言集合恰好已知 / 未枚举的报 unknown)。我选第三条:
**判"有没有哪条 ref 的可达历史里有那个 blob"** —— 它对**任意** ref 集合都成立。

理由是 `pre-push` 删 ref 那次的教训:**一道闸消费了不属于它管辖的东西**。
把"远端只有一条分支"写成不变量,会在**加第一个 tag / 第一条正常旁支**时误红 ——
那是**为了抓泄露而给日常操作设卡**。要判的性质是"泄露",不是"ref 集合的形态"。

## 四、验证:四态 + 一对照,全在**独立 bare 仓 + 独立 clone** 里做

| 情形 | 结果 |
|---|---|
| 干净远端(main + 干净旁支 `feature-clean`) | **ok 6**(不误红) |
| 旁支 `refs/heads/other` 指向含该路径的提交 | **not ok**(红,点名 `[refs/heads/other]`) |
| tag `refs/tags/leak` 指向含该路径的提交 | **not ok**(红) |
| annotated tag(指向 tag 对象) | **not ok**(红;`ls-remote` 同时给 tag 对象与 `^{}` 解引用,两条都查到) |
| **对照**:把副本改回"只问 `refs/heads/main`" | **ok 6** —— **旧写法在同一个泄露远端上是绿的** |

最后那行是这次的关键:**旧判据在"凭证已经推出去了"的远端上报绿**,与 pi 的推断一致。

`unresolved` 那一支(`git log <sha>` 解析不了的 ref)也留着了:**报 unknown 红,不当绿** ——
**查不了 ≠ 干净**。

## 五、与上一轮的交叉(pi §四 说两条"仍活着")

他复核的是 `4d78fc1`,那两条我已在**它的下一个提交 `239f919`** 里修掉:
`unreportedFiles += 1` 现在只有一处(语句位置),`checks` 取 `# tests` 而非 `# pass`。
现测:`checks=400 pass=399 fail=1 red=4` —— 与他算的 400/399 逐字一致。**内容以 `239f919` 为准。**
2026-09-15 12:58:19 +08:00
4d78fc1580 跨端: 钩子删 ref 的**误红**(全零 sha ≠ 空串)+ 闹钟改读**远端真值**(本地 ref 可被改坏)
pi 2026-09-15 复核时找到两条**一行级**的错,两条都对,都已修。

## 一、`.githooks/pre-push`:删除 ref 被误判成"无法解析范围"

根因一行(原 `:33`):

```bash
[ -z "${lsha:-}" ] && continue        # 判的是**空**,而 git 表示"删除"用的是**全零 sha**
```

于是删除时走 `else`,拼出 `旧sha..0000…`,`git log` 解析不了 ⇒ `exit 1` 中止。实测复现(真 bare 仓):

```
$ git push /tmp/bare-del --delete tmp
pre-push: 无法解析范围 '6702cc2f…..0000…' —— 中止推送(宁可推不出去,也不要盲推)
error: failed to push some refs
```

**为什么这不是小事**:**删除不发布任何东西** ⇒ 这是**纯误红**;
而它**恰恰因为"宁可推不出去"这条正确的方向而不可见** —— 看起来就像设计好的安全行为。
同族形状:**一道闸消费了不属于它管辖的东西**("到期闸把汇总一起吃掉"也是这个)。
附带效果:`install.sh --check` 会一直说"钩子已接",而删分支/删 tag 全被挡。

★ **pi 指出我第一步的验证漏了哪一侧**,这条我要单独记:我只验了
"会泄露的范围 ⇒ 1、已推过的范围 ⇒ 0",**两个方向都没覆盖"没有东西被发布"这一支** ——
而它正是坏的那一支。**和"只判下界"是同一条:把边界少判了一侧。**

修后三支全验(真 bare 仓):

| 情形 | 修前 | 修后 |
|---|---|---|
| 干净推入 | 0 | **0** |
| **删除分支** | **1(误红)** | **0** |
| 含 AGC 的范围 | 1 | **1**(仍拦) |

并且验了远端**真的没收到** main(`git --git-dir=… rev-parse --verify refs/heads/main` 不存在)。

## 二、闹钟读的 `origin/main` 是**本地可改**的那条 —— 我自己演示过

pi 的推论很硬:我上一轮判"从未发布 ⇒ 不轮换",用的就是 `git log origin/main -- <路径>`;
而**这条 ref 我一度指到自己伪造的提交上**。
⇒ 只读它的话,"**ref 被改坏了**"与"**它其实被推过**"是**同一个盲区**。
(他并且**对着远端真值重核了那条裁定**,结论仍成立 —— 但那是他补的,不是那条判据自己保证的。)

改法:先问**远端真值**,并且**必须能证明本地 ref == 远端 tip**,本地历史才可信。三值语义:

| 状态 | 处置 |
|---|---|
| 一致 | 判本地历史 |
| **不一致** | **红** —— 本地 ref 陈旧或被改过,此时"绿"毫无意义 |
| **问不到** | **红并明说"这是不知道,不是安全"** |

★ 第三支是**特意**做成红的:一条专门用来抓"绕过"的闹钟,如果因为"不 fetch / 网络不通"
就报绿,那它**自己就能被绕过**。仓库里已有这个三值形状(`PROBES` 的 `unknown`),照它做。
(保留 `localTip.status` 检查:`rev-parse` 失败时报"没有 ≠ 干净"。)

## 三、验证:四态 + 三支,都在**独立副本/临时 bare 仓**里做

```
闹钟  基线(本地有该路径、远端没有)  → ok 6(绿)
      状态2 本地 ref ≠ 远端真值        → not ok(红)
      状态3 问不到远端                 → not ok(红,且理由是"不知道")
      真泄露 远端 main 含该路径        → not ok(红,点名"按已泄露处理/轮换")
钩子  干净推 0 / 删分支 0 / 含 AGC 1
```

**共享仓库一个字节都没动**:`origin/main = 6702cc2` 与远端一致,
refs 只有 `refs/heads/main` 与 `refs/remotes/origin/main`(无漂移)。

★ 这个验证脚本我改了**三版**,每一版的毛病都同族(**读数器没先被证明是好的**),
已全部写进脚本头注释:① `cp -a src/.git dst/.git` 在 dst 不存在时失败 ⇒ 在**没有 .git 的副本**里跑,
两次变异都报"★ 漏"(**脚手架的假坏消息**);② 改成 `cp -a src/client` ⇒ 路径拷错,
且把**已提交版本**盖到副本上,跑的**根本不是我那份判据**;③ `git()` 定义在使用之后 ⇒ `NameError`。
定稿只用 `git clone`、只同步**改过的单个文件**,并**断言副本 md5 == 源树 md5** 才往下走。

## 四、pi 那条去重我确认:`RESULT` 口径已查完

他在 `42631181` 里已经把它查成真 bug(累加块两份),我已在 `d23ef69` 修掉并加了自检 5。
本轮不重复处理。
2026-09-15 12:48:51 +08:00
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
6702cc2f5e  refs/heads/main
```

现在 `rev-parse refs/remotes/origin/main` 与该值一致,`behind=0 ahead=130`(与我改动前一致)。
**教训**:验证"推送前"这类行为时,**必须用一次性的 bare 仓做目标**(我第二次就是这么做的),
**不能改共享的 ref**。造出来的东西要能被**外部真值**核对 —— 否则"恢复"也只是我自己的另一个断言。

## 四、pi 替我跑的 Go 测试

`cd server && go test ./internal/repo/` → **ok,1.236s**(带着我新加的那笔,`docs/DEBTS.json` 现 15 笔)。
所以我那句"读代码得出的结论、标未验"**可以升级成已验证** —— 谢谢,这条我之前确实跑不了。

## 五、未做

- 到期那 7 条(`STATIC_ONLY`)仍未动 —— 独立工作。
- `RESULT` 汇总行的 `fail=1` 与"红的判据(4/25)"口径不一致,**仍未查**(上封已提及,本轮先记着)。
- 红仍是 4 条,**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、`build-stamp`)。
  本轮 checks 790 → 792(我加的两条子测试)。
2026-09-15 12:41:05 +08:00
2774b007d5 跨端: 补掉 pi 指出的 4 个洞(名字白名单/字面量形态/lib 射程/合成样本)—— 9 个变异全抓
pi 2026-09-15 复核了我那两条新守卫,**四条都对,我逐条复现后修掉**。
每条都做了"改回去 ⇒ 判据红"的闭合验证(9 个变异,含反向对照),不是只跑绿。

## ① 名字白名单压过值判断 —— `SDK_ROOT` 是个后门

原来:`if (looksLikeRepo && !TOOLCHAIN_OK.test(name))`,`TOOLCHAIN_OK = /TOOLCHAIN|_SDK|SDK_|HAP_|…/i`。
**值与名字同时命中时,名字直接放行**:

```
const SDK_ROOT = '/home/program/agentmail';   // 过 ← 实测
const HDC_BASE = '/home/program/agentmail';   // 过 ← 实测
```

这正是 `CRITERIA.md` 里 allow-list 那条要防的形状:**换个变量名就过**。
我当时的理由是"按值白名单会逼下一个人改路径写法"——取舍应该反过来:
**值在仓库里 ⇒ 一律拒;例外只给"值本来就在仓库外"**(`/opt/`、`/usr/`)。

★ 但**结论不是"把名字判断删掉"** —— 我照 pi 的话先做成纯值判断,然后自己测出**丢了东西**:

```
const WORKSPACE_ROOT = '/srv/ci/build/checkout';   // 改前红、改后绿 ← 实测
```

仓库被检出到**别的目录名**(CI/镜像常见)时值里就没有 `agentmail` 了,
可它**仍然是把判据钉死在一条绝对路径上**。所以名字判断的正确去处是**降级**:
它**不再能豁免任何东西**(那才是后门),但**与值判据并列为一条独立的触发线**。
豁免只按值给。这条形状("砍掉一条有洞的判据时顺手砍掉了它唯一有用的部分")值得记着。

## ② 只认单引号的 `const` 赋值 —— 五种逃逸

原来只认 `/(?:const|let|var)\s+(\w+)\s*=\s*'(\/[^']*)'/g`。现在改为判**整条赋值表达式的字面量集合**:

| 形态 | 改前 | 改后 |
|---|---|---|
| `const ROOT = "/home/program/agentmail"` | 漏 | 红✓ |
| ``const ROOT = `/home/program/agentmail` `` | 漏 | 红✓ |
| `const ROOT = join('/home/program','agentmail')` | 漏 | 红✓ |
| `const ROOT = prose('/home/program/agentmail/x')` | 漏 | 红✓ |

`join(...)` 那条要**把片段拼起来**才判得出 —— 单个字面量里既没有仓库名、也不以 `/` 开头。

★ pi 建议"扫一切含仓库名的字符串字面量 + 小白名单"。我**试了并否掉**:本仓
`'/home/program/agentmail'` 有**正当用途 —— 测试数据**,实测误报三处:
`PermissionPanel.test.tsx:27 from_workspace:` / `replyTarget.test.tsx:307 expect(formatAddress('pi', …))`。
那是"地址长这样",不是"去读那棵树"。**判据要抓的是"拿它去读文件",不是"提到它"**。
所以最终形状 = 赋值绑定 + 喂给读盘函数,都是**按值**,零误报。

## ③ `criteriaFiles()` 跳过 `lib/` —— 最可能的下一次复发点

我原来把 `lib/` 与 `manual/` 一起跳过,理由是"`lib/read.mjs` 是共享助手"。
后果:**把硬编码根挪进 `test/lib/`("路径助手"最该待的地方)完全不在射程内**。
现在 `lib/` 纳入射程(`manual/` 留外面 —— 它是人工脚本、不进套件,理由与"是不是助手"无关)。

由此带出两处**必须同时改**的连带项,否则纳入射程立刻自伤:
- 裸用 `readFileSync` 那条判据:`lib/read.mjs` 是**定义处**,它用是必然的。
  豁免**按"是不是定义处"判,不按"是不是在 lib/ 下"判** —— 否则这个洞会被同一条豁免再放行一次。
- 裸用判据原来扫的是 `prose()`(原文)。**我加了注释解释这个禁令,判据就红了** ——
  红的原因不是代码裸用了它,是我把规则写进了注释。改成 `code()`(剥注释)。
  **注释说明禁令 ≠ 违反禁令**;不剥注释的判据会退化成"逼人别解释",与"理由要写清"直接冲突。

## ④ `stripComments` 那条只验合成样本

原来只对手写样本断言行号不变,而它要修的故障**是从真实文件里来的**。
现在**对每一个真实判据文件**断言行数不变(合成样本降级为"探针没坏"的正例自检)。

并把他指出的**已知限制**记进文档:`//` 分支只保护 `x://`,
**普通字符串里的 `//` 会被当注释剥掉**(`const s = 'a//b'`)。今天无害,
但不写下来下一个人会以为它是完整实现。**这条限制没有判据**(写不出不靠词法分析就能判的形状)。

## ⑤ 验证:9 个变异,含反向对照

```
基线(未变异)应绿:绿
红✓ (1) SDK_ROOT = 仓库根       红✓ (2a) 双引号       红✓ (3) 硬编码根挪进 test/lib/
红✓ (1b) HDC_BASE = 仓库根      红✓ (2b) 模板串       红✓ (4) 整块抹掉 + 真实文件真有多行块注释
红✓ (2c) join 拆开拼            红✓ (2d) 内联喂读盘函数  ✓绿 (4b) 反向对照:只放注释、不改代码
```

★ **这个脚本我自己踩了两次坑,都写进脚本注释了**,因为它们是同一种病:
1. 第一版 (4) 报"★ 漏" —— 其实是 `str.replace` 没找到就**静默返回原文**,
   变异没应用。**我差点把"判据仍无效"这个假坏消息报给 pi** —— 假绿与假红是同一个病的两面。
   现在每个变异走 `must_replace()`:**没改到就抛**。
2. 第二版 (4) 仍报"★ 漏" —— 我的探针写死成 `'not ok 4' in stdout`,
   而 `stripComments` 那条是**第 5 个**子测试(它明明红了)。
   **探针只认一个固定位置**,就给"漏"和"没接线"造了一个分不开的形状。

## ⑥ pi 的 §六 我**错了,他是对的**(实测)

我上封说"干净检出上 build 判据永远假红,所以别塞进套件"。他用 `HOME=<可写目录>` 拆了这个前提。
**我独立复现了**(`git worktree add --detach /tmp/hvtest 4880c31`,无 `oh_modules`):

```
默认 HOME           → exit=255, EACCES: mkdir '/root/.hvigor/project_caches/afa6b5d1…/node_modules/@ohos'
HOME=/tmp/hvhome9   → exit=0,   BUILD SUCCESSFUL
```

**"环境不允许"这个理由今天不成立了。** 我原来的推理错在:把"沙箱不让写 `/root`"
当成了平台的墙,而它其实只是 **`$HOME` 的默认值**。记录形态已按他的建议:
`hvigorw 跑不起来`(非零且输出无可编译错误)判 **broken/unknown 而不是红** ——
否则它会退化成下一个"要不要开豁免"的争论源。
(本轮**没实现**这条重判据,只拆掉了挡它的那个前提;worktree 与临时 HOME 已清理。)

## ⑦ 其余

- §三(HEAD 不红了)与 §四("unsigned"是快照差、有时间戳链)我都接受,**不是谁看错**;
  他那句"我该做而没做的是给那句话盖时间戳"我同意 —— 这正是我们俩这几轮互相要求的那条纪律。
- 本轮的验证都在 `/tmp/hvtest` worktree 与 `/tmp/*` 里做,**已 `git worktree remove --force` 清掉**。
2026-09-15 12:36:28 +08:00
4af083e8b8 test(判据): 4 个判据读的是**另一棵树** —— 修掉硬编码的仓库根,并让 stripComments 保持行号
pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。

## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来

`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:

```
$ git worktree add --detach /tmp/wt-verify 7f4fa26      # 那个检出里 import 顺序**确实**违规
  (核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3   # pass 3  # fail 0      ← **在一个明显违规的检出上 3/3 全绿**
```

因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。

同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):

```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts  →  const ROOT = '/home/program/agentmail';
其余 10 个                                                          →  const ROOT = join(HERE, '..', '..', '..');
```

已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。

## 二、修这条时又牵出一个:`stripComments` **改变了行号**

修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。

这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。

## 三、新增两条判据,让这两个形状不能再回来

1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
   (`code()` 剥注释,否则本文件自己的说明文字就会误报),找
   `const X = '/绝对路径'` 且**看着像仓库内**的声明。
   **例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
   按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
   **行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
   (别为了保行号把注释留下)。

两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
  (第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
  同样这个变异,在修路径**之前**是绿的。

## 四、未做 / 未验

- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
  ① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
     构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
     `hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
  所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
2026-09-15 12:06:15 +08:00
317f3265e3 fix(判据): 探针三值 + 发布候选标签 —— 顺带查出探针从写下那天起一次都没跑成过
pi 的两条"真实的洞",都落了,而且第一条当场抓到实证。

1. **探针三值**(可用 / 不可用 / 拿不准→红):`RESULT static=5 probe=ok|unknown`
   把"欠账余额"和"探针是否健康"拆成两个数字。
   **换完第一次运行就报 probe=unknown** —— 一查:探针调的是 `execFileSync`,
   而这个文件 import 的是 `spawnSync`,**名字根本没定义**。也就是说
   **探针从写下的那天起一次都没跑成过**,旧的两值设计把 `ReferenceError`
   和"没有设备"一起吞掉、统一报成"设备不可用":机制在、闸门从没开过,
   而它看起来完全健康。这正是 pi 描述的"恒不开闸",只是比预想更彻底。
   现在:命令在但跑不成 → unknown → 红;所有候选都不存在(本机没装 hdc)→ 可判的
   "没有设备工具" → false,避免没装 SDK 的机器天天假红。
   附 `--probe-selftest`(只跑分类器,不跑套件)+ 变异验证(把 unknown 当"不成立"→ 红)。

2. **releaseCandidate = !gitDirty**(从展示升成标签):BUILD_INFO 现在自报
   `releaseCandidate`,发布脚本在脏树时会打印"这个包不是发布候选"。
   判据 `build-stamp` 断言"标签与 gitDirty 必须一致"。
   **实证**:本轮我打的包正是这种情况 —— `gitDirty: true`(含着 gui-lab 未提交的
   NarrowStack/index.css),`releaseCandidate: false`,日志里明确说了"不是发布候选"。

3. 附带:`criteria-hygiene` 加一条"用到 `code/prose/bytes` 就必须真的 import"。
   理由是同一形状我这轮在三个文件里各犯过一次(最后一次是 `execFileSync`/`spawnSync`),
   而它表现为"判据红了"(ReferenceError 抛在判据自己身上),看起来像判据失败、
   不像判据写错。这条至少把最常写错的那几个名字变成明确的红。
2026-09-14 16:35:40 +08:00
d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00