Files
MailUI4Agents/deploy/install.sh
JianFeeeee 1f48c5c4ff feat(sandbox): am-sandbox —— 给命令套 Landlock 内核边界(界内可写、界外 EACCES)
「工作区档」此前名不副实:档位表写「本目录内可动、越界要问人」,而 pi 这一路只能按
**工具名**判(bash/write/edit 一律问人),因为命令的影响范围无法从文本静态判定
(`cd /工作区 && rm -rf /opt/x` 以"进工作区"开头)。pi 自己**故意**不内置沙箱
(docs/security.md §No Built-in Sandbox:进程内的部分沙箱会被误解成安全边界,
"Real isolation needs to come from the operating system or a container boundary")——
dsh 是把沙箱放进自己的运行时;这个二进制补的是 pi 这一侧的**宿主**部分。

## 语义

写:只有 `--rw` 列出的目录(含子树)与 `--rw-file` 列出的文件可写,其余写操作一律
EACCES(内核判)。读与执行不限制 —— Landlock 只做白名单式加法,要连读都挡住得靠
容器/挂载命名空间。套不上边界时 **fail closed**(退出码 126,不执行命令):静默裸跑
会让"档位=workspace"变成谎话,而谎话比做不到更危险。

## 判据

- 行为 `deploy/check-sandbox.sh`:19 项全绿 —— 界内可写/子目录递归/rename+删除、
  读界外、执行、写 /dev/null;界外新建/建目录/覆盖/删除/删目录/跨边界 rename/
  软链接逃逸/子进程继承;**对照**(不套边界时那些"必须被拒"的命令必须成功,否则
  判据可能只是环境本来就只读);fail-closed 那条(rw 不存在 ⇒ 126 且命令未执行)。
- 算法 `cmd/am-sandbox` 单测:按 ABI 逐位裁剪、文件目标不得带目录类权限、
  allowed ⊂ handled、参数解析。

## 过程中撞到两个"看着能过"的坑

1. `--rw-file` 任意文件 → add_rule **EINVAL**:文件目标只能用文件类权限
   (MAKE_*/REMOVE_*/REFER 是目录类),而错误信息只有 "invalid argument",
   看不出是权限位不匹配。单测里"文件权限必须是 handled 的子集"就是拦它的。
2. ★ 判据自己翻车:`"$SB" … 2>&1 | grep -q "Permission denied"` 在
   `set -o pipefail` 下 —— **grep 命中即退出 → 左侧 EPIPE 失败 → 整条管道判失败**
   ⇒ 7 条"界外必须被拒"全被判成"没被拒"。改成先收输出再匹配,顺带打印真实输出。

## 已知边界(写进文件头 + 判据输出留痕,不假装没有)

- 元数据(chmod/chown/utimes)不受 Landlock 管辖:界外文件**内容**改不了,
  但**模式位**能改
- 网络出向不受限
- 界内**已存在**的、指向界外文件的硬链,经界内路径写入会改到界外(路径式沙箱固有)
- 本机 ABI=2(无 TRUNCATE);实测 `truncate` 越界仍被拒(coreutils 先 open 写 ⇒
  WRITE_FILE 挡住),所以那个缺口比纸面上窄
- **读不限制** ⇒ 以 root 跑的 agent 仍能读整机(含 /etc/agentmail/*.env)。沙箱在这里
  的价值是"界外留不下脚印",不是"拿不到东西" —— 要后者得上容器

## 接线

`redeploy-gateway.sh` / `install.sh` 在**边界判据真跑绿了**之后才装到
`/opt/agentmail/bin/am-sandbox`(装一个套不上边界的工具等于发空头支票)。
pi 桥的接线(按档位把 worker 套进边界)还没做 —— 见随后的报告。
2026-09-14 23:33:11 +08:00

429 lines
23 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
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" <<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 原子替换二进制,
# 并在验证不过时自动回滚。