Files
MailUI4Agents/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
JianFeeeee 7fe279676a fix(pi-bridge)!: --rw 的 cwd 与 worker 实际用的 cwd 收成**一处决定**(pi 探针实测的第三例)
pi 2026-09-15 报、我用探针复核**成立**,而且它把 `99e6560` 的代价也一起说清了。

**分叉在哪**:cwd 有**两个来源**,而会话键 `keyOf(data) = data.session_id` **只看 session_id**:

```
父进程(算 --rw)  cwd = resolveWorkspaceCwd(to_workspace, …)   ← 来源:**这封信的地址**
子进程(真去干活)  cwd = job.session.cwd || resolveWorkspaceCwd(…) ← 来源:**会话上次实际用的 cwd**
```

于是同一 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`),父进程按**新地址**
算 rw,worker 却**复用会话、落在旧 cwd**。实测(真函数,`exists` 注入):

```
父进程算的 cwd  = /root/.pi/mail-sessions/sess-x
worker 实际会用 = /home/program/agentmail   (= 该会话的 state.cwd)
rw 含 worker 实际 cwd? = false  ⇒ 界内 EACCES
```

不是假想:本线程那条会话自上线起每次启动的 rw 都是 `/home/program/agentmail`,
那就是它的 `state.cwd` —— 此时来一封 `pi@.<会话>`(不带 path)的信就会踩到。

**★ `99e6560` 在这个组合上把失败方式变坏了**(这条必须记下来,我原先只报了它的好处):
· 之前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**)
· 之后:目录被建出来(那次修复的效果)⇒ **套上沙箱,而 rw 是地址算的那个**
  ⇒ worker 在会话自己的 cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的)
我用 `ensureCwd` 前/后各跑一次验证了这条因果,实测 `(b) 建了兜底目录: sandboxed = true,
rw 含 worker 实际 cwd? = false` —— 与 pi 报的 `sandboxed=true` 一字不差(他给了那个值,
我最初复现成 false,差别就在"兜底目录建没建",属于应用 `99e6560` 前后)。
⇒ **两处修复必须一起部署**,否则中间态是"界内也写不了"(比原先多问一次更糟)。
现在两次提交都在仓库、`drift` 报 5 处待部署,会一起上线。

**修法**:新增 `src/turn-cwd.mjs` 的纯函数 `resolveTurnCwd()` —— 输入全部来自**父进程也拿得到的
public 状态**(`sessionReused` / `storedCwd` / `toWorkspace` / `sessionKey` / 注入的解析函数),
父子两侧都从它取值;父进程再把决定**注入 job**(`session.resolvedCwd`),worker **消费**它、
不再自己推导。于是"三来源变一来源"落了第一步。

**判据(pi 要的那条等式)**:
· ★**等式**:用真 `sandboxWritePaths` 算 rw,断言"**`--rw` 里的 cwd === worker 会用的 cwd**";
  并附**反面对照**:按地址算出来的 rw **不含** `state.cwd`(= 修复前的错位状态)。
· 复用/非复用/`storedCwd` 为空三种输入各一条。
· 结构:pool 必须把决定交给 `workerLaunch` **并**注入 job;worker 取 cwd 的**每一处表达式**
  都必须先看 `resolvedCwd`。

★ **两处我自己的判据缺陷,都是变异测出来的,都记在测试文件里**:
1. 上一轮我在 `sandbox-launch.test.mjs` 写的 `assert.match(src, /resolveWorkspaceCwd\(/)`
   **本来就不该红也不该绿** —— 它护的是**写法**(池子直接调那个函数),而引入 `resolveTurnCwd`
   后池子改成"当参数传进去"(更对),它才红。**红得对**:它当初断言的是实现细节,
   不是它想要的性质。已改为断言性质(解析函数必须来自共用模块、且被显式传给纯函数)。
   顺带说明:它此前一直是**假绿**还是**真绿**我没法回测,但**它在引入纯函数后才红**说明它
   确实绑定了写法 —— 这正是"判据的适用范围没写出来"那一类。
2. 新版 worker 侧结构判据**第一版不具区分力**:只断言"文件里出现 `resolvedCwd`",
   把消费那一支删掉、退回 `job.session.cwd`,正则**仍然匹配**(别处还留着它)⇒ 变异后依旧全绿。
   已改为断言**优先级**(取 cwd 的表达式必须含 `resolvedCwd`)。
   ★ 变异实测:修好后重做同一变异 ⇒ 判据**变红**;两次变异均 `cp` 恢复 + `cmp` 校验。

**残余(未修,已进 DEBTS)**:**接管会话**那条路 worker 用会话文件 header 里的 `info.cwd`,
父进程读不到 ⇒ 首回合仍可能错位。父进程要拿它得用 `session-scan.mjs`,而 `readHeader` 未导出、
整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 240MB)。
彻底方向即 pi 说的:把"这次用哪个 cwd"完全收成父进程一处决定,worker 只消费。现在做不做等定。

验证:pi 桥 **502/502**;另三个桥 fail 0;`check-shared-libs` exit 0;`install.sh --check` exit 0。
2026-09-15 06:47:10 +08:00

235 lines
14 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 **同一个解析函数**,不许自己拼路径。
// ★ 注意判据在 2026-09-15 改过一次写法:原先断言的是"pool 里出现
// `resolveWorkspaceCwd(`" —— 那是个**实现细节**。引入 `resolveTurnCwd` 后
// 池子不再直接调它,而是**当参数传进去**(这样"这一轮用哪个 cwd"才收成一处决定),
// 于是旧断言红了。**红得对**:它当初护的是一条写法,而不是它真正想要的性质。
// 现在断言性质本身:解析函数必须来自共用模块、且被显式传给那个纯函数。
assert.match(src, /import \{[^}]*resolveWorkspaceCwd[^}]*\} from '\.\.\/lib\/workspace\.js'/,
'pool 的解析函数必须来自共用模块(不许自己拼路径)');
assert.match(src, /resolve:\s*resolveWorkspaceCwd/, 'pool 必须把与 worker 同一个解析函数交给 resolveTurnCwd');
// 并且那个纯函数确实**用了**注入进来的解析函数(否则传了也白传)
const turnCwd = readFileSync(join(HERE, '..', 'src', 'turn-cwd.mjs'), 'utf8');
assert.match(turnCwd, /resolve\(toWorkspace, fallback\(sessionKey\)\)/,
'resolveTurnCwd 必须实际调用注入的解析函数');
});
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');
});