From be8459cfe7dac127230b1f2d673af4cd169492f5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 07:05:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20=E7=8E=AF=E5=A2=83=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E8=87=AA=E5=B7=B1=E4=BE=9D=E8=B5=96=E7=9A=84=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B9=9F=E7=99=BB=E8=AE=B0=20+=20=E8=87=AA=E6=88=91?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E6=8E=92=E5=9C=A8=E7=94=A8=E5=AE=83=E4=BB=AC?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=20+=20=E9=9D=99=E9=BB=98=E6=94=B9=20HOME=20?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- deploy/lib/env-defaults.sh | 44 +++++++++++ .../pi-mail-bridge/test/env-guard.test.mjs | 73 ++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/deploy/lib/env-defaults.sh b/deploy/lib/env-defaults.sh index 78291ff..ac619db 100644 --- a/deploy/lib/env-defaults.sh +++ b/deploy/lib/env-defaults.sh @@ -42,6 +42,24 @@ # 否则"环境本来就齐"与"忘了 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:*) : ;; @@ -50,6 +68,24 @@ case ":${PATH:-}:" in 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 断言, @@ -85,6 +121,14 @@ if [ -z "$_am_home" ] || [ ! -d "$_am_home" ] || [ ! -w "$_am_home" ]; then # (静默改 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 diff --git a/plugins/pi-mail-bridge/test/env-guard.test.mjs b/plugins/pi-mail-bridge/test/env-guard.test.mjs index 39e31c8..e14bdfe 100644 --- a/plugins/pi-mail-bridge/test/env-guard.test.mjs +++ b/plugins/pi-mail-bridge/test/env-guard.test.mjs @@ -22,7 +22,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; // (本文件不再直接读文件:夹具在 test/lib/,边界判据在 layout-boundaries.test.mjs) import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -220,3 +221,73 @@ test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错 ); }); + + +// ── deploy/lib/env-defaults.sh 自身的外部命令依赖(pi 评审 2026-09-15)── + +const ENV_DEFAULTS = join(HERE, '..', '..', '..', 'deploy', 'lib', 'env-defaults.sh'); + +test('★ 环境兜底文件必须把**自己**用的外部命令登记出来', () => { + // pi 评审 2026-09-15:`env-defaults.sh` 自己用了 `id`/`getent`/`cut`/`df`/`awk`, + // 一个都没进 `AGENTMAIL_REQUIRE`(那张表只登记**调用者**的命令,而它由调用者赋值)。 + // ⇒ "环境兜底自己还需要环境",且缺 `df`/`awk` 时 **②b 那道空间门静默消失** + // (`df -Pk … | awk` 拿到空串 ⇒ 走"读不到 ⇒ 不判定"那支)。 + // + // ★ 为什么判据落在"登记了没有"而不是"缺命令会 exit 2": + // 我实测过——`env-defaults.sh` 的 **PATH 自修**(④)会把 `/usr/bin` 加回来, + // 所以"从 PATH 里拿掉 df"这种造法**根本造不出缺命令的环境**(自修又把它找回来了)。 + // 缺命令只可能发生在"`/usr/bin` 里真没有它"的机器上(distroless / 精简容器), + // 而那种环境我在这台机器上无法复现。⇒ 能**durable** 判定的只有两件事: + // ①这些命令被登记了;②检查发生在**下一次外部调用之前**。两条都断言。 + const src = readFileSync(ENV_DEFAULTS, 'utf8'); + const selfReq = src.match(/AGENTMAIL_REQUIRE_SELF="([^"]*)"/); + assert.ok(selfReq, 'env-defaults.sh 必须声明它自己依赖的命令(AGENTMAIL_REQUIRE_SELF)'); + for (const c of ['id', 'getent', 'cut', 'df', 'awk']) { + assert.ok(selfReq[1].split(/\s+/).includes(c), `AGENTMAIL_REQUIRE_SELF 里应当登记 ${c}`); + } +}); + +test('★ 顺序即正确性:自我依赖的检查必须在**第一次用它们之前**', () => { + // 这是 pi 那条"顺序不是风格,是正确性"的口径落在**本文件自己**身上。 + // 判据写成位置比较,而不是"有这段代码"——后者正是我这一轮反复写坏的那种判据。 + const src = readFileSync(ENV_DEFAULTS, 'utf8'); + const lines = src.split('\n'); + const checkLine = lines.findIndex((l) => l.includes('_am_self_missing=')); + const firstUseLine = lines.findIndex((l) => /^_am_uid="\$\(id /.test(l)); + assert.ok(checkLine > 0, '应当有自我依赖检查'); + assert.ok(firstUseLine > 0, '应当有 `id -u` 那次使用'); + assert.ok(checkLine < firstUseLine, + `自我依赖检查必须排在 \`id -u\` 之前(实际 ${checkLine + 1} vs ${firstUseLine + 1})`); +}); + +test('★ 静默改 HOME 必须留痕(兜底自己被环境打败那条路)', () => { + // 原先只有"调用者给的 HOME 不可写"才 WARN;而"按身份推出来的那个也不可用" + // (root 的 /root 在非 root 下不可写;passwd 里是 /nonexistent)**悄悄换了 HOME**。 + // 实测可达:非 root + 空 HOME ⇒ nobody 的 passwd home 是 /nonexistent。 + const src = readFileSync(ENV_DEFAULTS, 'utf8'); + assert.match(src, /按身份推出来的 HOME=%s 不可用/, + '兜底路径改了 HOME 就必须打一行 WARN("东西写到哪去了"不能变成谜)'); +}); + +test('非 root + 空 HOME ⇒ 改判 HOME 时**确实**会打 WARN(端到端)', (t) => { + if (process.getuid?.() !== 0) return t.skip('需要 root 才能切到 nobody 复现'); + // ★ 必须**同时**收 stderr:WARN 走 stderr(`>&2`),只收 stdout 会把它丢掉 —— + // 我第一版就是这样,于是判据红在"没找到 WARN"上,而实际是**判据自己没去读那一股**。 + // 用一个世界可读的副本:仓库里的 `deploy/lib/env-defaults.sh` 可能是 0600 + // (我在 umask 077 下用写文件工具建过),而 `nobody` 读不了它 —— 那是**另一件事** + // (文件权限),不该混进"改判 HOME 是否留痕"这条判据里。 + // ★ 顺带记下:这条判据第一次就是**红在这个权限上**,报的是"没找到 WARN", + // 看起来像"没打 WARN",实际是脚本**根本没跑起来**。这正是"命令不在≠输出为空" + // 的同族:**脚本没跑 ≠ 输出里没有那一行**。 + const staged = join(tmpdir(), `am-env-defaults-${process.pid}.sh`); + writeFileSync(staged, readFileSync(ENV_DEFAULTS, 'utf8'), { mode: 0o644 }); + const r = spawnSync('setpriv', ['--reuid=65534', '--regid=65534', '--clear-groups', + 'env', '-i', 'PATH=/usr/bin:/bin', 'TMPDIR=/tmp', + 'bash', '-c', `unset HOME; source ${staged}; echo "FINAL=$HOME"`], + { encoding: 'utf8' }); + try { unlinkSync(staged); } catch { /* 清理失败不影响判据 */ } + const out = `${r.stdout || ''}${r.stderr || ''}`; + assert.match(out, /\[WARN\]/, '改判 HOME 必须留下 WARN'); + assert.match(out, /FINAL=\/tmp\/agentmail-home-65534|FINAL=\/root/, + `最终 HOME 要说得出落在哪,实际输出:${out}`); +});