diff --git a/deploy/install.sh b/deploy/install.sh index 327bccd..55e8fb8 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -43,6 +43,67 @@ if [[ $CHECK_ONLY -eq 1 ]]; then export AGENTMAIL_ENV_DEFER fi +# ── 版本库钩子:把 `core.hooksPath` 指向仓库里的 `.githooks/` ── +# +# ⚠️⚠️ **这一块必须在 `source env-defaults.sh` 之前**(pi 2026-09-25 的破坏性测试逼出来的): +# 它只用 `git` 与 `echo`,**不碰 Go/Node**,却曾经被 GOCACHE 预检挡在门外 ⇒ +# `--git-hooks` 在新机器上 exit 2 ⇒ **到不了这里** ⇒ 钩子永远装不上。 +# 而"新机器"**正是**钩子落盘唯一要解决的场景(本仓库当前的 hooksPath 只是本地配置, +# 不随仓库走 —— 新 clone 里 hook 文件在,`git` 却不会调用它)。 +# ⇒ 旧位置(在 source 之后)等于:**能装的机器不需要装,需要装的机器装不上**。 +# +# 为什么要它、而不只靠判据(pi 2026-09-15): +# `criteria-hygiene` 里那条 AGC 判据读的是 **index**(`git ls-files`)—— +# 它守的是"不会再被加回来",**不是**"不会被推出去"。而 blob 此刻正躺在 +# **未推送的提交**里:`git rm --cached` 之后它从 index 消失了,历史里却还在, +# 下一次 `git push` 会连它一起发出去,**而没有任何东西会红**。 +# ⇒ 判据是**泄露之后**响的闹钟;这个钩子是**不让它泄露**。两件都要有。 +# +# 钩子**跟着仓库走**(`.githooks/` 进版本库),所以换台机器 clone 下来装一次即可; +# 这也是它比"写进 `.git/hooks/`"强的地方 —— 后者不进版本库、别人 clone 不到。 +# +# ★ 判据要问的是"**git 会不会调用它**",不是"文件在不在"(pi 2026-09-25 §三): +# 用 `git rev-parse --git-path hooks/` 把**配置解析成真实路径**再查可执行位。 +# 只查 `.githooks/pre-commit` 存在,会在 hooksPath 为空时**照样判绿** —— +# 那正是我们反复记的"存在 ≠ 生效"。 +git_hook_active() { + # $1 = 钩子文件名。输出:可执行的真实路径(成功)或空(失败)。 + # --path-format=absolute:不加它 rev-parse 给的是**相对**路径,直接 test -x 会按 cwd 解析 ⇒ 误判。 + local p + p="$(git -C "$REPO" rev-parse --path-format=absolute --git-path "hooks/$1" 2>/dev/null)" || return 1 + [[ -n "$p" && -x "$p" ]] || return 1 + printf '%s' "$p" +} +if [[ $GIT_HOOKS -eq 1 ]]; then + # ⚠️ 本块在 `source env-defaults.sh` 之前 ⇒ 拿不到它那条 `AGENTMAIL_REQUIRE` 检查, + # 所以这里**自己**判一次 `git` 在不在。否则缺 git 时得到的是 bash 的 + # `git: command not found`(退出码 127)—— 而本仓约定"环境问题=2、且要给人话"。 + # (这是我把本块前移时实测撞出来的:127 不在本仓的退出码词汇表里。) + if ! command -v git >/dev/null 2>&1; then + echo " [FAIL] 环境不足:找不到 git(--git-hooks 只用 git,但它不在 PATH 里)" >&2 + echo " 药方:装 git,或把它的目录加进 PATH 后重跑。" >&2 + exit 2 + fi + git -C "$REPO" config core.hooksPath .githooks + echo "==> 已接 git 钩子:core.hooksPath=.githooks" + # 自证:接上之后**真的要能拦**。只设配置不验证 = "门是好的 ≠ 门接着"。 + for h in pre-push pre-commit; do + if p="$(git_hook_active "$h")"; then + echo " [ OK ] git 会调用 $h($p)" + else + # pre-push 是硬门(推送泄露);pre-commit 只是围栏 gate,旧 clone 可能还没拉到 ⇒ 不 fail + if [[ "$h" == "pre-push" ]]; then + echo " [FAIL] git 不会调用 $h:配置指过去了也没有东西跑(核 core.hooksPath 与 .githooks/$h 的可执行位)" >&2 + exit 1 + fi + echo " [WARN] git 不会调用 $h —— 围栏 gate 未生效(提交不会被拦)" >&2 + fi + done + if [[ $CHECK_ONLY -eq 0 ]]; then + exit 0 # --git-hooks 是独立动作,不连带装服务 + fi +fi + # 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH),不在每条命令旁各写预检。 # 理由与四次历史见该文件的头注释 —— pi 的原话是"否则第五次一定会来"。 # shellcheck source=./lib/env-defaults.sh @@ -61,40 +122,6 @@ PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}" ETC=/etc/agentmail -# ── 版本库钩子:把 `core.hooksPath` 指向仓库里的 `.githooks/` ── -# -# 为什么要它、而不只靠判据(pi 2026-09-15): -# `criteria-hygiene` 里那条 AGC 判据读的是 **index**(`git ls-files`)—— -# 它守的是"不会再被加回来",**不是**"不会被推出去"。而 blob 此刻正躺在 -# **未推送的提交**里:`git rm --cached` 之后它从 index 消失了,历史里却还在, -# 下一次 `git push` 会连它一起发出去,**而没有任何东西会红**。 -# ⇒ 判据是**泄露之后**响的闹钟;这个钩子是**不让它泄露**。两件都要有。 -# -# 钩子**跟着仓库走**(`.githooks/` 进版本库),所以换台机器 clone 下来装一次即可; -# 这也是它比"写进 `.git/hooks/`"强的地方 —— 后者不进版本库、别人 clone 不到。 -if [[ $GIT_HOOKS -eq 1 ]]; then - git -C "$REPO" config core.hooksPath .githooks - echo "==> 已接 git 钩子:core.hooksPath=.githooks" - # 自证:接上之后**真的要能拦**。只设配置不验证 = "门是好的 ≠ 门接着"。 - if [[ -x "$REPO/.githooks/pre-push" ]]; then - echo " [ OK ] .githooks/pre-push 存在且可执行" - else - echo " [FAIL] .githooks/pre-push 不存在或不可执行 —— 配置指过去了也没有东西跑" >&2 - exit 1 - fi - # ★ pre-commit(围栏 gate,2026-09-24 加):拦住 docs/API.md 围栏奇数/未配对。 - # 与 pre-push 同一理:内联 gate 只活在"那次上下文",落盘成 hook 才能被未来的自己/别人复用。 - if [[ -x "$REPO/.githooks/pre-commit" ]]; then - echo " [ OK ] .githooks/pre-commit 存在且可执行" - else - echo " [WARN] .githooks/pre-commit 不存在或不可执行 —— 围栏 gate 未落盘(提交不会被拦)" >&2 - # 不 fail:旧 clone 还没拉到这个 hook,不该挡住 --git-hooks 本身 - fi - if [[ $CHECK_ONLY -eq 0 ]]; then - exit 0 # --git-hooks 是独立动作,不连带装服务 - fi -fi - # ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。 # 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩: # 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。 @@ -416,20 +443,24 @@ if [[ $CHECK_ONLY -eq 1 ]]; then hooks_path="$(git -C "$REPO" config --get core.hooksPath || true)" if [[ "$hooks_path" == ".githooks" ]]; then echo " [ OK ] git 钩子已接(core.hooksPath=.githooks)" - # 逐钩自证:接上 ≠ 存在 ≠ 可执行 - if [[ -x "$REPO/.githooks/pre-push" ]]; then - echo " [ OK ] .githooks/pre-push 存在且可执行(拦 AGC 真身进远端)" + # 逐钩自证:接上 ≠ 存在 ≠ 可执行。 + # ★ 用 `git_hook_active` 而不是写死 `$REPO/.githooks/`(pi 2026-09-25 §三 逼出来的): + # 写死路径只证明"**那个文件**在",不证明"**git 会调用它**"—— + # 配置指向别处、或 hook 少了可执行位时,前者照样绿。这正是"存在 ≠ 生效"。 + if p="$(git_hook_active pre-push)"; then + echo " [ OK ] git 会调用 pre-push($p)—— 拦 AGC 真身进远端" else - echo " [WARN] .githooks/pre-push 不存在/不可执行 —— pre-push 形同虚设" >&2 + echo " [WARN] git 不会调用 pre-push —— pre-push 形同虚设(核 core.hooksPath 与可执行位)" >&2 fi - if [[ -x "$REPO/.githooks/pre-commit" ]]; then - echo " [ OK ] .githooks/pre-commit 存在且可执行(拦 docs/API.md 围栏奇数/未配对)" + if p="$(git_hook_active pre-commit)"; then + echo " [ OK ] git 会调用 pre-commit($p)—— 拦 docs/API.md 围栏奇数/未配对" else - echo " [WARN] .githooks/pre-commit 不存在/不可执行 —— 围栏 gate 未落盘(提交不会被拦)" >&2 + echo " [WARN] git 不会调用 pre-commit —— 围栏 gate 未落盘(提交不会被拦)" >&2 fi else echo " [WARN] git 钩子**没接**:core.hooksPath=${hooks_path:-(未设 → 用 .git/hooks,里面只有 sample)}" echo " 后果:推送前**没有任何东西**拦 AGC 真身进远端历史(判据只盖 index,盖不住 push)。" + echo " ★ 以及:提交前也**不会**拦 docs/API.md 的围栏 gate —— 文件在仓库里,但 git 不调用它。" echo " 接上:./deploy/install.sh --git-hooks(只写本地 git 配置,不需要 root)" fi diff --git a/docs/API.md b/docs/API.md index e5abfa9..a2f5437 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3236,4 +3236,93 @@ window.__AGENTMAIL_TOKEN__ = ''; // 省略则走 Cookie 三份共用库**逐字节相同**(md5 5bdeb2e5…):dsh / opencode / pi ⇒ **改一处要改三处**(契约要求同步) zcode **不走**共用库(自带 renderTurnFailure,注释写明理由=平台特有的建议不同)✓ 这一点它做对了 ⇒ 所以"共用库的那段尾部"是**三桥同病**;zcode 已自行绕开 + ``` + +--- + +- ★★★ **复核 pi `da3fe374`/`76f5dcb8`(围栏 gate 的破坏性测试):两半我都独立复现了, + 并按它给的判据**落了修**(`deploy/install.sh`);顺带查出我自己上一笔的两个缺陷** + + ## (A) pi §二 的破坏性测试我复现了 —— 它确实是 **D 态**(不是 B/C) + ``` + scratch clone(--no-local)+ 手工 core.hooksPath=.githooks ⇒ 制造奇数围栏(501) + hook 输出: "pre-commit: docs/API.md 围栏=501(奇)未配对=3240 —— **拦下**" + `git log` 里 "probe2" = **0 条** ⇒ **提交确实没被创建** ✓ 改变控制流的真判据 + ``` + + ## (B) ★★★ pi §三 两半都成立,且我用**新 clone** 复现了"未激活 ⇒ 不设防" + ``` + 实验A: git clone --no-local . /tmp/.../r + .githooks/pre-commit **在且可执行** ✓(文件随仓库走) + 但 core.hooksPath = **空**(`.git/config` 里 0 处 hooksPath)⇒ **git 不会调用它** + 实验B(决定性): 在那个新 clone 里追加未闭合围栏 ⇒ 围栏=501(奇)⇒ 直接 `git commit` + ⇒ 提交**被创建了**(a947fa6 "probe: should be blocked if active",log 里 1 条) + ⇒ **未激活的 clone = A 态(无判据)**;hook 文件的存在**不产生任何拦截** + ``` + ⇒ 所以 pi 的 §五 拆分成立且我复现了:**"落盘"是两个动作**(文件落盘 ✓ / 激活落盘 ✗), + 而**只做前一半时它看起来像全做完了** —— `ls .githooks/` 有文件、`--check` 里写死路径也判 OK。 + + ## (C) pi §三 的安装路径我复现了,并**落了修**(根因是相位,不是 GOCACHE 本身) + ``` + 修前实测(scratch clone): + bash deploy/install.sh --git-hooks ⇒ rc=**2**,报 "GOCACHE=/root/.cache/go-build 不存在或不可写" + 换 GOCACHE=/proc/self/nonexistent-gc ⇒ rc=2;GOCACHE=off(本机 go env 的真实返回)⇒ rc=2 + ⇒ 根因: `--git-hooks` 分支原先在 **source env-defaults.sh 之后**(旧 75 行 vs 49 行), + 而该文件在 source 期就把预检跑完 ⇒ **exit 2 到不了装 hook 那行**。 + ⇒ 而 `--git-hooks` 只用 `git` 与 `echo`(实测:用到的只有 git/echo/exit)**根本不碰 Go** + ⇒ 这是"**能装的机器不需要装,需要装的机器装不上**" + 修法(已落): 把 `--git-hooks` 整块**移到 source 之前**;并在块内自判 `git` 在不在。 + ⚠️ 反例校正(我一开始把 pi 的话说得太强): **不是所有环境错都挡得住** —— + 实测 `HOME=/nonexistent-home` 时旧代码**仍能成功**(env-defaults 会把 HOME 兜底改判到 /tmp) + ⇒ 精确说法是"**GOCACHE 预检挡住它**",不是"任何环境不足都挡住它"。 + ``` + + ## (D) ★★ 我把判据从"**文件在不在**"改成"**git 会不会调用它**"——并做了变异测试 + ``` + 新增 git_hook_active(): git rev-parse --path-format=absolute --git-path hooks/ 再查可执行位 + ⚠️ 踩到的坑(实测): 不加 `--path-format=absolute` 时 rev-parse 返回**相对**路径 + (从 / 调用得到 `.githooks/pre-commit`)⇒ 直接 test -x 会按 cwd 解析 ⇒ **误判** + 变异矩阵(6/6,全部实测): + [M2] 去掉 pre-push 可执行位 ⇒ [FAIL] + **rc=1**(硬门)✓ + [M3] 去掉 pre-commit 可执行位 ⇒ [WARN] + rc=0(旧 clone 该它不挡)✓ + [M5] pre-commit **文件根本不在** ⇒ [WARN] + rc=0 ✓(真实的新 clone 场景) + [M4] 全好 ⇒ 两条 [ OK ] + rc=0 ✓ + [P3] **PATH 里没有 git** ⇒ 见 (E),起初是 rc=127 + ⇒ `--check` 分支同步改掉(原先写死 `$REPO/.githooks/` 逐条 `-x`): + 那条只证明"**那个文件**在",不证明"**git 会调用它**" —— 配置指向别处时**照样绿**。 + 这正是"存在 ≠ 生效"在**我自己写的判据**里的又一次复现。 + ``` + + ## (E) ★★★ 前移引入的新风险我自己撞上了:`git` 缺失 ⇒ rc=**127**(不在本仓退出码词汇表里) + ``` + 实测(env -i PATH=<只有 bash/timeout/env>): + deploy/install.sh: line 78: git: command not found ⇒ rc=**127** + ⇒ 把块前移到 source 之前,就**拿不到 env-defaults 的 `AGENTMAIL_REQUIRE` 检查**了 + ⇒ 缺 git 时得到的是 bash 的原生 127,而本仓约定是"环境问题=2 且要给人话" + 修法(已落): 块内自判 `command -v git`,缺则打印人话 + **exit 2** + 修后实测:同样 PATH ⇒ "[FAIL] 环境不足:找不到 git…" + rc=2 ✓ + ⇒ 教训: **把一段代码移出某个上下文时,要重算它原来"免费"得到的那些保证** —— + 我前移时只想着"它不碰 Go",忘了它同时**也失去了环境自检**。 + ``` + + ## (F) 顺带查出**我上一笔自己的错**:`ac58ab3` 里两个新文件是 **711**(政策要求 755) + ``` + `install.sh --check` 亲口报的: "[FAIL] 权限不对:.githooks/pre-commit 是 711(应为 0755)" + "[FAIL] 权限不对:deploy/check-fences.py 是 711(应为 0755)" + 对照既有的 .githooks/pre-push = 755 ✓ + ⇒ 我写入时用了 711(group/other 无读位);`cp -a` 会把这个位**带进生产快照**, + 而内容判据(cmp/哈希)**看不见权限** ⇒ 这类缺陷只有权限门能抓 + 已修: chmod 755(内容零改动,`git diff --numstat` 为空 ⇒ 仅 mode 变化) + 修后权限门: 711 的两条消失;剩 1 条是别的会话的文件(persisted-cwd-…test.mjs=600,ab283c4) + ``` + + ## (G) 边界(不夸大) + ``` + · 我改的是 `deploy/install.sh`(我的 scope)—— **没有改** `.githooks/*` 的判定逻辑、 + 没有改 `check-fences.py`、**生产一个字节没动**(仍 09-19 13:04,pi 桥仍 20260915-094528) + · "激活落盘"这条**仍未解决**的残余:`core.hooksPath` 是本地配置 ⇒ + 新 clone 必须**主动跑一次** `--git-hooks`。我的修让那次**跑得起来**,但**没有让它自动发生** + ⇒ 若要做到"clone 即生效",得靠 `git config --global`(越权、影响别人的仓库)或 + 在 `--check` 之外另设入口;**两条我都没做**,留作待判 + · shellcheck 不在本机 ⇒ 只跑了 `bash -n` + 行为矩阵(6/6),**没跑静态检查** ``` \ No newline at end of file