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。
235 lines
14 KiB
JavaScript
235 lines
14 KiB
JavaScript
/**
|
||
* 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');
|
||
});
|