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:
@ -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` 写在 **`<agentDir>` 直下**,
|
||||
// 而 rw 里只有 `<agentDir>/sessions` ⇒ Landlock 拒绝在 `agentDir` 里建新条目。
|
||||
//
|
||||
// 为什么以前没暴露:原来的注释只写了"回合结束保存会话"这一个写点,
|
||||
// 而**锁文件与凭据读写是另一个写点**,且它在**读凭据**这条路上 ——
|
||||
// 于是症状不是"某次工具调用失败",而是"这个 agent 完全不工作"。
|
||||
// 判据的教训:写点清单要按**真实进程的行为**列(凭据存储会在自己目录里加锁),
|
||||
// 不能只按"我们已知的那两处"列。
|
||||
pushDir(agentDir);
|
||||
pushDir(join(agentDir, 'sessions'));
|
||||
pushDir(env.AGENTMAIL_CONFIG_DIR || join(home ?? homedir(), '.agentmail'));
|
||||
|
||||
|
||||
@ -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 要在套沙箱时设标记');
|
||||
|
||||
Reference in New Issue
Block a user