refactor(telemetry): split outbound correlation out of telemetry scope (R4)

Address LaZzyMan round-8 follow-up review on PR #4390: even though R3's
host allowlist made the default behavior safe, the meta-architectural
concern remains: telemetry's namespace and consent flow shouldn't quietly
extend to wire-level behavior aimed at third-party LLM provider request
streams. The recipient sets differ; the consent decisions differ; they
deserve separate namespaces, separate threat models, separate PRs.

This commit (called R4 in the design doc) collapses the PR scope so it
lands ONLY telemetry observability work:

REMOVED from this PR:
  - packages/core/src/telemetry/llm-correlation-fetch.ts(.test.ts)
  - packages/core/src/telemetry/trusted-llm-hosts.ts(.test.ts)
  - telemetry.sessionIdHeaderHosts setting + Config getter +
    resolveTelemetrySettings wiring + settingsSchema entry
  - wrapFetchWithCorrelation usage from four provider construction
    points (default.ts, dashscope.ts, anthropicContentGenerator.ts,
    geminiContentGenerator/index.ts)
  - All session-id provider tests across the four providers + the
    contentGenerator.test.ts mock stub
  - "Session correlation header" section in telemetry.md

ADDED:
  - OutboundCorrelationSettings interface in packages/core/src/config/config.ts,
    standalone top-level namespace separate from TelemetrySettings —
    SECURITY-RELEVANT label, all defaults off
  - Config.getOutboundCorrelationPropagateTraceContext() getter
  - outboundCorrelation top-level entry in settingsSchema.ts with
    propagateTraceContext: { default: false } and explicit
    SECURITY-RELEVANT framing in the description
  - CLI config-load pipeline passes settings.outboundCorrelation into
    ConfigParameters
  - NOOP_PROPAGATOR (TextMapPropagator no-op) in sdk.ts, conditionally
    installed on NodeSDK when propagateTraceContext is false (default).
    When true, omits textMapPropagator from NodeSDK options so the SDK
    keeps its default W3C composite propagator
  - 2 new sdk.test.ts cases covering the propagator gate behavior

UNCHANGED:
  - UndiciInstrumentation registration + OTLP feedback-loop guard +
    HttpInstrumentation OTLP guard from R2/R3 stay intact — they are
    pure telemetry (client HTTP spans into the operator's own OTLP
    collector), no wire-level data egress
  - Documentation rewrites telemetry.md to split "client-side HTTP
    span on outbound fetch" (telemetry) from a new "Outbound
    correlation (SECURITY-RELEVANT)" top-level section
  - design doc gets R4 revision row + new §12 "R4 Scope Conflation
    Split" capturing the rationale and follow-up PR outline

The session-id apparatus (R3 code) lives in git history at commits
1c8528a56 / cb162e716 / 7a1b4f8d0 / 40e1efc1f / 106598ca2; the
follow-up PR can cherry-pick or restore those files under the new
outboundCorrelation.* namespace as LazzyMan suggested.

Vscode-ide-companion settings.schema.json regenerated.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
doudouOUC 2026-05-25 13:42:32 +08:00
parent 7a1b4f8d00
commit 9bdd3bd6f9
22 changed files with 334 additions and 1564 deletions

View file

@ -7,13 +7,14 @@
## 修订历史 ## 修订历史
| 修订 | 日期 | 触发 | 摘要 | | 修订 | 日期 | 触发 | 摘要 |
| ---- | ---------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- | | ---- | ---------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1 | 2026-05-21 | 初稿 | 全广播:所有出站 LLM 请求都带 `X-Qwen-Code-Session-Id` + `traceparent` | | R1 | 2026-05-21 | 初稿 | 全广播:所有出站 LLM 请求都带 `X-Qwen-Code-Session-Id` + `traceparent` |
| R2 | 2026-05-22 | wenshao R2/R3 review | 边界安全URL normalize、port matching、quote 对齐、staticCorrelationHeaders try/catch、host:port fallback strip | | R2 | 2026-05-22 | wenshao R2/R3 review | 边界安全URL normalize、port matching、quote 对齐、staticCorrelationHeaders try/catch、host:port fallback strip |
| R3 | 2026-05-23 | LaZzyMan REQUEST_CHANGES | **重大语义改动**`X-Qwen-Code-Session-Id` 默认作用域收窄到 first-partyAlibaba/DashScopehost 白名单。详见 §11 | | R3 | 2026-05-23 | LaZzyMan REQUEST_CHANGES | **重大语义改动**`X-Qwen-Code-Session-Id` 默认作用域收窄到 first-partyAlibaba/DashScopehost 白名单。详见 §11 |
| R4 | 2026-05-25 | LaZzyMan round-8 follow-up (scope conflation) | **PR scope 大幅收窄**:本 PR 仅保留 client HTTP span + OTLP loop guard`traceparent` 默认 offNoopTextMapPropagator新增 `outboundCorrelation.*` 顶级 namespace 放安全相关 toggleR3 落地的整套 `X-Qwen-Code-Session-Id` 机器**移除本 PR**,搬到独立 follow-up PR。详见 §12 |
**特别提示**:阅读 §3.1(目标)/ §3.2(非目标)/ §4.3Part B 设计)/ §4.4(配置 schema 影响)/ §5文件改动清单/ §9与 claude-code 对比)/ §10未来工作时,请同时参考 §11 —— **R3 修订让上述章节中关于"统一向所有 LLM provider 发送 session id 不区分第一方/第三方"的论断不再成立**。原文保留作为决策路径记录 **特别提示**:阅读 §3.1(目标)/ §3.2(非目标)/ §4.3Part B 设计)/ §4.4(配置 schema 影响)/ §5文件改动清单/ §9与 claude-code 对比)/ §10未来工作/ §11R3 host-allowlist scoping请同时参考 §12 —— **R4 修订让 R1-R3 关于"本 PR 同时落地 traceparent + session id header"的论断不再成立**:本 PR 现仅为 telemetry observability + 独立的 outbound trace-context toggle所有 outbound correlation header 工作(包括 R3 的 host allowlist整体搬到独立 follow-up PR。R3 工作代码本身没浪费,挪到 follow-up PR 即可复用
## 1. 背景 ## 1. 背景
@ -764,3 +765,102 @@ Gemini SDK 有两个不可见 default endpoint`generativelanguage.googleapis.
- **Per-request random UUID** (`X-Qwen-Code-Request-Id`) — LazzyMan 提的替代方案,列入 §10 - **Per-request random UUID** (`X-Qwen-Code-Request-Id`) — LazzyMan 提的替代方案,列入 §10
- **Gemini staleness lazy-invalidate** (§8.6 选项 A) — 与 R3 解耦,独立 sub-issue - **Gemini staleness lazy-invalidate** (§8.6 选项 A) — 与 R3 解耦,独立 sub-issue
- **`matchesTrustedHost` IPv6 支持** — 当前 IPv6 destination 永不在 allowlist 上(`URL.hostname` 返回 `[::1]` 带方括号pattern 语法无对应形式)。当前满足"命名 first-party endpoint"用例。若将来有 raw IP allowlist 需求再扩展。 - **`matchesTrustedHost` IPv6 支持** — 当前 IPv6 destination 永不在 allowlist 上(`URL.hostname` 返回 `[::1]` 带方括号pattern 语法无对应形式)。当前满足"命名 first-party endpoint"用例。若将来有 raw IP allowlist 需求再扩展。
## 12. R4 修订 — Scope Conflation Split
> 触发:[LaZzyMan round-8 follow-up review on PR #4390](https://github.com/QwenLM/qwen-code/pull/4390)
> 落地:本 PR 收窄R3 落地的 session-id 整套挪到独立 follow-up PR
### 12.1 触发与论证
R3 化解了 LaZzyMan 第一轮 review 的「广播稳定指纹给第三方 provider」担忧severity: high。但在 round-8 follow-up 中他升级到更深的架构原则反对:
> "Telemetry is not a container for adjacent features. The `traceparent` cross-process propagation and the `X-Qwen-Code-Session-Id` header injection are **not telemetry**. They are outbound-identity / outbound-correlation work that uses some OTel APIs internally as an implementation detail."
他的核心元论点:
- **"telemetry" namespace 暗示 recipient = 用户自己的 OTLP collector**
- 但 `traceparent``X-Qwen-Code-Session-Id` 的 recipient = **第三方 LLM provider**
- 两类不同 recipient 应该有两类不同的同意决策树
- 即使默认行为安全R3 已实现),把 wire-level 行为放在 `telemetry.*` 下**设了坏先例**:未来 telemetry PR 可以继续偷渡 wire 行为给第三方
- "If we accept that principle, the split is mechanical. If we don't, this PR is the wrong place to debate it because the technical fixes are already in."
### 12.2 解法概要("方案 C" hybrid split
经过几轮内部讨论(含 yiliang 提出的 customHeader 模板替代方案,最终判定 customHeader 不能携带 runtime-dynamic 值),决定走 **方案 C**
**本 PR 留下**
- `UndiciInstrumentation` 注册(产 client HTTP span → 用户自家 OTLP collector
- OTLP feedback-loop guard前者的必要副作用
- **`NoopTextMapPropagator` 默认安装** → `propagation.inject()` 是 no-op → outbound `fetch` 上**不再有 `traceparent`**
- **新增 `outboundCorrelation.propagateTraceContext: bool` (默认 false)** 作为独立 namespace 顶级设置;设 true 时安装默认 W3C composite propagator
- 整套 `R3 session-id` 代码(`llm-correlation-fetch.ts` / `trusted-llm-hosts.ts` / `telemetry.sessionIdHeaderHosts` setting / 4 个 provider 集成点 / 所有相关测试)**全部移除**
**搬到 follow-up PR**
- `X-Qwen-Code-Session-Id` header 整套机器R3 实现复用)
- 进入新 `outboundCorrelation.*` namespace具体 setting key TBD但**不会**叫 `telemetry.*`
- Follow-up PR 自带threat model section、独立 review、security-relevant 标注的 docs
- `X-Qwen-Code-Request-Id` per-request UUIDLazzyMan 在 R3 round 提出的替代设计)也归入此 follow-up 的考虑范围
### 12.3 与 R3 R1 论点的映射
| R1/R3 论点 | R4 后状态 |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| §3.1 "所有出站 LLM 请求带 traceparent" | ❌ **R4 默认 off**;需 `outboundCorrelation.propagateTraceContext: true` 才开 |
| §3.1 "所有出站 LLM 请求带 `X-Qwen-Code-Session-Id`" | ❌ **R4 整套移出本 PR**,搬到 follow-up PR |
| §4.3 fetch wrapper 注入 session id | ❌ 整段代码不在本 PR复用到 follow-up PR |
| §11 host allowlist (R3 设计) | ❌ 同上;整体迁移 follow-up PR |
| §4.4 不引入新 setting | ❌ **本 PR 新增 `outboundCorrelation.propagateTraceContext`** 一个 booleansession id 相关 setting 在 follow-up PR |
| §10 future work "`X-Qwen-Code-Request-Id`" | ✅ 仍是 future work与 session-id follow-up 一起设计 |
### 12.4 新 namespace 设计意图
`outboundCorrelation.*` 顶级 namespace 在本 PR 只有一个 boolean (`propagateTraceContext`),看起来过度结构化。但这是**精心选择的**
- **建立命名空间作为承诺**:让后续 session-id / request-id / etc. 自然进入这个 namespace
- **标注为 security-relevant**`settingsSchema.ts` description 显式写 "SECURITY-RELEVANT",文档化为"安全设置"而非"observability 设置"
- **defaults 全部 off**:符合 LazzyMan 提出的"open-source 客户端不应未经显式同意向第三方发稳定 id"原则
- **与 telemetry.\* 解耦**:用户读 settings.json 看到 `outboundCorrelation.*` 立刻能识别这是出站 wire 行为,不是 observability
### 12.5 实施
| 文件 | 改动 |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/core/src/telemetry/llm-correlation-fetch.ts` | **删除** |
| `packages/core/src/telemetry/llm-correlation-fetch.test.ts` | **删除** |
| `packages/core/src/telemetry/trusted-llm-hosts.ts` | **删除** |
| `packages/core/src/telemetry/trusted-llm-hosts.test.ts` | **删除** |
| `packages/core/src/telemetry/sdk.ts` | + `NoopTextMapPropagator`;按 `getOutboundCorrelationPropagateTraceContext()` 决定 SDK textMapPropagator |
| `packages/core/src/core/openaiContentGenerator/provider/default.ts` | 移除 `wrapFetchWithCorrelation` 引用 |
| `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` | 同上 |
| `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` | 同上 |
| `packages/core/src/core/geminiContentGenerator/index.ts` | 移除 `staticCorrelationHeaders` 引用 |
| 上述 4 个 provider 的 `*.test.ts` | 删 session-id 相关测试 case |
| `packages/core/src/config/config.ts` | 删 `TelemetrySettings.sessionIdHeaderHosts``getTelemetrySessionIdHeaderHosts`**新增 `OutboundCorrelationSettings` 接口 + `outboundCorrelationSettings` 字段 + `getOutboundCorrelationPropagateTraceContext()` getter** |
| `packages/core/src/telemetry/config.ts` | 删 `resolveTelemetrySettings` 中 sessionIdHeaderHosts 透传 |
| `packages/cli/src/config/settingsSchema.ts` | 删 `sessionIdHeaderHosts` schema**新增 `outboundCorrelation` 顶级 schema 项** |
| `packages/cli/src/config/config.ts` | 透传 `outboundCorrelation: settings.outboundCorrelation``ConfigParameters` |
| `packages/vscode-ide-companion/schemas/settings.schema.json` | `npm run generate:settings-schema` 重新生成 |
| `docs/developers/development/telemetry.md` | 重写 "Trace context propagation" → "Client-side HTTP span on outbound fetch";删 "Session correlation header" 整节;新增 "Outbound correlation (SECURITY-RELEVANT)" 顶级 section |
| `docs/design/telemetry-outbound-propagation-design.md` | 本节 + R4 表头 + 修订指针 |
### 12.6 对 LazzyMan 元论点的回应
| 论点 | R4 后状态 |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| "Telemetry namespace 暗示自家 collector 接收方" | ✅ wire 行为已搬出 `telemetry.*`;新 `outboundCorrelation.*` namespace 显式标识"出站第三方"语义 |
| "默认行为不应未经显式同意向第三方发标识符" | ✅ `propagateTraceContext` 默认 falsesession-id 整套 follow-up PR 也将默认 off |
| "telemetry PR 不应偷渡 wire-level 行为" | ✅ 本 PR 不再添加任何"telemetry 控制 wire 行为"的代码路径wire 行为统一由 `outboundCorrelation.*` 管 |
| "split is mechanical, work isn't wasted" | ✅ R3 落地代码物理删除自本 branch留在 git history 里给 follow-up PR 复用(或 cherry-pick |
### 12.7 follow-up PR 大纲(信息性,不在本 PR 范围)
未来 follow-up PR 应包含:
- `outboundCorrelation.sessionIdHeader: { enabled, trustedHosts }` 或类似 setting
- 复用 R3 已实现的 `wrapFetchWithCorrelation` / `matchesTrustedHost` / `DEFAULT_SESSION_ID_HEADER_HOSTS` 代码骨架
- threat model 一节明确recipient 集合、稳定 id 的去匿名化窗口、可选 per-request UUID 配套
- **默认 off**(无 default allowlist —— 比 R3 更严,符合 LazzyMan 的开源 CLI 原则)
- security-relevant 标注 + docs/users/configuration/settings.md 收录

View file

@ -262,25 +262,23 @@ and logs still carry `session.id`, and trace / log backends (Jaeger, Tempo,
Loki, Aliyun SLS / ARMS Tracing) handle per-session slicing natively without Loki, Aliyun SLS / ARMS Tracing) handle per-session slicing natively without
cardinality pressure. cardinality pressure.
### Trace context propagation ### Client-side HTTP span on outbound fetch
When telemetry is enabled, Qwen Code instruments outgoing `fetch()` requests When telemetry is enabled, Qwen Code registers `UndiciInstrumentation`
(used by `openai`, `@google/genai`, and `@anthropic-ai/sdk`) and automatically which creates a client-side HTTP span for every outbound `fetch()`
injects the standard W3C `traceparent` header on every LLM service call: request originated by the process — including the LLM SDKs (`openai`,
`@google/genai`, `@anthropic-ai/sdk`), the MCP StreamableHTTP client, the
`WebFetch` tool, and any IDE-extension out-of-process calls. The span
lets you see network latency (TTFB / response body transfer) separately
from upstream model processing time, which the existing
`api.generateContent` span alone can't distinguish.
``` These spans go to your **own** OTLP collector (or file outfile) just like
traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled> the rest of the telemetry — they do not affect what is written onto the
``` outbound HTTP request itself. Whether the W3C `traceparent` header is
also written into the outgoing request stream is controlled by a
The traceId is the qwen-code interaction trace id; the parent span is the **separate, security-relevant setting** documented in
active `api.generateContent` span. Any OTel-instrumented LLM service (e.g. [outbound correlation](#outbound-correlation-security-relevant) below.
DashScope serving from an ARMS Tracing backend) that reads this header will
make its server-side spans children of qwen-code's trace, giving you a single
end-to-end trace tree across the process boundary.
You also get a free client-side HTTP span per LLM call — useful for separating
network latency (TTFB / response body transfer) from upstream model processing
time, which the existing `api.generateContent` span alone can't distinguish.
**Feedback-loop avoidance.** OTel SDK uses `fetch` internally to upload OTLP **Feedback-loop avoidance.** OTel SDK uses `fetch` internally to upload OTLP
data. Without protection, instrumenting `fetch` would trace those uploads, data. Without protection, instrumenting `fetch` would trace those uploads,
@ -291,98 +289,53 @@ URLs matching the configured `telemetry.otlpEndpoint` /
`telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no `telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no
outbound HTTP uploads, so the hook is a no-op. outbound HTTP uploads, so the hook is a no-op.
**Scope: all `fetch()` calls, not just LLM.** The undici instrumentation ## Outbound correlation (SECURITY-RELEVANT)
patches `globalThis.fetch` for the entire process, so a client span +
`traceparent` injection happens on every outbound `fetch` — including the
`WebFetch` tool, MCP StreamableHTTP clients, and any IDE-extension
out-of-process calls — not only LLM SDK traffic. Two consequences worth
knowing:
- **Trace ID leakage.** The W3C `traceparent` header carries the trace ID These settings live in a **separate top-level namespace** from `telemetry.*`
to every destination, including third-party URLs supplied at runtime on purpose: telemetry controls data flow into the operator's own
(e.g. a `WebFetch` to an arbitrary domain). The trace ID is not a observability backend, while `outboundCorrelation.*` controls what
secret per the W3C spec — the value is `sha256(sessionId).slice(0, 32)` client-side correlation data qwen-code writes **into outbound LLM API
(a one-way hash, not the session id itself). Operators with stricter request streams** that reach third-party LLM provider endpoints
requirements who don't want even the hashed trace ID reaching (DashScope, OpenAI, Anthropic, etc.). Different recipients, different
third-party endpoints can disable telemetry entirely consent decision. **All values default to off.** See PR #4390 review
(`telemetry.enabled: false`); a per-destination scoping toggle for discussion for the framing rationale.
trace propagation specifically is tracked as a follow-up.
- **Span volume.** Non-LLM `fetch` calls show up as client HTTP spans in
your OTLP backend. Filter on `http.url` or `peer.service` if you want
to isolate the LLM-only subset.
### Session correlation header ### `outboundCorrelation.propagateTraceContext`
Alongside `traceparent`, Qwen Code can inject a custom HTTP header on
outbound LLM requests when telemetry is enabled:
```
X-Qwen-Code-Session-Id: <session id>
```
Pattern matched from Claude Code's `X-Claude-Code-Session-Id` (see
`src/services/api/client.ts:108` in the claude-code repo). The header is
product-namespaced to avoid collision with generic `X-Session-Id` headers
other tools may inject. Server-side ingestion (e.g. a DashScope
gateway or an OTLP-aware API gateway) can use this header to stitch its
observation of an LLM request back to the originating Qwen Code session
without having to parse trace context.
**Default scope: first-party Alibaba/DashScope endpoints only.** The
header is sent only to destinations whose hostname matches the
`telemetry.sessionIdHeaderHosts` allowlist. The default allowlist is:
```
dashscope.aliyuncs.com
dashscope-intl.aliyuncs.com
*.dashscope.aliyuncs.com
*.dashscope-intl.aliyuncs.com
*.alibaba-inc.com
*.aliyun-inc.com
```
Requests to third-party LLM providers (OpenAI, Anthropic, OpenRouter,
MiniMax, ModelScope, Mistral, DeepSeek, vanilla Gemini, ...) **do not
receive this header by default** — avoids broadcasting a stable
client-side session identifier to providers who don't need it for the
API call itself. See [PR #4390 review discussion](https://github.com/QwenLM/qwen-code/pull/4390)
for the full rationale.
Operators with broader correlation requirements can override the scope
in `settings.json`:
```jsonc ```jsonc
"telemetry": { "outboundCorrelation": {
"sessionIdHeaderHosts": ["*"] // restore broadcast "propagateTraceContext": false // default
"sessionIdHeaderHosts": [] // fully disable header
"sessionIdHeaderHosts": ["api.mycompany.com",
"*.gateway.mycompany.internal"] // custom allowlist
} }
``` ```
The header value comes from `Config.getSessionId()`, read fresh on every When `false` (default), Qwen Code installs a no-op `TextMapPropagator` on
outbound request (not captured at SDK construction time) on the OpenAI/ the OTel SDK. UndiciInstrumentation still creates client HTTP spans for
Anthropic providers. After a session reset triggered by `/clear`, your OTLP collector, but `propagation.inject()` is a no-op so **no
subsequent requests carry the new session id automatically — see the `traceparent` is written onto outbound requests**. Trace IDs stay
"Known limitation" note below for the one exception. internal to the operator's collector.
Empty session ids are not emitted (some HTTP middleware rejects empty When `true`, the SDK's default W3C composite propagator
header values). The header is omitted entirely when telemetry is disabled (`tracecontext` + `baggage`) is installed and the standard `traceparent`
or the destination host is outside the allowlist. header is written on every outbound `fetch`:
**Known limitation: Gemini provider.** `@google/genai`'s `HttpOptions` ```
interface does not expose a `fetch` hook (only static `headers`), so the traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled>
Gemini provider can only inject the session-id header at SDK construction ```
time. The host-allowlist check happens once against the SDK's
`baseUrl` (defaults to `generativelanguage.googleapis.com` — not on the Opt in only when the LLM provider also reports into your OTel collector
default allowlist, so no header by default). When operators do put the for cross-process trace stitching — e.g. ARMS Tracing serving DashScope.
Gemini SDK on a first-party endpoint via `baseUrl` and the allowlist For most operators the value is `false`; cross-vendor trace continuation
matches, the header IS attached but goes stale on `/clear` until the is niche.
content generator is recreated. All other providers (`openai`-family,
`anthropic`) use a fetch wrapper and are immune to this. Tracked as a ### Other outbound correlation headers
follow-up; in the meantime, spans and logs still carry the live session
id, so trace/log backends can correctly attribute requests to the new `X-Qwen-Code-Session-Id` and `X-Qwen-Code-Request-Id` are **not part of
session. 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"; correlation-header work moves to
its own design discussion rather than landing under telemetry.
## Aliyun Telemetry ## Aliyun Telemetry

View file

@ -1710,6 +1710,7 @@ export async function loadCliConfig(
screenReader, screenReader,
}, },
telemetry: telemetrySettings, telemetry: telemetrySettings,
outboundCorrelation: settings.outboundCorrelation,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
clearContextOnIdle: settings.context?.clearContextOnIdle, clearContextOnIdle: settings.context?.clearContextOnIdle,
fileFiltering: settings.context?.fileFiltering, fileFiltering: settings.context?.fileFiltering,

View file

@ -8,6 +8,7 @@ import type {
MCPServerConfig, MCPServerConfig,
BugCommandSettings, BugCommandSettings,
TelemetrySettings, TelemetrySettings,
OutboundCorrelationSettings,
AuthType, AuthType,
ChatCompressionSettings, ChatCompressionSettings,
ModelProvidersConfig, ModelProvidersConfig,
@ -1032,17 +1033,34 @@ const SETTINGS_SCHEMA = {
}, },
}, },
}, },
sessionIdHeaderHosts: {
description:
'Destination hostnames (or "*.suffix" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to ["*"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.',
type: 'array',
items: { type: 'string' },
},
}, },
additionalProperties: true, additionalProperties: true,
}, },
}, },
outboundCorrelation: {
type: 'object',
label: 'Outbound Correlation',
category: 'Advanced',
requiresRestart: true,
default: undefined as OutboundCorrelationSettings | undefined,
description:
"SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope).",
showInDialog: false,
jsonSchemaOverride: {
type: 'object',
properties: {
propagateTraceContext: {
description:
"Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Note: client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
},
fastModel: { fastModel: {
type: 'string', type: 'string',
label: 'Fast Model', label: 'Fast Model',

View file

@ -314,23 +314,6 @@ export interface TelemetrySettings {
resourceAttributes?: Record<string, string>; resourceAttributes?: Record<string, string>;
/** Per-signal cardinality controls. */ /** Per-signal cardinality controls. */
metrics?: TelemetryMetricsSettings; metrics?: TelemetryMetricsSettings;
/**
* Hostnames (or `*.suffix` patterns) that receive the
* `X-Qwen-Code-Session-Id` outbound correlation header.
*
* Default (when unset): Alibaba/DashScope first-party endpoints only
* see `DEFAULT_SESSION_ID_HEADER_HOSTS` in `telemetry/trusted-llm-hosts.ts`.
* The default is deliberately narrow so the stable session identifier
* does not get broadcast to arbitrary third-party LLM providers
* (OpenAI, Anthropic, OpenRouter, ...) configured under the
* OpenAI-compatible provider. PR #4390 review (LaZzyMan) for rationale.
*
* Overrides:
* - `[]` fully disables the header (no destinations)
* - `["*"]` restores the broadcast behavior across all destinations
* - `["api.mycompany.com", "*.internal.mycompany.com"]` custom allowlist
*/
sessionIdHeaderHosts?: string[];
/** /**
* Human-readable diagnostics produced while resolving * Human-readable diagnostics produced while resolving
* `resourceAttributes` (drops, coercions, reserved-key strips). * `resourceAttributes` (drops, coercions, reserved-key strips).
@ -354,6 +337,42 @@ export interface TelemetryMetricsSettings {
includeSessionId?: boolean; includeSessionId?: boolean;
} }
/**
* Security-relevant settings controlling what client-side correlation
* data qwen-code writes into outbound LLM API requests.
*
* **Why this is a separate namespace from `telemetry.*`:** telemetry
* controls data flow into the user's OWN observability backend (OTLP
* collector / file outfile). 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. See PR
* #4390 review (LaZzyMan) for the framing rationale.
*
* All values default to off / no propagation. Operators who want to
* propagate trace context for server-side trace stitching (e.g. ARMS
* Tracing + DashScope) opt in explicitly.
*/
export interface OutboundCorrelationSettings {
/**
* Inject W3C `traceparent` header on outbound HTTP requests
* originated by undici / global `fetch` (LLM SDK calls, MCP
* StreamableHTTP clients, WebFetch tool, etc.). Default: `false`.
*
* When `false`, the SDK is configured with a no-op
* `TextMapPropagator` so trace context stays internal to the user's
* OTLP collector (operator still gets client HTTP spans, but the
* trace id is not written onto third-party request streams).
*
* When `true`, the OTel default W3C composite propagator
* (`tracecontext` + `baggage`) is installed and `traceparent` is
* written on every outbound `fetch`. Useful when the LLM provider
* also reports into the operator's OTel collector e.g. ARMS
* Tracing + DashScope for cross-process trace stitching.
*/
propagateTraceContext?: boolean;
}
export interface OutputSettings { export interface OutputSettings {
format?: OutputFormat; format?: OutputFormat;
} }
@ -582,6 +601,7 @@ export interface ConfigParameters {
contextFileName?: string | string[]; contextFileName?: string | string[];
accessibility?: AccessibilitySettings; accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings; telemetry?: TelemetrySettings;
outboundCorrelation?: OutboundCorrelationSettings;
gitCoAuthor?: GitCoAuthorParam; gitCoAuthor?: GitCoAuthorParam;
usageStatisticsEnabled?: boolean; usageStatisticsEnabled?: boolean;
/** /**
@ -871,6 +891,7 @@ export class Config {
private autoModeDenialState: AutoModeDenialState = createDenialState(); private autoModeDenialState: AutoModeDenialState = createDenialState();
private readonly accessibility: AccessibilitySettings; private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings; private readonly telemetrySettings: TelemetrySettings;
private readonly outboundCorrelationSettings: OutboundCorrelationSettings;
private readonly gitCoAuthor: GitCoAuthorSettings; private readonly gitCoAuthor: GitCoAuthorSettings;
private readonly usageStatisticsEnabled: boolean; private readonly usageStatisticsEnabled: boolean;
private readonly fileReadCacheDisabled: boolean; private readonly fileReadCacheDisabled: boolean;
@ -1037,9 +1058,12 @@ export class Config {
outfile: params.telemetry?.outfile, outfile: params.telemetry?.outfile,
resourceAttributes: params.telemetry?.resourceAttributes, resourceAttributes: params.telemetry?.resourceAttributes,
metrics: params.telemetry?.metrics, metrics: params.telemetry?.metrics,
sessionIdHeaderHosts: params.telemetry?.sessionIdHeaderHosts,
resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings, resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings,
}; };
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
};
this.gitCoAuthor = { this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor), ...normalizeGitCoAuthor(params.gitCoAuthor),
name: 'Qwen-Coder', name: 'Qwen-Coder',
@ -2867,21 +2891,19 @@ export class Config {
return this.telemetrySettings.metrics?.includeSessionId ?? false; return this.telemetrySettings.metrics?.includeSessionId ?? false;
} }
/**
* Returns the configured host allowlist for the
* `X-Qwen-Code-Session-Id` outbound header, or `undefined` to indicate
* "use `DEFAULT_SESSION_ID_HEADER_HOSTS`". The caller (`llm-correlation-fetch`)
* resolves the default keeping it out of Config avoids importing the
* telemetry helper from here.
*/
getTelemetrySessionIdHeaderHosts(): readonly string[] | undefined {
return this.telemetrySettings.sessionIdHeaderHosts;
}
getTelemetryResourceAttributeWarnings(): readonly string[] { getTelemetryResourceAttributeWarnings(): readonly string[] {
return this.telemetrySettings.resourceAttributeWarnings ?? []; return this.telemetrySettings.resourceAttributeWarnings ?? [];
} }
/**
* Whether to inject W3C `traceparent` on outbound `fetch` requests
* (LLM SDKs, MCP, WebFetch, etc.). Default false see
* `OutboundCorrelationSettings` for rationale.
*/
getOutboundCorrelationPropagateTraceContext(): boolean {
return this.outboundCorrelationSettings.propagateTraceContext ?? false;
}
getTelemetryOutfile(): string | undefined { getTelemetryOutfile(): string | undefined {
return this.telemetrySettings.outfile; return this.telemetrySettings.outfile;
} }

View file

@ -106,30 +106,6 @@ describe('AnthropicContentGenerator', () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('installs the correlation fetch wrapper on the Anthropic client', async () => {
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
},
mockConfig,
);
const fetchFn = anthropicState.constructorOptions?.['fetch'] as
| unknown
| undefined;
// Wrapper installed regardless of telemetry-enabled state — per-call
// header injection is gated inside the wrapper itself. Exhaustive
// behavior is in llm-correlation-fetch.test.ts.
expect(typeof fetchFn).toBe('function');
expect(fetchFn).not.toBe(globalThis.fetch);
});
it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => { it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => {
// Non-Anthropic-native baseURL → IdeaLab-style proxy path: // Non-Anthropic-native baseURL → IdeaLab-style proxy path:
// - User-Agent presents as `claude-cli/<version> (external, cli)` // - User-Agent presents as `claude-cli/<version> (external, cli)`

View file

@ -33,7 +33,6 @@ import {
buildRuntimeFetchOptions, buildRuntimeFetchOptions,
redactProxyError, redactProxyError,
} from '../../utils/runtimeFetchOptions.js'; } from '../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../telemetry/llm-correlation-fetch.js';
import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js'; import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js';
import { createDebugLogger } from '../../utils/debugLogger.js'; import { createDebugLogger } from '../../utils/debugLogger.js';
import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js'; import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js';
@ -204,13 +203,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
// key as `X-Api-Key` to the IdeaLab proxy — leaking the credential to // key as `X-Api-Key` to the IdeaLab proxy — leaking the credential to
// a third-party endpoint. Explicit `null` suppresses the back-fill // a third-party endpoint. Explicit `null` suppresses the back-fill
// and forces the intended auth path. // and forces the intended auth path.
// Wrap fetch with per-request correlation header injection. Read the
// base fetch from runtimeOptions (proxy-aware) when present, else
// globalThis. See design doc §4.3 — fetch wrapper (not defaultHeaders)
// is required so the header tracks live session id across /clear resets.
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
this.client = new Anthropic({ this.client = new Anthropic({
...(useProxyIdentity ...(useProxyIdentity
? { authToken: contentGeneratorConfig.apiKey, apiKey: null } ? { authToken: contentGeneratorConfig.apiKey, apiKey: null }
@ -220,17 +212,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
maxRetries: contentGeneratorConfig.maxRetries, maxRetries: contentGeneratorConfig.maxRetries,
defaultHeaders, defaultHeaders,
...runtimeOptions, ...runtimeOptions,
// Cast through unknown: Anthropic SDK's `Fetch` type uses the older
// DOM-style `RequestInfo` (no URL) which `typeof fetch` (Node WHATWG
// overloads) is not structurally assignable to, even though they're
// call-compatible at runtime. The cast is safe because the wrapper
// delegates to baseFetch (= runtimeOptions.fetch ?? globalThis.fetch)
// without altering its call shape.
fetch: wrapFetchWithCorrelation(
baseFetch,
this.cliConfig,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as unknown as any,
}); });
this.converter = new AnthropicContentConverter( this.converter = new AnthropicContentConverter(

View file

@ -24,7 +24,6 @@ describe('createContentGenerator', () => {
getCliVersion: () => '1.0.0', getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false, getTelemetryEnabled: () => false,
getSessionId: () => 'test-session', getSessionId: () => 'test-session',
getTelemetrySessionIdHeaderHosts: () => undefined,
} as unknown as Config; } as unknown as Config;
const mockGenerator = { const mockGenerator = {
@ -62,7 +61,6 @@ describe('createContentGenerator', () => {
getCliVersion: () => '1.0.0', getCliVersion: () => '1.0.0',
getTelemetryEnabled: () => false, getTelemetryEnabled: () => false,
getSessionId: () => 'test-session', getSessionId: () => 'test-session',
getTelemetrySessionIdHeaderHosts: () => undefined,
} as unknown as Config; } as unknown as Config;
const mockGenerator = { const mockGenerator = {
models: {}, models: {},

View file

@ -91,103 +91,4 @@ describe('createGeminiContentGenerator', () => {
}), }),
); );
}); });
it('omits X-Qwen-Code-Session-Id from httpOptions.headers when telemetry is disabled', () => {
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as
| { httpOptions?: { headers?: Record<string, string> } }
| undefined;
expect(callArgs?.httpOptions?.headers).toBeDefined();
expect(callArgs?.httpOptions?.headers ?? {}).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
it('omits X-Qwen-Code-Session-Id for vanilla Gemini endpoint even when telemetry is enabled (third-party scope)', () => {
// PR #4390 review (LaZzyMan): the session id header is scoped to
// first-party (Alibaba/DashScope) destinations by default. A vanilla
// Gemini API call resolves to `generativelanguage.googleapis.com`,
// which is NOT on the default allowlist, so no header.
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
it('includes X-Qwen-Code-Session-Id when baseUrl points at a trusted DashScope endpoint', () => {
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const config = {
model: 'qwen-vl-plus',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
// Operator has pointed the Gemini SDK at a DashScope-compatible
// endpoint via baseUrl override. This IS on the default allowlist.
baseUrl: 'https://dashscope.aliyuncs.com/api/v1',
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers['X-Qwen-Code-Session-Id']).toBe(
'sess-gemini',
);
});
it('omits X-Qwen-Code-Session-Id when baseUrl is unset, even with allowlist override (factory fail-closed for unknown destination)', () => {
// The factory only passes a destinationUrl to staticCorrelationHeaders
// when `config.baseUrl` is set, because the Gemini SDK has two
// invisible defaults (generativelanguage.googleapis.com for public,
// {region}-aiplatform.googleapis.com for Vertex). Guessing one would
// mis-bucket the other under a googleapis-covering allowlist override.
// Operators who want correlation against Google endpoints must set
// `baseUrl` explicitly.
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(['*']),
} as unknown as Config;
const config = {
model: 'gemini-1.5-flash',
apiKey: 'k',
authType: AuthType.USE_GEMINI,
};
createGeminiContentGenerator(config, mockConfig);
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
httpOptions: { headers: Record<string, string> };
};
expect(callArgs.httpOptions.headers).not.toHaveProperty(
'X-Qwen-Code-Session-Id',
);
});
}); });

View file

@ -11,7 +11,6 @@ import type {
} from '../contentGenerator.js'; } from '../contentGenerator.js';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { InstallationManager } from '../../utils/installationManager.js'; import { InstallationManager } from '../../utils/installationManager.js';
import { staticCorrelationHeaders } from '../../telemetry/llm-correlation-fetch.js';
export { GeminiContentGenerator } from './geminiContentGenerator.js'; export { GeminiContentGenerator } from './geminiContentGenerator.js';
@ -39,27 +38,6 @@ export function createGeminiContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`, 'x-gemini-api-privileged-user-id': `${installationId}`,
}; };
} }
// Merge the session-id correlation header. `@google/genai`'s HttpOptions
// does not expose a `fetch` hook (unlike `openai` / `@anthropic-ai/sdk`),
// so we can only inject a static header here — captured at construction.
// Known limitation: after a `/clear`-triggered session reset, the Gemini
// SDK's cached headers retain the OLD session id until the contentGenerator
// is recreated. See design doc §8.6 + #4384 follow-up sub-issue tracking.
//
// Destination resolution: only pass when we KNOW the destination — i.e.
// when the operator has explicitly set `config.baseUrl`. The SDK has two
// different invisible defaults (`generativelanguage.googleapis.com` for
// public Gemini, `{region}-aiplatform.googleapis.com` for Vertex AI) and
// guessing wrong here would mis-bucket Vertex traffic if the operator
// overrides `telemetry.sessionIdHeaderHosts` to include any googleapis
// domain. Passing `undefined` makes `staticCorrelationHeaders` return
// `{}` (no header) — the fail-closed default. Operators who want
// correlation against a Google endpoint set `baseUrl` explicitly
// (which is the same input the SDK uses to resolve the destination).
headers = {
...headers,
...staticCorrelationHeaders(gcConfig, config.baseUrl),
};
const httpOptions = config.baseUrl const httpOptions = config.baseUrl
? { ? {
headers, headers,

View file

@ -492,17 +492,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
}), }),
); );
}); });
it('installs the correlation fetch wrapper on the OpenAI client', () => {
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
// Wrapper is always installed; per-request behavior depends on
// telemetry-enabled (verified end-to-end in llm-correlation-fetch.test.ts).
expect(typeof callArg.fetch).toBe('function');
expect(callArg.fetch).not.toBe(globalThis.fetch);
});
}); });
describe('buildMetadata', () => { describe('buildMetadata', () => {

View file

@ -16,7 +16,6 @@ import type {
ChatCompletionToolWithCache, ChatCompletionToolWithCache,
} from './types.js'; } from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
import { createDebugLogger } from '../../../utils/debugLogger.js'; import { createDebugLogger } from '../../../utils/debugLogger.js';
import { DefaultOpenAICompatibleProvider } from './default.js'; import { DefaultOpenAICompatibleProvider } from './default.js';
@ -139,9 +138,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
'openai', 'openai',
this.cliConfig.getProxy(), this.cliConfig.getProxy(),
); );
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
return new OpenAI({ return new OpenAI({
apiKey, apiKey,
baseURL: baseUrl, baseURL: baseUrl,
@ -149,7 +145,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
maxRetries, maxRetries,
defaultHeaders, defaultHeaders,
...(runtimeOptions || {}), ...(runtimeOptions || {}),
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
}); });
} }

View file

@ -168,50 +168,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
}), }),
); );
}); });
it('installs the correlation fetch wrapper on the OpenAI client', () => {
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
// Wrapper is always installed (so it can read live config per request);
// per-call header injection behavior is verified exhaustively in
// llm-correlation-fetch.test.ts.
expect(typeof callArg.fetch).toBe('function');
expect(callArg.fetch).not.toBe(globalThis.fetch);
});
it('wraps the proxy fetch (not globalThis.fetch) when runtimeOptions provides one', async () => {
// Regression guard for design §4.3: when proxy is configured,
// buildRuntimeFetchOptions returns { fetch: <bundled undici fetch> }
// so the proxy dispatcher and fetch share a single undici version.
// The correlation wrapper must wrap THAT fetch, not globalThis.fetch.
const proxyFetch = vi.fn(
async () => new Response(),
) as unknown as typeof fetch;
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai', proxyUrl?: string) => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue({
fetch: proxyFetch,
} as OpenAIRuntimeFetchOptions);
provider.buildClient();
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
fetch?: typeof fetch;
};
expect(typeof callArg.fetch).toBe('function');
// Wrapped, not raw — the wrapper is a different function reference.
expect(callArg.fetch).not.toBe(proxyFetch);
expect(callArg.fetch).not.toBe(globalThis.fetch);
// Positive assertion (PR #4390 review): the negatives above pass
// for ANY wrapper, including a buggy one that wraps globalThis.fetch
// instead of proxyFetch. Call the wrapped fetch and verify the
// delegation target is actually proxyFetch (telemetry off in
// mockCliConfig, so wrapper short-circuits straight to baseFetch).
await callArg.fetch!('https://api.example.com/v1');
expect(proxyFetch).toHaveBeenCalledTimes(1);
});
}); });
describe('buildRequest', () => { describe('buildRequest', () => {

View file

@ -5,7 +5,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js'; import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
import type { OpenAICompatibleProvider } from './types.js'; import type { OpenAICompatibleProvider } from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
import { import {
tokenLimit, tokenLimit,
CAPPED_DEFAULT_MAX_TOKENS, CAPPED_DEFAULT_MAX_TOKENS,
@ -89,13 +88,6 @@ export class DefaultOpenAICompatibleProvider
'openai', 'openai',
this.cliConfig.getProxy(), this.cliConfig.getProxy(),
); );
// Wrap fetch with per-request correlation header injection. Read the base
// fetch from runtimeOptions (proxy-aware) when present, else globalThis.
// Spread runtimeOptions FIRST, then override `fetch:` so our wrapper wraps
// the proxy fetch instead of being replaced by it. See design doc §4.3.
const baseFetch =
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
globalThis.fetch;
return new OpenAI({ return new OpenAI({
apiKey, apiKey,
baseURL: baseUrl, baseURL: baseUrl,
@ -103,7 +95,6 @@ export class DefaultOpenAICompatibleProvider
maxRetries, maxRetries,
defaultHeaders, defaultHeaders,
...(runtimeOptions || {}), ...(runtimeOptions || {}),
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
}); });
} }

View file

@ -188,7 +188,6 @@ export async function resolveTelemetrySettings(options: {
outfile, outfile,
resourceAttributes, resourceAttributes,
metrics: { includeSessionId: metricsIncludeSessionId }, metrics: { includeSessionId: metricsIncludeSessionId },
sessionIdHeaderHosts: settings.sessionIdHeaderHosts,
resourceAttributeWarnings: resourceAttributeWarnings.length resourceAttributeWarnings: resourceAttributeWarnings.length
? resourceAttributeWarnings ? resourceAttributeWarnings
: undefined, : undefined,

View file

@ -1,662 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { diag } from '@opentelemetry/api';
import type { Config } from '../config/config.js';
import {
SESSION_ID_HEADER,
staticCorrelationHeaders,
wrapFetchWithCorrelation,
} from './llm-correlation-fetch.js';
// Local alias for tests; the public helper is generic.
type FetchLike = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
function mockConfig(opts: {
enabled?: boolean;
sessionId?: string | (() => string);
/**
* Host allowlist returned by `getTelemetrySessionIdHeaderHosts`.
*
* - Key OMITTED: returns `['*']` (broadcast) so the bulk of tests below
* which exercise header-injection mechanics, not host-gating
* keep operating against the `api.example.com` test URLs.
* - Key PRESENT and `undefined`: returns `undefined`, letting the
* wrapper fall back to `DEFAULT_SESSION_ID_HEADER_HOSTS` (the real
* default allowlist). Used by host-gate tests.
* - Key PRESENT and an array: returns that array verbatim.
*/
hosts?: readonly string[];
}): Config {
const hostsKeyPresent = 'hosts' in opts;
return {
getTelemetryEnabled: () => opts.enabled ?? true,
getSessionId: () =>
typeof opts.sessionId === 'function'
? opts.sessionId()
: (opts.sessionId ?? ''),
getTelemetrySessionIdHeaderHosts: () =>
hostsKeyPresent ? opts.hosts : ['*'],
} as unknown as Config;
}
// Typed fetch mock returning a recorded-args view that avoids the
// `mock.calls[0]![1] as RequestInit` cast — calls is properly typed as
// `[input, init?][]` and `init` is optional.
function makeFetchMock(): {
fetch: FetchLike;
spy: ReturnType<typeof vi.fn>;
lastInit: () => RequestInit | undefined;
lastInput: () => string | URL | Request | undefined;
callCount: () => number;
} {
const spy = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response(),
);
return {
fetch: spy as unknown as FetchLike,
spy,
lastInit: () =>
spy.mock.calls.length > 0
? (spy.mock.calls[spy.mock.calls.length - 1]?.[1] as
| RequestInit
| undefined)
: undefined,
lastInput: () =>
spy.mock.calls.length > 0
? (spy.mock.calls[spy.mock.calls.length - 1]?.[0] as
| string
| URL
| Request
| undefined)
: undefined,
callCount: () => spy.mock.calls.length,
};
}
describe('wrapFetchWithCorrelation', () => {
it('attaches X-Qwen-Code-Session-Id when telemetry is enabled', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess-A' }),
);
await wrapped('https://api.example.com/v1/chat');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not attach header when telemetry is disabled (passes init through unchanged)', async () => {
const m = makeFetchMock();
const userInit = { method: 'POST', headers: { 'X-Custom': 'keep' } };
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: false, sessionId: 'sess-A' }),
);
await wrapped('https://api.example.com/v1/chat', userInit);
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
userInit,
);
});
it('does not attach header when sessionId is empty (skips defensively)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: '' }),
);
await wrapped('https://api.example.com/v1/chat');
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
undefined,
);
});
it('overrides existing same-name header on init (server gets real session id, not user-supplied spoof)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'real-sess' }),
);
await wrapped('https://api.example.com/v1/chat', {
headers: { [SESSION_ID_HEADER]: 'spoofed' },
});
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'real-sess',
);
});
it('preserves other headers and init fields when injecting', async () => {
const m = makeFetchMock();
const signal = new AbortController().signal;
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await wrapped('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-xxx',
'Content-Type': 'application/json',
},
body: '{"x":1}',
signal,
});
const init = m.lastInit()!;
expect(init.method).toBe('POST');
expect(init.body).toBe('{"x":1}');
expect(init.signal).toBe(signal);
const h = init.headers as Headers;
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
expect(h.get('Content-Type')).toBe('application/json');
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
});
it('reads fresh session id after a session reset (staleness regression — design §4.3 critical)', async () => {
let current = 'sess-A';
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: () => current }),
);
await wrapped('https://api.example.com/1');
expect(
(
(m.spy.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as
| Headers
| undefined
)?.get(SESSION_ID_HEADER),
).toBe('sess-A');
// Simulate /clear updating Config.sessionId without recreating SDK clients.
current = 'sess-B';
await wrapped('https://api.example.com/2');
expect(
(
(m.spy.mock.calls[1]?.[1] as RequestInit | undefined)?.headers as
| Headers
| undefined
)?.get(SESSION_ID_HEADER),
).toBe('sess-B');
});
it('propagates baseFetch rejection unchanged', async () => {
const err = new Error('network unreachable');
const spy = vi.fn(async () => {
throw err;
});
const wrapped = wrapFetchWithCorrelation(
spy as unknown as FetchLike,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await expect(wrapped('https://api.example.com/x')).rejects.toBe(err);
});
it('preserves Request input headers when init is undefined (defends Authorization etc.)', async () => {
// PR #4393 review feedback: previously `new Headers(init?.headers)` with
// undefined init dropped the Request's own headers (e.g. Authorization)
// because we then passed `{...init, headers}` which had only our session
// header. Fix seeds from input.headers when input is a Request.
const m = makeFetchMock();
const req = new Request('https://api.example.com/v1/chat', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-xxx',
'Content-Type': 'application/json',
},
});
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({ enabled: true, sessionId: 'sess' }),
);
await wrapped(req);
const init = m.lastInit()!;
const h = init.headers as Headers;
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
expect(h.get('Content-Type')).toBe('application/json');
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
});
it('falls through to baseFetch + diag.warn when header construction throws (telemetry never breaks LLM)', async () => {
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
const m = makeFetchMock();
// Config getter that throws — simulates a runtime bug that must not
// propagate and break the LLM request. Use a TRUSTED destination
// (broadcast allowlist) so we reach the throwing getSessionId() — a
// third-party destination would short-circuit at the host gate before
// ever calling getSessionId.
const config = {
getTelemetryEnabled: () => true,
getSessionId: () => {
throw new Error('config bug');
},
getTelemetrySessionIdHeaderHosts: () => ['*'],
} as unknown as Config;
const wrapped = wrapFetchWithCorrelation(m.fetch, config);
const userInit = { method: 'POST' };
const res = await wrapped('https://api.example.com/v1/chat', userInit);
expect(res).toBeInstanceOf(Response);
// baseFetch was called with the ORIGINAL init (no correlation header added)
expect(m.spy).toHaveBeenCalledWith(
'https://api.example.com/v1/chat',
userInit,
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('correlation header'),
);
warnSpy.mockRestore();
});
});
describe('staticCorrelationHeaders', () => {
// Tests pass `hosts: ['*']` (broadcast) unless they're specifically
// exercising the host gate, since broadcast was the original behavior
// before the LaZzyMan-review-driven scope narrowing.
const TRUSTED = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
it('returns header when telemetry enabled and sessionId non-empty', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: 'sess-A' }),
TRUSTED,
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('returns {} when telemetry disabled', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: false, sessionId: 'sess-A' }),
TRUSTED,
),
).toEqual({});
});
it('returns {} when sessionId is empty', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: '' }),
TRUSTED,
),
).toEqual({});
});
it('returns {} when destinationUrl is undefined (fail closed)', () => {
expect(
staticCorrelationHeaders(
mockConfig({ enabled: true, sessionId: 'sess-A' }),
),
).toEqual({});
});
it('returns {} when destinationUrl host is not on the trusted allowlist', () => {
// Default allowlist is Alibaba/DashScope only. A vanilla Gemini API call
// to googleapis.com should NOT receive the header. PR #4390 review
// (LaZzyMan): "the header should not be broadcast to every LLM provider".
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // use real default allowlist
}),
'https://generativelanguage.googleapis.com/v1beta',
),
).toEqual({});
});
it('returns header when destinationUrl host matches the default allowlist (DashScope)', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // use real default allowlist
}),
'https://dashscope.aliyuncs.com/compatible-mode/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('returns header when destinationUrl host matches the default allowlist (internal alibaba-inc)', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
'https://idealab.alibaba-inc.com/api/openai/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('respects ["*"] override to restore broadcast', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['*'],
}),
'https://api.openai.com/v1',
),
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
});
it('respects [] override to fully disable', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [],
}),
TRUSTED,
),
).toEqual({});
});
it('returns {} when destinationUrl is unparseable', () => {
expect(
staticCorrelationHeaders(
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
'not a url',
),
).toEqual({});
});
it('captures session id at call time — caller responsible for re-invoking on reset (Gemini staleness §8.6)', () => {
// Document by behavior: this helper takes a snapshot. Caller (e.g.
// geminiContentGenerator/index.ts factory) calls it once and bakes the
// value into SDK-construction `httpOptions.headers`. A session reset
// after construction won't update the header — known limitation.
let current = 'sess-A';
const config = mockConfig({ enabled: true, sessionId: () => current });
const snapshot = staticCorrelationHeaders(config, TRUSTED);
current = 'sess-B';
expect(snapshot[SESSION_ID_HEADER]).toBe('sess-A');
});
it('returns {} (does not throw) when allowlist is a bare string instead of array', () => {
// Same defensive normalization as wrapFetchWithCorrelation. Bare
// string from a malformed settings.json must not crash construction.
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
'dashscope.aliyuncs.com' as unknown as readonly string[],
} as unknown as Config;
// Falls back to DEFAULT — DashScope destination matches.
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
[SESSION_ID_HEADER]: 'sess-A',
});
});
it('returns {} (does not throw) when allowlist array contains non-string entries', () => {
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
[null, 'dashscope.aliyuncs.com', 42] as unknown as
| readonly string[]
| undefined,
} as unknown as Config;
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
[SESSION_ID_HEADER]: 'sess-A',
});
});
it('falls through to {} when config getters throw (telemetry must never break LLM path)', () => {
// Mirror the safety contract of `wrapFetchWithCorrelation`. This helper
// is called from the Gemini factory at construction time, so a throw
// here would propagate up and crash content-generator init for the
// whole session. PR #4390 review feedback (wenshao).
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
const exploding = {
getTelemetryEnabled: () => {
throw new Error('config exploded');
},
getSessionId: () => 'unreached',
getTelemetrySessionIdHeaderHosts: () => ['*'],
} as unknown as Config;
expect(staticCorrelationHeaders(exploding, TRUSTED)).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('staticCorrelationHeaders'),
);
warnSpy.mockRestore();
});
});
describe('wrapFetchWithCorrelation — host allowlist gating', () => {
// Dedicated block for the LaZzyMan-review-driven host gate. Uses the
// real default allowlist (no `hosts: ['*']` override) and exercises
// both the trusted-host pass and the third-party-host skip.
it('injects header for default-allowlisted host (dashscope.aliyuncs.com)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined, // real default allowlist
}),
);
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1/chat');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('injects header for sub-domain of allowlisted suffix (*.alibaba-inc.com)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://idealab.alibaba-inc.com/api/openai/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('skips header for third-party host (api.openai.com) under default allowlist', async () => {
// This is the core LaZzyMan-review fix: the stable session id no longer
// gets broadcast to third-party LLM providers by default.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('skips header for third-party host (api.anthropic.com) under default allowlist', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('https://api.anthropic.com/v1/messages');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('respects ["*"] override to restore broadcast behavior', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['*'],
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('tolerates whitespace around "*" so [" * "] still enables broadcast', async () => {
// Defensive: settings.json hand-edits are easy to get a stray space
// wrong. Without trim() the operator would silently get "no host
// matches" instead of broadcast.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [' * '],
}),
);
await wrapped('https://api.openai.com/v1/chat/completions');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('tolerates whitespace around host patterns (parity with "*" trim)', async () => {
// Same defensive normalization applies to host patterns, not just the
// broadcast wildcard. Without it, `" dashscope.aliyuncs.com"` would
// silently never match — inconsistent with the `[" * "]` tolerance.
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [' dashscope.aliyuncs.com '],
}),
);
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not throw at buildClient when settings returns a bare string instead of array (malformed JSON)', async () => {
// Regression: `broadcastAll` computation was outside the safety
// try/catch. A typo like `"sessionIdHeaderHosts": "dashscope..."`
// (forgot brackets) would let `.some(...)` throw TypeError at
// wrap time — bricking buildClient and the LLM session.
const m = makeFetchMock();
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
'dashscope.aliyuncs.com' as unknown as readonly string[],
} as unknown as Config;
// The wrap itself must not throw — fall back to default allowlist.
expect(() =>
wrapFetchWithCorrelation(m.fetch, malformedConfig),
).not.toThrow();
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
// Default allowlist applies → header IS attached for DashScope.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('does not throw when settings array contains non-string entries (malformed JSON)', async () => {
// Regression: `[null, "*.dashscope.aliyuncs.com"]` typo placeholder
// would let `null.trim()` throw in the broadcastAll predicate.
const m = makeFetchMock();
const malformedConfig = {
getTelemetryEnabled: () => true,
getSessionId: () => 'sess-A',
getTelemetrySessionIdHeaderHosts: () =>
[null, 'dashscope.aliyuncs.com', undefined, 42] as unknown as
| readonly string[]
| undefined,
} as unknown as Config;
expect(() =>
wrapFetchWithCorrelation(m.fetch, malformedConfig),
).not.toThrow();
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
// Non-string entries filtered out, surviving string matched.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
});
it('respects [] override to fully disable injection', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: [],
}),
);
// Even an otherwise-trusted destination is skipped when allowlist is [].
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('respects custom allowlist (operator-supplied host)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: ['gateway.mycompany.internal'],
}),
);
await wrapped('https://gateway.mycompany.internal/llm/v1');
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
'sess-A',
);
// Default allowlist hosts are NOT included when operator overrides.
await wrapped('https://dashscope.aliyuncs.com/v1');
expect(m.lastInit()?.headers).toBeUndefined();
});
it('skips header when destination URL is unparseable (fail closed)', async () => {
const m = makeFetchMock();
const wrapped = wrapFetchWithCorrelation(
m.fetch,
mockConfig({
enabled: true,
sessionId: 'sess-A',
hosts: undefined,
}),
);
await wrapped('not a valid url');
expect(m.lastInit()?.headers).toBeUndefined();
});
});

View file

@ -1,247 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { diag } from '@opentelemetry/api';
import type { Config } from '../config/config.js';
import {
DEFAULT_SESSION_ID_HEADER_HOSTS,
extractRequestHost,
matchesTrustedHost,
} from './trusted-llm-hosts.js';
/**
* Custom HTTP header attached to outbound LLM service requests when
* telemetry is enabled AND the destination is on the trusted-host
* allowlist. Product-namespaced (matching claude-code's
* `X-Claude-Code-Session-Id` pattern from `src/services/api/client.ts:108`)
* to avoid collision with generic `X-Session-Id` headers other tools may
* inject. Server-side ingestion can use this to stitch its observation of
* an LLM request back to the originating qwen-code session.
*
* Scope: see `DEFAULT_SESSION_ID_HEADER_HOSTS` for the default destination
* set and PR #4390 review (LaZzyMan) for the rationale behind not
* broadcasting to every third-party LLM provider.
*/
export const SESSION_ID_HEADER = 'X-Qwen-Code-Session-Id';
/**
* Loose fetch-like signature the wrapper internally programs against.
*
* Exists because the SDKs we wrap have **incompatible** Fetch types:
* - `openai@5.11`: `(input: string | URL | Request, init?) => Promise<Response>`
* - `@anthropic-ai/sdk`: `(input: RequestInfo, init?) => Promise<Response>`
* where `RequestInfo = string | Request` (NOT including URL).
*
* No single concrete signature satisfies both as a structural subtype, so the
* public `wrapFetchWithCorrelation` is generic and preserves the caller's
* exact type (the SDK's own Fetch). This type only describes the input shape
* we touch inside the wrapper.
*/
type FetchLikeLoose = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
/**
* Wrap a fetch implementation so outbound requests to trusted LLM
* destinations get the `X-Qwen-Code-Session-Id` correlation header
* populated from the **current** session id, not the value captured
* when the SDK client was constructed.
*
* Three gates, in order:
* 1. Telemetry enabled (`config.getTelemetryEnabled()`)
* 2. Destination host is on the trusted allowlist
* (`config.getTelemetrySessionIdHeaderHosts()` ?? default in-vendor set)
* 3. Session id is non-empty
*
* Any failed gate falls through to `baseFetch(input, init)` unchanged
* no header attached, no behavior change. This means a request to
* `api.openai.com` from a default-config install goes out exactly the
* same as it did before this PR landed.
*
* Why per-request and not `defaultHeaders`: SDK clients (and their static
* `defaultHeaders`) are constructed once at content-generator init and are
* NOT recreated on `/clear`-triggered session reset (`Config.resetSession()`
* updates `this.sessionId` but doesn't rebuild the contentGenerator). A
* static header would therefore go stale immediately after the first reset.
* Reading `config.getSessionId()` from inside the wrapper on every call
* gives the live value.
*
* Note on `trustedHosts`: snapshotted once at wrap time, not read per
* request. The session id is live-read but the allowlist is not a
* mid-session change to `telemetry.sessionIdHeaderHosts` in settings.json
* takes effect at next content-generator init (any change that mutates
* Config snapshot the wrapper retains is by definition a Config-level
* concern, not a request-time concern). Operators tuning the scope live
* should restart, or call the openai/anthropic clients via a fresh
* provider after settings reload.
*
* The caller is responsible for choosing the base fetch usually
* `runtimeOptions?.fetch ?? globalThis.fetch` so proxy-aware fetch (set up
* by `buildRuntimeFetchOptions`) is preserved when ProxyAgent is in use.
*
* Safety: the wrapper catches its own exceptions and falls through to
* baseFetch on any internal error. Telemetry must never break the LLM
* request path if Config getters throw, header construction fails, etc.,
* we still want the model call to proceed.
*/
export function wrapFetchWithCorrelation<TFetch extends FetchLikeLoose>(
baseFetch: TFetch,
config: Config,
): TFetch {
// Resolve the host allowlist once at wrap time (Config snapshot).
// Operators override via `telemetry.sessionIdHeaderHosts` in
// settings.json (e.g. `["*.api.openai.com"]` for a specific OpenAI-
// compatible proxy, or `["*"]` to restore the broadcast behavior the
// initial design proposed).
//
// Defensive normalization (PR #4390 review feedback): qwen-code's
// settings loader does not enforce JSON schema at runtime, so the
// getter can legitimately return a malformed value — a bare string
// ("dashscope.aliyuncs.com" instead of ["dashscope.aliyuncs.com"]),
// an array containing non-strings, or whitespace-padded entries
// (" * " or " dashscope.aliyuncs.com"). The chain below:
// 1. catches a throwing getter (mock without stub, pre-getter Config)
// 2. rejects a non-array value (bare string typo) → default allowlist
// 3. filters out non-string elements ([null, "..."] typo placeholder)
// 4. trims every surviving entry uniformly, so the `*` broadcast
// escape hatch and the host-pattern match path have parity
// Violating any of these would let `.includes / .some / matchesTrustedHost`
// throw at buildClient time — bricking the LLM session before the first
// prompt and violating the "telemetry must never break the LLM request
// path" contract that `staticCorrelationHeaders` already honors via its
// end-to-end try/catch.
let trustedHosts: readonly string[];
try {
const raw =
config.getTelemetrySessionIdHeaderHosts?.() ??
DEFAULT_SESSION_ID_HEADER_HOSTS;
trustedHosts = Array.isArray(raw)
? raw
.filter((p): p is string => typeof p === 'string')
.map((p) => p.trim())
: DEFAULT_SESSION_ID_HEADER_HOSTS;
} catch {
trustedHosts = DEFAULT_SESSION_ID_HEADER_HOSTS;
}
// Wildcard escape hatch so operators who want the old broadcast
// behavior can opt in via `["*"]` without us extending the pattern
// grammar in `matchesTrustedHost` (which would tempt other globbing).
// Pre-trimmed above, so `.includes('*')` covers `["*"]` / `[" * "]` /
// `["\t*\n"]` uniformly.
const broadcastAll = trustedHosts.includes('*');
const wrapped: FetchLikeLoose = async function correlationFetch(
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> {
let headers: Headers;
try {
if (!config.getTelemetryEnabled()) {
return baseFetch(input, init);
}
if (!broadcastAll) {
// Host gate: skip injection for destinations not on the allowlist.
// This is what scopes the "stable client fingerprint" exposure to
// first-party endpoints only. PR #4390 review (LaZzyMan).
const host = extractRequestHost(input);
if (!host || !matchesTrustedHost(host, trustedHosts)) {
return baseFetch(input, init);
}
}
const sid = config.getSessionId();
if (!sid) {
// Defensive: empty header value is rejected by some HTTP middleware.
// Skip injection rather than send `X-Qwen-Code-Session-Id: `.
return baseFetch(input, init);
}
// Seed headers: prefer init.headers when caller provided them (their
// intent overrides). Otherwise, if input is a Request, copy its own
// headers so they aren't lost when we pass `{...init, headers}` back
// to baseFetch — `new Headers(undefined)` would otherwise produce an
// empty Headers and drop the Request's headers (including
// Authorization). See PR #4393 review feedback.
headers = new Headers(init?.headers);
if (init?.headers === undefined && input instanceof Request) {
input.headers.forEach((value, key) => headers.set(key, value));
}
headers.set(SESSION_ID_HEADER, sid);
} catch (err) {
// Telemetry must never break the LLM request path. Log and fall through.
diag.warn(
`wrapFetchWithCorrelation: header construction failed, sending request without correlation header: ${err instanceof Error ? err.message : String(err)}`,
);
return baseFetch(input, init);
}
return baseFetch(input, { ...init, headers });
};
// Cast back to TFetch: runtime behavior matches whatever signature the
// caller's baseFetch has (we delegate to it without altering shape).
return wrapped as unknown as TFetch;
}
/**
* Static correlation headers. Captures the session id at call time
* subject to staleness if the host SDK keeps these headers in a
* captured-at-construction slot (e.g. `@google/genai`'s
* `httpOptions.headers` see design doc §8.6 for the known Gemini
* limitation). Prefer `wrapFetchWithCorrelation` whenever the SDK exposes
* a `fetch` hook.
*
* Host scope: same trusted-host allowlist as `wrapFetchWithCorrelation`,
* but evaluated once against the `destinationUrl` known at SDK
* construction. Callers that can't determine the destination should pass
* `undefined` the helper returns `{}` (no header, same as telemetry off).
*
* When telemetry is disabled or the destination isn't trusted, returns
* `{}` so the caller can spread it unconditionally without changing wire
* behavior.
*/
export function staticCorrelationHeaders(
config: Config,
destinationUrl?: string,
): Record<string, string> {
// Mirror the safety contract of `wrapFetchWithCorrelation`: telemetry must
// never break the LLM request path. This helper is called from the Gemini
// content-generator factory at construction time — a throw here would
// propagate up and crash content-generator init for the entire session.
// Fall through to `{}` instead. See PR #4390 review feedback (wenshao).
try {
if (!config.getTelemetryEnabled()) return {};
if (!destinationUrl) return {};
// Same defensive normalization as `wrapFetchWithCorrelation`. Bare
// string / array-with-nulls / whitespace-padded entries from a
// hand-edited settings.json would otherwise crash `.includes` /
// `matchesTrustedHost`. See sibling helper for full rationale.
const raw =
config.getTelemetrySessionIdHeaderHosts?.() ??
DEFAULT_SESSION_ID_HEADER_HOSTS;
const trustedHosts: readonly string[] = Array.isArray(raw)
? raw
.filter((p): p is string => typeof p === 'string')
.map((p) => p.trim())
: DEFAULT_SESSION_ID_HEADER_HOSTS;
const broadcastAll = trustedHosts.includes('*');
if (!broadcastAll) {
let host: string;
try {
host = new URL(destinationUrl).hostname;
} catch {
// Unparseable destination → treat as not on allowlist (fail closed).
return {};
}
if (!matchesTrustedHost(host, trustedHosts)) return {};
}
const sid = config.getSessionId();
if (!sid) return {};
return { [SESSION_ID_HEADER]: sid };
} catch (err) {
diag.warn(
`staticCorrelationHeaders: config read failed, omitting correlation header: ${err instanceof Error ? err.message : String(err)}`,
);
return {};
}
}

View file

@ -147,6 +147,7 @@ describe('Telemetry SDK', () => {
getDebugMode: () => false, getDebugMode: () => false,
getSessionId: () => 'test-session', getSessionId: () => 'test-session',
getCliVersion: () => '1.0.0-test', getCliVersion: () => '1.0.0-test',
getOutboundCorrelationPropagateTraceContext: () => false,
} as unknown as Config; } as unknown as Config;
}); });
@ -640,6 +641,49 @@ describe('Telemetry SDK', () => {
}); });
}); });
describe('Outbound trace-context propagation gate', () => {
function getTextMapPropagator(): unknown {
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
return (constructorCall as { textMapPropagator?: unknown })
.textMapPropagator;
}
it('installs a no-op TextMapPropagator by default (propagateTraceContext=false)', () => {
// Default behavior per PR #4390 R4 split: traceparent is NOT written
// onto outbound wire. The propagator's inject() must be a no-op so
// UndiciInstrumentation's `propagation.inject(carrier)` call writes
// nothing into the outgoing request's headers.
initializeTelemetry(mockConfig);
const propagator = getTextMapPropagator() as
| { inject: (...args: unknown[]) => void; fields: () => string[] }
| undefined;
expect(propagator).toBeDefined();
expect(typeof propagator!.inject).toBe('function');
// Sanity: fields() returns empty array → instrumentation knows there
// are no headers to clear / no propagator state.
expect(propagator!.fields()).toEqual([]);
// inject is a no-op — does not throw, does not mutate the carrier.
const carrier: Record<string, string> = { existing: 'h' };
expect(() =>
propagator!.inject({} as never, carrier, {} as never),
).not.toThrow();
expect(carrier).toEqual({ existing: 'h' });
});
it('uses the SDK default propagator when propagateTraceContext=true (operator opt-in)', () => {
vi.spyOn(
mockConfig,
'getOutboundCorrelationPropagateTraceContext',
).mockReturnValue(true);
initializeTelemetry(mockConfig);
// textMapPropagator is omitted from NodeSDK options → SDK installs
// its default W3C composite propagator. Test asserts the absence
// because the actual W3CTraceContextPropagator instance is created
// inside @opentelemetry/sdk-node which is auto-mocked here.
expect(getTextMapPropagator()).toBeUndefined();
});
});
describe('Instrumentations', () => { describe('Instrumentations', () => {
function getInstrumentations(): unknown[] { function getInstrumentations(): unknown[] {
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!; const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
@ -1115,6 +1159,7 @@ describe('refreshSessionContext', () => {
getDebugMode: () => false, getDebugMode: () => false,
getSessionId: () => 'test-session', getSessionId: () => 'test-session',
getCliVersion: () => '1.0.0-test', getCliVersion: () => '1.0.0-test',
getOutboundCorrelationPropagateTraceContext: () => false,
} as unknown as Config; } as unknown as Config;
}); });

View file

@ -5,7 +5,11 @@
*/ */
import { DiagLogLevel, diag } from '@opentelemetry/api'; import { DiagLogLevel, diag } from '@opentelemetry/api';
import type { DiagLogger } from '@opentelemetry/api'; import type {
Context,
DiagLogger,
TextMapPropagator,
} from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
@ -90,6 +94,29 @@ export function resolveHttpOtlpUrl(
// (2s) and overall (5s) timeouts, so this value is effectively unreachable there. // (2s) and overall (5s) timeouts, so this value is effectively unreachable there.
const SHUTDOWN_TIMEOUT_MS = 10_000; const SHUTDOWN_TIMEOUT_MS = 10_000;
/**
* `TextMapPropagator` that emits nothing. Installed when
* `outboundCorrelation.propagateTraceContext` is false (the default), so
* trace context stays internal to the user's OTLP collector and is not
* written into outbound `fetch` requests to third-party LLM providers.
*
* UndiciInstrumentation still creates client HTTP spans the propagator
* only governs whether `propagation.inject()` writes `traceparent` into
* the outgoing request's header carrier. With this propagator installed,
* inject is a no-op and outbound requests carry no trace headers. PR
* #4390 review (LaZzyMan): split outbound-wire behavior out of telemetry
* default-on.
*/
const NOOP_PROPAGATOR: TextMapPropagator = {
inject() {},
extract(context: Context): Context {
return context;
},
fields(): string[] {
return [];
},
};
let sdk: NodeSDK | undefined; let sdk: NodeSDK | undefined;
let telemetryInitialized = false; let telemetryInitialized = false;
let telemetryShutdownPromise: Promise<void> | undefined; let telemetryShutdownPromise: Promise<void> | undefined;
@ -397,12 +424,26 @@ export function initializeTelemetry(config: Config): void {
return path.slice(0, cut); return path.slice(0, cut);
}; };
// Outbound trace-context propagation gate (PR #4390 review, LaZzyMan):
// by default, install a no-op propagator so `traceparent` does NOT get
// written onto outbound `fetch` requests to LLM providers. Operators
// who want server-side trace stitching (e.g. ARMS+DashScope) opt in via
// `outboundCorrelation.propagateTraceContext: true`, which leaves the
// SDK's default W3C composite propagator in place. UndiciInstrumentation
// still creates client HTTP spans either way — the propagator only
// governs whether trace ids leak onto third-party request streams.
const textMapPropagator: TextMapPropagator | undefined =
config.getOutboundCorrelationPropagateTraceContext()
? undefined // undefined → NodeSDK keeps its default W3C propagator
: NOOP_PROPAGATOR;
sdk = new NodeSDK({ sdk = new NodeSDK({
resource, resource,
// Disable async host/process/env resource detectors: they leave attributes // Disable async host/process/env resource detectors: they leave attributes
// pending and trigger an OTel diag.error on any resource attribute read // pending and trigger an OTel diag.error on any resource attribute read
// before the detectors settle (e.g. during HttpInstrumentation span creation). // before the detectors settle (e.g. during HttpInstrumentation span creation).
autoDetectResources: false, autoDetectResources: false,
...(textMapPropagator && { textMapPropagator }),
spanProcessors: spanExporter ? [new BatchSpanProcessor(spanExporter)] : [], spanProcessors: spanExporter ? [new BatchSpanProcessor(spanExporter)] : [],
logRecordProcessors: logExporter logRecordProcessors: logExporter
? [new BatchLogRecordProcessor(logExporter)] ? [new BatchLogRecordProcessor(logExporter)]

View file

@ -1,181 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
DEFAULT_SESSION_ID_HEADER_HOSTS,
extractRequestHost,
matchesTrustedHost,
} from './trusted-llm-hosts.js';
describe('matchesTrustedHost', () => {
it('returns false for empty hostname (defensive)', () => {
expect(matchesTrustedHost('', ['dashscope.aliyuncs.com'])).toBe(false);
});
it('exact match (case-insensitive)', () => {
expect(
matchesTrustedHost('dashscope.aliyuncs.com', ['dashscope.aliyuncs.com']),
).toBe(true);
expect(
matchesTrustedHost('DashScope.Aliyuncs.COM', ['dashscope.aliyuncs.com']),
).toBe(true);
});
it('exact pattern rejects sub-domain (no implicit wildcard)', () => {
expect(
matchesTrustedHost('sub.dashscope.aliyuncs.com', [
'dashscope.aliyuncs.com',
]),
).toBe(false);
});
it('*.suffix matches the bare suffix domain', () => {
expect(matchesTrustedHost('alibaba-inc.com', ['*.alibaba-inc.com'])).toBe(
true,
);
});
it('*.suffix matches sub-domains', () => {
expect(
matchesTrustedHost('idealab.alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(true);
expect(
matchesTrustedHost('a.b.alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(true);
});
it('*.suffix rejects unrelated hosts that contain the suffix mid-name', () => {
// `evil-alibaba-inc.com` ends with `alibaba-inc.com` as a substring but
// is not a true sub-domain — the dot-anchored check should reject it.
expect(
matchesTrustedHost('evil-alibaba-inc.com', ['*.alibaba-inc.com']),
).toBe(false);
// Trailing-suffix attack: a host whose name HAPPENS to end in the suffix
// text but on a different TLD.
expect(
matchesTrustedHost('alibaba-inc.com.evil.net', ['*.alibaba-inc.com']),
).toBe(false);
});
it('mixed pattern list (exact + wildcard)', () => {
const patterns = [
'dashscope.aliyuncs.com',
'*.dashscope.aliyuncs.com',
'*.alibaba-inc.com',
];
expect(matchesTrustedHost('dashscope.aliyuncs.com', patterns)).toBe(true);
expect(matchesTrustedHost('sub.dashscope.aliyuncs.com', patterns)).toBe(
true,
);
expect(matchesTrustedHost('idealab.alibaba-inc.com', patterns)).toBe(true);
expect(matchesTrustedHost('api.openai.com', patterns)).toBe(false);
});
it('empty pattern list rejects everything', () => {
expect(matchesTrustedHost('dashscope.aliyuncs.com', [])).toBe(false);
});
});
describe('extractRequestHost', () => {
it('extracts from string URL', () => {
expect(extractRequestHost('https://dashscope.aliyuncs.com/v1/x')).toBe(
'dashscope.aliyuncs.com',
);
});
it('extracts from URL object', () => {
expect(extractRequestHost(new URL('https://api.openai.com/v1'))).toBe(
'api.openai.com',
);
});
it('extracts from Request', () => {
expect(
extractRequestHost(new Request('https://api.anthropic.com/v1/messages')),
).toBe('api.anthropic.com');
});
it('strips explicit port (URL.hostname does not include port)', () => {
expect(extractRequestHost('https://dashscope.aliyuncs.com:443/v1')).toBe(
'dashscope.aliyuncs.com',
);
});
it('strips userinfo (URL.hostname does not include user/password)', () => {
expect(
extractRequestHost('https://user:pw@dashscope.aliyuncs.com/v1'),
).toBe('dashscope.aliyuncs.com');
});
it('ignores query string and fragment', () => {
expect(
extractRequestHost('https://dashscope.aliyuncs.com/v1?key=secret#frag'),
).toBe('dashscope.aliyuncs.com');
});
it('returns bracketed form for IPv6 (will never match a string pattern, behaves as fail-closed)', () => {
// Document the IPv6 behavior so it's intentional rather than a
// surprise: URL.hostname returns `[::1]` (with brackets), our
// pattern grammar has no `[…]` form, so IPv6 destinations are
// effectively never on the allowlist. This is acceptable because
// qwen-code's allowlist is for named first-party endpoints, not
// raw IPs. If/when raw-IP correlation becomes a real ask, extend
// matchesTrustedHost — don't change extractRequestHost.
expect(extractRequestHost('http://[::1]:8080/x')).toBe('[::1]');
});
it('returns undefined for unparseable string', () => {
expect(extractRequestHost('not a url')).toBeUndefined();
});
});
describe('DEFAULT_SESSION_ID_HEADER_HOSTS', () => {
it('matches DashScope global + intl endpoints', () => {
expect(
matchesTrustedHost(
'dashscope.aliyuncs.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
expect(
matchesTrustedHost(
'dashscope-intl.aliyuncs.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
});
it('matches internal *.alibaba-inc.com / *.aliyun-inc.com', () => {
expect(
matchesTrustedHost(
'idealab.alibaba-inc.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(true);
expect(
matchesTrustedHost('gw.aliyun-inc.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(true);
});
it('does NOT match third-party LLM providers', () => {
expect(
matchesTrustedHost('api.openai.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost('api.anthropic.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost('openrouter.ai', DEFAULT_SESSION_ID_HEADER_HOSTS),
).toBe(false);
expect(
matchesTrustedHost(
'generativelanguage.googleapis.com',
DEFAULT_SESSION_ID_HEADER_HOSTS,
),
).toBe(false);
});
});

View file

@ -1,89 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Default allowlist for outbound correlation headers
* (`X-Qwen-Code-Session-Id`). Limited to Alibaba/DashScope endpoints where
* the LLM provider, the upstream telemetry backend (ARMS Tracing), and
* the qwen-code distribution are the same legal entity so the session
* id stays a first-party, in-vendor correlation handle.
*
* Why a default scope (rather than broadcasting to every LLM provider):
* PR #4390 review (LaZzyMan) pointed out that an open-source CLI sending
* a stable cross-request identifier to arbitrary third-party providers
* (OpenAI, Anthropic, OpenRouter, ...) is a cross-vendor fingerprinting
* surface those providers don't need for the API call itself. Restricting
* the default to in-vendor destinations mirrors the claude-code pattern
* (first-party client first-party backend), preserves the real product
* value (ARMS server-side trace stitching against DashScope), and makes
* the third-party-broadcast failure mode opt-in rather than default.
*
* Mirrors `DashScopeOpenAICompatibleProvider.isDashScopeProvider` so the
* two layers stay aligned. If you add a hostname there, add it here too.
*
* Pattern syntax (matches `matchesTrustedHost` below):
* - bare hostname exact match (case-insensitive)
* - `*.suffix` matches `suffix` itself AND any sub-domain of it
* (e.g. `*.alibaba-inc.com` matches `alibaba-inc.com`
* and `gw.alibaba-inc.com`)
*/
export const DEFAULT_SESSION_ID_HEADER_HOSTS: readonly string[] = [
'dashscope.aliyuncs.com',
'dashscope-intl.aliyuncs.com',
'*.dashscope.aliyuncs.com',
'*.dashscope-intl.aliyuncs.com',
'*.alibaba-inc.com',
'*.aliyun-inc.com',
];
/**
* Check whether `hostname` matches any pattern in `patterns`.
* Case-insensitive. Empty `hostname` always returns false.
*
* `*.suffix` patterns match:
* - the suffix domain itself (`alibaba-inc.com` matches `*.alibaba-inc.com`)
* - any sub-domain (`gw.alibaba-inc.com` matches `*.alibaba-inc.com`)
*
* This is intentionally a tiny pure helper, not a generic glob. Anything
* more elaborate (regex, port-aware, scheme-aware) should be added at the
* call site, not here, so the allowlist semantics stay obvious from the
* pattern strings users put in settings.
*/
export function matchesTrustedHost(
hostname: string,
patterns: readonly string[],
): boolean {
if (!hostname) return false;
const h = hostname.toLowerCase();
for (const raw of patterns) {
const p = raw.toLowerCase();
if (p.startsWith('*.')) {
const bare = p.slice(2);
if (h === bare || h.endsWith('.' + bare)) return true;
} else if (h === p) {
return true;
}
}
return false;
}
/**
* Extract the destination hostname from a fetch input. Returns `undefined`
* if the URL can't be parsed caller should treat that as "not on the
* allowlist" (fail closed).
*/
export function extractRequestHost(
input: string | URL | Request,
): string | undefined {
try {
if (typeof input === 'string') return new URL(input).hostname;
if (input instanceof URL) return input.hostname;
if (input instanceof Request) return new URL(input.url).hostname;
} catch {
return undefined;
}
return undefined;
}

View file

@ -435,18 +435,23 @@
"default": false "default": false
} }
} }
},
"sessionIdHeaderHosts": {
"description": "Destination hostnames (or \"*.suffix\" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to [\"*\"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.",
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"additionalProperties": true, "additionalProperties": true,
"description": "Telemetry configuration." "description": "Telemetry configuration."
}, },
"outboundCorrelation": {
"type": "object",
"properties": {
"propagateTraceContext": {
"description": "Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Note: client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
"type": "boolean",
"default": false
}
},
"additionalProperties": false,
"description": "SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope)."
},
"fastModel": { "fastModel": {
"description": "Model used for generating prompt suggestions and speculative execution. Leave empty to use the main model. A smaller/faster model (e.g., qwen3-coder-flash) reduces latency and cost.", "description": "Model used for generating prompt suggestions and speculative execution. Leave empty to use the main model. A smaller/faster model (e.g., qwen3-coder-flash) reduces latency and cost.",
"type": "string", "type": "string",