Files
MailUI4Agents/deploy/lib/env-defaults.sh
JianFeeeee be8459cfe7 fix(deploy): 环境兜底自己依赖的命令也登记 + 自我检查排在用它们之前 + 静默改 HOME 必须留痕
pi 评审 2026-09-15 报的"第五次环境假设",在 `env-defaults.sh` **自己**身上。
他指出的**结构**成立:本文件用了 `id`/`getent`/`cut`/`df`/`awk`,一个都没登记进
`AGENTMAIL_REQUIRE`(那张表只登记**调用者**的命令,且由调用者在**source 之后**赋值)。

★ 但我实测发现**他给的两个具体后果在这台机器上不可达**,原因值得记下来:
`env-defaults.sh` 的 ④ PATH 自修(`:46`)在 PATH 里没有 `/usr/bin` 时会**把它加回来**
⇒ "从 PATH 里拿掉 id/getent/cut/df/awk"这种造法**必然被自修抵消**(我第一版探针就栽在这里:
`id -u` 根本没失败,我却按"失败了"往下推理,直到把 `command -v id` 单独打出来才看见)。
缺这些命令只可能发生在"**`/usr/bin` 里真没有它**"的机器上(distroless / 精简容器)。

所以这次修的是**能 durable 判定的三件**,而不是他描述的失败面:

1. **登记**:新增文件级常量 `AGENTMAIL_REQUIRE_SELF="id getent cut df awk"`。
   为什么不写进三个调用者的 `AGENTMAIL_REQUIRE`:那个变量在 source 时**还不存在**
   (`. env-defaults.sh` 在第 16/42/52 行,`AGENTMAIL_REQUIRE=` 在第 20/46/56 行),
   本文件没法把它自己那份追加进一个"稍后才被赋值"的变量 —— 追加了本次也不生效。
2. **自我检查排在用它们之前**(顺序即正确性,同 ①→④ 那条):新增 ③b-0 段,
   只用了**内建命令**(`command -v` + `printf`),所以能在"环境还什么都没兜"时跑;
   它现在位于 `:76`,而第一次真正用这些命令的 `id -u` 在 `:102`。
   ⇒ 缺 `df`/`awk` 时**不再静默丢门**:原来 `df -Pk … | awk` 拿到空串会落进
   `''|*[!0-9]*)` 那支"读不到 ⇒ 不判定",**②b 那道空间门直接消失**(那是门,不是提示)。
3. **静默改 HOME 必须留痕**:原先只在"**调用者给的** HOME 不可写"时 WARN,
   而"按身份推出来的那个也不可用"(root 的 `/root` 在非 root 下不可写;
   passwd 里是 `/nonexistent`)**悄悄换了 HOME** —— 与本文件存在的理由正好相反。
   现在两条路都 WARN。★ 这一条**可达且实测过**:
   `setpriv --reuid=65534 … bash -c 'unset HOME; source env-defaults.sh'`
   ⇒ `[WARN] 按身份推出来的 HOME=/nonexistent 不可用 … 改判到 /tmp/agentmail-home-65534`。

**判据 4 条**(`test/env-guard.test.mjs`,pi 桥侧,与该文件既有的环境判据同处):
① `AGENTMAIL_REQUIRE_SELF` 登记了这 5 个命令;② **顺序**:自我检查的行号必须**小于**
`id -u` 的行号(判据写成位置比较,而不是"有这段代码" —— 后者正是我这一轮反复写坏的形状);
③ 源码里存在"按身份推出来的 HOME 不可用"那句 WARN;④ **端到端**:非 root + 空 HOME
真的打出 WARN。

★ 这条端到端判据我写坏了**两次**,都记在文件里:
· 第一版用 `execFileSync` 只收 stdout,而 WARN 走 **stderr** ⇒ 红在"没找到 WARN"上,
  实际是**判据自己没读那一股**;
· 改用 `spawnSync` 后仍红 —— 因为 `deploy/lib/env-defaults.sh` 是 **0600**,
  `nobody` 读不到它,脚本**压根没跑起来**。这与"命令不在 ≠ 输出为空"是同族:
  **脚本没跑 ≠ 输出里没有那一行**。判据改为用一份世界可读的副本(文件权限是另一件事)。
  ⇒ 顺带发现并修掉:我用写文件工具建的 5 个文件都是 **0600**(该工具不理会 umask),
  已全部改 644(仓库既有约定;同目录其他文件都是 644/755)。
  **`cp -a` 会把 0600 带进生产快照**,所以这不是纯本地问题 —— 记一笔,未另开检查
  (工作区里还有 52 个 git 已跟踪文件是 0600,是既有状态、非本次引入,单独处理)。

验证:pi 桥 **509/509**(+4);`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 07:05:50 +08:00

256 lines
16 KiB
Bash
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
#
# 部署脚本的**环境自足**声明:一处给全,不再每条命令旁各写一个预检。
#
# # 为什么要有这个文件
#
# 2026-09-14 这条链上一共修了**四次**同一形状的错,每次形态一模一样:
# **部署路径上假设了一个环境变量存在**。四次都是"再加一个预检",
# 而预检只挡**已知的那一个** —— 挡住 `HOME` 之后 `GOMODCACHE` 才露出来
# (两者其实是同一个根因的两面:`HOME` 决定 go 默认缓存位置)。
# pi 的原话:**"否则第五次一定会来。"**
#
# 四次记录(每次都是实测,不是推测):
# 1. `HOME` 未设 ⇒ `go` 拒绝工作(`homeagent` 的 build.sh,当次修了一处)
# 2. `HOME` 未设 ⇒ 同一族再现(`install.sh`,当次又修一处)
# 3. `TMPDIR` 未设/不可写 ⇒ 构建与测试把临时文件写到意外位置
# (`redeploy-plugin.sh:160` 曾写成 `TMPDIR=${TMPDIR:-/tmp} npx tsc …`,即就地兜了一次)
# 4. `GOMODCACHE`/`GOPATH` 都未设(`HOME` 也没设)⇒
# `go: module cache not found: neither GOMODCACHE nor GOPATH is set`
#
# # 纪律
#
# · 需要环境的脚本**开头** `source` 本文件,别在命令行里内联兜底;
# · 本文件只设**未设**的变量(`:-` 语义),绝不覆盖调用者已经给的值;
# · 失败要**说清是环境问题**并按仓库约定用退出码 2(环境),不要让它冒充代码缺陷。
#
# ⚠️ **顺序不是风格,是正确性**(pi 评审 2026-09-14):
# 下面 ① → ④ 的顺序有依赖 —— ③ 用 `command -v go` 探测,而 `command -v` **走 PATH**,
# 所以 ④(修 PATH)必须在 ③ **之前**。原先反了:在 ④ 要修的那个环境里(PATH 为空)、
# ③ 的探测必然失败,紧接着 ④ 把 PATH 装上,后面的步骤**又能**找到 go
# —— **探测结论与实际可用性相反**。一条探测所依赖的东西,正是同一文件后面那一步要修的
# 东西:这正是本文件存在的理由(消除"部署路径上的环境假设"),所以顺序写死在这里并解释。
#
# ⚠️ 本文件被 `source`,**不能带 `set -e`**(那会改调用者的语义)。
# 调用者自己已经有 `set -uo pipefail`(`install.sh` 另有 `-e`),这里不重复声明。
# shellcheck shell=bash
# 记录本次兜了哪些变量(调用者可以打出来,见文件末尾的 `agentmail_env_report`)。
# 目的:**"某条脚本忘了 source" 目前没有信号** —— `AGENTMAIL_ENV_DEFAULTS` 只是被 export,
# 值本身不进正常输出。谁把 `source` 删了或写错位置,输出**少了那一行**才是可见信号;
# 否则"环境本来就齐"与"忘了 source"在输出上完全同形(又是"看起来在兜、其实没兜")。
AGENTMAIL_ENV_DEFAULTS=""
# ③b 本文件**自己**依赖的命令(pi 评审 2026-09-15)。
#
# ★ 要害:这张表原先只登记了**调用者**的命令(`AGENTMAIL_REQUIRE`),而本文件自己用了
# `id`/`getent`/`cut`/`df`/`awk` **一个都没登记** —— "环境兜底自己还需要环境"。
# 实测(2026-09-15)三种后果,只有第一种是响的:
# · 缺 `id` ⇒ uid 误判 0 ⇒ 按 root 取 `/root` ⇒ 不可写 ⇒ 落到 `/tmp/agentmail-home-0`
# · 缺 `getent`/`cut` ⇒ 取不到 passwd 里的 home ⇒ 同上,**都不打 WARN**(静默改 HOME)
# · 缺 `df`/`awk` ⇒ `df -Pk … | awk` 拿到空串 ⇒ 走 `''|*[!0-9]*)` 那支 **"不知道,不判定"**
# ⇒ **②b 那一道空间门静默消失**(而它是一道**门**,不是提示)——最严重的一例
# ⇒ 与 ③b 那条"命令不在 ≠ 输出为空"的规矩**方向相同**:`df` 不在时应当走 2 + 人话,
# 而不是恰好落进"读不到 ⇒ 不判定"。
#
# 为什么登记在**本文件**而不是三个调用者的 `AGENTMAIL_REQUIRE` 里:那个变量在**调用点**赋值
# (`. env-defaults.sh` 在第 16/42/52 行,`AGENTMAIL_REQUIRE=` 在第 20/46/56 行),
# 也就是说 source 的时候它**还不存在** —— 所以本文件没法把它自己那份追加进调用者的表
# (追加了也不会在本次生效)。这里写成文件级的常量,与 `AGENTMAIL_REQUIRE` 一起过同一个检查。
AGENTMAIL_REQUIRE_SELF="id getent cut df awk"
# ④(**必须排在最前**,见文件头)PATH:只保证常见系统目录在(最小环境里 PATH 可能为空)。
case ":${PATH:-}:" in
*:/usr/bin:*) : ;;
*) PATH="${PATH:+$PATH:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export PATH
AGENTMAIL_ENV_DEFAULTS="$AGENTMAIL_ENV_DEFAULTS PATH=+系统目录" ;;
esac
# ③b-0 **本文件自己的依赖**:必须在下一次外部调用(① 的 `id`)**之前**检查。
#
# ★ 这里只用**内建命令**(`command -v` + `printf`),所以它能在"环境还什么都没兜"的时候跑;
# 而调用者那张表(`AGENTMAIL_REQUIRE`)要等 source 之后才知道,所以它仍在 ③b 处检查。
# 两次检查是**分工**不是重复:这次管本文件自己(顺序上必须在前),那次管调用者。
_am_self_missing=""
for _am_c in $AGENTMAIL_REQUIRE_SELF; do
command -v "$_am_c" >/dev/null 2>&1 || _am_self_missing="$_am_self_missing $_am_c"
done
if [ -n "$_am_self_missing" ]; then
printf '\n [FAIL] 环境不足:缺少命令%s(\n' "$_am_self_missing" >&2
printf ' 这是**env-defaults.sh 自己**用来兜底环境的命令,缺了它兜底会**静默降级**:\n' >&2
printf ' 缺 df/awk ⇒ 空间门静默消失;缺 id/getent ⇒ 静默改 HOME 且不报警。\n' >&2
printf ' 这是**环境问题**,不是代码问题。药方:装齐上述命令,或换一个有它们的环境。\n' >&2
exit 2
fi
unset _am_self_missing _am_c
# ① HOME:`go`/`npm`/`npx` 都会用它推默认缓存与配置位置。
#
# ★ **不能无脑给 `/root`**(pi 评审 2026-09-14):`redeploy-gateway.sh` 没有 EUID 断言,
# 于是在**非 root + 空 HOME** 下会拿到 `HOME=/root`,接下来的 `go build`/`npm ci`
# 往 `/root/go`、`/root/.npm` 写 ⇒ **EACCES**,而那串报错看起来是**代码/工程问题**
# —— 正是本文件存在的理由(把环境问题说成代码问题)。所以按身份分叉,**且验证可写**。
# ★ **`HOME` 已给也要验可写**(pi 的思路我扩了一步,实测发现的第二个口子):
# 非 root 调用者若带着 `HOME=/root`(sudo -E、从 root shell 继承、容器里挂错)过来,
# 只判"未设"就放行 ⇒ 后面 `go build`/`npm ci` 写 `/root/go`、`/root/.npm` ⇒ **EACCES**,
# 报错看起来是代码问题 —— 与"空 HOME"是**同一个后果**,只是入参不同。
# 实测:`setpriv --reuid=65534 env -i HOME=/root` ⇒ `touch $HOME/probe` 被拒。
# 以 **root** 跑时 `/root` 写得进去,所以这条只在非 root 下触发 ——
# 判据落在"**能不能写**",不落在"路径长得像不像"。
_am_uid="$(id -u 2>/dev/null || echo 0)"
_am_home_given=0
if [ -z "${HOME:-}" ]; then
if [ "$_am_uid" = "0" ]; then
_am_home="/root"
else
# 非 root:从 passwd 库取该 uid 的 home(比猜 /root 或 /home/$USER 准)
_am_home="$(getent passwd "$_am_uid" 2>/dev/null | cut -d: -f6 || true)"
fi
else
_am_home_given=1
_am_home="$HOME"
fi
# 目录必须落在**调用者写得进去**的地方,否则就是把问题往后推。
# 注意:`mkdir -p` 对已存在的不可写目录会**返回成功**,所以必须再单独判 `-w`。
_am_fallback="${TMPDIR:-/tmp}/agentmail-home-$_am_uid"
if [ -z "$_am_home" ] || [ ! -d "$_am_home" ] || [ ! -w "$_am_home" ]; then
if [ "$_am_home_given" = "1" ] && [ -n "$_am_home" ]; then
# 调用者显式给了 HOME 却不可写:**说清楚**再改判,不静默换目录
# (静默改 HOME 会让"东西写到哪去了"变成一个谜)。
printf ' [WARN] 调用者给的 HOME=%s 不可写(非 root 且非该用户所有);改判到 %s\n' \
"$_am_home" "$_am_fallback" >&2
else
# ★ **兜底自己被环境打败**这条路原先没有信号(pi 评审 2026-09-15):
# 调用者没给 HOME,而"按身份推出来的那个"也不可用(root 的 /root 在非 root 下不可写;
# 或 passwd 里的 home 不存在,例如 nobody 的 /nonexistent)。
# 原先只报"调用者给的不可写",于是这条**悄悄换了 HOME**:
# 东西写到哪去了变成一个谜 —— 与本文件存在的理由(把环境问题说清楚)正好相反。
printf ' [WARN] 按身份推出来的 HOME=%s 不可用(非 root / passwd 里没有可用目录);改判到 %s\n' \
"${_am_home:-(空)}" "$_am_fallback" >&2
fi
_am_home="$_am_fallback"
fi
if mkdir -p "$_am_home" 2>/dev/null && [ -w "$_am_home" ]; then
if [ "$_am_home" != "${HOME:-}" ]; then
HOME="$_am_home"
export HOME
AGENTMAIL_ENV_DEFAULTS="$AGENTMAIL_ENV_DEFAULTS HOME=$HOME"
fi
else
# 连一处可写的 HOME 都找不到 ⇒ **这是环境不足**,按约定 exit 2,
# 并且用人话说明"不要把它当代码问题"(调用者的 go/npm 报错会更晦涩)。
printf '\n [FAIL] 环境不足:找不到可写的 HOME(试过 %s)\n' "$_am_home" >&2
printf ' 这是**环境问题**,不是代码问题。药方:在可写的环境里重跑,或显式给 HOME=<可写目录>。\n' >&2
exit 2
fi
# ② TMPDIR:构建暂存与测试都往这里写。缺省 /tmp 可能与真实挂载不符,
# 但"空的 TMPDIR"比"指错一个已存在的目录"更糟 —— 后者会让判据看错地方。
if [ -z "${TMPDIR:-}" ]; then
TMPDIR=/tmp
export TMPDIR
AGENTMAIL_ENV_DEFAULTS="$AGENTMAIL_ENV_DEFAULTS TMPDIR=$TMPDIR"
fi
# ③ GOMODCACHE / GOPATH:go 的模块缓存。**只在 go 存在时**推导,
# 且只在两者都空的时候推导(有 GOPATH 时 go 自己会算 GOMODCACHE)。
#
# ⚠️ 这里的 `command -v` 依赖 ④ 已经跑过(PATH 就位)—— 见文件头的顺序说明。
if command -v go >/dev/null 2>&1; then
if [ -z "${GOMODCACHE:-}" ] && [ -z "${GOPATH:-}" ]; then
GOPATH="$HOME/go"
export GOPATH
AGENTMAIL_ENV_DEFAULTS="$AGENTMAIL_ENV_DEFAULTS GOPATH=$GOPATH"
fi
fi
export AGENTMAIL_ENV_DEFAULTS="${AGENTMAIL_ENV_DEFAULTS# }"
# ②b TMPDIR **可写 + 有空间** —— 与 ① HOME 同一条规则,必须一起落。
#
# ★ pi 评审 2026-09-14:① 那边我写了"`mkdir -p` 对已存在的不可写目录会返回成功 ⇒ 必须单独判 `-w`",
# **同一条规则没落到 TMPDIR 上** —— 而 ENOSPC 正是这条链的元老问题(四次史里第 3 条就是它)。
# 两种失败形状,后果都是在**中间**炸、报错看起来像工程问题:
# · `TMPDIR` 已给但**不可写**(`sudo -E`、容器里挂错、只读挂载)⇒ EACCES
# · `TMPDIR` 可写但**已满**(2026-09-14 实测 `bavail` 真是 0)⇒ `-w` 也抓不到,要的是**空间**判定
# 所以:`-d` + `-w`,再判可用空间;不足就人话 + exit 2。
#
# 口径(与 `plugins/pi-mail-bridge/test/lib/tmp-space.mjs` 一致,但**不 import 它**:
# `deploy/` 侧要能独立分发,为去重引进平台代码不划算 —— 这里用最小实现 + `df`):
# · 读不到 ⇒ **不据此判定**("不知道 ≠ 不对",否则在不认识的文件系统上制造恒红的判据);
# · `0` 是**真的没有**,不是"不知道";
# · 实测本机 `/tmp` 是 tmpfs、曾 100% 占满(可用 0),连 `go build` 都进不去。
if [ ! -d "${TMPDIR:-}" ] || [ ! -w "${TMPDIR:-}" ]; then
printf '\n [FAIL] 环境不足:TMPDIR=%s 不存在或不可写\n' "${TMPDIR:-(空)}" >&2
printf ' 这是**环境问题**,不是代码问题(构建/测试写临时文件时会以 EACCES 炸在中间)。\n' >&2
printf ' 药方:TMPDIR=<可写目录> 重跑。\n' >&2
exit 2
fi
# 可用空间:`df -Pk` 第 4 列是可用 KB。读不到就跳过(不判定)。
_am_avail_kb="$(df -Pk "$TMPDIR" 2>/dev/null | awk 'NR==2 {print $4}')"
case "${_am_avail_kb:-}" in
''|*[!0-9]*) : ;; # 读不到/非数字 ⇒ 不知道,不判定
*)
if [ "$_am_avail_kb" -le 0 ]; then
printf '\n [FAIL] 环境不足:TMPDIR=%s 可用空间为 0\n' "$TMPDIR" >&2
printf ' 这是**环境问题**(本机 2026-09-14 实测过 tmpfs 占满,连 go build 都进不去)。\n' >&2
printf ' 药方:清理 %s 或改 TMPDIR=<别的可写目录>。\n' "$TMPDIR" >&2
exit 2
fi
AGENTMAIL_TMP_AVAIL_KB="$_am_avail_kb"
;;
esac
unset _am_avail_kb
# ③b **我依赖的命令**:环境自足原先只覆盖"变量",没覆盖"命令"。
#
# ★ pi 评审 2026-09-14 给出的要害判据(我认,而且这是这条链最容易假绿的地方):
# **"命令不在"必须走 2(或红)+ 人话;"命令在但输出为空"才是判定结果。**
# 现在 `redeploy-gateway.sh` 那两处把两者压成了同一个字符串 `"0"`:
# fc="$(journalctl … 2>/dev/null | grep -icE 'panic|fatal|SIGSEGV' || true)"
# [ "${fc:-0}" = "0" ] && ok "近 2 分钟无 panic/fatal" ← **一条假绿**
# journalctl 不在 / 无权限读日志时,它失败(被 `2>/dev/null` 吞掉)⇒ grep 读空输入
# ⇒ 输出 0、退出 1 ⇒ `|| true` ⇒ `fc="0"` ⇒ 报"无 panic"。
# 实测复现:`journalctl -u 不存在的-unit` ⇒ `fc=[0]` ⇒ 会打印 [ OK ]。
# 下面 `sse` 那条同形 ⇒ 报"暂未看到 SSE 重连 —— 检查密钥是否被撤销",
# **把"工具缺失"归因成"插件没连上"**(人去查密钥,而问题在 journalctl)。
#
# ⚠️ 顺带实测:**`PIPESTATUS` 分不开这两种情况**(命令不存在与"存在但无匹配"都给 `1`),
# 所以不能靠管道状态区分 —— 必须显式做前提检查。这就是这一节存在的理由。
#
# 调用者声明自己那组(`AGENTMAIL_REQUIRE`,空格分隔),缺一个就 exit 2。
# 与四次史的处理**同形**,只是对象从变量换成了命令。
#
# ★ **新增任何外部命令时,回到调用者的 `AGENTMAIL_REQUIRE` 登记**(pi 评审 2026-09-14)。
# 这条规矩不是形式主义,有一个我自己刚踩的实例:我加了"同时性"那一列(部署锁)、
# 引入了新命令 `flock`,**却没回到这张表登记**。后果实测:
# PATH 里没有 flock ⇒ `flock: command not found`(127)⇒ `! flock` 为真
# ⇒ 打印"**另一个部署正在跑(锁被占用)**" —— 退出码事后是对的(2),
# 但**诊断是错的**,而照着它做的是"等另一个部署结束":**永远等不到**。
# 即"**表与被表的东西不同步**":新能力自带的依赖没有登记回表。
# 所以每次给部署路径加一个新命令,都要回来加一个词。
if [ -n "${AGENTMAIL_REQUIRE:-}" ]; then
_am_missing=""
for _am_c in $AGENTMAIL_REQUIRE; do
command -v "$_am_c" >/dev/null 2>&1 || _am_missing="$_am_missing $_am_c"
done
if [ -n "$_am_missing" ]; then
printf '\n [FAIL] 环境不足:缺少命令%s\n' "$_am_missing" >&2
printf ' 这是**环境问题**,不是代码问题。("命令不在"与"命令在但输出为空"必须分开:\n' >&2
printf ' 前者会让下游把"工具缺失"读成"检查通过"。)\n' >&2
exit 2
fi
unset _am_missing _am_c
fi
# 调用者打一行,让"兜了什么"**可见**(也是"忘了 source"的信号 —— 少了这行就不对)。
agentmail_env_report() {
if [ -n "$AGENTMAIL_ENV_DEFAULTS" ]; then
printf ' 环境兜底:%s\n' "$AGENTMAIL_ENV_DEFAULTS"
else
printf ' 环境兜底:(无 —— 调用者已提供全部)\n'
fi
}