fix(pi-bridge)!: 沙箱 rw 漏了 agentDir 本身 —— pi 侧的 Agent 整个不工作(凭据存储的锁文件写在它直下)

**症状(实测,2026-09-15)**:pi 处理不了任何一条消息 —— 回给 dsh 的是一封
「处理失败」通知:

    auth: Credential store read failed for llmsproxy:
    EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'
    已尝试 1 个:llmsproxy/AUTO

即**不是某次工具调用失败,而是这个 agent 完全不工作** —— 而它正是这条线上唯一的对端。

**根因**:`sandboxWritePaths` 把 `<agentDir>/sessions` 放进了 `--rw`,
**却没放 `<agentDir>` 本身**:

    pushDir(join(agentDir, 'sessions'));   // 少了 pushDir(agentDir)

而 pi 的凭据存储在 **`<agentDir>` 直下**建锁文件 `auth.json.lock`
⇒ Landlock 拒绝在 `agentDir` 里新建条目 ⇒ 读凭据这条路直接失败。

**因果链已在真二进制上闭合**(`/opt/agentmail/bin/am-sandbox`,非推理):

    # 只给 sub、不给父(= 修复前的形状)
    --rw /tmp/ll2/allowed/sub  →  echo > /tmp/ll2/allowed/newfile
    /bin/sh: cannot create …: Permission denied     ← 就是 pi 的那个 EACCES
    # 给父目录(= 修复后的形状)
    --rw /tmp/ll2/allowed      →  退出码 0,文件建出来

也确认了 `am-sandbox` 的语义确实是「**及其子树**」(`--rw` 给出的目录连同子树可写),
所以补上父目录这一条就够,不需要为锁文件单独加 `--rw-file`。

**为什么以前的判据护不住这一处 —— 夹具形状把出问题的那一角简化掉了**:
`sandbox-launch.test.mjs` 每个用例手写
`fsWith([..., `${HOME}/.pi/agent/sessions`, ...])`,**只列 sessions、不列 agentDir**,
于是"agentDir 在不在 rw 里"在这套测试里**永远测不出来**。
已加一个贴着生产形状的夹具 `fsRealShape()`(agentDir 与 sessions **都在**),
并把两个 workerLaunch 用例换成它。

**判据(两条,含反面对照)**:
· `agentDir` 本身必须在 `--rw` 里,且 `sessions` 也仍在(两个写点,不是替代关系);
· 反面对照:`agentDir` **不存在**时不得硬塞进 rw ——
  `am-sandbox` 对不存在的 `--rw` 路径 fail closed(退出码 126),
  所以"加 agentDir"不能变成"无条件加"。
★ 已按既定纪律先验区分力:临时移除 `pushDir(agentDir)` ⇒ 新用例**变红**(11/1),
`cp` 恢复后 `cmp` 校验一致。

**旁注(写点清单的教训)**:原来的注释只按"我们已知的写点"列(会话工作区、临时目录、
/dev/null、sessions、配置目录),而**凭据存储在它自己的目录里加锁**是另一个写点,
且它在**读凭据**这条路上 —— 所以漏了它的症状不是"某个工具不能用",而是"agent 不工作"。
写点清单要按**真实进程的行为**列,不能只按已知的那几处列。

验证:pi 桥 491/491(新增 2 条);sandbox-launch 12/12;`go test ./cmd/am-sandbox/` ok。
This commit is contained in:
2026-09-15 00:13:13 +08:00
parent 9fa509844a
commit 7f03ee7ca2
2 changed files with 62 additions and 2 deletions

View File

@ -22,6 +22,23 @@ import {
// 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。
const fsWith = (paths) => (p) => paths.includes(p);
/**
* 真实形状的 fs 夹具:`<agentDir>` **与**它的 `sessions` 子目录**都存在**。
*
* ★ 为什么要单独一个(2026-09-15):原先每个用例手写
* `fsWith([..., `${HOME}/.pi/agent/sessions`, ...])` —— 只列了 `sessions`,
* **没列 `agentDir` 本身**。于是"agentDir 在不在 rw 里"这件事
* **在这套测试里永远测不出来**:夹具把生产形状简化掉的那一角,恰好就是出问题的那一角。
* 实测的后果是 pi 那一侧的 Agent 整个不工作:
* `EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'`
* (凭据存储的锁文件写在 agentDir 直下,而 rw 里只有它的 sessions 子目录)。
* ⇒ 夹具形状必须贴着生产,否则判据护不住真正的那一处。
*/
const fsRealShape = (extra = []) => fsWith([
DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent`, `${HOME}/.pi/agent/sessions`,
`${HOME}/.agentmail`, '/dev/null', ...extra,
]);
const HERE = dirname(fileURLToPath(import.meta.url));
const NODE = '/usr/bin/node';
const WORKER = '/opt/agentmail/plugins/pi-mail-bridge/current/src/worker.mjs';
@ -30,7 +47,7 @@ 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 exists = fsRealShape();
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(l.sandboxed, true, `workspace 档应当套沙箱(reason=${l.reason})`);
@ -130,7 +147,7 @@ test('★ 沙箱自证:写得进界外 ⇒ 判定为"没有沙箱"(方向取
});
test('★ plan 档的 rw 里没有会话工作区("一个字都不许写")', () => {
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, `${HOME}/.agentmail`, '/dev/null']);
const exists = fsRealShape();
const plan = workerLaunch({ mode: 'plan', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.equal(plan.sandboxed, true, 'plan 档也要套沙箱(第二层)');
assert.ok(!plan.rw.includes(CWD), `plan 档的 rw 不该含会话工作区:${plan.rw.join(',')}`);
@ -139,6 +156,34 @@ test('★ plan 档的 rw 里没有会话工作区("一个字都不许写")',
assert.ok(ws.rw.includes(CWD), 'workspace 档必须含会话工作区(否则界内也写不了)');
});
test('★ agentDir **本身**必须在 rw 里(凭据存储的锁文件写在它直下)', () => {
// 这条是 2026-09-15 那次"pi 整个不工作"的回归判据。
// 症状不是"某次工具调用失败",而是 pi 连一条消息都处理不了:
// EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock'
// 关键:只把 `<agentDir>/sessions` 放进 rw **不够** —— 锁文件在 agentDir 直下。
const exists = fsRealShape();
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
const agentDir = `${HOME}/.pi/agent`;
const idx = l.argv.indexOf(agentDir);
assert.ok(idx > 0 && l.argv[idx - 1] === '--rw',
`--rw 里必须有 agentDir 本身(凭据存储要在这里建 auth.json.lock):${l.argv.join(' ')}`);
// sessions 仍然要在(回合结束保存会话是另一个写点),两个都要有
assert.ok(l.rw.includes(`${agentDir}/sessions`), 'sessions 也要在 rw 里(保存会话是另一个写点)');
// 而且 agentDir 必须先出现或被包含 —— 两者都在,不是替代关系
assert.ok(l.rw.includes(agentDir) && l.rw.includes(`${agentDir}/sessions`),
'agentDir 与它的 sessions 是两个写点,必须同时在 rw 里');
});
test('回归判据的反面对照:agentDir 不存在时不该硬塞进 rw', () => {
// am-sandbox 对不存在的 --rw 路径 fail closed(退出码 126),
// 所以"加 agentDir"不能变成"无条件加" —— 仍然要经 exists 这一关。
const exists = fsWith([DEFAULT_SANDBOX_BIN, CWD, '/tmp', `${HOME}/.pi/agent/sessions`, '/dev/null']);
const l = workerLaunch({ mode: 'workspace', cwd: CWD, nodePath: NODE, workerPath: WORKER, env: ENV, exists });
assert.ok(!l.rw.includes(`${HOME}/.pi/agent`), 'agentDir 不存在时不该进 rw(否则 am-sandbox 直接 126)');
assert.ok(l.rw.includes(`${HOME}/.pi/agent/sessions`), '但 sessions 存在就照收');
});
test('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => {
const src = readFileSync(join(HERE, '..', 'src', 'pool.mjs'), 'utf8');
assert.ok(src.includes("AGENTMAIL_PI_SANDBOXED: '1'"), 'pool 要在套沙箱时设标记');