Files
MailUI4Agents/deploy/install.sh
JianFeeeee 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 绿。
2026-09-15 13:55:01 +08:00

611 lines
36 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# 把 AgentMail Gateway 与各平台的 mail-bridge 安装为 systemd 服务。
#
# sudo ./deploy/install.sh
#
# 幂等:重复执行等价于「重新构建 + 重启」。已存在的 env 文件不会被覆盖,
# 因为里面有管理员密码与 Agent secret,重装不该把它们冲掉。
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 环境自足:一处给全(HOME/TMPDIR/GOMODCACHE/PATH),不在每条命令旁各写预检。
# 理由与四次历史见该文件的头注释 —— pi 的原话是"否则第五次一定会来"。
# shellcheck source=./lib/env-defaults.sh
. "$REPO/deploy/lib/env-defaults.sh"
# 本脚本依赖的外部命令:缺一个就 exit 2 + 人话(见 env-defaults.sh 的 ③b 一节)。
# 为什么必须显式声明:**"命令不在"与"命令在但输出为空"必须分开** ——
# 下游把前者读成后者时就会产出假绿(journalctl 那两处就是:工具缺失被读成"无 panic")。
AGENTMAIL_REQUIRE="git go npm npx node curl systemctl flock"
agentmail_env_report
# 与 `redeploy-gateway.sh` / `reset-demo.sh` 同源(pi 评审 2026-09-14 对出来的):
# 那两处都写 `${AGENTMAIL_PREFIX:-/opt/agentmail}`,只有这里写死 ——
# 谁设了那个变量,install 装到 A、redeploy 和 drift-check 看 B,三个工具各说各话。
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
ETC=/etc/agentmail
#
# ./deploy/install.sh --check # 干跑:只跑所有门禁与前置检查,**不写系统目录**
#
# 为什么要有 --check(pi 2026-09-14):发布路径是**最少被跑的门** ——
# `check-shared-libs.sh` 一直红着没人发现,正是因为它只挂在 install.sh 里。
# 而"最后一个能跑这一步的人"(有 root 的那个人)每次都在**改完一堆东西之后**才第一次跑它,
# 于是第一次失败就发生在最贵的时候。干跑把"门自己跑得动吗"提前暴露出来。
CHECK_ONLY=0
ALLOW_DIRTY=0
ALLOW_STALE=0
GIT_HOOKS=0
for a in "$@"; do
case "$a" in
--check) CHECK_ONLY=1 ;;
--allow-dirty) ALLOW_DIRTY=1 ;;
--allow-stale) ALLOW_STALE=1 ;;
--git-hooks) GIT_HOOKS=1 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "未知参数:$a(支持 --check / --allow-dirty / --allow-stale / --git-hooks)" >&2; exit 2 ;;
esac
done
# ── 版本库钩子:把 `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
if [[ $CHECK_ONLY -eq 0 ]]; then
exit 0 # --git-hooks 是独立动作,不连带装服务
fi
fi
# ★ **部署锁**(pi 评审 2026-09-14):环境前提里原先缺的第五列 —— **同时性**。
# 这台机器的工作区是多 agent 共用的(docs/DEV-TOOLING.md 自己记过),并发部署会互相踩:
# 两次写同一个目录/文件、两次后置验证互相把对方的"验证不过"当自己的结论、谁回滚不确定。
# 用 flock("检查文件存在"本身有竞态);锁随进程退出自动释放(fd 9 关闭)。
#
# ⚠️ 两个只有在这个脚本里才成立的前提(都是我自己实测撞出来的):
# · **本脚本没有 `bad` 这类输出函数**(它用裸 `echo >&2`)——照抄别的脚本的写法会得到
# `bad: command not found`(退出码 127),把"锁没拿到"报成"脚本坏了"。
# · **`--check` 阶段不建锁**:干跑是只读的、不需要互斥,而它又刻意允许在**没有写权限**时
# 运行(`/opt/agentmail/.deploy.lock: Permission denied` ⇒ 又变成 127)。
# 干跑的价值恰恰是"还没权限的人也能把门跑一遍",所以锁必须在写分支里。
if [[ $CHECK_ONLY -eq 0 ]]; then
_LOCK="$PREFIX/.deploy.lock"
if ! exec 9>"$_LOCK"; then
echo " [FAIL] 环境不足:无法创建部署锁 $_LOCK" >&2
exit 2
fi
if ! flock -n 9; then
echo " [FAIL] 环境不足:另一个部署正在跑($_LOCK 被占用)—— 等它结束再跑" >&2
exit 2
fi
fi
if [[ $CHECK_ONLY -eq 0 ]]; then
# ★ 退出码 **2**(环境/权限),不是 1(判据失败)—— 与另外两个部署脚本、
# 以及 env-defaults.sh 里"环境不足"的口径统一。原来这里是 1:
# "没有权限"与"门禁没过"在退出码上不可区分,调用者没法据此决定"该重跑还是该修代码"。
[[ $EUID -eq 0 ]] || { echo " [FAIL] 环境不足:需要 root:sudo $0(只验证不安装用 ./deploy/install.sh --check)" >&2; exit 2; }
else
echo "==> 干跑模式(--check):不会写 $PREFIX 或 $ETC"
# 写权限是这一步唯一的硬门槛,先说清楚 —— 下一个拿到权限的人要一眼看到需要什么
for d in "$PREFIX" "$ETC"; do
if [[ -e "$d" && ! -w "$d" ]]; then
echo " 注意:$d 存在但当前用户不可写(正式安装需要 root/sudo 或先放行该目录)"
fi
done
fi
echo "==> 构建前端"
# 干跑不装依赖(装依赖要写 node_modules/缓存,干跑的意义是"门能不能跑",不是"装得上装不上")。
if [[ $CHECK_ONLY -eq 1 ]]; then
[[ -d "$REPO/client/electron/node_modules" ]] || {
echo " 干跑需要一个已装好依赖的工作树(client/electron/node_modules 不存在)" >&2; exit 1; }
echo " 跳过 npm ci/install(干跑不写依赖);用的是当前 node_modules"
else
( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund )
fi
# 相位(pi 2026-09-14 裁定):部署门禁问的是"**别人已经产出的产物**能不能装到这台机器上",
# 不是"产物是不是从当前源码新鲜构建的"。后者属于构建相位(`packaging`/`build-stamp`),
# 挂在这里会**恒红**(部署路径不重新打包)—— 门挂错了相位。所以这里显式声明相位。
#
# ★★ `--check` 模式下**不因这一门失败就中止**(由 pi 2026-09-15 的发现引出):
# 本脚本是 `set -e` 的,而这一行是**第一个会红的门禁** ⇒ 它一红,脚本立刻退出,
# **后面所有诊断一行都不打**。实测(探针插在下面 264 行那块之前,命中 **0 次**):
#
# $ bash deploy/install.sh --check # 套件红(3 条不是我的)
# …(判据汇总打完就结束)——**264 行之后整块从未执行**
#
# 受害者不只是我新加的那段,**包括既有的版本库钩子检查** ——
# 即 `install.sh --check` 的 `[ OK ] git 钩子已接` / `[WARN] git 钩子没接`
# **只在判据全绿时才会出现**。⇒ **一个只在"没出问题"时才说话的诊断**,
# 与"读数器替一件事作证"同族。
# (pi 上一轮核过 `--check` 确实会报钩子 —— 那次套件是绿的,报的是真的;
# 但**这个机制比它看起来脆**:它依赖"门禁恰好全过"。)
#
# 修法:`--check` 下把这一门的失败**记下来**、继续跑完所有诊断,
# 最后用记下的码 `exit` 出去(**不是在文件末尾 —— 见下面"两个洞"**)。
# **真装路径行为完全不变**(仍 `set -e` 立刻中止 —— 装机器时不该带着红往下走)。
#
# ★★ 这一版修的是 pi 2026-09-15 抓到的**两个反向的洞**(我上一版自己造的):
#
# ① **`CHECK_GATE_RC` 只被打印、从来没被 `exit` 用**。
# 我上一版的注释写着"见文件末尾的 `CHECK_GATE_RC`",而**文件末尾根本没有那一行**
# (文件末尾是正式安装路径的 systemctl/curl,干跑走不到)—— 我把它从末尾挪走时,
# **挪了打印、丢了退出**。后果是**我上一个 commit 修的病反过来**:
# 修之前:第一道红中止一切,但命令**失败**;
# 修之后:诊断全跑完,命令**通过**(干跑分支无条件 `exit 0`)。
# 而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的
# `exit 0` 当绿读数。**"说法(注释)与实现不一致"第三次出现在我手上。**
# ⇒ 现在干跑分支的终点是 `exit "${CHECK_GATE_RC:-0}"`。
#
# ② **"干跑"并不只读:它会重写 `client/electron/dist`。**
# 同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
# 两个后果:
# · **"干跑"改共享状态**(而它自己那句"干跑没有执行"的单子只列了**工作区外**的路径,
# `dist` 悄悄在射程内);
# · **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的
# `gitRev`/`srcHash` 与当前源码,而它的红**恰恰就是"产物是在旧提交上构建的"**。
# 跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有人决定过"重构建"**。
# ⇒ 干跑改成:`gen:bg`(幂等生成,本来就在 build 里)+ **`vite build --outDir <临时目录>`**
# + `build-info.mjs`(`BUILD_INFO_OUT` 指到那个临时目录)。
# **真装的 build 一字不改**(仍然写真正的 `dist`)。
# ⚠️ 残余:`gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。
# 实测它内容幂等(`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在,
# 所以"干跑只读"这句话**准确的说法是"不改任何被跟踪文件的内容、不碰 dist"**,
# 不是"一个字节都不写"。我看不出的部分不写死。
#
# ★ 而 pi 2026-09-15 指出:上面那句"不改任何被跟踪文件的内容"**当时是假的** ——
# `npm run gen:bg` 会**原地重写** `src/background-takeover.generated.css`(**已跟踪**)。
# 他说得更准的一点是:**"幂等"只在一个方向上被保证** —— 守它的判据只判
# "源码里用到的类必须都在生成文件里",**反向(生成物里多出来的过期规则)没有判据**。
# 于是:有人删掉最后一处用法、忘了跑生成器 ⇒ 生成物留下一条过期规则 ⇒ 那条判据**仍绿**
# ⇒ 干跑一跑 `gen:bg` **原地删掉它** ⇒ 一个自称干跑的命令改了被跟踪文件,
# 还**擦掉了"有人忘了跑生成器"的证据**。(与 `dist` 那条形状完全相同。)
# ⇒ 现在:干跑**不再原地生成**,而是生成到临时路径,并**把漂移报出来**(不替人修 ——
# 干跑去修就等于把证据擦掉);判据那一侧已在 `background.test.mjs` 补了**双向**的
# "生成物与源码逐字节一致"。
if [[ $CHECK_ONLY -eq 1 ]]; then
npm_rc=0
# 产物写到临时目录:干跑不该动 dist(否则会静默刷掉 build-stamp 的红)
CHECK_DIST="$(mktemp -d -t agentmail-check-dist-XXXXXX)"
(
cd "$REPO/client/electron" \
&& npm run typecheck \
&& AGENTMAIL_CRITERIA_PHASE=install npm test \
&& GEN_BG_OUT="$CHECK_DIST/generated.css" node scripts/gen-background-takeover.mjs \
&& npx vite build --outDir "$CHECK_DIST" --emptyOutDir \
&& BUILD_INFO_OUT="$CHECK_DIST/BUILD_INFO.json" node scripts/build-info.mjs
) || npm_rc=$?
# ★ 生成物与真身不一致 ⇒ **报告,不修**(干跑替人修就把"有人忘了跑生成器"擦掉了)
if [[ -f "$CHECK_DIST/generated.css" ]] \
&& ! cmp -s "$CHECK_DIST/generated.css" "$REPO/client/electron/src/background-takeover.generated.css"; then
echo " [WARN] background-takeover.generated.css 与**当前源码**不一致(干跑**没有**替你改):"
echo " 修法:cd client/electron && npm run gen:bg,然后**把改动提交**。"
echo " (判据 test/background.test.mjs 会就这件事判红 —— 干跑这里只提示,不替你做。)"
fi
rm -rf "$CHECK_DIST"
if [[ $npm_rc -ne 0 ]]; then
echo " [FAIL] 前端门禁(typecheck / 判据 / build)退出码 ${npm_rc} —— 详见上面的红" >&2
CHECK_GATE_RC="$npm_rc"
else
echo " [ OK ] 前端门禁通过(构建产物与生成物都落在临时目录,**没有动 dist、也没动被跟踪的生成物**)"
fi
else
( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build )
fi
# ── 安装相位真正能判的那一半:**读产物自证**(不是重算 dist)──
# 同一个不变量("这份产物是谁")在两个相位要两种判据:构建相位重新计算,安装相位读它自己说的。
if [[ -f "$REPO/client/electron/dist/BUILD_INFO.json" ]]; then
echo "==> 产物自证(安装相位)"
read -r A_REV A_DIRTY A_RC < <(node -e '
const d = require("'"$REPO"'/client/electron/dist/BUILD_INFO.json");
console.log([d.gitRev ?? "?", d.gitDirty ? "dirty" : "clean", d.releaseCandidate === undefined ? "?" : String(d.releaseCandidate)].join(" "));
')
# git 命令在 PATH 里已由 env-defaults.sh 的 AGENTMAIL_REQUIRE 兜住(缺了提前 exit 2);
# 但**另一种失败**与它无关:仓库根不是 git 仓库(从 tarball 解出来部署的机器)。
# 原写法 `HEAD_REV="$(git … rev-parse --short HEAD)"` 在本脚本的 `set -e` 下,
# 失败会**直接中止**(退出码 127,无任何翻译);就算侥幸往下走,
# HEAD_REV 为空会让下面那句比较报出"这个包比源码旧:产物 gitRev=… ≠ HEAD="
# —— **把"这里不是 git 仓库"说成"产物过期"**,又是一个把环境问题说成代码问题的形状。
if HEAD_REV="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null)"; then
:
else
HEAD_REV=""
echo " ⚠ 读不到当前 HEAD($REPO 不是 git 仓库,或 git 不可用)—— 跳过产物↔源码的新旧比对" >&2
fi
echo " 产物:gitRev=$A_REV 树=$A_DIRTY releaseCandidate=$A_RC | 当前 HEAD=$HEAD_REV"
refuse=0
if [[ "$A_RC" == "false" ]]; then
echo " ✗ 这个包**不是发布候选**(构建时工作树是脏的)—— 它可能含着别人未提交的半成品。" >&2
refuse=1
fi
if [[ "$A_REV" != "$HEAD_REV" ]]; then
echo " ✗ 这个包比源码旧:产物 gitRev=$A_REV ≠ HEAD=$HEAD_REV。" >&2
refuse=1
fi
if [[ $refuse -eq 1 ]]; then
# 原先这行末尾挂着一个 `|| true` ⇒ 整行退出码恒 0 ⇒ 它作为 `if` 的条件**永远为真**
# ("判据的形式在、区分力不在")。它本意是"已经报过错,这里只是决定要不要继续",
# 但写成条件表达式就把结论定死了。改成显式计算,去掉 `|| true`。
_ok=0
[[ $A_RC == "false" && $ALLOW_DIRTY -eq 1 ]] && _ok=1
[[ $A_REV != "$HEAD_REV" && $ALLOW_STALE -eq 1 ]] && _ok=1
unset _ok
if [[ $CHECK_ONLY -eq 1 ]]; then
echo " (干跑:正式安装会被拒绝。要放行就显式说清:--allow-dirty / --allow-stale)" >&2
elif { [[ "$A_RC" != "false" || $ALLOW_DIRTY -eq 1 ]] && [[ "$A_REV" == "$HEAD_REV" || $ALLOW_STALE -eq 1 ]]; }; then
echo " (你显式放行了:--allow-dirty/--allow-stale —— 风险由你承担)" >&2
else
echo " 拒绝安装。放行要显式:sudo $0 --allow-dirty --allow-stale" >&2
exit 1
fi
fi
else
echo "==> 产物自证:dist/BUILD_INFO.json 不存在(还没构建过)—— 本相位只能报"不知道",不能报"没问题"" >&2
fi
echo "==> 校验插件共用模块同源"
# lib/ 下的纯函数模块在三个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
# 一侧改了另一侧没改,几个平台的行为就会悄悄分叉。
"$REPO/deploy/check-shared-libs.sh"
# 插件的纯函数测试(自动转发去重等)。
# 插件不参与构建产物,但它的逆行为会直接变成用户收件箱里的重复邮件,
# 因此也纳入部署前的门禁。zod 已在 node_modules 里就不重装。
if [[ -d "$REPO/plugins/opencode-mail-bridge/node_modules/zod" ]]; then
( cd "$REPO/plugins/opencode-mail-bridge" && npm test )
else
( cd "$REPO/plugins/opencode-mail-bridge" && npm install --no-audit --no-fund && npm test )
fi
# DSH 插件:先构建再跑测试。
#
# 它是 TypeScript 写的,package.json 的 main 指向 dist/index.js,而 dist/ 不进版本库
# (与 client/electron/dist 同理)—— 新克隆里不先 tsc,DSH 加载插件时会直接找不到入口。
# 测试本身只碰 lib/ 下的纯函数(不依赖 dist),但先构建能把类型错误也当成门禁。
#
# 盖住的三类约定都是「错了不当场报错、只在深处炸一个无关错误」:
# followup 消息形状、工作目录解析、会话快照的 subagent 过滤。
if [[ -d "$REPO/plugins/dsh-mail-bridge/node_modules/typescript" ]]; then
( cd "$REPO/plugins/dsh-mail-bridge" && npx tsc && npm test )
else
( cd "$REPO/plugins/dsh-mail-bridge" \
&& npm install --no-audit --no-fund && npx tsc && npm test )
fi
# pi 插件:纯 ESM,无构建步骤,唯一的外部依赖是全局装的 pi SDK。
#
# 它不 npm install:@earendil-works/pi-coding-agent 是全局包(peerDependency),
# 装在项目里会得到第二份 SDK,两份各自维护 ~/.pi/agent 的会话索引缓存。
# 因此这里只软链一次;SDK 不在时跳过测试(turn/naming 是纯函数,
# 但 lib 的测试也一起跑,没必要为缺 SDK 的机器留半套门禁)。
PI_SDK=/usr/lib/node_modules/@earendil-works/pi-coding-agent
if [[ -d "$PI_SDK" ]]; then
install -d "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works"
ln -sfn "$PI_SDK" "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works/pi-coding-agent"
( cd "$REPO/plugins/pi-mail-bridge" && npm test )
else
echo " 未找到 pi SDK($PI_SDK),跳过 pi 插件测试"
fi
# pi 邮件工具扩展:给**交互式**会话装上 send_mail / read_inbox。
#
# 守护进程(pi-mail-bridge.service)用 noExtensions:true 起会话,它的邮件工具
# 只给模型在邮件会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己
# 收不到邮件,只能绕到 curl + 密钥直连 Gateway。这个扩展补上那一侧 ——
# TUI 与邮箱是同一条 AgentMail 身份(agent pi)的两个入口。
#
# 为什么不会重复注册:noExtensions:true 只加载 CLI `-e` 传的扩展,
# settings.json 的 extensions 数组会被排除(见 SDK resource-loader.js),
# 因此守护进程的 worker 里零扩展、本扩展只作用于交互式会话。
if [[ -d "$PI_SDK" ]]; then
# `set -u` 下 `$HOME` 未设会直接杀掉部署(实测:`HOME: unbound variable`,
# 而它发生在所有门禁跑完之后 —— 最贵的位置)。cron / env -i / 某些 sudo 配置下
# HOME 就是没有的,所以给一个兜底。同一形状我这轮在 homeagent 的 build.sh 里
# 也修过一次(cron 里 HOME 未设 → Go 拒绝工作)——**这是这轮第三次**,
# 说明"环境变量在部署路径上不存在"这条要当成常态而不是意外。
PI_HOME="${SUDO_USER:+$(getent passwd "$SUDO_USER" | cut -d: -f6)}"
PI_HOME="${PI_HOME:-${HOME:-/root}}"
PI_SETTINGS="$PI_HOME/.pi/agent/settings.json"
EXT_PATH="$REPO/plugins/pi-mail-bridge/extension/index.ts"
if [[ $CHECK_ONLY -eq 1 ]]; then
echo " [干跑] 跳过:把邮件工具扩展注册进 $PI_SETTINGS(要写工作区外的 home 目录)"
elif [[ -d "$PI_HOME/.pi/agent" || -f "$PI_SETTINGS" ]]; then
install -d "$(dirname "$PI_SETTINGS")"
node -e '
const fs = require("fs");
const [path, target] = process.argv.slice(1);
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch { /* 新建 */ }
const list = Array.isArray(cfg.extensions) ? cfg.extensions : [];
if (list.includes(target)) { console.log(" 已注册,幂等跳过"); process.exit(0); }
list.push(target);
cfg.extensions = list;
fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
console.log(" 已注册 " + target);
' "$PI_SETTINGS" "$EXT_PATH"
else
echo " 未找到 $PI_HOME/.pi/agent,跳过(手动在 settings.json 加 extensions: [\"$EXT_PATH\"])"
fi
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
# ★ 版本库钩子装上了吗 —— **门自己的接线**也要验。
#
# 为什么放在这一相位(pi 2026-09-15 指出"判据只盖索引、盖不住 push"):
# `criteria-hygiene` 里那条 AGC 判据读的是 **index**,它只证明"不会再被加回来";
# 而 blob 还躺在**未推送的提交**里,下一次 `git push` 会连它一起发出去。
# 拦得住这一步的只有 `.githooks/pre-push` —— 而**没装上的钩子等于没有钩子**。
# 这与 `--check` 存在的理由是同一条:**门是好的 ≠ 门接着**。
hooks_path="$(git -C "$REPO" config --get core.hooksPath || true)"
if [[ "$hooks_path" == ".githooks" ]]; then
echo " [ OK ] git 钩子已接(core.hooksPath=.githooks,pre-push 会拦 AGC 真身)"
else
echo " [WARN] git 钩子**没接**:core.hooksPath=${hooks_path:-(未设 → 用 .git/hooks,里面只有 sample)}"
echo " 后果:推送前**没有任何东西**拦 AGC 真身进远端历史(判据只盖 index,盖不住 push)。"
echo " 接上:./deploy/install.sh --git-hooks(只写本地 git 配置,不需要 root)"
fi
# ── `origin` URL:**打印出来**(不判具体值)────────────────────────────────
#
# ★ 为什么只报不判(pi 2026-09-15):`.git/config` 是**共享、无版本控制**的状态,
# 而 `origin` 的 **URL 字符串没有可对的外部真值** —— 外部只能证明它的**效果**
# (能到达同一个仓、tip 相同),**证明不了它的值**。
# 我自己就栽过:把 `origin` 改坏后"对着远端真值恢复",`ls-remote` 只能告诉我
# "这个 URL 能到那个仓",于是我把 `http://192.168.2.106:3000/...`(原来的值)
# 换成了 `https://gitea.jianfgit.xyz/...` —— **同仓、不同值**,而我还说"已恢复"。
# ⇒ "对着远端真值恢复 URL"是个**类别错误**(这句是 pi 的原话,我认)。
#
# ★ 所以这里做两件**能做**的事:
# 1) 把**当前值打出来** —— 下次再有人改它,至少有一处"改前/改后"能被看见;
# 2) **判它可达**(`ls-remote` 能拿到 refs)—— 这才是真有外部真值的那部分性质。
# 不可达时判 WARN(不是 FAIL):局域网 URL 在离线/换网时本来就不可达,
# 而 `--check` 不该因为网络问题红。
#
# ⚠️ 我**没有**断言它等于某个常量:本仓并没有登记"规范 URL",
# 而编一个常量去比对 = **猜数字的判据**(真值无处可得,写死就成了我的记忆)。
# 要强判,先把规范值登记进版本库(`docs/` 或本文件顶部),再断言。
origin_url="$(git -C "$REPO" remote get-url origin 2>/dev/null || true)"
if [[ -z "$origin_url" ]]; then
echo " [WARN] 远端 \`origin\` **没有配** —— 推送/取远端都无从谈起"
else
echo " [ OK ] 远端 origin = ${origin_url}(下面是*效果*检查,值本身无外部真值可比)"
# ★ 必须**限时**:实测把 origin 指向一个不可达地址(`http://192.0.2.1:3999/…`)时,
# `git ls-remote` **挂住不动**(60s 都没返回)—— 而"干跑卡死"比"干跑报 WARN"坏得多
# (卡死的门会被当成"这脚本跑不了"而整个跳过)。所以给 15s 上限,
# 超时 = 不可达 = WARN(本脚本对网络问题的既定态度:WARN,不 FAIL)。
if timeout 15 git -C "$REPO" ls-remote --exit-code origin >/dev/null 2>&1; then
echo " [ OK ] origin 可达(\`git ls-remote origin\` 能拿到 refs)"
else
echo " [WARN] origin **不可达**(或 15s 内没应答)—— 本机的推送/取远端会失败"
echo " 注意:这条只说明'到不了',不说明'地址写错了';两者要分开看。"
echo " ★ 这里判 WARN、而 AGC 闹钟(criteria-hygiene 第 6 条)对'远端不可达'判**红** ——"
echo " 两处政策相反是**有意的**,别以'一致性'为名统一掉(统一到哪边都会弄坏一半):"
echo " · 这里问的是'本机配置能不能用' ⇒ 离线不是配置错 ⇒ **WARN**;"
echo " · 那边问的是'凭证有没有进过远端历史' ⇒ 查不了就**答不出** ⇒ **红**"
echo " ('看不到'不等于'干净')。"
echo " 理由原文见 client/electron/test/criteria-hygiene.test.mjs 该条注释。"
fi
fi
# ★ `go vet`/`go test` 提到这个相位来跑(pi 评审 2026-09-14)。
#
# 原先的措辞是"**所有会红的门禁都跑过了**(前端 typecheck/test/build、共用模块同源、
# 各插件测试、插件构建)",而紧接着的一段就把 `go vet + go test` 列进"干跑没有执行"
# —— **那两条正是会红的门禁**,两段话自相矛盾,而读者只会读那句加粗的结论。
# 更实际的理由:这两道门恰恰最容易在**别人的机器上**红(Go 版本、依赖、平台),
# 而"第一个拿到 root 的人第一次跑门禁"正是 `--check` 要解决的场景。
# 它们不写系统目录(只写 go 缓存),所以放进这个相位没有副作用。
echo "==> 校验 Gateway 源码(go vet + go test)"
# ★★ 先把"前面哪道门红了"传出去,**再**跑 go vet —— 位置是实测定下来的,不是随手放的:
# 我第一版把这段放在文件末尾,实测 go vet 因 go 缓存权限红 ⇒ `set -e` 直接中止
# ⇒ **那段话永远走不到**。放在 go vet **之前**,无论后面谁红它都报得出来。
# (**一个只在"一切顺利"时才报的总结 = 只在没出问题时才说话的诊断**,同一个病。)
if [[ -n "${CHECK_GATE_RC:-}" ]]; then
echo " [FAIL] 前端门禁没过(退出码 ${CHECK_GATE_RC})—— 上面标了 [FAIL] 的那一处就是" >&2
echo " ⇒ 但**诊断已经全部跑完**(本次改动要的正是这个:别让第一道红吃掉后面的诊断)。" >&2
fi
# 环境不再在这里逐个预检 —— 开头 source 的 env-defaults.sh 已经保证
# HOME/TMPDIR/GOMODCACHE/PATH 齐备(那道预检是"第四次"的补丁,已被一处取代)。
if ! ( cd "$REPO/server" && go vet ./... && go test ./... ); then
echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2
exit 1
fi
echo " [ OK ] go vet + go test 通过"
cat <<'EOF'
==> 干跑结束:**除了"构建 Gateway 二进制"之外,所有会红的门禁都跑过了**
(前端 typecheck/test/build、共用模块同源、各插件测试、插件构建、
**go vet + go test**)。
干跑的成功只能说明"门是好的",不能说明"装得上"。
下面这些步骤干跑**没有执行**(它们要写工作区外的目录,正式安装需要 root 或先放行):
· 前端产物 → server/internal/static/static/
· 构建 Gateway(go build -trimpath -o server/agentmail-gateway;vet/test 上面已跑)
· 生成 /etc/agentmail/*.env(已存在的不覆盖)+ chmod 0600
· 装 systemd unit → /etc/systemd/system/,daemon-reload + enable --now
· 把邮件工具扩展注册进 ~/.pi/agent/settings.json
拿不准就先跑 ./deploy/install.sh --check;正式安装用 sudo ./deploy/install.sh。
EOF
# ★ **无条件 `exit 0` 是上一个版本的洞**(pi 2026-09-15):干跑在门禁红时也报成功,
# 而这条链上引用过 `--check` 的 `exit 0` 当绿读数 ⇒ 那个读数不可信了。
# 现在只要前面记下了非零码就把那个码传出去。**"诊断跑完了" ≠ "门禁都过了"。**
exit "${CHECK_GATE_RC:-0}"
fi
echo "==> 前端产物嵌入 Gateway"
# 只清构建产物,不能 rm -rf 整个目录:
# placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库里带走了。
rm -rf "$REPO/server/internal/static/static/assets"
rm -f "$REPO/server/internal/static/static/index.html"
cp -r "$REPO/client/electron/dist/." "$REPO/server/internal/static/static/"
echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)"
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
# `-trimpath` 与 redeploy-gateway.sh 同源:不带它时 Go 会把源文件绝对路径编进
# 生产二进制(2026-09-14 实测 57 处 /home/program/agentmail/…)。
rm -f "$REPO/server/agentmail-gateway"
( cd "$REPO/server" && go vet ./... && go test ./... && go build -trimpath -o "$REPO/server/agentmail-gateway" ./cmd/server )
echo "==> 安装到 $PREFIX"
# ★ 权限位**显式给**,不靠 umask(pi 评审 2026-09-14 提的低优先项,实测是真问题):
# `install -d` 的权限受进程 umask 影响,而下面这些目录里装着**邮件数据库**。
# 实测本机生产:`/opt/agentmail/data` = **755**、`agentmail.db` = **644**(全局可读),
# 而同一段代码里 `agent-config`/`pi-config` 是显式 `-m 0700` —— 同一个脚本里两套口径。
# 数据库里是全部往来邮件(含正文与地址),不该全局可读。
# 目录 0700 是真正的保护(库文件权限由创建它的进程决定);
# `/etc/agentmail` 里是密码与密钥,同样收到 0700。
install -d -m 0755 "$PREFIX"
install -d -m 0700 "$PREFIX/data"
install -d -m 0700 "$ETC"
install -m 0755 "$REPO/server/agentmail-gateway" "$PREFIX/agentmail-gateway"
# ---- env 文件:仅在缺失时生成,密码随机 ----
if [[ ! -f "$ETC/gateway.env" ]]; then
ADMIN_PASS="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
cat > "$ETC/gateway.env" <<EOF
# AgentMail Gateway 环境变量
ADMIN_USER=admin
ADMIN_PASSWORD=$ADMIN_PASS
# 生产环境走 HTTPS 时置 true,Cookie 才会带 Secure 标记
SECURE_COOKIE=false
# 允许的前端跨域来源(逗号分隔);单二进制自带前端时通常无需配置
# CORS_ORIGINS=https://mail.example.com
EOF
chmod 0600 "$ETC/gateway.env"
echo " 已生成 $ETC/gateway.env(管理员 admin / $ADMIN_PASS)"
else
echo " $ETC/gateway.env 已存在,保留不动"
fi
if [[ ! -f "$ETC/opencode.env" ]]; then
cat > "$ETC/opencode.env" <<EOF
# opencode mail-bridge 插件配置
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
AGENTMAIL_AGENT_NAME=opencode
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
# 拿着它到 Web 后台「Agent 密钥」登记即可接入(journalctl -u opencode-serve | grep mail-bridge)。
# 也可以先在后台签发密钥,再把它填在这里。
AGENTMAIL_AGENT_KEY=
AGENTMAIL_CONFIG_DIR="$PREFIX/agent-config"
# 处理来信时使用的模型
AGENTMAIL_REPLY_PROVIDER=llmsproxy
AGENTMAIL_REPLY_MODEL=AUTO
# opencode serve 绑在 127.0.0.1,但同机任何进程都能调它开会话,
# 因此仍然设置访问密码。
OPENCODE_SERVER_PASSWORD=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
EOF
chmod 0600 "$ETC/opencode.env"
install -d -m 0700 "$PREFIX/agent-config"
echo " 已生成 $ETC/opencode.env(server password 为随机值;接入密钥首启时本地生成)"
else
echo " $ETC/opencode.env 已存在,保留不动"
fi
if [[ ! -f "$ETC/pi.env" ]]; then
cat > "$ETC/pi.env" <<EOF
# pi mail-bridge 插件配置
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
AGENTMAIL_AGENT_NAME=pi
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
# 拿着它到 Web 后台「Agent 密钥」登记即可接入(journalctl -u pi-mail-bridge | grep 密钥)。
AGENTMAIL_AGENT_KEY=
AGENTMAIL_CONFIG_DIR="$PREFIX/pi-config"
# 处理来信时首选的模型。留空表示用 pi 自己的默认(~/.pi/agent/settings.json)。
# 管理员在后台划定模型范围时,范围优先于这里的设置(B-2.2)。
AGENTMAIL_REPLY_PROVIDER=llmsproxy
AGENTMAIL_REPLY_MODEL=AUTO
# 单轮上限。超时**不算失败**:pi 仍在后台跑,转发挂在 agent_end 上,
# 说完了自然会转出去。这个值只决定「多久之后不再阻塞投递流水线」。
AGENTMAIL_TURN_TIMEOUT_MS=60000
EOF
chmod 0600 "$ETC/pi.env"
install -d -m 0700 "$PREFIX/pi-config"
echo " 已生成 $ETC/pi.env(接入密钥首启时本地生成)"
else
echo " $ETC/pi.env 已存在,保留不动"
fi
echo "==> 安装 systemd 单元"
# 单元与 drop-in 的**唯一真相**在 deploy/systemd/(镜像 systemd 的目录结构)。
#
# 以前这里逐个 install 三个 .service,而 drop-in(失败通知/重启退避等)只存在于
# /etc 里 —— 于是"仓库里的是旧的、机器上的是新的",谁跑一次本脚本就把部署退回
# 源码目录(旧版 ExecStart 指向 /home/program/agentmail/plugins/...)。
install -d "$PREFIX/bin"
install -m 0755 "$REPO/deploy/service-failure-notify.mjs" "$PREFIX/bin/service-failure-notify.mjs"
# 边界工具(Landlock 沙箱):只有本机能真建立边界时才装。理由与判据见
# redeploy-gateway.sh 同一段(am-sandbox 自己 fail closed,装个用不了的工具
# 等于给"档位=workspace"发空头支票)。
SANDBOX_STAGE="$(mktemp -d)/am-sandbox"
if ( cd "$REPO/server" && go build -trimpath -o "$SANDBOX_STAGE" ./cmd/am-sandbox ) \
&& AM_SANDBOX="$SANDBOX_STAGE" bash "$REPO/deploy/check-sandbox.sh" >/tmp/am-sandbox-check.log 2>&1; then
install -m 0755 "$SANDBOX_STAGE" "$PREFIX/bin/am-sandbox"
echo " 边界工具已装(判据 $(grep -c '\[ OK \]' /tmp/am-sandbox-check.log) 项全绿)"
else
echo " [警告] 本机建立不起 Landlock 边界:不装 am-sandbox(见 /tmp/am-sandbox-check.log)" >&2
fi
rm -f "$SANDBOX_STAGE"
while IFS= read -r f; do
rel="${f#"$REPO/deploy/systemd/"}"
install -D -m 0644 "$f" "/etc/systemd/system/$rel"
done < <(find "$REPO/deploy/systemd" -type f)
systemctl daemon-reload
echo "==> 启用并启动"
systemctl enable --now agentmail-gateway.service
if command -v opencode >/dev/null 2>&1; then
systemctl enable --now opencode-serve.service
else
echo " 未找到 opencode,跳过 opencode-serve(装好后执行:systemctl enable --now opencode-serve)"
fi
if [[ -d "$PI_SDK" ]]; then
systemctl enable --now pi-mail-bridge.service
else
echo " 未找到 pi SDK,跳过 pi-mail-bridge(装好后执行:systemctl enable --now pi-mail-bridge)"
fi
sleep 3
echo
echo "==> 状态"
systemctl --no-pager --lines=0 status agentmail-gateway.service || true
curl -sf -m 5 http://127.0.0.1:8180/health && echo " 健康检查通过" || echo " 健康检查失败,查看:journalctl -u agentmail-gateway -n 50"
# ─────────────────────────────────────────────────────────────────
# 日常只改后端时不需要重跑本脚本(它会重装 npm 依赖、重写 systemd 单元、
# 重新生成 env)。换二进制走:
#
# bash deploy/redeploy-gateway.sh # 含测试与后置验证
# bash deploy/redeploy-gateway.sh --dry-run # 先看要做什么
#
# 那个脚本用 sqlite3 .backup 备份数据库、install -m 0755 原子替换二进制,
# 并在验证不过时自动回滚。