上一步(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 项通过。
114 lines
5.1 KiB
JavaScript
114 lines
5.1 KiB
JavaScript
/**
|
||
* 按**档位**决定 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(',')}` };
|
||
}
|