Files
MailUI4Agents/plugins/pi-mail-bridge/test/env-guard.test.mjs
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

294 lines
17 KiB
JavaScript
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.

/**
* 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。
*
* # 缺陷现场(2026-09-14 实测)
*
* `/tmp` 是 tmpfs,被别人的东西占满,`bavail` 只剩 **0.70 MiB**。此时:
*
* not ok 323 - ★巨大的 message 行不进内存也不影响解析
* error: 'ENOSPC: no space left on device, write'
*
* 那条红的**形状**指向内存(用例名里就写着"不进内存"),真相是环境不足。
* 没有这一条判据时,下一个人会去 `session-scan.mjs` 找一个不存在的内存缺陷。
*
* # 三段判据(缺一段都不算数)
*
* 1. 纯函数两头都对:够 → 绿;不足 → 红**且说得出差多少**;
* 2. 端到端退出码:不足 → `2`(环境问题,与 `deploy/redeploy-plugin.sh` 同义),
* 不是 `1`(断言失败)—— 看到 `2` 才知道去查机器而不是查代码;
* 3. **反面样本**:喂一个不足的可用空间,判据必须红。
* 没有反面样本的判据等于没有判据(仓库既有规矩)。
*/
import assert from 'node:assert/strict';
import test from 'node:test';
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';
import { tmpdir } from 'node:os';
import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE, measureAvailBytes } from './lib/tmp-space.mjs';
import { translateEnvError } from './lib/env-error.mjs';
import { writeSession } from './lib/session-fixtures.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
// 前置脚本住在 `test/lib/`(它只在测试期跑,不该进生产快照 —— 见 test/lib/reach.mjs)。
const PREFLIGHT = join(HERE, 'lib', 'env-preflight.mjs');
// ─── 1. 纯函数:两头都对 ───────────────────────────────────────
test('空间充足 → 判绿,并报出可用与需要', () => {
const v = judgeSpace({ availBytes: MIN_FREE_BYTES + 1 });
assert.equal(v.ok, true);
assert.equal(v.known, true);
assert.match(v.note, /可用/);
assert.match(v.note, /需要/);
});
test('★反面样本:空间不足 → 判红,且说得出差多少', () => {
// 实测那个数:0.70 MiB 可用(`/tmp` 满时的真实值)。
const v = judgeSpace({ availBytes: 729_088 });
assert.equal(v.ok, false, '不足必须判红 —— 这是这条判据存在的理由');
assert.equal(v.known, true);
assert.match(v.note, /0\.7 MiB/);
assert.match(v.note, /32\.0 MiB/);
});
test('刚好多一点就够、刚好少一点就不够(边界不靠感觉)', () => {
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES }).ok, true, '≥ 阈值算够');
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES - 1 }).ok, false, '< 阈值算不够');
});
test('读不到可用空间 → 不判红(不知道 ≠ 不对)', () => {
// 平台不支持 statfs、或字段缺 —— 此时放行。理由见 test/lib/tmp-space.mjs 头注释:
// 在认不出的文件系统上判红,会造出一条总在亮的红灯,人就会学会忽略它。
for (const availBytes of [null, undefined, NaN]) {
const v = judgeSpace({ availBytes });
assert.equal(v.ok, true, `${String(availBytes)} 不该判红`);
assert.equal(v.known, false);
}
});
test('★0 字节不是「不知道」,是「真的没有」—— 必须判红', () => {
// 这条是实测踩出来的:第一版把 `availBytes <= 0` 一并当"没测到",
// 于是 `bavail` 只剩 712 字节时前置自检**放行**,紧接着 17 条用例 ENOSPC 全红
// —— 前置自检装了等于没装。0 是测量结果,不是测量失败。
const v = judgeSpace({ availBytes: 0 });
assert.equal(v.ok, false, '0 字节必须判红');
assert.equal(v.known, true);
});
test('阈值有据:等于「实测用例最大写入量 × 2 + 机动」,不是总容量的百分比', () => {
assert.equal(MEASURED_MAX_CASE_WRITE, 12 * 1024 * 1024, '12 MiB 来自 session-scan 那条用例的写入量');
assert.ok(MIN_FREE_BYTES > MEASURED_MAX_CASE_WRITE, '阈值必须大于单条用例的写入量');
});
// ─── 2. 端到端:退出码与文案 ───────────────────────────────────
/** 跑一次前置脚本(带参数),取 { status, stdout, stderr }。失败(非零退出)不抛。 */
function runPreflight(args = [], env = {}) {
try {
const stdout = execFileSync(process.execPath, [PREFLIGHT, ...args], {
encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'],
});
return { status: 0, stdout, stderr: '' };
} catch (e) {
return { status: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
}
}
test('端到端:退出码 2 与文案(两个方向都验,不依赖机器状态)', () => {
// ★ 这一条**不依赖机器状态**。第一版靠"本机 /tmp 恰好是满的"来验,pi 评审时
// 指出那是把判据绑在一个会变的环境上:/tmp 一被清空,这条就自动跳过、无声失效。
// 所以前置脚本开了 **只为测试存在** 的开关 `--inject-avail`(`pool.mjs` 的
// `workerPath` 是同一手法),把"可用空间"直接喂进去。
//
// 两个方向都要验:只验"不足⇒2",一个恒报不足的坏守卫也能绿;
// 只验"充足⇒0",一个恒放行的守卫也能绿。
const bad = runPreflight(['--inject-avail=0']);
assert.equal(bad.status, 2, '不足必须是 2(环境问题),不是 1(断言失败)');
assert.match(bad.stderr, /这是环境不足,不是断言失败/);
assert.match(bad.stderr, /TMPDIR=/);
const good = runPreflight(['--inject-avail=999999999']);
assert.equal(good.status, 0, '充足必须放行');
assert.match(good.stdout, /env-preflight/);
});
test('★开关真的被认:两个探针必须给出**相反**的判定与相反的关键词', () => {
// pi 评审的漏洞一:上一版只在"真实测量不足"那个分支里断言 ⇒ 机器一恢复健康
// (/tmp 被清空)这条就退化成弱检查,而它守的恰恰是"开关别静默失效"。
//
// 漏洞二(我做变异时撞上的,比漏洞一更隐蔽):按"真实测量"分叉的写法本身留了一个
// 短路分支 —— 本机真实可用就是 0 ⇒ 永远走不足分支,而那个分支只看退出码;
// 于是把开关**整个忽略掉**(永远用真实测量),断言**照样绿**。
// "断言在,区分力不在" —— 与 pi 点的是同一类病,只是它藏在"跑不到的分支"里。
//
// 修法:**不跟真实测量比,让两个探针自己互为反面**,并断言**输出里的判定词**
// (不只看退出码 —— 退出码可能与真实状态巧合相同):
// 探针 A:注入 1 字节 ⇒ exit 2 + 必须打印「< 需要」
// 探针 B:注入 128 MiB(>阈值)⇒ exit 0 + 必须打印「≥ 需要」
// 若开关被忽略,两次都按**真实**测量给同一个答案 ⇒ 至少一条红。
// 这个论证不依赖真实测量是多少。
const tiny = runPreflight(['--inject-avail=1']);
assert.equal(tiny.status, 2, '注入 1 字节必须 exit 2');
assert.match(tiny.stderr + tiny.stdout, /<\s*需要/, '必须打印「不足」的判定');
const plenty = runPreflight(['--inject-avail=134217728']);
assert.equal(plenty.status, 0, '注入 128 MiB(> 32 MiB 阈值)必须放行');
assert.match(plenty.stdout, /≥\s*需要/, '必须打印「充足」的判定(不是靠退出码近似)');
});
test('★非法参数必须炸(exit 2),不能静默放行', () => {
// `Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。笔误在这条链上等于**跳过守卫**,
// 这是 pi 评审时点出来的:`--inject-avail=abc` 原本会安安静静地放行。
for (const bad of ['--inject-avail=abc', '--inject-avail=-1', '--inject-avail=', '--typo=1', '--measure']) {
const r = runPreflight([bad]);
assert.equal(r.status, 2, `${bad} 必须 exit 2(参数/环境问题),实际 ${r.status}`);
}
// 合法值不能被这条误伤。
assert.equal(runPreflight(['--inject-avail=null']).status, 0, 'null 是合法值(没测到 ⇒ 放行)');
});
test('测量层:读不到的目录 → null(这条判据不依赖机器状态,永远跑得了)', () => {
// 覆盖"读不到 ⇒ null ⇒ 放行"那一支。端到端那条一旦被跳过,就只剩这条管它。
assert.equal(measureAvailBytes('/definitely/not/here'), null);
assert.equal(measureAvailBytes(''), null, '空串不是合法目录');
assert.equal(measureAvailBytes(undefined), null, '不传也要能兜住');
// 真实的临时目录必须量得出一个数(健康时是正数、满时可能是 0 —— 两者都是
// "量到了",都不是 null;这正是 `0` 与"没测到"必须分开的那条线)。
const real = measureAvailBytes(tmpdir());
assert.ok(real === null || typeof real === 'number', '要么量到数,要么明确 null');
});
// ─── 3. 兜底:绕过前置脚本时也不能伪装成内存缺陷 ────────────────
//
// 这一段原先靠**读源码文本**(断言 `session-scan.test.mjs` 里出现 `/ENOSPC/`)。
// pi 评审时指出它钉的是装饰不是机制:那段解释性注释里本来就有 "ENOSPC" 这个词,
// **把整段翻译逻辑删掉、只留注释,判据照样绿**。按仓库规矩改成行为判据 ——
// 纯函数喂反面样本 + 接线检查(`WIRING` 那套:直接调被接上的那个函数)。
test('★反面样本:ENOSPC 必须被翻译成「环境问题」,普通错误必须原样返回', () => {
const enospc = Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' });
const t = translateEnvError(enospc);
assert.equal(t.translated, true, 'ENOSPC 必须翻译');
assert.match(t.error.message, /环境问题/);
assert.match(t.error.message, /不是内存缺陷/, '必须点明它不是内存缺陷 —— 这正是那次误导的根');
assert.match(t.error.message, /TMPDIR=/, '必须带药方');
assert.equal(t.error.cause, enospc, '原始错误要挂上 cause,别把现场丢了');
// 别的错误必须**原样**返回(不是包一层)—— 否则真正的代码缺陷会被套上
// "环境问题"的外衣,那比不翻译更坏。
const other = new Error('Cannot read properties of undefined');
const o = translateEnvError(other);
assert.equal(o.translated, false);
assert.equal(o.error, other, '普通错误必须原样返回同一个对象');
});
test('只有 message 里写着 no space left(没有 code)时也认', () => {
// 跨平台差异:不同 Node/文件系统的 errno 包装不一律带 code。
const t = translateEnvError(new Error('write failed: no space left on device'));
assert.equal(t.translated, true);
});
test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错(喂假写,不靠机器状态)', () => {
// 这条是**行为**判据,不是文本判据:直接调被接上的那个函数,喂一个必然 ENOSPC 的
// 假写。删掉 `test/lib/session-fixtures.mjs` 里那段 `translateEnvError(e).error`,这条立刻红。
//
// ★ 用 `os.tmpdir()`(纯字符串)而不是 `tmpdir()`(会 statfs)当根:
// 这里所有创建都被假写打断 ⇒ 目录不会被真正建出来 ⇒ 不需要真临时目录,
// 也不会往共享 /tmp 里留东西(pi 评审:判据自己别往被测资源里丢垃圾)。
const fakeWrite = () => {
throw Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' });
};
assert.throws(
() => writeSession(tmpdir(), '--probe--', 'x.jsonl', { id: 's' }, [], fakeWrite),
(e) => {
assert.match(e.message, /环境问题/, '必须是人话,不是原始英文 ENOSPC');
assert.match(e.message, /不是内存缺陷/);
return true;
}
);
// 反向对照:普通写失败必须原样抛(证明上面那条不是因为"什么都翻译"才过的)。
const brokenWrite = () => { throw new Error('EACCES: permission denied'); };
assert.throws(
() => writeSession(tmpdir(), '--probe2--', 'y.jsonl', { id: 's' }, [], brokenWrite),
/EACCES/,
'不该被翻译成环境问题'
);
});
// ── 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}`);
});