**症状(实测,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。
182 lines
8.8 KiB
JavaScript
182 lines
8.8 KiB
JavaScript
/**
|
||
* 按**档位**决定 worker 怎么起:直接 node,还是先套一层 `am-sandbox`(Landlock)。
|
||
*
|
||
* # 这一步补的是「工作区档」缺的那一维
|
||
*
|
||
* 档位表写的是「本目录内可动、越界要问人」,而桥原先只能按**工具名**判
|
||
* (bash/write/edit 一律问人)—— 因为命令的影响范围无法从文本静态判定。沙箱把
|
||
* 「界内/界外」交给内核去判,于是这条档位第一次是真的:
|
||
*
|
||
* plan / workspace 档 → worker 跑在 `am-sandbox --rw <会话工作区> …` 里面,
|
||
* 界外写是 EACCES(不依赖模型配合、也不依赖人点得准)
|
||
* full 档 → **不套**(发件人已声明全权,与档位表一致)
|
||
*
|
||
* # 为什么 rw 里既有会话工作区、又有几个固定目录
|
||
*
|
||
* 沙箱只放行「写」,而且必须把**跑起来真正需要的写点**列全,否则 worker 自己会崩:
|
||
*
|
||
* - 会话工作区:agent 该动手的地方
|
||
* - 临时目录(`os.tmpdir()`):构建/工具链的日常写点(dsh 也是这么放的)
|
||
* - `/dev/null`:**任何 `cmd 2>/dev/null` 都是一次界外写** —— 实测漏了它会让
|
||
* 一整代命令集体 Permission denied。用 `--rw-file`(只放行写这个设备,
|
||
* 不放行在 /dev 里建/删任何东西)
|
||
* - pi 自己的会话登记(`<agentDir>/sessions`):不写它,回合结束保存会话就失败
|
||
* - 桥自己的配置目录(`AGENTMAIL_CONFIG_DIR`,默认 `~/.agentmail`):
|
||
* worker 会往里面写 `explicit-sends.jsonl`
|
||
*
|
||
* 这几条是**实测得出**的写点清单,不是想当然:少一条的症状是 worker 回合中途报
|
||
* EACCES/EROFS,而不是"沙箱没生效"。
|
||
*
|
||
* # 拿不到工作区就不套
|
||
*
|
||
* `--rw` 必须给真实目录,`am-sandbox` 对不存在的路径直接 fail closed(退出码 126)
|
||
* —— 那会让这条会话连 worker 都起不来。所以这里取不到 cwd 时**不猜**:退回不套沙箱,
|
||
* 由调用方把原因打进日志(不许静默)。
|
||
*/
|
||
|
||
import { existsSync } from 'node:fs';
|
||
import { homedir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
/** 边界工具的默认位置(由 redeploy-gateway.sh / install.sh 安装)。 */
|
||
export const DEFAULT_SANDBOX_BIN = '/opt/agentmail/bin/am-sandbox';
|
||
|
||
/**
|
||
* 档位 × 有没有沙箱 → 被守卫的工具(bash/write/edit)该怎么办。
|
||
*
|
||
* | 档位 | 沙箱 | 决定 |
|
||
* |---|---|---|
|
||
* | full | 任意 | `allow`(发件人已声明全权) |
|
||
* | plan | 任意 | `block`(本档只许看;沙箱是第二层,但先把话说清楚更快) |
|
||
* | workspace | **在** | `allow` —— **这是这一步改的东西** |
|
||
* | workspace | 不在 | `ask`(回退到原来的逐条问人) |
|
||
*
|
||
* 为什么 workspace 档在有沙箱时可以不问:档位的语义是「本目录内可动,越界要问人」,
|
||
* 而**内核已经把"越界"变成了 EACCES** —— 再问一遍只是让人点一次"同意",点完
|
||
* 该失败的还是失败(人点了同意也挡不住内核)。真正的语义由 am-sandbox 承担:
|
||
* 界内:不问(内核保证出不去)
|
||
* 界外:不用问,直接失败,模型从错误里知道"要越界得走 full 档或请人执行"
|
||
* 而**没有沙箱时必须继续问** —— 那才是原来唯一的一层。
|
||
*/
|
||
export function guardDecision({ mode, sandboxed, toolName, guarded }) {
|
||
if (String(mode) === 'full') return 'allow';
|
||
if (!guarded) return 'pass';
|
||
if (String(mode) === 'plan') return 'block';
|
||
return sandboxed ? 'allow' : 'ask';
|
||
}
|
||
|
||
/**
|
||
* 自证:标记说"套了沙箱",但真的套上了吗?
|
||
*
|
||
* 为什么不只信 `AGENTMAIL_PI_SANDBOXED`:那只是父进程设的一个环境变量。父进程
|
||
* 判断错(或有人手改 env)会让闸门以为"内核兜住了"而放行 —— 那是最坏的一类错
|
||
* (既不问、也不拦)。所以现场真写一个**一定在界外**的金丝雀文件:
|
||
* 写得进去 ⇒ 没有沙箱(退回逐条问人,方向取严)
|
||
* 被拒 ⇒ 有沙箱(EACCES/EROFS/EPERM 都算)
|
||
*
|
||
* 金丝雀路径取 `/`:会话工作区与 `os.tmpdir()` 都在 rw 里,只有根目录永远不在。
|
||
*/
|
||
export function verifySandboxActive({
|
||
canaryPath = `/.agentmail-sandbox-canary-${process.pid}`,
|
||
writeFile, unlink,
|
||
} = {}) {
|
||
try {
|
||
writeFile(canaryPath, 'x');
|
||
} catch (e) {
|
||
const code = e?.code || '';
|
||
return { active: true, reason: `界外写入被拒(${code || e?.message || 'error'})` };
|
||
}
|
||
try { unlink(canaryPath); } catch { /* 尽力清理,失败不影响结论 */ }
|
||
return { active: false, reason: '**能写界外** —— 沙箱没生效(退回逐条问人)' };
|
||
}
|
||
|
||
/**
|
||
* 取沙箱二进制路径;返回空串表示"不套"。
|
||
*
|
||
* `AGENTMAIL_PI_SANDBOX` 显式给出时以它为准(`""` / `0` / `off` = 明确关掉);
|
||
* 没给时看默认位置在不在。**显式给了却不存在的路径**也算关掉,但要由调用方出声
|
||
* —— 静默降级成"裸跑"正是这套东西最不该有的行为。
|
||
*/
|
||
export function sandboxBin(env = process.env, exists = existsSync) {
|
||
const raw = env.AGENTMAIL_PI_SANDBOX;
|
||
if (raw !== undefined) {
|
||
const v = String(raw).trim();
|
||
if (v === '' || v === '0' || v.toLowerCase() === 'off') return '';
|
||
return exists(v) ? v : '';
|
||
}
|
||
return exists(DEFAULT_SANDBOX_BIN) ? DEFAULT_SANDBOX_BIN : '';
|
||
}
|
||
|
||
/**
|
||
* 沙箱要放行的写点。返回 `{ dirs, files }`。
|
||
*
|
||
* 只保留**存在**的目录/文件:`am-sandbox` 对不存在的 rw 路径 fail closed,
|
||
* 而这里宁可少放行也不要让 worker 起不来(少放行的症状是可诊断的 EACCES)。
|
||
*/
|
||
export function sandboxWritePaths({
|
||
cwd, env = process.env, exists = existsSync, tmp = undefined, home = undefined,
|
||
}) {
|
||
const dirs = [];
|
||
const files = [];
|
||
const pushDir = (d) => { if (d && exists(d) && !dirs.includes(d)) dirs.push(d); };
|
||
|
||
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'));
|
||
|
||
const devNull = '/dev/null';
|
||
if (exists(devNull)) files.push(devNull);
|
||
|
||
return { dirs, files };
|
||
}
|
||
|
||
/**
|
||
* 决定这次的 worker 启动方式。
|
||
*
|
||
* @param {object} o
|
||
* @param {number|string} [o.mode] 会话档位(plan / workspace / full)
|
||
* @param {string} [o.cwd] 会话工作区(由调用方用与 worker 同一个函数解析)
|
||
* @param {string} o.nodePath node 可执行文件
|
||
* @param {string} o.workerPath worker 脚本
|
||
* @returns {{cmd: string, argv: string[], sandboxed: boolean, reason: string}}
|
||
*/
|
||
export function workerLaunch({
|
||
mode = 'workspace', cwd = '', nodePath, workerPath, env = process.env, exists = existsSync,
|
||
}) {
|
||
const direct = (reason) => ({ cmd: nodePath, argv: [workerPath], sandboxed: false, reason });
|
||
|
||
if (String(mode) === 'full') return direct('full 档:发件人已声明全权');
|
||
const bin = sandboxBin(env, exists);
|
||
if (!bin) return direct('没有可用的 am-sandbox(未安装 / 被显式关掉 / 路径不存在)');
|
||
if (!cwd || !exists(cwd)) return direct(`拿不到会话工作区(cwd=${cwd || '空'})—— 不猜`);
|
||
|
||
// plan 档 = "一个字都不许写":连会话工作区都不给写权限(沙箱是第二层,
|
||
// 第一层是 worker 里那道 block)。workspace 档才把工作区放进 rw。
|
||
const { dirs, files } = sandboxWritePaths({
|
||
cwd: String(mode) === 'plan' ? '' : cwd, env, exists,
|
||
});
|
||
const argv = [];
|
||
for (const d of dirs) argv.push('--rw', d);
|
||
for (const f of files) argv.push('--rw-file', f);
|
||
argv.push('--', nodePath, workerPath);
|
||
|
||
return { cmd: bin, argv, sandboxed: true, rw: dirs, reason: `rw=${dirs.join(',')}` };
|
||
}
|