Files
MailUI4Agents/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
JianFeeeee 99e6560122 fix(pi-bridge): 首回合也要套沙箱 —— pool 在算 launch 前先把兜底目录建出来(pi 报的同形缺口)
pi 2026-09-15 报的缺口,我先逐环核了再改(**成立**):

```
pool.mjs:190   cwd = resolveWorkspaceCwd(to_workspace, piMailFallback(session_id)).cwd
                 ↓ 地址不带 path(`pi@.<会话>`)⇒ cwd = ~/.pi/mail-sessions/<key>
                 ↓ **这个目录第一次不存在**
sandbox.js:168 if (!cwd || !exists(cwd)) return direct("拿不到会话工作区")  ← 不套沙箱
worker.mjs:388 ensureCwd(cwd, grouped)   ← 建目录的人**在决定之后**才跑
```

⇒ 无 path 的新会话**首回合不套沙箱** ⇒ 父进程不打 `AGENTMAIL_PI_SANDBOXED`
⇒ worker 的 `sandboxActive()` 为假 ⇒ `guardDecision(workspace, sandboxed=false)` = **`ask`**
⇒ **"界内不问"这条保证对无 path 新会话的首回合不成立**。

**端到端实测(不是推理)**,用真函数跑了一遍无 path 新会话:

```
解析结果 cwd = /root/.pi/mail-sessions/brand-new-key-probe | grouped = false
修复前(目录不存在): sandboxed = false | 拿不到会话工作区(…)—— 不猜   → guardDecision = ask
修复后(目录已建)  : sandboxed = true
  rw 含兜底目录 = true ; rw = […/brand-new-key-probe, /tmp, /root/.pi/agent]  → guardDecision = allow
```

**修法**(pi 给的最小修法):pool 在 `workerLaunch` 之前调 `ensureCwd(cwd, grouped)`。
`ensureCwd` 只在 `!grouped` 时建,所以 **N-2「笔误不落真目录」不受影响**:
path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回**兜底**+grouped=false ⇒ 建的是兜底目录,
笔误路径永远不会被创建。(这点我单独核过,因为"顺手建目录"最容易在这里越界。)
同时把 `resolveWorkspaceCwd` 的调用收成一次(原先在参数里内联算 cwd),
保证"用来判 exists 的 cwd"与"拿去当 --rw 的 cwd"是**同一个值**。

**判据(行为 + 结构,含顺序断言)**:
· 行为:兜底目录不存在时 `sandboxed=false` 且理由是"拿不到会话工作区"
  —— 这条**真规则是对的**,不能改成"无条件套"(`am-sandbox` 对不存在的 `--rw` fail closed,126);
· 结构:`src/pool.mjs` 里 `ensureCwd(` 必须出现在 `workerLaunch(` **之前**
  —— 光判"调没调"不够:顺序错了等于没补(这正是当初 worker 建目录的位置问题)。
★ 已先验区分力:移除 `ensureCwd` 调用 ⇒ 新判据**变红**(12/1),`cp` 恢复后 `cmp` 校验一致。

**为什么原判据护不住**:`sandbox-launch.test.mjs` 的 `fsRealShape()` 里 cwd 总是存在的,
而生产里这个 cwd **恰恰是 worker 自己建的** ——
与前面 `agentDir` 那次是同一个形状(夹具把生产形状简化掉的那一角,正是出问题的那一角),
只是换了另一角。这是同一条教训的第二个实例,值得并进 docs。

验证:pi 桥 **497/497**(+1);另三个桥 fail 0;`check-shared-libs.sh` exit 0;
`install.sh --check` exit 0;`drift` 报 4 处待部署(`src/paths.mjs` 新增 + 三个文件内容不同),
与本次改动一致 —— 这条红正是"待部署"的可操作信号。
2026-09-15 06:33:49 +08:00

224 lines
13 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.

/**
* worker 的**启动方式**按档位定:plan/workspace 套 Landlock 边界,full 档不套。
*
* # 为什么这条判据必须是"行为"而不是"文件里有那行字"
*
* 接线错的方式很安静:
* - 忘了把会话工作区放进 `--rw` ⇒ **界内也写不了**,worker 回合中途报 EACCES;
* - 把 full 档也套上 ⇒ 发件人声明了全权却被内核拦,行为与档位表相反;
* - 拿不到 cwd 时"猜一个目录" ⇒ 沙箱放行的是 A、worker 落在 B,最难查的那一类。
* 所以这里直接调 `workerLaunch`,断言它给出的**命令行**是什么。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_SANDBOX_BIN, guardDecision, sandboxBin, sandboxWritePaths, verifySandboxActive, workerLaunch,
} from '../lib/sandbox.js';
// 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。
const fsWith = (paths) => (p) => paths.includes(p);
/**
* 真实形状的 fs 夹具:`<agentDir>` **与**它的 `sessions` 子目录**都存在**。
*
* ★ 为什么要单独一个(2026-09-15):原先每个用例手写
* `fsWith([..., `${HOME}/.pi/agent/sessions`, ...])` —— 只列了 `sessions`,
* **没列 `agentDir` 本身**。于是"agentDir 在不在 rw 里"这件事
* **在这套测试里永远测不出来**:夹具把生产形状简化掉的那一角,恰好就是出问题的那一角。
* 实测的后果是 pi 那一侧的 Agent 整个不工作:
* `EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'`
* (凭据存储的锁文件写在 agentDir 直下,而 rw 里只有它的 sessions 子目录)。
* ⇒ 夹具形状必须贴着生产,否则判据护不住真正的那一处。
*/
const fsRealShape = (extra = []) => fsWith([
DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent`, `${HOME}/.pi/agent/sessions`,
`${HOME}/.agentmail`, '/dev/null', ...extra,
]);
const HERE = dirname(fileURLToPath(import.meta.url));
const NODE = '/usr/bin/node';
const WORKER = '/opt/agentmail/plugins/pi-mail-bridge/current/src/worker.mjs';
const CWD = '/home/program/agentmail';
const HOME = '/root';
const ENV = { TMPDIR: '/tmp', HOME };
test('★ workspace 档:套沙箱,且会话工作区在 rw 里', () => {
const exists = fsRealShape();
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, true, `workspace 档应当套沙箱(reason=${l.reason})`);
assert.equal(l.cmd, DEFAULT_SANDBOX_BIN);
// 会话工作区必须在 rw 里 —— 少了它,界内也写不了
const rwIdx = l.argv.indexOf(CWD);
assert.ok(rwIdx > 0 && l.argv[rwIdx - 1] === '--rw', `--rw 里必须有会话工作区:${l.argv.join(' ')}`);
// /dev/null 用 --rw-file(只放行写这个设备),否则 `cmd 2>/dev/null` 全线失败
const dnIdx = l.argv.indexOf('/dev/null');
assert.ok(dnIdx > 0 && l.argv[dnIdx - 1] === '--rw-file', `--rw-file 里必须有 /dev/null:${l.argv.join(' ')}`);
// 命令要在 `--` 之后,且是 node + worker
const sep = l.argv.indexOf('--');
assert.deepEqual(l.argv.slice(sep + 1), [NODE, WORKER], '`--` 之后应当是 node 与 worker 脚本');
});
test('★ full 档:不套(发件人已声明全权)', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', '/dev/null']);
const l = workerLaunch({ mode: 'full', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, false);
assert.deepEqual(l.argv, [WORKER], 'full 档必须直接起 worker');
assert.match(l.reason, /full/);
});
test('★ 拿不到会话工作区时不套,且理由说得出(不猜目录)', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, '/tmp', '/dev/null']);
const l = workerLaunch({ mode: 'workspace', cwd: '', nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, false, '没有 cwd 时必须退回不套 —— 猜一个 rw 会让界内也写不了');
assert.match(l.reason, /工作区/);
});
test('没有沙箱二进制时不套(但理由是明确的)', () => {
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists: fsWith([CWD]) });
assert.equal(l.sandboxed, false);
assert.match(l.reason, /am-sandbox/);
});
test('AGENTMAIL_PI_SANDBOX:显式关掉 / 显式指路', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, '/var/tmp/my-sandbox', CWD, '/tmp', '/dev/null']);
// 显式关:即使默认装好了也不套
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: 'off' }, exists), '');
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '0' }, exists), '');
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: ' ' }, exists), '');
// 显式指路:用给的那个
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/var/tmp/my-sandbox' }, exists), '/var/tmp/my-sandbox');
// 显式给了但不存在 ⇒ 不套(由调用方把理由打进日志,不静默裸跑)
assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/nope' }, exists), '');
// 没给 ⇒ 看默认位置
assert.equal(sandboxBin({}, exists), DEFAULT_SANDBOX_BIN);
assert.equal(sandboxBin({}, fsWith([])), '');
});
test('rw 只收**存在**的路径(am-sandbox 对不存在的 rw fail closed)', () => {
const exists = fsWith(['/tmp', '/dev/null']); // sessions/config 都不在
const { dirs, files } = sandboxWritePaths({ cwd: '/nope', env: ENV, exists, home: HOME });
assert.deepEqual(dirs, ['/tmp'], '不存在的目录不该进 rw');
assert.deepEqual(files, ['/dev/null']);
// 去重:同一个目录给两次会让 argv 里出现重复的 --rw(无害但说明来源没收敛)
const dup = sandboxWritePaths({ cwd: '/tmp', env: { TMPDIR: '/tmp' }, exists, home: HOME });
assert.equal(dup.dirs.filter((d) => d === '/tmp').length, 1, 'rw 目录要去重');
});
// ─── 闸门决策(这一步改的就是它)───
test('★ 档位 × 沙箱 → 被守卫工具该怎么办', () => {
const T = (mode, sandboxed) => guardDecision({ mode, sandboxed, toolName: 'bash', guarded: true });
// 有沙箱:workspace 档**不再逐条问人** —— 界内不问、界外由内核拒。
// 这一条是"被问 15 次"那个体验的终点;改回 'ask' 会立刻红。
assert.equal(T('workspace', true), 'allow', '有沙箱时 workspace 档不该再问人');
// 没有沙箱:必须继续问 —— 那是原来唯一的一层。
assert.equal(T('workspace', false), 'ask', '没有沙箱时必须逐条问人(回退到原来的行为)');
// full 档:任意情况下都放行(含没有沙箱时)。
assert.equal(T('full', false), 'allow');
assert.equal(T('full', true), 'allow');
// plan 档:一律拒(沙箱是第二层,先把话说清楚更快)。
assert.equal(T('plan', true), 'block');
assert.equal(T('plan', false), 'block');
// 不被守卫的工具(read/grep/find/ls):不拦。
assert.equal(guardDecision({ mode: 'workspace', sandboxed: false, toolName: 'read', guarded: false }), 'pass');
});
test('★ 沙箱自证:写得进界外 ⇒ 判定为"没有沙箱"(方向取严)', () => {
const blocked = verifySandboxActive({
canaryPath: '/virtual/canary',
writeFile: () => { const e = new Error('permission denied'); e.code = 'EACCES'; throw e; },
unlink: () => {},
});
assert.equal(blocked.active, true, '写界外被拒 = 在边界内');
assert.match(blocked.reason, /EACCES/);
const leaked = verifySandboxActive({
canaryPath: '/virtual/canary',
writeFile: () => {},
unlink: () => {},
});
assert.equal(leaked.active, false, '能写界外就必须判为"没沙箱" —— 否则闸门会既不问也不拦');
});
test('★ plan 档的 rw 里没有会话工作区("一个字都不许写")', () => {
const exists = fsRealShape();
const plan = workerLaunch({ mode: 'plan', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(plan.sandboxed, true, 'plan 档也要套沙箱(第二层)');
assert.ok(!plan.rw.includes(CWD), `plan 档的 rw 不该含会话工作区:${plan.rw.join(',')}`);
const ws = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.ok(ws.rw.includes(CWD), 'workspace 档必须含会话工作区(否则界内也写不了)');
});
test('★ agentDir **本身**必须在 rw 里(凭据存储的锁文件写在它直下)', () => {
// 这条是 2026-09-15 那次"pi 整个不工作"的回归判据。
// 症状不是"某次工具调用失败",而是 pi 连一条消息都处理不了:
// EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'
// 关键:只把 `<agentDir>/sessions` 放进 rw **不够** —— 锁文件在 agentDir 直下。
const exists = fsRealShape();
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
const agentDir = `${HOME}/.pi/agent`;
const idx = l.argv.indexOf(agentDir);
assert.ok(idx > 0 && l.argv[idx - 1] === '--rw',
`--rw 里必须有 agentDir 本身(凭据存储要在这里建 auth.json.lock):${l.argv.join(' ')}`);
// sessions 仍然要在(回合结束保存会话是另一个写点),两个都要有
assert.ok(l.rw.includes(`${agentDir}/sessions`), 'sessions 也要在 rw 里(保存会话是另一个写点)');
// 而且 agentDir 必须先出现或被包含 —— 两者都在,不是替代关系
assert.ok(l.rw.includes(agentDir) && l.rw.includes(`${agentDir}/sessions`),
'agentDir 与它的 sessions 是两个写点,必须同时在 rw 里');
});
test('回归判据的反面对照:agentDir 不存在时不该硬塞进 rw', () => {
// am-sandbox 对不存在的 --rw 路径 fail closed(退出码 126),
// 所以"加 agentDir"不能变成"无条件加" —— 仍然要经 exists 这一关。
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, '/dev/null']);
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.ok(!l.rw.includes(`${HOME}/.pi/agent`), 'agentDir 不存在时不该进 rw(否则 am-sandbox 直接 126)');
assert.ok(l.rw.includes(`${HOME}/.pi/agent/sessions`), '但 sessions 存在就照收');
});
test('★ 首回合也要套沙箱:pool 必须在算 launch **之前**把兜底目录建出来', () => {
// pi 评审 2026-09-15 报的缺口,链条:
// 地址不带 path(`pi@.<会话>`)⇒ cwd = 兜底 `~/.pi/mail-sessions/<key>`
// ⇒ **这个目录第一次不存在** ⇒ workerLaunch 走 "拿不到会话工作区" 那条退路
// ⇒ 不套沙箱 ⇒ 父进程不打标记 ⇒ guardDecision 对 workspace 档返回 **ask**
// ⇒ "界内不问"这条保证对无 path 新会话的**首回合**不成立。
// 而建目录的人原先只有 worker 的 ensureCwd —— 它在**决定之后**才跑。
//
// 为什么原来的判据护不住:本文件里 cwd 总是存在的(`fsRealShape()` 把 CWD 放进去),
// 而生产里那个 cwd 恰恰**是 worker 自己建的** —— 又是"夹具把生产形状简化掉的那一角"。
//
// 这里做成**行为判据**而不是读源码:把"目录不存在 → 不套"这条真规则喂给它,
// 断言"不存在时不套"(以免有人以为可以无条件套),再断言 pool 里确实先建。
const exists = fsWith([DEFAULT_SANDBOX_BIN, '/tmp', '/dev/null']); // 兜底目录**不存在**
const l = workerLaunch({ mode: 'workspace', cwd: `${HOME}/.pi/mail-sessions/new`, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, false, '目录不存在时确实不套(这条规则本身是对的,不能改成无条件套)');
assert.match(l.reason, /工作区/, '理由要说得出是"拿不到会话工作区"');
// 所以必须在建目录**之后**才问 workerLaunch —— 源码结构判据(顺序是关键,光看有没有不够)
const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8');
const ensureIdx = src.indexOf('ensureCwd(');
const launchIdx = src.indexOf('launch = workerLaunch(');
assert.ok(ensureIdx > 0, 'pool 必须调 ensureCwd(否则首回合不套沙箱)');
assert.ok(launchIdx > 0, 'pool 必须调 workerLaunch');
assert.ok(ensureIdx < launchIdx,
'ensureCwd 必须在 workerLaunch **之前** —— 顺序错了等于没补(这正是当初 worker 建目录的位置问题)');
// 而且必须用与 worker 同一个解析函数的结果,不许自己拼路径
assert.match(src, /resolveWorkspaceCwd\(/, 'pool 要用与 worker 同一个函数解析 cwd');
});
test('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => {
const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8');
assert.ok(src.includes("AGENTMAIL_PI_SANDBOXED: '1'"), 'pool 要在套沙箱时设标记');
const worker = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
assert.match(worker, /verifySandboxActive\(/, 'worker 必须现场自证,而不是只信标记');
assert.match(worker, /guardDecision\(/, '闸门必须走 guardDecision');
});