Files
MailUI4Agents/deploy/install.sh
JianFeeeee 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

545 lines
31 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` 下把这一门的失败**记下来**、继续跑完所有诊断,
# 最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)。
# **真装路径行为完全不变**(仍 `set -e` 立刻中止 —— 装机器时不该带着红往下走)。
if [[ $CHECK_ONLY -eq 1 ]]; then
npm_rc=0
( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) \
|| npm_rc=$?
if [[ $npm_rc -ne 0 ]]; then
echo " [FAIL] 前端门禁(typecheck / 判据 / build)退出码 ${npm_rc} —— 详见上面的红" >&2
CHECK_GATE_RC="$npm_rc"
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 " 注意:这条只说明'到不了',不说明'地址写错了';两者要分开看。"
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
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 原子替换二进制,
# 并在验证不过时自动回滚。