Skip to content

fix(vscode): preserve Windows file links in session exports - #8953

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
yefuyou:fix/windows-file-link-uri
Aug 24, 2026
Merged

fix(vscode): preserve Windows file links in session exports#8953
wenshao merged 4 commits into
QwenLM:mainfrom
yefuyou:fix/windows-file-link-uri

Conversation

@yefuyou

@yefuyou yefuyou commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Before recording (compressed before.mp4):

before_pr.mp4

After recording (compressed after.mp4):

after_pr.mp4

What this PR does

Preserve Windows drive-letter paths in session export links and allow the Web UI Markdown renderer to open file:// links through the existing file click handler.

Why it's needed

On Windows, session export links could encode a drive-letter path incorrectly and be rejected by Markdown rendering, causing a click to open the browser and return HTTP 502 instead of opening the exported file in VS Code.

Reviewer Test Plan

How to verify

  1. On Windows, start a Qwen Code Companion conversation and run /export md.
  2. Click the generated Markdown export link.
  3. Confirm that the exported .md file opens in VS Code, with no browser navigation or HTTP 502.
  4. Confirm that HTML, JSON, and JSONL export links still work.
  5. Local validation: npm run clean, npm ci, npm run format, npm run lint:ci, npm run build, and npm run typecheck passed when run separately.

Evidence (Before & After)

Before: clicking the Windows Markdown export link opened the browser and showed HTTP 502; the compressed before.mp4 recording is attached above. After: clicking the same link opens the exported Markdown file in VS Code without browser navigation or HTTP 502; the compressed after.mp4 recording is attached above.

Tested on

OS Status
🍏 macOS N/A
🪟 Windows
🐧 Linux N/A

Environment

Windows 11, VS Code Qwen Code Companion 0.21.9, workspace opened through GitHub Codespaces.

Risk & Scope

  • Main risk or tradeoff: file:// links are explicitly allowed by the Markdown renderer so the existing local-file click handler can process Windows paths.
  • Not validated / out of scope: Manual UI verification on macOS/Linux; the full root test:ci run reached @qwen-code/external-context (172 tests passed) but ended without an aggregate summary or exit code, so the full-suite result is inconclusive.
  • Breaking changes / migration notes: None.

Linked Issues

Fixes #8644

中文说明

本 PR 做了什么

保留会话导出链接中的 Windows 盘符路径,并允许 Web UI Markdown 渲染器通过现有的文件点击处理器打开 file:// 链接。

为什么需要它

在 Windows 上,会话导出链接可能错误地编码盘符,随后被 Markdown 渲染器拒绝,导致点击链接后跳转到浏览器并返回 HTTP 502,而不是在 VS Code 中打开导出的文件。

审阅者测试计划

如何验证

  1. 在 Windows 上启动 Qwen Code Companion 会话并运行 /export md
  2. 点击生成的 Markdown 导出链接。
  3. 确认导出的 .md 文件在 VS Code 中打开,且不会跳转浏览器或出现 HTTP 502。
  4. 确认 HTML、JSON 和 JSONL 导出链接仍然正常。
  5. 本地验证:分别运行的 npm run cleannpm cinpm run formatnpm run lint:cinpm run buildnpm run typecheck 均通过。

证据(修复前与修复后)

修复前:点击 Windows Markdown 导出链接会打开浏览器并显示 HTTP 502;压缩后的 before.mp4 录屏已附在上方。修复后:点击同一链接会在 VS Code 中打开导出的 Markdown 文件,不会跳转浏览器或出现 HTTP 502;压缩后的 after.mp4 录屏已附在上方。

已测试平台

操作系统 状态
🍏 macOS N/A
🪟 Windows
🐧 Linux N/A

环境

Windows 11、VS Code Qwen Code Companion 0.21.9,工作区通过 GitHub Codespaces 打开。

风险与范围

  • 主要风险或权衡:Markdown 渲染器显式允许 file:// 链接,使现有的本地文件点击处理器能够处理 Windows 路径。
  • 未验证/不在范围内:未在 macOS/Linux 上进行手动 UI 验证;远端 root test:ci 已运行到 @qwen-code/external-context(172 个测试通过),但日志没有最终汇总或退出码,因此全量结果尚不确定。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #8644

@yefuyou
yefuyou marked this pull request as ready for review August 11, 2026 17:27
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR — and for steering it through two rounds of review feedback.

Template looks good ✓

Problem: observed bug with solid evidence — #8644 reports the failure with real VS Code error output, a maintainer confirmed the root cause in that thread (vscode.Uri.file(...).toString() percent-encodes the drive-letter colon) and suggested pathToFileURL, and this PR attaches before/after recordings. Since the last pass, a maintainer also reproduced it end-to-end in headless Chromium against the merge base: on main the export link never renders as an anchor at all (markdown-it's default validator blocks the file: scheme), and with linkify enabled the label text gets auto-linked to a real public domain, so the click navigates the webview off to the internet — exactly the reported "browser opens / HTTP 502" symptom. The defect is real, and broader than the PR title says: POSIX exports break the same way.

Direction: aligned. The renderer half is the load-bearing piece — without it the link is never clickable; the producer half is what #8644 literally asked for and keeps the link correct and human-readable rather than merely clickable. The maintainer's 2×2 isolation confirms both halves behave as described.

Size: 4 files, +317/−52 — 184 production lines (SessionMessageHandler.ts 5, MarkdownRenderer.tsx 179) and 185 test lines. The changed files are not in core module paths; the change spans two packages because the bug itself does (the link is produced in the companion handler and rendered by the webui Markdown renderer), and every MarkdownRenderer consumer is enumerated in the Stage 2 review.

Approach: the producer side is exactly the one-line fix suggested on the issue, plus paren-escaping so Markdown link parsing can't break on ( ) in file names. The renderer side is larger than a bare scheme-allow, but every addition traces to a named finding from the prior review rounds or a concrete attack shape (authority validation, UNC/encoded-slash edge cases, the image channel, always-intercepted clicks), and the new helpers replace logic that was duplicated across the file. Nothing speculative.

Risk: no elevated risk signals — none of the changed files are in the revert-prone path set.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 也感谢你在两轮 review 反馈后的持续修改。

模板完整 ✓

问题:已观测到的 bug,证据充分 —— #8644 附带真实的 VS Code 报错,维护者在该 issue 中确认了根因(vscode.Uri.file(...).toString() 会把盘符冒号做百分号编码)并建议改用 pathToFileURL,本 PR 还附上了修复前后的录屏。上次审查之后,一位维护者又在 headless Chromium 中对 merge base 做了端到端复现:main 上导出链接根本不会渲染成锚点(markdown-it 默认校验器屏蔽 file: 协议),而 linkify 开启后标签文本会被自动链接到一个真实存在的公网域名,点击会把 webview 直接跳到公网 —— 正是 issue 中"打开浏览器 / HTTP 502"的症状。缺陷真实存在,而且比 PR 标题的范围更大:POSIX 上的导出同样坏。

方向:对齐。渲染器那一半是起决定作用的 —— 没有它链接根本不可点;生产端那一半是 #8644 字面上要求的修法,让链接正确且可读,而不仅仅是可点。维护者的 2×2 隔离实验确认两部分的行为与描述一致。

规模:4 个文件,+317/−52 —— 生产代码 184 行(SessionMessageHandler.ts 5 行、MarkdownRenderer.tsx 179 行),测试代码 185 行。改动文件不在核心模块路径内;改动跨两个包是因为 bug 本身跨包(链接在 companion 处理器生成、由 webui 的 Markdown 渲染器渲染),MarkdownRenderer 的所有使用方已在 Stage 2 审查中逐一列出。

方案:生产端正是 issue 中建议的那一行修法,外加括号转义,防止文件名中的 ( ) 破坏 Markdown 链接解析。渲染端比"仅放行 file: 协议"要大,但每一处新增都能对应到前两轮 review 中的具名发现或具体的攻击形态(authority 校验、UNC/编码斜杠边界情况、图片通道、点击一律拦截),新增辅助函数替换了文件中原本重复的逻辑。没有投机性改动。

风险:无升级风险信号 —— 改动文件不在易回滚路径集合内。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at f874bcb42e75160dfab3e173b34165da8e102e19 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Code review

Re-read the full diff from scratch at the current head (same commit as the previous pass — this update refreshes the evidence rather than the findings, and independently re-verified every claim below against the code).

Independent take first: for #8644 I would have done exactly two things — pathToFileURL on the producer, and a validateLink override on the renderer that re-allows file: only for local URIs (empty or localhost authority) with every file:// click intercepted. The PR matches that shape; everything beyond it traces to a named prior-round finding or a concrete attack shape, and the new helpers replace logic that was duplicated across the file.

What I verified by reading, independent of the author's claims:

  • End-to-end path: the companion App.tsx wires onFileClick through MessageListAssistantMessageMessageContentMarkdownRenderer into FileMessageHandler.handleOpenFile; shouldResolveAgainstWorkspace (path.win32.isAbsolute) classifies the slash-form drive path D:/aplikacja/file.md as absolute, so vscode.Uri.file opens the real file. The legacy broken %3A form still sitting in old conversations decodes to the same path on the consumer side.
  • Consumers: AssistantMessage renders export messages with enableFileLinks={false} — exactly the config the fix must work under, and it does (the file: branch of the click handler is not gated on enableFileLinks). WebFetchToolCall, GenericToolCall, UserMessage, and PermissionDrawer render without a handler: there, file:// links become inert, always-intercepted anchors instead of raw text — cosmetic, in the safe direction. The CLI (InlineMarkdownRenderer / MarkdownDisplay) and web-shell (Markdown.tsx) use their own renderers and are untouched.
  • Security posture: fail-closed at every branch — authority must be empty or localhost (case-insensitive), UNC and encoded-slash shapes are rejected on both decoded and encoded forms, rejected URIs stay inert text so path-linkification cannot revive them, file: images are replaced by their alt text via createTextNode (no HTML injection), and every file:// click gets preventDefault even when no handler is wired. The allowed surface never exceeds what plain absolute-path links could already do on main through the same open-in-editor handler.
  • The prior pass's watch item is settled: the green ubuntu unit suite ran the new Windows drive-letter test through the node:url mock, so the { windows: true } option exists on CI's Node.

Maintainer verification landed since the last pass (@wenshao — real handler + real renderer bundled per arm, driven in headless Chromium with real MouseEvents, A/B against merge-base, egress-blocked; reported in the thread below, quoted here as the maintainer's evidence, not re-run by this bot): every export format round-trips on the PR arm including spaces/parens/#-in-filename cases; the 2×2 isolation shows the renderer half is load-bearing; the security matrix rejects every UNC/authority attack shape and shows the PR closes pre-existing holes rather than opening new ones. Two non-blocking findings from that run, which my own read corroborates:

  • The two rejects empty-authority UNC… tests pass only under jsdom — Chromium collapses file:////server/… down to pathname /server/…, so the startsWith('//') guard never fires in the real webview and the link opens as a local path. Impact is low (openFile only opens an editor tab; nothing UNC/SMB is fetched), and the maintainer tested a 4-line raw-form rejection patch in the same harness with no regressions. Merge-then-follow-up, not a blocker.
  • Optional nits: middle-click fires auxclick, which the handler does not intercept (likely inert inside a VS Code webview); removeFileUriImages lower-cases the whole HTML string before testing whether it contains file:.

Pre-existing and explicitly out of scope for this PR: bare filename mentions in prose (notes.md, setup.py) still linkify to public domains on both arms — worth a follow-up issue.

CI test evidence

Unattended CI run — no PR code executed here. Fetched once at this commit, not polled; the table region below is updated in place by the finalize job once CI settles.

Checks on this commit (bot orchestration and skipped-for-fork jobs omitted):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
precheck-pr / precheck ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Integration Tests (CLI, No Sandbox) ⏭️ skipped (fork PR)
Test (macos-latest / windows-latest, Node 22.x) ⏭️ skipped (fork PR)

All checks on this commit landed green, web-shell E2E Smoke included — the only red-or-pending signal at review time was that smoke job, which completed successfully at ~02:10 UTC. The unit suite — including the new drive-letter, UNC-rejection, and handler tests — passed on ubuntu; the Windows drive-letter case is covered there through the node:url mock since the OS matrix is skipped for fork PRs.

Not verified: a click inside the actual VS Code Companion webview on Windows — neither sandbox lane reaches the extension webview (/tmux drives the TUI, /verify cannot host a VS Code extension), so there is no lane to name for it. The maintainer's real-Chromium A/B above is the strongest available substitute for exactly that surface, and it passes. The author's recordings and the maintainer's harness are attributed as their respective evidence; this run executed nothing.

中文说明

代码审查

在当前 head 上重新通读了完整 diff(与上次审查是同一个提交 —— 本次更新是刷新证据而非推翻结论,并且独立复核了下面每一条断言)。

先说独立方案:针对 #8644,我会做且只做两件事 —— 生产端改用 pathToFileURL,渲染端覆写 validateLink 仅对本地 file: URI(authority 为空或 localhost)放行,并拦截所有 file:// 点击。PR 与这个方案同形;超出部分每一处都能对应到前两轮 review 的具名发现或具体攻击形态,新增辅助函数替换了文件中原本重复的逻辑。

独立读代码验证(不依赖作者声明):

  • 端到端链路:companion App.tsxonFileClickMessageListAssistantMessageMessageContentMarkdownRenderer 接到 FileMessageHandler.handleOpenFileshouldResolveAgainstWorkspacepath.win32.isAbsolute)把斜杠形式盘符路径 D:/aplikacja/file.md 判为绝对路径,vscode.Uri.file 直接打开真实文件。旧会话里残留的坏 %3A 形式在消费端也能解码成同一路径。
  • 使用方AssistantMessage 渲染导出消息时传 enableFileLinks={false} —— 正是修复必须生效的配置,且确实生效(点击处理器的 file: 分支不受 enableFileLinks 门控)。WebFetchToolCallGenericToolCallUserMessagePermissionDrawer 没有传处理器:那里 file:// 链接变成点击必被拦截的惰性锚点而非原始文本 —— 外观差异,方向安全。CLI(InlineMarkdownRenderer / MarkdownDisplay)与 web-shell(Markdown.tsx)用各自独立的渲染器,不受影响。
  • 安全姿态:处处 fail-closed —— authority 必须为空或 localhost(大小写不敏感),UNC 与编码斜杠形态在解码前后都被拒绝,被拒 URI 保持为惰性文本、路径链接化无法将其复活,file: 图片经 createTextNode 替换为 alt 文本(无 HTML 注入),未接处理器时每个 file:// 点击也被 preventDefault。放行面不超过 main 上纯绝对路径链接经同一处理器本就能做的事。
  • 上次的观察项已落定:ubuntu 单测全绿,新的 Windows 盘符测试经由 node:url mock 在 CI 的 Node 上跑通。

上次审查之后,维护者 @wenshao 的本地验证已落地(真实 handler + 真实渲染器按 arm 打包、headless Chromium 中用真实 MouseEvent 驱动、对 merge-base 做 A/B、出站流量被拦截;详见下方其报告,此处作为维护者证据引用,本机器人未重跑):所有导出格式在 PR 侧均闭环,包括空格/括号/文件名含 # 的场景;2×2 隔离实验表明渲染器那一半是起决定作用的;安全矩阵拒绝了全部 UNC/authority 攻击形态,且显示本 PR 是在堵已有的洞而非开新洞。该验证中两条非阻塞发现,与我的独立阅读相互印证:

  • 两条"拒绝空 authority UNC"测试只在 jsdom 下成立 —— Chromium 会把 file:////server/… 折叠成 pathname /server/…startsWith('//') 守卫在真实 webview 中不会触发,该链接会按本地路径打开。影响较低(openFile 只打开编辑器标签页,不会发起 UNC/SMB 访问),维护者已在同一 harness 中实测了一个 4 行"先拒绝原始形态"的补丁,无回归。可合并后跟进,不阻塞。
  • 可选小项:中键点击触发 auxclick,处理器未拦截(在 VS Code webview 中大概率无害);removeFileUriImages 在判断是否包含 file: 之前先对整个 HTML 做了小写化。

既有问题、明确不在本 PR 职责内:正文里裸文件名(notes.mdsetup.py)仍会被 linkify 成公网域名 —— 两个 arm 表现一致,值得单独开跟进 issue。

CI 测试证据

无人值守 CI 运行 —— 未执行任何 PR 代码。对此提交只抓取一次、不轮询;下方表格区域由 finalize 任务在 CI 结束后原地更新。

此提交上的全部检查已变绿,包括 web-shell E2E Smoke —— 审查时刻唯一未完成的就是它,已于约 02:10(UTC)成功结束。单测(含新增盘符、UNC 拒绝、handler 测试)在 ubuntu 通过 —— Windows 盘符场景由 node:url mock 覆盖,fork PR 跳过 macOS/Windows 矩阵。

未验证:Windows 上真实 VS Code Companion webview 内的点击 —— 两条沙箱通道都够不到扩展 webview(/tmux 驱动的是 TUI,/verify 无法承载 VS Code 扩展),因此没有可指的通道;维护者上述真实 Chromium A/B 正是该表面目前可得到的最强替代,且已通过。作者录屏与维护者 harness 分别作为其各自的证据引用;本次运行未执行任何代码。

Qwen Code · qwen3.8-max

Reviewed at f874bcb42e75160dfab3e173b34165da8e102e19 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, hardened fix for a confirmed bug that turned out to be worse than reported; my independent read, the green unit CI, and the maintainer's real-Chromium A/B all line up.

Stepping back: this PR started as the minimal fix the issue thread called for, and two review rounds turned the renderer half into something genuinely better than a bare scheme-allow. My independent proposal was the same two-piece shape; the PR matches it and then closes every hole the prior rounds named. What landed since the last pass strengthens the picture rather than changing it: the maintainer's end-to-end A/B in real Chromium shows main is broken in a nastier way than #8644 described (the click navigates the webview to a real public domain via linkify), shows the fix holds for every export format including spaces/parens/# filenames, and shows the security matrix closes pre-existing holes instead of opening new ones. If I had to maintain this in six months, the fail-closed parser and the pinning tests would read well.

Remaining reservations, all non-blocking and named: (1) the two empty-authority UNC tests certify a boundary only under jsdom — Chromium collapses the slashes so the guard doesn't fire in the real webview; impact is low (an editor tab on a local path, no network fetch) and the maintainer has a tested 4-line patch ready as a follow-up; (2) the last hop — a click inside the actual Windows VS Code Companion webview — rests on the author's recordings plus the maintainer's harness, since no sandbox lane reaches the extension webview; (3) the auxclick and per-render lower-case nits; (4) bare-filename linkify exposure is pre-existing and wants its own issue. None of these block the merge.

Housekeeping: @wenshao approved this exact commit on the strength of his own A/B run; the standing CHANGES_REQUESTED reviews are this bot's earlier /review rounds against superseded commits (345f97c9, e9066105), which a fresh approval supersedes.

Verdict executed: CI landed fully green on f874bcb42e75160dfab3e173b34165da8e102e19 (web-shell E2E Smoke, the last job, finished at ~02:10 UTC), and the deferred approval was posted against that exact commit at 01:59 UTC while this pass was finishing its comment updates — no second approval is needed or posted. With the maintainer's approval, both required reviews now stand on the reviewed commit.

中文说明

回顾全局:这个 PR 最初就是 issue 讨论中给出的最小修法,两轮 review 之后,渲染器那一半已明显优于"仅放行协议"。我的独立方案同样是两部分结构;PR 与之一致,并把前两轮点名的每个漏洞都堵上了。上次审查之后落地的证据强化而非改变了判断:维护者在真实 Chromium 中的端到端 A/B 表明 main 的坏法比 #8644 描述的更糟(点击会经 linkify 把 webview 跳到真实公网域名),修复在所有导出格式(含空格/括号/文件名含 #)上成立,安全矩阵是在堵已有的洞而非开新洞。六个月后接手维护这套代码,fail-closed 的解析器和钉住行为的测试读起来是舒服的。

剩余保留意见,均不阻塞、逐条点名:(1) 两条空 authority UNC 测试只在 jsdom 下认证了该边界 —— Chromium 会折叠多余斜杠,守卫在真实 webview 中不会触发;影响较低(本地路径打开编辑器标签页,无网络访问),维护者已有实测过的 4 行补丁可作跟进;(2) 最后一跳 —— Windows 上真实 VS Code Companion webview 内的点击 —— 只有作者录屏加维护者 harness 背书,因为没有任何沙箱通道够得到扩展 webview;(3) auxclick 与每次渲染小写化两个小项;(4) 裸文件名被 linkify 成公网域名是既有暴露面,应另开 issue。这些都不阻塞合并。

流程说明:@wenshao 已在同一提交上基于其自跑的 A/B 批准;当前挂着的 CHANGES_REQUESTED 评审是本机器人早前针对已被取代的提交(345f97c9e9066105)的 /review,一次新的批准会取代其自身早先的修改请求。

裁决已执行:f874bcb42e75160dfab3e173b34165da8e102e19 上的 CI 全绿(最后一个任务 web-shell E2E Smoke 于约 02:10 UTC 结束),推迟的批准已于 01:59 UTC 针对该提交发布(本次运行收尾更新评论期间由 finalize 流程完成)—— 无需也不会重复批准。加上维护者的批准,所需两票均已落在被审查的提交上。

Qwen Code · qwen3.8-max

Reviewed at f874bcb42e75160dfab3e173b34165da8e102e19 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): PR #8953 fixes Windows session-export links: it preserves...: executing SessionMessageHandler.test.ts in this worktree (vitest collection failed resolving @qwen-code/core deps after a --ignore-scripts install; its ke…; PR #8953 fixes Windows session-export links: it preserves...: did not run the 2 new test files (no node_modules in the review worktree; did not perform install) — verification is code-trace based.; PR #8953 fixes Windows session-export links: it preserves...: did not execute the two new test files ( SessionMessageHandler.test.ts , MarkdownRenderer.test.tsx ) — the review worktree has no node_modules and I did not …; PR #8953 fixes Windows session-export links: it preserves...: none — all checks above completed within budget.; PR #8953 fixes Windows session-export links: it preserves...: could not run the webui test suite in-worktree (no node_modules installed in this worktree), so Finding 1's mechanism rests on source reading plus the Node/re…, and 1 more.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

未探索到全部深度(达到工具调用预算):PR #8953 fixes Windows session-export links: it preserves...:executing SessionMessageHandler.test.ts in this worktree (vitest collection failed resolving @qwen-code/core deps after a --ignore-scripts install; its ke…;PR #8953 fixes Windows session-export links: it preserves...:did not run the 2 new test files (no node_modules in the review worktree; did not perform install) — verification is code-trace based.;PR #8953 fixes Windows session-export links: it preserves...:did not execute the two new test files ( SessionMessageHandler.test.ts , MarkdownRenderer.test.tsx ) — the review worktree has no node_modules and I did not …;PR #8953 fixes Windows session-export links: it preserves...:none — all checks above completed within budget.;PR #8953 fixes Windows session-export links: it preserves...:could not run the webui test suite in-worktree (no node_modules installed in this worktree), so Finding 1's mechanism rests on source reading plus the Node/re…,另有 1 条。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] With enableFileLinks=true (the component default — ThinkingMessage via MessageContent with onFileClick wired, and hardcoded in CollapsibleFileContent.tsx), this override newly renders file:// anchors that the pre-existing upgradeAnchorIfFilePath then hijacks: FILE_PATH_REGEX/BARE_FILE_REGEX match inside the URI, the anchor is rewritten to href="#" with data-file-path set to the RAW file:///… URI, and the a.file-path-link branch of handleContainerClick fires before the new normalizeExplicitFileLink branch — so onFileClick receives the raw URI, which FileMessageHandler.handleOpenFile joins onto the workspace folder (it is neither posix- nor win32-absolute). — Failure scenario: thinking content or an expanded file-reference block containing [file.md](file:///D:/aplikacja/file.md) → click → Uri.joinPath(workspace, 'file:///…') → nonexistent path → "Failed to open file" toast. Pre-diff the embedded path was linkified into a working link, so this is a regression on enableFileLinks=true surfaces; the new test masks it (renders with enableFileLinks=false and a %20 URL). Probe-confirmed with a flip: skipping file: hrefs in upgradeAnchorIfFilePath makes the click deliver the decoded path.

Suggested change
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

(suggestion block only pins this hunk; the actual fix belongs in upgradeAnchorIfFilePath — add file to the existing https?|mailto|ftp|data early-return — plus a regression test with enableFileLinks=true)

中文说明

[严重]enableFileLinks=true(组件默认值 —— ThinkingMessage 经由 MessageContent 传入且已接线 onFileClickCollapsibleFileContent.tsx 中为硬编码)下,此改动新渲染出的 file:// 锚点会被既有的 upgradeAnchorIfFilePath 劫持:FILE_PATH_REGEX/BARE_FILE_REGEX 能匹配 URI 内部,锚点被改写为 href="#"data-file-path 被设为原始的 file:///… URI,handleContainerClicka.file-path-link 分支先于新的 normalizeExplicitFileLink 分支触发 —— 于是 onFileClick 收到原始 URI,而 FileMessageHandler.handleOpenFile 会把它拼接进工作区目录(它既不是 posix 绝对路径也不是 win32 绝对路径)。—— 失败场景:思考内容或展开的文件引用块中包含 [file.md](file:///D:/aplikacja/file.md) → 点击 → Uri.joinPath(workspace, 'file:///…') → 不存在的路径 → 弹出 "Failed to open file" 错误提示。改动前嵌入的路径会被 linkify 成可用的纯文本链接,因此这对 enableFileLinks=true 场景是一次回归;新增测试掩盖了该问题(以 enableFileLinks=false 渲染且使用了带 %20 的 URL)。已用探针确认并验证了修复翻转:在 upgradeAnchorIfFilePath 中跳过 file: href 后,点击能正确传递解码后的路径。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Saving an export to a UNC location produces a two-slash authority-form URI — pathToFileURL('\\\\server\\share\\file.md').hreffile://server/share/file.md (verified on Node 22) — which this override now renders as a clickable anchor, but normalizeExplicitFileLink only strips ^file:/// (three slashes): the two-slash form fails the strip, the drive-letter check, and startsWith('/'), so it returns /file://server/share/file.md, and handleOpenFile opens that as an absolute POSIX path → ENOENT. — Failure scenario: a Windows user saves the session export to a network share (showSaveDialog allows it) → the success-message link renders and is clickable → clicking yields a "Failed to open file" toast instead of opening the export. Also reachable by pasting [app](file://server/share/app.md) into chat. Pre-diff the anchor never rendered, so this broken click is newly introduced by this diff. Probe-confirmed end to end.

中文说明

[严重] 将导出保存到 UNC 位置时会生成带两个斜杠的 authority 形式 URI —— pathToFileURL('\\\\server\\share\\file.md').hreffile://server/share/file.md(已在 Node 22 上验证)—— 此改动使其可以渲染为可点击的锚点,但 normalizeExplicitFileLink 只会剥离 ^file:///(三个斜杠):两个斜杠的形式无法通过剥离、盘符检查和 startsWith('/') 判断,最终返回 /file://server/share/file.md,而 handleOpenFile 将其当作 POSIX 绝对路径打开 → ENOENT。—— 失败场景:Windows 用户将会话导出保存到网络共享(showSaveDialog 允许这样做)→ 成功消息中的链接可渲染且可点击 → 点击时弹出 "Failed to open file" 错误而不是打开导出文件。在聊天中粘贴 [app](file://server/share/app.md) 同样可触发。改动前该锚点根本不会渲染,因此这个坏点击是本 diff 新引入的。已通过探针端到端确认。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This override also feeds markdown-it's image rule (it consults the same validateLink), so ![alt](file:///…) now renders a live <img src="file:///…"> — a render-time fetch that no part of this component intercepts (handleContainerClick only matches <a>; processFilePaths never touches <img>). Probe: with the repo's markdown-it 14, base renders ![x](file:///home/user/private.png) as literal text; with this override it renders <img src="file:///home/user/private.png" alt="x">. The /export html session viewer renders through ChatViewerAssistantMessage → this component into a page with no CSP meta, opened from disk — a file:// origin where browsers permit file→file image subresources. — Failure scenario: attacker-planted content (tool output, web-fetch results, pasted transcripts) containing ![x](file:///home/user/private.png) loads the viewer's local image into the shareable export document when the export is opened from disk; non-image targets become a broken-icon probe. In the IDE-companion webview the CSP (img-src ${cspSource} data:) blocks the fetch, replacing previously-plain text with a broken-image icon. R1-2-style click-time preventDefault cannot stop a render-time fetch.

Suggested change
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

(suggestion block only pins this hunk; the fix is to scope the file: allowance to links only — strip/blank any img[src^="file:"] in the renderedHtml pipeline regardless of enableFileLinks, or admit file: at the link rule/renderer level only — plus a test that ![x](file:///tmp/a.png) renders no file:-src img)

中文说明

[严重] 该覆写同样作用于 markdown-it 的图片规则(它查询同一个 validateLink),因此 ![alt](file:///…) 现在会渲染出一个真实的 <img src="file:///…"> —— 这是渲染时发起的资源请求,组件中没有任何部分拦截它(handleContainerClick 只匹配 <a>processFilePaths 从不处理 <img>)。探针验证:使用仓库内的 markdown-it 14,改动前 ![x](file:///home/user/private.png) 渲染为纯文本;加上此覆写后渲染为 <img src="file:///home/user/private.png" alt="x">/export html 会话查看器经由 ChatViewerAssistantMessage → 本组件渲染到一个没有 CSP meta 的页面,并从磁盘打开 —— 即 file:// 源,浏览器允许 file→file 的图片子资源。—— 失败场景:攻击者可植入的内容(工具输出、网页抓取结果、粘贴的会话记录)中包含 ![x](file:///home/user/private.png),当导出文件从磁盘打开时,会把查看者本地的图片加载进可分享的导出文档;非图片目标则成为损坏图标的探测手段。在 IDE 伴侣 webview 中,CSP(img-src ${cspSource} data:)会阻止该请求,把原本的纯文本替换为一个损坏的图片图标。R1-2 那种点击时 preventDefault 无法阻止渲染时的资源请求。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This diff makes file:// anchors renderable, but the file:// branch of normalizeExplicitFileLink treats any decoded # as a literal filename character — so hand-written file:// links carrying this component's own supported #L<n> line-fragment convention normalize to path#L<n> instead of path:<n>. — Failure scenario: chat content containing [app.ts](file:///tmp/src/app.ts#L12) (newly rendered as an anchor; pre-diff validateLink rejected file:) → click returns /tmp/src/app.ts#L12handleOpenFile's :line regex does not recognize #L12 → ENOENT → "Failed to open file" toast, while the schemeless twin [app.ts](/tmp/src/app.ts#L12) correctly opens at line 12 (pinned by the existing test). Probe-confirmed with flip: splitting the fragment off the RAW href before percent-decoding (and converting a matching #L<n> to :n) yields onFileClick('/tmp/src/app.ts:12') while preserving the system-URI %23 invariant (a # decoded from %23 stays literal).

中文说明

[严重] 此 diff 使 file:// 锚点可以被渲染出来,但 normalizeExplicitFileLinkfile:// 分支把任何解码后的 # 都当作普通文件名字符 —— 因此手写的、携带本组件自身支持的 #L<n> 行片段约定的 file:// 链接会被规范化为 path#L<n> 而不是 path:<n>。—— 失败场景:聊天内容中包含 [app.ts](file:///tmp/src/app.ts#L12)(现在会新渲染为锚点;改动前 validateLink 拒绝 file:)→ 点击返回 /tmp/src/app.ts#L12handleOpenFile:line 正则无法识别 #L12 → ENOENT → 弹出 "Failed to open file" 错误提示;而不带 scheme 的等价写法 [app.ts](/tmp/src/app.ts#L12) 却能正确在第 12 行打开(已有测试固定了该行为)。已用探针确认并验证翻转:在百分号解码之前先从原始 href 上分离片段(并把匹配的 #L<n> 转换为 :n)后,onFileClick('/tmp/src/app.ts:12') 正常触发,同时保留了系统 URI 的 %23 不变量(从 %23 解码出的 # 仍按字面字符处理)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

filePath: string,
): string {
const markdownLinkPath = vscode.Uri.file(filePath).toString();
const markdownLinkPath = pathToFileURL(filePath).href;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] pathToFileURL does not percent-encode parentheses, and markdown-it truncates a link destination at the first unbalanced ) — so an export saved to a filename containing an unmatched ) produces a success-message link whose rendered href is silently truncated to a wrong path. — Failure scenario: /export md saved as a).md (legal on Windows/macOS/Linux; sessionExportService takes path.basename(saveUri.fsPath) verbatim) → the message becomes [a).md](file:///tmp/a).md) (verified: pathToFileURL('/tmp/a).md').href leaves ) unencoded) → markdown-it parses the destination as file:///tmp/a and renders <a href="file:///tmp/a">a).md</a>.md) (verified with this repo's markdown-it + this override) → the click opens /tmp/a — silently opening a WRONG existing file, or an error toast — while the link text displays the correct filename. Pre-diff nothing clickable existed for this content. Balanced parens (a(b).md) parse fine; the break is specifically unmatched ). The %29-escaped variant round-trips through normalizeExplicitFileLink/safeDecodePath (verified).

Suggested change
const markdownLinkPath = pathToFileURL(filePath).href;
const markdownLinkPath = pathToFileURL(filePath)
.href.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
中文说明

[严重] pathToFileURL 不会对圆括号做百分号编码,而 markdown-it 会在第一个不配对的 ) 处截断链接目标 —— 因此当导出保存为包含不配对 ) 的文件名时,成功消息中的链接渲染出的 href 会被悄悄截断成一个错误的路径。—— 失败场景:/export md 保存为 a).md(在 Windows/macOS/Linux 上均为合法文件名;sessionExportService 原样使用 path.basename(saveUri.fsPath))→ 消息变为 [a).md](file:///tmp/a).md)(已验证:pathToFileURL('/tmp/a).md').href 不对 ) 编码)→ markdown-it 将目标解析为 file:///tmp/a 并渲染出 <a href="file:///tmp/a">a).md</a>.md)(已用本仓库的 markdown-it + 本覆写验证)→ 点击会打开 /tmp/a —— 悄悄打开一个错误的已存在文件或弹出错误提示 —— 而链接文字显示的却是正确的文件名。改动前该内容不存在任何可点击的形式。配对的圆括号(a(b).md)解析正常;问题仅出现在不配对的 ) 上。%29 转义变体可以经由 normalizeExplicitFileLink/safeDecodePath 正确往返(已验证)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This is now the third copy of the /^file:\/\//i scheme predicate in this file — the same literal already gates normalizeExplicitFileLink (~line 52) and the click-routing branch in handleContainerClick (~line 375), with no shared constant. Line 101 decides whether the anchor renders at all; line 375 decides whether the click is intercepted — the two coupled halves of this exact feature. — Concrete cost: a future edit that changes scheme detection in one place but not the others (e.g. tightening this override to file:///) makes render and routing diverge: the link renders but the click is not intercepted, so the webview navigates the raw URL — the same bug class this PR fixes; and every future scheme change must be coordinated across three sites.

Suggested change
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);
const FILE_URI_PATTERN = /^file:\/\//i;

(extract once at module level and reuse in createMarkdownInstance, normalizeExplicitFileLink, and handleContainerClick)

中文说明

[建议] 这已是本文件中 /^file:\/\//i scheme 判断的第三份拷贝 —— 同一个字面量已经出现在 normalizeExplicitFileLink(约第 52 行)和 handleContainerClick 的点击路由分支(约第 375 行),且不存在共享常量。第 101 行决定锚点是否被渲染出来;第 375 行决定点击是否被拦截 —— 正是本功能互相耦合的两半。—— 具体代价:未来若只修改其中一处而其他处的 scheme 检测保持不变(例如把本覆写收紧为 file:///),渲染与路由就会分叉:链接被渲染出来但点击不被拦截,webview 会直接导航到原始 URL —— 即本 PR 要修复的同类 bug;并且未来每次 scheme 变更都要在三处同步修改。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +99 to +101
const defaultValidateLink = md.validateLink;
md.validateLink = (url: string): boolean =>
/^file:\/\//i.test(url) || defaultValidateLink(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The rejection-bypass side of this disjunction is unpinned by any test: mutation-testing shows md.validateLink = () => true (admitting every scheme, including javascript:) survives the entire suite — all three existing tests use file links, and none renders a non-file link through this component. (The fallback side IS pinned: dropping || defaultValidateLink(url) kills two existing tests.) — Concrete cost: a future refactor to () => true — or any variant admitting disallowed schemes — ships green with dangerous-scheme links rendered as anchors.

Suggested fix: add two cases — [docs](https://example.com/a.md) still renders an anchor with the original href, and [x](javascript:alert(1)) renders no anchor.

中文说明

[建议] 该析取表达式中“绕过拒绝”的一侧没有任何测试固定:变异测试表明 md.validateLink = () => true(放行所有 scheme,包括 javascript:)能在整套测试下存活 —— 现有三个测试都使用 file 链接,没有任何测试通过本组件渲染非 file 链接。(回退一侧被固定的:去掉 || defaultValidateLink(url) 会挂掉两个现有测试。)—— 具体代价:未来重构成 () => true —— 或任何放行被禁 scheme 的变体 —— 会在测试全绿的情况下让危险 scheme 的链接被渲染为锚点。

建议修复:新增两个用例 —— [docs](https://example.com/a.md) 仍渲染出带原 href 的锚点;[x](javascript:alert(1)) 不渲染锚点。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +75 to +77
expect(onFileClick).toHaveBeenCalledWith(
'C:/Users/Me/My Exports/export.md',
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The POSIX file:/// variant of export links is newly reachable through this diff but untested — including normalizeExplicitFileLink's leading-/ re-prepend branch, the only thing keeping Linux/macOS export links absolute. — Concrete cost: if that branch regresses (e.g. removed as dead code during cleanup), a Linux/macOS export file:///workspace/export.md strips down to the relative workspace/export.md, handleOpenFile resolves it against the workspace folder, and the click opens a wrong or nonexistent file — with the entire suite green.

Suggested fix: add a case rendering [export.md](file:///tmp/exports/export.md) asserting onFileClick receives /tmp/exports/export.md.

中文说明

[建议] POSIX 形式的 file:/// 导出链接在本 diff 之后变得可触达,但没有测试覆盖 —— 包括 normalizeExplicitFileLink 中重新补上前导 / 的分支,它是 Linux/macOS 导出链接保持绝对路径的唯一保障。—— 具体代价:如果该分支发生回归(例如在清理时被当作死代码删除),Linux/macOS 的导出链接 file:///workspace/export.md 剥离后会剩下相对路径 workspace/export.mdhandleOpenFile 会将其相对工作区目录解析,点击会打开一个错误或根本不存在的文件 —— 而整套测试依然是绿的。

建议修复:新增一个用例,渲染 [export.md](file:///tmp/exports/export.md) 并断言 onFileClick 收到 /tmp/exports/export.md

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +49 to +52
if (
process.platform !== 'win32' &&
filePath === 'D:\\aplikacja\\file.md'
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This global vi.mock('node:url') special-cases exactly one fixture string ('D:\\aplikacja\\file.md'), coupling the module mock to a single test's data. — Concrete cost: a maintainer adding a second Windows-path export test (different drive, UNC path, spaces) gets no windows-mode coercion for the new path — on Linux/macOS CI pathToFileURL treats it as a POSIX relative path and returns a baffling CWD-based URL like file:///home/runner/…/E:%5Cother%5Cfile.md (verified empirically), so the new test fails looking like a production regression rather than a mock gap.

Suggested change
if (
process.platform !== 'win32' &&
filePath === 'D:\\aplikacja\\file.md'
) {
if (
process.platform !== 'win32' &&
/^[a-zA-Z]:\\/.test(filePath)
) {
中文说明

[建议] 这个全局的 vi.mock('node:url') 只特判了一个 fixture 字符串('D:\\aplikacja\\file.md'),把模块级 mock 与单个测试的数据耦合在了一起。—— 具体代价:当维护者新增第二个 Windows 路径导出测试(不同盘符、UNC 路径、带空格)时,新路径不会获得 windows 模式转换 —— 在 Linux/macOS CI 上 pathToFileURL 会把它当作 POSIX 相对路径处理,返回形如 file:///home/runner/…/E:%5Cother%5Cfile.md 的令人困惑的基于 CWD 的 URL(已实验验证),使新测试的失败看起来像生产回归而不是 mock 缺口。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +71 to +73
anchor?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test pins only the onFileClick payload of the file:// click branch, not its navigation suppression — the branch's e.preventDefault()/e.stopPropagation() is untested and nothing in the file asserts event.defaultPrevented. Mutation-verified: deleting only e.preventDefault(); keeps the whole suite green (jsdom performs no navigation), and the assertion proposed below fails on the mutated code and passes on the real code. The click branch predates this PR but was dead for markdown-rendered content until this diff's validateLink override made it reachable — so its core behavior now deserves pinning. — Concrete cost: a future refactor of handleContainerClick that drops preventDefault ships green, and each click on an export-success link then also performs the anchor's default navigation (double-open in the VS Code webview; window navigation in consumers that don't block it).

Suggested change
anchor?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
anchor?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
中文说明

[建议] 该测试只固定了 file:// 点击分支传给 onFileClick 的参数,没有固定它对默认导航的抑制 —— 分支中的 e.preventDefault()/e.stopPropagation() 未被测试,整个文件也没有任何地方断言 event.defaultPrevented。已通过变异验证:仅删除 e.preventDefault(); 整套测试仍然全绿(jsdom 不会真的导航),而下文建议的断言在变异代码上会失败、在真实代码上会通过。该点击分支早于本 PR 就存在,但在本 diff 的 validateLink 覆写使其可达之前,对 markdown 渲染内容而言它是死代码 —— 因此它的核心行为现在值得被固定下来。—— 具体代价:未来某次 handleContainerClick 重构若丢掉 preventDefault,会在测试全绿的情况下合入,导出成功链接的每次点击都会同时触发锚点的默认导航(在 VS Code webview 中造成双重打开;在不阻止导航的使用方中导致窗口被导航走)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): PR #8953 fixes Windows session-export links: the VS Code ...: none — all planned checks completed within budget.; PR #8953 fixes Windows session-export links: the VS Code ...: none — all checks I started completed within budget..

中文说明

未探索到全部深度(达到工具调用预算):PR #8953 fixes Windows session-export links: the VS Code ...:none — all planned checks completed within budget.;PR #8953 fixes Windows session-export links: the VS Code ...:none — all checks I started completed within budget.

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines 61 to 64
} else {
// Preserve the authority component for UNC paths.
filePath = `//${uriPath}`;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-1: file:// links with a remote authority — newly renderable by this diff's validateLink override and test-blessed by the new UNC test — normalize to a UNC path that the companion's openFile sink passes straight to vscode.Uri.fileopenTextDocument, so clicking an attacker-named UNC authority in chat content triggers Windows SMB negotiation (NTLM authentication) to that host. Chat content is routinely attacker-influenced (files, issues, and web pages the agent reads and echoes). Probe-verified at this commit: [docs](file://attacker.example/share/readme.md) renders as a clickable anchor and the click delivers //attacker.example/share/readme.md to onFileClick; shouldResolveAgainstWorkspace classifies it as absolute (no workspace join). Attribution note: the same sink was already reachable pre-PR via protocol-relative [x](//attacker/share/f.md) links, so this diff widens and test-blesses the route rather than creating it — but the file:// surface it adds is new. — Failure scenario: the agent echoes [docs](file://attacker.example/share/readme.md) from attacker-controlled content → rendered clickable (newly possible via this diff) → user clicks → handleOpenFilevscode.Uri.file('//attacker.example/share/readme.md') → on Windows the filesystem redirector authenticates to attacker.example, leaking the user's NTLMv2 hash on a single click.

Fix sketch (spans the override, this branch, and the UNC test):

// createMarkdownInstance: admit only empty-authority file URIs
md.validateLink = (url: string): boolean =>
  /^file:\/\/\//i.test(url) || defaultValidateLink(url);

If UNC export targets must stay supported, require explicit user confirmation before opening a file URI with a non-empty authority.

中文说明

[严重] R2-1:带远程 authority 的 file:// 链接 —— 本 diff 的 validateLink 覆写使其新近可渲染,且新的 UNC 测试为其背书 —— 会被规范化为 UNC 路径,而 IDE 伴侣的 openFile 端点会将其原样传给 vscode.Uri.fileopenTextDocument,因此点击聊天内容中攻击者命名的 UNC authority 会触发 Windows 向该主机的 SMB 协商(NTLM 认证)。聊天内容经常受攻击者影响(agent 读取并回显的文件、issue、网页)。已在本提交上用探针验证:[docs](file://attacker.example/share/readme.md) 渲染为可点击锚点,点击会向 onFileClick 传递 //attacker.example/share/readme.mdshouldResolveAgainstWorkspace 将其判定为绝对路径(不会拼接工作区)。溯源说明:同一端点在本 PR 之前已可经由协议相对链接 [x](//attacker/share/f.md) 触达,因此本 diff 是扩大并为该路由背书,而非创造它 —— 但它新增的 file:// 面是全新的。—— 失败场景:agent 回显来自攻击者可控内容的 [docs](file://attacker.example/share/readme.md) → 渲染为可点击链接(本 diff 之后才可能)→ 用户点击 → handleOpenFilevscode.Uri.file('//attacker.example/share/readme.md') → 在 Windows 上文件系统重定向器向 attacker.example 发起认证,单次点击即泄露用户的 NTLMv2 哈希。修复方向:将 validateLink 收紧为仅放行空 authority 的 file:///;若必须保留 UNC 导出目标,打开带非空 authority 的 file URI 前要求用户显式确认(并同步更新 UNC 测试)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +406 to +410
// Skip external links — let browser handle them normally
if (/^(https?|mailto|ftp|data):/i.test(href)) {
if (
/^(https?|mailto|ftp|data):/i.test(href) ||
FILE_URI_PATTERN.test(href)
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: file:// anchors are now rendered even in consumers that never pass onFileClick (PermissionDrawer plan text, WebFetchToolCall/GenericToolCall tool output); there the file:// click branch is skipped (it requires onFileClick) and this branch returns without e.preventDefault(), delegating navigation of a file:// URL to the host — a dead link with zero feedback where hosts block it, host-dependent navigation where they don't. Probe-verified against the installed markdown-it: pre-diff the same input rendered as inert literal text. — Failure scenario: tool output or plan text containing [x](file:///tmp/a.md) renders as a live-looking link (pre-diff: inert literal text); the user clicks; no handler is wired → default navigation of the file:// URL — blocked with an error or silently by most hosts, while an embedded webview may navigate away from the chat UI.

Suggested fix:

if (FILE_URI_PATTERN.test(href)) {
  e.preventDefault();
  e.stopPropagation();
  onFileClick?.(normalizeExplicitFileLink(href));
  return;
}

(or gate the validateLink override behind an opt-in prop that only the assistant-message path sets)

中文说明

[建议] R2-3:现在即使在没有传 onFileClick 的使用方(PermissionDrawer 的计划文本、WebFetchToolCall/GenericToolCall 的工具输出)中,file:// 锚点也会被渲染出来;在这些场景下 file:// 点击分支被跳过(它要求 onFileClick),而本分支直接 return 且不调用 e.preventDefault(),等于把 file:// URL 的导航权交给宿主 —— 在阻止该协议的宿主上是毫无反馈的死链接,在不阻止的宿主上则产生依赖宿主的导航行为。已用仓库内的 markdown-it 探针验证:改动前同样的输入渲染为纯文本。—— 失败场景:工具输出或计划文本中的 [x](file:///tmp/a.md) 渲染成看似可用的链接(改动前为纯文本);用户点击;没有接线任何处理器 → 触发 file:// URL 的默认导航 —— 多数宿主会以报错或静默方式拦截,而内嵌 webview 可能被导航离开聊天界面。建议修复见上方代码块(对 file:// href 无条件 preventDefault,仅在 onFileClick 存在时调用;或用可选 prop 限定 validateLink 覆写的生效范围)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +141 to +142
it('preserves safe links while rejecting javascript links', () => {
renderMarkdown('[docs](https://example.com/a.md)');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-7: Round-1 finding R1-7 still stands. The new test added the https-preserve case R1-7 asked for, but still never renders a javascript: input, so the a[href^="javascript:"] assertion is vacuously true. Mutation-verified with flip at this commit: replacing the override with md.validateLink = () => true keeps the suite green (8/8), and adding the input below makes the mutated code fail. The PR does not currently render javascript: anchors — only the rejection side is unpinned. — Failure scenario: a future edit to the override admitting disallowed schemes ships a clickable javascript: anchor (an XSS surface in chat content) while this test stays green, because no javascript: input is ever exercised.

Suggested change
it('preserves safe links while rejecting javascript links', () => {
renderMarkdown('[docs](https://example.com/a.md)');
it('preserves safe links while rejecting javascript links', () => {
renderMarkdown('[docs](https://example.com/a.md) [xss](javascript:alert(1))');
中文说明

[建议] R1-7:第一轮发现 R1-7 仍然存在。新增测试补上了 R1-7 要求的 https 保留用例,但仍未渲染任何 javascript: 输入,因此 a[href^="javascript:"] 断言恒真。已在本提交上做了变异验证并确认翻转:把覆写替换为 md.validateLink = () => true 后整套测试仍然全绿(8/8),而加入上面的输入后变异代码会失败。当前 PR 并不会渲染 javascript: 锚点 —— 只是"拒绝"这一侧没有被测试固定。—— 失败场景:未来对覆写的修改若放行了被禁 scheme,会在测试全绿的情况下让可点击的 javascript: 锚点(聊天内容中的 XSS 面)进入生产,因为从未有任何 javascript: 输入被执行过。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +40 to +42
const markdownLinkPath = pathToFileURL(filePath)
.href.replace(/\(/g, '%28')
.replace(/\)/g, '%29');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: The fixture rewrite export (#1).htmlexport a).html dropped producer-side coverage for two of the three encoding behaviors this line exists for. Mutation-verified at this commit: deleting .replace(/\(/g, '%28') keeps all 25 tests green (no remaining fixture contains (), and no fixture contains # — yet #%23 is now load-bearing, because the consumer's normalizeExplicitFileLink splits the raw href on # before decoding (pre-PR the consumer tolerated an unencoded #; post-PR it truncates the path at it). — Failure scenario: if a future edit regresses %28 (e.g. back to bare pathToFileURL(filePath).href), exporting summary (draft.md emits an unbalanced-paren destination that markdown-it renders as no link at all — the success message appears as raw markdown text, the exact bug class this PR fixes for ). If the href builder ever emits an unencoded #, clicking the export link opens a truncated path (wrong file / ENOENT toast) while the suite stays green.

Suggested fix (one fixture pins %28, %23, and %29 simultaneously):

filename: 'export (#1).html',
uri: { fsPath: '/workspace/export (#1).html' },
// expected content ends with:
// (file:///workspace/export%20%28%231%29.html)
中文说明

[建议] R2-4:fixture 从 export (#1).html 改写为 export a).html,丢掉了这行代码所要实现的三种编码行为中两种的生产端覆盖。已在本提交上做变异验证:删除 .replace(/\(/g, '%28') 后全部 25 个测试仍然通过(现有 fixture 均不含 (),也没有任何 fixture 含 # —— 而 #%23 现在是承重保证,因为消费端 normalizeExplicitFileLink 会在解码之前# 切分原始 href(改动前消费端容忍未编码的 #,改动后会在该处截断路径)。—— 失败场景:若未来某次修改使 %28 回归(例如退回裸 pathToFileURL(filePath).href),导出 summary (draft.md 会产出圆括号不配对的目标,markdown-it 将不渲染任何链接 —— 成功消息会显示为原始 markdown 文本,正是本 PR 为 ) 修复的那类 bug。若 href 构造器某次输出了未编码的 #,点击导出链接会打开被截断的路径(错误文件 / ENOENT 提示),而测试套件依然全绿。建议修复(一个 fixture 同时固定 %28%23%29)见上方代码块。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +65 to +66
const lineMatch = fragment.match(/^L?(\d+)(?:-\d+)?$/i);
return lineMatch ? `${filePath}:${parseInt(lineMatch[1], 10)}` : filePath;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-5: The rewrite duplicates the line-fragment grammar (/^L?(\d+)(?:-\d+)?$/i plus the :${parseInt(...)} transform) into a second branch of the same function — the fallback copy remains at lines 73-77 — and a third pre-existing copy lives in normalizePathAndLine (~line 161). — Concrete cost: any change to the fragment grammar (e.g. column specs #L12C34) must now be made in three places in one file, two of them inside this single function; missing the newest branch makes the explicit-link click path and the auto-linkified file-path-link path disagree about where app.ts#L12 resolves — surfacing later as a link that opens the wrong location or not at all.

Suggested fix:

const lineMatch = fragment.match(/^L?(\d+)(?:-\d+)?$/i);
const lineSuffix = lineMatch ? `:${parseInt(lineMatch[1], 10)}` : '';
// file:// branch: return filePath + lineSuffix;
// otherwise:      return decodedPath + lineSuffix;
中文说明

[建议] R2-5:此次重写把行片段语法(/^L?(\d+)(?:-\d+)?$/i:${parseInt(...)} 转换)复制进了同一函数的第二个分支 —— 兜底拷贝仍在第 73-77 行 —— 而第三份既有拷贝位于 normalizePathAndLine(约第 161 行)。—— 具体代价:未来对片段语法的任何修改(例如列号 #L12C34)都要在同一文件的三处进行,其中两处在这一个函数内部;漏掉最新分支会使显式链接点击路径与自动 linkify 的 file-path-link 路径对 app.ts#L12 的解析结果不一致 —— 日后表现为链接打开错误位置或根本无法打开。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines 407 to 412
if (
/^(https?|mailto|ftp|data):/i.test(href) ||
FILE_URI_PATTERN.test(href)
) {
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-6: This diff edits two identical copies of the same "skip external/file-scheme hrefs" predicate — in upgradeAnchorIfFilePath (~lines 232-235) and here in handleContainerClick — instead of sharing one helper. The round-1 FILE_URI_PATTERN extraction (R1-6) was done, but this composite disjunction remains duplicated. — Concrete cost: the next scheme change (adding vscode: or adjusting file:// handling) must remember both sites; missing one makes render-time link promotion and click routing diverge — e.g. a file:// anchor promoted to file-path-link at render time while the click handler treats it as external, or vice versa — which shows up only as a link that silently does nothing when clicked, with no error to trace.

Suggested fix:

const isExternalLinkScheme = (href: string): boolean =>
  /^(https?|mailto|ftp|data):/i.test(href) || FILE_URI_PATTERN.test(href);
// call from both upgradeAnchorIfFilePath and handleContainerClick
中文说明

[建议] R2-6:本 diff 修改了同一"跳过外部/file 协议 href"判断的两份相同拷贝 —— upgradeAnchorIfFilePath(约第 232-235 行)和此处 handleContainerClick —— 而没有共享一个辅助函数。第一轮的 FILE_URI_PATTERN 提取(R1-6)已完成,但这个复合析取判断仍是重复的。—— 具体代价:下一次 scheme 变更(新增 vscode: 或调整 file:// 处理)必须同时记得两处;漏掉一处会使渲染期的链接提升与点击路由分叉 —— 例如 file:// 锚点在渲染期被提升为 file-path-link 而点击处理器却按外部链接处理,或反之 —— 只会表现为点击后链接无声无息,没有任何错误可供排查。

— qwen3.8-max via Qwen Code /review (v0.21.10)

const renderedHtml = useMemo(() => {
try {
let html = md.render(content);
let html = removeFileUriImages(md.render(content));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-7: The new removeFileUriImages pass adds an unconditional full innerHTML parse + serialize round-trip to every markdown render, even though the near-universal case (no file: URI images) makes it pure waste; with enableFileLinks=true the same HTML string is then re-parsed by processFilePaths — two sequential full DOM round-trips per memo evaluation. — Failure scenario: renderedHtml recomputes on every content change — in MessageContent that means on every streamed token of a growing assistant message; each token now pays one extra O(html-size) DOM round-trip for content that contains no images at all.

Suggested fix (a false positive merely falls back to today's cost):

const removeFileUriImages = (html: string): string => {
  if (typeof document === 'undefined' || !html.includes('file:')) {
    return html;
  }
  // ...existing DOM sweep
};
中文说明

[建议] R2-7:新增的 removeFileUriImages 处理为每次 markdown 渲染都加上了一次无条件的完整 innerHTML 解析 + 序列化往返,而绝大多数情况(不含 file: URI 图片)下这纯属浪费;当 enableFileLinks=true 时,同一份 HTML 字符串随后又会被 processFilePaths 再次解析 —— 每次 memo 计算两次串行的完整 DOM 往返。—— 失败场景:renderedHtml 在每次 content 变化时重新计算 —— 在 MessageContent 中即增长中的助手消息每流式输出一个 token 一次;现在每个 token 都要为根本不含图片的内容多付一次 O(html 长度) 的 DOM 往返。建议修复(给该处理加廉价前置判断;误报只会退回当前成本)见上方代码块。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Comment on lines +61 to +63
} else {
// Preserve the authority component for UNC paths.
filePath = `//${uriPath}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-8: file://localhost/... (a valid RFC 8089 form meaning the local machine) falls into the UNC branch and normalizes to //localhost/... instead of the local path. Probe-verified with flip at this commit: the component emits onFileClick('//localhost/tmp/notes.md') for [notes](file://localhost/tmp/notes.md), and the fix below makes it emit /tmp/notes.md with all 8 existing tests still passing. The product's own producers never emit this form, so the trigger is hand-written content. — Failure scenario: [notes](file://localhost/tmp/notes.md) renders as a live link (newly possible via this diff); on click, normalizeExplicitFileLink returns //localhost/tmp/notes.md; on macOS/Linux openTextDocument then fails with file-not-found even though /tmp/notes.md exists and is what the URI denotes — only Windows resolves \\localhost\....

Suggested change
} else {
// Preserve the authority component for UNC paths.
filePath = `//${uriPath}`;
} else if (uriPath.toLowerCase().startsWith('localhost/')) {
filePath = '/' + uriPath.slice('localhost/'.length);
} else {
// Preserve the authority component for UNC paths.
filePath = `//${uriPath}`;
中文说明

[建议] R2-8:file://localhost/...(RFC 8089 的合法形式,表示本机)会落入 UNC 分支,被规范化为 //localhost/... 而不是本地路径。已在本提交上探针验证并确认翻转:组件对 [notes](file://localhost/tmp/notes.md) 触发 onFileClick('//localhost/tmp/notes.md'),应用上方修复后触发 /tmp/notes.md,且现有 8 个测试全部通过。产品自身的生产者不会产出该形式,因此触发源是手写内容。—— 失败场景:[notes](file://localhost/tmp/notes.md) 渲染为可用链接(本 diff 之后才可能);点击时 normalizeExplicitFileLink 返回 //localhost/tmp/notes.md;在 macOS/Linux 上 openTextDocument 会报文件不存在,尽管 /tmp/notes.md 存在且正是该 URI 所指 —— 只有 Windows 能解析 \\localhost\...

— qwen3.8-max via Qwen Code /review (v0.21.10)

@yefuyou

yefuyou commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: f874bcb42e75160dfab3e173b34165da8e102e19

Reason:

  • input:diff_unavailable

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

The current head f874bcb is clean. I traced the precheck input:diff_unavailable to gh 2.97.0 rejecting ESC bytes that only exist in an earlier commit patch and were removed by e906610; the final PR tree and e906610..f874bcb contain no ESC/control bytes. A maintainer-triggered /review should be safe.

@yefuyou

yefuyou commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@doudouOUC Would you mind taking a look at the latest head when you have a chance? You originally confirmed the root cause in #8644. The automated precheck is currently blocked by ESC bytes from an earlier commit patch, while the final tree is clean. If it looks good, a maintainer-triggered review would help. Thanks!

@yefuyou

yefuyou commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@wenshao Sorry for the ping.

This PR is currently blocked only because the fork workflow runs are waiting for maintainer approval:

  • Qwen Code CI: action_required
  • Web-shell Visuals: action_required

The current head is clean (f874bcb). The earlier diff issue was from an old patch containing escape sequences, not the current commit.

Could you please help approve the pending workflow runs or trigger a fresh review on the current head when you have time?

Thanks!

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 123 passed · 0 failed · 123 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:123 通过 · 0 失败 · 123 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #8953 — fix(vscode): preserve Windows file links in session exports

Verdict: merge-ready — 123/123 scripted assertions passed, 0 unexpected failures. Verified head: f874bcb42e75160dfab3e173b34165da8e102e19 (merge base tip 3a1f86d8056cb1bec689ea71ba6590c1d9e43041).

中文摘要
  • 结论: merge-ready。123 条脚本化断言全部通过,0 个意外失败。
  • A/B 结论: 中心主张成立且两半改动都经过验证。base 对 Windows 导出链接发出 file:///d%3A/aplikacja/file.md(冒符被编码),markdown-it 14 的默认 validateLink 拒绝所有 file: scheme,链接退化为纯文本并被路径 linkify 切成错误链接(/aplikacja/file.md,盘符丢失;POSIX 路径甚至产生 29.h 垃圾链接)。head 发出规范的 file:///D:/...,渲染为锚点,点击经现有 openFile 处理器以正确路径打开;旧 %3A 格式的已存会话链接在 head 渲染器上同样可用(向后兼容)。base 三个控制单元按预期全部"无可用链接",head 三个单元全部"锚点+正确点击"。
  • 安全硬化: 9 种敌意 URI(远程主机、localhost.evil、空 authority UNC、%2F%2F/%5C%5C 编码 UNC、带端口主机、IP 主机)全部被拒且文本保持惰性;10 种合法形态(含大小写 scheme/host、行号片段、文件名中的 #)全部交付正确路径;file: 图片(含 UNC 图片)被剥离,远程图片保留;javascript:/data:text/html 仍被默认校验拒绝。
  • 变异矩阵: 6 个变异(移除 validateLink 覆盖、移除惰性文本跳过、移除图片剥离、关闭 host/UNC 拒绝、handler 回退到 vscode.Uri、单字符正对照)全部被各自的新测试杀死;两个未变异对照全绿。新测试无空转。
  • Findings: 无阻塞项。两条建议级:① 同一文本节点中只要出现 file://,该节点内其他普通路径也不再 linkify(代码注释已说明意图,但 PR 描述未列明此代价);② file:///..%2f..%2fetc/passwd 这类点段 URI 会被接受并在点击时归一化为绝对路径——与既有的"显式绝对路径链接可直接打开"能力等价,不构成新能力。
  • 未覆盖: Windows/Codespaces/VS Code 端到端(502 链条)无法在本容器复现,仅复现了 wire 形态与渲染/点击行为;浅克隆下 4 个提交只能验证聚合 diff;companion 套件中 5 个 ide-server.test.ts 失败为环境问题(HOME 目录假设),base 上逐字节相同,非本 PR 回归。

Central claim + A/B

Central claim: /export success messages carry a file:// link that the Web UI Markdown renderer renders as a clickable anchor and routes through the existing file-open handler, for Windows drive-letter paths (and without regressing POSIX).

Secondary claims: (1) the renderer hardening rejects hostile file: URIs (remote/UNC authorities, encoded UNC) and strips file: images; (2) POSIX and other-format export links are unchanged.

Handler wire format (SessionMessageHandler)

cell base (vscode-uri 3.1.0 oracle) head (pathToFileURL + paren-escape)
D:\aplikacja\file.md file:///d%3A/aplikacja/file.md (colon encoded) file:///D:/aplikacja/file.md
/workspace/export (#1).html file:///workspace/export%20%28%231%29.html byte-identical
C:\data\export copy).md (mock-approximated; real vscode-uri encodes parens) file:///C:/data/export%20copy%29.md (unbalanced paren escaped)

Capture: 02-handler-ab-base-vs-head.png. Base oracle = real vscode-uri (the library VS Code's Uri is built on) fed the win32-normalized path; colon-encoding (:%3A) verified locally via URI.file('/tmp/a:b.txt')file:///tmp/a%3Ab.txt.

Renderer behavior (MarkdownRenderer), driven with the real export-message markdown

wire format in message base renderer head renderer
file:///d%3A/aplikacja/file.md (base Windows wire, old conversations) no anchor; stray fragment links file.md + /aplikacja/file.md (drive letter dropped → wrong file opens) anchor renders; click → onFileClick('d:/aplikacja/file.md'), navigation prevented
file:///D:/aplikacja/file.md (head Windows wire) no anchor; stray links file.md + D:/aplikacja/file.md anchor renders; click → onFileClick('D:/aplikacja/file.md')
file:///workspace/export%20%28%231%29.html (POSIX, identical on both builds) no anchor; stray links file.md + 29.h (garbage) anchor renders; click → onFileClick('/workspace/export (#1).html')
mixed text node open /var/log/app.md or file:///tmp/x.md linkifies /var/log/app.md and /tmp/x.md (fragment of the URI) fully inert (documented intent in code comment)

Capture: 01-renderer-ab-head-vs-base.png. Head 4/4, base 4/4 control assertions (base asserts the broken state; all held).

Root cause chain, measured: markdown-it 14.2.0's default validateLink rejects every file: URL (probed directly), so in base the export link never rendered as an anchor on any platform; the base renderer's path linkify then chopped the rejected-URI text into clickable fragments — dropping the Windows drive letter and producing garbage (29.h) for percent-encoded POSIX names. The PR's renderer half (validateLink override + inert-text skip) is what makes anchors exist and keeps rejected URIs inert; the handler half is what emits a canonical, parseable URI and escapes unbalanced parens. Back-compat: old %3A links still render and click correctly on head (cell 1).

Receiver end checked: FileMessageHandler.handleOpenFile treats D:/... as absolute via path.win32.isAbsolute (shouldResolveAgainstWorkspace), so the delivered path opens correctly on Windows.

Corrections

  • The PR description states HTML/JSON/JSONL export links "still work" (implying they worked before) and frames the base defect as Windows-only. The base A/B shows POSIX export links were equally broken in base: the percent-encoded filename was chopped into garbage fragment links (file.md, 29.h) that open the wrong files. The PR fixes POSIX as well as Windows; the description understates the base defect rather than the fix. (Evidence: base cells of 01-renderer-ab-head-vs-base.png.) This is a correction of the description, not a request to change code.

Findings (non-blocking)

  1. Suggestion — unnamed tradeoff: mixed text nodes lose path links. Any text node that merely mentions file:// is skipped entirely by processFilePaths, so a message like open /var/log/app.md or file:///tmp/x.md no longer linkifies /var/log/app.md (base did). The code comment documents the intent (keep rejected-URI text inert), but the PR description's accepted-tradeoff list does not name it. Repro: mixed-node cell of the renderer A/B harness (harness/pr8953-renderer-ab.test.tsx). Impact: cosmetic, rare message shape; the base behavior it removes was itself part of the defect surface (it also linkified URI fragments).
  2. Informational — dot-segment file URIs are accepted. file:///..%2f..%2fetc/passwd passes parseAllowedLocalFileUri (decoded path /../../etc/passwd is neither //-prefixed nor host-carrying) and a click delivers /../../etc/passwd to openFile, which normalizes it. This grants no new capability: an explicit [x](/etc/passwd) markdown link already reaches the same absolute-path branch of the click handler in base and head. Noted for completeness, not as a defect.
  3. Informational — file://127.0.0.1/... is rejected (only empty or localhost authority allowed). Safe default; loopback-authority file URIs are not produced by pathToFileURL, so nothing legitimate loses.
  4. Suggestion — test-fidelity notes. (a) The pre-existing vscode.Uri.file mock in SessionMessageHandler.test.ts uses encodeURI, which leaves parens literal; real vscode-uri encodes them (%28%29). The updated expectation string happens to match real vscode-uri byte-for-byte, but the mock itself still under-encodes. (b) The handler's Windows behavior is only exercised on CI through the node:url mock ({ windows: true } shim) because pathToFileURL is platform-dependent — inherent to both base and head implementations, not a defect; a Windows CI lane would close it.

Not covered

  • The exact 502/browser-navigation chain from the issue (Windows 11 + Codespaces + real VS Code webview) is not reproducible in this container (no VS Code, no Windows, no Codespaces). This round reproduces the shape — broken/inert link in base, working intercepted anchor in head — not the browser-navigation trigger. Reviewer Test Plan steps 1–3 remain manual Windows checks.
  • Per-commit attribution: the checkout is depth-2 (shallow); the metadata lists 4 commits but only f874bcb is locally reachable. The aggregate HEAD^1..HEAD diff was verified; per-commit claims were not individually exercised.
  • 5 pre-existing companion failures in src/ide-server.test.ts (lines 161/193/225/271/304): environmental (/home/test HOME assumption vs this container's HOME), byte-identical failing set on base and head — not a PR regression, left unfixed. Companion delta vs base: +0 new failures.
  • Full root test:ci not run; targeted gates only (webui 626/626; companion 496 passed / 5 pre-existing env failures / 1 skipped; tsc --noEmit clean on both packages; eslint clean on the 4 changed files with a planted-violation liveness probe).
  • file:///C|/x.md legacy pipe form not probed (decodes to a local path; no security consequence).

Methodology

Environment: node:22-bookworm CI container at refs/pull/8953/merge (depth 2); npm ci + npm run build pre-run at head. Harnesses (in harness/, rerunnable) are vitest test files dropped unmodified into each tree's package, importing the component/handler by relative path so each arm runs its own tree's code; base arm ran in a scratch git worktree at HEAD^1 (removed afterwards; base webui dev-deps symlinked from head's identical, lockfile-unchanged node_modules). The renderer A/B drives real React rendering in jsdom and real click events; the handler A/B mocks only collaborators (vscode, sessionExportService, imageHandler), with the base arm's vscode.Uri.file implemented by the real vscode-uri package fed win32-normalized input. The security matrix and mutation matrix ran on the head tree; mutations were applied to scratch copies and restored via git checkout HEAD -- (restoration verified by git status). Raw logs in logs/; captures 0104 in evidence/. Assertion counts in assertions.json map 1:1 to executed expect()/gate checks (renderer A/B 13+7, handler A/B 3+2, security matrix 84, mutation controls+mutants 2+6, gates 6).

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: (cd packages/vscode-ide-companion) npx --no-install vitest run ./src/webview/handlers/SessionMessageHandler.test.ts
file packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: (cd packages/webui) npx --no-install vitest run ./src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: PPPPP
  packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 1 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 2 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 2 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 3 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 3 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 4 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 4 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 5 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 5 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)

Evidence images

01-renderer-ab-head-vs-base

02-handler-ab-base-vs-head

03-security-matrix-head

04-mutation-matrix-all-killed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head f874bcb. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:

  • packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on f874bcb42e75160dfab3e173b34165da8e102e19 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 f874bcb42e75160dfab3e173b34165da8e102e19既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — real browser + real handler, A/B against merge-base

Verified head f874bcb42e75160dfab3e173b34165da8e102e19 against merge-base 9946d13a3578e770f88a4788aad6f1890237b58c.

Verdict: this is a real fix for a real defect, and the defect is meaningfully worse than the PR describes. Recommend merge. One non-blocking finding (two of the new security tests are green only under jsdom) with a tested 4-line patch below.

How this was verified (harness, so the numbers are reproducible)

I did not trust jsdom for the renderer half — the component ships into a Chromium webview, so it was exercised in Chromium.

  1. Producer side (real code, real Windows semantics). SessionMessageHandler was bundled per arm and driven through its actual /export <fmt> path with a stubbed storage layer only. vscode.Uri is the real vscode-uri package (the same class VS Code exposes), loaded with process.platform spoofed to win32 for the duration of that require, so URI.file() takes its genuine Windows branch. pathToFileURL is real Node forced onto { windows: true }. Both arms therefore produce byte-exact Windows output on a Linux host.
  2. Consumer side (real component, real browser). The actual MarkdownRenderer.tsx from each arm was bundled and rendered with React in headless Chromium via Playwright, mounted with enableFileLinks={false} — which is what AssistantMessage.tsx:93 actually passes for the /export message. Clicks are dispatched as real MouseEvents; onFileClick, defaultPrevented, main-frame navigations and outbound requests are all recorded. All non-loopback egress is blocked and logged rather than sent.
  3. The two halves are chained: the string the real handler emits is the string the real renderer receives.

1. What actually breaks on main today

The root cause is not the drive-letter encoding. markdown-it's default validateLink blocks the file: scheme outright, so [file.md](file:///…) never becomes a link at all. webui also sets linkify: true — and because .md, .py, .sh, .io … are real TLDs, markdown-it then auto-links the label text into <a href="http://file.md">. Nothing prevents that click.

main @ merge-base PR #8953
rendered DOM [<a href="http://file.md">file.md</a>](file:///d%3A/aplikacja/file.md) <a href="file:///D:/aplikacja/file.md">file.md</a>
click defaultPrevented false true
postMessage → openFile never fires D:/aplikacja/file.md
main-frame navigation http://file.md/ none

file.md and export.md are live, registered, publicly-resolvable domains. In an early run before I added egress blocking, the click did not just attempt the navigation — Chromium followed it to a third-party site (https://ciobanu.com/). So on main the chat panel leaves the webview for the public internet, which is exactly the "browser opens / HTTP 502" symptom reported.

2. It is not Windows-only

The same /export md on macOS/Linux is broken identically — [export.md](file:///tmp/…) linkifies to http://export.md/ and navigates away. The PR fixes that too. Consider widening the title/description beyond Windows.

3. Which half of the PR is load-bearing? (2×2 isolation)

renderer link producer result
main main broken — navigates to http://file.md/
PR PR fixed — openFile("D:/aplikacja/file.md")
PR main (vscode.Uri) fixedopenFile("d:/aplikacja/file.md")
main PR (pathToFileURL) still broken — navigates to http://file.md/

The MarkdownRenderer change is the fix. The SessionMessageHandler change alone repairs nothing: with the renderer fix in place, the old file:///d%3A/… form already resolves correctly (Windows drive letters are case-insensitive). That is not a reason to drop it — it makes the link human-readable and it is what #8644 literally asked for — but the PR description reads as if pathToFileURL is the fix, and it isn't.

4. Format coverage (real handler → real renderer → click)

export fsPath main result PR result
md (win) D:\aplikacja\file.md navigates to http://file.md/ D:/aplikacja/file.md
md (win, spaces+parens) D:\My Exports\chat (1).md navigates to http://29.md/ D:/My Exports/chat (1).md
html (win, parens + #) C:\workspace\export (#1).html no anchor — dead text C:/workspace/export (#1).html
json (win) C:\Users\Me\session.json no anchor — dead text C:/Users/Me/session.json
jsonl (win) C:\Users\Me\session.jsonl no anchor — dead text C:/Users/Me/session.jsonl
md (posix) /tmp/qwen exports/export.md navigates to http://export.md/ /tmp/qwen exports/export.md
md (posix, literal #) /tmp/qwen exports/a#b.md navigates to http://23b.md/ /tmp/qwen exports/a#b.md

Every format is correct on the PR arm, including the #-in-filename and parens cases.

5. Security matrix (Chromium, enableFileLinks both false and true)

All correctly rejected on the PR arm — no anchor rendered, onFileClick never fires: file://server/share/…, file://attacker.example/…, file://localhost.evil/…, file:///%2F%2Fserver/…, file:///%5C%5Cserver/…, file://localhost//server/…, file://user:pw@localhost/…, file://C:/a/f.md. Correctly allowed: file://localhost/…, FILE:///…, #L12 fragments, %23 literal hashes. file:// images are stripped; javascript: still stripped; https:// links untouched.

For contrast, main with enableFileLinks=true happily handed /server/share/payload.md, /attacker.example/share/payload.md and /localhost.evil/share/payload.md to openFile — so this PR closes existing holes, it does not open them.


Finding (non-blocking, merge-then-follow-up): two new tests are green only under jsdom

rejects empty authority UNC file URI links on the default path and … with extra slash assert a guarantee the code does not provide in a real browser:

new URL('file:////server/share/payload.md').pathname decodedPath.startsWith('//') guard
jsdom / whatwg-url (test env) //server/share/payload.md fires → rejected ✅
Chromium (the actual runtime) /server/share/payload.md — extra slashes collapsed does not fire → accepted

In the webview the link renders and fires openFile('/server/share/payload.md').

Real-world impact is low — what reaches the host is a single-slash local path, not a UNC/SMB path, and openFile only opens an editor tab — so I would not hold the merge for it. But the tests currently certify a boundary that isn't enforced, which is the kind of thing that rots quietly.

I tested this patch in the same Chromium harness — it closes both cases, regresses none of the 13 allow/reject probes, and keeps all 18 MarkdownRenderer.test.tsx tests green:

   const { filePath: rawUri, line } = splitLineFragment(raw);
+  // Chromium collapses `file:////server/...` down to pathname `/server/...`,
+  // so the pathname check below cannot see it. Reject on the raw form first.
+  if (/^file:\/{4,}/i.test(rawUri)) {
+    return undefined;
+  }
   let uri: URL;

Residual, pre-existing, explicitly not this PR's job

Plain assistant prose that merely mentions a filename still linkifies to a public domain and navigates the panel away — identical on both arms:

The plan lives in notes.md and the entry point is setup.py.
  → <a href="http://notes.md">, <a href="http://setup.py">, defaultPrevented=false

Same root class as #8644, and arguably the larger remaining exposure. Worth a follow-up issue: either drop linkify for bare filename-shaped tokens, or intercept http(s) anchors whose host is a bare filename.

Nits (optional)

  • The new file:// anchors are intercepted on click only. A middle-click fires auxclick and is not prevented (defaultPrevented=false in the harness). Very likely inert inside a VS Code webview, but a button !== 0 / auxclick guard would close it.
  • removeFileUriImages runs html.toLowerCase() before the includes('file:') test, allocating a full copy of every message's HTML on every render even when no file: is present. A case-insensitive indexOf/regex test avoids it.

Local gates

check result
packages/webui full suite 489 passed / 34 files
packages/vscode-ide-companion full suite 487 passed, 1 skipped / 53 files
tsc --noEmit (both packages) clean
eslint src (both packages) clean
A/B: PR tests vs merge-base sources 13/18 MarkdownRenderer + 2/25 SessionMessageHandler fail → the tests genuinely pin new behavior

Note that repo CI does not run tsc, so the typecheck above is an extra local gate, not a CI signal.

中文说明

本地验证报告 —— 真实浏览器 + 真实 handler,与 merge-base 做 A/B

验证对象 head f874bcb42e75160dfab3e173b34165da8e102e19,基线 merge-base 9946d13a3578e770f88a4788aad6f1890237b58c

结论:这是针对真实缺陷的真实修复,而且缺陷比 PR 描述的更严重。建议合并。 有一条非阻塞发现(两个新增安全测试只在 jsdom 下成立),下方附已实测的 4 行补丁。

验证方式

没有用 jsdom 验证渲染器那一半——该组件最终运行在 Chromium webview 里,所以在真实 Chromium 中执行。

  1. 生产端(真实代码 + 真实 Windows 语义):按 arm 打包 SessionMessageHandler,走它真实的 /export <fmt> 路径,仅桩掉存储层。vscode.Uri 用的是真实的 vscode-uri 包(即 VS Code 暴露的同一个类),在 require 期间把 process.platform 伪装成 win32,让 URI.file() 走真正的 Windows 分支;pathToFileURL 是真实 Node 强制 { windows: true }。因此两个 arm 在 Linux 上产出的都是与 Windows 逐字节一致的结果。
  2. 消费端(真实组件 + 真实浏览器):把每个 arm 的真实 MarkdownRenderer.tsx 打包,用 React 渲染进 Playwright 驱动的 headless Chromium,并以 enableFileLinks={false} 挂载——这正是 AssistantMessage.tsx:93/export 消息实际传的值。点击用真实 MouseEvent 派发;记录 onFileClickdefaultPrevented、主框架导航与对外请求。所有非本机出站流量被拦截并记录,不实际发出。
  3. 两半是串联的:真实 handler 产出的字符串就是真实渲染器接收的字符串。

1. main 上真正坏掉的是什么

根因不是盘符编码。markdown-it 默认的 validateLink 直接屏蔽 file: 协议,所以 [file.md](file:///…) 根本不会变成链接。而 webui 又开了 linkify: true——由于 .md.py.sh.io 等都是真实顶级域,markdown-it 转而把标签文本自动链接成 <a href="http://file.md">,且没有任何逻辑阻止这次点击。

main @ merge-base PR #8953
渲染 DOM [<a href="http://file.md">file.md</a>](file:///d%3A/aplikacja/file.md) <a href="file:///D:/aplikacja/file.md">file.md</a>
点击 defaultPrevented false true
postMessage → openFile 从未触发 D:/aplikacja/file.md
主框架导航 http://file.md/

file.mdexport.md 是真实注册、公网可解析的域名。在我加出站拦截之前的一次运行中,点击不只是尝试导航——Chromium 真的跳到了第三方站点(https://ciobanu.com/)。也就是说在 main 上,聊天面板会离开 webview 跳往公网,这正是 issue 中"打开浏览器 / HTTP 502"的症状。

2. 这不是 Windows 独有问题

macOS/Linux 上的 /export md 坏法完全一样——[export.md](file:///tmp/…) 被 linkify 成 http://export.md/ 并跳走。本 PR 一并修好了。建议把标题/描述的范围从 Windows 放宽。

3. PR 的哪一半在起作用?(2×2 隔离实验)

渲染器 链接生成方 结果
main main 坏 —— 跳转到 http://file.md/
PR PR 修好 —— openFile("D:/aplikacja/file.md")
PR mainvscode.Uri 修好 —— openFile("d:/aplikacja/file.md")
main PRpathToFileURL 仍然坏 —— 跳转到 http://file.md/

起作用的是 MarkdownRenderer 的改动。SessionMessageHandler 那一半单独并不能修复任何问题:有了渲染器修复后,旧的 file:///d%3A/… 形式本来就能正确解析(Windows 盘符大小写不敏感)。这不构成删掉它的理由——它让链接可读,也正是 #8644 字面上要求的——但 PR 描述读起来像是 pathToFileURL 才是修复,事实并非如此。

4. 格式覆盖(真实 handler → 真实渲染器 → 点击)

导出 fsPath main 结果 PR 结果
md (win) D:\aplikacja\file.md 跳转 http://file.md/ D:/aplikacja/file.md
md (win, 空格+括号) D:\My Exports\chat (1).md 跳转 http://29.md/ D:/My Exports/chat (1).md
html (win, 括号 + #) C:\workspace\export (#1).html 无锚点——死文本 C:/workspace/export (#1).html
json (win) C:\Users\Me\session.json 无锚点——死文本 C:/Users/Me/session.json
jsonl (win) C:\Users\Me\session.jsonl 无锚点——死文本 C:/Users/Me/session.jsonl
md (posix) /tmp/qwen exports/export.md 跳转 http://export.md/ /tmp/qwen exports/export.md
md (posix, 文件名含 #) /tmp/qwen exports/a#b.md 跳转 http://23b.md/ /tmp/qwen exports/a#b.md

PR arm 上全部格式正确,包括文件名含 # 和括号的场景。

5. 安全矩阵(Chromium,enableFileLinksfalsetrue 各跑一遍)

PR arm 上全部正确拒绝——不渲染锚点、onFileClick 不触发:file://server/share/…file://attacker.example/…file://localhost.evil/…file:///%2F%2Fserver/…file:///%5C%5Cserver/…file://localhost//server/…file://user:pw@localhost/…file://C:/a/f.md。正确放行:file://localhost/…FILE:///…#L12 行号片段、%23 字面井号。file:// 图片被剥离;javascript: 仍被剥离;https:// 链接不受影响。

作为对照,mainenableFileLinks=true 时会把 /server/share/payload.md/attacker.example/share/payload.md/localhost.evil/share/payload.md 直接交给 openFile——所以本 PR 是在堵已有的洞,而不是开新洞。

发现(非阻塞,可合并后跟进):两个新增测试只在 jsdom 下成立

rejects empty authority UNC file URI links on the default path… with extra slash 断言了代码在真实浏览器中并不提供的保证:

new URL('file:////server/share/payload.md').pathname decodedPath.startsWith('//') 守卫
jsdom / whatwg-url(测试环境) //server/share/payload.md 触发 → 拒绝 ✅
Chromium(实际运行时) /server/share/payload.md——多余斜杠被折叠 不触发 → 放行

在 webview 中该链接会渲染出来,并触发 openFile('/server/share/payload.md')

真实影响较低——交给宿主的是单斜杠本地路径而非 UNC/SMB 路径,且 openFile 只是打开编辑器标签页——所以我不会因此卡住合并。但测试目前认证了一条并未真正生效的边界,这类问题会悄悄腐化。

我在同一套 Chromium harness 里实测了下面这个补丁:它堵住了两个用例,13 条放行/拒绝探针全部无回归,且 MarkdownRenderer.test.tsx 的 18 个测试全绿:

   const { filePath: rawUri, line } = splitLineFragment(raw);
+  // Chromium collapses `file:////server/...` down to pathname `/server/...`,
+  // so the pathname check below cannot see it. Reject on the raw form first.
+  if (/^file:\/{4,}/i.test(rawUri)) {
+    return undefined;
+  }
   let uri: URL;

遗留问题(既有的,明确不属于本 PR 职责)

普通助手正文里只要提到文件名,仍会被 linkify 成公网域名并把面板跳走——两个 arm 表现一致:

The plan lives in notes.md and the entry point is setup.py.
  → <a href="http://notes.md">、<a href="http://setup.py">,defaultPrevented=false

#8644 同一根因类别,而且可以说是剩下更大的暴露面。值得开一个跟进 issue:要么对形如裸文件名的 token 关掉 linkify,要么拦截 host 是裸文件名的 http(s) 锚点。

小建议(可选)

  • 新的 file:// 锚点只在 click 上被拦截。中键点击触发的是 auxclick,未被阻止(harness 中 defaultPrevented=false)。在 VS Code webview 里大概率无害,但加一个 button !== 0 / auxclick 守卫可以彻底封死。
  • removeFileUriImagesincludes('file:') 判断之前先做了 html.toLowerCase(),即使内容里没有 file: 也会在每次渲染时为每条消息的 HTML 分配一份完整副本。改成大小写不敏感的 indexOf/正则判断即可避免。

本地门禁

检查 结果
packages/webui 全量测试 489 通过 / 34 个文件
packages/vscode-ide-companion 全量测试 487 通过,1 跳过 / 53 个文件
tsc --noEmit(两个包) 干净
eslint src(两个包) 干净
A/B:PR 的测试对 merge-base 源码 MarkdownRenderer 13/18 失败 + SessionMessageHandler 2/25 失败 → 测试确实钉住了新行为

注意仓库 CI 并不跑 tsc,所以上面的类型检查是额外的本地门禁,不是 CI 信号。

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge August 24, 2026 01:44
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 157 passed · 0 failed · 157 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:157 通过 · 0 失败 · 157 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - follow-up round at an unchanged head

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 157 passed · 0 failed · 157 total

This is a follow-up round: HEAD^2 (f874bcb) and HEAD^1 (3a1f86d) are byte-identical to the OIDs the previous round verified, and the effective diff matches the snapshot's screen.diff modulo hash abbreviation — the tree under test is unchanged. Per the follow-up protocol, every carried measurement was re-run anyway (re-measured, never diffed from the old report).

中文 — 判定:✅ 通过 · 可合入(agent 判定,跟进轮次)
  • 结论: merge-ready。157 条脚本化断言全部通过,0 个意外失败。本轮为跟进轮次:head(f874bcb)与 base tip(3a1f86d)与上一轮完全相同,但所有关键测量均重新执行,未沿用旧报告数字。
  • A/B 结论: 中心主张成立。base 渲染器对 Windows 导出链接(file:///d%3A/...,冒符被编码)不渲染锚点,且把被拒文本切成错误片段链接(点击交付 file.md、丢盘符的 /aplikacja/file.md、POSIX 产生 29.h 垃圾);head 渲染锚点并经现有 openFile 处理器交付正确路径(D:/aplikacja/file.md),旧 %3A 链接在 head 上向后兼容。handler 侧 base 用真实 vscode-uri 作 oracle 发出 d%3A 形式,head 发出规范 file:///D:/...;POSIX wire 两臂逐字节相同(无回归)。
  • 安全面: 9 种敌意 URI(远程/仿冒 localhost/空 authority UNC/编码 UNC/回环 IP/userinfo)全部惰性;9 种合法形态(含大小写 scheme、行号片段、文件名含 #、旧 %3A wire)交付正确路径;file: 图片被剥离;javascript:/data: 仍被默认校验拒绝。
  • 变异矩阵: 3 个变异(移除 validateLink 覆盖、移除惰性文本跳过、移除括号转义)均被同文件测试杀死,两个未变异对照全绿。
  • 上一轮 findings 状态: 4 条全部"stands"(重测确认),无新增阻塞项。
  • 未覆盖: Windows/Codespaces/VS Code 端到端 502 链条(仅复现 wire 形态与渲染/点击行为);浅克隆下 4 提交仅验证聚合 diff;companion 5 个 ide-server.test.ts 失败为环境问题(两臂逐字节相同)。

Previous-finding status table (follow-up round)

# finding (previous round) severity status at f874bcb (unchanged head)
1 Mixed text nodes lose path links (unnamed tradeoff) Suggestion stands — re-measured: R4 cell; base linkifies /var/log/app.md and the URI fragment /tmp/x.md, head renders the node fully inert. I agree with the previous classification: cosmetic, rare shape, and the removed base behavior was itself part of the defect surface.
2 Dot-segment file URIs accepted, normalized on click (no new capability) Informational stands — re-measured, and the sibling swept: file:///..%2f..%2fetc/passwd delivers /../../etc/passwd and file:///..%2fetc/passwd delivers /../etc/passwd; both accepted. Still no new capability (explicit [x](/etc/passwd) reaches the same handler branch on base and head). The previously unprobed legacy pipe form file:///C|/x.md is now probed: accepted, delivered as local path /C|/x.md — same no-new-capability class.
3 file://127.0.0.1/... rejected (safe default) Informational stands — matrix cell re-run, rejected and inert.
4 Test-fidelity notes (mock under-encodes; windows shim inherent) Suggestion stands — re-derived with the real oracle this round: real vscode-uri emits %3A/%28%29 while the test's vscode.Uri.file mock uses encodeURI (under-encodes). Note the mock is now dead code on head (the handler no longer calls Uri.file); the head test's expected POSIX wire matches the real vscode-uri byte-for-byte (H2 base wire == head expectation). The node:url windows shim remains inherent (measured: without it, Linux pathToFileURL mangles drive paths).

No declined or deferred rows existed; nothing worsened (head is unchanged).

Central claim + A/B

Central claim: /export success messages carry a file:// link that the Web UI Markdown renderer renders as a clickable anchor and routes through the existing file-open handler, for Windows drive-letter paths, without regressing POSIX.

Secondary claims: (1) the renderer hardening rejects hostile file: URIs and strips file: images; (2) POSIX/other-format export links are unchanged and legacy %3A Windows links remain clickable.

Handler wire format (SessionMessageHandler)

cell base (real vscode-uri 3.1.0 oracle, win32-normalized) head (pathToFileURL + paren escape)
H1 D:\aplikacja\file.md file:///d%3A/aplikacja/file.md (colon encoded) file:///D:/aplikacja/file.md (canonical)
H2 /workspace/export (#1).html file:///workspace/export%20%28%231%29.html byte-identical
H3 C:\data\export copy).md file:///c%3A/data/export%20copy%29.md file:///C:/data/export%20copy%29.md (unbalanced paren escaped)
H4 /tmp/экспорт.json UTF-8 percent-encoded byte-identical

Head cells additionally round-trip: new URL(href) parses and the decoded pathname equals the win32-normalized original (4/4). Capture: 02-handler-ab-base-vs-head.png. Oracle note: URI.file is host-platform-dependent (on Linux it treats \ literally), so the base oracle win32-normalizes first — the same emulation the previous round used; verified to reproduce the real Windows vscode.Uri.file output shape (d%3A).

Renderer behavior (MarkdownRenderer), driven with the real export-message markdown

wire format in message base renderer (control) head renderer
file:///D:/aplikacja/file.md (head Windows wire) no anchor; fragments ["file.md","D:/aplikacja/file.md"], first click delivers file.md (wrong file) anchor; click → onFileClick('D:/aplikacja/file.md'), navigation prevented
file:///d%3A/aplikacja/file.md (legacy Windows wire) no anchor; fragments drop the drive letter (/aplikacja/file.md) anchor; click → onFileClick('d:/aplikacja/file.md') (back-compat)
file:///workspace/export%20%28%231%29.html (POSIX) no anchor; garbage fragment 29.h anchor; click → onFileClick('/workspace/export (#1).html')
mixed text node open /var/log/app.md or file:///tmp/x.md linkifies /var/log/app.md and the URI fragment /tmp/x.md fully inert (0 anchors) — documented intent
[open](file://server/share/file.md) (hostile) no file anchor, but clickable fragment /server/share/file.md fully inert (0 anchors)

Capture: 01-renderer-ab-head-vs-base.png. Head 5/5, base 5/5 control assertions (base asserts the broken state; all held). Receiver end re-confirmed statically: shouldResolveAgainstWorkspace (utils/file-path.ts) treats D:/... as absolute via path.win32.isAbsolute — unchanged by the PR.

Security matrix at head (28/28)

  • Hostile, inert (9): remote server, attacker authority, localhost.evil, file://// and file:///// UNC, %2F%2F/%5C%5C encoded UNC, 127.0.0.1 authority, userinfo authority — zero anchors of any kind, no click delivery.
  • Default validator still rejects: javascript:, data:text/html; https: links still render unintercepted.
  • Benign, delivered (9): C:/Users/Me/My Exports/export.md, /tmp/exports/export.md, file://localhost/..., FILE:///..., FiLe://LOCALHOST/..., legacy %3A wire, lowercase drive, #L12 fragment → :12, %23 in filename → literal #.
  • Images: file: images stripped (alt text kept), UNC image stripped, https image kept.
  • No-handler: file anchor without onFileClick is still preventDefaulted (no browser navigation).

Capture: 03-security-matrix-head.png. Harness note: markdown-it preserves scheme case in href (FILE://), so the harness matches anchors case-insensitively; the component's own /i patterns already handle it.

Corrections

Carried from the previous round and re-confirmed by this round's base cells: the PR description frames the base defect as Windows-only, but the base renderer equally broke POSIX export links (R3 base cell produced the garbage 29.h fragment). This is a correction of the description, not a request to change code.

Findings (non-blocking)

No new findings this round. The four carried findings all stand (status table above); the dot-segment and pipe-form siblings swept this round fall inside finding 2's existing classification (no new capability).

Mutation matrix

mutant suite (same file as mutant) result
unmutated control, renderer MarkdownRenderer.test.tsx green (18/18, inside webui 626/626)
unmutated control, handler SessionMessageHandler.test.ts green (25/25, inside companion 496 passed)
M1: remove validateLink override renderer killed — 6/18 fail; all benign file-URI accept tests (anchor missing, no click delivery)
M2: remove inert-text skip renderer killed — 7/18 fail; exactly the 7 hostile-rejection tests (rejected URI text linkified into fragments)
M3: remove paren escape handler killed — 1/25 fail; exactly encodes exported file links before rendering markdown (expected %28%231%29, received literal parens)

Capture: 04-mutation-matrix-all-killed.png. The two renderer hunks are independently load-bearing for disjoint cell classes (M1 kills accepts, M2 kills rejects); the paren escape is load-bearing because Node's pathToFileURL does not percent-encode parens (measured directly).

Not covered

  • The exact 502/browser-navigation chain (Windows 11 + Codespaces + real VS Code webview) is not reproducible in this container; this round reproduces the shape (broken/inert link in base, intercepted anchor in head), not the browser-navigation trigger. Reviewer Test Plan steps 1–3 remain manual Windows checks.
  • Per-commit attribution: checkout is depth-2 (shallow); metadata lists 4 commits but only f874bcb is locally reachable. Aggregate HEAD^1..HEAD diff verified; per-commit claims not individually exercised.
  • 5 pre-existing companion failures in src/ide-server.test.ts (HOME assumption /home/test vs this container): A/A control this round shows the byte-identical failing set (same 5 test names) on base and head — environmental, not a PR regression, left unfixed.
  • Full root test:ci not run; targeted gates only (webui 626/626; companion 496 passed / 5 env failures / 1 skipped; tsc --noEmit clean on both packages; eslint clean on the 4 changed files with a planted-violation liveness probe).
  • The first security-matrix run showed 2 failures traced to a case-sensitive a[href^="file:"] selector in my harness (markdown-it preserves FILE: case in href); fixed and re-run green before counting. Documented in methodology, not counted as PR failures.

Methodology

Environment: node:22-bookworm CI container at refs/pull/8953/merge (depth 2), npm ci + npm run build pre-run at head; verified head f874bcb42e75160dfab3e173b34165da8e102e19, base tip 3a1f86d8056cb1bec689ea71ba6590c1d9e43041 — both identical to the previous round's cited OIDs, and the effective diff byte-matches the snapshot's screen.diff modulo hash abbreviation (input closure unchanged; all measurements re-run regardless). Harnesses (in harness/, rerunnable) are vitest files dropped unmodified into each tree's package so each arm runs its own tree's code; the arm is selected by PR8953_ARM. Renderer harness drives real React rendering in jsdom with real click events. Handler harness mocks only collaborators (vscode, sessionExportService, imageHandler); the base arm's vscode.Uri.file is the real vscode-uri 3.1.0 package fed win32-normalized input (oracle for the Windows extension host), the head arm uses the PR's own { windows: true } node:url shim. Base arm ran in a scratch git worktree at HEAD^1 (removed afterwards); its nested node_modules were symlinked from head's identical, lockfile-unchanged install — realpath asserted: vscode-uri resolves to the shared root install and @qwen-code/webui to head's dist, whose only loaded symbol (stripZeroWidthSpaces) is untouched by the PR (0 diff files). Mutations were applied to the head tree and restored via git checkout HEAD -- (restoration verified by git status). Raw logs in logs/; captures 0104 in evidence/. Assertion counts map 1:1 to executed expect()/gate checks: renderer A/B 17+12, handler A/B 20+8, security matrix 86, mutation verdicts 5, gates 9 (webui suite, companion suite incl. known-fail set, A/A failing-set identity, 2× tsc, eslint clean, eslint liveness probe, input-closure identity, base-arm realpath).

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: (cd packages/vscode-ide-companion) npx --no-install vitest run ./src/webview/handlers/SessionMessageHandler.test.ts
file packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: (cd packages/webui) npx --no-install vitest run ./src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: PPPPP
  packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 1 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 2 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 2 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 3 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 3 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 4 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 4 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)
round 5 · packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts: P (exit 0)
round 5 · packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.test.tsx: P (exit 0)

Evidence images

01-renderer-ab-head-vs-base

02-handler-ab-base-vs-head

03-security-matrix-head

04-mutation-matrix-all-killed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@wenshao
wenshao added this pull request to the merge queue Aug 24, 2026
Merged via the queue into QwenLM:main with commit 632e22b Aug 24, 2026
44 checks passed
@yefuyou
yefuyou deleted the fix/windows-file-link-uri branch August 24, 2026 02:19
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants