diff --git a/plugins/pi-mail-bridge/lib/workspace.js b/plugins/pi-mail-bridge/lib/workspace.js index dd5fa2e..a1b8745 100644 --- a/plugins/pi-mail-bridge/lib/workspace.js +++ b/plugins/pi-mail-bridge/lib/workspace.js @@ -13,21 +13,6 @@ import { existsSync, mkdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -/** - * 没有可用 `to_workspace` 时的兜底目录(pi 专用)。 - * - * 命名与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在 lib/ 下 - * (三平台逐字节相同)写死了 `.dsh`,不能为 pi 改 —— pi 的会话落进 `~/.dsh/` - * 会让人以为是 DSH 在干活。 - * - * 放在这里而不是 worker 里:**父进程要用同一个兜底值**。worker 的沙箱 rw 列表 - * 由父进程算(`pool.mjs` → `lib/sandbox.js`),父进程猜一个目录而 worker 落在另一个, - * 症状是"界内也写不了"—— 为此把兜底收成一处,两边 import 同一个函数。 - */ -export function piMailFallback(sessionKey) { - return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default')); -} - /** * 校验寻址里的工作目录,不可用时返回调用方给的兜底。 * diff --git a/plugins/pi-mail-bridge/src/paths.mjs b/plugins/pi-mail-bridge/src/paths.mjs new file mode 100644 index 0000000..d8d910f --- /dev/null +++ b/plugins/pi-mail-bridge/src/paths.mjs @@ -0,0 +1,53 @@ +/** + * pi 平台专有的路径常量。 + * + * # 为什么这个文件在 `src/` 而不在 `lib/` + * + * 按 `docs/PLUGIN-CONTRACT.md` 第六节,`lib/` 是**候选共用**目录:里面的文件在四个桥里 + * **逐字节相同**,由 `deploy/check-shared-libs.sh` 校验并纳入 `install.sh` 门禁。 + * 而本文件的内容**按定义就是平台专有的**(目录名写死 `.pi`), + * 所以它不能住 `lib/` —— 否则下一个人会问"它为什么不在 `ALL_LIBS` 里"。 + * + * # 这一搬是有来历的(别再搬回去) + * + * `piMailFallback` 原先定义在共用的 `lib/workspace.js` 里,于是 pi 那一份与 + * opencode / dsh / zcode 三份**不再逐字节相同**,`check-shared-libs.sh` 变红: + * + * 共用模块已分叉:lib/workspace.js(plugins/opencode-mail-bridge vs plugins/pi-mail-bridge) + * + * 契约的逃逸口**不是豁免清单**,而是共用函数 `resolveWorkspaceCwd(workspace, fallback)` + * 的**第二个参数** —— 平台兜底值本来就该由平台侧传进去。四个桥对照一下就很清楚: + * + * | 桥 | 平台兜底值在哪 | 怎么交给共用函数 | + * |----------|---------------------------------------------|--------------------| + * | opencode | 自己的 `index.js` | 传 `directory` | + * | zcode | 自己的 `src/index.mjs`(`zcodeSessionFallback`) | 传进去 | + * | dsh | 共用的 `mailSessionFallback`(写死 `.dsh`) | 直接用 | + * | pi | **这里** | 传进去 | + * + * ⇒ `doc 1150` 不用改、也不该加旁路:pi 只是唯一一个把平台值**造在共用模块里**的, + * 把它挪回平台侧就恢复了规矩。(共用 `workspace.js` 里那个 `.dsh` 常量仍是它保真的 + * 唯一一份,与本节不矛盾:规矩保的是"**只有一份**",不是"不许有平台数据"。) + * + * # 为什么父子共用这一个文件 + * + * `pool.mjs`(父)与 `worker.mjs`(子)**必须算出同一个兜底目录**: + * worker 的沙箱 `rw` 清单由父进程算(`lib/sandbox.js`), + * 父进程猜一个目录而 worker 落在另一个,症状是"**界内也写不了**"。 + * 所以兜底值收成这一处,两边 import 同一个函数。 + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * 没有可用 `to_workspace` 时的兜底目录(pi 专用)。 + * + * 目录名是 `.pi` 而不是 `.dsh`:pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。 + * + * @param {string|undefined} sessionKey 会话标识;空值收敛成 `default` + * @returns {string} `~/.pi/mail-sessions/` + */ +export function piMailFallback(sessionKey) { + return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default')); +} diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 257cecf..7501d5d 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -74,7 +74,8 @@ import { fileURLToPath } from 'node:url'; import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; import { workerLaunch } from '../lib/sandbox.js'; -import { piMailFallback, resolveWorkspaceCwd } from '../lib/workspace.js'; +import { resolveWorkspaceCwd } from '../lib/workspace.js'; +import { piMailFallback } from './paths.mjs'; const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url)); diff --git a/plugins/pi-mail-bridge/src/worker.mjs b/plugins/pi-mail-bridge/src/worker.mjs index b4b43bb..7c43f68 100644 --- a/plugins/pi-mail-bridge/src/worker.mjs +++ b/plugins/pi-mail-bridge/src/worker.mjs @@ -49,7 +49,8 @@ import { createMailTools } from './tools.mjs'; import { openSession, runTurn } from './session-pool.mjs'; import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs'; import { planNamingSync, planWriteBack } from './naming.mjs'; -import { piMailFallback, resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js'; +import { piMailFallback } from './paths.mjs'; import { modelAttemptOrder, renderFailureReport } from '../lib/model-scope.js'; import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js'; import { autoRelayDecision } from '../lib/relay-policy.js'; diff --git a/plugins/pi-mail-bridge/test/pi-paths.test.mjs b/plugins/pi-mail-bridge/test/pi-paths.test.mjs new file mode 100644 index 0000000..d98750a --- /dev/null +++ b/plugins/pi-mail-bridge/test/pi-paths.test.mjs @@ -0,0 +1,76 @@ +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 { piMailFallback } from '../src/paths.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); + +const HOME = homedir(); + +/** + * 这一组判据补的是一个**不对称**(pi 评审 2026-09-15): + * 当初把 `piMailFallback` 造在共用的 `lib/workspace.js` 里时, + * **四个桥的 `npm test` 全绿,只有 `check-shared-libs.sh` 抓得到** + * —— 因为 `piMailFallback` 一条测试都没有,没有任何东西读它。 + * + * 为什么这个测试文件是**独立**的(不放进 `test/workspace.test.mjs`): + * 那个文件在四个桥里**逐字节相同**(md5 一致,属于共用测试), + * 往里加 pi 专有的断言会把共用测试也弄分叉 —— 与 `lib/` 是同一条规矩。 + */ + +test('★ piMailFallback 落在 ~/.pi/mail-sessions/(不是 .dsh)', () => { + // 目录名必须是 `.pi`:落进 `~/.dsh/` 会让人以为是 DSH 在干活。 + const p = piMailFallback('abc-123'); + assert.equal(p, join(HOME, '.pi', 'mail-sessions', 'abc-123')); + // 反向断言:绝不能带 `.dsh`(这正是当初要分叉出来的原因,写死它防回归) + assert.ok(!p.includes('.dsh'), `兜底目录不该落在 dsh 名下:${p}`); +}); + +test('空 key 收敛成 default(而不是 undefined/literal 空串)', () => { + // 缺失 session_id 时不能拼出 `.../undefined` 或 `.../`: + // 那会让所有缺 key 的会话挤进同一个目录,且目录名像个 bug。 + assert.equal(piMailFallback(undefined), join(HOME, '.pi', 'mail-sessions', 'default')); + assert.equal(piMailFallback(''), join(HOME, '.pi', 'mail-sessions', 'default')); + assert.equal(piMailFallback(null), join(HOME, '.pi', 'mail-sessions', 'default')); +}); + +test('数字/非字符串 key 也收敛成字符串(不产生 NaN 路径)', () => { + assert.equal(piMailFallback(42), join(HOME, '.pi', 'mail-sessions', '42')); +}); + +test('★ 结构判据:平台兜底值住在 src/,不在共用的 lib/ 里', () => { + // 这是这次分叉的**结构性**回归判据 —— 光测行为测不出"它住在哪"。 + const pathsSrc = readFileSync(join(ROOT, 'src', 'paths.mjs'), 'utf8'); + assert.match(pathsSrc, /export function piMailFallback/, '兜底函数应当在 src/paths.mjs'); + + const sharedWorkspace = readFileSync(join(ROOT, 'lib', 'workspace.js'), 'utf8'); + assert.ok(!sharedWorkspace.includes('piMailFallback'), + '共用 lib/workspace.js 里不该再有平台专有的 piMailFallback(它就是分叉的原因)'); + assert.ok(!sharedWorkspace.includes("'.pi'"), + '共用模块里不该出现 pi 的目录名常量'); + // `.dsh` 仍留在共用模块里是**对的**:那是 dsh 自己的兜底值, + // 也是共用模块保真的"唯一一份"。规矩保的是"只有一份",不是"不许有平台数据"。 + assert.ok(sharedWorkspace.includes('mailSessionFallback'), + '共用模块里 dsh 的兜底函数应当保留(它不是 pi 的,不该跟着搬)'); +}); + +test('★ 父子同源:pool 与 worker 从同一个模块取兜底值', () => { + // 为什么这条要紧:worker 的沙箱 rw 清单由**父进程**(pool.mjs)算。 + // 父进程猜一个目录、worker 落在另一个 ⇒ 症状是"界内也写不了"。 + // 所以两个文件必须 import 同一个函数,而不是各自拼一份。 + for (const f of ['pool.mjs', 'worker.mjs']) { + const src = readFileSync(join(ROOT, 'src', f), 'utf8'); + assert.match(src, /import \{ piMailFallback \} from '\.\/paths\.mjs'/, + `${f} 应当从 ./paths.mjs import piMailFallback`); + // 反向:不能再从共用的 lib/workspace.js import 它 + const workspaceImport = src.split('\n').find((l) => l.startsWith('import') && l.includes('workspace.js')); + assert.ok(workspaceImport !== undefined, `${f} 仍应从 lib/workspace.js import 别的函数`); + assert.ok(!workspaceImport.includes('piMailFallback'), + `${f} 不该再从共用模块 import piMailFallback:${workspaceImport}`); + } +});