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'));
}
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*