From 99e656012238bfb206e9b9a545f408a6777c96be Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 06:33:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge):=20=E9=A6=96=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E4=B9=9F=E8=A6=81=E5=A5=97=E6=B2=99=E7=AE=B1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20pool=20=E5=9C=A8=E7=AE=97=20launch=20=E5=89=8D?= =?UTF-8?q?=E5=85=88=E6=8A=8A=E5=85=9C=E5=BA=95=E7=9B=AE=E5=BD=95=E5=BB=BA?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=88pi=20=E6=8A=A5=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E5=BD=A2=E7=BC=BA=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-15 报的缺口,我先逐环核了再改(**成立**): ``` pool.mjs:190 cwd = resolveWorkspaceCwd(to_workspace, piMailFallback(session_id)).cwd ↓ 地址不带 path(`pi@.<会话>`)⇒ cwd = ~/.pi/mail-sessions/ ↓ **这个目录第一次不存在** sandbox.js:168 if (!cwd || !exists(cwd)) return direct("拿不到会话工作区") ← 不套沙箱 worker.mjs:388 ensureCwd(cwd, grouped) ← 建目录的人**在决定之后**才跑 ``` ⇒ 无 path 的新会话**首回合不套沙箱** ⇒ 父进程不打 `AGENTMAIL_PI_SANDBOXED` ⇒ worker 的 `sandboxActive()` 为假 ⇒ `guardDecision(workspace, sandboxed=false)` = **`ask`** ⇒ **"界内不问"这条保证对无 path 新会话的首回合不成立**。 **端到端实测(不是推理)**,用真函数跑了一遍无 path 新会话: ``` 解析结果 cwd = /root/.pi/mail-sessions/brand-new-key-probe | grouped = false 修复前(目录不存在): sandboxed = false | 拿不到会话工作区(…)—— 不猜 → guardDecision = ask 修复后(目录已建) : sandboxed = true rw 含兜底目录 = true ; rw = […/brand-new-key-probe, /tmp, /root/.pi/agent] → guardDecision = allow ``` **修法**(pi 给的最小修法):pool 在 `workerLaunch` 之前调 `ensureCwd(cwd, grouped)`。 `ensureCwd` 只在 `!grouped` 时建,所以 **N-2「笔误不落真目录」不受影响**: path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回**兜底**+grouped=false ⇒ 建的是兜底目录, 笔误路径永远不会被创建。(这点我单独核过,因为"顺手建目录"最容易在这里越界。) 同时把 `resolveWorkspaceCwd` 的调用收成一次(原先在参数里内联算 cwd), 保证"用来判 exists 的 cwd"与"拿去当 --rw 的 cwd"是**同一个值**。 **判据(行为 + 结构,含顺序断言)**: · 行为:兜底目录不存在时 `sandboxed=false` 且理由是"拿不到会话工作区" —— 这条**真规则是对的**,不能改成"无条件套"(`am-sandbox` 对不存在的 `--rw` fail closed,126); · 结构:`src/pool.mjs` 里 `ensureCwd(` 必须出现在 `workerLaunch(` **之前** —— 光判"调没调"不够:顺序错了等于没补(这正是当初 worker 建目录的位置问题)。 ★ 已先验区分力:移除 `ensureCwd` 调用 ⇒ 新判据**变红**(12/1),`cp` 恢复后 `cmp` 校验一致。 **为什么原判据护不住**:`sandbox-launch.test.mjs` 的 `fsRealShape()` 里 cwd 总是存在的, 而生产里这个 cwd **恰恰是 worker 自己建的** —— 与前面 `agentDir` 那次是同一个形状(夹具把生产形状简化掉的那一角,正是出问题的那一角), 只是换了另一角。这是同一条教训的第二个实例,值得并进 docs。 验证:pi 桥 **497/497**(+1);另三个桥 fail 0;`check-shared-libs.sh` exit 0; `install.sh --check` exit 0;`drift` 报 4 处待部署(`src/paths.mjs` 新增 + 三个文件内容不同), 与本次改动一致 —— 这条红正是"待部署"的可操作信号。 --- plugins/pi-mail-bridge/src/pool.mjs | 19 ++++++++++-- .../test/sandbox-launch.test.mjs | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 7501d5d..9d6d8c3 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -74,7 +74,7 @@ import { fileURLToPath } from 'node:url'; import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; import { workerLaunch } from '../lib/sandbox.js'; -import { resolveWorkspaceCwd } from '../lib/workspace.js'; +import { ensureCwd, resolveWorkspaceCwd } from '../lib/workspace.js'; import { piMailFallback } from './paths.mjs'; const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url)); @@ -183,11 +183,26 @@ export function createWorkerPool({ // 用 spawn 而不是 fork:fork 只会 exec node,套不进中间那层沙箱。 // `stdio` 里带 'ipc' 时 node 同样会给子进程设 NODE_CHANNEL_FD,而沙箱是 // exec 透传,所以 worker 的 `process.send` 照常可用。 + // ★ 目录必须**先建出来**,否则首回合不套沙箱(pi 评审 2026-09-15)。 + // + // 链是:地址不带 path(`pi@.<会话>`)⇒ cwd = 兜底 `~/.pi/mail-sessions/`, + // **这个目录第一次不存在** ⇒ `workerLaunch` 的 `if (!cwd || !exists(cwd))` 走 + // "拿不到会话工作区"那条退路 ⇒ **不套沙箱** ⇒ 父进程不打标记 ⇒ + // worker 的 `sandboxActive()` 为假 ⇒ `guardDecision` 对 workspace 档返回 **`ask`** + // ⇒ **"界内不问"这条保证对无 path 新会话的首回合不成立**。 + // + // 而建目录的人原先只有 worker(`worker.mjs` 的 `ensureCwd`)——它在**决定之后**才跑。 + // 所以这里补一次:先建,再算 launch。`ensureCwd` 只在 `!grouped` 时建 + // (`grouped` = path 位是一个已存在的目录),所以 N-2「笔误不落真目录」不受影响: + // path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回兜底 + grouped=false, + // 于是建的是**兜底目录**,笔误路径永远不会被创建。 let launch; try { + const ws = resolveWorkspaceCwd(job.data?.to_workspace, piMailFallback(job.data?.session_id)); + ensureCwd(ws.cwd, ws.grouped); launch = workerLaunch({ mode: job.data?.permission_mode || 'workspace', - cwd: resolveWorkspaceCwd(job.data?.to_workspace, piMailFallback(job.data?.session_id)).cwd, + cwd: ws.cwd, nodePath: process.execPath, workerPath, // 注入点:判据不许依赖"这台机器装没装 /opt/agentmail/bin/am-sandbox"。 diff --git a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs index 2e14bc6..eb85320 100644 --- a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs +++ b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs @@ -184,6 +184,36 @@ test('回归判据的反面对照:agentDir 不存在时不该硬塞进 rw', () assert.ok(l.rw.includes(`${HOME}/.pi/agent/sessions`), '但 sessions 存在就照收'); }); +test('★ 首回合也要套沙箱:pool 必须在算 launch **之前**把兜底目录建出来', () => { + // pi 评审 2026-09-15 报的缺口,链条: + // 地址不带 path(`pi@.<会话>`)⇒ cwd = 兜底 `~/.pi/mail-sessions/` + // ⇒ **这个目录第一次不存在** ⇒ workerLaunch 走 "拿不到会话工作区" 那条退路 + // ⇒ 不套沙箱 ⇒ 父进程不打标记 ⇒ guardDecision 对 workspace 档返回 **ask** + // ⇒ "界内不问"这条保证对无 path 新会话的**首回合**不成立。 + // 而建目录的人原先只有 worker 的 ensureCwd —— 它在**决定之后**才跑。 + // + // 为什么原来的判据护不住:本文件里 cwd 总是存在的(`fsRealShape()` 把 CWD 放进去), + // 而生产里那个 cwd 恰恰**是 worker 自己建的** —— 又是"夹具把生产形状简化掉的那一角"。 + // + // 这里做成**行为判据**而不是读源码:把"目录不存在 → 不套"这条真规则喂给它, + // 断言"不存在时不套"(以免有人以为可以无条件套),再断言 pool 里确实先建。 + const exists = fsWith([DEFAULT_SANDBOX_BIN, '/tmp', '/dev/null']); // 兜底目录**不存在** + const l = workerLaunch({ mode: 'workspace', cwd: `${HOME}/.pi/mail-sessions/new`, nodePath: NODE, workerPath: WORKER, env: ENV, exists }); + assert.equal(l.sandboxed, false, '目录不存在时确实不套(这条规则本身是对的,不能改成无条件套)'); + assert.match(l.reason, /工作区/, '理由要说得出是"拿不到会话工作区"'); + + // 所以必须在建目录**之后**才问 workerLaunch —— 源码结构判据(顺序是关键,光看有没有不够) + const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8'); + const ensureIdx = src.indexOf('ensureCwd('); + const launchIdx = src.indexOf('launch = workerLaunch('); + assert.ok(ensureIdx > 0, 'pool 必须调 ensureCwd(否则首回合不套沙箱)'); + assert.ok(launchIdx > 0, 'pool 必须调 workerLaunch'); + assert.ok(ensureIdx < launchIdx, + 'ensureCwd 必须在 workerLaunch **之前** —— 顺序错了等于没补(这正是当初 worker 建目录的位置问题)'); + // 而且必须用与 worker 同一个解析函数的结果,不许自己拼路径 + assert.match(src, /resolveWorkspaceCwd\(/, 'pool 要用与 worker 同一个函数解析 cwd'); +}); + test('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => { const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8'); assert.ok(src.includes("AGENTMAIL_PI_SANDBOXED: '1'"), 'pool 要在套沙箱时设标记');