Files
MailUI4Agents/deploy/install.sh
JianFeeeee da5d9339e2 把 PATH 归一化提到文件最前(修我 54d641e 那个"只修一半"的修法),并把 --help 从数行号改成锚定
★ 我上一版(54d641e)修了三条触发带,但**引入了第二个问题**(矩阵实测发现):
     空 PATH / 最小 PATH 下: de1b072 rc=2("找不到 git"+人话,**符合**本仓环境词汇表)
                            in-block rc=**128**("fatal: not in a git directory",**不在**词汇表)
   ⇒ 因为归一化仍在 `REPO=…dirname…`(line 11)**之后** ⇒ dirname 先失败 ⇒ REPO 退化成 /
   ⇒ ★ "修一处、坏一处":我把一处**符合约定的人话诊断**换成了**裸退出码**

★ 正确位置 = **文件最前**(env-defaults.sh:124 自己也注明 ④「**必须排在最前**」):
     4 变体 × 6 种 PATH 矩阵(真 git worktree,工具齐全但 PATH 无 git):
       PATH            old(^de1b072)  de1b072   in-block(54d641e)  final
       (空)            1/-           2/-       128/-             **0/.githooks**
       仅 bash/env     1/-           2/-       128/-             **0/.githooks**
       /opt/tools      0/.githooks   2/-       0/.githooks       **0/.githooks**
       /usr/local/sbin 0/.githooks   2/-       0/.githooks       **0/.githooks**
       /sbin:/usr/sbin 0/.githooks   2/-       0/.githooks       **0/.githooks**
       /usr/bin        0/.githooks   0/.githooks 0/.githooks     **0/.githooks**
   ⇒ final 六行全绿;连跑两次幂等;`command -v git` 真失败时仍 exit 2 + 人话(负控制保留)

★ 顺带修一处**既存**脆弱点(被本次插入暴露,非本次引入):
     `--help` 原为 `sed -n '2,20p'`(**数行号**)⇒ 任何人往文件头插行都会让它开始印**实现代码**
     (原版就已经把 `set -euo pipefail`/`REPO=…` 印出来;插 PATH 块后会多印 5 行 case…esac)
     改为 `usage()` = `sed -n '2,/^set -euo pipefail/p' | sed '$d'`(**锚定**)
     实测: --help 7 行纯注释 ✓(原 19 行含代码);相对/绝对/符号链接三种调用都 rc=0 ✓

★ 验证: bash -n OK;mode=755;mode gate 对 install.sh 零命中
★ 边界: 只改 deploy/install.sh 一个文件;生产未动(仍 09-19 13:04)
2026-09-25 05:43:57 +08:00

785 lines
49 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
# ⚠️⚠️ **PATH 归一化必须排在文件最前**(pi 2026-09-25 实测报回):
# 规则与 `lib/env-defaults.sh` 的 ④ 逐字相同(那里注明"**必须排在最前**",此处提前到 `dirname` 之前)。
# 为什么非在这里不可:下面这行 `REPO=…dirname…` 是**本文件第一条外部命令**,
# PATH 缺 `/usr/bin` 时它先失败 ⇒ `REPO` 退化成 `/` ⇒ 后面即使找到 git 也会
# `fatal: not in a git directory`(退出码 **128**,不在本仓"环境问题=2+人话"的词汇表里)。
# 幂等:已含 `/usr/bin` 时一个字节都不改。
case ":${PATH:-}:" in
*:/usr/bin:*) : ;;
*) PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; export PATH ;;
esac
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 用法块 = 文件头注释。**锚定在 `set -euo pipefail` 上,不数行号** ——
# 原来写 `sed -n '2,20p'`,任何人往文件头插几行都会让 `--help` 开始印**实现代码**
# (本文件 2026-09-25 插 PATH 归一化时就会多印 5 行 `case … esac`)。锚定后插多少行都不漏。
usage() { sed -n '2,/^set -euo pipefail/p' "$0" | sed '$d'; }
# ══════════════════════════════════════════════════════════════════════════════
# ./deploy/install.sh --check # 干跑:只跑所有门禁与前置检查,**不写系统目录**
#
# 为什么要有 --check(pi 2026-09-14):发布路径是**最少被跑的门** ——
# `check-shared-libs.sh` 一直红着没人发现,正是因为它只挂在 install.sh 里。
# 而"最后一个能跑这一步的人"(有 root 的那个人)每次都在**改完一堆东西之后**才第一次跑它,
# 于是第一次失败就发生在最贵的时候。干跑把"门自己跑得动吗"提前暴露出来。
#
# ⚠️ ★★ **参数解析必须在 `source env-defaults.sh` 之前**(pi 2026-09-15 的相位标记要它):
# 那条相位标记(`AGENTMAIL_ENV_DEFER`)是给**source 期间**跑的预检看的,
# 而 `env-defaults.sh` 在 source 时就把预检跑完了 ⇒ 标记必须在 source **之前**就位。
# 我第一版把解析留在原处、只在 source 之后设标记 ⇒ **标记永远没生效**,
# 而输出看不出区别(`--check` 照样一条诊断都不跑)—— 靠实测才抓到。
# ══════════════════════════════════════════════════════════════════════════════
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) usage; exit 0 ;;
*) echo "未知参数:$a(支持 --check / --allow-dirty / --allow-stale / --git-hooks)" >&2; exit 2 ;;
esac
done
if [[ $CHECK_ONLY -eq 1 ]]; then
AGENTMAIL_ENV_DEFER=1
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/<name>` 把**配置解析成真实路径**再查可执行位。
# 只查 `.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 不在本仓的退出码词汇表里。)
#
# ⚠️⚠️ 但只补 ① 不够 —— 前移同时**丢掉了第二条**保证(pi 2026-09-25 实测报回:活回归):
# **env-defaults ④ 的 PATH 归一化**。本条现已在**文件最前**统一补回(见该处注释),
# 故这里不再重复。★ 两条的失败方向相反:① 缺了是**漏**(少一条检查),
# ② 缺了是**误**(git 装在 /usr/bin 却报"找不到 git",凭空假红)。
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
. "$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
# ★ **部署锁**(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"
# ★★ 源文件权限政策(dsh 2026-09-19 接线)。
#
# 为什么单独一条、为什么**必须挂在这里**:这一类**只有它一个判据**,而且
# `git` 根本看不见 —— `git ls-files -s` 只记 **100644 / 100755**(可执行位),
# **组/其他读位不进版本库**。实测:把受跟踪文件 `chmod 600 ↔ 644`,
# `git status` **两次都空**、`git diff` 也不动 ⇒ 没有任何"顺带"的通道会发现它。
#
# 后果不是"本地难看":这些是**源码**,会被构建/测试/部署**以不同身份**读
# (`install.sh` 自己会切身份做可写性判定;`client/electron/test` 里还有
# **以 nobody 降权跑**的判据)。0600 在"跑的人恰好是属主"时不炸,换个身份就是
# EACCES —— 而那串报错**看起来像代码问题**。
#
# ⚠️ 这条判据**写出来很久了却从没被任何东西调用过**(`grep -rn` 全仓:唯一的
# 非注释提及是 `mutants/summary.py` 里一句 `print(...)` 的文案),
# 而它**当时正红着**(3 条:2 个 600 的文件 + `jobs/` 缺 x 位)——
# 正是本仓反复消的那个形状:"**判据在,但走不到**"。
# 它自己那份 `[ -x ]` 恒真的 root 陷阱也修过一版(`b7dc9e9`),修得对,
# 但**没有任何人会跑到那一行**,所以修与不修没有可观察差别。
#
# ⚠️⚠️ 接线时必须走 `--check` 的**累积**通道(`CHECK_GATE_RC`),不能直接调:
# `check-file-modes.sh` 红时 `exit 1`,而本脚本是 `set -e`
# ⇒ 它会在**这里**中止,后面 `build-stamp`/钩子/`--check` 的收尾诊断**一行都不打**。
# 我第一版就是直接调的,**实测**:接线后 `构建 Gateway`/`启用并启动` 等在日志里
# **命中 0 次**(接线前 `构建 Gateway` 命中 2 次)—— 那正是本文件 141-157 行
# 刚修过的同一个毛病("第一道红吃掉后面所有诊断"),我自己又造了一遍。
# ⇒ 照 `npm_rc` 的既有做法:真装路径仍 `set -e` 立刻中止(装机器时不该带着红往下走),
# 干跑路径**记下来、跑完、最后 `exit "${CHECK_GATE_RC:-0}"`**。
if [[ $CHECK_ONLY -eq 1 ]]; then
_fm_rc=0
"$REPO/deploy/check-file-modes.sh" || _fm_rc=$?
if [[ $_fm_rc -ne 0 ]]; then
echo " [FAIL] 权限政策没过(退出码 ${_fm_rc})—— 上面标了 [FAIL] 的那几处就是" >&2
# 只记**第一个**失败码(既有做法):`CHECK_GATE_RC` 是"干跑退出码"的单一来源
[[ -n "${CHECK_GATE_RC:-}" ]] || CHECK_GATE_RC="$_fm_rc"
fi
else
"$REPO/deploy/check-file-modes.sh"
fi
# 插件的纯函数测试(自动转发去重等)。
# 插件不参与构建产物,但它的逆行为会直接变成用户收件箱里的重复邮件,
# 因此也纳入部署前的门禁。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)"
# 逐钩自证:接上 ≠ 存在 ≠ 可执行。
# ★ 用 `git_hook_active` 而不是写死 `$REPO/.githooks/<h>`(pi 2026-09-25 §三 逼出来的):
# 写死路径只证明"**那个文件**在",不证明"**git 会调用它**"——
# 配置指向别处、或 hook 少了可执行位时,前者照样绿。这正是"存在 ≠ 生效"。
if p="$(git_hook_active pre-push)"; then
echo " [ OK ] git 会调用 pre-push($p)—— 拦 AGC 真身进远端"
else
echo " [WARN] git 不会调用 pre-push —— pre-push 形同虚设(核 core.hooksPath 与可执行位)" >&2
fi
if p="$(git_hook_active pre-commit)"; then
echo " [ OK ] git 会调用 pre-commit($p)—— 拦 docs/API.md 围栏奇数/未配对"
else
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
# ── `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 齐备(那道预检是"第四次"的补丁,已被一处取代)。
# ★★ 环境失败**不许冒充代码缺陷**(pi 2026-09-15;规矩本来就在 `lib/env-defaults.sh:25`:
# "失败要说清是环境问题…不要让它冒充代码缺陷")。
#
# 实测过的形状:`go vet ./...` 报
# internal/middleware/auth.go:9:2: open /root/.cache/go-build/55/…-d: permission denied
# —— 它**指着源码文件**说 permission denied,而一行 .go 都没问题;跟着本步原来那句
# "不过 —— 先修好再安装" 就把人送去改代码。pi 用 `go vet ./internal/repo/`(通过)
# vs `go vet ./...`(死在缓存条目)两条命令就分开了 ⇒ 真因是 build 缓存不可用。
#
# 现在**两道**:① `env-defaults.sh` 的 ③c 预检 GOCACHE(可写、探针实测);
# ② 这里按**输出分类**兜住剩下的(预检过不了的在 ① 就 exit 2 了,这里是二手保险)。
# 分类命中 ⇒ 退出码 **2(环境)**,并且**不再说"先修好"**。
GOOUT="$(mktemp -t agentmail-govet-XXXXXX)"
if ! ( cd "$REPO/server" && go vet ./... && go test ./... ) >"$GOOUT" 2>&1; then
cat "$GOOUT" >&2
# ⚠️ 分类必须在 `rm` **之前**读那个文件 —— 先删再 grep 会让这条分类**永远不触发**
# (那正是本仓反复出现的形状:判据存在但永远不会响)。
if grep -qE 'permission denied|cannot find (package|module)|no space left on device|module cache not found|build cache is required|GOCACHE' "$GOOUT"; then
rm -f "$GOOUT"
echo " [FAIL] go vet / go test **跑不起来**(环境问题,不是代码缺陷)" >&2
echo " 上面报错里出现了缓存/权限/空间类的字样 —— 它常常**指着源码文件**报错," >&2
echo " 但一行 .go 都没问题(实测形状:open <GOCACHE>/…-d: permission denied)。" >&2
echo " 药方:GOCACHE=<可写目录> 重跑。按仓库约定这算**环境**(退出码 2)。" >&2
# ★★ 干跑里**不在这里退**(pi 2026-09-15 的相位标记):这一步只 gate 得了 go 那一段,
# 而干跑的产品是诊断 ⇒ 记账、继续,让后面与它无关的门(干的产物自证等)照跑,
# 最后由末尾的 `agentmail_env_flush` 统一按环境 exit 2。
# ⚠️ 我第一版**没改这里**,于是末尾那个 flush **永远走不到**
# (实测:退出码 2,末尾环境报文一行都没有)—— 又一个"判据在,但走不到"。
if [ "${AGENTMAIL_ENV_DEFER:-0}" = "1" ]; then
# 用**同一个 KEY** `gocache`:这条与 ③c 的预检是**同一个根因**
#(缓存不可写 ⇒ go 跑不起来),不该被数成两项。
agentmail_env_defer "gocache" "go vet / go test 跑不起来:缓存/权限/空间问题(见上面原文)。
药方:GOCACHE=<可写目录> 重跑。"
else
exit 2
fi
else
rm -f "$GOOUT"
echo " [FAIL] go vet / go test 不过 —— 先修好再安装" >&2
echo " (本次输出里**没有**缓存/权限/空间类字样 ⇒ 按**代码缺陷**处理。)" >&2
exit 1
fi
fi
rm -f "$GOOUT"
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` 当绿读数 ⇒ 那个读数不可信了。
# 现在只要前面记下了非零码就把那个码传出去。**"诊断跑完了" ≠ "门禁都过了"。**
#
# ★★ 推迟的环境问题在这里统一报(pi 2026-09-15):它们**不是"某道门红了"**,
# 而是"**有一步跑不起来**"。所以优先级最高、退出码 **2(环境)**,
# 而诊断**已经全部跑完**(这正是推迟到末尾的理由:环境预检在开头 exit 2
# 会把与它毫无关系的门一并吃掉 —— 那是本仓修过一次的老毛病,入口换成了环境预检而已)。
# ⚠️ 位置也得对:我第一版把这段放在**前端门禁的 early-return 之后**,
# 于是"环境不足 + 前端门禁红"时它**永远不会执行** —— 又一次"判据在,但走不到"。
# 实测抓到(`--check` 仍退 1、环境报文一行都没有)。
if ! agentmail_env_flush; then
echo ' ⇒ 退出码 2(环境)。**注意这与「某道门红了(1)」是两件事**:环境不足时,"门绿"也装不上。' >&2
exit 2
fi
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 原子替换二进制,
# 并在验证不过时自动回滚。