#!/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 for a in "$@"; do case "$a" in --check) CHECK_ONLY=1 ;; --allow-dirty) ALLOW_DIRTY=1 ;; --allow-stale) ALLOW_STALE=1 ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; *) echo "未知参数:$a(支持 --check / --allow-dirty / --allow-stale)" >&2; exit 2 ;; esac done # ★ **部署锁**(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`), # 挂在这里会**恒红**(部署路径不重新打包)—— 门挂错了相位。所以这里显式声明相位。 ( cd "$REPO/client/electron" && npm run typecheck && AGENTMAIL_CRITERIA_PHASE=install npm test && npm run build ) # ── 安装相位真正能判的那一半:**读产物自证**(不是重算 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 # ★ `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)" # 环境不再在这里逐个预检 —— 开头 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" < "$ETC/opencode.env" <&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 原子替换二进制, # 并在验证不过时自动回滚。