fix(pi-bridge)!: --rw 的 cwd 与 worker 实际用的 cwd 收成**一处决定**(pi 探针实测的第三例)
pi 2026-09-15 报、我用探针复核**成立**,而且它把 `99e6560` 的代价也一起说清了。 **分叉在哪**:cwd 有**两个来源**,而会话键 `keyOf(data) = data.session_id` **只看 session_id**: ``` 父进程(算 --rw) cwd = resolveWorkspaceCwd(to_workspace, …) ← 来源:**这封信的地址** 子进程(真去干活) cwd = job.session.cwd || resolveWorkspaceCwd(…) ← 来源:**会话上次实际用的 cwd** ``` 于是同一 session_id 下地址换个形状(`pi@/some/dir` → `pi@.<会话>`),父进程按**新地址** 算 rw,worker 却**复用会话、落在旧 cwd**。实测(真函数,`exists` 注入): ``` 父进程算的 cwd = /root/.pi/mail-sessions/sess-x worker 实际会用 = /home/program/agentmail (= 该会话的 state.cwd) rw 含 worker 实际 cwd? = false ⇒ 界内 EACCES ``` 不是假想:本线程那条会话自上线起每次启动的 rw 都是 `/home/program/agentmail`, 那就是它的 `state.cwd` —— 此时来一封 `pi@.<会话>`(不带 path)的信就会踩到。 **★ `99e6560` 在这个组合上把失败方式变坏了**(这条必须记下来,我原先只报了它的好处): · 之前:兜底目录不存在 ⇒ 不套沙箱 ⇒ `ask`(有人应答时**写得进去**) · 之后:目录被建出来(那次修复的效果)⇒ **套上沙箱,而 rw 是地址算的那个** ⇒ worker 在会话自己的 cwd 里写 ⇒ **EACCES,且没有"问一次"这条路**(内核拒的) 我用 `ensureCwd` 前/后各跑一次验证了这条因果,实测 `(b) 建了兜底目录: sandboxed = true, rw 含 worker 实际 cwd? = false` —— 与 pi 报的 `sandboxed=true` 一字不差(他给了那个值, 我最初复现成 false,差别就在"兜底目录建没建",属于应用 `99e6560` 前后)。 ⇒ **两处修复必须一起部署**,否则中间态是"界内也写不了"(比原先多问一次更糟)。 现在两次提交都在仓库、`drift` 报 5 处待部署,会一起上线。 **修法**:新增 `src/turn-cwd.mjs` 的纯函数 `resolveTurnCwd()` —— 输入全部来自**父进程也拿得到的 public 状态**(`sessionReused` / `storedCwd` / `toWorkspace` / `sessionKey` / 注入的解析函数), 父子两侧都从它取值;父进程再把决定**注入 job**(`session.resolvedCwd`),worker **消费**它、 不再自己推导。于是"三来源变一来源"落了第一步。 **判据(pi 要的那条等式)**: · ★**等式**:用真 `sandboxWritePaths` 算 rw,断言"**`--rw` 里的 cwd === worker 会用的 cwd**"; 并附**反面对照**:按地址算出来的 rw **不含** `state.cwd`(= 修复前的错位状态)。 · 复用/非复用/`storedCwd` 为空三种输入各一条。 · 结构:pool 必须把决定交给 `workerLaunch` **并**注入 job;worker 取 cwd 的**每一处表达式** 都必须先看 `resolvedCwd`。 ★ **两处我自己的判据缺陷,都是变异测出来的,都记在测试文件里**: 1. 上一轮我在 `sandbox-launch.test.mjs` 写的 `assert.match(src, /resolveWorkspaceCwd\(/)` **本来就不该红也不该绿** —— 它护的是**写法**(池子直接调那个函数),而引入 `resolveTurnCwd` 后池子改成"当参数传进去"(更对),它才红。**红得对**:它当初断言的是实现细节, 不是它想要的性质。已改为断言性质(解析函数必须来自共用模块、且被显式传给纯函数)。 顺带说明:它此前一直是**假绿**还是**真绿**我没法回测,但**它在引入纯函数后才红**说明它 确实绑定了写法 —— 这正是"判据的适用范围没写出来"那一类。 2. 新版 worker 侧结构判据**第一版不具区分力**:只断言"文件里出现 `resolvedCwd`", 把消费那一支删掉、退回 `job.session.cwd`,正则**仍然匹配**(别处还留着它)⇒ 变异后依旧全绿。 已改为断言**优先级**(取 cwd 的表达式必须含 `resolvedCwd`)。 ★ 变异实测:修好后重做同一变异 ⇒ 判据**变红**;两次变异均 `cp` 恢复 + `cmp` 校验。 **残余(未修,已进 DEBTS)**:**接管会话**那条路 worker 用会话文件 header 里的 `info.cwd`, 父进程读不到 ⇒ 首回合仍可能错位。父进程要拿它得用 `session-scan.mjs`,而 `readHeader` 未导出、 整表 `scan()` 在父进程里代价大(worker 里实测 1431ms / 240MB)。 彻底方向即 pi 说的:把"这次用哪个 cwd"完全收成父进程一处决定,worker 只消费。现在做不做等定。 验证:pi 桥 **502/502**;另三个桥 fail 0;`check-shared-libs` exit 0;`install.sh --check` exit 0。
This commit is contained in:
@ -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('套了沙箱才给子进程标记(标记 + 自证两处都在)', () => {
|
||||
|
||||
100
plugins/pi-mail-bridge/test/turn-cwd.test.mjs
Normal file
100
plugins/pi-mail-bridge/test/turn-cwd.test.mjs
Normal file
@ -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/,
|
||||
'新建分支也必须以父进程的决定为准');
|
||||
});
|
||||
Reference in New Issue
Block a user