fix(e7): readBoundedBody 读全响应体 —— 修 runForEachWhile 只读首个 chunk 的静默截断 - #648
Conversation
`Stream.runForEachWhile` 只消费第一个 chunk 就停,即使谓词恒返回 true。三种读法实测: runForEachWhile 1 chunk / 4,090 字节;runForEach 3 chunks / 18,063 字节;response.text 18,034 字符。唯一的生产调用方 `call()` 于是拿到截断的 JSON,登出态 web search 真调报 「invalid response … Unterminated string in JSON」——上游返回的其实是成功结果。 引入点 commit d341b20(PR #639,落地 #489 的有界读取要求);该 commit 之前是 `yield* response.text`,读全、无此缺陷。为了修 #223 R2 Major 6 的 DoS 上限,把「读全」 这条正确性弄丢了,并且发了版。 修法与 `tool/read.ts:146` 同构:`runForEach` 读全 + tagged error 在触限那一刻中止上游流。 不能天真换成裸 `runForEach` —— 那会读完整条流再截,把 R2 的 DoS 洞放回来。 `MAX_BODY_BYTES` 仍是与块大小无关的硬限,`truncated` 语义与后缀不变。 类级防复发(本仓第二次踩同一个 API,第一次的教训只写在 read.ts:143 的注释里, 对正在写另一个文件的人不可见 —— 注释不是闸门): 新增 `packages/ui-mac/src/main/stream-read-hygiene.test.ts`,全仓源码禁 `Stream.runForEachWhile`, 并自带覆盖面自检与自我变异,避免成为空闸门。 行为闸落在 packages/opencode/test,而该目录以前一条都没进过 alpha CI(那份文件抬头早已 把这条盲区写下来)。补一个 CI 步骤只跑 alpha 自有的那份 web search 闸门测试。 Fixes #647 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codex 第 1 轮 Major ①/②。修复本身(readBoundedBody)未动。 ① stream-read-hygiene.test.ts —— 空闸门 + 覆盖面自检假绿 手写 walker 只进 `packages/<一级目录>/src`,却表述为「全仓生产源码」。实际漏掉 packages/console/*、packages/stats/*、packages/sdk/js 三个嵌套 workspace 共 353 个生产 文件;在 packages/stats/server/src/ingest.ts 写违规代码可完全绕过,而当时的自检 (扫到 >200 文件 + 两个事故锚命中)仍然全绿 —— 它只证明「扫到了一些文件」,不证明「没漏」。 改为 `git ls-files --cached --others --exclude-standard`(可证明完备,且覆盖尚未 add 的 新文件)。覆盖面自检改成「漏扫可观测」:钉住根 package.json 的 workspace 通配符清单 (新增 workspace 根即红)+ 断言每个嵌套 workspace 根都真有文件进入集合 + 断言 packages/stats/server/src/ingest.ts 这个具体绕过点在集合里。抬头措辞改成守住什么写什么, 并显式列出守不住什么。 ② alpha-ci.yml —— 零用例默认放行 `bun test` 对「文件被清空 / 用例条件注册成零条」打印 `Ran 0 tests` 并**退出 0**(已实测)。 新步骤改为解析实际通过数并钉下界 40(当前 45),低于即红;bun 非零退出仍单独判红。
codex 第 1 轮 Major ①/② 已修(push
|
| 场景 | bun exit | parsed pass | 步骤 exit |
|---|---|---|---|
| A 文件被清空(0 用例) | 0 | 0 | 1 ✅ |
| B 用例条件注册成零条 | 0 | 0 | 1 ✅ |
| C 闸门被砍到 5 条 | 0 | 5 | 1 ✅ |
| D 45 条但有真失败 | 1 | 44 | 1 ✅ |
| E 真实闸门文件 | 0 | 45 | 0 ✅ |
本地闸门(重跑)
| 闸 | 结论 |
|---|---|
| north-star | PASS(CI 原命令,含全部 :(exclude)) |
| typecheck | 零增量 —— ui-mac 干净;opencode 仍是同样 2 条既有错误 |
| unit | 零回归 —— ui-mac 3085 pass / 0 fail(基线 3077,+8 = 本闸门);opencode 闸门 45 pass / 0 fail |
| docs gate | 无 Markdown 改动,no-op |
YAML 已用 yaml.safe_load 验证可解析,test job 步骤序列正确。
建票
#649 —— alpha 自有的其余 opencode 测试不在任何 CI 里跑(type:chore/P2),含 23 秒耗时与 Bun.serve 超时告警的评估要求。
第三种同类假绿(报告,未修,按范围要求不扩)
bun test src 对「测试文件被删除」同样默认放行。 删掉 stream-read-hygiene.test.ts,ui-mac суite 跑 3077 条全绿、CI 全绿,类级闸门就此消失 —— 与 ② 同一类(用例数减少不可观测),但②我修的是「文件还在但零用例」,这一种是「文件没了」。证据是现成的:本 PR 的基线测量(不含该文件)就是 3077 pass / 0 fail。
与 ② 的区别在于它不自愈。最小修法是给 ui-mac 那一步套用与本 PR ② 相同的下界断言形态。是否要做请裁决 —— 它影响的是所有 3000+ 条 ui-mac 断言,不止本票。
第三种同类假绿:`bun test src` 对「测试文件被删除」默认放行。删掉 stream-read-hygiene.test.ts
后 ui-mac 跑 3077 条全绿、CI 全绿,类级闸就此消失且不自愈。刚用一整轮确立「一个能被静默删除
的闸门不是闸门」,不能同时交付一个能被静默删除的闸门。
把「断言实际执行的用例数」抽成 scripts/bun-test-floor.sh,三处共用同一个机制而不是三份拷贝
(拷贝会各自漂移,那是下一次漏掉的成因)。
两层下界,各管各的 —— 单靠整套 suite 的地板抓不到「少了一个文件」:
· ui-mac 整套 suite 地板 3000(当前 3085):抓灾难性丢失(加载失败/成片删除/runner 配错)。
刻意留大余量 —— 会随正常增删漂移的阈值早晚被调松或删掉,那又是一个自毁的闸门。
实测:删掉类级闸后本步 3077 条**仍然绿**,所以它抓不到单文件删除,不能靠它。
· 类级闸单独点名,下界 6(当前 8,留出登记项增减的余量):这一层才抓单文件删除。
实测:文件删除 → bun exit 1 → 红;文件清空 → bun exit 0 但 0 条 → 下界判红;放回 → 8 条绿。
第三种假绿已在本 PR 修掉(push
|
| 层 | 下界 | 抓什么 |
|---|---|---|
| ui-mac 整套 suite | 3000(当前 3085) | 灾难性丢失:suite 加载失败、成片删除、runner 配错 |
| 类级闸单独点名 | 6(当前 8) | 单个闸门文件被删除 |
地板 3000 抓不到「删掉一个 8 条用例的文件」(3085→3077 远高于 3000)。所以单靠地板无法闭合这条缝——必须点名。地板值刻意留大余量,正是按你说的「别钉死在当前数字上」;点名步骤的下界 6 也留了 2 条余量(登记项增减不会误红)。
绕过实测
删掉 stream-read-hygiene.test.ts 后,同一时刻跑两步:
===== 步骤 1:ui-mac 整套 suite,地板 3000 =====
EXIT = 0 ← 全绿!
── bun exit=0 · 实际通过 3077 条 · 下界 3000
✓ 3077 条断言真的执行了
===== 步骤 2:点名类级闸自身,下界 6 =====
EXIT = 1 ← 红
── bun exit=0 · 实际通过 0 条 · 下界 6
::error::… 测试失败
这组对照就是「为什么两层都要有」的证据:只上地板等于没修。
另一分支(文件还在但被清空,bun 会 exit 0):
EXIT = 1
── bun exit=0 · 实际通过 0 条 · 下界 6
::error::只跑了 0 条断言,低于下界 6。用例被删除/清空/条件注册成零条时,bun 会打印 Ran 0 tests 并退出 0,只看退出码的步骤会全绿。
放回后:EXIT = 0 · 实际通过 8 条 · 下界 6 ✓,文件与备份 diff 一致。
三步在本地按 CI 的方式全跑一遍
ui-mac suite EXIT=0 · 3085 条 · 下界 3000 ✓
class gate EXIT=0 · 8 条 · 下界 6 ✓
websearch gate EXIT=0 · 45 条 · 下界 40 ✓
#649 已追加
docs-only PR 跳过全部测试 job 那段已追加进 #649 正文:现象、为什么是延迟而非永久(违规永久留在 git ls-files 枚举里,下一个 code PR 必红)、以及真实成本——违规会在一个与它无关的 PR 上炸红,作者没碰过那个文件却看到自己的 PR 红,这训练人去怀疑闸门而不是怀疑代码,继而学会加 exclude / --admin 合掉。并给了三个候选处置供评估。
本地闸门(重跑)
| 闸 | 结论 |
|---|---|
| north-star | PASS(CI 原命令,含全部 :(exclude)) |
| typecheck | 零增量 —— ui-mac 干净;opencode 仍是同样 2 条既有错误 |
| unit | 零回归 —— ui-mac 3085 / 0 fail;类闸 8 / 0;websearch 闸 45 / 0 |
| YAML | yaml.safe_load 通过,test job 六步序列正确 |
第四种?
无。 说明搜索了什么见下一条回复。
自查第四种时发现:我上一条回复答了「无」,是错的。`test` job 里还有两步只看退出码 —— `bun test (contracts consumer fixtures)` 与 `bun test (ext)` —— 与刚修的三步是**同一类**。 实测(把 ext 全部 10 个测试文件清空): 裸 `bun test` → `Ran 0 tests across 10 files`,**exit 0**,原步骤全绿 带下界的新步骤 → exit 1 两步一并接到 scripts/bun-test-floor.sh(下界 15 / 100,当前 19 / 132)。 只修被点名的实例、留着同类的其余实例,正是本 PR 在讲的那个故事。机制已经有了,整类一起收。 同时放宽脚本参数检查:允许只给 floor + workdir(整包跑,等价于原来的裸 `bun test`), 不再强制第三个过滤参数。
更正:上一条我答「无」,是错的(push
|
上一轮我在 ui-mac 上实测出「整包地板抓不到删掉一个 N 条用例的文件」,却只把这条规律应用了 一次:ext 只加了地板 100,而 ext 排除 REQ-062 drift lock 后还剩 106 条 —— 删掉那个闸门文件 CI 静默全绿。同一个类,修了一个实例。这一轮做全量。 闸门文件的判据(写进 scripts/gate-files.tsv 抬头):删掉它就移除某条**具体保证**,而不只是 减少覆盖率;操作性signature = 它断言的是自己模块之外的东西(仓库源码文本、vendored/生成的 fixture、上游文件、钉住的清单),于是没有别的测试会因为它消失而变红。 跨五个 CI 步骤扫全,登记 21 个:契约哈希锁、REQ-062 prompt drift lock、云 websearch kill-switch、REQ-100 skill 注入门、seed 快照守卫、REQ-012 上游 DOM 锚点、ADR-027 seam 存活、 对比度/接管件/cards/permission 挂载/settings surface/reduced-motion/route authority 六个棘轮、 alpha-web 契约 fixture、契约横幅接线、#647 类级禁令与 web search 行为闸。 逐个删除实测:21/21 删除即红、放回即绿。 三件配套: · scripts/gate-files.tsv —— 登记簿,每行带下界与「这道闸保证什么」。 · scripts/assert-gate-files.sh —— 逐个跑 + 逐个判下界;登记簿被清空(<15 条)也判红。 · packages/ui-mac/src/main/gate-file-registry.test.ts —— **完备性闸**:凡文件名命中仓库 闸门命名习惯(ratchet/lock/anchors/contract/copies/snapshot/drift/hygiene/rebrand/seam/ coexistence/census)的测试文件,必须要么登记、要么在 NOT_GATES 里写明为什么不是。 这一条才是「别再只应用一次」的机制本身;它自己也在登记簿里。实测新增未登记文件即红。 分层判据写进了 tsv 抬头、脚本抬头与 workflow 注释三处,并各自附上实测数字 (ui-mac 3077≥3000 全绿、ext 106≥100 全绿),免得下一个人再以为地板就够了。
Q2 全量类扫完成(push
|
| 文件 | 作用 |
|---|---|
scripts/gate-files.tsv |
登记簿:21 行,每行 = 下界 + 工作目录 + 路径 + 这道闸保证什么 |
scripts/assert-gate-files.sh |
逐个跑 + 逐个判下界;登记簿被清空(<15 条)也判红 |
packages/ui-mac/src/main/gate-file-registry.test.ts |
完备性闸 —— 这条才是防「只应用一次」的机制本身 |
完备性闸:凡文件名命中本仓闸门命名习惯(ratchet/lock/anchors/contract/copies/snapshot/drift/hygiene/rebrand/seam/coexistence/census)的测试文件,必须要么登记、要么在 NOT_GATES 里写明为什么不是。两边都没有 = 红。它自己也在登记簿里。
绕过实测:新增一个 probe-new-ratchet.test.ts(未登记)→
(fail) 命名命中闸门词的测试文件,要么已登记、要么已写明不是闸门
+ ["packages/ui-mac/src/main/probe-new-ratchet.test.ts"]
4 pass / 1 fail
这正是当初漏掉 ext drift lock 时会亮的那盏灯。
21 个闸门文件 · 逐个删除实测(全 PASS)
闸门文件 删除后 放回后 判定
------------------------------------------------------------------------------------------
packages/alpha-contracts-consumer/src/contracts.test.ts exit=1 exit=0 PASS
packages/ext/src/prompt-rebrand.test.ts exit=1 exit=0 PASS
packages/ext/src/cloud-websearch-kill.test.ts exit=1 exit=0 PASS
packages/ext/src/gen-skill-paths.test.ts exit=1 exit=0 PASS
packages/ext/src/cloud-contract-hook.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/stream-read-hygiene.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/websearch-copies.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/extension-seed-snapshot.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/alpha-web-contract-fixtures.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/alpha-contract-health.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/main/gate-file-registry.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/upstream-anchors.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/surface-seam-contract.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/contrast-ratchet.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/takeover-adapter-coexistence.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/session-timeline/cards/cards-contract.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/permission-mount-ratchet.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/settings-surface-ratchet.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/renderer/alpha-ui/reduced-motion-ratchet.test.ts exit=1 exit=0 PASS
packages/ui-mac/src/shared/route-authority-ratchet.test.ts exit=1 exit=0 PASS
packages/opencode/test/tool/alpha-websearch-failure.test.ts exit=1 exit=0 PASS
------------------------------------------------------------------------------------------
不合格条数: 0
判为「不是闸门」的三个(写进 NOT_GATES,附理由)
| 文件 | 理由 |
|---|---|
ext-bundle-lock.test.ts |
bundle 锁的功能单测(互斥/争用/死 pid/心跳超时)。删掉是这个功能少测了 |
alpha-auth-clock.test.ts |
只是 clock 撞上了 lock;内容是刷新时机纯逻辑单测 |
model-contract.test.ts |
createModelContract 函数的输入→输出单测,不是钉外部产物的契约锁 |
分层判据已写进三处
gate-files.tsv 抬头、assert-gate-files.sh 抬头、workflow 注释,各自附上实测数字——ui-mac 3077≥3000 全绿、ext 106≥100 全绿——说明为什么地板不够、为什么必须两层。免得下一个人再以为地板就够了(这正是这一轮发生的事)。
自查发现的一个真缺陷
我给 gate-file-registry.test.ts 登记的下界写成 6,而它只有 5 条 —— assert-gate-files.sh 第一次跑就把它判红了。机制在自己身上先生效了一次,已改为 4。
本地闸门
north-star PASS;typecheck 零增量(ui-mac 干净、opencode 仍是同样 2 条既有错误);unit 零回归(contracts 19、ext 132、ui-mac 3090、闸门登记簿 21/21);YAML 校验通过;无 Markdown 改动。
心里没底的一个,请你判
route-authority-ratchet.test.ts 自己的抬头写着「主判据不在本文件 —— route-deep-link-consumer.test.ts 执行的才是真路径」。我按「源码棘轮」把它登记了(下界 35),但没有登记 route-deep-link-consumer.test.ts——它不命中命名词,而我判它是行为测试不是闸门。如果那份才是真保证的承载者,它现在只被 ui-mac 的地板 3000 罩着,和 ext drift lock 当初的处境一样。请裁决要不要一并登记。
owner 裁决 route-deep-link-consumer.test.ts 必须登记,理由是我自己的判据:
route-authority-ratchet 抬头写着「THE PRIMARY JUDGEMENT IS NOT IN THIS FILE」,那么删掉受托方
保证就没了,棘轮只守住「源码里有那段文字」—— 按本仓定义就是假闸门。而我判它不登记的理由是
「不命中命名词」,那正是问题所在:按名字枚举与当初只扫一级目录的 walker 是同一种缺陷。
做法(选显式列 + 发现绊线,不选自动解析抬头):
· 权威 = 登记簿新增 `delegates_to` 列,**默认拒**:每行必须显式填,没有委派写 `-`,留空即红;
且每个受托方必须也在登记簿里。自然语言的委派说法太多(「真实判据见」「行为层闸门在」
「移至」「由 X 断言」…),按措辞枚举与按文件名枚举是同一种缺陷,不能当权威。
· 发现绊线 = 闸门文件正文里出现的**任何**其它测试文件都必须已被分类(已登记 / NOT_GATES /
REFERENCED_BUT_UNREGISTERED 三选一)。不理解语义,只要求「提到了就表态」。
用绊线核完 21 个抬头,**另外找出 7 处**未登记的委派关系(一层二层都没发现):
websearch-copies → core/alpha-websearch-sovereignty(运行时半场,此前不在任何 CI)
→ sidecar-env、alpha-config-injection(「真实判据见」)
gen-skill-paths → ext-receipt-v2(「decoder 强度用例移至」)
takeover-coexistence → permission-single-surface(「行为层闸门在」)
permission-mount → permission-single-surface
route-authority → route-deep-link-consumer、route-upstream-shape
二阶闭包再找出 2 处:alpha-config-injection → sidecar-ready-message(纯文本锚曾被变异绕过)、
alpha-models。逐层登记到不动点。
登记簿 21 → 30 条(含首次纳入 CI 的 packages/core 那份主权闸运行时证据)。
删除实测 30/30 全 PASS。委派规则绕过实测:受托方未登记 → 两条断言各自转红;
delegates_to 留空 → 测试与 shell runner 两侧都转红。
未登记但已表态的 4 处写进 REFERENCED_BUT_UNREGISTERED,各带理由与跟踪票:
三个 opencode 云 MCP/网关测试归 #649 评估(owner 已指示不在本 PR 纳入),
两个上游 websearch 测试由 north-star 守卫保护、alpha 刻意不接管。
委派规则完成(push
|
| 委派方 | 受托方 | 抬头原话 |
|---|---|---|
websearch-copies |
core/alpha-websearch-sovereignty |
「运行时证据在两个引擎包的 alpha 自有测试里」 |
websearch-copies |
sidecar-env / alpha-config-injection |
「真实判据见 … 两条」 |
gen-skill-paths |
ext-receipt-v2 |
「decoder 强度用例移至 …」 |
takeover-coexistence |
permission-single-surface |
「行为层闸门在 …」 |
permission-mount-ratchet |
permission-single-surface |
「与运行时行为闸 … 互补」 |
route-authority-ratchet |
route-deep-link-consumer / route-upstream-shape |
「THE PRIMARY JUDGEMENT IS NOT IN THIS FILE」 |
二阶闭包又找出 2 处(登记新文件后绊线再次转红):alpha-config-injection → sidecar-ready-message(抬头写着纯文本锚曾被 injection.error && undefined 变异实证绕过,值上车的判据已移走)与 → alpha-models。逐层登记到不动点。
其中一个是真窟窿:packages/core/test/alpha-websearch-sovereignty.test.ts —— websearch 主权闸的运行时半场,此前不在任何 CI 步骤里跑(整包地板都不覆盖 packages/core)。现已登记(11 条 / 下界 9 / 401ms)。
表态为「不登记」的 4 处,各带理由与跟踪票:三个 opencode 云 MCP/网关测试归 #649 评估(你已指示不在本 PR 纳入),两个上游 websearch 测试由 north-star 守卫保护、alpha 刻意不接管。
4. 登记簿 30 条 · 删除实测 30/30 PASS
不合格条数: 0
(完整 30 行表格与上一条评论同格式,每行 删除 exit=1 / 放回 exit=0。)
本地闸门
| 闸 | 结论 |
|---|---|
| north-star | PASS(CI 原命令,含全部 :(exclude)) |
| typecheck | 零增量 —— ui-mac 干净;opencode 仍是同样 2 条既有错误 |
| unit | 零回归 —— contracts 19 / ext 132 / ui-mac 3094 / 闸门登记簿 30-30 |
| docs gate | 无 Markdown 改动,no-op |
YAML 校验通过。test job 现为 6 步:三个整包地板 + 一个登记簿逐点步。
上一份装机产物(`94a76b669` / `8706d0c4…`)早于 #648 的 `readBoundedBody` 修复, K1.5(登出态 keyless 真调)在其上必红 —— #643 的 AC2 不可能转绿。在当前 alpha HEAD `e578e00ae` 上重跑 `ship:mac` 并重新装机,证据目录的指纹与 `probe.ts` 的 `PINNED_ASAR_SHA256` / `PINNED_COMMIT` 同步换成新产物 (`60589c59c58e44ac0daede93fc7397a8a04365f5345eac4312e205a0d8f48e44`)。 真机启动已验:main / renderer / GPU / NetworkService / NodeService 五进程齐, CDP 列出 `oc://renderer/index.html` page target(窗口真开), `server ready { url: 'http://127.0.0.1:50360' }`,crash.log 无崩溃。 本仓两个打包坑(source-only contracts-consumer 留原始 .ts、eager ajv `new Function` 撞 renderer CSP)均未复现。 阻断项解除,用两条独立判据确认「打包版里真的生效」而非「源码合了就假定」: 1. 解包 app.asar,`out/main/chunks/node-*.js` 的 `McpWebSearch.readBoundedBody` 确为 `Stream.runForEach` + `catchTag("BodyCapReached")`; 2. 真机跑 `--keyless`:K1.5 `completed`,拿到完整多 chunk 的真实搜索结果,exit 0、 10/10 必需项通过。 两条反向 fail-closed 自检也在新产物上复验:登出态跑登录态相位 → blocked + exit 2; 登录态跑 `--keyless` → blocked + exit 2。两者都只记前置、零证据产出。 未改动源码,未改动 `docs/verification/` 下任何其它目录;旧产物的原始记录 (`*20260727T0952*.json`)保留不动。登录态那一半仍待 owner 本人执行。 Refs #643 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(verification): #643 E7 打包版取证准备 —— 探针 + 证据骨架 + 一个阻断项 #223 AC5「packaged 真调用与 keyless fallback 均有证据」的取证需要 owner 本人的登录态, 只有取证之前的一切可以先做完。本 PR 交付那一切: - 打包应用重打并真机启动验(alpha@94a76b669;装机版 app.asar 的 sha256 钉进探针, 在错的构建上跑出来的绿会被第一条判据挡住)。 - 一个两相位探针:默认 = 登录态,`--keyless` = 登出态,**各自对错误的登录态 fail-closed**。 故意不做自动识别 —— 会自己切相位的探针永远报不出「未登录,无法取证」。 每项自带运行前定好的判据,输出机器可读 JSON;造不出来的失败态记 `not-producible` + 理由, 不记成绿。探针零密钥:用的每个凭证都是打包应用自己写进 alpha-secrets 的,写盘前经 redact。 - 证据骨架:逐项判据表(登录态那半留空待 owner)、复现步骤、边界。 准备阶段已实跑出两件事: 1. **未登录自检**:登录态相位 blocked + exit 2,零证据产出(results/logged-in-*.json)。 2. **一个阻断项**:登出态 keyless 真调在本构建上是坏的。`readBoundedBody` 用的 `Stream.runForEachWhile` 在谓词恒真时仍只读第一个 chunk(实测 4,090 / 18,063 字节), 于是任何多 chunk 的搜索响应被截断 → `invalid_response`。#639 之前该处是 `response.text`, 属那一刀带进来的回归;本仓 read.ts:143-145 早已记过同一个 API 的雷。 修复归一张 CODE 票,不在本 VERIFY 票里动源码。 另记两项待裁决:402 的采集范围(Issue 正文与基线票 6 的 2026-07-25 更正相反), 以及云工具在引擎里的真实 id 可能是 `cloud_cloud_web_search`(MCP catalog 会拼 server 名前缀)。 Refs #643 * docs(verification): #643 重打包到 e578e00 —— keyless 阻断项解除,登出态相位 10/10 绿 上一份装机产物(`94a76b669` / `8706d0c4…`)早于 #648 的 `readBoundedBody` 修复, K1.5(登出态 keyless 真调)在其上必红 —— #643 的 AC2 不可能转绿。在当前 alpha HEAD `e578e00ae` 上重跑 `ship:mac` 并重新装机,证据目录的指纹与 `probe.ts` 的 `PINNED_ASAR_SHA256` / `PINNED_COMMIT` 同步换成新产物 (`60589c59c58e44ac0daede93fc7397a8a04365f5345eac4312e205a0d8f48e44`)。 真机启动已验:main / renderer / GPU / NetworkService / NodeService 五进程齐, CDP 列出 `oc://renderer/index.html` page target(窗口真开), `server ready { url: 'http://127.0.0.1:50360' }`,crash.log 无崩溃。 本仓两个打包坑(source-only contracts-consumer 留原始 .ts、eager ajv `new Function` 撞 renderer CSP)均未复现。 阻断项解除,用两条独立判据确认「打包版里真的生效」而非「源码合了就假定」: 1. 解包 app.asar,`out/main/chunks/node-*.js` 的 `McpWebSearch.readBoundedBody` 确为 `Stream.runForEach` + `catchTag("BodyCapReached")`; 2. 真机跑 `--keyless`:K1.5 `completed`,拿到完整多 chunk 的真实搜索结果,exit 0、 10/10 必需项通过。 两条反向 fail-closed 自检也在新产物上复验:登出态跑登录态相位 → blocked + exit 2; 登录态跑 `--keyless` → blocked + exit 2。两者都只记前置、零证据产出。 未改动源码,未改动 `docs/verification/` 下任何其它目录;旧产物的原始记录 (`*20260727T0952*.json`)保留不动。登录态那一半仍待 owner 本人执行。 Refs #643 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(verification): #643 402 按裁决改为「采」+ MISMATCH 判读方法 owner 2026-07-27 在 #643 上裁定 402「采」(多采一项没有害处,漏采要再叫 owner 一次), 但上一轮探针仍按 Issue 正文走 out-of-scope —— 裁决没落进代码。本次补上。 **P3.7 从「跳过」改成真打的判据**,且不预设结论:发一次带真 bearer 的 `POST /v1/tools/web_search`,按运行前定好的规则判读 —— 402 ⇒ pass;200 ⇒ `not-producible`(账户被预授权通过,这本身就是「今天产生不了」的 可观测证据,带实测 balanceFen/walletUsedFen/plan 作理由);其它状态 ⇒ fail(LOUD)。 不记成绿,也不静默跳过 —— 与 P3.4/P3.5/P3.6 同一套处置。 可产生性已按平台源码判定(alpha-platform `packages/gateway/src/worker.ts`): - 臂 A(per-job 预算耗尽)**桌面端不可达** —— 需 `auth.via === "job"` 的 job token (`lib/tenant-auth.ts:114-123`);桌面端拿的是 route-purpose 绑定 JWT,jobId 恒空 ⇒ `perJobPrecall` 直接 pass/enforced:false。与 P3.4 同源。 - 臂 B(`accountPreauth` 拒绝)只在账户余额+会员额度双空时触发;估价是路由常量, 客户端无调价杠杆。把账户打空是破坏性计费变更,且会连带打红同轮的 P2.1/P2.2/P2.3 —— 402 证据与 AC1/计费证据不可能在同一账户形态下同时取到。**owner 无需为 402 做额外操作。** 映射本身由 L1 覆盖:`packages/opencode/test/tool/alpha-websearch-failure.test.ts`。 `#643` 正文冲突已消:out-of-scope 与退出条件两处按裁决改成与基线票 6 的 2026-07-25 更正一致(须含 402,产生不了则记 not-producible)。 README §7 补上 **`MISMATCH` 的判读方法** —— 此前只记录不解读,等于把判断留给一个没有 上下文的人。现在写清:出现 MISMATCH 意味着所有按 `cloud_web_search` 字面量下的闸 **当前是空闸门**(盯着一个引擎从不注册的工具名),并逐个列出要重核的闸(permission deny / ext `tool.execute.before` / catalog 拼名)、下错了各会怎样、以及判据必须是「用真实 id 跑一遍确认真的命中」而不是 grep 到字面量。 证据同步重跑,使committed 证据由 committed 探针产出: 登出态相位在新产物上独立两遍均 10/10(K1.5 不是侥幸);两条反向 fail-closed 自检复验通过。 本地闸门:north-star UPSTREAM 命中 0;docs gate 11 个相对链接全解析。 未改源码,未改 `docs/verification/` 其它目录,未动应用登录态(保持登出)。 Refs #643 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(verification): #643 owner 登录态取证结果(打包版 e578e00) owner 亲自登录后跑完登录态相位。23 项判据:AC2(登出态 keyless)已在前一轮通过; 本轮登录态暴露六条必需红,其中两条是真缺陷、两条是探针判据缺陷: 真缺陷 - 云 MCP tools/call 恒 forbidden:cloud_web_search 要 purpose=model.invoke, 而桌面云通道令牌 ALPHA_CLOUD_TOKEN 恒为 cloud.dispatch(alpha-platform#131) - /mcp 传输层零鉴权,tools/list 与账户无关 ⇒ P1.3 那条 PASS 是空绿(alpha-platform#132) 探针判据缺陷(alpha-code#651) - P1.5 断的是「每个 agent 配置键有 deny」这个机制,而有效判决本来就全是 deny - P1.2 断言观测点错:引擎加载时已解析 {file:},/config 天然看不到 file ref - P3.8 判据构造上不可满足(工具已被 Permission.disabled 过滤,模型拿不到) 另:引擎侧真实工具 id 实测为 cloud_cloud_web_search(双前缀),按字面量 cloud_web_search 下的 permission 闸因此是空闸门(alpha-code#650)。 #643 不能关:AC1 待 #131 修复并手动部署后重取,AC3 待 #651 收敛判据。 --------- Co-authored-by: jinjunnn <slmbaovanetti99@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #647
缺陷
readBoundedBody()用Stream.runForEachWhile,它只消费第一个 chunk 就停,即使谓词恒返回true。唯一的生产调用方call()(mcp-websearch.ts:339)于是拿到截断的 JSON,登出态 websearch 真调报
invalid response … Unterminated string in JSON—— 而上游返回的是成功结果。Stream.runForEachWhile(修复前)Stream.runForEachresponse.text引入点
d341b208d(PR #639,落地 #489 的有界读取要求);该 commit 之前是const body = yield* response.text,读全、无此缺陷。已发版的回归。修法
与
tool/read.ts:146同构 ——runForEach读全 + tagged error 在触限那一刻中止上游流:不能天真换成裸
runForEach—— 那会读完整条流再截,把 #223 R2 Major 6 的 DoS 洞放回来(R2 复现:单个 3 MiB chunk 实收 3,145,728 字节,而声明上限 2,097,152)。
MAX_BODY_BYTES仍是与块大小无关的硬限;truncated语义与[truncated at N bytes]后缀不变。类扫
Stream.runForEachWhile生产用法:全仓仅mcp-websearch.ts:272一处。read.ts:144是禁令注释,不是调用。
readBoundedBody调用方:生产仅mcp-websearch.ts:339;另有测试引用一处。类级防复发:注释不是闸门
第一次踩(
read.ts)的教训只写进了注释:六周后
mcp-websearch.ts照 API 直觉又写了一遍。一条写在 A 文件里的教训,对正在写 B 文件的人不可见。
新增
packages/ui-mac/src/main/stream-read-hygiene.test.ts—— 全仓生产源码禁Stream.runForEachWhile,并强制「新增提及即分类」。闸门自身带两条防空闸自检:扫描器覆盖面(必须扫到 >200 个文件且包含两次事故现场文件)+ 检测器自我变异。
登记清单不构成豁免:登记的文件仍被单独断言「是禁令、不是调用」。
诚实边界写在文件抬头:这是源码文本闸,
Stream["runForEachWhile"]/ 取别名绕得过。它挡的是现实里唯一见过的形态(有人照 API 直觉直接写了它),是减速带不是安全边界。
缺陷本身的行为闸在 opencode 测试里,断言的是可观测结果。
顺带修的空闸门
行为闸住在
packages/opencode/test,而该目录一条都没进过 alpha CI(testjob 以前只跑contracts-consumer / ext / ui-mac)——
alpha-websearch-failure.test.ts的抬头早已把这条盲区写下来。补一个 CI 步骤,只跑 alpha 自有的那份 web search 闸门测试(不跑 opencode 全量,那不属
alpha)。
测试 + 变异验证
新增
#647一组三条,全部断言可观测结果(字节数 / 上游是否被cancel/pull次数),无源码文本断言。
变异 A —— 撤掉「读全」修复(换回
runForEachWhile):变异 A 复现出的报错文本与线上真实故障逐字一致。
变异 B —— 撤掉「触限即中止」修复(裸
runForEach,读完再截):两条「提前中止」信号各自独立转红(分别单独验过),不是一条带着另一条。
两处修复放回后:45 pass / 0 fail。
变异 C —— 类级闸自身(把
runForEachWhile重新写回mcp-websearch.ts):注意第二条:该文件在登记清单里,清单没有把它放过去 —— 登记不等于豁免。
放回后:6 pass / 0 fail。
本地闸门(主 checkout,未等 GitHub Actions)
:(exclude)):零上游改动。mcp-websearch.ts属 ADR-035 已收编面;两个测试文件对--diff-filter=DMR是Aalpha-mcp-websearch-gate.test.ts(342,31)、alpha-websearch-failure.test.tsTS2345);后者行号 540 → 644,正是本 PR 插入的 104 行位移,错误文本逐字相同。ui-mac typecheck 干净未重新打包应用(按约定留到审计通过之后)。
🤖 Generated with Claude Code