Files
MailUI4Agents/plugins/pi-mail-bridge/test/turn-cwd.test.mjs
JianFeeeee 7fe279676a 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。
2026-09-15 06:47:10 +08:00

101 lines
5.5 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.

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/,
'新建分支也必须以父进程的决定为准');
});