@maxiaochao/pi-toolkit 0.13.7 → 0.13.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +5 -1
  3. package/extensions/btw/index.ts +7 -16
  4. package/extensions/btw/tests/context.mjs +78 -0
  5. package/package.json +4 -3
  6. package/packages/cache-export/CHANGELOG.md +10 -0
  7. package/packages/cache-export/README.md +14 -0
  8. package/packages/cache-export/TESTING.md +8 -2
  9. package/packages/cache-export/docs/demo.gif +0 -0
  10. package/packages/cache-export/docs/screenshot.png +0 -0
  11. package/packages/cache-export/extensions/cache-export.ts +44 -3
  12. package/packages/cache-export/package.json +1 -1
  13. package/packages/cache-export/src/render.ts +120 -59
  14. package/packages/cache-export/tests/e2e-browser.mjs +45 -5
  15. package/packages/cache-export/tests/evidence.mjs +113 -0
  16. package/packages/cache-export/tests/extension.mjs +52 -0
  17. package/packages/cache-export/tests/run-tests.mjs +12 -11
  18. package/packages/codex-edit/CHANGELOG.md +7 -0
  19. package/packages/codex-edit/README.md +4 -0
  20. package/packages/codex-edit/TESTING.md +4 -0
  21. package/packages/codex-edit/docs/test-report.html +19 -11
  22. package/packages/codex-edit/docs/test-report.md +22 -13
  23. package/packages/codex-edit/docs/test-summary.md +1 -1
  24. package/packages/codex-edit/extensions/codex-edit.ts +12 -4
  25. package/packages/codex-edit/tests/comparison-cases.mjs +35 -0
  26. package/packages/codex-edit/tests/comparison-harness.mjs +5 -6
  27. package/packages/codex-edit/tests/generate-test-report.mjs +12 -7
  28. package/packages/codex-edit/tests/native-vs-codex.ts +14 -14
  29. package/packages/codex-edit/tests/released-vs-local.test.mjs +4 -0
  30. package/packages/herdr-subagents/ARCHITECTURE.md +4 -1
  31. package/packages/herdr-subagents/CHANGELOG.md +12 -0
  32. package/packages/herdr-subagents/README.md +11 -2
  33. package/packages/herdr-subagents/TESTING.md +12 -0
  34. package/packages/herdr-subagents/docs/demo.webm +0 -0
  35. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  36. package/packages/herdr-subagents/extensions/subagent-report.ts +84 -30
  37. package/packages/herdr-subagents/package.json +2 -2
  38. package/packages/herdr-subagents/tests/report-boundary.mjs +83 -0
  39. package/packages/herdr-subagents/tests/report-lifecycle.mjs +69 -0
  40. package/packages/herdr-subagents/tests/runtime.mjs +26 -0
  41. package/packages/herdr-subagents/tests/standalone-package.mjs +23 -0
  42. package/packages/worktree/CHANGELOG.md +4 -0
  43. package/packages/worktree/README.md +4 -0
  44. package/packages/worktree/TESTING.md +4 -0
  45. package/packages/worktree/bin/pi-worktree +1 -1
  46. package/packages/worktree/tests/run-tests.sh +10 -0
  47. package/skills/web-browser/SKILL.md +3 -1
  48. package/skills/web-browser/scripts/start.js +42 -8
@@ -0,0 +1,52 @@
1
+ import "../../background-tasks/tests/runtime.mjs";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import cacheExport from "../extensions/cache-export.ts";
7
+ import { parseEntries } from "../src/render.ts";
8
+ const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } = await import("@earendil-works/pi-coding-agent");
9
+ const { createAssistantMessageEventStream } = await import("@earendil-works/pi-ai");
10
+ const root = mkdtempSync(join(tmpdir(), "cache-sdk-"));
11
+ const runtime = await ModelRuntime.create({ authPath: join(root, "auth.json"), modelsPath: join(root, "models.json"), refreshOnCreate: false });
12
+ let session;
13
+ try {
14
+ runtime.registerProvider("cache-fixture", {
15
+ api: "cache-fixture", apiKey: "not-secret", baseUrl: "http://127.0.0.1:1",
16
+ models: [{ id: "offline", name: "Offline", input: ["text"], reasoning: false, contextWindow: 128000, maxTokens: 4096, promptCache: { short: 300, long: 86400 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }],
17
+ streamSimple(model, _context, options) {
18
+ const stream = createAssistantMessageEventStream();
19
+ const timestamp = Date.now();
20
+ queueMicrotask(async () => {
21
+ await options.onPayload?.({ prompt_cache_retention: "24h", prompt_cache_key: "SECRET_KEY", input: "SECRET_PAYLOAD" }, model);
22
+ const message = { role: "assistant", provider: model.provider, api: model.api, model: model.id, timestamp, durationMs: 10, content: [{ type: "text", text: "offline" }], stopReason: "stop", usage: { input: 100, output: 1, cacheRead: 1000, cacheWrite: 0, totalTokens: 1101, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
23
+ stream.push({ type: "start", partial: message });
24
+ stream.push({ type: "done", reason: "stop", message });
25
+ });
26
+ return stream;
27
+ },
28
+ });
29
+ const settingsManager = SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false }, cacheWarming: "off" });
30
+ const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, extensionFactories: [cacheExport] });
31
+ await resourceLoader.reload();
32
+ const created = await createAgentSession({ cwd: root, agentDir: root, modelRuntime: runtime, model: runtime.getModel("cache-fixture", "offline"), resourceLoader, settingsManager, sessionManager: SessionManager.inMemory(root), tools: [] });
33
+ session = created.session;
34
+ assert.deepEqual(created.extensionsResult.errors, []);
35
+ const errors = [];
36
+ session.extensionRunner.onError((error) => errors.push(error));
37
+ await session.bindExtensions({});
38
+ await session.prompt("Produce deterministic usage.");
39
+ const entries = session.sessionManager.getBranch();
40
+ const data = parseEntries(entries);
41
+ assert.equal(data.requests.length, 1);
42
+ assert.equal(data.requests[0].policyReferenceSeconds, 86400, "native hook observation must survive projection and match its assistant message");
43
+ assert.equal(data.requests[0].durationMs, 10);
44
+ assert.doesNotMatch(JSON.stringify(entries), /SECRET_KEY|SECRET_PAYLOAD/);
45
+ await session.prompt("/cache_export native.html");
46
+ assert.match(readFileSync(join(root, "native.html"), "utf8"), /86400s recorded reference/);
47
+ assert.deepEqual(errors, []);
48
+ console.log("PASS native Pi cache export: safe request evidence, message correlation, session-relative path");
49
+ } finally {
50
+ if (session) { await session.abort(); session.dispose(); }
51
+ rmSync(root, { recursive: true, force: true });
52
+ }
@@ -118,14 +118,14 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
118
118
  check("G fresh=799 -> not member", analyzeStreaks(parseEntries(mk3(7190, 799)).requests).length === 0);
119
119
  }
120
120
 
121
- // H. TTL expiry attribution ---------------------------------------------
121
+ // H. Unknown historical timing/policy ---------------------------------------------
122
122
  {
123
123
  const entries = [
124
124
  req({ cached: 50000, fresh: 2000, min: 1 }),
125
125
  req({ cached: 5000, fresh: 15000, min: 40 }), // 39min gap same model, hit .25
126
126
  ];
127
127
  const m = analyzeMisses(parseEntries(entries).requests, parseEntries(entries).events);
128
- check("H 39min gap + low hit -> possible TTL expiry", m.misses.length === 1 && m.misses[0].causes.some(([c]) => c === "possible TTL expiry"),
128
+ check("H missing request timing/policy -> no invented TTL expiry", m.misses.length === 1 && !m.misses[0].causes.some(([c]) => c === "possible TTL expiry"),
129
129
  JSON.stringify(m.misses.map((x) => x.causes)));
130
130
  }
131
131
 
@@ -156,7 +156,7 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
156
156
  JSON.stringify(two && two.causes));
157
157
  }
158
158
 
159
- // H4. 1h retention survives intervening read-only requests -----------------
159
+ // H4. One-hour write references do not establish later expiry -----------------
160
160
  {
161
161
  const entries = [
162
162
  req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
@@ -165,13 +165,13 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
165
165
  ];
166
166
  const d = parseEntries(entries);
167
167
  const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
168
- check("H4 inherited 1h window -> rebuild, not TTL expiry",
169
- d.requests[1].cacheTtlMinutes === 60 && three && three.causes.some(([cause]) => cause === "cache rebuild") &&
168
+ check("H4 no invented expiry after intervening reported reads",
169
+ d.requests[0].policyReferenceSeconds === 3600 && d.requests[1].policyReferenceSeconds === null && three && three.causes.some(([cause]) => cause === "cache rebuild") &&
170
170
  !three.causes.some(([cause]) => cause === "possible TTL expiry"),
171
- JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
171
+ JSON.stringify({ reference: d.requests[1].policyReferenceSeconds, causes: three && three.causes }));
172
172
  }
173
173
 
174
- // H5. short-lived replacement clears an older 1h retention window ----------
174
+ // H5. Replacement without historical policy remains unknown ----------
175
175
  {
176
176
  const entries = [
177
177
  req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
@@ -180,9 +180,9 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
180
180
  ];
181
181
  const d = parseEntries(entries);
182
182
  const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
183
- check("H5 short replacement resets TTL to 5min",
184
- d.requests[1].cacheTtlMinutes === 5 && three && three.causes.some(([cause]) => cause === "possible TTL expiry"),
185
- JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
183
+ check("H5 replacement without TTL metadata remains unknown",
184
+ d.requests[1].policyReferenceSeconds === null && three && !three.causes.some(([cause]) => cause === "possible TTL expiry"),
185
+ JSON.stringify({ reference: d.requests[1].policyReferenceSeconds, causes: three && three.causes }));
186
186
  }
187
187
 
188
188
  // I. cache rebuild (prev turn was a miss, same model) -------------------
@@ -393,8 +393,9 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
393
393
  const active = entry(req({ cached: 8000, fresh: 2000, min: 3, model: "active-model" }), "p3", "p1");
394
394
 
395
395
  let command;
396
- registerCacheExport({ registerCommand: (name, definition) => { if (name === "cache_export") command = definition; } });
396
+ registerCacheExport({ on() {}, registerCommand: (name, definition) => { if (name === "cache_export") command = definition; } });
397
397
  await command.handler(reportFile, {
398
+ cwd: root,
398
399
  sessionManager: {
399
400
  getBranch: () => [first, active],
400
401
  getEntries: () => [first, abandoned, active],
@@ -1,3 +1,10 @@
1
+ ## Unreleased
2
+
3
+ - Reject file/ancestor target conflicts before any patch writes, regardless of operation order.
4
+ - Preserve ordinary source permission bits on Move, including executable bits and permissions masked by umask.
5
+ - Add real-filesystem cases to the shared corpus. Keep the frozen parser baseline unchanged and mark filesystem-only baseline comparisons unsupported.
6
+ - Keep expected-failure assertions outside exception handlers so unexpected success cannot pass as rejection.
7
+
1
8
  ## 2026-10-21 - v0.2.0
2
9
 
3
10
  - persist personal routing rules in the Pi agent directory across package updates
@@ -136,3 +136,7 @@ Herdr Subagents does not automatically inherit the parent's extensions. To enabl
136
136
  - `docs/test-report.html`: current self-contained report; its authoritative source is `docs/test-report.md`.
137
137
 
138
138
  The historical 20-case benchmark found equal final correctness, with first editor-call success improving from 85% to 95%. The completed frozen 100-task three-arm benchmark (`explicit-edit-100-sol-low-v3-complete`) ran all 300 chains: native and released v0.1.7 scored 91.25%, while the local version scored 92.00%. All arms reached 92% Final Exact, all eight final failures were shared, and there was no local-only final regression. The sanitized run evidence is committed under `benchmark/results/`; the generated report contains the full methodology, paired results, efficiency, and safety evidence.
139
+
140
+ ## Filesystem safety
141
+
142
+ Preflight rejects file/parent target conflicts before any write. Move preserves ordinary permission bits, including executable bits. These checks prevent predictable path conflicts; they do not promise cross-file rollback for arbitrary disk failures. Real-filesystem regressions cover both operation orders, unchanged original bytes after rejection, and moved-file permissions.
@@ -57,3 +57,7 @@
57
57
  `summary.md` 的 20-case 报告是历史证据。当前正式运行 `explicit-edit-100-sol-low-v3-complete` 已完成 300/300 条 chain,实际成本 $12.388;本地版 First Exact 92%、Final Exact 92%、Score v2 92.00%,满足全部正式门槛。原生与发布版均为 First 91%、Final 92%、Score v2 91.25%;8 个最终失败为三臂共同例外,本地独有最终回归为 0。
58
58
 
59
59
  公开、去敏后的正式证据提交在 `benchmark/results/explicit-edit-100-sol-low-v3.json`。正常测试只读取该文件,因此干净 checkout 不依赖 `.cache`,且 Markdown 与 HTML 可逐字节重建。导入新运行时,使用 `benchmark/export-evidence.mjs <run-directory> <output.json>`;只有在确认运行完整性、配置 identity 和无敏感字段后才替换正式证据。预算必须在每次运行前声明 scheduling stop 与 hard cap,预算停止的前缀一律标为 INCOMPLETE,禁止选择性补跑后宣称完整。任何模型路由、prompt 或 tool schema 变化仍需重跑独立模型基准,不能用 parser 测试替代。
60
+
61
+ ## Filesystem review regressions
62
+
63
+ The shared comparison corpus includes two file/parent conflicts (both orderings) and a move preserving `0755`. The local extension runs against real temporary files; rejection must leave all original bytes unchanged. Native edit and the frozen released parser baseline cannot exercise these filesystem operations, so those arms are explicitly unsupported, not fabricated successes. The released parser file remains unchanged. Ordinary permission bits are tested on POSIX; no arbitrary disk-failure transaction rollback is claimed.
@@ -16,9 +16,9 @@
16
16
  <main><div class="eyebrow">GPT-5.6 Sol / Pi 0.85.1 / Explicit Edit Benchmark</div><h1 id="top">Codex Edit 测试报告</h1>
17
17
  <p><strong>回归检查:通过;Agent Benchmark:通过(有共同例外)</strong></p>
18
18
  <p>被测包:<code>@maxiaochao/pi-codex-edit</code>(当前工作区)</p>
19
- <p>本报告严格区分真实模型基准、确定性协议回归和安全回归,三类成功率不会混算。</p>
19
+ <p>本报告严格区分真实模型基准、确定性协议回归和安全回归,三类成功率不会混算。 Agent Benchmark 是已提交的历史运行证据,不代表本次文件系统修复重新运行了付费模型基准。</p>
20
20
  <h2 id="summary">执行摘要</h2>
21
- <ul><li>Agent Benchmark:<strong>通过(有共同例外)</strong> — 已完成 300/300 条 chain;本地版 First Exact 92/100、Final Exact 92/100,全部正式门槛通过。</li><li>协议/包回归:<strong>通过</strong> — 5/5 项检查通过。</li><li>安全回归:<strong>5/5 正确拒绝且零写入</strong>,包括工作区外路径。</li><li>下文提供 Agent、协议、安全、效率、版本、integrity 和任务来源的完整证据。</li></ul>
21
+ <ul><li>Agent Benchmark:<strong>通过(有共同例外)</strong> — 已完成 300/300 条 chain;本地版 First Exact 92/100、Final Exact 92/100,全部正式门槛通过。</li><li>协议/包回归:<strong>通过</strong> — 5/5 项检查通过。</li><li>安全回归:<strong>7/7 正确拒绝且零写入</strong>,包括工作区外路径。</li><li>下文提供 Agent、协议、安全、效率、版本、integrity 和任务来源的完整证据。</li></ul>
22
22
  <h2 id="benchmark">Agent Benchmark:真实模型基准</h2>
23
23
  <p><strong>状态:通过(有共同例外)</strong></p>
24
24
  <p>三个测试臂均完成全部 100 个配对任务(共 300 条 chain),实际成本为 $12.388。本地版满足全部正式验收门槛;8 个最终失败均为三臂共同失败,不是本地版独有回归。</p>
@@ -95,10 +95,10 @@
95
95
  <tr><td>独立包 tarball</td><td>通过</td><td><code>npm pack --dry-run --json</code></td></tr>
96
96
  </tbody></table></div>
97
97
  <h2 id="section-4">确定性回归基线</h2>
98
- <ul><li>Node 测试:预期 29 个,覆盖用户配置覆盖与校验、parser、matcher、路由、包契约、resolved-path 防护、发布版对比、分层调度和生产事故语料。</li><li>共享对比 corpus:12 个 case。</li><li>原生 edit:5 通过 / 2 失败或正确拒绝 / 5 不适用。</li><li>Codex Edit 发布版 v0.1.7:6 通过 / 6 失败或正确拒绝。</li><li>Codex Edit 本地版:7 通过 / 5 正确安全拒绝。</li></ul>
99
- <p>“正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示 Pi 原生 <code>edit</code> 无法用一次编辑协议调用表达该操作,不计为工具缺陷。</p>
98
+ <ul><li>Node 测试:预期 29 个,覆盖用户配置覆盖与校验、parser、matcher、路由、包契约、resolved-path 防护、发布版对比、分层调度和生产事故语料。</li><li>共享对比 corpus:15 个 case。</li><li>原生 edit:5 通过 / 2 失败或正确拒绝 / 8 不适用。</li><li>Codex Edit 发布版 v0.1.7:6 通过 / 6 失败或正确拒绝 / 3 不适用。</li><li>Codex Edit 本地版:8 通过 / 7 正确安全拒绝。</li></ul>
99
+ <p>“正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示原生 edit 无法表达该操作,或冻结的发布版 parser 基线不包含文件系统执行层;不计为工具缺陷。层级冲突与权限保留使用本地扩展和真实临时文件验证,不伪造发布版执行结果。</p>
100
100
  <h2 id="safety">安全回归</h2>
101
- <p>本地版 5/5 个安全 case 均正确拒绝,且工作区零写入。特别是 <code>outside-workspace-absolute-path</code> 的结果为 <strong>工作区外路径 — 正确拒绝</strong>;放行该操作会允许任意写入 benchmark sandbox 之外。安全拒绝不计入编辑失败率。</p>
101
+ <p>本地版 7/7 个安全 case 均正确拒绝,且工作区零写入。特别是 <code>outside-workspace-absolute-path</code> 的结果为 <strong>工作区外路径 — 正确拒绝</strong>;放行该操作会允许任意写入 benchmark sandbox 之外。安全拒绝不计入编辑失败率。</p>
102
102
  <h2 id="incidents">生产事故回归</h2>
103
103
  <p>这些 case 来自实际报告过的问题。Agent-and-protocol case 使用不包含工具语法提示的用户任务,可进入后续付费模型运行;protocol-only case 只验证指定工具形态,不用于宣称模型质量提升。</p>
104
104
  <div class="table-wrap"><table><thead><tr><th>Incident</th><th>层级</th><th>对应回归</th><th>问题</th></tr></thead><tbody>
@@ -110,14 +110,14 @@
110
110
  </tbody></table></div>
111
111
  <p>冻结的 100-task 清单按每 5 个任务包含 4 Neutral + 1 Challenge 的顺序调度;本次完整运行覆盖全部 80 个 Neutral 和 20 个 Challenge。</p>
112
112
  <h2 id="comparison">确定性对比结论</h2>
113
- <p>每个测试臂运行同一组 12 个 case。对比 1 执行 24 次工具调用(原生 + 本地);对比 2 执行 24 次 parser/执行评估(发布版 + 本地)。下表以 7 个有效编辑任务为完成率分母;5 个故意无效或危险的 patch 单独计入安全检查。</p>
113
+ <p>共享语料共 15 个 case。不适用的测试臂不执行。对比 1 验证本地扩展的 8 个有效编辑和 7 个安全 case;对比 2 只比较 parser 基线适用的 7 个有效编辑和 5 个安全 case。</p>
114
114
  <h3 id="1-pi-edit-vs-codex-edit">1. Pi 原生 edit vs Codex Edit 本地版</h3>
115
115
  <div class="table-wrap"><table><thead><tr><th>指标</th><th>Pi 原生 edit</th><th>Codex Edit 本地版</th><th>变化</th></tr></thead><tbody>
116
- <tr><td>有效任务完成</td><td>5/7</td><td>7/7</td><td>+2 个</td></tr>
117
- <tr><td>有效任务完成率</td><td>71.4%</td><td>100.0%</td><td>+28.6 pp</td></tr>
118
- <tr><td>有效任务失败</td><td>1/7</td><td>0/7</td><td>减少 1 个</td></tr>
119
- <tr><td>有效任务不适用</td><td>1/7</td><td>0/7</td><td>减少 1 个</td></tr>
120
- <tr><td>正确拒绝适用的危险/陈旧 patch</td><td>1/1 (100.0%)</td><td>5/5 (100.0%)</td><td>Codex 覆盖全部 5 个</td></tr>
116
+ <tr><td>有效任务完成</td><td>5/8</td><td>8/8</td><td>+3 个</td></tr>
117
+ <tr><td>有效任务完成率</td><td>62.5%</td><td>100.0%</td><td>+37.5 pp</td></tr>
118
+ <tr><td>有效任务失败</td><td>1/8</td><td>0/8</td><td>减少 1 个</td></tr>
119
+ <tr><td>有效任务不适用</td><td>2/8</td><td>0/8</td><td>减少 2 个</td></tr>
120
+ <tr><td>正确拒绝适用的危险/陈旧 patch</td><td>1/1 (100.0%)</td><td>7/7 (100.0%)</td><td>Codex 覆盖全部 7 个</td></tr>
121
121
  </tbody></table></div>
122
122
  <h3 id="2-codex-edit-v0-1-7-vs">2. Codex Edit 发布版 v0.1.7 vs 本地修改版</h3>
123
123
  <div class="table-wrap"><table><thead><tr><th>指标</th><th>发布版 v0.1.7</th><th>本地修改版</th><th>变化</th></tr></thead><tbody>
@@ -130,6 +130,9 @@
130
130
  <h2 id="case-edit-vs-codex-edit">逐 case:原生 edit vs 本地 Codex Edit</h2>
131
131
  <p>该对比在隔离临时工作区中执行 Pi 导出的 <code>createEditToolDefinition</code>,以及本地扩展实际注册的 <code>apply_patch</code>。</p>
132
132
  <div class="table-wrap"><table><thead><tr><th>Case</th><th>原生 edit</th><th>本地 Codex Edit</th><th>说明</th></tr></thead><tbody>
133
+ <tr><td><code>file-parent-conflict</code></td><td>不适用</td><td>正确拒绝/失败</td><td>两者均避免危险写入</td></tr>
134
+ <tr><td><code>file-parent-conflict-reversed</code></td><td>不适用</td><td>正确拒绝/失败</td><td>两者均避免危险写入</td></tr>
135
+ <tr><td><code>move-preserves-file-mode</code></td><td>不适用</td><td>通过</td><td>Codex 协议可处理该形态</td></tr>
133
136
  <tr><td><code>unique-replacement</code></td><td>通过</td><td>通过</td><td>预期行为一致</td></tr>
134
137
  <tr><td><code>disjoint-edits-one-file</code></td><td>通过</td><td>通过</td><td>预期行为一致</td></tr>
135
138
  <tr><td><code>repeated-markdown-separator</code></td><td>正确拒绝/失败</td><td>通过</td><td>Codex 协议可处理该形态</td></tr>
@@ -147,6 +150,9 @@
147
150
  <h2 id="case-v0-1-7-vs-codex-edit">逐 case:发布版 v0.1.7 vs 本地 Codex Edit</h2>
148
151
  <p>发布版测试臂使用从 tag <code>codex-edit-v0.1.7</code> 冻结的语义基线(source blob <code>e2896b59d40b5f6f01b3a4a7918476f4acc28cd8</code>)。</p>
149
152
  <div class="table-wrap"><table><thead><tr><th>Case</th><th>发布版 v0.1.7</th><th>本地版</th><th>回归状态</th></tr></thead><tbody>
153
+ <tr><td><code>file-parent-conflict</code></td><td>不适用</td><td>正确拒绝/失败</td><td>仅本地真实文件验证</td></tr>
154
+ <tr><td><code>file-parent-conflict-reversed</code></td><td>不适用</td><td>正确拒绝/失败</td><td>仅本地真实文件验证</td></tr>
155
+ <tr><td><code>move-preserves-file-mode</code></td><td>不适用</td><td>通过</td><td>仅本地真实文件验证</td></tr>
150
156
  <tr><td><code>unique-replacement</code></td><td>通过</td><td>通过</td><td>已保留</td></tr>
151
157
  <tr><td><code>disjoint-edits-one-file</code></td><td>通过</td><td>通过</td><td>已保留</td></tr>
152
158
  <tr><td><code>repeated-markdown-separator</code></td><td>通过</td><td>通过</td><td>已保留</td></tr>
@@ -167,6 +173,8 @@
167
173
  <tr><td>同一 raw path 出现多个普通 <code>Update File</code></td><td>合并后按顺序执行</td><td><code>repeated-update-same-path</code></td></tr>
168
174
  <tr><td>Add/delete/move 或 resolved path alias 冲突</td><td>写入前拒绝</td><td><code>conflicting-targets</code>、<code>resolved-path-alias-conflict</code></td></tr>
169
175
  <tr><td>工作区外绝对路径</td><td>读取或写入前正确拒绝</td><td><code>outside-workspace-absolute-path</code></td></tr>
176
+ <tr><td>文件与父目录层级冲突</td><td>任意操作写入前拒绝,两种顺序均零写入</td><td><code>file-parent-conflict</code>、<code>file-parent-conflict-reversed</code></td></tr>
177
+ <tr><td>Move 丢失执行权限</td><td>保留源文件普通权限位,不受 umask 截断</td><td><code>move-preserves-file-mode</code></td></tr>
170
178
  <tr><td>空 update chunk</td><td>拒绝,不猜测意图</td><td><code>empty-update-chunk</code></td></tr>
171
179
  <tr><td>陈旧预期行</td><td>拒绝;调用方必须重新读取并生成 patch</td><td><code>stale-expected-lines</code></td></tr>
172
180
  <tr><td>缺少 <code>patch</code> 参数</td><td>必填 TypeBox schema 在扩展执行前拒绝</td><td>包契约测试</td></tr>
@@ -5,12 +5,13 @@
5
5
  被测包:`@maxiaochao/pi-codex-edit`(当前工作区)
6
6
 
7
7
  本报告严格区分真实模型基准、确定性协议回归和安全回归,三类成功率不会混算。
8
+ Agent Benchmark 是已提交的历史运行证据,不代表本次文件系统修复重新运行了付费模型基准。
8
9
 
9
10
  ## 执行摘要
10
11
 
11
12
  - Agent Benchmark:**通过(有共同例外)** — 已完成 300/300 条 chain;本地版 First Exact 92/100、Final Exact 92/100,全部正式门槛通过。
12
13
  - 协议/包回归:**通过** — 5/5 项检查通过。
13
- - 安全回归:**5/5 正确拒绝且零写入**,包括工作区外路径。
14
+ - 安全回归:**7/7 正确拒绝且零写入**,包括工作区外路径。
14
15
  - 下文提供 Agent、协议、安全、效率、版本、integrity 和任务来源的完整证据。
15
16
 
16
17
  ## Agent Benchmark:真实模型基准
@@ -123,16 +124,16 @@ Score v2 = `覆盖率 ×(0.75 × 首次精确率 + 0.25 × 最终精确率)`
123
124
  ## 确定性回归基线
124
125
 
125
126
  - Node 测试:预期 29 个,覆盖用户配置覆盖与校验、parser、matcher、路由、包契约、resolved-path 防护、发布版对比、分层调度和生产事故语料。
126
- - 共享对比 corpus:12 个 case。
127
- - 原生 edit:5 通过 / 2 失败或正确拒绝 / 5 不适用。
128
- - Codex Edit 发布版 v0.1.7:6 通过 / 6 失败或正确拒绝。
129
- - Codex Edit 本地版:7 通过 / 5 正确安全拒绝。
127
+ - 共享对比 corpus:15 个 case。
128
+ - 原生 edit:5 通过 / 2 失败或正确拒绝 / 8 不适用。
129
+ - Codex Edit 发布版 v0.1.7:6 通过 / 6 失败或正确拒绝 / 3 不适用。
130
+ - Codex Edit 本地版:8 通过 / 7 正确安全拒绝。
130
131
 
131
- “正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示 Pi 原生 `edit` 无法用一次编辑协议调用表达该操作,不计为工具缺陷。
132
+ “正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示原生 edit 无法表达该操作,或冻结的发布版 parser 基线不包含文件系统执行层;不计为工具缺陷。层级冲突与权限保留使用本地扩展和真实临时文件验证,不伪造发布版执行结果。
132
133
 
133
134
  ## 安全回归
134
135
 
135
- 本地版 5/5 个安全 case 均正确拒绝,且工作区零写入。特别是 `outside-workspace-absolute-path` 的结果为 **工作区外路径 — 正确拒绝**;放行该操作会允许任意写入 benchmark sandbox 之外。安全拒绝不计入编辑失败率。
136
+ 本地版 7/7 个安全 case 均正确拒绝,且工作区零写入。特别是 `outside-workspace-absolute-path` 的结果为 **工作区外路径 — 正确拒绝**;放行该操作会允许任意写入 benchmark sandbox 之外。安全拒绝不计入编辑失败率。
136
137
 
137
138
  ## 生产事故回归
138
139
 
@@ -150,17 +151,17 @@ Score v2 = `覆盖率 ×(0.75 × 首次精确率 + 0.25 × 最终精确率)`
150
151
 
151
152
  ## 确定性对比结论
152
153
 
153
- 每个测试臂运行同一组 12 个 case。对比 1 执行 24 次工具调用(原生 + 本地);对比 2 执行 24 次 parser/执行评估(发布版 + 本地)。下表以 7 个有效编辑任务为完成率分母;5 个故意无效或危险的 patch 单独计入安全检查。
154
+ 共享语料共 15 个 case。不适用的测试臂不执行。对比 1 验证本地扩展的 8 个有效编辑和 7 个安全 case;对比 2 只比较 parser 基线适用的 7 个有效编辑和 5 个安全 case。
154
155
 
155
156
  ### 1. Pi 原生 edit vs Codex Edit 本地版
156
157
 
157
158
  | 指标 | Pi 原生 edit | Codex Edit 本地版 | 变化 |
158
159
  |---|---:|---:|---:|
159
- | 有效任务完成 | 5/7 | 7/7 | +2 个 |
160
- | 有效任务完成率 | 71.4% | 100.0% | +28.6 pp |
161
- | 有效任务失败 | 1/7 | 0/7 | 减少 1 个 |
162
- | 有效任务不适用 | 1/7 | 0/7 | 减少 1 个 |
163
- | 正确拒绝适用的危险/陈旧 patch | 1/1 (100.0%) | 5/5 (100.0%) | Codex 覆盖全部 5 个 |
160
+ | 有效任务完成 | 5/8 | 8/8 | +3 个 |
161
+ | 有效任务完成率 | 62.5% | 100.0% | +37.5 pp |
162
+ | 有效任务失败 | 1/8 | 0/8 | 减少 1 个 |
163
+ | 有效任务不适用 | 2/8 | 0/8 | 减少 2 个 |
164
+ | 正确拒绝适用的危险/陈旧 patch | 1/1 (100.0%) | 7/7 (100.0%) | Codex 覆盖全部 7 个 |
164
165
 
165
166
  ### 2. Codex Edit 发布版 v0.1.7 vs 本地修改版
166
167
 
@@ -179,6 +180,9 @@ Score v2 = `覆盖率 ×(0.75 × 首次精确率 + 0.25 × 最终精确率)`
179
180
 
180
181
  | Case | 原生 edit | 本地 Codex Edit | 说明 |
181
182
  |---|---|---|---|
183
+ | `file-parent-conflict` | 不适用 | 正确拒绝/失败 | 两者均避免危险写入 |
184
+ | `file-parent-conflict-reversed` | 不适用 | 正确拒绝/失败 | 两者均避免危险写入 |
185
+ | `move-preserves-file-mode` | 不适用 | 通过 | Codex 协议可处理该形态 |
182
186
  | `unique-replacement` | 通过 | 通过 | 预期行为一致 |
183
187
  | `disjoint-edits-one-file` | 通过 | 通过 | 预期行为一致 |
184
188
  | `repeated-markdown-separator` | 正确拒绝/失败 | 通过 | Codex 协议可处理该形态 |
@@ -200,6 +204,9 @@ Score v2 = `覆盖率 ×(0.75 × 首次精确率 + 0.25 × 最终精确率)`
200
204
 
201
205
  | Case | 发布版 v0.1.7 | 本地版 | 回归状态 |
202
206
  |---|---|---|---|
207
+ | `file-parent-conflict` | 不适用 | 正确拒绝/失败 | 仅本地真实文件验证 |
208
+ | `file-parent-conflict-reversed` | 不适用 | 正确拒绝/失败 | 仅本地真实文件验证 |
209
+ | `move-preserves-file-mode` | 不适用 | 通过 | 仅本地真实文件验证 |
203
210
  | `unique-replacement` | 通过 | 通过 | 已保留 |
204
211
  | `disjoint-edits-one-file` | 通过 | 通过 | 已保留 |
205
212
  | `repeated-markdown-separator` | 通过 | 通过 | 已保留 |
@@ -223,6 +230,8 @@ Score v2 = `覆盖率 ×(0.75 × 首次精确率 + 0.25 × 最终精确率)`
223
230
  | 同一 raw path 出现多个普通 `Update File` | 合并后按顺序执行 | `repeated-update-same-path` |
224
231
  | Add/delete/move 或 resolved path alias 冲突 | 写入前拒绝 | `conflicting-targets`、`resolved-path-alias-conflict` |
225
232
  | 工作区外绝对路径 | 读取或写入前正确拒绝 | `outside-workspace-absolute-path` |
233
+ | 文件与父目录层级冲突 | 任意操作写入前拒绝,两种顺序均零写入 | `file-parent-conflict`、`file-parent-conflict-reversed` |
234
+ | Move 丢失执行权限 | 保留源文件普通权限位,不受 umask 截断 | `move-preserves-file-mode` |
226
235
  | 空 update chunk | 拒绝,不猜测意图 | `empty-update-chunk` |
227
236
  | 陈旧预期行 | 拒绝;调用方必须重新读取并生成 patch | `stale-expected-lines` |
228
237
  | 缺少 `patch` 参数 | 必填 TypeBox schema 在扩展执行前拒绝 | 包契约测试 |
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Agent Benchmark:**通过(有共同例外)** — 完成 300/300 条 chain;全部正式验收门槛通过。
4
4
  - 协议/包回归:**通过** — 5/5 项检查通过。
5
- - 安全回归:**5/5 正确拒绝**,覆盖陈旧上下文、空 chunk、目标冲突、resolved alias 和工作区外路径。
5
+ - 安全回归:**7/7 正确拒绝**,覆盖陈旧上下文、空 chunk、目标冲突、resolved alias 和工作区外路径。
6
6
  - First Exact:原生 edit 91/100,发布版 91/100,本地版 92/100;Final Exact:三臂均为 92/100。
7
7
  - 总成本:$12.388;Provider 失败:0。
8
8
 
@@ -1,4 +1,4 @@
1
- import { access, lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
1
+ import { access, chmod, lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { Text } from "@earendil-works/pi-tui";
@@ -114,6 +114,12 @@ export default async function (pi) {
114
114
  resolved.push({ ...operation, source, destination });
115
115
  }
116
116
  if (new Set(targets).size !== targets.length) throw new Error("Multiple patch operations target the same resolved path");
117
+ const targetSet = new Set(targets);
118
+ for (const target of targets) {
119
+ for (let parent = dirname(target); parent !== dirname(parent); parent = dirname(parent)) {
120
+ if (targetSet.has(parent)) throw new Error(`Patch file target is also a parent directory: ${parent}`);
121
+ }
122
+ }
117
123
 
118
124
  const committed = [];
119
125
  return withPathQueues(targets, async () => {
@@ -126,7 +132,7 @@ export default async function (pi) {
126
132
  if (await pathExists(operation.source)) throw new Error(`Cannot add existing file ${operation.path}`);
127
133
  prepared.push({ ...operation, before: "", after: operation.content });
128
134
  } else {
129
- await fileInfo(operation.source, `Source file ${operation.path}`);
135
+ const info = await fileInfo(operation.source, `Source file ${operation.path}`);
130
136
  const before = await readFile(operation.source, "utf8");
131
137
  const after = operation.action === "delete"
132
138
  ? ""
@@ -134,7 +140,7 @@ export default async function (pi) {
134
140
  if (operation.destination && await pathExists(operation.destination)) {
135
141
  throw new Error(`Cannot move over existing file ${operation.moveTo}`);
136
142
  }
137
- prepared.push({ ...operation, before, after });
143
+ prepared.push({ ...operation, before, after, mode: info.mode & 0o777 });
138
144
  }
139
145
  }
140
146
 
@@ -147,7 +153,9 @@ export default async function (pi) {
147
153
  await unlink(operation.source);
148
154
  } else if (operation.destination) {
149
155
  await mkdir(dirname(operation.destination), { recursive: true });
150
- await writeFile(operation.destination, operation.after, "utf8");
156
+ await writeFile(operation.destination, operation.after, { encoding: "utf8", flag: "wx", mode: operation.mode });
157
+ // Creation applies umask; restore the source's ordinary permission bits.
158
+ await chmod(operation.destination, operation.mode);
151
159
  await unlink(operation.source);
152
160
  } else {
153
161
  await writeFile(operation.source, operation.after, "utf8");
@@ -1,4 +1,39 @@
1
1
  export const comparisonCases = [
2
+ ...[false, true].map((childFirst) => ({
3
+ id: childFirst ? "file-parent-conflict-reversed" : "file-parent-conflict",
4
+ kind: "safety",
5
+ executionOnly: true,
6
+ input: { "important.txt": "before\n" },
7
+ expected: null,
8
+ patch: `*** Begin Patch
9
+ *** Update File: important.txt
10
+ @@
11
+ -before
12
+ +after
13
+ ${childFirst ? "*** Add File: parent/child.txt\n+child\n*** Add File: parent\n+file" : "*** Add File: parent\n+file\n*** Add File: parent/child.txt\n+child"}
14
+ *** End Patch`,
15
+ nativeCalls: null,
16
+ // The frozen released baseline covers the parser, not filesystem preflight.
17
+ outcomes: { native: "unsupported", released: "unsupported", local: "fail" },
18
+ })),
19
+ {
20
+ id: "move-preserves-file-mode",
21
+ kind: "valid-edit",
22
+ executionOnly: true,
23
+ input: { "run.sh": "#!/bin/sh\necho old\n" },
24
+ inputModes: { "run.sh": 0o755 },
25
+ expected: { "moved.sh": "#!/bin/sh\necho new\n" },
26
+ expectedModes: { "moved.sh": 0o755 },
27
+ patch: `*** Begin Patch
28
+ *** Update File: run.sh
29
+ *** Move to: moved.sh
30
+ @@
31
+ -echo old
32
+ +echo new
33
+ *** End Patch`,
34
+ nativeCalls: null,
35
+ outcomes: { native: "unsupported", released: "unsupported", local: "pass" },
36
+ },
2
37
  {
3
38
  id: "unique-replacement",
4
39
  kind: "valid-edit",
@@ -30,15 +30,14 @@ export function applyOperations(input, operations, applyUpdate) {
30
30
  }
31
31
 
32
32
  export function runPatchCase(testCase, parse, applyUpdate, expectedOutcome) {
33
+ let actual;
33
34
  try {
34
- const actual = applyOperations(testCase.input, parse(testCase.patch), applyUpdate);
35
- if (expectedOutcome !== "pass") {
36
- assert.fail(`${testCase.id}: expected ${expectedOutcome}, but patch succeeded`);
37
- }
38
- assert.deepEqual(actual, testCase.expected, `${testCase.id}: final filesystem differs`);
39
- return "pass";
35
+ actual = applyOperations(testCase.input, parse(testCase.patch), applyUpdate);
40
36
  } catch (error) {
41
37
  if (expectedOutcome === "pass") throw error;
42
38
  return "fail";
43
39
  }
40
+ assert.equal(expectedOutcome, "pass", `${testCase.id}: patch unexpectedly succeeded`);
41
+ assert.deepEqual(actual, testCase.expected, `${testCase.id}: final filesystem differs`);
42
+ return "pass";
44
43
  }
@@ -314,6 +314,8 @@ function buildMarkdown(results, benchmark) {
314
314
  const assessment = benchmarkAssessment(benchmark);
315
315
  const validCases = comparisonCases.filter((testCase) => testCase.outcomes.local === "pass");
316
316
  const safetyCases = comparisonCases.filter((testCase) => testCase.outcomes.local === "fail");
317
+ const parserValidCases = validCases.filter((testCase) => !testCase.executionOnly);
318
+ const parserSafetyCases = safetyCases.filter((testCase) => !testCase.executionOnly);
317
319
  const nativeValidPasses = validCases.filter((testCase) => testCase.outcomes.native === "pass").length;
318
320
  const nativeValidFailures = validCases.filter((testCase) => testCase.outcomes.native === "fail").length;
319
321
  const nativeValidUnsupported = validCases.filter((testCase) => testCase.outcomes.native === "unsupported").length;
@@ -331,6 +333,7 @@ function buildMarkdown(results, benchmark) {
331
333
  `被测包:\`@maxiaochao/pi-codex-edit\`(当前工作区)`,
332
334
  "",
333
335
  "本报告严格区分真实模型基准、确定性协议回归和安全回归,三类成功率不会混算。",
336
+ "Agent Benchmark 是已提交的历史运行证据,不代表本次文件系统修复重新运行了付费模型基准。",
334
337
  "",
335
338
  "## 执行摘要",
336
339
  "",
@@ -352,10 +355,10 @@ function buildMarkdown(results, benchmark) {
352
355
  `- Node 测试:预期 29 个,覆盖用户配置覆盖与校验、parser、matcher、路由、包契约、resolved-path 防护、发布版对比、分层调度和生产事故语料。`,
353
356
  `- 共享对比 corpus:${comparisonCases.length} 个 case。`,
354
357
  `- 原生 edit:${count("pass", "native")} 通过 / ${count("fail", "native")} 失败或正确拒绝 / ${count("unsupported", "native")} 不适用。`,
355
- `- Codex Edit 发布版 v0.1.7:${count("pass", "released")} 通过 / ${count("fail", "released")} 失败或正确拒绝。`,
358
+ `- Codex Edit 发布版 v0.1.7:${count("pass", "released")} 通过 / ${count("fail", "released")} 失败或正确拒绝 / ${count("unsupported", "released")} 不适用。`,
356
359
  `- Codex Edit 本地版:${count("pass", "local")} 通过 / ${count("fail", "local")} 正确安全拒绝。`,
357
360
  "",
358
- "“正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示 Pi 原生 `edit` 无法用一次编辑协议调用表达该操作,不计为工具缺陷。",
361
+ "“正确拒绝”表示无效或陈旧操作被拒绝,且工作区没有变化。“不适用”表示原生 edit 无法表达该操作,或冻结的发布版 parser 基线不包含文件系统执行层;不计为工具缺陷。层级冲突与权限保留使用本地扩展和真实临时文件验证,不伪造发布版执行结果。",
359
362
  "",
360
363
  "## 安全回归",
361
364
  "",
@@ -373,7 +376,7 @@ function buildMarkdown(results, benchmark) {
373
376
  "",
374
377
  "## 确定性对比结论",
375
378
  "",
376
- `每个测试臂运行同一组 ${comparisonCases.length} 个 case。对比 1 执行 ${comparisonCases.length * 2} 次工具调用(原生 + 本地);对比 2 执行 ${comparisonCases.length * 2} 次 parser/执行评估(发布版 + 本地)。下表以 ${validCases.length} 个有效编辑任务为完成率分母;${safetyCases.length} 个故意无效或危险的 patch 单独计入安全检查。`,
379
+ `共享语料共 ${comparisonCases.length} 个 case。不适用的测试臂不执行。对比 1 验证本地扩展的 ${validCases.length} 个有效编辑和 ${safetyCases.length} 个安全 case;对比 2 只比较 parser 基线适用的 ${parserValidCases.length} 个有效编辑和 ${parserSafetyCases.length} 个安全 case。`,
377
380
  "",
378
381
  "### 1. Pi 原生 edit vs Codex Edit 本地版",
379
382
  "",
@@ -389,9 +392,9 @@ function buildMarkdown(results, benchmark) {
389
392
  "",
390
393
  "| 指标 | 发布版 v0.1.7 | 本地修改版 | 变化 |",
391
394
  "|---|---:|---:|---:|",
392
- `| 有效任务完成 | ${releasedValidPasses}/${validCases.length} | ${localValidPasses}/${validCases.length} | +${localValidPasses - releasedValidPasses} 个 |`,
393
- `| 有效任务完成率 | ${rate(releasedValidPasses, validCases.length)} | ${rate(localValidPasses, validCases.length)} | ${signedPoints(localValidPasses, releasedValidPasses, validCases.length)} |`,
394
- `| 安全 case 正确拒绝 | ${releasedSafetyPasses}/${safetyCases.length} (${rate(releasedSafetyPasses, safetyCases.length)}) | ${localSafetyPasses}/${safetyCases.length} (${rate(localSafetyPasses, safetyCases.length)}) | 无变化 |`,
395
+ `| 有效任务完成 | ${releasedValidPasses}/${parserValidCases.length} | ${parserValidCases.length}/${parserValidCases.length} | +${parserValidCases.length - releasedValidPasses} 个 |`,
396
+ `| 有效任务完成率 | ${rate(releasedValidPasses, parserValidCases.length)} | 100.0% | ${signedPoints(parserValidCases.length, releasedValidPasses, parserValidCases.length)} |`,
397
+ `| 安全 case 正确拒绝 | ${releasedSafetyPasses}/${parserSafetyCases.length} (${rate(releasedSafetyPasses, parserSafetyCases.length)}) | ${parserSafetyCases.length}/${parserSafetyCases.length} (100.0%) | 无变化 |`,
395
398
  `| 保留原有通过任务 | ${releasedValidPasses}/${releasedValidPasses} | ${releasedValidPasses}/${releasedValidPasses} | 100% 保留 |`,
396
399
  "",
397
400
  "这些是确定性协议/执行成功率,不是独立模型采样成功率。Agent 质量、token、成本和耗时只在 Agent Benchmark 章节报告。",
@@ -424,7 +427,7 @@ function buildMarkdown(results, benchmark) {
424
427
  ...comparisonCases.map((testCase) => {
425
428
  const released = outcomeLabel(testCase.outcomes.released);
426
429
  const local = outcomeLabel(testCase.outcomes.local);
427
- const status = testCase.outcomes.released === "fail" && testCase.outcomes.local === "pass"
430
+ const status = testCase.executionOnly ? "仅本地真实文件验证" : testCase.outcomes.released === "fail" && testCase.outcomes.local === "pass"
428
431
  ? "已修复"
429
432
  : "已保留";
430
433
  return `| \`${testCase.id}\` | ${released} | ${local} | ${status} |`;
@@ -440,6 +443,8 @@ function buildMarkdown(results, benchmark) {
440
443
  "| 同一 raw path 出现多个普通 `Update File` | 合并后按顺序执行 | `repeated-update-same-path` |",
441
444
  "| Add/delete/move 或 resolved path alias 冲突 | 写入前拒绝 | `conflicting-targets`、`resolved-path-alias-conflict` |",
442
445
  "| 工作区外绝对路径 | 读取或写入前正确拒绝 | `outside-workspace-absolute-path` |",
446
+ "| 文件与父目录层级冲突 | 任意操作写入前拒绝,两种顺序均零写入 | `file-parent-conflict`、`file-parent-conflict-reversed` |",
447
+ "| Move 丢失执行权限 | 保留源文件普通权限位,不受 umask 截断 | `move-preserves-file-mode` |",
443
448
  "| 空 update chunk | 拒绝,不猜测意图 | `empty-update-chunk` |",
444
449
  "| 陈旧预期行 | 拒绝;调用方必须重新读取并生成 patch | `stale-expected-lines` |",
445
450
  "| 缺少 `patch` 参数 | 必填 TypeBox schema 在扩展执行前拒绝 | 包契约测试 |",
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { chmod, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { createEditToolDefinition } from "@earendil-works/pi-coding-agent";
@@ -37,15 +37,13 @@ async function runNativeCase(testCase) {
37
37
  for (const [index, call] of testCase.nativeCalls.entries()) {
38
38
  await edit.execute(`native-${index}`, call, undefined, undefined, { cwd: workspace });
39
39
  }
40
- if (testCase.outcomes.native !== "pass") {
41
- assert.fail(`${testCase.id}: native edit unexpectedly succeeded`);
42
- }
43
- assert.deepEqual(await readSnapshot(workspace), testCase.expected);
44
- return "pass";
45
40
  } catch (error) {
46
41
  if (testCase.outcomes.native === "pass") throw error;
47
42
  return "fail";
48
43
  }
44
+ assert.equal(testCase.outcomes.native, "pass", `${testCase.id}: native edit unexpectedly succeeded`);
45
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
46
+ return "pass";
49
47
  } finally {
50
48
  await rm(workspace, { recursive: true, force: true });
51
49
  }
@@ -66,16 +64,12 @@ async function runCodexCase(tool, testCase) {
66
64
  const workspace = await mkdtemp(join(tmpdir(), "codex-edit-local-"));
67
65
  try {
68
66
  await writeSnapshot(workspace, testCase.input);
67
+ for (const [path, mode] of Object.entries(testCase.inputModes ?? {})) await chmod(join(workspace, path), mode);
69
68
  try {
70
69
  await tool.execute("codex-local", { patch: testCase.patch }, undefined, undefined, {
71
70
  cwd: workspace,
72
71
  hasUI: false,
73
72
  });
74
- if (testCase.outcomes.local !== "pass") {
75
- assert.fail(`${testCase.id}: local Codex Edit unexpectedly succeeded`);
76
- }
77
- assert.deepEqual(await readSnapshot(workspace), testCase.expected);
78
- return "pass";
79
73
  } catch (error) {
80
74
  if (testCase.outcomes.local === "pass") throw error;
81
75
  assert.deepEqual(
@@ -85,6 +79,12 @@ async function runCodexCase(tool, testCase) {
85
79
  );
86
80
  return "fail";
87
81
  }
82
+ assert.equal(testCase.outcomes.local, "pass", `${testCase.id}: local Codex Edit unexpectedly succeeded`);
83
+ assert.deepEqual(await readSnapshot(workspace), testCase.expected);
84
+ for (const [path, mode] of Object.entries(testCase.expectedModes ?? {})) {
85
+ assert.equal((await stat(join(workspace, path))).mode & 0o777, mode, `${testCase.id}: file permissions changed`);
86
+ }
87
+ return "pass";
88
88
  } finally {
89
89
  await rm(workspace, { recursive: true, force: true });
90
90
  }
@@ -101,9 +101,9 @@ export default async function () {
101
101
  assert.equal(results.length, comparisonCases.length);
102
102
  assert.equal(results.filter(({ native }) => native === "pass").length, 5);
103
103
  assert.equal(results.filter(({ native }) => native === "fail").length, 2);
104
- assert.equal(results.filter(({ native }) => native === "unsupported").length, 5);
105
- assert.equal(results.filter(({ codex }) => codex === "pass").length, 7);
106
- assert.equal(results.filter(({ codex }) => codex === "fail").length, 5);
104
+ assert.equal(results.filter(({ native }) => native === "unsupported").length, 8);
105
+ assert.equal(results.filter(({ codex }) => codex === "pass").length, 8);
106
+ assert.equal(results.filter(({ codex }) => codex === "fail").length, 7);
107
107
  for (const result of results) console.log(`${result.id}: native=${result.native}, codex=${result.codex}`);
108
108
  console.log(`Native edit vs local Codex Edit: ${results.length} comparison cases passed`);
109
109
  }
@@ -8,6 +8,10 @@ import { applyLegacyUpdate, parseLegacyPatch } from "./legacy-parser-v0.1.7.mjs"
8
8
  test("released v0.1.7 versus local parser over the growing comparison corpus", () => {
9
9
  const results = [];
10
10
  for (const testCase of comparisonCases) {
11
+ if (testCase.executionOnly) {
12
+ results.push({ id: testCase.id, released: "unsupported", local: "unsupported" });
13
+ continue; // Verified with real files by native-vs-codex.ts, not by a parser-only baseline.
14
+ }
11
15
  const released = runPatchCase(
12
16
  testCase,
13
17
  parseLegacyPatch,