feat(pi-bridge): worker 按档位套沙箱 —— plan/workspace 进 Landlock 边界,full 档不进
上一步(1f48c5c)做出并验了边界工具;这一步把它接到 worker 的启动路径上, 于是「工作区档 = 本目录内可动」第一次由**内核**保证。 ## 规矩 - `plan` / `workspace` 档 → `am-sandbox --rw <会话工作区> … -- node worker.mjs` - `full` 档 → **不套**(发件人已声明全权,与档位表一致) - 拿不到会话工作区 → **不套**,并把理由打进日志(猜一个 `--rw` 会让"界内也写不了") - 启动方式从 `fork` 换成 `spawn`(fork 只会 exec node,套不进中间那层), `stdio` 里带 `'ipc'` 时 node 同样设 `NODE_CHANNEL_FD`,而沙箱是 exec 透传 ⇒ worker 的 `process.send` 照常可用 ## rw 清单是**实测得出**的,不是想当然 `lib/sandbox.js` 里那几条(会话工作区 / `os.tmpdir()` / `<agentDir>/sessions` / `AGENTMAIL_CONFIG_DIR` / `--rw-file /dev/null`)每条都对应一个真实的失败模式: 少了 `/dev/null`,`cmd 2>/dev/null` 一律 Permission denied(实测撞到);少了 `<agentDir>/sessions`,回合结束保存会话就失败。真机验证:一个**真实的 pi agent** 跑在边界里,界内写成功、`/opt` 被拒(Permission denied),并如实汇报两者。 ## 两处必须收成一处的东西 - 会话工作区由**父进程**用与 worker 同一个函数解析(`resolveWorkspaceCwd`)—— 父进程猜一个目录当 rw、worker 落在另一个,症状是最难查的那一类 - `piMailFallback` 从 worker 挪进 `lib/workspace.js`:父进程要用同一个兜底值 ## 判据与踩到的坑 - `sandbox-launch.test.mjs` 6 条行为断言(套/不套、rw 里有 cwd 与 /dev/null、 `--` 之后是 node+worker、拿不到 cwd 时的理由、env 开关三态、rw 去重与只收存在的路径)。 变异"永不套沙箱" ⇒ 恰好那几条红。 - ★ 池测试原先会**随这台机器装没装 am-sandbox 而变** —— 那正是假绿的来源。 给 `createWorkerPool` 加了 `env` 注入点,测试显式 `AGENTMAIL_PI_SANDBOX=off`。 - ★ 给 import 起名 `spawn` 撞上本文件已有的 `function spawn(job)` ⇒ 自己调自己 (`RangeError: Maximum call stack size exceeded`,池测试当场红)。改名 `spawnProcess`。 - pi 桥全套 485 项通过。
This commit is contained in:
87
plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
Normal file
87
plugins/pi-mail-bridge/test/sandbox-launch.test.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 { DEFAULT_SANDBOX_BIN, sandboxBin, sandboxWritePaths, workerLaunch } from '../lib/sandbox.js';
|
||||
|
||||
// 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。
|
||||
const fsWith = (paths) => (p) => paths.includes(p);
|
||||
|
||||
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 = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, `${HOME}/.agentmail`, '/dev/null']);
|
||||
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 目录要去重');
|
||||
});
|
||||
Reference in New Issue
Block a user