Files
MailUI4Agents/plugins/pi-mail-bridge/lib/sandbox.js
JianFeeeee 64f002cf66 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 项通过。
2026-09-14 23:43:06 +08:00

114 lines
5.1 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 怎么起:直接 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(',')}` };
}