Skip to content

feat(core): send session ID to Routify endpoints - #10896

Merged
DragonnZhang merged 7 commits into
QwenLM:mainfrom
DragonnZhang:dragon/add-session-id-header
Sep 5, 2026
Merged

feat(core): send session ID to Routify endpoints#10896
DragonnZhang merged 7 commits into
QwenLM:mainfrom
DragonnZhang:dragon/add-session-id-header

Conversation

@DragonnZhang

Copy link
Copy Markdown
Collaborator

What this PR does

Adds the active Qwen Code session ID as the session_id request header for HTTPS LLM calls to the three ModelRouter endpoints documented by Routify: routify.alibaba-inc.com, routify-online.alibaba-inc.com, and routify-pub.alibaba-inc.com.

The session value is resolved for every request, so /clear and other session changes are reflected without rebuilding the model client. OpenAI-compatible, DashScope, Anthropic, Gemini, and Vertex request paths are covered. Existing request headers and proxy-aware fetch behavior are preserved.

The destination check is intentionally fixed and exact. Subdomains, lookalike hosts, plain HTTP URLs, and all other providers do not receive the header.

Why it's needed

Routify's ModelRouter accepts session_id for session affinity, traffic marking, and troubleshooting. The supplied AP recreation log contains 37 Anthropic requests to routify-pub.alibaba-inc.com, but none includes any affinity header documented by ModelRouter. Reusing Qwen Code's existing session ID lets ModelRouter keep requests from one CLI session correlated without introducing another identifier.

Reviewer Test Plan

How to verify

  1. Point each supported provider protocol at one of the three HTTPS Routify endpoints and capture the outbound request. Confirm that it contains one non-empty session_id header while authorization and content headers remain unchanged.
  2. Start a new session with /clear and send another request. Confirm that session_id changes to the new active session value.
  3. Point the same provider at a subdomain, a lookalike alibaba-inc.com hostname, a plain HTTP Routify URL, or an unrelated provider. Confirm that session_id is absent.
  4. Run the focused core tests. The local run passed 392 tests across exact-host matching and all SDK integration paths.

Evidence (Before & After)

N/A (no UI change). Before: all 37 requests in the supplied Routify AP recreation log lacked a documented affinity header. After: focused tests confirm the header is added only to the three exact HTTPS Routify destinations and follows session rotation. A live request was not replayed because the supplied archive contains a plaintext bearer token.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local Node.js 24.18.0, no sandbox. npm run build, npm run typecheck, targeted ESLint, and 392 focused unit tests passed.

Risk & Scope

  • Main risk or tradeoff: Routify receives a stable identifier across requests in one Qwen Code session. Exposure is limited to the three exact documented HTTPS hostnames; there is no suffix match, wildcard, or user-configurable broadcast.
  • Not validated / out of scope: live post-change Routify traffic, Windows/Linux runtime verification, non-LLM traffic, MCP/tool requests, request IDs, trace propagation, and body metadata.
  • Breaking changes / migration notes: none.

Linked Issues

Related to #4384.

中文说明

本 PR 做了什么

为发往 Routify 文档列出的三个 ModelRouter 端点的 HTTPS 大模型请求增加当前 Qwen Code 会话 ID,使用的请求头是 session_id。三个端点分别是 routify.alibaba-inc.comroutify-online.alibaba-inc.comroutify-pub.alibaba-inc.com

每次请求都会读取当前会话值,因此执行 /clear 或发生其它会话切换后,无需重建模型客户端即可使用新的值。OpenAI 兼容、DashScope、Anthropic、Gemini 和 Vertex 请求链路均已覆盖,同时保留已有请求头和支持代理的 fetch 行为。

目标地址检查采用固定的精确匹配。子域名、相似域名、普通 HTTP 地址以及其它所有模型服务都不会收到该请求头。

为什么需要这个改动

Routify 的 ModelRouter 支持使用 session_id 做会话亲和、流量染色和问题排查。用户提供的 AP 复现日志中有 37 个发往 routify-pub.alibaba-inc.com 的 Anthropic 请求,但均未携带 ModelRouter 文档列出的任何亲和请求头。复用 Qwen Code 已有的会话 ID,可以在不新增标识符的情况下,让 ModelRouter 关联同一个 CLI 会话中的请求。

Reviewer 测试计划

如何验证

  1. 将各支持的模型协议分别指向三个 Routify HTTPS 端点之一并抓取出站请求,确认请求中有且仅有一个非空 session_id,同时认证头和内容相关请求头保持不变。
  2. 通过 /clear 创建新会话后再次发送请求,确认 session_id 已更新为新的当前会话值。
  3. 将同一个模型服务指向子域名、相似的 alibaba-inc.com 域名、普通 HTTP Routify 地址或无关模型服务,确认请求中没有 session_id
  4. 运行核心包定向测试。本地共通过 392 项测试,覆盖精确域名匹配和所有 SDK 集成链路。

证据(改动前后)

不适用(无 UI 改动)。改动前:用户提供的 Routify AP 复现日志中 37 个请求均未携带文档所列亲和请求头。改动后:定向测试确认仅三个精确的 HTTPS Routify 目标会添加该请求头,并且会话切换后值会更新。由于用户提供的压缩包中含有明文 bearer token,没有重放真实线上请求。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

本地 Node.js 24.18.0,无沙箱。npm run buildnpm run typecheck、定向 ESLint 以及 392 项定向单元测试均通过。

风险与范围

  • 主要风险或权衡:Routify 会收到同一个 Qwen Code 会话内跨请求稳定的标识符。暴露范围仅限文档列出的三个精确 HTTPS 主机名,不存在后缀匹配、通配或用户可配置的全量广播。
  • 未验证或不在范围内:改动后的 Routify 真实流量、Windows/Linux 运行时验证、非大模型流量、MCP/工具请求、请求 ID、trace 传播和请求体元数据。
  • 破坏性改动或迁移说明:无。

关联 Issue

关联 #4384

@DragonnZhang

Copy link
Copy Markdown
Collaborator Author

E2E test report

Baseline

The supplied AP recreation archive contains 37 Anthropic requests to routify-pub.alibaba-inc.com. None includes session_id or another ModelRouter-documented affinity header.

Changed behavior

Focused tests verify that HTTPS requests to all three documented Routify hosts receive the current Qwen Code session ID, that /clear-style session rotation is observed per request, and that subdomains, lookalike hosts, plain HTTP URLs, and unrelated providers remain unchanged. Existing request headers and Request-carried headers are preserved.

Local verification

  • 392 focused unit tests passed across OpenAI-compatible, DashScope, Anthropic, Gemini, and the shared destination gate.
  • npm run build passed.
  • npm run typecheck passed for all workspaces and integration tests.
  • Targeted ESLint passed.

A live post-change Routify request was not replayed because the supplied archive contains a plaintext bearer token.

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 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 Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Re-run 2026-09-05 — gate outcome: PASSED, and the PR has since merged. This section is preserved verbatim from the first pass at c5014591; only the footer moved. What changed since then:

  • The direction escalation was answered on the thread, not by me. @wenshao built a live Routify rig — bundled CLI in a container, the three ModelRouter hostnames plus a subdomain, a lookalike, a suffix-lookalike and api.openai.com hijacked to a local HTTPS server behind a private CA, every inbound request's raw headers written to a JSONL ledger — ran 20 lanes × 5 protocols plus a tmux TUI lane on head and on the pre-PR base bundle, and published the measured facts I said I couldn't judge (no opt-out under telemetry.enabled: false; first stable cross-request identifier on that path; per-session not per-request; the propagateTraceContext precedent defaults off). They then approved at 04:15:28Z, @qqqys approved the same head at 04:19:05Z with an independent Critical-only scan, and the author merged at 04:20:32Z. That is the principle's owner making the call, which is what point 1 below asked for. @LaZzyMan, whose feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 review established the principle, never responded — recording that for the thread, not relitigating it.
  • Point 3 below stands as a docs follow-up. The merged telemetry.md still replaces the feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 section outright, and the new design doc never references feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 or explains why the earlier principle doesn't apply, so nothing in the repo records that it was reconsidered.
  • Size at head (the first pass measured the opening commit): 133 production lines, 500 test lines, 66 docs lines — 699 total, matching +686/-13. Still nowhere near a threshold; Stage 0 raised no escalation. feat title, so no Tier 1 hard block, and the approval guardrail computes ok (cross-repository, but not a refactor).
  • Stage 1-pre found nothing to check: base is main, which is the default branch, and GitHub's closing-reference parser returns an empty set — "Related to feat(telemetry): propagate W3C traceparent + X-Qwen-Code-Session-Id to LLM service calls #4384" is prose, not a closing keyword, so no duplicate/already-fixed branch applies.
  • Stage 1e did match (openaiContentGenerator in the changed paths), so Stage 2 carries the full enrichments and the CI evidence rather than a summary. Read that section for the one thing worth a maintainer's attention: the ubuntu unit lane never went green on the merged head.

Thanks for the PR — the implementation is careful, which makes the direction question below the one worth settling first.

Template ✓ — complete and bilingual, with a reviewer test plan.

Problem. Real within its stated scope: ModelRouter accepts session_id for affinity/traffic marking, and the 37-request Routify recreation log supplied to the author shows none of them carrying an affinity header. Two caveats: that log isn't public (deliberately — it contains a bearer token), and no open issue asks for this — the referenced #4384 was closed as completed by #4390 (opt-in traceparent), so this PR is a follow-up with no open issue behind it.

Direction — escalating to a maintainer before code review. Three things give me pause:

  1. The diff deletes the "Other outbound correlation headers" section of docs/developers/development/telemetry.md — the paragraph recording the feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 review outcome (LaZzyMan): telemetry doesn't send identifiers to LLM providers, and correlation headers may land only after their own threat model and operator-consent flow under the outboundCorrelation.* namespace. The new behavior has the opposite shape: unconditional, explicitly "not controlled by telemetry.enabled or outboundCorrelation.*", and the design doc says intentionally not configurable. The established pattern in that namespace is opt-in (today: propagateTraceContext, default off). Maybe the narrowness of scope — three exact HTTPS Routify hostnames, fail-closed matching, everything else untouched — justifies skipping consent; but that is a product call for the maintainer who owns the principle, not one the gate makes silently.
  2. It touches the request path of every provider (OpenAI-compatible, DashScope, Anthropic, Gemini/Vertex) plus telemetry docs — core infrastructure, where the bar is 100% confidence or escalate, and a direction doubt means escalate.
  3. Deleting the record of a prior decision rather than amending it hides the history a future reader needs to judge this change. If this lands, the docs should say the feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 principle was reconsidered and why — not just disappear.

Size. Core paths touched: 128 production lines, 154 test lines, 62 docs lines — well under any size threshold. Not a scale concern.

Approach. The how is solid: one small module, exact hostname match over HTTPS only, fail-closed on invalid URLs, session ID re-read per request (so /clear rotation works without rebuilding SDK clients), existing headers and custom/proxy fetch preserved. If the direction gets a yes, code review should move fast.

Risk. The provider paths here match the repo's revert-history high-risk pattern (openaiContentGenerator); if this proceeds, it should carry full Stage 2 enrichments and CI evidence before any approval.

⏸️ Stopping before code review and deferring to @LaZzyMan — the correlation-header principle you established in the #4390 review is what this PR's docs change removes. Does the Routify-only scope supersede the operator-consent requirement, or should this be gated under outboundCorrelation.*? @DragonnZhang, the pause is a direction call, not a verdict on code quality.

中文说明

感谢贡献——实现本身很细致,因此下面的方向问题更需要先解决。

模板 ✓——完整、双语、包含评审测试计划。

问题。 在其陈述的范围内是真实的:ModelRouter 支持 session_id 做会话亲和/流量标记,作者收到的 37 条 Routify 复现日志中没有一条携带亲和请求头。两点说明:该日志未公开(有意为之——其中含有 bearer token),且目前没有开放的 issue 要求此改动——所引用的 #4384 已由 #4390(可选的 traceparent)关闭为已完成,因此本 PR 是一个没有开放 issue 支撑的后续改动。

方向——在进入代码审查前升级给维护者。 有三点顾虑:

  1. 本 diff 删除了 docs/developers/development/telemetry.md 中"其他出站关联请求头"一节——该段记录了 feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 评审(LaZzyMan)的结论:遥测不向 LLM 提供方发送标识符;关联请求头只有在拥有自己的威胁模型和操作者同意流程、并落在 outboundCorrelation.* 命名空间下才可合入。新行为的形状正好相反:无条件生效,文档明确写着"不受 telemetry.enabledoutboundCorrelation.* 控制",设计文档也写明有意不提供配置项。该命名空间既有模式是显式开启(目前:propagateTraceContext,默认关闭)。也许范围足够窄——三个精确匹配的 HTTPS Routify 主机、fail-closed 匹配、其余完全不受影响——足以豁免同意流程;但这是该原则的负责维护者要做的产品决策,不应由 gate 悄悄决定。
  2. 改动触及所有提供方的请求路径(OpenAI 兼容、DashScope、Anthropic、Gemini/Vertex)以及遥测文档——属于核心基础设施,标准是 100% 确信或升级;方向上有疑问就意味着升级。
  3. 删除先前决策的记录而非修订它,会让未来读者失去判断此改动所需的历史。如果本 PR 合入,文档应说明 feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 原则被重新考虑的原因,而不是让记录直接消失。

规模。 触及核心路径:128 行生产代码、154 行测试、62 行文档——远低于任何规模阈值。不是规模问题。

方案。 实现方式是可靠的:单一小模块、仅 HTTPS 的精确主机名匹配、非法 URL 时 fail-closed、每次请求重新读取会话 ID(因此 /clear 轮换会话时无需重建 SDK 客户端)、保留现有请求头和自定义/代理 fetch。如果方向获得认可,代码审查预计会很快。

风险。 此处的提供方路径命中了本仓库回滚历史的高风险模式(openaiContentGenerator);如果继续,批准前应完成完整的 Stage 2 增强与 CI 证据。

⏸️ 在代码审查前暂停并转交 @LaZzyMan——您在 #4390 评审中确立的关联请求头原则正是本 PR 文档改动所删除的内容。仅 Routify 的范围是否可以豁免操作者同意要求,还是应通过 outboundCorrelation.* 加以控制?@DragonnZhang,这次暂停是方向决策,不是对代码质量的否定。

Qwen Code · qwen3.8-max-2026-09-02

Re-run reviewed at 82494329cfd3871e08864ff178010852c0676587; original Stage 1 pass at c5014591bbb2699c6aa92df20b378489105d5de5 · 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.

Reviewed. Suggestions are inline.

中文说明

已审查。 建议见行内评论。

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

Comment thread docs/developers/development/telemetry.md Outdated
Comment thread docs/developers/development/telemetry.md Outdated
Comment thread packages/core/src/core/llm-content-generator/llm-content-generator.ts Outdated
Comment thread packages/core/src/core/openaiContentGenerator/provider/default.ts Outdated
Comment thread packages/core/src/core/outbound-session-id.ts Outdated

@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): "agent 4": none — no Budget gap: lines; all planned checks completed..

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/outbound-session-id.ts:40 — [probe] session_id skip decision leaves no debug trace
  • docs/design/2026-09-03-outbound-session-id-header.md:23 — [review] streaming session_id injection claimed by the design doc is pinned by no test
中文说明

未探索到全部深度(达到工具调用预算):"agent 4"none — no Budget gap: lines; all planned checks completed.

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

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

Comment thread packages/core/src/core/llm-content-generator/llm-content-generator.ts Outdated
Comment thread docs/developers/development/telemetry.md Outdated
Comment thread packages/core/src/core/outbound-session-id.ts

@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.

Reviewed. Suggestions are inline.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/llm-content-generator/llm-content-generator.ts:396 — [probe] embedContent injection branch's ...request.config preservation is unpinned — the sole embedding test passes no config
  • packages/core/src/core/llm-content-generator/llm-content-generator.test.ts:117 — [probe] generateContentStream's session_id injection is unpinned — the streaming path shares buildGenerateContentConfig only by today's wiring

Convergence: round 3 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/core/src/core/llm-content-generator/llm-content-generator.ts (findings in round 2; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

已审查。 建议见行内评论。

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 3 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/core/llm-content-generator/llm-content-generator.ts(第 2 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

@wenshao

wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification report — live Routify rig (head 0c63f0f9 vs base 9bb2f853)

I built a real environment for this PR instead of trusting the unit tests: the bundled CLI (npm run build && npm run bundle) running inside a Linux container where the three ModelRouter hostnames — plus a subdomain, a lookalike, a suffix-lookalike and api.openai.com — are hijacked to a local HTTPS server with a private CA. That server writes every inbound request (raw headers + body) to a JSONL ledger, so the evidence below is what the endpoint actually received, not what a mock recorded. 20 lanes across the OpenAI-compatible, DashScope, Anthropic, Gemini and Vertex protocols, plus one interactive TUI lane, each run twice: once on this PR's head and once on the pre-PR base bundle.

Bottom line: the mechanism does exactly what the PR says it does, and the A/B is clean — 26/49 head requests carry session_id, 0/43 base requests do. Every positive value equals the CLI's own session id, no existing header is dropped or altered, and no negative destination ever receives it. What I found is one behaviour gap (web_search), one exposure property worth an explicit decision (redirects), one genuine test hole, and two nits. None of them is a defect in the injection logic itself; the direction question triage escalated is still the thing that gates this PR, and I've added measured facts for it below.

1. Confirmed on the wire

wire matrix

Claim Result
Header reaches all three documented hosts over HTTPS routify, routify-online, routify-pub — all injected
Value is the live session id ✅ every value matches the session id the CLI recorded for that process
Subdomain / lookalike / suffix-lookalike / plain HTTP / unrelated provider excluded sub.routify-pub…, routify-preview…, routify.alibaba-inc.com.evil.example, http://routify-pub…, api.openai.com — absent in all of them
All five provider protocols covered ✅ OpenAI-compatible, DashScope, Anthropic, Gemini (:streamGenerateContent), Vertex (/v1beta1/publishers/google/models/…)
Existing headers preserved ✅ same-lane base↔head header diff is exactly +session_id; nothing dropped, no value changed, request body byte-identical
Proxy-aware fetch preserved ✅ with HTTPS_PROXY set, the proxy logs the CONNECT and the header is still injected
Operator customHeaders.session_id is overridden, other custom headers survive session_id = CLI value, X-Op-Marker untouched
Side queries and subagents inherit the same value ✅ the memory-extraction subagent and the auto-mode security classifier requests carry the identical id

/clear rotation, in a live TUI process (same process, same SDK client):

clear rotation

2. Findings

gaps

F1 — web_search talks to the same Routify host without the header. packages/core/src/tools/web-search.ts:652 builds its own new OpenAI({…}) with buildRuntimeFetchOptions(...) and no session wrapper. Its gate (classifyDashScopeBaseUrl, web-search.ts:127-152) explicitly accepts alibaba-inc.com and its subdomains, so WEB_SEARCH_BASE_URL=https://routify-pub.alibaba-inc.com/protocol/websearch/v1 is a supported configuration — and in the run above its /responses calls arrive at Routify with Authorization but no session_id, in the very same session whose chat turns carry it. This is a real model request, not "non-LLM traffic", so today the docs sentence ("Qwen Code's LLM requests … include the current Qwen Code session ID") overstates the coverage. Fix is one line (fetch: buildSessionAwareFetch(...) on that client) or a narrower doc claim.

F2 — a Routify 302 forwards the identifier to another registrable domain. The design doc mentions this in one clause; here is what it costs. When the endpoint answers 302 → https://routify.alibaba-inc.com.evil.example/…, fetch strips Authorization (its own cross-origin rule) but forwards session_id verbatim. So the redirect hop that is considered too dangerous for the credential still carries the new stable identifier. The destination check runs once, before the request; the wrapper cannot see the redirect. If that is acceptable, it deserves a sentence in the security-boundary section saying so explicitly ("a compromised or misconfigured Routify response can move the affinity id to any host"); if it is not, the wrapper needs redirect: 'manual' plus a re-check per hop.

F3 — the streaming Gemini/Vertex path is unpinned by tests (the same thing the review bot deferred in round 3, now confirmed with a mutant). Deleting the injection from generateContentStream only — delete streamConfig.httpOptions — keeps all 402 tests green. Streaming is the path production actually uses (my live Gemini lane hit :streamGenerateContent), so the primary Gemini behaviour rests on today's wiring rather than on a test. One assertion mirroring the existing non-streaming test closes it.

F4 — a trailing-dot FQDN reaches the same host but falls outside the match set (minor, fail-closed). https://routify-pub.alibaba-inc.com./… and its %2e form parse to hostname routify-pub.alibaba-inc.com., which is not in the exact-match list, so no header — while DNS resolves it to the same Routify endpoint (lane 10 above completed normally). Same spelling also drops it out of the DashScope provider's *.alibaba-inc.com rule, so that user silently loses both the affinity value and the DashScope headers. No leak risk; only affinity loss.

F5 — two lines of the new module are provably dead (nit). url.hostname.toLowerCase(): the WHATWG parser already lower-cases https: hostnames — the probe in the figure below tries uppercase, mixed case, ports, userinfo and percent-encoded forms and never produces a hostname that toLowerCase() changes. And the catch in buildSessionIdHeaders is unreachable, because requestUrl() already swallows the only throwing call. Both are harmless; both are also the two mutants my matrix could not kill for a good reason.

mutation matrix

O1 — for all three Routify hosts the OpenAI protocol resolves to the DashScope provider, not the default one (isDashScopeProvider matches any *.alibaba-inc.com origin — visible as the X-DashScope-* headers in every Routify lane). So provider/default.ts's hook is defensive-only for today's host list; the DashScope and Anthropic hooks are the ones carrying the feature. Worth knowing when reviewing which paths need tests.

3. Measured facts for the direction question (@LaZzyMan)

Not my call to make, but these are the numbers behind it:

  • There is no opt-out. Every lane above ran with telemetry.enabled: false and privacy.usageStatisticsEnabled: false, and the header was still sent. The host list is a module constant; no setting, env var or flag suppresses it.
  • It is the first stable cross-request identifier on this path. The base run's request headers to the same endpoint contain only User-Agent, x-stainless-* and X-DashScope-* — nothing that correlates two requests. (The Gemini path already has x-gemini-api-privileged-user-id, but only when usage statistics are enabled.)
  • The existing precedent in that namespace is opt-in: outboundCorrelation.propagateTraceContext defaults to false (config.ts:2616). This PR's behaviour is on by default and explicitly outside that namespace.
  • Blast radius per session: every model request in a session shares one value — 2/2 in a plain headless run, and 3/3 in a tool-call run (main turn, auto-mode security classifier, follow-up turn); side queries and subagents inherit it. So it is per-session, not per-request — exactly what affinity needs, and exactly what makes it a correlator.

4. Not verified

Real Routify traffic (no access); Windows/macOS runtime lanes (rig is Linux/arm64 in Docker; the code is platform-independent); whether ModelRouter actually honours the header; behaviour of a Routify endpoint that answers with Set-Cookie/other affinity mechanisms; MCP, tool and OAuth-refresh traffic (explicitly out of scope in the PR).

5. Repro

Bundled head + base under bundle/ and bundle-base/; docker run --add-host routify{,-online,-pub}.alibaba-inc.com:127.0.0.1 … node:24-bookworm-slim, one fake-Routify HTTPS server with a private CA (NODE_EXTRA_CA_CERTS), one settings.json per lane (modelProviders + security.auth.selectedType), qwen -p … per lane, ledger = every request's raw headers. TUI lane drives the real UI under tmux and sends /clear between turns. Mutation matrix flips one decision at a time and reruns the six touched suites (vitest, 402 tests). Environment: macOS 26.6.2 host (Node v24.18.1 for the build), container Node v24.19.0, head 0c63f0f920, base 9bb2f85303.

Full test state on head: 402/402 in the six touched suites; 1272/1272 across src/core/{openaiContentGenerator,anthropicContentGenerator,llm-content-generator} + outbound-session-id + web-search.

中文说明

维护者验证报告 —— 真实 Routify 链路实测(head 0c63f0f9 vs base 9bb2f853

我没有只看单测,而是搭了一套真实环境:把打包后的 CLI(npm run build && npm run bundle)放进 Linux 容器,把三个 ModelRouter 主机名(外加子域名、相似域名、后缀相似域名和 api.openai.com)劫持到本地 HTTPS 服务(私有 CA 签发证书)。该服务把每个进来的请求(原始请求头 + 请求体)写成 JSONL 流水账,因此下面的证据是端点真实收到的字节,不是 mock 记录。共 20 条泳道,覆盖 OpenAI 兼容、DashScope、Anthropic、Gemini、Vertex 五种协议,另有一条交互式 TUI 泳道;每条都在 PR head 和改动前 base 两个包上各跑一遍。

结论:机制与 PR 描述完全一致,A/B 干净——head 49 个请求中 26 个带 session_id,base 43 个请求 0 个带。 每个出现的值都等于该进程自己的会话 ID;没有任何既有请求头被丢弃或改写;所有反例目标都没有收到该头。我发现的是:一个行为缺口(web_search)、一个需要显式拍板的暴露面(重定向)、一个真实的测试盲区,以及两个吹毛求疵项。这些都不是注入逻辑本身的缺陷;真正卡住这个 PR 的仍是 triage 升级的方向问题,我在第 3 节给出了可量化的事实。

1. 链路上已确认的部分

断言 结果
HTTPS 下三个文档主机都收到该头 routifyroutify-onlineroutify-pub 全部注入
值就是当前会话 ID ✅ 每个值都等于该进程记录的 sessionId
子域名 / 相似域名 / 后缀相似域名 / 明文 HTTP / 无关服务 均排除 ✅ 五类反例全部没有该头
五种协议全覆盖 ✅ OpenAI 兼容、DashScope、Anthropic、Gemini(:streamGenerateContent)、Vertex
既有请求头保持不变 ✅ 同泳道 base↔head 头部差异恰好是 +session_id,无删除无改值,请求体逐字节相同
代理链路仍然生效 ✅ 设置 HTTPS_PROXY 后代理记录到 CONNECT,且该头照常注入
运维自定义的 session_id 被覆盖,其它自定义头保留 session_id 用 CLI 的值,X-Op-Marker 原样保留
侧查询与子 agent 共享同一个值 ✅ 记忆抽取子 agent、auto 模式安全分类器的请求都带同一个 ID

/clear 轮换:在同一个 TUI 进程(不重启、不重建 SDK 客户端)内,第二轮的值确实换成了新的会话 ID,且两个值都与 CLI 自己记录的会话 ID 一一对应。

2. 发现

F1 —— web_search 打同一个 Routify 主机却没有该头。 packages/core/src/tools/web-search.ts:652 自己 new OpenAI({…}),只用了 buildRuntimeFetchOptions(...),没有接会话包装;而它的准入检查(classifyDashScopeBaseUrlweb-search.ts:127-152)明确接受 alibaba-inc.com 及其子域名,所以 WEB_SEARCH_BASE_URL=https://routify-pub.alibaba-inc.com/... 是受支持的配置。实测中它的 /responses 请求带着 Authorization 到达 Routify,却没有 session_id,而同一会话的对话请求是带的。这是真实的模型请求,不属于"非大模型流量",因此当前文档里"Qwen Code 的大模型请求都会带上会话 ID"的说法覆盖过宽。修法要么给该客户端加一行 fetch: buildSessionAwareFetch(...),要么收窄文档表述。

F2 —— Routify 返回 302 会把标识符转发到另一个可注册域。 设计文档用一句话提了这件事,这里是它的代价:当端点回 302 → https://routify.alibaba-inc.com.evil.example/… 时,fetch 会按跨源规则剥掉 Authorization,却把 session_id 原样转发。也就是说,被认为不安全到不能带凭据的那一跳,仍然带走了这个稳定标识符。目标检查只在发出前做一次,包装器看不到重定向。如果接受这一行为,安全边界一节应当明写("被攻陷或配置错误的 Routify 响应可以把亲和 ID 转移到任意主机");如果不接受,则需要 redirect: 'manual' 并逐跳重新判定。

F3 —— Gemini/Vertex 的流式路径没有任何测试钉住(正是评审 bot 第 3 轮延后的那条,现在用变异体坐实)。只在 generateContentStream 里删掉注入(delete streamConfig.httpOptions),402 个测试全绿。而流式恰恰是生产实际走的路径(实测 Gemini 泳道打的就是 :streamGenerateContent),所以主路径行为目前只靠"当前接线方式"保证。补一条对照非流式用例的断言即可闭合。

F4 —— FQDN 末尾带点的写法能打到同一台主机,却落在精确匹配集之外(次要,且是 fail-closed)。https://routify-pub.alibaba-inc.com./…(含 %2e 形式)解析出的 hostname 是 routify-pub.alibaba-inc.com.,不在列表里,因此不注入;而 DNS 会把它解析到同一个 Routify 端点(泳道 10 正常完成)。同样的写法还会掉出 DashScope provider 的 *.alibaba-inc.com 规则,于是该用户同时失去亲和值和 DashScope 请求头。没有泄漏风险,只是亲和失效。

F5 —— 新模块里有两行是死代码(吹毛求疵)。url.hostname.toLowerCase():WHATWG 解析器对 https: 主机名已经做了小写化——探针试了大写、混合大小写、端口、userinfo、百分号编码等形式,没有任何一种能让 toLowerCase() 改变结果;buildSessionIdHeaderscatch 也不可达,因为唯一会抛的调用已经被 requestUrl() 吞掉了。两处都无害,也正是变异矩阵中"杀不掉且理应杀不掉"的两个变异体。

O1 —— 三个 Routify 主机在 OpenAI 协议下走的是 DashScope provider,不是 default(isDashScopeProvider 匹配任意 *.alibaba-inc.com origin——从每条 Routify 泳道都带 X-DashScope-* 头可以看出)。因此对当前的主机列表来说,provider/default.ts 的挂点只是防御性的;真正承载该功能的是 DashScope 和 Anthropic 两个挂点。评审时判断"哪些路径需要测试"要以此为准。

3. 关于方向问题的可量化事实(@LaZzyMan

这不是我能替你拍板的事,但下面是相关数据:

  • 没有关闭开关。 上述所有泳道都在 telemetry.enabled: falseprivacy.usageStatisticsEnabled: false 下运行,该头照样发送。主机列表是模块常量,没有任何设置、环境变量或命令行开关能抑制它。
  • 它是这条链路上第一个跨请求稳定标识符。 base 运行时发往同一端点的请求头里只有 User-Agentx-stainless-*X-DashScope-*,没有任何能关联两次请求的东西。(Gemini 路径本来就有 x-gemini-api-privileged-user-id,但仅在开启用量统计时。)
  • 同命名空间既有先例是显式开启: outboundCorrelation.propagateTraceContext 默认为 falseconfig.ts:2616)。本 PR 的行为是默认开启,且明确置于该命名空间之外。
  • 单会话暴露量: 同一会话内所有模型请求共用一个值——普通 headless 运行 2/2,带工具调用的运行 3/3(主轮次、auto 模式安全分类器、后续轮次);侧查询与子 agent 继承同一个值。也就是说它是按会话而非按请求的——这正是亲和所需要的,也正是它成为关联标识的原因。

4. 未验证

真实 Routify 线上流量(无访问权限);Windows/macOS 运行时泳道(本次靶场是 Docker 内 Linux/arm64,代码本身与平台无关);ModelRouter 是否真的消费该头;Routify 端返回 Set-Cookie 等其它亲和机制时的行为;MCP、工具和 OAuth 刷新流量(PR 已明确列为范围外)。

5. 复现方式

head 与 base 分别打包到 bundle/bundle-base/docker run --add-host routify{,-online,-pub}.alibaba-inc.com:127.0.0.1 … node:24-bookworm-slim,容器内起一个假 Routify HTTPS 服务(私有 CA + NODE_EXTRA_CA_CERTS),每条泳道一份 settings.jsonmodelProviders + security.auth.selectedType),逐条跑 qwen -p …,流水账记录每个请求的原始请求头。TUI 泳道用 tmux 驱动真实界面并在两轮之间发 /clear。变异矩阵每次只翻一个判定并重跑六个受影响测试文件(vitest,402 个用例)。环境:macOS 26.6.2 宿主(构建用 Node v24.18.1),容器 Node v24.19.0,head 0c63f0f920,base 9bb2f85303

head 上的完整测试状态:六个受影响套件 402/402;src/core/{openaiContentGenerator,anthropicContentGenerator,llm-content-generator} + outbound-session-id + web-search 合计 1272/1272。

@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.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R4-1 streaming session_id injection unpinned (llm-content-generator.test.ts:117) — already recorded in the round-3 deferral list (review 5111238830)(与第 3 轮延后记录重复,未重新发布)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the unit suite ran locally on Linux only.

Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/llm-content-generator/llm-content-generator.ts:109 — [probe] R3-1 buildHttpOptions' request-level httpOptions branches (destination gate, header-merge precedence) have no test; the precedence-flip mutant ships green a…
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the unit suite ran locally on Linux only.

收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

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

@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.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R5-1 generateContentStream session_id injection unpinned (llm-content-generator.test.ts:425) — already recorded in the round-3 deferral list (review 5111238830) and re-confirmed as a duplicate in the round-4 body (review 5117011624)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the unit suite ran locally on Linux only.

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/llm-content-generator/llm-content-generator.test.ts:186 — [review] stale test title names a removed fallback tier
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the unit suite ran locally on Linux only.

收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

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

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (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: 313 passed · 0 failed · 313 total

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

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

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

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

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

Verification report

PR #10896 deep verification — feat(core): send session ID to Routify endpoints

Verdict: findings — 313 scripted assertions executed, 313 pass / 0 fail.
The central claim is load-bearing and proven on the wire: 25/25 flip cells
(20 positive + 5 session-rotation) go from no header at base to a correct
session_id at head, across all five provider paths. Four findings are worth a reviewer's attention; none is a
correctness regression, and one (F1) is a maintainer product/consent decision
rather than a code defect.

  • Verified head: 82494329cfd3871e08864ff178010852c0676587 (git rev-parse HEAD^2)
  • Base control: 74fe3a659dde2859f152d6c860e04cfddca86d05 (HEAD^1)
  • Round: first (no previous-report.md in the context dir)
中文摘要

结论:findings(313 项脚本断言全部通过,0 失败)。

A/B 结论:中心主张成立且已在真实链路上证明。用真实 HTTPS 服务端冒充三个 Routify 域名(DNS 重定向到本地、本地 CA 签发证书),驱动编译后的真实 generator,覆盖 Anthropic、OpenAI 兼容默认 provider、DashScope、Gemini、Vertex 五条链路。见 A/B 表:25/25 翻转单元格(20 个正向 + 5 个会话轮转)从 base 的「无该请求头」翻转为 head 的「携带正确 session_id」;43/43 单元格的请求头名集合在两臂之间完全一致,凭证头取值逐字节相同;会话轮转(startNewSession(),即 /clear 实际调用的路径)在同一个 generator 实例上观测到 A→B。

Findings(按严重度)

  • F1(需 maintainer 决策):本 PR 把一个发往第三方 LLM 端点的稳定关联标识做成无条件、无任何开关,同时删掉了 docs/developers/development/telemetry.md 中记录「此类请求头必须在默认关闭的 outboundCorrelation.* 命名空间下、经独立威胁模型与 operator 同意流程提出」的段落(含 PR feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 评审确立的原则)。代码本身无缺陷,暴露面也被实测限定在三个精确 HTTPS 主机;问题在于与仓库已记录的同意策略冲突。
  • F2(实测):白名单只在第一跳生效。四个重定向单元格全部把 session_id 带到了白名单外的主机,而 authorization 被 Fetch 标准在跨源重定向时剥离——新标识比凭证活得更久。见 重定向边界 一节。Gemini 链路上 x-goog-api-key 同样跨源,但那是既有 SDK/Fetch 行为,非本 PR 引入。
  • F3(描述准确性 / dead switch)default.ts 的那处改动在生产中不可能携带该请求头——三个符合条件的主机有 0 个会选中 DefaultOpenAICompatibleProviderisDashScopeProvider 把任意 *.alibaba-inc.com 判为 DashScope,且它在 determineProvider 中最先判断)。该 hunk 单独构造时功能正常,属于一致性防御,但描述中「OpenAI-compatible 链路已覆盖」会让人误以为默认 provider 服务 Routify 流量。
  • F4(次要)web-search.ts 自建了一个 OpenAI 客户端,其准入判断接受 *.alibaba-inc.com,但从未包装 fetch。对 shipped dist 做普查:只有 3 个模块安装了 buildSessionAwareFetchweb-search.js 为 0。PR 已声明「工具请求不在范围内」,但设计文档的「Provider coverage」未提及树内还存在第五个可指向同一批主机的 OpenAI 客户端。

已排除(未成立的更坏猜测):Gemini 请求级 httpOptions.headers 没有顶掉客户端级请求头(43/43 一致,user-agent 逐字节相同);各种主机名伪造(userinfo、fragment/query/path 放置、反斜杠、百分号编码斜杠、内嵌 tab/换行、西里尔同形字、截断 TLD、IPv6 字面量、协议相对、结尾点)全部 fail closed(47/47);纯 HTTP fail closed;空 session id 产生 {} 而非空值请求头;带凭证的 URL 在到达白名单判断之前就被 fetch 拒绝。

变异矩阵:16 行中 15 行被杀掉;阳性对照 M0(改请求头名)在被改文件自己的测试里红了 7 项,证明该命令确实收集到了相关测试。唯一存活者 M2(.toLowerCase())判定为冗余防御而非覆盖缺口:把编译产物中的该调用删掉后重跑 47 项探针,结果 47/47 完全不变——因为 WHATWG URL 本身已把主机名转小写。

未覆盖范围:逐 commit 归因(depth-2 浅克隆下 git rev-list HEAD^1..HEAD^2 返回 1,快照记录 7 个 commit);对当前 main 的试合并(快照 baseRefOid 本地不存在);流式链路与 embedContent 的真实链路验证;真实 TUI 中敲 /clear;真实 Routify 线上请求;Windows/macOS;仓库级 lint 与全量测试套件;F2 的修复方案未实现也未实测。详见 Not covered


1. Scope

Central claim. An outbound LLM request addressed over HTTPS to one of the
three exact hostnames routify.alibaba-inc.com, routify-online.alibaba-inc.com,
routify-pub.alibaba-inc.com carries exactly one non-empty session_id header
equal to the active Config.getSessionId(); every other destination carries
none; and pre-existing headers (including credentials) are untouched.

Secondary claim 1. The value is resolved per request, so the session
rotation /clear performs changes the header without rebuilding the SDK
client
.

Secondary claim 2. All five provider paths are covered (OpenAI-compatible,
DashScope, Anthropic, Gemini, Vertex) and proxy-aware fetch is preserved.

Two mechanisms implement this, and they differ in a way that matters:

mechanism paths destination tested
A — buildSessionAwareFetch() installed as the SDK's fetch Anthropic, OpenAI-compatible default, DashScope the real request URL at fetch time
B — request-level httpOptions.headers Gemini, Vertex (LlmContentGenerator) the configured baseUrl (httpOptions?.baseUrl ?? this.clientBaseUrl)

Out of scope by choice: streaming and embedContent on the wire, a live TUI
/clear, a live Routify request, Windows/macOS, repo-wide lint and the full
test suite. Listed in Not covered.

2. Central claim — A/B table

Witness: 01-ab-session-id-none-at-base-sid-at-head.png.

Both arms ran the identical harness file against the identical fake
peer
; only CORE_DIST differs. ARM=base rewrites every expectation to
"header absent", so a base-arm red would be an unexpected outcome — there were
none.

ARM=head  74 assertions passed, 0 unexpected, 0 cells never reached the peer (46 cells, 52 requests)
ARM=base  49 assertions passed, 0 unexpected, 0 cells never reached the peer (46 cells, 52 requests)
COMPARE  119 assertions passed, 0 unexpected
cell group driver (class actually serving it, read at runtime) base head
positive ×3 hosts anthropicAnthropicContentGenerator none SID
positive ×3 hosts openai-defaultdirect:DefaultOpenAICompatibleProvider none SID
positive ×3 hosts openai-factoryfactory:DashScopeOpenAICompatibleProvider none SID
positive ×3 hosts dashscopedirect:DashScopeOpenAICompatibleProvider none SID
positive ×3 hosts geminiLlmContentGenerator(gemini) none SID
positive ×3 hosts vertexLlmContentGenerator(vertex) none SID
uppercase host anthropic, gemini none SID
flip total 25/25 noneSID
subdomain / suffix lookalike / prefix lookalike / sibling / evil.test anthropic, gemini none none
trailing-dot FQDN routify-pub.alibaba-inc.com. anthropic, gemini none none
plain http:// to a listed host anthropic, gemini none none
userinfo https://routify-pub…@evil.test/ anthropic, openai-default, gemini refused refused
rotation (startNewSession() mid-client) all 5 paths none A → B
redirect 302 → evil.test anthropic, openai-default, openai-factory, gemini none,none SID,SIDF2

Every cell asserts it reached the peer before "absent" is accepted, so no
negative cell is vacuous; 0 of 46 cells failed to reach it on either arm. The
userinfo cells assert the platform refusal itself (no request emitted, driver
error raised) rather than being counted as an absence.

Secondary claim 1 verified as stated. The rotation cells reuse the same
generator instance
across cliConfig.startNewSession(SESSION_B) — the exact
call /clear makes (packages/cli/src/ui/commands/clearCommand.ts:98) — and
observed A → B on all five paths.

Header preservation: 43/43 cells have an identical header-name set across
arms
(head minus session_id), user-agent byte-identical on all 20
positive cells, and every credential header (authorization, x-api-key)
byte-identical. This disproves the risk I went in expecting on mechanism B:
that injecting request-level httpOptions.headers would displace the
client-level User-Agent / x-gemini-api-privileged-user-id set in
createLlmContentGenerator. It does not — @google/genai 2.6.0 merges them.

Destination sweep (witness 03-destination-sweep-47-of-47-fail-closed.png):
47/47 probes of buildSessionIdHeaders() match the specification quoted from
the PR's own design doc. Fail-closed on: http://, HTTP://, file:, ws:,
subdomain, deep subdomain, …com.attacker.test, no-dot suffix lookalike,
prefix lookalike, sibling host, truncated TLD, userinfo, backslash-before-@,
listed host in fragment / query / path, embedded tab, embedded newline,
Cyrillic-о homograph, percent-encoded slash, IPv6 literal, trailing dot,
empty string, unparseable string, protocol-relative, scheme-only. Present on:
the three hosts, explicit :443, non-default port, no path, root path,
uppercase and mixed case, surrounding whitespace, URL object, Request
object. Empty session id yields {}, not an empty header. A caller-set
session_id is replaced by the active session.

3. Corrections to the PR description

Labelled as corrections to the description, not requests to change code.

  1. "OpenAI-compatible … request paths are covered." For the three Routify
    hostnames the OpenAI-compatible path that actually runs is DashScope,
    never the default provider: isDashScopeProvider() treats any
    *.alibaba-inc.com host as DashScope
    (provider/dashscope.ts:231-236, "Internal Alibaba domains proxying to
    DashScope-compatible APIs") and determineProvider() tests it first. Probed
    on both arms — header-eligible hosts that reach default.ts buildClient(): 0 of 3. See F3.
  2. The design doc's redirect sentence ("Standard fetch redirect behavior
    applies after the initial destination check, so a Routify response can
    forward the header by redirecting the request") describes as benign
    forwarding what is in fact a defeat of the allowlist that the same document
    presents as the security boundary. See F2.
  3. Test count. The plan states "392 focused unit tests". I measured 404
    passing across the six touched test files (6 files, 16.9 s). 0 failures
    either way; the difference is almost certainly file selection.

4. Findings

F1 — a third-party correlation identifier lands outside the repo's default-off consent namespace, and the paragraph recording that requirement is deleted

Severity: needs a maintainer decision. Not a code defect.

The repository already has a settings namespace whose sole documented purpose
is this exact data flow, and whose documented policy is opt-in:

packages/core/src/config/config.ts:616-628 — "Security-relevant settings
controlling what client-side correlation data qwen-code writes into outbound
LLM API requests. … telemetry controls data flow into the user's OWN
observability backend … The settings here control data flow OUT of the
qwen-code process and INTO third-party LLM provider request streams
(DashScope, OpenAI, Anthropic, etc.). Different recipients = different
consent decision
, so a different settings tree. All values default to off
/ no propagation.
Operators who want to propagate trace context … opt in
explicitly
."

packages/cli/src/config/settingsSchema.ts:1440 — "SECURITY-RELEVANT.
All values default to off."

This PR deletes the paragraph in docs/developers/development/telemetry.md
that recorded how a session-ID header must be proposed:

"X-Qwen-Code-Session-Id and X-Qwen-Code-Request-Id are not part of
this PR
. They will be designed and proposed in their own follow-up PR(s)
under the same outboundCorrelation.* namespace, each with its own
threat model and operator-consent flow. PR #4390 review (LaZzyMan)
established the principle: 'telemetry's scope of work doesn't include sending
identifiers to LLM providers' …"

and replaces it with: "This behavior is not controlled by telemetry.enabled
or outboundCorrelation.*."
The new design doc states: "The behavior is
intentionally not configurable."
The module's own debug logger is named
OUTBOUND_CORRELATION (packages/core/src/core/outbound-session-id.ts:10) —
the namespace it does not join.

Reproduce:

git diff HEAD^1..HEAD -- docs/developers/development/telemetry.md
sed -n '616,650p' packages/core/src/config/config.ts
sed -n '1433,1442p' packages/cli/src/config/settingsSchema.ts

Bounded as usual. The technical exposure this round measured is narrow and
the code is correct: three exact hostnames, HTTPS only, no suffix or wildcard
match, 47/47 hostile shapes fail closed, no displacement of existing headers,
no opt-out needed to keep working. The finding is not "this leaks"; it is that
the repo's recorded answer to "may qwen-code write a client-side correlation
identifier into a third-party LLM request stream?" was "only behind an explicit
opt-in under outboundCorrelation.*", and this PR answers it the other way
while removing the record of the question. Whether a first-party Alibaba
ModelRouter endpoint is a "third-party recipient" for that policy is a product
call, not something this verification can settle — but it should be made by a
maintainer explicitly, and the deleted paragraph should not silently disappear.

F2 — the allowlist is enforced on the first hop only: session_id follows a 302 to a host outside it, while authorization does not

Severity: medium. Measured, 4/4 cells. Witness
04-redirect-leak-session-id-outlives-authorization.png.

The design doc names the exact-host comparison as the security boundary. It
holds on the first hop and is then bypassed by standard redirect following:

driver: anthropic    provider: AnthropicContentGenerator
  hop 1 (allowlisted)   Host=routify-pub.alibaba-inc.com:8443 POST /rd/v1/messages
                        session_id = sess-A-aaaaaaaa-1111
                        authorization=Bearer test-key
  hop 2 (302 target)    Host=evil.test:8443       GET  /v1/messages
                        session_id = sess-A-aaaaaaaa-1111
                        authorization=<ABSENT>

HEAD: session_id crossed to a non-allowlisted host on 4 of 4 redirect cells;
      a credential header crossed on 1.
BASE: session_id crossed on 0 (the header does not exist at base).

The asymmetry is the point: the Fetch standard strips Authorization on a
cross-origin redirect, so the platform already treats a credential as
something that must not outlive the origin it was issued for. Nothing strips
session_id, because the check lives in the caller, not per hop. On the Gemini
path x-goog-api-key also crosses — that is pre-existing @google/genai +
Fetch behaviour and is not this PR's contribution; the PR's contribution is
session_id.

Reproduce (both arms; the base arm shows 0 crossings):

DIR=tmp/pr10896-verify-20260905-045351
NODE_EXTRA_CA_CERTS=$DIR/certs/ca.pem ARM=head \
  CORE_DIST=$PWD/packages/core/dist/src OUT_JSON=$DIR/raw/requests-head.json \
  CERTS=$DIR/certs node --import ./$DIR/dns-preload.mjs $DIR/01-wire-oracle.mjs
DIR=$DIR node $DIR/06-redirect-evidence.mjs

Precondition, stated plainly so this is not over-read: it requires the
allowlisted endpoint — or anything able to make it respond — to emit a 3xx to
an attacker-chosen host. That is a compromised or misconfigured router, or an
open redirect on any path of those three hosts. It is not triggerable by an
arbitrary third party with no foothold there, and no exploit is demonstrated
here. What the measurement does establish is that the boundary the design doc
advertises ("It does not use suffix matching, wildcards, path matching, or a
user-configurable allowlist") is a first-hop property, and the doc's
sentence about redirects reads as a feature rather than as that limitation.

I did not implement or measure a fix. The options each cost something:
redirect: 'manual'/'error' would change SDK retry and streaming semantics;
re-checking per hop needs a dispatcher rather than a fetch wrapper; accepting
it needs the trust assumption on the Routify endpoint written down as such.

F3 — default.ts's hunk cannot carry the header in production: 0 of 3 header-eligible hosts select it

Severity: low (description accuracy / dead switch).

baseUrl                                         provider chosen by determineProvider()        default.ts hunk reachable?
https://routify.alibaba-inc.com:8443            DashScopeOpenAICompatibleProvider             no
https://routify-online.alibaba-inc.com:8443     DashScopeOpenAICompatibleProvider             no
https://routify-pub.alibaba-inc.com:8443        DashScopeOpenAICompatibleProvider             no
https://api.example.com:8443                    DefaultOpenAICompatibleProvider               YES (but not a Routify host)
header-eligible hosts that reach default.ts buildClient(): 0 of 3      (identical on both arms)

isDashScopeProvider() returns true for any hostname ending .alibaba-inc.com
(provider/dashscope.ts:231-236) and determineProvider() tests DashScope
first, so the two sets — "hosts that select the default provider" and "hosts
eligible for the header" — do not intersect. The same ordering also excludes the
seven subclasses that inherit buildClient().

The hunk is not broken: constructed directly it works, and the harness
proves it (direct:DefaultOpenAICompatibleProvider cells show SID on all
three hosts, and mutation M14 removing the wrapper was killed by
default.test.ts). It is defensible as consistency for a future allowlist
entry outside alibaba-inc.com. But per the AGENTS.md rule on added switches,
its read site decides the severity: today no production configuration can reach
it with a header-eligible destination, so the description's "OpenAI-compatible
… paths are covered" is carried entirely by the DashScope hunk.

Reproduce: CORE_DIST=$PWD/packages/core/dist/src ARM=head OUT_JSON=/tmp/ps.json node tmp/pr10896-verify-20260905-045351/04-provider-selection.mjs

F4 — web-search.ts builds an OpenAI client that its own gate accepts for *.alibaba-inc.com, but never wraps it

Severity: low. Static evidence (read + census over the shipped artifact), not driven end-to-end.

classifyDashScopeBaseUrl() accepts 'alibaba-inc.com' as a suffix
(packages/core/src/tools/web-search.ts:145), so
WEB_SEARCH_BASE_URL=https://routify-pub.alibaba-inc.com/… passes the tool's
own admission gate. That tool then builds a client at web-search.ts:652 with
buildRuntimeFetchOptions(...) and no fetch: wrapper.

Census over the compiled output — which modules install the wrapper:

packages/core/dist/src/core/anthropicContentGenerator/anthropicContentGenerator.js
packages/core/dist/src/core/openaiContentGenerator/provider/dashscope.js
packages/core/dist/src/core/openaiContentGenerator/provider/default.js
packages/core/dist/src/tools/web-search.js   ->  0 occurrences

So an LLM chat-completions request to one of the exact three hosts carries no
session_id when it comes from web search, while every content-generator
request to the same host carries one — an operator correlating a session would
see a gap. The PR explicitly scopes out "MCP/tool requests", so this may be
intentional; the finding is that the design doc's Provider coverage list does
not mention that a fifth in-tree OpenAI client exists and can be pointed at the
same hosts.

Disproved — consequences that do not hold

Recorded so the findings above are not over-read:

  • No header displacement on the Gemini path. 43/43 cells identical
    header-name sets; user-agent byte-identical; credentials byte-identical.
  • No hostname confusion. 47/47 destination probes fail closed on every
    spoofing shape tried, including the ones that defeat naive string matching.
  • No plaintext exposure. http:// to a listed host yields no header on
    both mechanisms.
  • No empty header. An empty session id yields {}.
  • No credential-bearing-URL bypass. Fetch refuses the URL before the
    allowlist is consulted (3/3 cells, both arms).
  • No non-LLM blast radius. The wrapper is installed at exactly three SDK
    client constructors; MCP, tool fetches and subprocesses are untouched.

5. Mutation matrix — vacuity check on the PR's tests

Witness 02-mutation-matrix-15-killed-1-redundant.png. Suite under mutation:
the six test files the PR touches, baseline 404 passing.

ID guard mutated result failing tests
M0 POSITIVE CONTROL: header renamed killed 7/404
M1 HTTPS-only requirement killed 1/404
M2 hostname .toLowerCase() SURVIVED 0/404
M3 exact-host allowlist killed 6/404
M4 empty session id yields no header killed 1/404
M5 invalid URL fails closed killed 1/404
M6 wrapper preserves Request headers killed 2/404
M7 wrapper preserves init headers killed 2/404
M8 active session overrides caller-set session_id killed 1/404
M9 runtime/proxy fetch preserved as the base killed 4/404
M10 Gemini falls back to client-level baseUrl killed 3/404
M11 embedContent gets the header killed 1/404
M12 Anthropic installs the wrapper killed 1/404
M13 DashScope installs the wrapper killed 2/404
M14 Default provider installs the wrapper killed 2/404
M15 COMBINATION: all three provider wrappers removed together killed 5/404

15 killed / 1 survived / 0 not applied.

The positive control M0 mutated the header name in outbound-session-id.ts and
turned 7 tests red across four files (anthropicContentGenerator,
llm-content-generator, dashscope, default), so the chosen command does
collect tests that exercise the mutated module — the survivor is about the
suite, not about the harness. M15 is the combination row: removing the three
provider wrappers together kills 5 tests, so the set is load-bearing.

One incidental observation from M0, which is why its reds are worth listing:
none of the 7 fell in outbound-session-id.test.ts, the mutant's own file.
That suite contains zero occurrences of the literal 'session_id' — every
assertion keys off the exported SESSION_ID_HEADER constant
(outbound-session-id.test.ts:34,62,63,113,137,152,168), so it is
self-referential on the wire contract and cannot detect a change to it. The
actual header name is pinned only by the three provider tests that spell
'session_id' literally. Not a defect and not a merge condition — the constant
is the right thing for those assertions to use — but it means a rename of the
wire contract is caught by the integration layer, not by the unit layer, which
is the reverse of the usual expectation.

M2 adjudicated: redundant defence, not a coverage gap. Deleting
.toLowerCase() leaves every test green — and it also leaves every behaviour
unchanged, because WHATWG URL already lowercases the host during parsing
(new URL('https://ROUTIFY-PUB.ALIBABA-INC.COM/x').hostname
routify-pub.alibaba-inc.com). I proved this rather than inferring it: I
applied M2 to the compiled module and re-ran the 47-probe destination sweep
against it — 47/47 passed, identical output, with the uppercase and
mixed-case hosts still receiving the header. So the clause cannot decide any
outcome; nothing is untested and no code should change. (My initial
expectKilled prediction for M2 was wrong; the aggregation counts it as an
adjudicated pass and this paragraph is the audit trail.)

6. Reviewer Test Plan, walked step by step

step outcome
1. Point each provider protocol at a Routify HTTPS endpoint, capture the request, confirm one non-empty session_id with auth/content headers unchanged done. 25/25 flip cells (20 positive = 6 driver constructions × 3 hosts + 2 uppercase, plus 5 rotation); exactly one session_id; 43/43 identical header-name sets; credential values byte-identical
2. /clear, send again, confirm session_id changes done, with a substitution. No interactive TUI in this sandbox, so I drove the exact call /clear makes (clearCommand.ts:98config.startNewSession()) on the same Config and the same generator instance: A → B on all 5 paths
3. Subdomain, lookalike alibaba-inc.com host, plain-HTTP Routify URL, unrelated provider → absent done. 14 wire cells (plus 3 userinfo cells asserting platform refusal) and the 47-probe sweep, all absent / fail closed
4. Run the focused core tests ("392 tests") done, count differs. 6 files, 404 passed, 0 failed, 16.9 s

7. Not covered

  • Per-commit attribution. git rev-list HEAD^1..HEAD^2 returns 1 at the
    depth-2 shallow boundary while the metadata snapshot lists 7 commits, so
    individual commits (notably fix(core): align Gemini session destination and
    fix(core): address session header review feedback) were not exercised
    separately. I verified the aggregate HEAD^1..HEAD diff only.
  • Trial merge into current main. The snapshot's baseRefOid
    (9bb2f853…) is not present locally (git cat-file fails), so I could not
    test the merge against today's main tip. The base used is HEAD^1 =
    74fe3a659d, correct for a merge-ref checkout.
  • Streaming and embeddings on the wire. Only generateContent was driven.
    generateContentStream (Anthropic SSE, Gemini :streamGenerateContent) and
    embedContent were not exercised against the peer; embedContent's header
    injection is covered by the PR's own tests and mutation M11 killed it.
  • A live TUI /clear and a live Routify request (no credentials in this
    sandbox; the PR itself did not replay one either).
  • Windows and macOS runtimes — the PR marks both unverified.
  • Repo-wide gates. Only packages/core tsc --noEmit and the six touched
    test files ran. No lint, no CLI package tests, no full suite, no integration
    tests.
  • Base-worktree build noise. The isolated tsc --build in the base worktree
    reported pre-existing errors in ajv / ignore / qwenIgnoreParser.ts /
    schemaValidator.ts. Those files are untouched by this PR, they come from
    resolving the root copies of two packages that are also nested under
    packages/core/node_modules, and head's tsc --noEmit in the real workspace
    is clean. I did not chase them.
  • F2's mitigation was not implemented or measured; the finding states the
    options and their costs instead.
  • F4 is read-and-census evidence; I did not drive the web-search tool
    end-to-end.

8. Methodology

Environment: the CI merge-ref checkout (HEAD = merge, HEAD^1 = base tip
74fe3a659d, HEAD^2 = PR head 82494329cf), node:22-bookworm, Node
v22.23.2, npm ci + npm run build already completed at head. The wire oracle
(01-wire-oracle.mjs) drives the real compiled generators from
packages/core/dist/src — no stub of any code under test. Its peer is a real
https.createServer on 127.0.0.1 presenting a locally generated CA-signed
certificate whose SANs cover every test hostname, plus a plain-HTTP server for
the scheme cells, and a local GCE-metadata stub provisioned so that no ADC
lookup could reach the network. In the event the Vertex cells supplied an API
key, which the SDK reports takes precedence over project/location, so the stub
was not needed and no ADC traffic was generated. The three Routify hostnames (and the
negative shapes) are resolved to 127.0.0.1 by a --import preload that wraps
dns.lookup, scoped to the harness process; nothing system-wide was modified
(the container is not root, so /etc/hosts was never an option). TLS trusts the
local CA via NODE_EXTRA_CA_CERTS; NODE_TLS_REJECT_UNAUTHORIZED was not
disabled. A real Config is constructed per cell, so getSessionId(),
getProxy() and startNewSession() are the production implementations. The
base arm ran the byte-identical harness against packages/core built at
HEAD^1 in a scratch worktree under tmp/ (since removed): the PR touches
0 dependency manifests, so reusing the root node_modules is a clean
control — I asserted openai, @anthropic-ai/sdk and @google/genai all
realpath into the root tree (via import.meta.resolve, not require.resolve,
which throws ERR_PACKAGE_PATH_NOT_EXPORTED on these ESM-only packages), linked
only the nested third-party packages/core/node_modules (it contains no
@qwen-code/* entry, so no workspace symlink can pull head code in), and
confirmed the base dist contains 0 occurrences of buildSessionIdHeaders
/ buildSessionAwareFetch. Both arms recorded 52 requests. Raw per-arm logs,
both full peer record streams, the mutation matrix, the sweep results and the
aggregation live in raw/; every harness is a .mjs file in this directory and
reruns as printed.

Flakiness gate log

rounds=5 files=6 skipped=0
file packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts
file packages/core/src/core/llm-content-generator/index.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/llm-content-generator/index.test.ts
file packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/llm-content-generator/llm-content-generator.test.ts
file packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/openaiContentGenerator/provider/dashscope.test.ts
file packages/core/src/core/openaiContentGenerator/provider/default.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/openaiContentGenerator/provider/default.test.ts
file packages/core/src/core/outbound-session-id.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/outbound-session-id.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: PPPPP
  packages/core/src/core/llm-content-generator/index.test.ts: PPPPP
  packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: PPPPP
  packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: PPPPP
  packages/core/src/core/openaiContentGenerator/provider/default.test.ts: PPPPP
  packages/core/src/core/outbound-session-id.test.ts: PPPPP

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

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 1 · packages/core/src/core/llm-content-generator/index.test.ts: P (exit 0)
round 1 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 1 · packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: P (exit 0)
round 1 · packages/core/src/core/openaiContentGenerator/provider/default.test.ts: P (exit 0)
round 1 · packages/core/src/core/outbound-session-id.test.ts: P (exit 0)
round 2 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 2 · packages/core/src/core/llm-content-generator/index.test.ts: P (exit 0)
round 2 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 2 · packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: P (exit 0)
round 2 · packages/core/src/core/openaiContentGenerator/provider/default.test.ts: P (exit 0)
round 2 · packages/core/src/core/outbound-session-id.test.ts: P (exit 0)
round 3 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 3 · packages/core/src/core/llm-content-generator/index.test.ts: P (exit 0)
round 3 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 3 · packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: P (exit 0)
round 3 · packages/core/src/core/openaiContentGenerator/provider/default.test.ts: P (exit 0)
round 3 · packages/core/src/core/outbound-session-id.test.ts: P (exit 0)
round 4 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 4 · packages/core/src/core/llm-content-generator/index.test.ts: P (exit 0)
round 4 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 4 · packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: P (exit 0)
round 4 · packages/core/src/core/openaiContentGenerator/provider/default.test.ts: P (exit 0)
round 4 · packages/core/src/core/outbound-session-id.test.ts: P (exit 0)
round 5 · packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts: P (exit 0)
round 5 · packages/core/src/core/llm-content-generator/index.test.ts: P (exit 0)
round 5 · packages/core/src/core/llm-content-generator/llm-content-generator.test.ts: P (exit 0)
round 5 · packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts: P (exit 0)
round 5 · packages/core/src/core/openaiContentGenerator/provider/default.test.ts: P (exit 0)
round 5 · packages/core/src/core/outbound-session-id.test.ts: P (exit 0)

Evidence images

01-ab-session-id-none-at-base-sid-at-head

02-mutation-matrix-15-killed-1-redundant

03-destination-sweep-47-of-47-fail-closed

04-redirect-leak-session-id-outlives-authorization

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

Qwen Code · sandboxed verification

@qqqys qqqys 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.

APPROVE (verified at head 8249432)

Historical blocking item — fixed at head code

R2-1, the sole Critical (round-2 CHANGES_REQUESTED): buildHttpOptions used to fall through to contentGeneratorConfig.baseUrl — a destination the GoogleGenAI client never sends to — letting the exact-host boundary be blessed by a divergent constructor shape. At this head the third fallback is gone verbatim per the requested fix (destination = httpOptions?.baseUrl ?? this.clientBaseUrl), and the guard test it asked for exists and goes under the honest name: “does not infer the SDK destination from content generator config” (test file line 293), alongside three more destination-precedence tests (constructor-only, request-over-constructor, request-level Routify). Round 5 at this exact head posted zero findings. GitHub’s reviewDecision still reads CHANGES_REQUESTED only because the requesting bot’s later rounds at this head were COMMENT-class reviews, which do not flip the flag procedurally — the code it blocked has since been rewritten to its prescription.

My Critical-only scan (injection boundary)

  • The allow-list is exact-host (https: protocol + hostname lowercased + includes on the three Routify hosts), anything else returns {}; every failure path (URL parse throw, missing fetch input) is fail-closed toward no-injection. The session identifier can only reach the three first-party Routify hosts.
  • The fetch wrapper composes correctly on all three wiring styles: OpenAI providers and Anthropic pass runtimeOptions?.fetch as the wrapper's base (no override loss — the fetch: key sits after the ...runtimeOptions spread deliberately); the wrapper merges Request-headers + init-headers + session_id rather than clobbering, and the no-session case short-circuits to the base fetch untouched.
  • Gemini path reads destination from exactly the value the client was constructed with (clientBaseUrl = finalOptions.httpOptions?.baseUrl), so injection and delivery destination cannot diverge — the R2-1 class, closed; and createLlmContentGenerator keeps mirroring config.baseUrl into httpOptions.baseUrl, with argument order to the new constructor parameters verified against the signature.
  • No new Critical found in the docs change or test additions; live behavior was independently confirmed against a real Routify rig by the maintainer’s verification report on the thread (head then 0c63f0f9), and the human maintainer has approved this exact head.

CI at head

17 green, zero failures; Test (ubuntu-latest) and the fleet-web-shell smoke show cancellations (the recurring contended-pool condition this week, on a PR whose core-package change the smoke lane never executes) — non-attributable, non-gating per policy.

@DragonnZhang
DragonnZhang added this pull request to the merge queue Sep 5, 2026
Merged via the queue into QwenLM:main with commit 53e3fc7 Sep 5, 2026
110 of 112 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review for the re-run @wenshao asked for. Heads up on the framing: this PR merged at 04:20:32Z, five minutes after the trigger, so nothing below gates it — the two follow-ups at the end are for a subsequent PR, not for this one.

Code review

I wrote my own baseline before opening the diff: one small module holding an exact-host + HTTPS gate, a fetch wrapper that re-reads the session ID per request so /clear rotation works without rebuilding SDK clients, hook it at each SDK client construction, and use the GenAI SDK's request-level httpOptions for Gemini since that client doesn't take a fetch wrapper. That is what this PR built, so I have no simpler path to offer.

It beats my baseline in one place I'd have got wrong. My instinct for the Gemini destination would have been contentGeneratorConfig.baseUrl as a fallback — which is exactly the R2-1 Critical the review bot caught in round 2: that value is not a URL the GoogleGenAI client ever sends to, so the gate could bless an injection whose real destination was generativelanguage.googleapis.com. At head the chain is httpOptions?.baseUrl ?? this.clientBaseUrl, where clientBaseUrl is finalOptions.httpOptions?.baseUrl — the same string the SDK was constructed with — and createLlmContentGenerator mirrors config.baseUrl into httpOptions.baseUrl. Injection decision and delivery destination can no longer diverge, and there's a guard test named honestly for it ("does not infer the SDK destination from content generator config").

What I checked beyond the diff:

  • Every consumer of the changed signatures. createLlmContentGenerator has exactly one production call site, contentGenerator.ts:569, which already passes the CLI Config — so the new third ctor arg is populated on the real USE_GEMINI/USE_VERTEX_AI path, not a dead switch. The deprecated GeminiContentGenerator/createGeminiContentGenerator aliases re-export the same symbols. Direct new LlmContentGenerator(...) outside index.ts is test-only; an external caller omitting cliConfig gets buildHttpOptions returning httpOptions untouched — no injection, no breakage.
  • All three fetch wirings. cliConfig is a non-optional Config on DefaultOpenAICompatibleProvider (line 79) and inherited by DashScope via super(...), so the buildSessionAwareFetch(..., this.cliConfig) calls are type-safe, and runtimeOptions in the Anthropic generator is a const object, making the non-optional .fetch access safe. In all three the fetch: key sits after the ...runtimeOptions spread deliberately, so the runtime fetch becomes the wrapper's base rather than being clobbered.
  • The gate fails closed on every path. requestUrl() swallows parse errors; url?.protocol !== 'https:' short-circuits before url.hostname is ever dereferenced when the URL is undefined; a cross-realm URL falls through to new URL(input.url) which throws and is caught. Empty session ID → {}. Off-allowlist → fetchLike(input, init) with the original arguments, byte-for-byte untouched.
  • Reuse. The round-1 duplication finding (the same base-fetch + wrapper wiring pasted at three sites) was fixed by extracting buildSessionAwareFetch, and it composes on top of the existing buildRuntimeFetchOptions proxy/undici machinery instead of parallel-implementing it. That's the right call.

No AGENTS.md violations. kebab-case.ts, collocated test, ESM, no any (uses unknown plus explicit narrowing casts), no cross-package relative imports, license header present, comments near-zero, and the module sits in packages/core/src/core/ where it belongs.

No Critical findings at head. The only Critical ever raised on this PR (R2-1) is verifiably gone.

sequenceDiagram
    participant P1 as Provider client (OpenAI, DashScope, Anthropic)
    participant P2 as buildSessionAwareFetch wrapper
    participant P3 as outbound-session-id gate
    participant P4 as Config
    participant P5 as base runtime fetch
    participant P6 as Routify endpoint
    P1->>P2: fetch(input, init) once per HTTP request
    P2->>P3: buildSessionIdHeaders(config, input)
    P3->>P3: parse URL, require https, exact host match
    alt parse fails or host not allowlisted
        P3-->>P2: empty headers
        P2->>P5: pass through, original args untouched
    else one of the three Routify hosts
        P3->>P4: getSessionId() read now, not at construction
        P4-->>P3: current session id
        P3-->>P2: session_id header
        P2->>P2: Request headers, then init headers, then session_id
        P2->>P5: fetch(input, init with merged headers)
    end
    P5->>P6: HTTPS request
Loading
Files changed (14 of 14 shown)
File What changed
packages/core/src/core/outbound-session-id.ts New 86-line module: the three-host HTTPS allowlist, the fail-closed header builder, the fetch wrapper, and the shared runtime-fetch helper. The whole feature's decision logic lives here.
packages/core/src/core/outbound-session-id.test.ts New 170-line suite for the gate: each allowlisted host, subdomain / lookalike / plain-HTTP / non-URL rejections, empty session ID, Request+init header merge and precedence, session rotation, and the globalThis.fetch fallback.
packages/core/src/core/llm-content-generator/llm-content-generator.ts Widens the ctor httpOptions to the SDK's full HttpOptions, stores clientBaseUrl and cliConfig, adds buildHttpOptions, and calls it from the shared generate/stream config builder and from embedContent.
packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +233 lines: constructor-only and request-level destinations, both precedence directions, the config-fallback negative, embedding, and /clear-style rotation across two successive requests.
packages/core/src/core/llm-content-generator/index.ts One line — forwards the CLI Config it already had as the generator's new third argument.
packages/core/src/core/llm-content-generator/index.test.ts Two call sites updated for the extra argument.
packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts Installs the wrapper after the ...runtimeOptions spread, plus an AnthropicFetch type alias for the SDK's narrower fetch signature. Also drops one blank line.
packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +48 lines: asserts a fetch is installed, then executes it against a stubbed runtime fetch and checks the header arrives.
packages/core/src/core/openaiContentGenerator/provider/default.ts One line — fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig) after the runtime spread.
packages/core/src/core/openaiContentGenerator/provider/default.test.ts +26 lines: effect-shaped test executing the installed fetch against both a Routify and an api.openai.com URL.
packages/core/src/core/openaiContentGenerator/provider/dashscope.ts Same one-line wiring as the default provider.
packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +21 lines, same effect-shaped pattern.
docs/design/2026-09-03-outbound-session-id-header.md New design doc: motivation, the security boundary, request lifecycle, provider coverage, and what is explicitly out of scope.
docs/developers/development/telemetry.md Replaces the "Other outbound correlation headers" section with "Routify session affinity". See follow-up 3 below.

Testing

This is an unattended CI run, so per the skill's rules I did not build or execute anything from this PR. Everything below is read from the PR's own CI via the API, plus the live-rig report already on this thread.

The honest headline: the ubuntu unit lane never went green on the merged head. It was cancelled by its own 120-minute job timeout with packages/cli red and packages/core unfinished. That is not the same as "17 green, zero failures" — the second approval counted conclusions but the unit lane's conclusion is cancelled, not success. None of it is attributable to this PR, and the reason it doesn't matter is a commit-delta fact rather than an opinion, given after the table.

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Dependency CVE audit success
Secret scan (TruffleHog) success
Test (ubuntu-latest, Node 22.x) cancelled — 120-min job timeout
web-shell E2E Smoke (ubuntu-latest, Node 22.x) cancelled
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped
CI on merge commit 53e3fc73 (run 33944288984) cancelled before Test/Lint started — superseded by the next push to main at 04:27:32Z under the workflow's concurrency group

What actually happened inside the cancelled Test (ubuntu-latest, Node 22.x) job (101211139089):

packages/cli, first pass, 01:45:06Z:
  Test Files  4 failed | 1007 passed (1011)
       Tests  5 failed | 28490 passed | 90 skipped (28585)
      Errors  3 errors        # [vitest-worker]: Timeout calling "onTaskUpdate" x3
   Duration  4789.96s
   DFSAMPLE 01:45:07 load[232.02 244.56 250.80]

the five failures:
  FAIL src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke point
       > routes every per-request runtime-root pin through runWithPinnedRuntimeBaseDir
       AssertionError: acpAgent.ts must not name runWithAcpRuntimeOutputDir directly.
       4595: return runWithAcpRuntimeOutputDir(settings, cwd, operation);
       9200: return await runWithAcpRuntimeOutputDir(settings, cwd, async () => {
  FAIL src/ui/components/SkillReviewDialog.test.tsx > auto-refreshes the preview when the
       staged file changes on disk            -> vi.waitFor.timeout, 5000ms
  FAIL src/ui/hooks/useWorktreeSession.test.tsx > reacts to sidecar deletion (clear)
                                               -> vi.waitFor.timeout, 2000ms
  FAIL src/ui/hooks/useWorktreeSession.test.tsx > reacts to sidecar creation
                                               -> vi.waitFor.timeout, 2000ms
  FAIL src/ui/utils/export/export-transcript-document.test.ts > bounds repeated-separator
       checks in decoded URL authorities
       -> spawnSync /usr/local/lib/nodejs/node-v22.23.2-linux-x64/bin/node ETIMEDOUT

then the --retry=2 pass opened with @qwen-code/qwen-code-core test:ci at 01:45:14Z
and the job was killed at 02:02:08Z: ##[error]The operation was canceled.
packages/core printed no summary on this head.

Classifying each, from the diff and the check identity rather than from anything the log claims about itself:

  • acpAgent.test.ts is pre-existing on main and not this PR's. It's a deterministic source-scanning guard, not a timing test, so "flaky" is not available as an explanation. It fails identically on two main pushes that contain none of this PR's code and postdate this head: b0a463c6 (run 33935570123, 01:14Z) and b7d302af (run 33939407800, 02:33Z) — Test Files 1 failed | 1012 passed and 1 failed | 1013 passed respectively, same assertion, same two line numbers. It also failed on this PR's earlier head 0c63f0f9. Someone should fix that guard on main; it is currently red for everyone.
  • The other four are runner exhaustion. Three are vi.waitFor timeouts on filesystem-watch behaviour (2000–5000 ms budgets) and one is a bare spawnSync ... ETIMEDOUT, alongside three vitest worker RPC timeouts, on a runner sampling load 232/244/250. All four are in packages/cli UI and export code.
  • Nothing here can be this PR's. The diff touches only packages/core/src/** and docs/**. It has no path to ACP runtime-root pinning, the skill-review dialog, worktree sidecar watching, or transcript export.

Why the missing packages/core result is recoverable. One commit earlier, at 0c63f0f9, the same lane completed packages/core green: Test Files 640 passed | 1 skipped (641), Tests 23434 passed | 10 skipped, including ✓ src/core/outbound-session-id.test.ts (14 tests) 101ms. And 0c63f0f9...82494329 is a single test-only commit+79/-0 in llm-content-generator.test.ts, zero production lines changed. So the green core result carries forward to the merged head by construction, and the wire evidence below does too. Mid-run on the merged head we also catch ✓ src/core/llm-content-generator/llm-content-generator.test.ts (22 tests) at 01:55:50Z before the kill.

Live wire evidence — the maintainer's rig, already on this thread. @wenshao's report is the strongest evidence in this thread and it is not a mock: a bundled CLI in a container with the three ModelRouter hostnames (plus a subdomain, a lookalike, a suffix-lookalike and api.openai.com) hijacked to a local HTTPS server behind a private CA, every inbound request's raw headers written to a JSONL ledger, 20 lanes × 5 protocols plus a tmux TUI lane, each run on head and on the pre-PR base bundle. 26/49 head requests carry session_id, 0/43 base requests do; every value equals the CLI's own session id; same-lane header diff is exactly +session_id with byte-identical bodies; proxy lane logs the CONNECT and injects; operator customHeaders.session_id is overridden while X-Op-Marker survives; /clear rotation confirmed in a live TUI process. Since head moved only by tests after that report, it applies to the merged commit unchanged.

Not verified, and why: real Routify traffic (nobody on the thread has access); whether ModelRouter actually honours the header; Windows/macOS runtime lanes (skipped in CI — the author covered macOS locally, the maintainer's rig is Linux/arm64 in Docker, and the code is platform-independent); packages/core completing a full suite on the merged head specifically (job timeout, mitigated by the test-only delta above); the merged tree post-merge (that CI run was cancelled before it started).

On the sandboxed lanes: no additional lane is needed for this PR's central claim. /verify exists to settle behavioural claims that static review and CI can't, and this one is already settled at wire level by the rig above — an A/B against the base bundle with a raw-header ledger is strictly stronger than anything /verify would produce. A /verify job from this same trigger is in fact running now (run 33944079666, started 04:16:13Z) and will post its own report; the @qwen-code /tmux lane was skipped by that run. The two open items below are code follow-ups, not verification questions — no lane would surface them.

Follow-ups for a subsequent PR

None of these blocked this one and none should have; they're what I'd file now that it's landed.

  1. web_search reaches the same Routify host without the header. packages/core/src/tools/web-search.ts:662 still builds its own new OpenAI({...}) with buildRuntimeFetchOptions('openai', ...) and no session wrapper — I confirmed this at head, not just in the earlier report. Its gate classifyDashScopeBaseUrl (web-search.ts:127–156) explicitly accepts alibaba-inc.com and subdomains, so WEB_SEARCH_BASE_URL=https://routify-pub.alibaba-inc.com/... is a supported configuration whose /responses calls arrive at Routify with Authorization but no session_id, in a session whose chat turns do carry it. The docs claim was narrowed to the five provider paths in 6936a4d0, so it is no longer wrong — but the affinity story has a hole on a supported config. One line (fetch: buildSessionAwareFetch(...) on that client) closes it; otherwise the design doc should name web-search as a deliberate exclusion.
  2. generateContentStream is wired correctly but unpinned by tests. I checked the wiring: line 265 routes through the same buildGenerateContentConfig as line 249, so streaming injects today. The gap is that no test asserts it, and streaming is the path production actually uses. A future refactor that gives streaming its own config builder ships green while Routify streaming requests silently lose affinity. One test mirroring the non-streaming case closes it. This is the review bot's round-3 deferral and the maintainer's F3, still open at head — the final commit added request-level httpOptions coverage for generateContent, not for the stream.
  3. telemetry.md no longer records the decision it reversed. The replaced section carried the feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 review outcome — telemetry doesn't send identifiers to LLM providers, and correlation headers land only under outboundCorrelation.* with their own threat model and operator-consent flow. The new design doc states the new behaviour but never references feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 or explains why the earlier principle doesn't apply here, so nothing in the repo records that it was reconsidered. The direction call itself is the maintainers' and is settled — this is purely about the next reader being able to see the reasoning.
  4. Two provably dead lines (nit, both fail-closed): url.hostname.toLowerCase() is a no-op because the WHATWG parser already lower-cases https: hostnames, and the catch in buildSessionIdHeaders is unreachable because requestUrl() swallows the only throwing call. AGENTS.md asks for no error handling on impossible scenarios. Harmless; worth dropping whenever someone next touches the file.
中文说明

这是 @wenshao 要求 re-run 的代码审查。先说明背景:本 PR 已于 04:20:32Z 合入,就在触发命令五分钟后,因此下面的内容都不构成阻断——最后两条后续项是给后续 PR 的,不是给这个 PR 的。

代码审查。 我在看 diff 之前先写了自己的方案:一个小模块承载"精确主机 + HTTPS"准入判定、一个每次请求重读会话 ID 的 fetch 包装器(这样 /clear 轮换会话时无需重建 SDK 客户端)、在每个 SDK 客户端构造处挂接,Gemini 则用 SDK 的请求级 httpOptions(因为该客户端不接受 fetch 包装)。这正是本 PR 的做法,所以我没有更简的路径可提。

有一处它比我的方案更好,而那一处我本来会写错。我对 Gemini 目的地的直觉会是回退到 contentGeneratorConfig.baseUrl——这恰恰是评审 bot 第 2 轮抓到的 R2-1 Critical:那个值并不是 GoogleGenAI 客户端真正发送的地址,因此准入判定可能放行一个实际目的地是 generativelanguage.googleapis.com 的注入。当前 head 上的链路是 httpOptions?.baseUrl ?? this.clientBaseUrl,其中 clientBaseUrl 就是 finalOptions.httpOptions?.baseUrl——与构造 SDK 时用的同一个字符串——而 createLlmContentGenerator 会把 config.baseUrl 镜像进 httpOptions.baseUrl。注入判定与实际投递目的地不再可能分叉,并且有一条命名诚实的守护测试("does not infer the SDK destination from content generator config")。

diff 之外我核对的内容:

  • 改动签名的每一个消费方。 createLlmContentGenerator 只有一个生产调用点 contentGenerator.ts:569,它本来就传入 CLI Config——因此新增的第三个构造参数在真实的 USE_GEMINI/USE_VERTEX_AI 路径上是有值的,不是死开关。已废弃的 GeminiContentGenerator/createGeminiContentGenerator 别名再导出同一批符号。index.ts 之外直接 new LlmContentGenerator(...) 只出现在测试里;外部调用方不传 cliConfigbuildHttpOptions 原样返回 httpOptions——不注入,也不破坏。
  • 三处 fetch 接线。 cliConfigDefaultOpenAICompatibleProvider 上是非可选的 Config(第 79 行),DashScope 通过 super(...) 继承,因此 buildSessionAwareFetch(..., this.cliConfig) 类型安全;Anthropic 生成器里的 runtimeOptionsconst 对象,非可选的 .fetch 访问也安全。三处的 fetch: 键都刻意放在 ...runtimeOptions 展开之后,因此 runtime fetch 成为包装器的 base,而不是被覆盖掉。
  • 准入判定在所有路径上 fail-closed。 requestUrl() 吞掉解析异常;url?.protocol !== 'https:'url 为 undefined 时会短路,永远不会去解引用 url.hostname;跨 realm 的 URL 会落到 new URL(input.url) 并抛错被捕获。会话 ID 为空 → {}。不在 allowlist → 用原始参数调 fetchLike(input, init),逐字节不变。
  • 复用。 第 1 轮指出的重复(同样的 base-fetch + 包装器接线粘在三个站点)已通过抽出 buildSessionAwareFetch 修复,而且它是叠加在既有 buildRuntimeFetchOptions 的代理/undici 机制之上,不是并行实现。这个选择是对的。

无 AGENTS.md 违规。kebab-case.ts、测试同目录、ESM、无 any(用 unknown 加显式收窄转换)、无跨包相对导入、有 license 头、注释接近于零,模块位置也在它该在的 packages/core/src/core/

head 上无 Critical。本 PR 唯一被提过的 Critical(R2-1)已确证消失。

测试。 这是无人值守的 CI 运行,因此按 skill 规则我没有构建或执行任何来自本 PR 的代码。下面全部内容来自通过 API 读取的 PR 自身 CI,加上本线程已有的实测报告。

老实说的结论:ubuntu 单测泳道在被合入的那个 head 上从未变绿。 它被自身 120 分钟的 job 超时取消,当时 packages/cli 是红的,packages/core 没跑完。这与"17 绿、0 失败"不是一回事——第二个批准数的是 conclusion,而单测泳道的 conclusion 是 cancelled,不是 success。这些都不是本 PR 造成的,而它之所以不要紧,依据是一条 commit 差分事实,不是主观判断(见表格之后)。

被取消的 Test (ubuntu-latest, Node 22.x) job 内部实际发生的事(见上方英文代码块):packages/cli 首轮 4 个测试文件失败 / 1007 通过,5 个用例失败 / 28490 通过,另有 3 个 [vitest-worker]: Timeout calling "onTaskUpdate" 未处理错误,采样到的 runner 负载是 232/244/250。随后 --retry=2 那轮以 @qwen-code/qwen-code-core test:ci 开始于 01:45:14Z,job 在 02:02:08Z 被杀(##[error]The operation was canceled.),packages/core 在这个 head 上没有打印任何汇总。

分类依据是 diff 与 check 身份,而不是日志正文对自己说了什么:

  • acpAgent.test.tsmain 上的既有失败,与本 PR 无关。 它是确定性的源码扫描守护测试,不是计时测试,所以"flaky"这个解释用不上。它在两次不含本 PR 任何代码、且晚于本 head 的 main push 上以完全相同的方式失败:b0a463c6(run 33935570123,01:14Z)与 b7d302af(run 33939407800,02:33Z),断言相同、两处行号相同。它在本 PR 早先的 head 0c63f0f9 上也失败。这个守护测试该在 main 上修——它现在对所有人都是红的。
  • 另外四个是 runner 资源耗尽。 三个是文件系统 watch 行为上的 vi.waitFor 超时(2000–5000ms 预算),一个是裸的 spawnSync ... ETIMEDOUT,同时伴随三个 vitest worker RPC 超时,而 runner 负载采样为 232/244/250。四个全在 packages/cli 的 UI 与导出代码里。
  • 这些都不可能是本 PR 造成的。 diff 只触及 packages/core/src/**docs/**,与 ACP runtime-root pinning、skill-review 对话框、worktree sidecar 监听、transcript 导出之间没有任何路径。

为什么缺失的 packages/core 结果是可以补上的。 早一个 commit,在 0c63f0f9 上,同一泳道跑完了 packages/core 且全绿:Test Files 640 passed | 1 skipped (641)Tests 23434 passed | 10 skipped,其中包含 ✓ src/core/outbound-session-id.test.ts (14 tests) 101ms。而 0c63f0f9...82494329单个纯测试 commit——llm-content-generator.test.ts+79/-0,生产代码零改动。因此那份 core 全绿结果按构造就延续到被合入的 head,下面的链路证据同样延续。在被合入 head 的中途日志里也能看到 ✓ src/core/llm-content-generator/llm-content-generator.test.ts (22 tests)(01:55:50Z),就在 job 被杀之前。

链路实测证据——维护者的靶场,已在本线程。 @wenshao 的报告是本线程里最强的证据,而且不是 mock:打包后的 CLI 跑在容器里,三个 ModelRouter 主机名(外加子域名、相似域名、后缀相似域名和 api.openai.com)被劫持到私有 CA 签发的本地 HTTPS 服务,每个入站请求的原始请求头写入 JSONL 流水账,20 条泳道 × 5 种协议再加一条 tmux TUI 泳道,每条都在 head 和改动前 base 两个包上各跑一遍。head 49 个请求中 26 个带 session_id,base 43 个请求 0 个带;每个值都等于 CLI 自己的会话 ID;同泳道头部差异恰好是 +session_id,请求体逐字节相同;代理泳道既记录到 CONNECT 也完成注入;运维自定义的 session_id 被覆盖而 X-Op-Marker 保留;/clear 轮换在真实 TUI 进程内确认。由于此后 head 只增加了测试,这份报告对被合入的 commit 依然适用。

未验证项及原因:真实 Routify 线上流量(线程上无人有访问权限);ModelRouter 是否真的消费该头;Windows/macOS 运行时泳道(CI 中 skipped——作者本地覆盖了 macOS,维护者靶场是 Docker 内 Linux/arm64,代码本身与平台无关);packages/core 在被合入 head 上跑完整个套件(job 超时,由上面的纯测试差分缓解);合入后的树(那次 CI 在开始之前就被取消了)。

关于沙箱泳道: 本 PR 的核心断言不需要额外泳道。/verify 的用途是解决静态审查和 CI 无法判定的行为断言,而这一条已经被上面的靶场在链路层面解决——针对 base 包的 A/B 加原始请求头流水账,严格强于 /verify 能产出的任何东西。事实上同一次触发已经有一个 /verify job 正在运行(run 33944079666,开始于 04:16:13Z),它会自行发布报告;该运行的 @qwen-code /tmux 泳道被 skipped。下面两条待办是代码后续项,不是验证问题——没有任何泳道能把它们暴露出来。

给后续 PR 的待办(都不构成对本 PR 的阻断,本来也不该阻断):

  1. web_search 打同一个 Routify 主机却没有该头。 packages/core/src/tools/web-search.ts:662 在 head 上仍然自己 new OpenAI({...}),只用 buildRuntimeFetchOptions('openai', ...),没有会话包装——我是在 head 上确认的,不只是引用早先的报告。其准入判定 classifyDashScopeBaseUrl(web-search.ts:127–156)明确接受 alibaba-inc.com 及其子域名,所以 WEB_SEARCH_BASE_URL=https://routify-pub.alibaba-inc.com/... 是受支持的配置,它的 /responses 请求会带着 Authorization 却没有 session_id 到达 Routify,而同一会话的对话请求是带的。文档承诺已在 6936a4d0 收窄到五条 provider 路径,因此不再——但一个受支持配置上的亲和故事有个洞。一行 fetch: buildSessionAwareFetch(...) 即可闭合;否则设计文档应把 web-search 明确列为有意排除。
  2. generateContentStream 接线正确但没有测试钉住。 我核对了接线:第 265 行与第 249 行走同一个 buildGenerateContentConfig,所以流式路径今天是注入的。缺口在于没有测试断言这一点,而流式恰恰是生产实际走的路径。将来某次重构给流式单独建一个 config builder,测试会全绿,而 Routify 流式请求会静默失去亲和值。补一条对照非流式用例的测试即可闭合。这是评审 bot 第 3 轮的延后项、也是维护者的 F3,在 head 上仍未解决——最后那个 commit 补的是 generateContent 的请求级 httpOptions 覆盖,不是流式。
  3. telemetry.md 不再记录它所推翻的那个决定。 被替换掉的那一节承载着 feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390 的评审结论——遥测不向 LLM 提供方发送标识符,关联请求头只有在 outboundCorrelation.* 命名空间下、拥有自己的威胁模型和操作者同意流程才可合入。新的设计文档陈述了新行为,却从未引用 feat(telemetry): client-side HTTP span + opt-in W3C traceparent propagation (#4384) #4390,也没有解释为什么早先的原则在此不适用,因此仓库里没有任何地方记录这一原则被重新考虑过。方向决策本身属于维护者,且已经定了——这一条纯粹是关于后来的读者能否看到推理过程。
  4. 两行可证死代码(吹毛求疵,且都 fail-closed):url.hostname.toLowerCase() 是空操作,因为 WHATWG 解析器已对 https: 主机名做过小写化;buildSessionIdHeaders 里的 catch 不可达,因为唯一会抛的调用已被 requestUrl() 吞掉。AGENTS.md 要求不为不可能的场景写错误处理。无害;下次有人动这个文件时顺手删掉即可。

Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid implementation, direction settled by two maintainers, wire behaviour A/B'd on a real rig; the reservations are follow-ups, not defects.

Stepping back. My independent proposal for this problem was the same shape the author built, so I have no "you could have done it in a third of the code" complaint — and in the one place I'd have improvised (where the Gemini injection decision reads its destination from), the author's version is correct and mine would have been the R2-1 bug. That's the strongest signal I have that this was written by someone who understood the failure modes rather than pattern-matching a header injector.

Does it solve something users care about? Narrowly, yes: Routify's ModelRouter documents session_id for affinity and traffic marking, and the 37-request log the author was given shows none of them carrying it. It's not a broad user-facing feature — it's an operator-side correlation affordance for one internal endpoint family. That's a legitimate thing to ship, but it is exactly the kind of change whose cost is a stable cross-request identifier and whose benefit is mostly on the server side, which is why the direction question I raised in Stage 1 was the right one to stop on. It got a real answer rather than a shrug: @wenshao built a live rig, measured the exposure (no opt-out, first stable correlator on that path, per-session not per-request, propagateTraceContext precedent is opt-in and defaults off), and then approved; @qqqys approved the same head with an independent Critical-only scan of the injection boundary. That is the principle's owner making the call, which is what I asked for. I'm satisfied — though I'd note for the record that @LaZzyMan, whose #4390 review established the principle the docs change removed, never weighed in on the thread.

Is the code straightforward? Yes. 86 lines of production module, one decision function, one wrapper, one helper; every provider hook is a single line placed deliberately after the runtime-options spread. Six months from now I'd thank the author, not curse them — the allowlist is one constant array, the gate is fail-closed on every branch I could find, and the injection decision provably reads the same URL the SDK sends to.

Is every change necessary? Almost. The diff is 133 production lines, 500 test lines, 66 docs lines — the test-to-production ratio is nearly 4:1, which is what five rounds of review produced, and it's the reason the mutation evidence in those rounds kept getting answered with effect-shaped tests instead of expect.any(Function). The only thing I'd call out is the telemetry.md edit: replacing the section outright deleted the record of the #4390 decision, and the new design doc doesn't reference it either, so the repo no longer contains the reasoning for reversing it. That's a docs follow-up, not a reason to have blocked the merge.

Did the results match what the PR promised? Yes, and better than the PR's own evidence did. The author could only offer focused unit tests plus a macOS-local claim (they declined to replay live traffic because the supplied archive contained a plaintext bearer token — the right call). The maintainer's rig supplied what was missing: 26/49 head requests carrying the header against 0/43 on base, exact-host boundary holding against a subdomain, a lookalike, a suffix-lookalike, plain HTTP and api.openai.com, byte-identical bodies, proxy lane intact, operator header override behaving as documented, /clear rotation confirmed in a live TUI. And the head moved only by tests after that report, so it transfers to the merged commit.

The one thing I'd want a maintainer to see before filing this away as clean: the ubuntu unit lane never went green on the merged head. It hit its 120-minute timeout with packages/cli showing 5 failures and packages/core unfinished, and the post-merge CI run on 53e3fc73 was cancelled before its Test and Lint jobs even started. I traced all of it in Stage 2 — one failure is a deterministic source-scan guard that is red on main right now at two commits containing none of this PR's code, and the other four are vi.waitFor/spawnSync ETIMEDOUT on a runner sampling load 232/244/250. This PR touches only packages/core and docs, so none of it is attributable. And the missing core result is recoverable by arithmetic rather than faith: core ran green at 0c63f0f9 (640 files, 23434 tests, including this PR's new gate suite), and 0c63f0f9...82494329 is a single test-only commit. But "green suite" and "suite that ran" are different claims, and only the second one is true at head. That acpAgent.test.ts guard is red for everyone on main and deserves its own fix.

Am I approving because it's good or because I ran out of reasons to say no? Neither, in the end — this PR is already merged, so there is no approval for me to give and no --request-changes I could legitimately submit. I'm recording the verdict instead: on the code as it stands at 82494329 I would have approved, GUARD computes ok (cross-repository, but a feat title, so the fork-refactor guardrail doesn't apply), Stage 0 raised no escalation (133 production lines is nowhere near the size thresholds), and no pull_request-event CI run is still in flight on that head. I'm deliberately not emitting an approve-on-green marker — there's nothing left to defer, and a standing approval instruction on a merged PR is just noise for the finalize job.

@DragonnZhang since you have write access and merged this yourself, the four follow-ups in my Stage 2 comment are yours to file or drop: the web_search client at web-search.ts:662 still reaching Routify without the header, the streaming Gemini path being wired correctly but unpinned by any test, the missing #4390 cross-reference in the docs, and the two dead lines in the new module. The first two are the ones I'd actually open a PR for.

中文说明

信心度:4/5 —— 实现扎实,方向已由两位维护者拍板,链路行为已在真实靶场上做过 A/B;我保留的意见是后续项,不是缺陷。

退一步看整体。我对这个问题独立想到的方案与作者构建的形状相同,所以我没有"本可以用三分之一的代码完成"这类意见——而在我本来会随手发挥的那一处(Gemini 注入判定从哪里读取目的地),作者的版本是对的,我的版本就会是 R2-1 那个 bug。这是我能拿出的最强信号:写这段代码的人理解失效模式,而不是照着"请求头注入器"的模式套。

它解决了用户在意的问题吗?范围很窄,但是:Routify 的 ModelRouter 文档支持用 session_id 做会话亲和与流量染色,而作者拿到的 37 条请求日志中没有一条携带它。这不是面向大众用户的功能——它是面向某一组内部端点的运维侧关联能力。这类改动值得做,但它的代价恰恰是一个跨请求稳定标识符,而收益主要在服务端,这正是我在 Stage 1 停下来提方向问题的原因。那个问题得到了真正的回答,而不是被敷衍:@wenshao 搭了实测靶场,量化了暴露面(没有关闭开关、是该链路上第一个稳定关联标识、按会话而非按请求、propagateTraceContext 先例是显式开启且默认关闭),然后批准;@qqqys 在同一个 head 上以独立的 Critical-only 注入边界扫描批准。这就是该原则的负责人在做决定,也正是我要求的。我认可——但为了记录完整我要指出:其 #4390 评审确立了本次文档改动所删除那条原则的 @LaZzyMan,在线程上始终没有表态。

代码是否直白?是。86 行生产模块、一个判定函数、一个包装器、一个 helper;每个 provider 挂点都是一行,且刻意放在 runtime-options 展开之后。六个月后我会感谢作者而不是骂他——allowlist 是一个常量数组,准入判定在我能找到的每条分支上都 fail-closed,注入判定读的确实是 SDK 实际发送的那个 URL。

每一处改动都必要吗?几乎。diff 是 133 行生产代码、500 行测试、66 行文档——测试与生产代码之比接近 4:1,这是五轮评审的产物,也正是各轮变异证据每次都能被"按效果"的测试回应、而不是被 expect.any(Function) 打发掉的原因。唯一要点名的是 telemetry.md 那处编辑:整节替换删掉了 #4390 决定的记录,而新设计文档也没有引用它,因此仓库里不再包含推翻该决定的推理过程。这是文档后续项,不是当初该阻断合入的理由。

结果与 PR 的承诺相符吗?相符,而且比 PR 自己给出的证据更好。作者当时只能提供定向单测加一个 macOS 本地的说法(他们拒绝重放真实流量,因为提供的压缩包含有明文 bearer token——这个判断是对的)。维护者的靶场补上了缺失的部分:head 49 个请求中 26 个携带该头,base 0/43;精确主机边界在子域名、相似域名、后缀相似域名、明文 HTTP 和 api.openai.com 面前都守住了;请求体逐字节相同;代理泳道完好;运维自定义头的覆盖行为与文档一致;/clear 轮换在真实 TUI 中确认。而此后 head 只增加了测试,所以这份证据延续到被合入的 commit。

有一件事我希望维护者在把这份归档为"干净"之前看到:ubuntu 单测泳道在被合入的 head 上从未变绿。 它撞上自身 120 分钟超时,当时 packages/cli 有 5 个失败、packages/core 没跑完,而 53e3fc73 的合入后 CI 运行在 Test 与 Lint job 开始之前就被取消了。我在 Stage 2 里把全过程追了一遍——其中一个失败是确定性的源码扫描守护测试,此刻在 main 上就是红的,且出现在两个不含本 PR 任何代码的 commit 上;另外四个是 vi.waitFor/spawnSync ETIMEDOUT,而 runner 采样负载为 232/244/250。本 PR 只触及 packages/coredocs,因此都无法归因于它。而缺失的 core 结果可以靠算术而非信念补上:core 在 0c63f0f9 上跑绿过(640 个文件、23434 个用例,含本 PR 新增的准入套件),而 0c63f0f9...82494329 是单个纯测试 commit。但"套件全绿"和"套件跑过"是两个不同的断言,在 head 上只有第二个成立。那个 acpAgent.test.ts 守护测试对 main 上所有人都是红的,值得单独修。

我批准是因为它好,还是因为我找不到拒绝的理由?最终两者都不是——本 PR 已经合入,所以没有批准可给,也没有我可以正当提交的 --request-changes。我改为记录结论:按 82494329 上的代码状态,我会批准;GUARD 计算为 ok(跨仓库,但标题是 feat,因此 fork-refactor 护栏不适用);Stage 0 未提出升级(133 行生产代码远低于任何规模阈值);该 head 上没有仍在运行的 pull_request 事件 CI。我刻意发出 approve-on-green 标记——已经没有任何东西需要延后,而在一个已合入的 PR 上留一条常设批准指令,对 finalize job 只是噪音。

@DragonnZhang 由于你有 write 权限且是自己合入的,我 Stage 2 评论里的四条后续项由你决定立项或放弃:web-search.ts:662 的客户端仍会在没有该头的情况下打到 Routify、Gemini 流式路径接线正确却没有任何测试钉住、文档缺少 #4390 的交叉引用、以及新模块里的两行死代码。前两条是我真的会去开 PR 的。

Qwen Code · qwen3.8-max-2026-09-02

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants