diff --git a/plugins/pi-mail-bridge/lib/sandbox.js b/plugins/pi-mail-bridge/lib/sandbox.js new file mode 100644 index 0000000..9a4e077 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/sandbox.js @@ -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 自己的会话登记(`/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(',')}` }; +} diff --git a/plugins/pi-mail-bridge/lib/workspace.js b/plugins/pi-mail-bridge/lib/workspace.js index a1b8745..dd5fa2e 100644 --- a/plugins/pi-mail-bridge/lib/workspace.js +++ b/plugins/pi-mail-bridge/lib/workspace.js @@ -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')); +} + /** * 校验寻址里的工作目录,不可用时返回调用方给的兜底。 * diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index ed25af1..cc6f563 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -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'], diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index 746871a..18b01b4 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -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')); -} - /** * 找到这封邮件该落进的会话文件,装载它。 * diff --git a/plugins/pi-mail-bridge/test/pool.test.mjs b/plugins/pi-mail-bridge/test/pool.test.mjs index 1672469..34c08f7 100644 --- a/plugins/pi-mail-bridge/test/pool.test.mjs +++ b/plugins/pi-mail-bridge/test/pool.test.mjs @@ -96,6 +96,9 @@ function makePool(opts = {}) { workerMaxMs: opts.workerMaxMs ?? 5000, maxAttempts: opts.maxAttempts, workerPath: opts.workerPath || STUB, + // 桩 worker 验的是调度不变量;沙箱开关属于另一条判据(sandbox-launch.test.mjs)。 + // 不显式关掉的话,判据会随"这台机器装没装 am-sandbox"而变 —— 那正是假绿的来源。 + env: { AGENTMAIL_PI_SANDBOX: 'off' }, }); return { pool, lines }; } @@ -221,6 +224,7 @@ test('config() 每次派活时重取:allowedModels 随心跳变,不能用快 onReconfigure: () => {}, maxWorkers: 1, workerMaxMs: 5000, + env: { AGENTMAIL_PI_SANDBOX: 'off' }, workerPath: STUB, }); p2.submit('mail', { mail_id: 'c1', session_id: 'C1', __hold: 20 }); diff --git a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs new file mode 100644 index 0000000..fb2687e --- /dev/null +++ b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs @@ -0,0 +1,87 @@ +/** + * worker 的**启动方式**按档位定:plan/workspace 套 Landlock 边界,full 档不套。 + * + * # 为什么这条判据必须是"行为"而不是"文件里有那行字" + * + * 接线错的方式很安静: + * - 忘了把会话工作区放进 `--rw` ⇒ **界内也写不了**,worker 回合中途报 EACCES; + * - 把 full 档也套上 ⇒ 发件人声明了全权却被内核拦,行为与档位表相反; + * - 拿不到 cwd 时"猜一个目录" ⇒ 沙箱放行的是 A、worker 落在 B,最难查的那一类。 + * 所以这里直接调 `workerLaunch`,断言它给出的**命令行**是什么。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_SANDBOX_BIN, sandboxBin, sandboxWritePaths, workerLaunch } from '../lib/sandbox.js'; + +// 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。 +const fsWith = (paths) => (p) => paths.includes(p); + +const NODE = '/usr/bin/node'; +const WORKER = '/opt/agentmail/plugins/pi-mail-bridge/current/src/worker.mjs'; +const CWD = '/home/program/agentmail'; +const HOME = '/root'; +const ENV = { TMPDIR: '/tmp', HOME }; + +test('★ workspace 档:套沙箱,且会话工作区在 rw 里', () => { + const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, `${HOME}/.agentmail`, '/dev/null']); + const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + + assert.equal(l.sandboxed, true, `workspace 档应当套沙箱(reason=${l.reason})`); + assert.equal(l.cmd, DEFAULT_SANDBOX_BIN); + // 会话工作区必须在 rw 里 —— 少了它,界内也写不了 + const rwIdx = l.argv.indexOf(CWD); + assert.ok(rwIdx > 0 && l.argv[rwIdx - 1] === '--rw', `--rw 里必须有会话工作区:${l.argv.join(' ')}`); + // /dev/null 用 --rw-file(只放行写这个设备),否则 `cmd 2>/dev/null` 全线失败 + const dnIdx = l.argv.indexOf('/dev/null'); + assert.ok(dnIdx > 0 && l.argv[dnIdx - 1] === '--rw-file', `--rw-file 里必须有 /dev/null:${l.argv.join(' ')}`); + // 命令要在 `--` 之后,且是 node + worker + const sep = l.argv.indexOf('--'); + assert.deepEqual(l.argv.slice(sep + 1), [NODE, WORKER], '`--` 之后应当是 node 与 worker 脚本'); +}); + +test('★ full 档:不套(发件人已声明全权)', () => { + const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', '/dev/null']); + const l = workerLaunch({ mode: 'full', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + assert.equal(l.sandboxed, false); + assert.deepEqual(l.argv, [WORKER], 'full 档必须直接起 worker'); + assert.match(l.reason, /full/); +}); + +test('★ 拿不到会话工作区时不套,且理由说得出(不猜目录)', () => { + const exists = fsWith([DEFAULT_SANDBOX_BIN, '/tmp', '/dev/null']); + const l = workerLaunch({ mode: 'workspace', cwd: '', nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + assert.equal(l.sandboxed, false, '没有 cwd 时必须退回不套 —— 猜一个 rw 会让界内也写不了'); + assert.match(l.reason, /工作区/); +}); + +test('没有沙箱二进制时不套(但理由是明确的)', () => { + const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists: fsWith([CWD]) }); + assert.equal(l.sandboxed, false); + assert.match(l.reason, /am-sandbox/); +}); + +test('AGENTMAIL_PI_SANDBOX:显式关掉 / 显式指路', () => { + const exists = fsWith([DEFAULT_SANDBOX_BIN, '/var/tmp/my-sandbox', CWD, '/tmp', '/dev/null']); + // 显式关:即使默认装好了也不套 + assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: 'off' }, exists), ''); + assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '0' }, exists), ''); + assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: ' ' }, exists), ''); + // 显式指路:用给的那个 + assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/var/tmp/my-sandbox' }, exists), '/var/tmp/my-sandbox'); + // 显式给了但不存在 ⇒ 不套(由调用方把理由打进日志,不静默裸跑) + assert.equal(sandboxBin({ AGENTMAIL_PI_SANDBOX: '/nope' }, exists), ''); + // 没给 ⇒ 看默认位置 + assert.equal(sandboxBin({}, exists), DEFAULT_SANDBOX_BIN); + assert.equal(sandboxBin({}, fsWith([])), ''); +}); + +test('rw 只收**存在**的路径(am-sandbox 对不存在的 rw fail closed)', () => { + const exists = fsWith(['/tmp', '/dev/null']); // sessions/config 都不在 + const { dirs, files } = sandboxWritePaths({ cwd: '/nope', env: ENV, exists, home: HOME }); + assert.deepEqual(dirs, ['/tmp'], '不存在的目录不该进 rw'); + assert.deepEqual(files, ['/dev/null']); + // 去重:同一个目录给两次会让 argv 里出现重复的 --rw(无害但说明来源没收敛) + const dup = sandboxWritePaths({ cwd: '/tmp', env: { TMPDIR: '/tmp' }, exists, home: HOME }); + assert.equal(dup.dirs.filter((d) => d === '/tmp').length, 1, 'rw 目录要去重'); +});