diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 9d6d8c3..202cfd0 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -69,6 +69,7 @@ // ★ 起名 spawnProcess:本文件里已经有一个 `function spawn(job)`,直接叫 spawn 会 // 自己调自己(实测踩到:RangeError: Maximum call stack size exceeded)。 +import { existsSync } from 'node:fs'; import { spawn as spawnProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -76,6 +77,7 @@ import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js' import { workerLaunch } from '../lib/sandbox.js'; import { ensureCwd, resolveWorkspaceCwd } from '../lib/workspace.js'; import { piMailFallback } from './paths.mjs'; +import { resolveTurnCwd } from './turn-cwd.mjs'; const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url)); @@ -196,13 +198,29 @@ export function createWorkerPool({ // (`grouped` = path 位是一个已存在的目录),所以 N-2「笔误不落真目录」不受影响: // path 位给了但不存在 ⇒ `resolveWorkspaceCwd` 返回兜底 + grouped=false, // 于是建的是**兜底目录**,笔误路径永远不会被创建。 + // ★ "这一轮用哪个 cwd"收成**一处决定**(pi 评审 2026-09-15 的探针实测): + // 原先父进程只按**这封信的地址**算 rw,而 worker 复用会话时用的是 + // **上次实际用过的 cwd**(`job.session.cwd`)—— 会话键只看 session_id, + // 所以地址一换形状(`pi@/some/dir` → `pi@.<会话>`)两者就分叉, + // 症状是"**界内也写不了**"(rw 里没有 worker 真正要写的那个目录)。 + // 现在两侧都从这个纯函数取值,并把它注入 job(worker 消费,不再自己推导)。 + const sessionReused = Boolean(state.sessionFile && state.cwd && existsSync(state.sessionFile)); + const turnCwd = resolveTurnCwd({ + sessionReused, + storedCwd: state.cwd, + toWorkspace: job.data?.to_workspace, + sessionKey: job.data?.session_id, + fallback: piMailFallback, + resolve: resolveWorkspaceCwd, + }); let launch; try { - const ws = resolveWorkspaceCwd(job.data?.to_workspace, piMailFallback(job.data?.session_id)); - ensureCwd(ws.cwd, ws.grouped); + // 复用路径的 cwd 本来就必须存在(是 worker 上次实际用的);只有按地址算出来的 + // 才需要先建(否则 `workerLaunch` 会因为 `!exists(cwd)` 不套沙箱 —— 见 99e6560)。 + if (turnCwd.source === 'address') ensureCwd(turnCwd.cwd, turnCwd.grouped); launch = workerLaunch({ mode: job.data?.permission_mode || 'workspace', - cwd: ws.cwd, + cwd: turnCwd.cwd, nodePath: process.execPath, workerPath, // 注入点:判据不许依赖"这台机器装没装 /opt/agentmail/bin/am-sandbox"。 @@ -296,6 +314,9 @@ export function createWorkerPool({ session: { sessionFile: state.sessionFile || '', cwd: state.cwd || '', + // 父进程已经算好的"这一轮用哪个 cwd"。worker **消费**它而不是自己重新推导 —— + // 这是"三来源变一来源"的第一步(残余的接管路径见 src/turn-cwd.mjs 的头注释)。 + resolvedCwd: turnCwd.cwd, }, grants: [...state.grants], lastSyncedName: state.lastSyncedName || '', diff --git a/plugins/pi-mail-bridge/src/turn-cwd.mjs b/plugins/pi-mail-bridge/src/turn-cwd.mjs new file mode 100644 index 0000000..fc1d307 --- /dev/null +++ b/plugins/pi-mail-bridge/src/turn-cwd.mjs @@ -0,0 +1,78 @@ +/** + * 决定"**这一轮 worker 用哪个 cwd**"—— 父子两侧的唯一来源。 + * + * # 为什么需要这个模块(一次实测出来的分叉) + * + * worker 的沙箱 `--rw` 清单由**父进程**算(`pool.mjs` → `lib/sandbox.js`), + * 而 worker 实际用的 cwd 是它自己推的。两个来源一旦不一致,症状是 + * **"界内也写不了"** —— 最难查的一类,因为 EACCES 落在"界内", + * 读日志的人会以为沙箱装错了。 + * + * `pool.mjs` 里那句注释原本防的是**公式**分叉(父子都调 `resolveWorkspaceCwd`)。 + * 但 cwd 有**两个来源**,所以只统一公式不够(pi 评审 2026-09-15 用探针实测): + * + * 父进程:`resolveWorkspaceCwd(to_workspace, …)` ← 来源:**这封信的地址** + * 子进程:`job.session.cwd || resolveWorkspaceCwd(…)` ← 来源:**这个会话上次实际用的 cwd** + * + * 而会话键 `keyOf(data) = data.session_id` —— **只看 session_id,不看 path 位**。 + * 于是同一个 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`, + * 或换成另一个已存在目录),父进程按**新地址**算 rw,worker 却**复用会话、 + * 落在旧 cwd** 上 ⇒ rw 里没有 worker 真正要写的那个目录。 + * + * 实测(探针,`exists` 注入): + * + * 父进程算出的 cwd = /root/.pi/mail-sessions/sess-x + * worker 实际会用 = /home/program/agentmail (= 这个会话的 state.cwd) + * sandboxed = true, rw 含 worker 实际 cwd? = **false** + * + * 不是假想:本线程那条会话自上线起 5 次启动的 rw 全是 `/home/program/agentmail`, + * 那就是它的 `state.cwd`;此时只要来一封写给 `pi@.<会话>`(不带 path)的信就会踩到。 + * + * # 这条与"首回合补目录"那次修复的关系(必须一起看) + * + * `pool.mjs` 先 `ensureCwd` 再算 launch 的修复(commit `99e6560`)在**首回合**这个目标上 + * 是净胜,但它在"地址换形状"这个组合上**改变了失败方式**: + * + * · 修复前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**) + * · 修复后:目录被建出来 ⇒ **套上沙箱,而 rw 是地址算的那个** ⇒ worker 在会话自己的 + * cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的,不是策略问的) + * + * ⇒ 两处必须一起对齐,否则第一处会把一个"多问一次"变成"界内也写不了"。 + * + * # 残余(已知,未修) + * + * **接管会话**(`adoptID`)那条路上,worker 用的是**会话文件 header 里的** `info.cwd` + * (`worker.mjs` 的接管分支),父进程读不到 ⇒ 仍会错位。父进程要用 `session-scan.mjs` + * 才能拿到它,而那个模块的 `readHeader` 未导出、整表 `scan()` 在父进程里代价大 + * (worker 里实测 1431ms / 240MB)。彻底的方向是**把"这次用哪个 cwd"收成父进程一处决定** + * (它有 `state.sessionFile`,header 也能读),worker 只消费、不再自己推导 —— 三来源变一来源。 + * 在那之前,接管路径的错位没有任何一层提示,已记入 `docs/DEBTS.json`。 + */ + +/** + * 这一轮该用哪个 cwd。 + * + * 输入**全部来自 public 状态**(父进程也拿得到),所以两侧算出的一定是同一个值。 + * + * @param {object} o + * @param {boolean} o.sessionReused 这次是否复用已有会话(= `job.session.sessionFile` 存在且文件在) + * @param {string} [o.storedCwd] 复用时这个会话**上次实际用的** cwd(`state.cwd`) + * @param {string} [o.toWorkspace] 这封信地址里的 path 位 + * @param {string|number} [o.sessionKey] 会话标识(兜底目录用) + * @param {(key: string|number|undefined) => string} o.fallback 平台兜底目录函数 + * @param {(workspace: string|undefined, fallback: string) => {cwd: string, grouped: boolean}} o.resolve + * `resolveWorkspaceCwd` —— 注入是为了可测且不重复实现寻址规则 + * @returns {{cwd: string, grouped: boolean, source: 'reused'|'address'}} + * `source` 是给日志/判据用的:能一眼看出这一轮 cwd 是**复用**来的还是**按地址**算的 + */ +export function resolveTurnCwd({ + sessionReused, storedCwd, toWorkspace, sessionKey, fallback, resolve, +}) { + // 与 worker 同一条规则:复用会话时,**上次实际用的 cwd 优先**。 + // 这正是父进程原先漏掉的那一支 —— 父进程只看了地址。 + if (sessionReused && storedCwd) { + return { cwd: String(storedCwd), grouped: true, source: 'reused' }; + } + const { cwd, grouped } = resolve(toWorkspace, fallback(sessionKey)); + return { cwd, grouped, source: 'address' }; +} diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index 7c43f68..c6d86fd 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -337,7 +337,11 @@ async function loadSession(mailTools) { const given = job.session?.sessionFile; if (given && existsSync(given)) { - const cwd = job.session.cwd || resolveWorkspaceCwd( + // ★ cwd 优先取**父进程已经算好的那个**(`job.session.resolvedCwd`)—— + // 父进程用它算沙箱的 `--rw`,worker 必须用同一个值,否则 rw 里可能没有 + // 我们真正要写的目录(症状"界内也写不了")。见 src/turn-cwd.mjs 头注释。 + // 父进程没给(老协议/接管路径)时才退回自己推导。 + const cwd = job.session?.resolvedCwd || job.session.cwd || resolveWorkspaceCwd( data.to_workspace, piMailFallback(data.session_id)).cwd; const opened = await openSession({ cwd, modelRuntime, customTools: mailTools, @@ -385,11 +389,18 @@ async function loadSession(mailTools) { if (!grouped && data.to_workspace) { log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`); } - ensureCwd(cwd, grouped); + // ★ 与父进程对齐:父进程已经按**同一个纯函数**算过这一轮的 cwd 并据此算了 + // `--rw`。若两者不一致(理论上不该),取父进程那个 —— 因为 rw 是按它算的, + // 用别的值就是"界内也写不了"。父进程没给时才用本地推导的结果。 + const turnCwd = job.session?.resolvedCwd || cwd; + if (job.session?.resolvedCwd && job.session.resolvedCwd !== cwd) { + log(`注意:父进程定的 cwd=${job.session.resolvedCwd} 与本地推导的 ${cwd} 不同,按父进程的走`); + } + ensureCwd(turnCwd, grouped || Boolean(job.session?.resolvedCwd)); const opened = await openSession({ - cwd, modelRuntime, customTools: mailTools, extension: permissionExtension(), + cwd: turnCwd, modelRuntime, customTools: mailTools, extension: permissionExtension(), }); - log(`新建 pi 会话 ${opened.session.sessionId}(cwd=${cwd})`); + log(`新建 pi 会话 ${opened.session.sessionId}(cwd=${turnCwd})`); return { ...opened, cwd, reused: false }; } diff --git a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs index eb85320..eca71f0 100644 --- a/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs +++ b/plugins/pi-mail-bridge/test/sandbox-launch.test.mjs @@ -210,8 +210,19 @@ test('★ 首回合也要套沙箱:pool 必须在算 launch **之前**把兜 assert.ok(launchIdx > 0, 'pool 必须调 workerLaunch'); assert.ok(ensureIdx < launchIdx, 'ensureCwd 必须在 workerLaunch **之前** —— 顺序错了等于没补(这正是当初 worker 建目录的位置问题)'); - // 而且必须用与 worker 同一个解析函数的结果,不许自己拼路径 - assert.match(src, /resolveWorkspaceCwd\(/, 'pool 要用与 worker 同一个函数解析 cwd'); + // 而且必须用与 worker **同一个解析函数**,不许自己拼路径。 + // ★ 注意判据在 2026-09-15 改过一次写法:原先断言的是"pool 里出现 + // `resolveWorkspaceCwd(`" —— 那是个**实现细节**。引入 `resolveTurnCwd` 后 + // 池子不再直接调它,而是**当参数传进去**(这样"这一轮用哪个 cwd"才收成一处决定), + // 于是旧断言红了。**红得对**:它当初护的是一条写法,而不是它真正想要的性质。 + // 现在断言性质本身:解析函数必须来自共用模块、且被显式传给那个纯函数。 + assert.match(src, /import \{[^}]*resolveWorkspaceCwd[^}]*\} from '\.\.\/lib\/workspace\.js'/, + 'pool 的解析函数必须来自共用模块(不许自己拼路径)'); + assert.match(src, /resolve:\s*resolveWorkspaceCwd/, 'pool 必须把与 worker 同一个解析函数交给 resolveTurnCwd'); + // 并且那个纯函数确实**用了**注入进来的解析函数(否则传了也白传) + const turnCwd = readFileSync(join(HERE, '..', 'src', 'turn-cwd.mjs'), 'utf8'); + assert.match(turnCwd, /resolve\(toWorkspace, fallback\(sessionKey\)\)/, + 'resolveTurnCwd 必须实际调用注入的解析函数'); }); test('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => { diff --git a/plugins/pi-mail-bridge/test/turn-cwd.test.mjs b/plugins/pi-mail-bridge/test/turn-cwd.test.mjs new file mode 100644 index 0000000..966dd7d --- /dev/null +++ b/plugins/pi-mail-bridge/test/turn-cwd.test.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; + +import { resolveTurnCwd } from '../src/turn-cwd.mjs'; +import { piMailFallback } from '../src/paths.mjs'; +import { resolveWorkspaceCwd } from '../lib/workspace.js'; +import { sandboxWritePaths } from '../lib/sandbox.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); +const HOME = homedir(); + +const call = (o) => resolveTurnCwd({ + fallback: piMailFallback, resolve: resolveWorkspaceCwd, ...o, +}); + +/** + * 这一组补的是 pi 评审 2026-09-15 用探针实测出来的**输入**分叉: + * + * 父进程:`resolveWorkspaceCwd(to_workspace, …)` ← 来源:**这封信的地址** + * 子进程:`job.session.cwd || resolveWorkspaceCwd(…)` ← 来源:**会话上次实际用的 cwd** + * + * 原先那条"父子同源"判据断言的是**函数**同源(两边 import 同一个 `piMailFallback`), + * 它**测不出输入分叉** —— 所以这里改成断言一条**等式**: + * "`--rw` 的 cwd(父进程算) === worker 会用的 cwd"。 + */ + +test('★ 复用会话时:cwd 取 state.cwd,而不是按这封信的地址重算', () => { + // 实测场景:会话 d042cc4c 的 state.cwd = /home/program/agentmail, + // 此时来一封写给 `pi@.<会话>`(不带 path)的信。 + const r = call({ + sessionReused: true, + storedCwd: '/home/program/agentmail', + toWorkspace: undefined, // 地址不带 path ⇒ 按地址算会得到兜底目录 + sessionKey: 'sess-x', + }); + assert.equal(r.cwd, '/home/program/agentmail', + '复用会话时必须用会话上次实际用的 cwd —— 按地址重算就是 rw 与 worker 分叉'); + assert.equal(r.source, 'reused'); + // 对照:按地址算会得到兜底目录,与 state.cwd 不同 ⇒ 如果不修,rw 就是错的 + const byAddress = resolveWorkspaceCwd(undefined, piMailFallback('sess-x')); + assert.notEqual(byAddress.cwd, r.cwd, '两种来源确实不同名 —— 这正是分叉的成因'); +}); + +test('★ 等式:--rw 里必须有 worker 会用的那个 cwd', () => { + // 这是 pi 要的那条可断言等式。用真 sandboxWritePaths 算 rw, + // 断言 worker 实际会用的 cwd **在 rw 里**。 + const stored = '/home/program/agentmail'; + const r = call({ sessionReused: true, storedCwd: stored, toWorkspace: undefined, sessionKey: 'sess-x' }); + + const exists = (p) => [stored, '/tmp', `${HOME}/.pi/agent`, `${HOME}/.pi/agent/sessions`, + `${HOME}/.agentmail-pi`, '/dev/null'].includes(p); + const { dirs } = sandboxWritePaths({ cwd: r.cwd, env: { TMPDIR: '/tmp', HOME }, exists, home: HOME }); + + assert.ok(dirs.includes(r.cwd), + `--rw 里必须有 worker 会用的 cwd(${r.cwd}):rw=${dirs.join(',')}`); + // 反面对照:如果不改(按地址算),rw 里就没有那个目录 —— 证明这条判据有区分力 + const wrong = call({ sessionReused: false, toWorkspace: undefined, sessionKey: 'sess-x' }); + const wrongDirs = sandboxWritePaths({ cwd: wrong.cwd, env: { TMPDIR: '/tmp', HOME }, exists, home: HOME }).dirs; + assert.ok(!wrongDirs.includes(stored), + '按地址算出来的 rw 不该含 state.cwd —— 这就是修复前的错位状态'); +}); + +test('没有复用时仍按地址算(不能一律取 state.cwd)', () => { + const r = call({ sessionReused: false, storedCwd: '/home/program/agentmail', toWorkspace: '/home/program/agentmail', sessionKey: 'k' }); + assert.equal(r.source, 'address', '没复用就该按地址算'); + assert.equal(r.cwd, '/home/program/agentmail'); +}); + +test('复用了但 state.cwd 为空 ⇒ 退回按地址算(老状态没有 cwd)', () => { + const r = call({ sessionReused: true, storedCwd: '', toWorkspace: '/tmp', sessionKey: 'k' }); + assert.equal(r.source, 'address'); +}); + +test('★ 结构判据:pool 算 launch 与 worker 取值必须来自同一个决定', () => { + const pool = readFileSync(join(ROOT, 'src', 'pool.mjs'), 'utf8'); + assert.match(pool, /resolveTurnCwd\(/, 'pool 必须用这个纯函数决定 cwd'); + assert.match(pool, /cwd: turnCwd\.cwd/, 'pool 必须把该决定交给 workerLaunch(--rw 按它算)'); + assert.match(pool, /resolvedCwd: turnCwd\.cwd/, 'pool 必须把该决定注入 job 给 worker'); + + // worker 侧:★ 这条断言改过一版,因为**第一版不具区分力**(我自己变异测出来的)。 + // 第一版只断言"文件里出现 `job.session?.resolvedCwd`" —— 把消费那一支删掉、 + // 退回 `job.session.cwd`,那条正则**仍然匹配得上**(因为别处还留着它), + // 于是变异后测试依旧全绿 ⇒ 这条判据护不住它声称要护的东西。 + // 现在断言的是**优先级**:每一处取 cwd 的表达式都必须先看 `resolvedCwd`。 + const worker = readFileSync(join(ROOT, 'src', 'worker.mjs'), 'utf8'); + const cwdExprs = [...worker.matchAll(/const cwd = ([^;]+);/g)].map((m) => m[1]); + assert.ok(cwdExprs.length >= 1, '应当至少有一处取 cwd 的表达式'); + for (const expr of cwdExprs) { + assert.ok(expr.includes('resolvedCwd'), + `取 cwd 的表达式必须先看父进程的决定(resolvedCwd),实际是:${expr.trim()}`); + } + // 新建分支那处同样是"父进程优先" + assert.match(worker, /const turnCwd = job\.session\?\.resolvedCwd \|\| cwd/, + '新建分支也必须以父进程的决定为准'); +});