fix(pi-bridge): 首回合也要套沙箱 —— pool 在算 launch 前先把兜底目录建出来(pi 报的同形缺口)
pi 2026-09-15 报的缺口,我先逐环核了再改(**成立**):
```
pool.mjs:190 cwd = resolveWorkspaceCwd(to_workspace, piMailFallback(session_id)).cwd
↓ 地址不带 path(`pi@.<会话>`)⇒ cwd = ~/.pi/mail-sessions/<key>
↓ **这个目录第一次不存在**
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` 新增 + 三个文件内容不同),
与本次改动一致 —— 这条红正是"待部署"的可操作信号。
This commit is contained in:
@ -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/<key>`,
|
||||
// **这个目录第一次不存在** ⇒ `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"。
|
||||
|
||||
@ -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/<key>`
|
||||
// ⇒ **这个目录第一次不存在** ⇒ 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 要在套沙箱时设标记');
|
||||
|
||||
Reference in New Issue
Block a user