/** * 按**档位**决定 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 自己的会话登记(`/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(',')}` }; }