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:
2026-09-14 23:43:06 +08:00
parent 1f48c5c4ff
commit 64f002cf66
6 changed files with 259 additions and 14 deletions

View File

@ -0,0 +1,113 @@
/**
* 按**档位**决定 worker 怎么起:直接 node,还是先套一层 `am-sandbox`(Landlock)。
*
* # 这一步补的是「工作区档」缺的那一维
*
* 档位表写的是「本目录内可动、越界要问人」,而桥原先只能按**工具名**判
* (bash/write/edit 一律问人)—— 因为命令的影响范围无法从文本静态判定。沙箱把
* 「界内/界外」交给内核去判,于是这条档位第一次是真的:
*
* plan / workspace 档 → worker 跑在 `am-sandbox --rw <会话工作区> …` 里面,
* 界外写是 EACCES(不依赖模型配合、也不依赖人点得准)
* full 档 → **不套**(发件人已声明全权,与档位表一致)
*
* # 为什么 rw 里既有会话工作区、又有几个固定目录
*
* 沙箱只放行「写」,而且必须把**跑起来真正需要的写点**列全,否则 worker 自己会崩:
*
* - 会话工作区:agent 该动手的地方
* - 临时目录(`os.tmpdir()`):构建/工具链的日常写点(dsh 也是这么放的)
* - `/dev/null`:**任何 `cmd 2>/dev/null` 都是一次界外写** —— 实测漏了它会让
* 一整代命令集体 Permission denied。用 `--rw-file`(只放行写这个设备,
* 不放行在 /dev 里建/删任何东西)
* - pi 自己的会话登记(`<agentDir>/sessions`):不写它,回合结束保存会话就失败
* - 桥自己的配置目录(`AGENTMAIL_CONFIG_DIR`,默认 `~/.agentmail`):
* worker 会往里面写 `explicit-sends.jsonl`
*
* 这几条是**实测得出**的写点清单,不是想当然:少一条的症状是 worker 回合中途报
* EACCES/EROFS,而不是"沙箱没生效"。
*
* # 拿不到工作区就不套
*
* `--rw` 必须给真实目录,`am-sandbox` 对不存在的路径直接 fail closed(退出码 126)
* —— 那会让这条会话连 worker 都起不来。所以这里取不到 cwd 时**不猜**:退回不套沙箱,
* 由调用方把原因打进日志(不许静默)。
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
/** 边界工具的默认位置(由 redeploy-gateway.sh / install.sh 安装)。 */
export const DEFAULT_SANDBOX_BIN = '/opt/agentmail/bin/am-sandbox';
/**
* 取沙箱二进制路径;返回空串表示"不套"。
*
* `AGENTMAIL_PI_SANDBOX` 显式给出时以它为准(`""` / `0` / `off` = 明确关掉);
* 没给时看默认位置在不在。**显式给了却不存在的路径**也算关掉,但要由调用方出声
* —— 静默降级成"裸跑"正是这套东西最不该有的行为。
*/
export function sandboxBin(env = process.env, exists = existsSync) {
const raw = env.AGENTMAIL_PI_SANDBOX;
if (raw !== undefined) {
const v = String(raw).trim();
if (v === '' || v === '0' || v.toLowerCase() === 'off') return '';
return exists(v) ? v : '';
}
return exists(DEFAULT_SANDBOX_BIN) ? DEFAULT_SANDBOX_BIN : '';
}
/**
* 沙箱要放行的写点。返回 `{ dirs, files }`。
*
* 只保留**存在**的目录/文件:`am-sandbox` 对不存在的 rw 路径 fail closed,
* 而这里宁可少放行也不要让 worker 起不来(少放行的症状是可诊断的 EACCES)。
*/
export function sandboxWritePaths({
cwd, env = process.env, exists = existsSync, tmp = undefined, home = undefined,
}) {
const dirs = [];
const files = [];
const pushDir = (d) => { if (d && exists(d) && !dirs.includes(d)) dirs.push(d); };
pushDir(cwd);
pushDir(tmp ?? (env.TMPDIR || '/tmp'));
const agentDir = env.PI_CODING_AGENT_DIR || join(home ?? homedir(), '.pi', 'agent');
pushDir(join(agentDir, 'sessions'));
pushDir(env.AGENTMAIL_CONFIG_DIR || join(home ?? homedir(), '.agentmail'));
const devNull = '/dev/null';
if (exists(devNull)) files.push(devNull);
return { dirs, files };
}
/**
* 决定这次的 worker 启动方式。
*
* @param {object} o
* @param {number|string} [o.mode] 会话档位(plan / workspace / full)
* @param {string} [o.cwd] 会话工作区(由调用方用与 worker 同一个函数解析)
* @param {string} o.nodePath node 可执行文件
* @param {string} o.workerPath worker 脚本
* @returns {{cmd: string, argv: string[], sandboxed: boolean, reason: string}}
*/
export function workerLaunch({
mode = 'workspace', cwd = '', nodePath, workerPath, env = process.env, exists = existsSync,
}) {
const direct = (reason) => ({ cmd: nodePath, argv: [workerPath], sandboxed: false, reason });
if (String(mode) === 'full') return direct('full 档:发件人已声明全权');
const bin = sandboxBin(env, exists);
if (!bin) return direct('没有可用的 am-sandbox(未安装 / 被显式关掉 / 路径不存在)');
if (!cwd || !exists(cwd)) return direct(`拿不到会话工作区(cwd=${cwd || '空'})—— 不猜`);
const { dirs, files } = sandboxWritePaths({ cwd, env, exists });
const argv = [];
for (const d of dirs) argv.push('--rw', d);
for (const f of files) argv.push('--rw-file', f);
argv.push('--', nodePath, workerPath);
return { cmd: bin, argv, sandboxed: true, reason: `rw=${dirs.join(',')}` };
}

View File

@ -13,6 +13,21 @@ import { existsSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
/**
* 没有可用 `to_workspace` 时的兜底目录(pi 专用)。
*
* 命名与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在 lib/ 下
* (三平台逐字节相同)写死了 `.dsh`,不能为 pi 改 —— pi 的会话落进 `~/.dsh/`
* 会让人以为是 DSH 在干活。
*
* 放在这里而不是 worker 里:**父进程要用同一个兜底值**。worker 的沙箱 rw 列表
* 由父进程算(`pool.mjs` → `lib/sandbox.js`),父进程猜一个目录而 worker 落在另一个,
* 症状是"界内也写不了"—— 为此把兜底收成一处,两边 import 同一个函数。
*/
export function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*

View File

@ -67,10 +67,14 @@
* 一处只增不减的结构。
*/
import { fork } from 'node:child_process';
// ★ 起名 spawnProcess:本文件里已经有一个 `function spawn(job)`,直接叫 spawn 会
// 自己调自己(实测踩到:RangeError: Maximum call stack size exceeded)。
import { spawn as spawnProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
import { workerLaunch } from '../lib/sandbox.js';
import { piMailFallback, resolveWorkspaceCwd } from '../lib/workspace.js';
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
@ -90,6 +94,7 @@ const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
export function createWorkerPool({
log, config, onReconfigure,
maxWorkers = 3, maxParked = 5, workerMaxMs = 600_000, maxAttempts = 3, workerPath = WORKER_PATH,
env = process.env,
}) {
/** 正在跑的 worker:mailSessionKey -> {child, mailID, startedAt, timer} */
const running = new Map();
@ -167,7 +172,39 @@ export function createWorkerPool({
function spawn(job) {
const state = sessionState.get(job.key) || { grants: new Set(), lastSyncedName: '' };
const child = fork(workerPath, [], {
// worker 的启动方式按**档位**定:plan/workspace 套 Landlock 边界,full 档不套。
//
// 会话工作区在父进程这边用**与 worker 同一个函数**解析(`resolveWorkspaceCwd`),
// 不是另写一套规则 —— 父进程猜一个目录当 `--rw`,而 worker 实际落在另一个目录,
// 症状是"界内也写不了",最难查的那一类。
//
// 用 spawn 而不是 fork:fork 只会 exec node,套不进中间那层沙箱。
// `stdio` 里带 'ipc' 时 node 同样会给子进程设 NODE_CHANNEL_FD,而沙箱是
// exec 透传,所以 worker 的 `process.send` 照常可用。
let launch;
try {
launch = workerLaunch({
mode: job.data?.permission_mode || 'workspace',
cwd: resolveWorkspaceCwd(job.data?.to_workspace, piMailFallback(job.data?.session_id)).cwd,
nodePath: process.execPath,
workerPath,
// 注入点:判据不许依赖"这台机器装没装 /opt/agentmail/bin/am-sandbox"。
// 生产走 process.env(默认即此),测试传 { AGENTMAIL_PI_SANDBOX: 'off' }。
env,
});
} catch (e) {
// 解析工作区失败不该让整封邮件没了:退回不套沙箱,并把原因留在日志里
// (静默降级是这套东西最不该有的行为)。
launch = { cmd: process.execPath, argv: [workerPath], sandboxed: false, reason: `解析工作区失败: ${e?.message || e}` };
}
if (launch.sandboxed) {
log(`worker 套沙箱(${launch.reason})`);
} else {
log(`worker 不套沙箱:${launch.reason}`);
}
const child = spawnProcess(launch.cmd, launch.argv, {
// stdio 继承:worker 里 pi SDK 自己打的东西直接进 journalctl。
// 'ipc' 必须显式列出,否则 process.send 不存在。
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],

View File

@ -49,7 +49,7 @@ import { createMailTools } from './tools.mjs';
import { openSession, runTurn } from './session-pool.mjs';
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs';
import { planNamingSync, planWriteBack } from './naming.mjs';
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { piMailFallback, resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { modelAttemptOrder, renderFailureReport } from '../lib/model-scope.js';
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
import { autoRelayDecision } from '../lib/relay-policy.js';
@ -289,17 +289,6 @@ function describeToolCall(event) {
// ─── 会话装载 ───
/**
* 没有可用 `to_workspace` 时的兜底目录。
*
* 与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在 lib/ 下
* (三平台逐字节相同),写死了 `.dsh`,不能为 pi 改 —— pi 的会话落进 `~/.dsh/`
* 会让人以为是 DSH 在干活。
*/
function piMailFallback(sessionKey) {
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 找到这封邮件该落进的会话文件,装载它。
*

View File

@ -96,6 +96,9 @@ function makePool(opts = {}) {
workerMaxMs: opts.workerMaxMs ?? 5000,
maxAttempts: opts.maxAttempts,
workerPath: opts.workerPath || STUB,
// 桩 worker 验的是调度不变量;沙箱开关属于另一条判据(sandbox-launch.test.mjs)。
// 不显式关掉的话,判据会随"这台机器装没装 am-sandbox"而变 —— 那正是假绿的来源。
env: { AGENTMAIL_PI_SANDBOX: 'off' },
});
return { pool, lines };
}
@ -221,6 +224,7 @@ test('config() 每次派活时重取:allowedModels 随心跳变,不能用快
onReconfigure: () => {},
maxWorkers: 1,
workerMaxMs: 5000,
env: { AGENTMAIL_PI_SANDBOX: 'off' },
workerPath: STUB,
});
p2.submit('mail', { mail_id: 'c1', session_id: 'C1', __hold: 20 });

View 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 目录要去重');
});