Skip to content

fix(e7): readBoundedBody 读全响应体 —— 修 runForEachWhile 只读首个 chunk 的静默截断 - #648

Merged
jinjunnn merged 6 commits into
alphafrom
fix/654-readboundedbody-first-chunk
Jul 27, 2026
Merged

fix(e7): readBoundedBody 读全响应体 —— 修 runForEachWhile 只读首个 chunk 的静默截断#648
jinjunnn merged 6 commits into
alphafrom
fix/654-readboundedbody-first-chunk

Conversation

@jinjunnn

Copy link
Copy Markdown
Owner

Fixes #647

缺陷

readBoundedBody()Stream.runForEachWhile,它只消费第一个 chunk 就停,即使谓词恒返回
true。唯一的生产调用方 call()(mcp-websearch.ts:339)于是拿到截断的 JSON,登出态 web
search 真调报 invalid response … Unterminated string in JSON —— 而上游返回的是成功结果。

读法 chunk 数 字节
Stream.runForEachWhile(修复前) 1 4,090
Stream.runForEach 3 18,063
response.text 18,034

引入点 d341b208d(PR #639,落地 #489 的有界读取要求);该 commit 之前是
const body = yield* response.text,读全、无此缺陷。已发版的回归。

修法

tool/read.ts:146 同构 —— runForEach 读全 + tagged error 在触限那一刻中止上游流:

class BodyCapReached extends Schema.TaggedErrorClass<BodyCapReached>()("BodyCapReached", {}) {}

yield* Stream.runForEach(response.stream, (chunk) =>
  Effect.gen(function* () {
    const remaining = MAX_BODY_BYTES - size
    if (chunk.byteLength <= remaining) { chunks.push(chunk); size += chunk.byteLength; return }
    if (remaining > 0) { chunks.push(chunk.subarray(0, remaining)); size = MAX_BODY_BYTES }
    truncated = true
    return yield* new BodyCapReached()   // 触限即拉断上游
  }),
).pipe(Effect.catchTag("BodyCapReached", () => Effect.void))

不能天真换成裸 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)的教训只写进了注释:

we avoid Stream.runForEachWhile (it currently swallows the final unterminated line …)

六周后 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(test job 以前只跑
contracts-consumer / ext / ui-mac)—— alpha-websearch-failure.test.ts 的抬头早已把这条盲区
写下来。补一个 CI 步骤,只跑 alpha 自有的那份 web search 闸门测试(不跑 opencode 全量,那不属
alpha)。

测试 + 变异验证

新增 #647 一组三条,全部断言可观测结果(字节数 / 上游是否被 cancel / pull 次数),
无源码文本断言。

变异 A —— 撤掉「读全」修复(换回 runForEachWhile):

(fail) a multi-chunk body arrives whole — not just the first chunk
       Expected: 14097   Received: 4090
(fail) the truncated multi-chunk JSON no longer breaks a real search call
       Web search failed: invalid response … SchemaError(SyntaxError: JSON Parse error: Unterminated string)
(fail) reading to the end did NOT relax the cap — the upstream is torn down at the limit
       expect(cancelled).toBe(true)  Expected: true  Received: false
0 pass / 3 fail

变异 A 复现出的报错文本与线上真实故障逐字一致

变异 B —— 撤掉「触限即中止」修复(裸 runForEach,读完再截):

(fail) reading to the end did NOT relax the cap — the upstream is torn down at the limit
       expect(pulls).toBeLessThan(8)   Expected: < 8   Received: 8
       expect(cancelled).toBe(true)    Expected: true  Received: false
2 pass / 1 fail

两条「提前中止」信号各自独立转红(分别单独验过),不是一条带着另一条。
两处修复放回后:45 pass / 0 fail

变异 C —— 类级闸自身(把 runForEachWhile 重新写回 mcp-websearch.ts):

(fail) 全仓源码零处调用 Stream.runForEachWhile
       + ["packages/opencode/src/tool/mcp-websearch.ts"]
(fail) packages/opencode/src/tool/mcp-websearch.ts 里那处提及仍然是禁令,不是调用
       Expected: false  Received: true
4 pass / 2 fail

注意第二条:该文件在登记清单里,清单没有把它放过去 —— 登记不等于豁免。
放回后:6 pass / 0 fail

本地闸门(主 checkout,未等 GitHub Actions)

结论
north-star PASS —— 按 CI 原命令跑(含全部 :(exclude)):零上游改动。mcp-websearch.ts 属 ADR-035 已收编面;两个测试文件对 --diff-filter=DMRA
typecheck 零增量。base 与 HEAD 都是同样 2 条既有错误(alpha-mcp-websearch-gate.test.ts(342,31)alpha-websearch-failure.test.ts TS2345);后者行号 540 → 644,正是本 PR 插入的 104 行位移,错误文本逐字相同。ui-mac typecheck 干净
unit 零回归。ui-mac base 3077 pass / 0 fail → HEAD 3083 pass / 0 fail(+6 = 新增闸门)。opencode alpha 自有测试 45 + 7 + 15 全绿
docs gate 无 Markdown 改动,no-op

未重新打包应用(按约定留到审计通过之后)。

🤖 Generated with Claude Code

jinjunnn and others added 2 commits July 27, 2026 06:19
`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 非零退出仍单独判红。
@jinjunnn

Copy link
Copy Markdown
Owner Author

codex 第 1 轮 Major ①/② 已修(push ca69f24ab)

修复本身(readBoundedBody)未动 —— Q1/Q2/Q3/Q6 已过,不碰。

stream-read-hygiene.test.ts —— 枚举补全 + 漏扫可观测

手写 walker 换成 git ls-files --cached --others --exclude-standard(可证明完备,且覆盖尚未 git add 的新文件)。覆盖面自检从「扫到了一些文件」改成「漏扫本身可观测」:钉住根 package.json 的 workspace 通配符清单(新增 workspace 根即红)+ 断言每个嵌套 workspace 根都真有文件进入集合 + 断言 packages/stats/server/src/ingest.ts 这个具体绕过点在集合里。抬头改成守住什么写什么,并显式列出守不住什么。

绕过实测 —— 在 packages/stats/server/src/ingest.ts 注入 Stream.runForEachWhile(...),同一时刻同时跑新旧两版闸门:

############ 新闸门(必须红)############
- ["packages/opencode/src/tool/read.ts", "packages/opencode/src/tool/mcp-websearch.ts"]
+ ["packages/opencode/src/tool/read.ts", "packages/stats/server/src/ingest.ts", ...]
(fail) 提到这个名字的文件集合没有变 —— 新增即分类
 6 pass / 2 fail

############ 旧闸门(HEAD 上的已提交版本,证明绕过真的成立)############
 6 pass / 0 fail        ← 全绿。codex 的判断完全正确。

撤掉注入后:8 pass / 0 fail。旧版漏掉的是 packages/console/* + packages/stats/* + packages/sdk/js353 个生产文件

alpha-ci.yml —— 零用例不再默认放行

先证明前提:bun test 对被清空的文件打印 Ran 0 tests across 1 fileexit code = 0(实测)。原步骤在闸门被删光时会全绿

改为解析实际通过数 + 钉下界 40(当前 45);bun 非零退出仍单独判红。五个场景逐一实测(脚本是从 workflow 里逐字拷出来跑的):

场景 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 条绿。
@jinjunnn

Copy link
Copy Markdown
Owner Author

第三种假绿已在本 PR 修掉(push 0c17d3c48)

形态

把「断言实际执行的用例数」抽成 scripts/bun-test-floor.sh,三处共用同一个机制而不是三份拷贝(拷贝会各自漂移,那正是下一次漏掉的成因)。

关键:两层下界,各管各的。 你要的两件事之间有张力,我按实测拆开处理 ——

下界 抓什么
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`),
不再强制第三个过滤参数。
@jinjunnn

Copy link
Copy Markdown
Owner Author

更正:上一条我答「无」,是错的(push 08daf5af3)

自查第四种时,我先按类枚举了 test job 的全部步骤,发现还有两步是同一类:

- name: bun test (contracts consumer fixtures)   →  run: bun test     ← 只看退出码
- name: bun test (ext)                           →  run: bun test     ← 只看退出码

它们和我刚修的三步共享完全相同的零用例默认放行缺陷。

绕过实测(把 ext 全部 10 个测试文件清空)

--- 原步骤形态(裸 bun test,只看退出码)---
 0 pass
 0 fail
Ran 0 tests across 10 files. [6.00ms]
裸 bun test EXIT = 0        ← 原来的 CI 步骤:全绿

--- 新步骤形态(带下界 100)---
EXIT = 1
── bun exit=0 · 实际通过 0 条 · 下界 100 · (packages/ext: )
::error::只跑了 0 条断言,低于下界 100。…只看退出码的步骤会全绿。

还原后 132 条绿。两步已接到同一个 scripts/bun-test-floor.sh(下界 15 / 100,当前 19 / 132)。

顺带修的一个真缺陷

脚本原先要求 0 -ge 3,而这两步只传 floor + workdir(整包跑)——会直接 exit 2。已放宽为 -ge 2这个是我在实测过程中撞出来的,不是看出来的 —— 又一次说明「点名危险形状不够,得真去跑」。

现在 test job 五步全部带下界

EXIT=0 ·   19 条 · 下界   15 · packages/alpha-contracts-consumer
EXIT=0 ·  132 条 · 下界  100 · packages/ext
EXIT=0 · 3085 条 · 下界 3000 · packages/ui-mac src
EXIT=0 ·    8 条 · 下界    6 · packages/ui-mac 类级闸
EXIT=0 ·   45 条 · 下界   40 · packages/opencode web search 闸

关于范围

这一步是我自己判的:你上一轮说「只修被点名的、留着同类的其余实例」等于明知故犯交付假闸门,那条理由对这两步逐字成立,而修法就是把已经写好并已绕过实测过的机制多接两行。如果你判它超范围,revert 掉 08daf5af3 的 workflow 部分即可,脚本参数放宽那一处建议保留(它是真 bug)。

本地闸门(重跑)

north-star PASS;typecheck 零增量(ui-mac 干净、opencode 仍是同样 2 条既有错误);unit 零回归(五步如上);YAML 校验通过;无 Markdown 改动。

第四种之后还有没有第五种

我把 alpha-ci.yml 全部 job 过了一遍,其余步骤不属这一类:check:vendor / assert-seed-assets.sh 都是断言具体文件内容而非「跑了多少」;docs-gate[ -z "$MD" ] && exit 0 是设计意图(无 Markdown 就无链接可查);typecheck 各步对空包会通过,但空包本身会先让别的闸红。除已追加进 #649 的 docs-only 跳过问题外,没有第五种。

上一轮我在 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 全绿),免得下一个人再以为地板就够了。
@jinjunnn

Copy link
Copy Markdown
Owner Author

Q2 全量类扫完成(push eaefe0132)

你说得对 —— 我在 ui-mac 上实测出那条规律,却只应用了一次。这一轮做全量,并把「别再只应用一次」本身做成机制。

判据(写进了 scripts/gate-files.tsv 抬头)

闸门文件 = 删掉它就移除某条具体保证,而不只是减少覆盖率。
操作性 signature:它断言的是自己模块之外的东西 —— 仓库源码文本、vendored/生成的 fixture、上游文件、钉住的清单 —— 于是没有别的测试会因为它消失而变红。

三件配套

文件 作用
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 刻意不接管。
@jinjunnn

Copy link
Copy Markdown
Owner Author

委派规则完成(push d3f40aa74),登记簿 21 → 30

1. route-deep-link-consumer.test.ts 已登记

下界 7(实际 9 条)。删除实测:删除 exit=1 / 放回 exit=0 → PASS

2. 做法:显式列(权威)+ 发现绊线(不是权威)

选了做法 2 + 一个加强版的做法 1,理由是你自己指出的那条:自动解析抬头要按措辞枚举(「真实判据见」「行为层闸门在」「THE PRIMARY JUDGEMENT IS NOT IN THIS FILE」「移至」「由 X 断言」…),而按措辞枚举与按文件名枚举、与当初只扫一级目录的 walker 是同一种缺陷 —— 说法不合的就隐形。所以措辞扫描不能当权威

  • 权威:登记簿新增 delegates_to 列,默认拒 —— 每行必须显式填,没有委派写 -,留空即红;且每个受托方必须也在登记簿里。
  • 发现绊线(只用来找、不用来判):闸门文件正文里出现的任何其它测试文件,必须已被分类 —— 已登记 / NOT_GATES / REFERENCED_BUT_UNREGISTERED 三选一。不理解语义,只要求「提到了就表态」。

绕过实测 A —— A 委派给 B、B 未登记(把 route-deep-link-consumer 从登记簿删掉):

error: 下列闸门文件把主判据委派给了一个**没有登记**的测试文件。
+ ["…/route-authority-ratchet.test.ts → …/route-deep-link-consumer.test.ts"]
(fail) 被委派的受托方必须也在登记簿里
error: 下列测试文件在某个闸门文件的正文里被提到,却没有被分类。
+ ["…/route-authority-ratchet.test.ts → …/route-deep-link-consumer.test.ts", …]

两条断言各自独立转红。

绕过实测 B —— delegates_to 留空(默认拒):

error: 这些行没有填 delegates_to。没有委派请显式写 '-' —— 留空默认拒,不默认放行。
+ ["…/upstream-anchors.test.ts"]
(fail) 每行都显式填了 delegates_to    8 pass / 1 fail

同一处 shell runner 侧:assert-gate-files EXIT=1

还原后 TSV 与备份 diff 一致。

3. 核完 21 个抬头,另外找出 7 处未登记的委派(+ 二阶 2 处)

委派方 受托方 抬头原话
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 步:三个整包地板 + 一个登记簿逐点步。

@jinjunnn
jinjunnn merged commit e578e00 into alpha Jul 27, 2026
6 checks passed
@jinjunnn
jinjunnn deleted the fix/654-readboundedbody-first-chunk branch July 27, 2026 11:41
jinjunnn pushed a commit that referenced this pull request Jul 28, 2026
上一份装机产物(`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>
jinjunnn added a commit that referenced this pull request Jul 28, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[E7][CODE] readBoundedBody 只读首个 chunk 导致响应静默截断 —— keyless web search 真调失败

1 participant