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 要在套沙箱时设标记');