Skip to content

feat(voice): support trusted private ASR base URLs - #8350

Merged
rockybot2026 merged 31 commits into
QwenLM:mainfrom
rockybot2026:feat/voice-private-base-url-allowlist
Aug 6, 2026
Merged

rockybot2026 merged 31 commits into
QwenLM:mainfrom
rockybot2026:feat/voice-private-base-url-allowlist

Conversation

@rockybot2026

@rockybot2026 rockybot2026 commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds security.allowedInsecureVoiceBaseUrls, an empty-by-default exact allowlist for voice provider base URLs. A matching entry lets managed deployments route voice transcription through an HTTP or private-network ASR gateway while preserving the existing default deny behavior.

The exception is accepted only from User, System, and SystemDefaults settings. Workspace values are ignored with a warning. Entries must include an explicit http:// or https:// scheme and the full provider path. Matching includes the normalized scheme, host, port, and path; only URL serialization and trailing slashes are normalized, and wildcards or hostname suffixes are not supported. When a legacy environment/OAuth input is normalized with an inferred /v1 suffix, a rejection names the effective normalized URL that must be allowlisted; the allowlist itself still never infers missing path segments.

The same resolved decision is enforced across CLI batch transcription, CLI/daemon streaming, and Desktop batch/streaming. Known metadata, link-local, unspecified, and mapped-loopback addresses remain blocked even when listed. Desktop also resolves credentials from exactly one provider matching the selected voice model and fails closed on ambiguous or malformed entries. Public HTTPS custom providers do not require an insecure allowlist entry; cleartext or private-network endpoints still require an exact match.

Why it's needed

Managed deployments often expose regional ASR services through isolated APIG/VPC endpoints whose hostnames differ by region. The current unconditional HTTPS/public-network checks reject these endpoints, and a hard-coded vendor or region hostname list would not scale. A trusted exact-URL policy lets operators declaratively own each regional endpoint without weakening defaults for ordinary users.

Reviewer Test Plan

How to verify

  1. Put an HTTP private gateway URL in both a User/System provider baseUrl and security.allowedInsecureVoiceBaseUrls, select that provider ID as voiceModel, and confirm voice configuration resolves with the private-network opt-in.
  2. Remove the allowlist entry or change its scheme, host, port, or path and confirm resolution fails closed.
  3. Put the setting in Workspace scope and confirm it is ignored with a settings warning.
  4. Try unspecified, link-local, cloud metadata, mapped-loopback, and compact or fully expanded IPv4-compatible IPv6 forms and confirm they remain blocked.
  5. Configure duplicate providers with the selected voice model ID and confirm Desktop rejects the ambiguous credentials.

Locally verified commands:

cd packages/cli && npx vitest run src/config/settings.test.ts src/serve/routes/workspace-voice.test.ts src/ui/voice/voice-transcriber.test.ts
cd packages/desktop && bun test packages/server-core/src/voice
cd packages/desktop && bunx tsc -p packages/server-core/tsconfig.json --noEmit
npm run lint
npm run typecheck
npm run build

Results: CLI focused suites 247/247 passed; Desktop voice suites 109/109 passed; Desktop server-core typecheck passed; root lint, workspace typecheck, and build passed; repository pre-commit formatting and lint hooks passed. All eight GitHub Qwen inline suggestions were addressed and resolved. A verified local medium review then found two mutation-test coverage gaps; both were fixed, and the completed follow-up low-effort review of the final diff reports Findings: None.

Evidence (Before & After)

Before: a non-loopback HTTP voice endpoint or a hostname resolving to a private address is rejected unconditionally.

After: the endpoint is accepted only when its complete normalized URL is explicitly listed in trusted configuration. Non-matches and protected address classes continue to fail closed.

N/A — this is settings and network-policy behavior with no visual/TUI change.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested locally
🐧 Linux ⚠️ not tested locally

Environment (optional)

macOS 26.4, Node.js v24.10.0, npm 11.6.1, Bun 1.3.11. Unit tests use injected lookups and no real network or credentials.

Risk & Scope

  • Main risk or tradeoff: an administrator can opt a voice endpoint into cleartext/private-network egress. This is constrained to trusted scopes, exact full-URL matches, and voice-only traffic; metadata/link-local/unspecified addresses remain blocked.
  • Not validated / out of scope: no live ASR request was sent because it requires a private gateway and credentials; Windows and Linux were not tested locally and are left to CI. Full npm run preflight was attempted, but its aggregate test phase was affected by a machine-level Aone Git hook in temporary-repository tests plus unrelated baseline/flaky failures; changed-path suites were rerun with sanitized Git configuration and passed.
  • Breaking changes / migration notes: the allowlist still defaults to empty. Desktop now treats a provider whose ID exactly matches the selected voice model as authoritative; duplicate or incomplete entries and unresolved keys fail instead of silently falling back to environment credentials. Complete or remove such an entry to restore the legacy DashScope/environment fallback.
  • PR size: the diff is slightly above the 1,200-line guideline because the same security contract is covered across CLI, daemon, Desktop, schema, documentation, and regression tests. Splitting it would temporarily leave egress paths with inconsistent policy.

Linked Issues

Relates to #8286.

中文说明

本 PR 做了什么

本 PR 新增 security.allowedInsecureVoiceBaseUrls:一个默认空的语音 provider base URL 精确白名单。匹配后,受管部署可以通过 HTTP 或私网 ASR 网关转发语音转写,同时保持现有默认拒绝行为。

该例外仅接受 User、System 和 SystemDefaults 作用域配置。Workspace 中的值会被忽略并产生警告。条目必须包含显式 http:// 或 https:// scheme 和完整 provider path。匹配包含规范化后的 scheme、host、port 和 path;仅 URL 序列化和尾部斜杠会被规范化,不支持通配符或域名后缀匹配。旧环境变量/OAuth 输入若被自动补上 /v1,拒绝信息会展示实际需要加入白名单的规范化 URL;白名单本身仍不会推断缺失 path。

同一个解析结果会一致应用到 CLI 批量转写、CLI/daemon 流式转写以及 Desktop 批量/流式转写。即使被列入白名单,已知元数据、link-local、未指定地址和映射 loopback 地址仍会被阻断。Desktop 还会从与所选语音模型精确匹配的唯一 provider 解析凭证,并对歧义或畸形配置 fail closed。公网 HTTPS 自定义 provider 无需不安全白名单条目;明文或私网端点仍要求精确匹配。

为什么需要

受管部署通常通过隔离的 APIG/VPC 端点暴露区域 ASR 服务,而且不同区域使用不同域名。当前无条件 HTTPS/公网检查会拒绝这些端点,硬编码厂商或区域域名列表也无法扩展。可信的完整 URL 精确策略允许运维方以声明式方式管理每个区域端点,同时不削弱普通用户的默认安全策略。

Reviewer 测试计划

如何验证

  1. 在 User/System provider 的 baseUrl 和 security.allowedInsecureVoiceBaseUrls 中同时填写一个 HTTP 私网网关 URL,将该 provider ID 选为 voiceModel,确认语音配置解析出私网例外。
  2. 删除白名单条目,或修改其 scheme、host、port、path,确认解析 fail closed。
  3. 将该设置放入 Workspace 作用域,确认它被忽略并产生设置警告。
  4. 尝试未指定地址、link-local、云元数据、映射 loopback,以及紧凑或完全展开的 IPv4-compatible IPv6 形式,确认它们仍被阻断。
  5. 配置两个与所选语音模型 ID 相同的 provider,确认 Desktop 拒绝歧义凭证。

本地验证命令与英文部分相同。结果:CLI 定向套件 247/247 通过;Desktop voice 套件 109/109 通过;Desktop server-core 类型检查通过;根目录 lint、workspace typecheck 和 build 通过;仓库 pre-commit 格式化和 lint hook 通过。GitHub Qwen 的 8 条行内建议均已修复并 resolve;随后本地 verified medium review 发现 2 个 mutation 测试覆盖缺口,也已修复,最终 diff 的后续 low-effort review 报告 Findings: None。

证据(Before & After)

Before:非 loopback HTTP 语音端点或解析到私网地址的域名会被无条件拒绝。

After:仅当完整规范化 URL 在可信配置中被明确列出时才接受该端点。非匹配项和受保护地址类别仍然 fail closed。

N/A——这是设置和网络策略行为,没有可视化/TUI 变化。

测试平台

OS 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 本地未测试
🐧 Linux ⚠️ 本地未测试

环境

macOS 26.4、Node.js v24.10.0、npm 11.6.1、Bun 1.3.11。单元测试使用注入的 lookup,不访问真实网络或凭证。

风险与范围

  • 主要风险或取舍:管理员可以允许语音端点使用明文/私网出口。该能力仅限可信作用域、完整 URL 精确匹配和语音流量;元数据、link-local、未指定地址仍被阻断。
  • 未验证/范围外:未发送真实 ASR 请求,因为这需要私网网关和凭证;Windows 和 Linux 未在本地测试,交由 CI。已尝试完整 npm run preflight,但聚合测试阶段受到机器级 Aone Git hook 注入临时仓库测试以及无关基线/偶发失败影响;改动相关套件已在隔离 Git 配置下重跑并通过。
  • 破坏性变更/迁移说明:白名单仍默认空。Desktop 现在将 ID 与所选语音模型精确匹配的 provider 视为权威配置;重复或不完整条目、无法解析的 key 会直接失败,不再静默回退到环境凭证。补全或移除该条目后可恢复原 DashScope/环境回退。
  • PR 规模:diff 略高于 1,200 行建议值,因为同一安全契约需要同时覆盖 CLI、daemon、Desktop、schema、文档和回归测试。拆分会暂时造成不同出口路径的策略不一致。

关联 Issue

关联 #8286。

@wenshao
wenshao marked this pull request as ready for review August 2, 2026 01:24
@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Overview

Adds security.allowedInsecureVoiceBaseUrls, an empty-by-default exact-match allowlist that lets trusted (User/System/SystemDefaults) configuration opt a voice provider base URL into HTTP and/or private-network egress. The resolved allowInsecureBaseUrl flag travels with the voice config and is enforced at every guard call site (CLI batch transcribeVoiceAudio, daemon streaming voice-ws.ts, Desktop batch/streaming via assertVoiceConfigNetworkAllowed). Workspace-scoped values are stripped before merge (extending the existing allowPrivateNetworkHooks mechanism) and surfaced as a settings warning. Metadata, link-local, unspecified, and mapped/compat loopback addresses stay blocked even when allowlisted.

What looks good

  • The trust model is right: workspace stripping reuses the proven stripWorkspacePrivateNetworkHooks path (now stripWorkspaceSecurityBypasses), with tests for user-scope honor, workspace-scope strip + warning, and user-over-workspace precedence.
  • I verified the new allowInsecureBaseUrl field is consumed at every guard call site on both surfaces — not a dead switch.
  • Always-blocked classes (loopback aliases, ::ffff: mapped forms, 0.0.0.0/::, 169.254/16, 100.100.100.200, AWS IMDSv6 fd00:ec2::254, fe80::/10) are enforced both on IP-literal base URLs and on DNS results, with thorough tests including compact IPv4-compatible IPv6 forms.
  • Desktop test DI (readNoJson when readQwenJson is injected) correctly prevents tests from bleeding into the host machine's real system settings, and the desktop system/system-defaults paths + env overrides match the CLI's storage-paths-lite.ts exactly.
  • Docs, CLI settings schema, and the VS Code companion JSON schema are all updated consistently.

Findings

1. Allowlist entry normalization diverges between CLI and Desktop (Suggestion, cross-surface correctness)

Desktop's normalizeAllowedVoiceBaseUrl (resolve-voice-config.ts) runs entries through normalizeBaseUrl(raw), which defaults the scheme to https:// and appends /v1. The CLI's normalizeAllowedVoiceBaseUrl (services/voice-transcriber.ts) only URL-parses and trims trailing slashes — a scheme-less entry throws in new URL() and is silently dropped, and /v1 is never appended. So the same settings.json entry voice.internal.example/v1 or http://voice.internal.example is honored on Desktop but ignored by the CLI, and the failure mode is a generic "must use an https baseUrl" error with no hint that the allowlist entry didn't match. Suggest: (a) document that entries must be complete URLs with explicit scheme and full path (the docs table currently says "exact normalized" without defining normalization), and (b) emit a settings warning for entries that fail to parse instead of dropping them silently. Aligning the two normalizers would be even better.

2. Desktop credential resolution now hard-fails configs that previously worked (Suggestion, behavior change)

fromQwenSettings previously scanned for any DashScope-compatible provider with a resolvable key and fell back to env credentials otherwise. It now throws for an id-matching provider that is incomplete (missing baseUrl/envKey, or unresolved key) and for duplicate ids, and the throw short-circuits the ?? fromEnv(...) fallback. An existing user with a provider entry whose id happens to equal the selected voice model but who relies on DASHSCOPE_API_KEY from the environment regresses from working → error. The fail-closed behavior is stated in the PR description, so this may be intended — but consider limiting the hard fail to non-DashScope/allowlisted endpoints, or at least calling the break out in release notes.

3. Desktop requires allowlisting even for public HTTPS custom providers; CLI does not (Suggestion, consistency/naming)

On Desktop, any non-DashScope provider — including a public https:// endpoint — must appear in security.allowedInsecureVoiceBaseUrls ("uses a custom baseUrl that is not listed…"), while the CLI accepts a public HTTPS custom baseUrl without any listing. Besides the cross-surface inconsistency, gating a perfectly secure endpoint behind a setting named allowedInsecureVoiceBaseUrls is confusing for operators. Worth either aligning the surfaces or documenting the Desktop-only rule explicitly.

4. Security-guard logic is duplicated with non-identical implementations (Suggestion, maintainability)

isAlwaysBlockedVoiceAddress, isAwsIpv6MetadataAddress, and readIpv4CompatibleIpv6 now exist in two hand-maintained copies (CLI services/voice-transcriber.ts vs desktop net-guard.ts) that already differ: desktop's trailing dotted-quad regex catches embedded-IPv4 forms like 64:ff9b::169.254.169.254 (NAT64-prefixed metadata), while the CLI's ^::ffff:/^::-anchored patterns do not; the hex NAT64 form (64:ff9b::a9fe:a9fe) is missed by both. The link-local checks are written differently (>= 0xfe80 && <= 0xfebf vs (h & 0xffc0) === 0xfe80) even though currently equivalent. For a security guard this duplication is a drift hazard — consider extracting a shared module, or at minimum mirroring the two test suites so a divergence fails CI. Blocking 64:ff9b::/96-embedded always-blocked addresses on both surfaces would close the remaining niche gap.

Verdict

No Critical findings. The security posture is sound: default deny is preserved, the opt-in is exact-match and trusted-scope only, and the always-blocked classes are enforced on both literal and resolved addresses with good test coverage. Findings 1–2 are the ones I'd most like addressed (or explicitly acknowledged) before merge, since both produce silent or surprising failures for operators; 3–4 can be follow-ups.

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🚫 Takeover not engaged: fork takeover requires the PR author to hold write access on this repository (author rockybot2026 currently: read). A maintainer can adopt the PR instead: snapshot the head into an in-repo branch, open a new PR (commit authorship is preserved), and take that over.

中文说明

🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 rockybot2026 当前为:read)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 PR 执行接管。

@rockybot2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 797499a:

  1. CLI and Desktop now use the same strict allowlist contract: entries and exact model-provider URLs must include an explicit scheme and full path; only URL serialization and trailing slashes are normalized. The docs/schema and rejection messages now spell this out and point operators to security.allowedInsecureVoiceBaseUrls.
  2. Desktop exact-ID provider failures remain intentionally fail-closed. Falling back to unrelated environment credentials can cross a provider or regional boundary, so duplicate/incomplete entries and missing keys now require the operator to complete or remove the exact entry. The PR description and design doc now call out this migration behavior explicitly.
  3. Desktop now accepts public HTTPS custom providers without an insecure allowlist entry, matching CLI behavior. HTTP/private endpoints still require an exact trusted entry and the runtime network guard.
  4. Both guards now decode dotted and hexadecimal IPv4-mapped IPv6 forms. Trusted RFC1918 mapped addresses work; mapped loopback/metadata stay blocked; mapped public addresses are not misclassified. I opened refactor(voice): keep CLI and Desktop address guards in sync #8361 for shared policy vectors/NAT64 and ci(desktop): gate voice security tests on pull requests #8360 for a repository-owned Desktop voice CI gate.

Final local verification: CLI focused suites 246/246, Desktop voice suites 103/103, Desktop server-core TypeScript check, root lint/typecheck/build, and pre-commit formatting/lint all pass. I also iterated three completed Qwen local /review quick passes; the latest report has no findings.

@rockybot2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/30746387737)._

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 2, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [ubuntu-latest / Java 11] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [ubuntu-latest / Java 11] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@rockybot2026
rockybot2026 enabled auto-merge August 2, 2026 13:09
@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

✅ AutoFix round 16 finished — view run. See this round's report below.

中文说明

✅ AutoFix 第 16 轮已完成 —— 查看运行。本轮报告见下方。

…#8350)

Deduplicate the IPv6-transition unwrapping sequence (mapped, compatible,
NAT64, dotted-quad) that was repeated verbatim between isPrivateNetworkIp
and isAlwaysBlockedVoiceAddress on both CLI and Desktop surfaces. A single
unwrapIpv6TransitionStep helper now yields the next canonical address (or
'blocked' for unrecognized ::ffff: forms), and each predicate recurses
through it, preserving the exact re-check semantics at every unwrap level.
@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Round summary

Addressed the two remaining automated-reviewer suggestions (round 2) about the duplicated IPv6-transition unwrapping ladder in the voice network guards.

Feedback points

Finding Decision Details
[rc:3699042248] CLI: duplicated unwrap ladder between isPrivateNetworkIp and isAlwaysBlockedVoiceAddress Implemented Extracted unwrapIpv6TransitionStep helper in voice-transcriber.ts. Both predicates now share the same mapped → dotted-quad → compatible → NAT64 → ::ffff:-catch-all sequence, eliminating the drift surface. Recursion semantics preserved exactly — each predicate re-enters itself on the unwrapped address so loopback/transition/terminal checks are re-applied at every level.
[rc:3699042250] Desktop: same duplication in net-guard.ts Implemented Mirrored the same extraction in the Desktop guard, including the Desktop-specific ipv4Embedded regex step. The previously inconsistent step order between the two Desktop predicates (mapped-before-compatible vs compatible-before-mapped) is now unified — the order was semantically irrelevant (disjoint domains) but the inconsistency was a readability trap.
Cross-package shared module (both findings suggested it) Declined CLI (packages/cli, npm/vitest) and Desktop (packages/desktop, bun) use different package managers and runtimes. A shared module would require a new cross-package dependency for ~30 lines of pure logic. The within-file deduplication eliminates the actionable drift risk (two predicates diverging within one file); the cross-surface mirroring is already enforced by identical test vectors on both sides.

All earlier findings (rc:3697507205 through rc:3698686266) were already resolved in prior commits (797499a, 0692403, 46c9b1a, 0741498) and re-verified as holding.

Changed files

  • packages/cli/src/services/voice-transcriber.ts — extracted unwrapIpv6TransitionStep, refactored both predicates (+31/−32)
  • packages/desktop/packages/server-core/src/voice/net-guard.ts — mirrored extraction (+30/−31)

Conflict notes

No conflicts (--conflict false).

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check (both touched files) — passed
  • vitest run src/ui/voice/voice-transcriber.test.ts (CLI, touched) — 51 passed
  • vitest run src/config/settings.test.ts — 12 failed / 146 passed (pre-existing failures, confirmed identical on HEAD without this change; unrelated to voice guards)
  • Desktop voice suites — not runnable via npm (bun tests outside npm workspaces, tracked in ci(desktop): gate voice security tests on pull requests #8360); verified statically
中文说明

轮次总结

处理了自动化审查器(第 2 轮)剩余的两条建议,关于语音网络守卫中重复的 IPv6 过渡机制解包阶梯。

反馈要点

发现 决定 详情
[rc:3699042248] CLI:isPrivateNetworkIp 与 isAlwaysBlockedVoiceAddress 之间重复的解包阶梯 已实现 在 voice-transcriber.ts 中提取了 unwrapIpv6TransitionStep 辅助函数。两个谓词现在共享同一条 mapped → 点分十进制 → compatible → NAT64 → ::ffff: 兜底序列,消除了漂移面。递归语义完全保留——每个谓词在解包后的地址上重新进入自身,因此 loopback/过渡/终态检查在每一层都会被重新应用。
[rc:3699042250] Desktop:net-guard.ts 中的相同重复 已实现 在 Desktop 守卫中镜像了相同的提取,包括 Desktop 特有的 ipv4Embedded 正则步骤。此前两个 Desktop 谓词之间不一致的步骤顺序(mapped 在 compatible 之前 vs compatible 在 mapped 之前)现已统一——该顺序在语义上无关(定义域不相交),但不一致是可读性陷阱。
跨包共享模块(两条建议均提及) 已拒绝 CLI(packages/cli,npm/vitest)和 Desktop(packages/desktop,bun)使用不同的包管理器和运行时。共享模块需要为约 30 行纯逻辑引入新的跨包依赖。文件内去重已消除了可操作的漂移风险(同一文件内两个谓词分歧);跨平台镜像已由两侧相同的测试向量强制保证。

所有早期发现(rc:3697507205 至 rc:3698686266)已在先前提交(797499a93、06924038d、46c9b1a91、074149854)中解决,并经重新验证仍然成立。

变更文件

  • packages/cli/src/services/voice-transcriber.ts — 提取 unwrapIpv6TransitionStep,重构两个谓词(+31/−32)
  • packages/desktop/packages/server-core/src/voice/net-guard.ts — 镜像提取(+30/−31)

冲突说明

无冲突(--conflict false)。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check(两个变更文件)— 通过
  • vitest run src/ui/voice/voice-transcriber.test.ts(CLI,已触及)— 51 通过
  • vitest run src/config/settings.test.ts — 12 失败 / 146 通过(预先存在的失败,已确认在不包含本次变更的 HEAD 上完全相同;与语音守卫无关)
  • Desktop 语音套件 — 无法通过 npm 运行(bun 测试位于 npm workspace 之外,见 ci(desktop): gate voice security tests on pull requests #8360);已静态验证

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round — no action needed

The latest review round on PR #8350 contains no actionable feedback:

  • Automated review: COMMENTED with zero findings. The reviewer noted the desktop bun test suites could not be executed in its harness (tracked in ci(desktop): gate voice security tests on pull requests #8360) and reviewed that half statically only — informational, not a defect.
  • Inline comments: none.
  • Sandboxed verification: passed — 62/62 scripted assertions, 247/247 targeted gate tests, verdict merge-ready.
  • Failed checks: none.
  • Still-red checks: none.

No code changes were made this round.

中文说明

Autofix 审查轮次 — 无需操作

PR #8350 的最新审查轮次不包含可操作的反馈:

  • 自动审查:以 COMMENTED 结束,发现数量为零。审查者指出其测试环境无法运行桌面端 bun 测试套件(已在 ci(desktop): gate voice security tests on pull requests #8360 中跟踪),仅对该部分进行了静态审查——属于信息说明,非缺陷。
  • 行内评论:无。
  • 沙箱验证:通过 — 62/62 条脚本断言、247/247 条定向门禁测试,判定为 merge-ready(可合入)。
  • 失败检查:无。
  • 持续红色检查:无。

本轮未做任何代码变更。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (7200000ms)) (attempt 13/100) — it will retry on the next scan.

What I found before stopping:
Qwen failed during address-review: timeout (7200000ms).

See the Qwen Autofix agent step logs for model/tool output.

Run log: https://github.com/QwenLM/qwen-code/actions/runs/31020135934


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Overview

Adds security.allowedInsecureVoiceBaseUrls — an empty-by-default, exact-match allowlist that lets trusted scopes (User/System/SystemDefaults) opt a voice provider base URL into cleartext HTTP and/or private-network egress. The decision is computed once in each surface's config resolver and threaded as allowInsecureBaseUrl into the shared network guard, so CLI batch, CLI/daemon streaming, and Desktop batch/streaming all apply the same verdict.

The security core is genuinely good work:

  • Fail-closed default is preserved. Empty list, workspace scope stripped before merge (stripWorkspaceSecurityBypasses) plus a settings warning, exact normalized-URL match with no wildcards or suffix matching.
  • The always-blocked floor survives the opt-in. Metadata (169.254.169.254, 100.100.100.200, fd00:ec2::254), link-local, unspecified, and loopback stay blocked even when listed — and the IPv6 transition-address handling (::ffff:, IPv4-compatible, 64:ff9b::/96 NAT64, with 64:ff9b:1::/48 / 2001::/23 / 2002::/16 blocked outright) is more thorough than what it replaces. I spot-checked the decoders against WHATWG URL canonicalization (0177.0.0.1, 2130706433, 0:0:0:0:0:ffff:a9fe:a9fe, 64:ff9b::10.0.0.8, ::ffff:0:7f00:1) and they classify correctly, with the ::ffff:-prefix catch-all failing closed on shapes the decoders don't recognize.
  • Rejecting embedded credentials instead of stripping them (https://[email protected]/...) is a real fix, not just cleanup.
  • check:voice-guard-sync is the right instinct: a comment is not a mechanism, and the CLI/desktop guards must not drift.

Below are the things I'd want addressed.


1. Desktop: the ambiguity check fires before the public-HTTPS fall-through, breaking dictation for configs that work today

packages/desktop/packages/server-core/src/voice/resolve-voice-config.ts:532

if (distinct.length > 1) {
  throw new Error(`Voice model '${voiceModel}' is ambiguous. ${PROVIDER_ENTRY_REMEDY}`);
}
...
if (isPublicHttps && !allowInsecureBaseUrl) {
  return undefined;   // legacy fall-through
}

The throw is unconditional on classification, so it runs before the entry is ever classified. That contradicts the design doc's own contract:

Public HTTPS entries keep the legacy fall-through (OAuth, then the shared DashScope provider, then environment credentials), preserving the pre-allowlist credential precedence for existing installs.

That guarantee only holds for the single-entry case. Concrete break — an OAuth-signed-in desktop user with two public HTTPS entries sharing the voice model id:

"modelProviders": {
  "openai":    [{ "id": "qwen3-asr-flash", "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",      "envKey": "DASHSCOPE_API_KEY" }],
  "dashscope": [{ "id": "qwen3-asr-flash", "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "envKey": "DASHSCOPE_API_KEY" }]
}

Before this PR desktop ignored id-matching entirely and OAuth resolved. After it, dictation dies with Voice model 'qwen3-asr-flash' is ambiguous. — and the remedy text asks the user to delete a provider entry that has nothing to do with voice network policy. Neither entry needs a policy decision, so nothing is gained by failing.

The design doc widens this further and acknowledges it: because desktop scans provider groups protocol-agnostically while the CLI only sees groups whose id resolves to a protocol, a duplicate in a custom group with no providerProtocol mapping hard-fails desktop while the CLI resolves the model normally. Documenting a break isn't the same as bounding it — this one takes out dictation entirely on the credential path, not just the policy path.

Suggested fix: classify first, then decide. Only raise ambiguity when at least one distinct entry actually needs a policy decision (allowlisted / cleartext / private / loopback); otherwise return undefined and let the legacy chain run, exactly as the single-entry public-HTTPS case does.

The same ordering applies to the baseUrl must be a string (:539), embedded-credentials (:560), and envKey must be a string (:619) throws. Those are more defensible (genuinely malformed entries), but they're equally invisible to the fall-through contract and deserve a deliberate call either way.

Test gap: the only ambiguity test (resolve-voice-config.test.ts:1390) uses two private, allowlisted URLs — precisely the case where failing closed is correct. There's no test pinning what happens with two public HTTPS duplicates plus valid OAuth, which is why this slipped through.

2. None of the desktop changes run in CI

voice-ws-handler.test.ts → voice-ws-handler.isolated.ts is a legitimate rename (packages/desktop/package.json:43 already has the .isolated.ts runner loop, and mock.module('ws', ...) genuinely needs process isolation). But I checked every workflow under .github/workflows/ and none invokes bun test or bun run test — CI covers packages/desktop-shell (Rust/Tauri) and check:desktop-isolation, not the bun workspace.

That means the desktop half of this PR — the 235-line net-guard.ts rewrite, the 200→809-line resolver, and 1,761 lines of new tests — is verified only by local runs. Note also that the PR's own verification command, bun test packages/server-core/src/voice, does not pick up .isolated.ts (bun's default pattern is *.test.ts); only bun run test from the desktop root does. Worth confirming that file was actually exercised.

The new check:voice-guard-sync step is the one desktop-touching thing that does run in CI, which makes item 3 more load-bearing than it looks.

3. normalizeMirroredCode strips braces, so the drift guard is semantics-blind

scripts/check-voice-guard-sync.js:271

return stripComments(text).replace(/\s+/g, '').replace(/^export/, '').replace(/[{}]/g, '');

Dropping every { and } means block structure is erased: if (x) { a(); b(); } and if (x) a(); b(); normalize identically, as do f({a:1},{b:2}) and f({a:1,b:2}), and `${a}${b}` and `${ab}`. For a guard whose stated job is "the classification decides whether voice audio may use an insecure or private endpoint," normalizing away control flow is the wrong trade.

I understand why it's there — desktop writes if (isIP(host) !== 6) return host; where the CLI writes the braced form. Two cleaner options: format both extracted units with prettier before comparing, or just make the two files agree on brace style and drop the strip.

Two more gaps in the same script:

  • The units list is hand-maintained, so a new helper added to only one guard is invisible. Consider asserting that every top-level function declaration in net-guard.ts appears in units — that way adding a classifier forces a list update instead of silently escaping the check.
  • extractTopLevelUnit (:244) keys on a column-0 } terminator, so it's coupled to prettier's exact output. Fine in practice, but worth a comment noting the dependency.

The stripComments tokenizer itself (regex-literal detection via preceding-token heuristics) is nicely done and correctly avoids eating http:// inside strings.

4. A hand-copied dotenv@17 grammar with no drift guard

resolve-voice-config.ts:159 copies dotenv's LINE regex verbatim so ~/.qwen/.env yields identical values on both surfaces. The reasoning is sound and the comment is honest, but this is explicitly excluded from check-voice-guard-sync.js — and it feeds both credential lookup and $VAR interpolation of allowlist entries. A dotenv bump in the npm workspace silently diverges desktop.

dotenv is already present in the npm workspace; a fixture-corpus parity test (same input → same output as the real parser) would close this the same way check:voice-guard-sync closes the net-guard mirror.

Related, and correctly documented in the design doc but worth restating for whoever operates this: because settings pass through env interpolation, anything that controls the process environment or ~/.qwen/.env can supply an allowlist entry. That is a meaningfully wider trusted surface than "root-owned /etc/qwen-code/settings.json," and it's the one line in the threat model I'd want a managed-deployment operator to read.

5. Scope

27 files, ~5,000 added lines, for a feature the title describes as an allowlist. The desktop resolver alone grew 200 → 809 lines and picked up System/SystemDefaults settings reading, JSONC parsing (new strip-json-comments dep), .env parsing, env-var interpolation, prototype-key filtering, provider-group merge mirroring, and platform-specific system-path resolution — all CLI-parity work that is adjacent to the allowlist rather than required by it.

The PR body's justification (splitting would leave egress paths with inconsistent policy) is fair for the guard changes, which genuinely must land together. It doesn't extend to the desktop trusted-settings/parity layer, which could ship separately and get its own review attention. Given item 2, that layer is both the largest and the least-verified part of the diff.

Smaller items

  • packages/desktop/bun.lock: only the dependency line changed — verified [email protected] already resolves at :3164, so the lockfile is consistent. No action.
  • Docs overstate the path requirement. settings.md and the schema say each entry "must include ... the full path (for example, /v1)", but the code only requires an exact match — a root-path entry legitimately matches a root-path provider. "Must match the provider's complete path exactly" would be accurate.
  • resolveDesktopVoiceConfig:771 does new URL(creds.baseUrl) unguarded. normalizeBaseUrl's parse-failure branch (:76) can return an unparseable string, which would surface as a raw TypeError instead of one of the file's careful operator-facing messages. Narrow, but cheap to wrap.
  • Behavior change worth a release note: the CLI now rejects https://user:pass@host/... rather than stripping userinfo. Correct call, but anyone relying on the old stripping gets a hard failure. It's in the design doc; make sure it reaches the changelog.
  • Nice touch: the Object.hasOwn guard on envKey (voice-transcriber.ts:1107, resolve-voice-config.ts:479) preventing constructor/toString from reaching .trim() as a function — with a test that loops the three prototype members. Good instinct.
  • Test hygiene: vi.stubEnv('DASHSCOPE_API_KEY', '') in voice-transcriber.test.ts fixes a real local-vs-CI divergence (CI already clears it at ci.yml:449). Good catch, and the comment explains why.

Verdict

The network-policy core is careful, well-reasoned, and better tested than what it replaces — the address classification in particular is a clear improvement, and the mechanical drift guard is the right shape even if its normalization needs tightening.

Blocking on item 1: the unconditional ambiguity throw breaks a plausible existing desktop config that has nothing to do with this feature, and it contradicts the PR's own stated fall-through contract. It needs the check moved after classification plus a test covering public-HTTPS duplicates with valid OAuth.

Items 2–4 are about how this stays correct after merge rather than whether it's correct now, but item 2 (no desktop CI) makes them harder to defer — right now a desktop-side regression in this security path would ship silently.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 14/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 14/100 轮)。改动内容与我反驳保留之处如下:

Autofix review round — PR #8350

This round triaged @wenshao's issue-level review. Critical-only mode is active, and the previous round exhausted the time budget, so per the budget rule this round fixed the smallest blocking subset (item 1) and deferred the rest with recorded reasons. There were no inline comments this round, so there are no thread resolutions or reply comments; every disposition is recorded here.

Item 1 (blocking) — ambiguity throw fires before classification — FIXED

Commit 59e1315dd restructures the desktop resolver exactly as suggested: classify first, then decide.

  • Per-entry validation + classification was extracted from fromExactModelProvider into classifyVoiceProviderEntry. The Voice model '...' is ambiguous error now fires only when at least one classified entry actually needs a policy decision (allowlisted / cleartext / private / loopback). Duplicate matches that all keep the legacy fall-through (public HTTPS, unallowlisted) now return control to the legacy chain (OAuth → DashScope provider → environment), exactly like the single-entry public-HTTPS case — including the reviewer's concrete scenario of two cross-group public HTTPS entries sharing the voice model id for an OAuth-signed-in user.
  • Deliberate call on the malformed-entry throws the reviewer flagged: baseUrl must be a string and embedded-credentials entries still fail closed regardless of what other entries exist — a corrupt or credential-bearing entry must never silently reroute where audio goes. Missing/unparseable baseUrls keep their per-entry warn-and-ignore fall-through, and the envKey must be a string check stays behind the policy gate, so public-HTTPS fall-through never validates envKey (same as the single-entry path today).
  • Test gap closed with two new tests: the reviewer's exact case (two public HTTPS duplicates + valid OAuth → OAuth resolves, no ambiguity error) and the boundary (a public HTTPS entry plus an allowlisted private duplicate still fails closed with the ambiguity error).
  • The design doc's ambiguity contract was updated to match: duplicates are ambiguous only when a matching entry needs a policy decision; otherwise the whole set keeps the legacy fall-through.
  • Verification surfaced a latent bug in the PR's own new test file: it used spyOn without importing it from bun:test (bun does not provide spyOn as a global), so the "falls through and warns" test failed on any real bun test run. The import is fixed; this is further evidence for item 2.

Item 2 — desktop changes run in no CI — deferred (follow-up)

Adding a CI workflow for the bun workspace is infrastructure needing maintainer decisions (runner capacity, bun toolchain), not a Critical fix, and is out of scope for a Critical-only round under the budget warning. Evidence gathered this round confirms the gap: (a) the spyOn global bug above shipped despite the PR's stated local verification; (b) the combined bun test packages/server-core/src/voice run has 6 failures that reproduce identically on the pre-change tree — 5 in voice-stream-session.test.ts caused by its un-restored mock.module('../runtime/platform') leaking across files in bun's single-process run (the same hazard that made this PR rename voice-ws-handler.test.ts to .isolated.ts), plus the resolve-config warn-capture test that only fails under that leakage; (c) .isolated.ts files run only via bun run test from the desktop root, not the PR's bun test packages/server-core/src/voice command. Suggested follow-up issue: desktop bun-test CI step + rename voice-stream-session.test.ts to .isolated.ts.

Item 3 — normalizeMirroredCode strips braces — deferred (follow-up)

Hardening of the drift guard, not a current-behavior defect (the guard passes and neither mirrored file changed this round). Deferred under the budget warning; the reviewer's suggested fixes (prettier-both-sides, or align brace style and drop the strip; plus asserting every top-level function appears in units) are recorded for the follow-up.

Item 4 — hand-copied dotenv grammar without drift guard — deferred (follow-up)

Same reasoning: a fixture-corpus parity test against the real dotenv parser is valuable post-merge insurance, not a Critical fix this round.

Item 5 — scope — deferred to maintainer

Whether to split the desktop trusted-settings/parity layer into a separate PR is a scope call for the maintainer; it cannot be decided here, and at this stage of the PR the guard changes must land together regardless.

Smaller items

  • bun.lock: reviewer verified it is consistent — no action.
  • Docs "full path" wording: deferred. The wording lives in both settings.md and the settings-schema description, so a fix requires touching settingsSchema.ts and regenerating the committed schema artifact — a separate small change, not this round's blocking subset.
  • Unguarded new URL(creds.baseUrl) at the end of resolveDesktopVoiceConfig: deferred (error-message quality on an edge path, not Critical).
  • Release note for the embedded-credentials rejection behavior change: noted for the release process.
  • Object.hasOwn guards and the vi.stubEnv test hygiene: no action needed (positive remarks).

Verification

Commands actually run and their results:

  • bun test packages/server-core/src/voice/resolve-voice-config.test.ts (desktop, focused) — 70 pass / 0 fail (baseline before this round: 67 pass / 1 fail on the missing-spyOn ReferenceError). bun 1.3.14 was installed via npm i -g bun; desktop workspace deps are not installed on this runner, and [email protected] resolves from the root node_modules at the exact locked version.
  • bun test ./packages/server-core/src/voice/voice-ws-handler.isolated.ts — 15 pass / 0 fail (confirming the renamed isolated file runs and passes).
  • bun test packages/server-core/src/voice (combined directory run) — 129 pass / 6 fail; the same 6 failures reproduce identically on the pre-change tree (verified against a git archive baseline), 5 from the pre-existing mock.module cross-file interference in voice-stream-session.test.ts and 1 that passes solo — see item 2.
  • Focused tsc on packages/desktop/packages/server-core (surrogate; desktop workspace deps unavailable): diffed error sets between this tree and the pre-change baseline — no new errors; the baseline's Cannot find name 'spyOn' is removed by the import fix; resolve-voice-config.ts itself has zero errors in both trees. Remaining errors are identical missing-desktop-dependency noise.
  • node scripts/check-voice-guard-sync.js — pass.
  • npm run build (root) — pass.
  • npm run typecheck (root, after build; a first run before build failed with stale packages/core/dist TS6305 project-reference errors unrelated to this diff) — pass.
  • npm run lint (root) — pass (packages/desktop/** is eslint-ignored and prettier-ignored at root, so the root gate does not reach the changed files; the desktop-side checks above cover them).
  • Settings schema regeneration: not applicable (no settings source changed this round).
中文说明

Autofix 评审轮次 — PR #8350

本轮分诊了 @wenshao 的 issue 级评审。当前处于 Critical-only 模式,且上一轮已耗尽时间预算,因此按照预算规则,本轮只修复了最小的阻塞子集(条目 1),其余条目均记录原因后延后处理。本轮没有行内评论,因此没有线程解决或回复评论操作;所有处置结论均记录在此。

条目 1(阻塞)— 歧义检查在分类之前抛出 — 已修复

提交 59e1315dd 按照评审建议重构了桌面端解析器:先分类,再决策。

  • 将每个条目的校验与分类逻辑从 fromExactModelProvider 中提取到 classifyVoiceProviderEntry。Voice model '...' is ambiguous 错误现在只在至少一个已分类条目确实需要策略决策(allowlist 名单内 / 明文 / 私有网络 / loopback)时才抛出。所有保持遗留回退(fall-through)的重复匹配项(公共 HTTPS、未列入名单)现在会交还给遗留凭据链(OAuth → DashScope provider → 环境变量),与单条公共 HTTPS 条目完全一致 —— 包括评审给出的具体场景:OAuth 已登录用户名下、两个跨 provider 组共享同一语音模型 id 的公共 HTTPS 条目。
  • 对评审指出的"格式错误条目"抛错做出明确决策:baseUrl must be a string 与内嵌凭据(embedded credentials)条目无论是否存在其他条目都会 fail closed —— 损坏或携带凭据的条目绝不能悄悄改变音频的去向。缺失/无法解析的 baseUrl 保持原有的"逐条目警告并忽略"的回退行为;envKey must be a string 检查保持在策略判定之后,因此公共 HTTPS 回退路径永远不会校验 envKey(与当前单条目路径一致)。
  • 用两个新测试补齐了测试缺口:评审给出的原始场景(两个公共 HTTPS 重复条目 + 有效 OAuth → 解析出 OAuth,不报歧义错误)以及边界场景(一个公共 HTTPS 条目 + 一个已列入名单的私有重复条目,仍然 fail closed 报歧义错误)。
  • 设计文档中的歧义约定已同步更新:仅当某个匹配条目需要策略决策时才判定为歧义;否则整组条目保持遗留回退。
  • 验证过程中还发现了本 PR 新增测试文件中的一个潜在缺陷:文件使用了 spyOn 却没有从 bun:test 导入(bun 并不把 spyOn 作为全局变量提供),导致 "falls through and warns" 测试在任何真实的 bun test 运行下都会失败。已修复该导入;这也是条目 2 的又一佐证。

条目 2 — 桌面端改动没有任何 CI 覆盖 — 延后(跟进 issue)

为 bun workspace 增加 CI 工作流属于基础设施改动,需要维护者决策(runner 容量、bun 工具链),不是 Critical 修复,且在预算警告下超出本轮范围。本轮收集的证据证实了该缺口:(a) 上述 spyOn 全局变量缺陷在 PR 声明过本地验证的情况下仍然被合入;(b) 合并运行 bun test packages/server-core/src/voice 时出现 6 个失败,且在改动前的基线上完全相同地复现 —— 其中 5 个来自 voice-stream-session.test.ts 中未还原的 mock.module('../runtime/platform') 在 bun 单进程运行中跨文件泄漏(与本 PR 把 voice-ws-handler.test.ts 改名为 .isolated.ts 所规避的风险完全相同),另外 1 个 resolve-config 警告捕获测试仅在该泄漏下失败、单独运行通过;(c) .isolated.ts 文件只能通过桌面端根目录的 bun run test 运行,PR 所用的 bun test packages/server-core/src/voice 命令并不会执行它。建议跟进 issue:增加桌面端 bun-test CI 步骤 + 将 voice-stream-session.test.ts 改名为 .isolated.ts。

条目 3 — normalizeMirroredCode 去除花括号 — 延后(跟进 issue)

这是对漂移防护脚本的加固,不是当前行为缺陷(防护检查通过,且本轮两个镜像文件都未改动)。按预算警告延后;评审建议的修复方向(两侧先过 prettier,或统一花括号风格后去掉 strip;以及断言每个顶层函数都出现在 units 列表中)已记录待跟进。

条目 4 — 手工复制的 dotenv 语法没有漂移防护 — 延后(跟进 issue)

同理:针对真实 dotenv 解析器的 fixture 语料对等测试是有价值的合入后保险,但不是本轮的 Critical 修复。

条目 5 — 范围 — 交由维护者决策

是否把桌面端 trusted-settings/parity 层拆分为单独 PR 属于范围决策,应由维护者决定;此处无法代为决定,且以 PR 当前阶段而言,guard 相关改动无论如何都必须一起合入。

较小条目

  • bun.lock:评审已核实一致 — 无需处理。
  • 文档中 "full path" 措辞:延后。该措辞同时存在于 settings.md 与 settings-schema 描述中,修复需要改动 settingsSchema.ts 并重新生成已提交的 schema 产物 — 属于独立的小改动,不属于本轮阻塞子集。
  • resolveDesktopVoiceConfig 末尾未加防护的 new URL(creds.baseUrl):延后(边缘路径上的报错质量问题,非 Critical)。
  • 内嵌凭据拒绝这一行为变更的 release note:已记录,交由发布流程处理。
  • Object.hasOwn 防护与 vi.stubEnv 测试卫生处理:无需操作(正面评价)。

验证

实际执行的命令及结果:

  • bun test packages/server-core/src/voice/resolve-voice-config.test.ts(桌面端,聚焦运行)— 70 通过 / 0 失败(本轮改动前基线:67 通过 / 1 失败,失败原因正是缺失 spyOn 的 ReferenceError)。bun 1.3.14 通过 npm i -g bun 安装;本 runner 未安装桌面端 workspace 依赖,[email protected] 从根 node_modules 解析,版本与 lockfile 完全一致。
  • bun test ./packages/server-core/src/voice/voice-ws-handler.isolated.ts — 15 通过 / 0 失败(确认改名后的 isolated 文件可运行且通过)。
  • bun test packages/server-core/src/voice(目录合并运行)— 129 通过 / 6 失败;同样的 6 个失败在改动前基线上完全相同地复现(通过 git archive 基线验证),其中 5 个来自 voice-stream-session.test.ts 既有的 mock.module 跨文件干扰,1 个单独运行即通过 — 见条目 2。
  • 针对 packages/desktop/packages/server-core 的聚焦 tsc(替代方案;桌面端 workspace 依赖不可用):对比当前树与改动前基线的错误集合 — 无新增错误;基线中的 Cannot find name 'spyOn' 已被导入修复消除;resolve-voice-config.ts 本身在两棵树中均零报错。其余错误为完全一致的"缺失桌面端依赖"噪音。
  • node scripts/check-voice-guard-sync.js — 通过。
  • npm run build(根)— 通过。
  • npm run typecheck(根,在 build 之后执行;首次先于 build 运行时因 packages/core/dist 过期出现 TS6305 项目引用错误,与本 diff 无关)— 通过。
  • npm run lint(根)— 通过(packages/desktop/** 在根级被 eslint 与 prettier 忽略,根部门禁不覆盖改动文件;桌面端侧由上述检查覆盖)。
  • settings schema 重新生成:不适用(本轮未改动任何 settings 源)。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(voice): support trusted private ASR base URLs (#8350)

Reviewed at 59e1315. Scope: the PR diff only (27 files, +5142/-219).

Overview

Adds security.allowedInsecureVoiceBaseUrls — an empty-by-default, exact-full-URL allowlist that lets a voice provider endpoint use cleartext HTTP or a private-network address. The exception is stripped from Workspace scope (with a settings warning), threaded as allowInsecureBaseUrl through the resolved voice config, and enforced identically on CLI batch, CLI/daemon streaming, and Desktop batch/streaming. The address classifier is rewritten on both surfaces (IPv4-mapped / IPv4-compatible / NAT64 decoding, 6to4 + Teredo + local-use-NAT64 blocking, AWS IPv6 IMDS), a mechanical drift guard (scripts/check-voice-guard-sync.js) is wired into CI, and the Desktop resolver grows a mini CLI-settings loader (System/SystemDefaults paths, JSONC, env interpolation, scope merge).

No Critical findings. CI is green, the drift guard passes against the head files (I ran checkMirrorSet on the fetched 59e1315 sources — 0 drift in both mirror sets), and I spot-checked the address classifier by loading net-guard.ts directly: ::ffff:127.0.0.1, ::ffff:7f00:1, ::127.0.0.1, 64:ff9b::7f00:1 all classify as always-blocked/loopback; ::ffff:192.168.1.1 and 64:ff9b::a00:5 as private-but-allowlistable; 169.254.169.254, fd00:ec2::254, 2001::1, 2002:c0a8:101::1, 64:ff9b:1::a00:5, ::, 0.0.0.0 as always-blocked. That matches the documented contract.

Things done well

  • Trusted-scope stripping is at the merge boundary (stripWorkspaceSecurityBypasses), not at the read site, so every consumer of settings.merged inherits it — and the untrusted-workspace path drops the whole object anyway.
  • The decision is computed once in resolveVoiceTranscriptionConfig / resolveDesktopVoiceConfig and carried on the config object, so the four egress paths cannot disagree.
  • isAlwaysBlockedVoiceAddress is deliberately not gated on the allowlist — metadata/link-local/unspecified/transition ranges stay blocked even for a listed URL, on both the literal and the DNS path.
  • Rejecting embedded credentials rather than stripping them is the right call; the comment explaining why (https://[email protected]/… already resolved evil.com) is exactly the kind of why that earns a comment.
  • Adding a mechanical drift check instead of relying on a "keep in sync" comment.

Suggestions

1. The drift guard is brace-blind, so real control-flow drift passes silently.
normalizeMirroredCode does .replace(/\s+/g,'').replace(/^export/,'').replace(/[{}]/g,''). Stripping every brace makes

if (x) { a(); b(); }   //  vs.   if (x) a(); b();

compare equal — I confirmed this against the script's own exports. It also strips braces inside string/template/regex literals, so `${a}${b}` ≡ `$a$b` and x{2}y ≡ x2y.

The brace strip is currently load-bearing: four units (normalizeIpAddress, isAwsIpv6MetadataAddress, isAlwaysBlockedVoiceAddress, isLoopbackVoiceAddress) differ only in single-statement brace style between the two files. The cheap fix is to brace the desktop side (if (isIP(host) !== 6) return host; → braced) so the strip can be dropped entirely; prettier won't undo that. Otherwise, emit {/} as tokens from the tokenizer stripComments already contains and compare token streams.

2. Desktop tests don't run in PR CI. bun test for packages/desktop only appears in desktop-release.yml — ci.yml runs check:desktop-isolation and now check:voice-guard-sync, but never the 109 desktop voice tests this PR adds. The drift guard catches copy drift; it doesn't catch a regression in resolve-voice-config.ts, which is where most of the new security logic lives (ambiguity resolution, scope merge, env interpolation, fail-closed paths). Given this is network-egress policy, a CI step running at least bun test packages/server-core/src/voice would be worth the minute. Related: voice-ws-handler.test.ts → .isolated.ts is a legitimate use of the existing convention (the find-loop in packages/desktop test), but it inherits the same gap.

3. The hand-copied dotenv grammar should be the real dependency. DOTENV_LINE + parseEnvFileContent copy dotenv@17's regex verbatim and are guarded only by a comment — not by check-voice-guard-sync.js. The PR already adds an npm dep to server-core (strip-json-comments), so adding dotenv and calling dotenv.parse() costs the same and removes a permanent drift liability against an upstream package the CLI upgrades independently.

4. Malformed allowlist entries fail silently. normalizeAllowedVoiceBaseUrl returns undefined for an entry missing a scheme or carrying userinfo, on both surfaces, with no diagnostic. The operator's outcome is the generic "add its exact complete normalized URL (X) to security.allowedInsecureVoiceBaseUrls" error — while the entry they already added sits there unmatched. Since the docs make "explicit scheme, full path, no wildcards" a hard requirement, a warning naming the unparseable entries (like the existing workspace-scope warning) would turn the most likely misconfiguration into a one-line fix.

5. Desktop dedups same-ID providers on the raw baseUrl, before normalization. In fromExactModelProvider, distinct is keyed on match.baseUrl || '', so http://gw/v1 and http://gw/v1/ survive as two entries, classify to the same normalized baseUrl, and then trip classified.length > 1 && classified.some(needsVoicePolicyDecision) → hard "is ambiguous" failure for what is one endpoint. Deduping on the classified entry.baseUrl instead would fix it without touching the ambiguity semantics.

6. settings.md table cell. The new security row is a ~1,100-character description in a single markdown cell, which pushes the source line past 1,400 columns and renders as an unusably wide table. Consider a one-line summary in the cell plus a short prose subsection below the table for the matching rules, blocked ranges, and the WebSocket-derivation caveat.

Risks worth flagging in release notes

  • CLI: hostnames that resolve to loopback are now always blocked, with no allowlist escape hatch. Previously isPrivateNetworkIp returned false for loopback, so a DNS alias for a local ASR server (asr.localtest.me, an /etc/hosts entry) worked. It now fails closed. The design doc calls this out and the error message names the accepted spellings, so the behavior is deliberate — but it's the one break here that an operator cannot configure around, and it's the case most likely to hit an existing local-dev setup. Worth a release note, and arguably worth letting an exactly-allowlisted URL resolve to loopback.
  • Desktop: a provider whose id matches the selected voice model is now authoritative and fails closed on duplicates, unsupported scheme, missing allowlist match, or unresolved envKey — previously such an entry was ignored unless DashScope-compatible and voice fell back to OAuth. Documented in the PR body; belongs in the changelog too.
  • CLI: embedded credentials in a voice baseUrl now throw instead of being stripped.
  • Scope. The Desktop resolver has effectively grown a second implementation of the CLI settings loader — system-path resolution, JSONC, env interpolation, deep-merge, prototype-key filtering, legacy v5 provider unwrapping — and mergeModelProviders mirrors customDeepMerge's actual behavior, which contradicts the mergeStrategy: REPLACE declared in settingsSchema. The comment acknowledges this, but it means the mirror is pinned to a CLI implementation detail that a future fix will silently break, and none of it is covered by the mechanical drift check. If this lands as-is, an issue tracking "extract a shared trusted-voice-policy reader" would keep it from rotting.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 15/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 15/100 轮)。改动内容与我反驳保留之处如下:

Review round 11 — summary

Critical-only mode is active after 10 change-producing rounds. Per the repository's review policy, this round lands only the two Critical fixes and defers the 13 Suggestions to follow-up issues/PRs; every deferral is recorded as a reply on its own thread so nothing is dropped silently.

Feedback points and decisions

Critical — fixed

  1. R10-1 (scripts/check-voice-guard-sync.js) — normalizeMirroredCode stripped every {/} including inside string/template/regex literal content, so semantically different mirrored units normalized identically (probe-verified in the finding: mutating ${prefix} → $prefix in desktop deriveWebSocketBase passed the guard; block-structure drift also normalized equal).
    Fixed with the fix the finding suggested — comparing parse trees instead of brace-stripped text: each unit is parsed with the TypeScript parser and re-printed with removeComments: true; single-statement blocks are unwrapped (keeping the documented brace-style tolerance); source positions are stripped so the printer emits canonical layout. Literal contents and statement structure are now compared exactly. Verified against the finding's exact scenarios: the ${prefix} → $prefix mutation is detected, block-structure drift is detected, the brace-free control mutation is still detected, and the unmutated real mirrors still pass.
  2. R10-2 (scripts/check-voice-guard-sync.js) — the whitespace collapse erased whitespace inside preserved literal content and ASI-significant newlines (probe-verified in the finding: '/compatible-mode/v1' → '/compatible-mode/ v1' masked; a newline inserted after return masked, flipping the drifted side's classification).
    Fixed by the same parse-tree comparison: literals are printed verbatim from the AST (inner whitespace preserved), and the parser itself models ASI (return + newline parses as return;), so both holes are closed structurally rather than patched. Verified against the finding's exact scenarios on the real mirrored units.

The fix removes the entire ~200-line hand-rolled scanner (stripComments, REGEX_PRECEDING_KEYWORDS, the regex-vs-division heuristic) in favor of ~90 lines built on the project's own TypeScript compiler (a devDependency of every workspace, hoisted to the root node_modules; CI runs the guard after npm ci). Net diff: +144/−185 lines. Five tests pin the previously masked drift classes and the unmangled normalization; the existing "passes on the real mirrored sources" test keeps pinning the whole mirror set.

Suggestions — deferred to follow-up issue/PR (Critical-only mode)

  • R10-3 scanner/normalizer spurious-drift edge cases — partially overtaken: the parse-tree rewrite replaces the scanner (the TypeScript parser handles regex literals and ${} natively); the remaining export const extraction and arrow-body style edges stay known loud, safe-direction gaps. Deferred.
  • R10-4 guard test-suite gaps — items 1–2 overtaken (the scanner no longer exists; the new tests pin unmangled normalization and the drift mutations); items 3–4 (spawnSync failure-path test, pinned MIRROR_SETS inventory) deferred.
  • R10-5 two policy-unit groups without a sync mechanism — deferred (touches production code on both runtimes).
  • R10-6 IP-literal-path always-blocked message — deferred (blocking is correct on every path; diagnostic polish).
  • R10-7 streaming-negative default-wiring coverage — deferred (additive test coverage).
  • R10-8 CLI no-/v1-inference pin — deferred (additive test coverage).
  • R10-9 desktop top-level scheme recheck test — deferred (additive test coverage).
  • R10-10 non-string allowlist entry tests — deferred (additive test coverage).
  • R10-11 parent ~/.env fallback content tests — deferred (additive test coverage).
  • R10-12 reversed-ordering dedup test — deferred (additive test coverage).
  • R10-13 derived-WebSocket-URL docs rewording — deferred (docs accuracy, not a behavior defect).
  • R10-14 CGNAT/ULA trusted-permission pins — deferred (additive test coverage).
  • R10-15 dotenv parity corpus / version pin — deferred (needs a dependency-placement decision).

Each deferral is posted as a reply on its own thread (comment-replies.json) with the thread left open.

Review body (CHANGES_REQUESTED)

The review's "Not reviewed" notes (the desktop bun suites not running in CI — tracked by the author in #8360; the CLI integration suite skipped in CI; the reverse audit stopped by the time budget; the actionlint embedded-shell limitation; the Test Plan count observation) are coverage statements about the review itself, not code findings, and request no code change.

Conflicts

None (--conflict false; no merge performed).

Verification

  • npm run build — passed (exit 0). Also required to bring this checkout to a built state: two script tests (check-i18n, install-script standalone packaging) failed only while the checkout was unbuilt and pass after the build; neither imports the guard script.
  • npm run typecheck — passed (exit 0, all workspaces)
  • npm run lint — passed (exit 0)
  • npm run test:scripts — 47/47 test files passed, 948 tests passed | 14 skipped
  • npx vitest run --config ./scripts/tests/vitest.config.ts check-voice-guard-sync — 11/11 passed (focused suite for the touched script)
  • node scripts/check-voice-guard-sync.js — "Voice guard mirror check passed.", exit 0
  • Mutation harness on the finding's probe-verified scenarios (throwaway script outside the repo): all four previously masked drift classes (template-brace stripping, string whitespace, ASI newline, block structure) are now detected; the unmutated mirrors report no drift
  • npx prettier --check on both touched files — clean
  • Not run: npm run bundle + integration tests — the change touches only the CI guard script and its unit tests, not the bundled CLI surface; no settings source changed, so generate:settings-schema is not applicable.

Commit: fix(scripts): compare voice guard mirrors as parse trees (#8350)

中文说明

第 11 轮评审 — 总结

在完成 10 个产生改动的评审轮后已进入仅处理 Critical 的模式。按仓库评审策略,本轮只合入两个 Critical 修复,13 条建议(Suggestion)延后到后续 issue/PR;每条延后都会在其所属讨论串中以回复形式记录,不会被静默丢弃。

反馈点与决定

Critical — 已修复

  1. R10-1(scripts/check-voice-guard-sync.js) — normalizeMirroredCode 无条件去除所有 {/},包括字符串/模板/正则字面量内容里的花括号,导致语义不同的镜像单元规范化后相同(该发现已探针验证:将 desktop deriveWebSocketBase 中的 ${prefix} 突变为 $prefix 时守卫通过;块结构漂移同样规范化相等)。
    已修复,采用该发现建议的方案——比较语法树而非去花括号后的文本:每个单元用 TypeScript 解析器解析,并以 removeComments: true 重新打印;单语句块被展开(保留文档承诺的大括号风格容忍);剥离源码位置信息使打印机输出规范布局。字面量内容与语句结构现在逐字比较。已按该发现的原始场景验证:${prefix} → $prefix 突变被检出,块结构漂移被检出,不含花括号的对照突变仍被检出,未突变的真实镜像仍然通过。
  2. R10-2(scripts/check-voice-guard-sync.js) — 空白压缩抹掉了保留字面量内容中的空白与影响 ASI 的换行(该发现已探针验证:'/compatible-mode/v1' → '/compatible-mode/ v1' 被掩盖;return 后插入换行被掩盖,漂移侧的分类结果翻转)。
    已修复,同样由语法树比较解决:字面量由 AST 逐字打印(内部空白保留),解析器本身即建模 ASI(return 后跟换行解析为 return;),两个漏洞从结构上闭合而非打补丁。已在真实镜像单元上按该发现的原始场景验证。

本次修复整体删除了约 200 行手写扫描器(stripComments、REGEX_PRECEDING_KEYWORDS、正则/除法判定启发式),替换为约 90 行基于项目自身 TypeScript 编译器的实现(typescript 是每个 workspace 的 devDependency,提升到根 node_modules;CI 在 npm ci 之后运行该守卫)。净 diff:+144/−185 行。五个测试钉住了此前被掩盖的漂移类别与无破坏的规范化输出;既有的 "passes on the real mirrored sources" 测试继续钉住整个镜像集合。

建议类 — 延后到后续 issue/PR(仅处理 Critical 模式)

  • R10-3 扫描器/规范化器虚假漂移边界场景 — 部分被覆盖:语法树重写替换了扫描器(TypeScript 解析器原生处理正则字面量与 ${});剩余的 export const 提取与箭头函数体风格边界仍是已知的、高声失败且方向安全的缺口。延后。
  • R10-4 守卫测试套件缺口 — 第 1、2 项已被覆盖(扫描器已不存在;新测试钉住无破坏的规范化与漂移突变);第 3、4 项(spawnSync 失败路径测试、钉住 MIRROR_SETS 清单)延后。
  • R10-5 两组无同步机制的策略单元 — 延后(涉及两个运行时的生产代码)。
  • R10-6 IP 字面量路径的 always-blocked 消息 — 延后(所有路径阻断行为正确,属诊断信息打磨)。
  • R10-7 流式负向默认装配覆盖 — 延后(增量测试覆盖)。
  • R10-8 CLI 不做 /v1 推断的钉住测试 — 延后(增量测试覆盖)。
  • R10-9 desktop 顶层 scheme 复查测试 — 延后(增量测试覆盖)。
  • R10-10 非字符串白名单条目测试 — 延后(增量测试覆盖)。
  • R10-11 父级 ~/.env 回退带内容测试 — 延后(增量测试覆盖)。
  • R10-12 反序去重测试 — 延后(增量测试覆盖)。
  • R10-13 派生 WebSocket URL 的文档改写 — 延后(文档准确性问题,非行为缺陷)。
  • R10-14 CGNAT/ULA 受信放行钉住 — 延后(增量测试覆盖)。
  • R10-15 dotenv 对等语料/版本固定 — 延后(需要先做依赖放置决策)。

每条延后都会在其所属讨论串中发布回复(comment-replies.json),讨论保持开放。

评审主体(CHANGES_REQUESTED)

评审中的 "Not reviewed" 说明(desktop bun 套件未在 CI 中运行——作者已在 #8360 跟踪;CLI 集成套件在 CI 中被跳过;反向审计因时间预算中止;actionlint 内嵌 shell 的限制;Test Plan 计数观察)是评审自身覆盖面的陈述,不是代码发现,也不要求任何代码改动。

冲突

无(--conflict false,未执行合并)。

验证

  • npm run build — 通过(exit 0)。该命令同时用于将本检出置于已构建状态:两个脚本测试(check-i18n、install-script 独立打包)仅在检出未构建时失败,构建后即通过;二者均不导入该守卫脚本。
  • npm run typecheck — 通过(exit 0,全部 workspace)
  • npm run lint — 通过(exit 0)
  • npm run test:scripts — 47/47 个测试文件通过,948 个测试通过 | 14 个跳过
  • npx vitest run --config ./scripts/tests/vitest.config.ts check-voice-guard-sync — 11/11 通过(针对被改脚本的聚焦套件)
  • node scripts/check-voice-guard-sync.js — "Voice guard mirror check passed.",exit 0
  • 针对该发现探针验证场景的突变测试(仓库外的一次性脚本):此前被掩盖的四个漂移类别(模板花括号剥离、字符串空白、ASI 换行、块结构)现在全部被检出;未突变镜像报告无漂移
  • npx prettier --check 两个被改文件 — 干净
  • 未运行:npm run bundle + 集成测试 — 本次改动只涉及 CI 守卫脚本及其单元测试,不涉及打包后的 CLI 行为面;未改动 settings 源,故无需 generate:settings-schema。

提交:fix(scripts): compare voice guard mirrors as parse trees (#8350)

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round: no action taken

PR: #8350 · Base: main · Conflict: none

This round had no actionable feedback:

  • Reviews: none newer than the last evaluation.
  • Inline comments: none in scope.
  • Issue-level comments: none.
  • Failed checks: none, and no still-red checks persisting from earlier rounds.

Critical-only mode is active (after 10 change-producing rounds). The non-Critical findings listed in the deferred section of this round's feedback remain open for human follow-up, per the ratchet policy; this round did not modify code, resolve threads, or post replies for them.

No commits were made; the branch head is unchanged.

中文说明

Autofix 审查轮次:未采取任何操作

PR: #8350 · 基础分支: main · 冲突: 无

本轮没有可处理的反馈:

  • 审查(Reviews): 自上次评估以来没有新的审查。
  • 行内评论(Inline comments): 本轮范围内没有。
  • Issue 级评论: 没有。
  • 失败的检查(Failed checks): 没有,也没有此前轮次遗留的持续失败检查。

当前处于仅处理 Critical 的模式(在 10 个产生改动的轮次之后)。本轮反馈中"已延后"部分列出的非 Critical 问题,按照棘轮策略保持开放、留待人工跟进;本轮没有为这些条目修改代码、解决讨论串或发布回复。

本轮没有产生任何提交;分支 HEAD 保持不变。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round — no action taken

This round found no actionable feedback on PR #8350:

  • Reviews: none newer than the last evaluation
  • Inline comments: none
  • Issue-level comments: none
  • Failed checks: none — all 44 reported check runs completed as SUCCESS or SKIPPED
  • Still-red checks: none

The PR is in Critical-only mode after 10 change-producing rounds. The deferred non-Critical feedback listed for this round (one automated-reviewer PR comment) was intentionally left untouched per the Critical-only rules: no code change, no thread resolution, and no reply for it. It remains open for human follow-up.

No code changes were made and no commits were created in this round.

中文说明

Autofix 评审轮次 — 未采取任何操作

本轮在 PR #8350 上未发现可处理的反馈:

  • 评审(Reviews): 自上次评估以来没有新的评审
  • 行内评论(Inline comments): 无
  • Issue 级评论(Issue-level comments): 无
  • 失败的检查(Failed checks): 无 —— 报告的全部 44 个检查运行结果均为 SUCCESS 或 SKIPPED
  • 持续失败的检查(Still-red checks): 无

该 PR 在经历 10 个产生改动的轮次后已进入仅处理 Critical 的模式。本轮列出的延后非 Critical 反馈(一条自动评审器的 PR 评论)按 Critical-only 规则有意未做处理:不为它修改代码、不解决相关话题、也不回复。该条目保持开放,留待人工跟进。

本轮未做任何代码改动,也未创建任何提交。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real local environment, real network egress

I built this PR from source and exercised the policy against a real cleartext-HTTP ASR gateway bound to this machine's actual private LAN address (10.177.107.157:8791), driven by the real qwen serve daemon with real 16 kHz WAV audio — no mocked fetch, no injected lookup. The decisive evidence is the gateway's own request log: whether the audio and the Authorization header physically left the process.

Verdict: the contract in the PR description holds on every case I could construct. LGTM to merge, with three small notes below (one worth a follow-up line of code, two worth a sentence in the description).

PR head ce6b480 (merge of main)
Baseline main @ 8fd0162 — same worktree, same node_modules, only packages/cli/src swapped and re-bundled
Host macOS (Darwin 25.6.0), Node v24.18.1, npm 11.16.0, Bun 1.3.14
Surfaces driven daemon POST /workspace/voice/transcribe (batch) · daemon /voice/stream WebSocket (realtime) · CLI TUI startup (settings warnings)
Isolation dedicated QWEN_HOME, dedicated workspace, no real provider credentials

1. Batch path — before vs. after, same machine, same gateway

before/after

Full matrix. ALLOW = the daemon returned a transcript that came back from the private gateway; DENY = the URL policy rejected it; gw+1 = the gateway actually received the request. Every DENY row produced zero gateway requests — the policy fails closed before egress, not after.

# Scenario BEFORE AFTER
S1 HTTP private-IP gateway, no allowlist entry DENY DENY
S2 HTTP private-IP gateway, exact entry in User scope DENY ALLOW gw+1
S3 Entry differs only in path (/v1 → /v2) DENY DENY
S4 Entry differs only in port DENY DENY
S5 Entry differs only in scheme (https) DENY DENY
S6 Entry present only in Workspace scope (repo self-grant) DENY DENY
S7 Entry written with extra trailing slashes (/v1///) DENY ALLOW gw+1
S8 Hostname resolving via DNS to the private LAN IP, allowlisted DENY ALLOW gw+1
S9 Hostname resolving via DNS to loopback, allowlisted DENY DENY
S10 Cloud metadata 169.254.169.254, allowlisted DENY DENY
S11 IPv4-mapped IPv6 spelling of the private IP, allowlisted DENY ALLOW gw+1
S12 IPv4-mapped IPv6 spelling of 127.0.0.1, allowlisted DENY DENY
S13 HTTPS to the private LAN IP, no allowlist entry DENY DENY
S14 Public HTTPS provider, no allowlist entry policy pass* policy pass*

* S14 passed the URL policy on both builds and then failed at the TLS handshake (deliberately: the hostname resolves to a public IP whose certificate does not match, so the audio body is never transmitted). This confirms public HTTPS still needs no allowlist entry — the change is scoped to cleartext/private endpoints.

Notable details from the gateway log on the ALLOW rows:

host=10.177.107.157:8791  auth=Bearer sk-regional-gateway-secret-8350
model=qwen3-asr-flash  format=wav  base64AudioChars=110950

The provider key really is on the wire in cleartext — the risk the settings description warns about is accurate, and it is worth keeping that wording prominent.

S11 also exercises normalization end-to-end: the entry http://[::ffff:10.177.107.157]:8791/v1 and the request both normalize to http://[::ffff:ab1:6b9d]:8791/v1, and that is the Host header the gateway observed.

2. Streaming path — daemon /voice/stream WebSocket

I implemented the qwen-asr-realtime protocol on the same private gateway (session.created → session.update/updated → input_audio_buffer.append/commit → session.finish/finished) and pushed real PCM through the daemon's client WebSocket.

streaming

Without the allowlist entry, no upstream connection is attempted at all — the gateway sees no upgrade request. With it, 26 realtime frames / 83 150 bytes of PCM arrive and a transcript comes back. The batch and streaming legs agree on the same resolved decision, as claimed.

3. Trust boundary — Workspace scope is ignored and surfaced

An untrusted repo dropping security.allowedInsecureVoiceBaseUrls in .qwen/settings.json cannot self-grant (S6 above), and the user is told why on CLI startup:

workspace scope warning

4. Test suites, gates, and mutation testing

Command Result
vitest run on the 6 changed CLI suites 300 passed
npm run test:scripts → check-voice-guard-sync.test.js 11 passed
npm run check:voice-guard-sync passed
bun test packages/server-core/src/voice 135 passed (8 files)
bun test ./…/voice-ws-handler.isolated.ts 15 passed
npm run typecheck · npm run lint:ci · desktop tsc --noEmit all passed

Passing tests only prove the tests run, so I broke the implementation three ways to check the tests and the new CI gate actually bind the contract:

  • Removed the 100.100.100.200 metadata check from the desktop mirror only → check:voice-guard-sync failed with isAlwaysBlockedVoiceAddress: bodies differ. The new drift gate is real, not decorative — this is the most valuable thing the PR adds beyond the feature itself.
  • Made the allowlist match on origin instead of the full normalized URL → 5 tests failed. Path/port/scheme exactness is pinned.
  • Stopped stripping the setting from workspace settings → 2 tests failed. The trust boundary is pinned.

Notes (none blocking)

a) The remediation hint is truncated exactly where it matters, on the daemon path. The new message is 363 chars; sanitizeVoiceErrorMessage cuts at MAX_TRANSCRIPTION_ERROR_LENGTH = 200 (pre-existing). The fixed prefix before the URL is 177 chars, so only ~23 characters of URL survive. What an operator actually sees in the daemon log is:

… add its exact complete normalized URL (http://10.177.107.157:...

The HTTP client always gets the generic voice_transcription_failed, so this log line is the only place the actionable URL appears — and any realistic managed hostname is longer than 23 chars. The TUI path is unaffected (the message reaches the history item unmodified). Cheapest fix: shorten the sentence, or log the URL as its own field. Worth one follow-up commit; not a reason to hold the merge.

b) Embedded credentials in baseUrl now hard-fail (behavior change, verified A/B). main silently stripped user:pass@; this PR throws Voice model '…' baseUrl must not contain embedded credentials. I confirmed both builds on the same input. Failing loudly is the right call, but it belongs in Breaking changes / migration notes next to the Desktop credential change.

c) providerProtocol is now honored for voice model discovery — a real functional change beyond the allowlist. On main, a custom provider id mapped through providerProtocol yields availableVoiceModels: [] and 400 unknown_voice_model; with the two added providerProtocolConfig: lines it resolves normally. That is a genuine (welcome) fix, but it is invisible in the PR description and untestable from the stated test plan — worth a sentence so reviewers know it is intentional.

Not covered by this run

Desktop was verified by its unit suites and typecheck only — I did not launch the Electron app, so the ambiguous-duplicate-provider fail-closed behavior is unit-verified, not runtime-verified. Windows and Linux untested. No real vendor ASR service was contacted; the gateway is a local stand-in that speaks both wire protocols.

中文版本

维护者验证 —— 本地真实环境、真实网络出口

我从源码构建了本 PR,并把策略放到绑定在本机真实私网地址上的真实明文 HTTP ASR 网关(10.177.107.157:8791)前验证,由真实 qwen serve daemon 驱动,使用真实 16 kHz WAV 音频 —— 没有 mock fetch,也没有注入 lookup。判据是网关自己的请求日志:音频和 Authorization 头到底有没有真的离开进程。

结论:PR 描述中的契约在我能构造的每个用例上都成立,建议合并,另有三点说明(一点值得一行后续代码,两点值得在描述里补一句)。

PR head ce6b480(已 merge main)
基线 main @ 8fd0162 —— 同一 worktree、同一 node_modules,只替换 packages/cli/src 后重新 bundle
主机 macOS(Darwin 25.6.0)、Node v24.18.1、npm 11.16.0、Bun 1.3.14
覆盖面 daemon POST /workspace/voice/transcribe(批量)· daemon /voice/stream WebSocket(实时)· CLI TUI 启动(设置警告)
隔离 独立 QWEN_HOME、独立 workspace,不使用真实 provider 凭证

1. 批量路径 —— 同一台机器、同一网关的 before / after

ALLOW = daemon 返回了来自私网网关的转写结果;DENY = URL 策略拒绝;gw+1 = 网关确实收到了请求。所有 DENY 行的网关请求数都是 0 —— 策略在出网之前 fail closed,而不是之后。

# 场景 BEFORE AFTER
S1 HTTP 私网 IP 网关,无白名单条目 DENY DENY
S2 HTTP 私网 IP 网关,User 作用域精确条目 DENY ALLOW gw+1
S3 仅 path 不同(/v1 → /v2) DENY DENY
S4 仅端口不同 DENY DENY
S5 仅 scheme 不同(https) DENY DENY
S6 条目只写在 Workspace 作用域(仓库自授权) DENY DENY
S7 条目多写了尾部斜杠(/v1///) DENY ALLOW gw+1
S8 DNS 解析到私网 IP 的域名,已加白 DENY ALLOW gw+1
S9 DNS 解析到 loopback 的域名,已加白 DENY DENY
S10 云元数据地址 169.254.169.254,已加白 DENY DENY
S11 私网 IP 的 IPv4-mapped IPv6 写法,已加白 DENY ALLOW gw+1
S12 127.0.0.1 的 IPv4-mapped IPv6 写法,已加白 DENY DENY
S13 到私网 IP 的 HTTPS,无白名单条目 DENY DENY
S14 公网 HTTPS provider,无白名单条目 策略放行* 策略放行*

* S14 在两个构建上都通过了 URL 策略,随后在 TLS 握手阶段失败(这是刻意设计:域名解析到一个证书不匹配的公网 IP,因此音频体从未发出)。这印证了公网 HTTPS 仍然不需要白名单条目 —— 改动只作用于明文/私网端点。

ALLOW 行上网关日志的关键信息:

host=10.177.107.157:8791  auth=Bearer sk-regional-gateway-secret-8350
model=qwen3-asr-flash  format=wav  base64AudioChars=110950

provider key 确实以明文出现在链路上 —— 设置项描述里的风险提示是准确的,这段措辞值得保留在显眼位置。

S11 同时端到端验证了规范化:条目 http://[::ffff:10.177.107.157]:8791/v1 与请求都规范化为 http://[::ffff:ab1:6b9d]:8791/v1,网关观察到的 Host 头也正是这个值。

2. 流式路径 —— daemon /voice/stream WebSocket

我在同一个私网网关上实现了 qwen-asr-realtime 协议(session.created → session.update/updated → input_audio_buffer.append/commit → session.finish/finished),并通过 daemon 的客户端 WebSocket 推入真实 PCM。

没有白名单条目时,根本不会发起上游连接 —— 网关看不到任何 upgrade 请求。加白后,26 个实时帧 / 83 150 字节 PCM 到达网关并返回转写结果。批量与流式两条腿给出一致的解析结果,与 PR 描述相符。

3. 信任边界 —— Workspace 作用域既被忽略,也有提示

不可信仓库在 .qwen/settings.json 中写入 security.allowedInsecureVoiceBaseUrls 无法自授权(见 S6),并且 CLI 启动时会明确告知原因(见英文部分截图)。

4. 测试套件、门禁与变异测试

命令 结果
对 6 个改动 CLI 套件执行 vitest run 300 通过
npm run test:scripts → check-voice-guard-sync.test.js 11 通过
npm run check:voice-guard-sync 通过
bun test packages/server-core/src/voice 135 通过(8 个文件)
bun test ./…/voice-ws-handler.isolated.ts 15 通过
npm run typecheck · npm run lint:ci · desktop tsc --noEmit 全部通过

测试通过只能证明测试跑了,所以我用三种方式破坏实现,检验测试和新 CI 门禁是否真的锁住了契约:

  • 只在 desktop 镜像里删掉 100.100.100.200 元数据判断 → check:voice-guard-sync 以 isAlwaysBlockedVoiceAddress: bodies differ 失败。这个新增漂移门禁是真的有效,而不是摆设 —— 这是本 PR 除功能本身之外最有价值的部分。
  • 把白名单匹配从完整规范化 URL 降级为只比 origin → 5 个测试失败。path/port/scheme 的精确性被锁住了。
  • 不再从 workspace 设置中剥离该项 → 2 个测试失败。信任边界被锁住了。

说明(均不阻塞合并)

a) 在 daemon 路径上,补救提示恰好在关键处被截断。 新消息长 363 字符;sanitizeVoiceErrorMessage 在 MAX_TRANSCRIPTION_ERROR_LENGTH = 200 处截断(该常量是既有的)。URL 之前的固定前缀是 177 字符,所以只有约 23 个字符的 URL 能留下。运维在 daemon 日志里实际看到的是:

… add its exact complete normalized URL (http://10.177.107.157:...

HTTP 客户端拿到的始终是通用的 voice_transcription_failed,所以这行日志是运维唯一能看到可操作 URL 的地方 —— 而任何现实中的受管域名都长于 23 个字符。TUI 路径不受影响(消息原样进入 history item)。最省事的修法:缩短这句话,或把 URL 作为独立字段输出。值得一个后续 commit,不必因此拦住合并。

b) baseUrl 内嵌凭证现在会硬失败(行为变更,已 A/B 验证)。 main 会静默剥离 user:pass@;本 PR 抛出 Voice model '…' baseUrl must not contain embedded credentials. 我在同一输入上确认了两个构建的差异。显式失败是正确取舍,但这一条应当写进破坏性变更/迁移说明,与 Desktop 凭证变更并列。

c) providerProtocol 现在会参与语音模型发现 —— 这是白名单之外的真实功能变更。 在 main 上,通过 providerProtocol 映射的自定义 provider id 会得到 availableVoiceModels: [] 和 400 unknown_voice_model;加上新增的两行 providerProtocolConfig: 后即可正常解析。这是一个真实且值得欢迎的修复,但在 PR 描述里完全看不到,按现有测试计划也测不出来 —— 建议补一句,让 reviewer 知道这是有意为之。

本次未覆盖

Desktop 仅通过单元套件和类型检查验证,我没有启动 Electron 应用,因此"重复 provider 歧义 fail closed"是单测层面验证,而非运行时验证。Windows 与 Linux 未测试。没有访问任何真实厂商 ASR 服务;网关是本地替身,同时实现了两种线上协议。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round — no action taken

This round found no actionable feedback on PR #8350:

  • Reviews: none newer than the last evaluation (2026-08-06T12:26:11Z)
  • Inline comments: none
  • Issue-level comments: none actionable — the only newer items are an informational triage re-run summary and this bot's own previous round summary
  • Failed checks: none — all reported check runs completed as SUCCESS or SKIPPED
  • Still-red checks: none
  • Base conflict: none reported; no merge performed

The PR is in Critical-only mode after 10 change-producing rounds. The deferred non-Critical feedback listed for this round (one maintainer PR comment — a local verification report concluding "LGTM to merge" with non-blocking notes) was intentionally left untouched per the Critical-only rules: no code change, no thread resolution, and no reply for it. It remains open for human follow-up.

No code changes were made and no commits were created in this round.

中文说明

Autofix 评审轮次 — 未采取任何操作

本轮在 PR #8350 上未发现可处理的反馈:

  • 评审(Reviews): 自上次评估(2026-08-06T12:26:11Z)以来没有新的评审
  • 行内评论(Inline comments): 无
  • Issue 级评论(Issue-level comments): 无可处理条目 —— 较新的条目仅有一条 triage 重跑的信息性总结和该机器人自身上一轮的总结
  • 失败的检查(Failed checks): 无 —— 报告的全部检查运行结果均为 SUCCESS 或 SKIPPED
  • 持续失败的检查(Still-red checks): 无
  • 与基础分支的冲突(Base conflict): 未报告冲突;未执行合并

该 PR 在经历 10 个产生改动的轮次后已进入仅处理 Critical 的模式。本轮列出的延后非 Critical 反馈(一条维护者的 PR 评论 —— 一份本地验证报告,结论为 "LGTM to merge",附若干非阻塞备注)按 Critical-only 规则有意未做处理:不为它修改代码、不解决相关话题、也不回复。该条目保持开放,留待人工跟进。

本轮未做任何代码改动,也未创建任何提交。

Deferred non-Critical feedback

Critical-only mode is active after 10 change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

完成 10 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@rockybot2026
rockybot2026 added this pull request to the merge queue Aug 6, 2026
Merged via the queue into QwenLM:main with commit 732f4d8 Aug 6, 2026
121 checks passed
qqqys added a commit to qqqys/qwen-code that referenced this pull request Aug 6, 2026
Picks up main so `Test (ubuntu-latest, Node 22.x)` stops failing. CI checks
out `refs/pull/8410/head` but runs the base branch's `ci.yml`, so main's
"Check voice guard mirror sync" step ran `npm run check:voice-guard-sync`
against this branch's older `package.json`, which predates that script
(added in QwenLM#8350) -> `npm error Missing script`, exit 1. Nothing in this PR
caused it; the branch was simply 17 commits behind.

Merge resolution: both sides had added a `const reviewAddressJob` in
`scripts/tests/qwen-autofix-workflow.test.js`, in different hunks, so git
merged them textually into a duplicate `const` (SyntaxError at import,
whole suite unloadable). Kept main's bounded slice
`(?=\n {2}[a-z][a-z0-9-]*:\n|$)` and dropped this branch's older
unbounded `[\s\S]*$` form — main's is strictly more general (it still
allows EOF, so it keeps working while review-address is the last job, and
it shrinks correctly once a job is appended after it).
doudouOUC added a commit to doudouOUC/qwen-code that referenced this pull request Aug 7, 2026
Syncs 46 commits of base drift. CI's 'Check voice guard mirror sync' step
runs from main and invokes `npm run check:voice-guard-sync`, a script
added alongside that step in 732f4d8 (QwenLM#8350) and absent from this
branch — so the check failed on missing-script, not on anything in this
diff. Merged rather than rebased so existing review comments stay
anchored.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants