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:
@ -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'],
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到这封邮件该落进的会话文件,装载它。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user