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。
294 lines
17 KiB
JavaScript
294 lines
17 KiB
JavaScript
/**
|
||
* 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。
|
||
*
|
||
* # 缺陷现场(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}`);
|
||
});
|