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

@ -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'));
}
/**
* 找到这封邮件该落进的会话文件,装载它。
*