fix(fs): guard langFromPath against Object.prototype extension keys

A filename whose extension is an Object.prototype key (foo.constructor,
foo.__proto__) resolved to the inherited member through the plain-object index,
so a function reached the read card's lang hint and failed the tool-output JSON
validation, failing an otherwise successful read. Look the extension up as an
own property only. Added rejection tests, converted the zh Note headings to the
all-English sibling convention, and named the parallel-file-reads terminal
golden as the TUI-unchanged evidence in the Testing section (both languages).
This commit is contained in:
Chinesezjc
2026-07-30 20:32:51 +08:00
parent 22ff54dda4
commit 0ae52fbb9f
5 files changed
+21 -7

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md
2026-07-30-web-read-card.md: 076959680737d3bc439deb367dcdcdc5da83dfb3
2026-07-30-web-read-card.zh.md: c3e3ec1c3ccd8b36832bc12b6a7ffd000745fb74
2026-07-30-web-read-card.md: 7d517beb359eec17948ea312b0478604cf92a49b
2026-07-30-web-read-card.zh.md: bfcc17e782a6a1caf0f775875264839af357be0d
@@ -40,7 +40,7 @@ The read tool now computes `presentationMeta` for every top-level read, a small
## Testing
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged.
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
## Related
@@ -4,13 +4,13 @@ Status: implemented
[English](2026-07-30-web-read-card.md) | 中文
## 问题
## Problem
`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView``kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `<path>…</path><type>file</type><content>…</content>` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。
结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 `ContentBlock[]`(已渲染文本)加上一个不透明的 `meta`;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 `N: text` 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。
## 决策
## Decision
给[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` 标签 `read`——仅在结果侧。`ToolResultView` 增加 `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }``ReadFileLine { number; text }` 是共享的行单元。`ToolCallView` 不动:待定状态仍是 `GenericCallView``kind: 'read'`),因为一次调用在 `execute` 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。
@@ -40,7 +40,7 @@ read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已
## Testing
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的行 `number``0``1.5``NaN``Infinity`)、不是非负整数的 `totalLines``-1``1.5``NaN`)、以及行号重复、递减或超过 `totalLines` 的情况)。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content``card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures`fs-read``fs-read-window``fs-edit``fs-policy-reject``fs-write-overwrite``parallel-tool-calls``workspace-context``workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card`transcript.ts``card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的行 `number``0``1.5``NaN``Infinity`)、不是非负整数的 `totalLines``-1``1.5``NaN`)、以及行号重复、递减或超过 `totalLines` 的情况)。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content``card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures`fs-read``fs-read-window``fs-edit``fs-policy-reject``fs-write-overwrite``parallel-tool-calls``workspace-context``workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card`transcript.ts``card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。`apps/cli``parallel-file-reads` 终端 golden`examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt`)正钉住这一点:一次真实回放执行 read 工具、经新的 `card: 'read'` 门渲染,golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致。
## Related
+6 -1
View File
@@ -201,7 +201,12 @@ export function langFromPath(path: string): string | undefined {
const dot = base.lastIndexOf('.')
// A leading dot is a dotfile (no extension), not an empty extension.
if (dot <= 0) return undefined
return LANG_BY_EXTENSION[base.slice(dot + 1).toLowerCase()]
const ext = base.slice(dot + 1).toLowerCase()
// Own-property check only: a filename whose extension is an Object.prototype
// key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the
// inherited member — otherwise a function would reach `lang` and fail the
// tool-output JSON validation.
return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
}
/**
@@ -139,6 +139,15 @@ describe('langFromPath', () => {
expect(langFromPath('data.unknownext')).toBeUndefined()
expect(langFromPath('trailingdot.')).toBeUndefined()
})
it('returns undefined for a filename whose extension is an Object.prototype key', () => {
// Own-property lookup only: these must not resolve to the inherited member
// (a function/object), which would fail the tool-output JSON validation.
expect(langFromPath('foo.constructor')).toBeUndefined()
expect(langFromPath('foo.__proto__')).toBeUndefined()
expect(langFromPath('foo.toString')).toBeUndefined()
expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
})
})
describe('readMetaFromMeta', () => {