Files
MailUI4Agents/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
JianFeeeee 7f03ee7ca2 fix(pi-bridge)!: 沙箱 rw 漏了 agentDir 本身 —— pi 侧的 Agent 整个不工作(凭据存储的锁文件写在它直下)
**症状(实测,2026-09-15)**:pi 处理不了任何一条消息 —— 回给 dsh 的是一封
「处理失败」通知:

    auth: Credential store read failed for llmsproxy:
    EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'
    已尝试 1 个:llmsproxy/AUTO

即**不是某次工具调用失败,而是这个 agent 完全不工作** —— 而它正是这条线上唯一的对端。

**根因**:`sandboxWritePaths` 把 `<agentDir>/sessions` 放进了 `--rw`,
**却没放 `<agentDir>` 本身**:

    pushDir(join(agentDir, 'sessions'));   // 少了 pushDir(agentDir)

而 pi 的凭据存储在 **`<agentDir>` 直下**建锁文件 `auth.json.lock`
⇒ Landlock 拒绝在 `agentDir` 里新建条目 ⇒ 读凭据这条路直接失败。

**因果链已在真二进制上闭合**(`/opt/agentmail/bin/am-sandbox`,非推理):

    # 只给 sub、不给父(= 修复前的形状)
    --rw /tmp/ll2/allowed/sub  →  echo > /tmp/ll2/allowed/newfile
    /bin/sh: cannot create …: Permission denied     ← 就是 pi 的那个 EACCES
    # 给父目录(= 修复后的形状)
    --rw /tmp/ll2/allowed      →  退出码 0,文件建出来

也确认了 `am-sandbox` 的语义确实是「**及其子树**」(`--rw` 给出的目录连同子树可写),
所以补上父目录这一条就够,不需要为锁文件单独加 `--rw-file`。

**为什么以前的判据护不住这一处 —— 夹具形状把出问题的那一角简化掉了**:
`sandbox-launch.test.mjs` 每个用例手写
`fsWith([..., `${HOME}/.pi/agent/sessions`, ...])`,**只列 sessions、不列 agentDir**,
于是"agentDir 在不在 rw 里"在这套测试里**永远测不出来**。
已加一个贴着生产形状的夹具 `fsRealShape()`(agentDir 与 sessions **都在**),
并把两个 workerLaunch 用例换成它。

**判据(两条,含反面对照)**:
· `agentDir` 本身必须在 `--rw` 里,且 `sessions` 也仍在(两个写点,不是替代关系);
· 反面对照:`agentDir` **不存在**时不得硬塞进 rw ——
  `am-sandbox` 对不存在的 `--rw` 路径 fail closed(退出码 126),
  所以"加 agentDir"不能变成"无条件加"。
★ 已按既定纪律先验区分力:临时移除 `pushDir(agentDir)` ⇒ 新用例**变红**(11/1),
`cp` 恢复后 `cmp` 校验一致。

**旁注(写点清单的教训)**:原来的注释只按"我们已知的写点"列(会话工作区、临时目录、
/dev/null、sessions、配置目录),而**凭据存储在它自己的目录里加锁**是另一个写点,
且它在**读凭据**这条路上 —— 所以漏了它的症状不是"某个工具不能用",而是"agent 不工作"。
写点清单要按**真实进程的行为**列,不能只按已知的那几处列。

验证:pi 桥 491/491(新增 2 条);sandbox-launch 12/12;`go test ./cmd/am-sandbox/` ok。
2026-09-15 00:13:13 +08:00

194 lines
11 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('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => {
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');
});