From 7f03ee7ca2404b08aadea3292e028b13e7e013e0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 00:13:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge)!:=20=E6=B2=99=E7=AE=B1=20rw=20?= =?UTF-8?q?=E6=BC=8F=E4=BA=86=20agentDir=20=E6=9C=AC=E8=BA=AB=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20pi=20=E4=BE=A7=E7=9A=84=20Agent=20=E6=95=B4?= =?UTF-8?q?=E4=B8=AA=E4=B8=8D=E5=B7=A5=E4=BD=9C=EF=BC=88=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E7=9A=84=E9=94=81=E6=96=87=E4=BB=B6=E5=86=99?= =?UTF-8?q?=E5=9C=A8=E5=AE=83=E7=9B=B4=E4=B8=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **症状(实测,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` 把 `/sessions` 放进了 `--rw`, **却没放 `` 本身**: pushDir(join(agentDir, 'sessions')); // 少了 pushDir(agentDir) 而 pi 的凭据存储在 **`` 直下**建锁文件 `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。 --- plugins/pi-mail-bridge/lib/sandbox.js | 15 ++++++ .../test/sandbox-launch.test.mjs | 49 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/plugins/pi-mail-bridge/lib/sandbox.js b/plugins/pi-mail-bridge/lib/sandbox.js index 34beec8..ca3db47 100644 --- a/plugins/pi-mail-bridge/lib/sandbox.js +++ b/plugins/pi-mail-bridge/lib/sandbox.js @@ -123,6 +123,21 @@ export function sandboxWritePaths({ pushDir(cwd); pushDir(tmp ?? (env.TMPDIR || '/tmp')); const agentDir = env.PI_CODING_AGENT_DIR || join(home ?? homedir(), '.pi', 'agent'); + // ★ agentDir **本身**也要在 rw 里,不能只给它的 `sessions` 子目录。 + // + // 实测(2026-09-15):pi 这一侧的 Agent 报 + // auth: Credential store read failed for llmsproxy: + // EACCES: permission denied, mkdir '/root/.pi/agent/auth.json.lock' + // —— **整条任务失败,pi 连一条消息都处理不了**("划定范围内的模型全部调用失败")。 + // 原因是 pi 的凭据存储把锁文件 `auth.json.lock` 写在 **`` 直下**, + // 而 rw 里只有 `/sessions` ⇒ Landlock 拒绝在 `agentDir` 里建新条目。 + // + // 为什么以前没暴露:原来的注释只写了"回合结束保存会话"这一个写点, + // 而**锁文件与凭据读写是另一个写点**,且它在**读凭据**这条路上 —— + // 于是症状不是"某次工具调用失败",而是"这个 agent 完全不工作"。 + // 判据的教训:写点清单要按**真实进程的行为**列(凭据存储会在自己目录里加锁), + // 不能只按"我们已知的那两处"列。 + pushDir(agentDir); pushDir(join(agentDir, 'sessions')); pushDir(env.AGENTMAIL_CONFIG_DIR || join(home ?? homedir(), '.agentmail')); diff --git a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs index 42f144e..2e14bc6 100644 --- a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs +++ b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs @@ -22,6 +22,23 @@ import { // 假文件系统:只认列出来的路径,避免判据依赖本机装了什么。 const fsWith = (paths) => (p) => paths.includes(p); +/** + * 真实形状的 fs 夹具:`` **与**它的 `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' + // 关键:只把 `/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 要在套沙箱时设标记');