Files
MailUI4Agents/plugins/pi-mail-bridge/lib/sandbox.js
JianFeeeee 7f03ee7ca2 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。
2026-09-15 00:13:13 +08:00

182 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 按**档位**决定 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(',')}` };
}