From edb7f01c627aee690faa0fd3147f5c019c49d389 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 7 Sep 2026 15:39:50 +0800 Subject: [PATCH 1/8] feat(core): user-configurable session-ID header for trusted hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateways like OpenCode Go reject requests without a stable per-conversation header (x-opencode-session, enforced since 2026-09-06), and customHeaders cannot carry runtime-dynamic values — the template route was reviewed and rejected in the outbound-propagation design (§12.7), which pre-specifies this setting instead. Adds outboundCorrelation.sessionIdHeader { enabled, headerName, trustedHosts }: default off with an empty host allowlist, HTTPS-only exact-host matching, header-name token validation (an invalid name skips the user branch and keeps the built-in first-party one), and the enabled flag re-checked at the send site so the path fails closed. The value resolves per request via the existing wrapFetchWithSessionId seam, so /new and /resume rotate it without rebuilding the client. The built-in Routify allowlist behaviour is unchanged. Closes #10995 Co-authored-by: Qwen-Coder --- docs/users/configuration/settings.md | 27 ++++ packages/cli/src/config/settingsSchema.ts | 26 ++++ packages/core/src/config/config.test.ts | 43 ++++++ packages/core/src/config/config.ts | 58 ++++++++ .../core/src/core/outbound-session-id.test.ts | 131 +++++++++++++++++- packages/core/src/core/outbound-session-id.ts | 62 +++++++-- .../schemas/settings.schema.json | 24 ++++ 7 files changed, 361 insertions(+), 10 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 6d427813f3c..df3a625bc7f 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -661,6 +661,33 @@ Configures logging and metrics collection for Qwen Code. For more information, s | `telemetry.sensitiveSpanAttributeMaxLength` | number | Maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be between `1` and `104857600` (100 MiB). Set lower if your collector or backend rejects large attributes. | `1048576` | | `telemetry.outfile` | string | Path to write telemetry to a file. When set, overrides OTLP export. | | +#### outboundCorrelation + +⚠️ **Security-relevant.** Controls what client-side correlation data qwen-code writes into outbound LLM API requests — a separate consent decision from `telemetry.*` (which governs data flowing into your OWN observability backend). All values default to off. + +| Setting | Type | Description | Default | +| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` | +| `outboundCorrelation.sessionIdHeader.enabled` | boolean | Master switch for sending the qwen-code session ID as a per-request header to the hosts you list. When off (default), nothing is sent beyond the built-in first-party allowlist. | `false` | +| `outboundCorrelation.sessionIdHeader.headerName` | string | Header name to set. Must be a valid HTTP header name (letters, digits, `.`, `_`, `-`); an invalid name is ignored with a warning. | `session_id` | +| `outboundCorrelation.sessionIdHeader.trustedHosts` | string[] | Exact hostnames (no wildcards) allowed to receive the header. HTTPS requests only; matching is case-insensitive. An empty list sends nothing even when enabled. | `[]` | + +The session ID is resolved per request, so `/new` and `/resume` rotate the value without restarting. Some gateways require such a per-conversation identifier — for example OpenCode Go rejects requests without `x-opencode-session` since 2026-09-06: + +```json +{ + "outboundCorrelation": { + "sessionIdHeader": { + "enabled": true, + "headerName": "x-opencode-session", + "trustedHosts": ["opencode.ai"] + } + } +} +``` + +**Privacy note:** the header is a stable identifier that lets every listed host group all requests of one conversation. Only list hosts you already send your prompt content to. + ### Example `settings.json` Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0: diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 73a0f3d398b..bcbedfe1d0d 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1458,6 +1458,32 @@ const SETTINGS_SCHEMA = { type: 'boolean', default: false, }, + sessionIdHeader: { + description: + 'Send the qwen-code session ID as a per-request header to the listed HTTPS hosts. Default off with an empty host list — nothing is sent unless you opt in. Use for gateways that require a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session` since 2026-09-06). The value is resolved per request, so `/new` and `/resume` rotate it. Privacy: listed hosts can group every request of one conversation under a stable ID — only list hosts you already send your prompts to.', + type: 'object', + properties: { + enabled: { + description: + 'Master switch. Default false: no user-configured session header is sent anywhere.', + type: 'boolean', + default: false, + }, + headerName: { + description: + 'Header name to set, e.g. `x-opencode-session`. Must be a valid HTTP header name (letters, digits, dot, underscore, hyphen); an invalid name is ignored with a warning. Default: `session_id`.', + type: 'string', + }, + trustedHosts: { + description: + 'Exact hostnames (no wildcards) allowed to receive the header, e.g. `["opencode.ai"]`. HTTPS requests only; matching is case-insensitive. Default empty — `enabled: true` with no hosts sends nothing.', + type: 'array', + items: { type: 'string' }, + default: [], + }, + }, + additionalProperties: false, + }, }, additionalProperties: false, }, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 961337246e7..6435cab42be 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -9385,6 +9385,49 @@ describe('Server Config (config.ts)', () => { ); }, ); + + // Default-to-undefined is security-relevant — controls whether the + // session ID is written as a header onto user-chosen hosts. + it('getOutboundSessionIdHeaderSettings is undefined unless enabled', () => { + expect( + new Config({ + ...baseParams, + outboundCorrelation: undefined, + }).getOutboundSessionIdHeaderSettings(), + ).toBeUndefined(); + expect( + new Config({ + ...baseParams, + outboundCorrelation: { + sessionIdHeader: { enabled: false, trustedHosts: ['opencode.ai'] }, + }, + }).getOutboundSessionIdHeaderSettings(), + ).toBeUndefined(); + expect( + new Config({ + ...baseParams, + outboundCorrelation: { sessionIdHeader: { enabled: true } }, + }).getOutboundSessionIdHeaderSettings(), + ).toEqual({ enabled: true }); + }); + + it('getOutboundSessionIdHeaderSettings passes the configured shape through', () => { + const config = new Config({ + ...baseParams, + outboundCorrelation: { + sessionIdHeader: { + enabled: true, + headerName: 'x-opencode-session', + trustedHosts: ['opencode.ai'], + }, + }, + }); + expect(config.getOutboundSessionIdHeaderSettings()).toEqual({ + enabled: true, + headerName: 'x-opencode-session', + trustedHosts: ['opencode.ai'], + }); + }); }); describe('UseRipgrep Configuration', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6362c624b46..ceb8c81776a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -645,6 +645,49 @@ export interface OutboundCorrelationSettings { * Tracing + DashScope — for cross-process trace stitching. */ propagateTraceContext?: boolean; + /** + * Send the qwen-code session ID as a per-request header to + * user-chosen HTTPS hosts. Default: disabled with an empty host + * list — nothing is sent anywhere unless the operator opts in. + * + * Some OpenAI-compatible gateways require a stable per-conversation + * identifier for prompt-cache routing (e.g. OpenCode Go rejects + * requests without `x-opencode-session` since 2026-09-06). The value + * is resolved per request from `getSessionId()`, so `/new` and + * `/resume` rotate it without rebuilding the client. + * + * Privacy: the header is a stable identifier that lets the listed + * hosts group every request of one conversation. Only list hosts you + * already send your prompts to; see + * docs/design/telemetry-outbound-propagation-design.md §12.7. + */ + sessionIdHeader?: OutboundSessionIdHeaderSettings; +} + +/** + * User-configurable session-ID header for outbound LLM requests. + * Lives under `outboundCorrelation.*` — see the parent interface for + * why this namespace is separate from `telemetry.*`. + */ +export interface OutboundSessionIdHeaderSettings { + /** + * Master switch. Default `false`: no user-configured header is sent, + * and `trustedHosts`/`headerName` are ignored. + */ + enabled?: boolean; + /** + * Header name to set, e.g. `x-opencode-session`. Must be a valid + * HTTP token (letters, digits, `-`); an invalid name is ignored with + * a debug warning rather than sent. Default: `session_id`. + */ + headerName?: string; + /** + * Exact hostnames (no wildcards) that may receive the header, e.g. + * `["opencode.ai"]`. HTTPS only. Matching is case-insensitive. + * Default: empty — with `enabled: true` but no hosts, nothing is + * sent (fail-closed rather than "all hosts"). + */ + trustedHosts?: string[]; } export interface OutputSettings { @@ -2617,6 +2660,7 @@ export class Config { this.outboundCorrelationSettings = { propagateTraceContext: params.outboundCorrelation?.propagateTraceContext ?? false, + sessionIdHeader: params.outboundCorrelation?.sessionIdHeader, }; this.gitCoAuthor = { ...normalizeGitCoAuthor(params.gitCoAuthor), @@ -7510,6 +7554,20 @@ export class Config { return this.outboundCorrelationSettings.propagateTraceContext ?? false; } + /** + * The user-configured session-ID header settings, or `undefined` + * when the feature is off (the default). Consumers treat `undefined` + * as "send nothing beyond the built-in first-party allowlist" — see + * `core/outbound-session-id.ts`. + */ + getOutboundSessionIdHeaderSettings(): + | OutboundSessionIdHeaderSettings + | undefined { + const settings = this.outboundCorrelationSettings.sessionIdHeader; + if (!settings?.enabled) return undefined; + return settings; + } + getTelemetryOutfile(): string | undefined { return this.telemetrySettings.outfile; } diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts index 6082546e6af..fa9cd1d643e 100644 --- a/packages/core/src/core/outbound-session-id.test.ts +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -5,7 +5,10 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Config } from '../config/config.js'; +import type { + Config, + OutboundSessionIdHeaderSettings, +} from '../config/config.js'; import { buildSessionAwareFetch, buildSessionIdHeaders, @@ -13,9 +16,15 @@ import { wrapFetchWithSessionId, } from './outbound-session-id.js'; -function config(sessionId = 'session-1'): Config { +function config( + sessionId = 'session-1', + sessionIdHeader?: OutboundSessionIdHeaderSettings, +): Config { return { getSessionId: vi.fn().mockReturnValue(sessionId), + getOutboundSessionIdHeaderSettings: vi + .fn() + .mockReturnValue(sessionIdHeader), } as unknown as Config; } @@ -168,3 +177,121 @@ describe('outbound session ID', () => { expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); }); }); + +describe('outbound session ID — user-configured header', () => { + const OPENCODE = { + enabled: true, + headerName: 'x-opencode-session', + trustedHosts: ['opencode.ai'], + }; + + it('sends the configured header to a trusted host', () => { + expect( + buildSessionIdHeaders( + config('session-1', OPENCODE), + 'https://opencode.ai/zen/go/v1/chat/completions', + ), + ).toEqual({ 'x-opencode-session': 'session-1' }); + }); + + it('sends nothing when the feature is off, whatever the hosts say', () => { + expect( + buildSessionIdHeaders( + config('session-1', { ...OPENCODE, enabled: false }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({}); + expect( + buildSessionIdHeaders(config(), 'https://opencode.ai/zen/go/v1'), + ).toEqual({}); + }); + + it.each([ + 'https://api.openai.com/v1', + 'https://evil-opencode.ai/zen/go/v1', + 'https://opencode.ai.evil.example/v1', + 'http://opencode.ai/zen/go/v1', + ])('does not send the configured header to %s', (url) => { + expect(buildSessionIdHeaders(config('session-1', OPENCODE), url)).toEqual( + {}, + ); + }); + + it('sends nothing when enabled with an empty host list', () => { + expect( + buildSessionIdHeaders( + config('session-1', { ...OPENCODE, trustedHosts: [] }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({}); + expect( + buildSessionIdHeaders( + config('session-1', { enabled: true, trustedHosts: undefined }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({}); + }); + + it('defaults the header name to session_id and matches hosts case-insensitively', () => { + expect( + buildSessionIdHeaders( + config('session-1', { enabled: true, trustedHosts: ['OpenCode.AI '] }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); + }); + + it('skips an invalid header name but keeps the built-in branch', () => { + for (const headerName of ['x opencode', 'x:y', 'x\r\nInjected: 1', '']) { + expect( + buildSessionIdHeaders( + config('session-1', { ...OPENCODE, headerName }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({}); + } + expect( + buildSessionIdHeaders( + config('session-1', { ...OPENCODE, headerName: 'x:y' }), + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); + }); + + it('emits both headers when a built-in host is also user-trusted under another name', () => { + expect( + buildSessionIdHeaders( + config('session-1', { + enabled: true, + headerName: 'x-session', + trustedHosts: ['routify-pub.alibaba-inc.com'], + }), + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1', 'x-session': 'session-1' }); + }); + + it('re-resolves the session ID per request across rotations', async () => { + const cliConfig = config('session-1', OPENCODE); + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrappedFetch = wrapFetchWithSessionId(baseFetch, cliConfig); + + await wrappedFetch('https://opencode.ai/zen/go/v1/chat/completions'); + vi.mocked(cliConfig.getSessionId).mockReturnValue('session-2'); + await wrappedFetch('https://opencode.ai/zen/go/v1/chat/completions'); + + const firstHeaders = new Headers(baseFetch.mock.calls[0][1]?.headers); + const secondHeaders = new Headers(baseFetch.mock.calls[1][1]?.headers); + expect(firstHeaders.get('x-opencode-session')).toBe('session-1'); + expect(secondHeaders.get('x-opencode-session')).toBe('session-2'); + }); + + it('does not send an empty session ID through the configured header', () => { + expect( + buildSessionIdHeaders(config('', OPENCODE), 'https://opencode.ai/v1'), + ).toEqual({}); + }); +}); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts index 60d554d6ea8..901e80e9f8c 100644 --- a/packages/core/src/core/outbound-session-id.ts +++ b/packages/core/src/core/outbound-session-id.ts @@ -16,6 +16,12 @@ export const SESSION_ID_HEADER_HOSTS: readonly string[] = [ 'routify-pub.alibaba-inc.com', ]; +// Conservative HTTP token subset (letters, digits, dot, underscore, +// hyphen) — covers real header names like `x-opencode-session` while +// keeping anything a Headers implementation could reject, or that could +// smuggle a second header, out of the configured value. +const VALID_HEADER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + type FetchLike = ( input: string | URL | Request, init?: RequestInit, @@ -37,14 +43,52 @@ export function buildSessionIdHeaders( ): Record { try { const url = requestUrl(destination); - if ( - url?.protocol !== 'https:' || - !SESSION_ID_HEADER_HOSTS.includes(url.hostname.toLowerCase()) - ) { + if (url?.protocol !== 'https:') { + return {}; + } + const hostname = url.hostname.toLowerCase(); + + const builtIn = SESSION_ID_HEADER_HOSTS.includes(hostname); + + // User-configured branch (`outboundCorrelation.sessionIdHeader`): + // exact-host allowlist, HTTPS only, disabled by default. `enabled` + // is re-checked here even though the Config getter already gates on + // it — this is a security-relevant send path, so it fails closed + // against a settings object that says off. An invalid header name + // skips this branch only — the built-in one still runs. + const settings = config.getOutboundSessionIdHeaderSettings(); + let configuredHeader: string | undefined; + if (settings && settings.enabled !== false) { + const headerName = (settings.headerName ?? SESSION_ID_HEADER).trim(); + if (!VALID_HEADER_NAME.test(headerName)) { + debugLogger.warn( + `Ignoring outboundCorrelation.sessionIdHeader.headerName "${headerName}": not a valid HTTP header name.`, + ); + } else { + const trustedHosts = (settings.trustedHosts ?? []) + .map((host) => host.trim().toLowerCase()) + .filter((host) => host.length > 0); + if (trustedHosts.includes(hostname)) { + configuredHeader = headerName; + } + } + } + + if (!builtIn && configuredHeader === undefined) { return {}; } const sessionId = config.getSessionId(); - return sessionId ? { [SESSION_ID_HEADER]: sessionId } : {}; + if (!sessionId) { + return {}; + } + const headers: Record = {}; + if (builtIn) { + headers[SESSION_ID_HEADER] = sessionId; + } + if (configuredHeader !== undefined) { + headers[configuredHeader] = sessionId; + } + return headers; } catch (error) { debugLogger.warn( `Unable to add ${SESSION_ID_HEADER} to outbound request: ${ @@ -62,14 +106,16 @@ export function wrapFetchWithSessionId( const fetchLike = baseFetch as FetchLike; const wrapped: FetchLike = async (input, init) => { const sessionHeaders = buildSessionIdHeaders(config, input); - const sessionId = sessionHeaders[SESSION_ID_HEADER]; - if (!sessionId) return fetchLike(input, init); + const entries = Object.entries(sessionHeaders); + if (entries.length === 0) return fetchLike(input, init); const headers = new Headers( input instanceof Request ? input.headers : undefined, ); new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); - headers.set(SESSION_ID_HEADER, sessionId); + for (const [name, value] of entries) { + headers.set(name, value); + } return fetchLike(input, { ...init, headers }); }; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 49c28ad576a..062bcd7a4ed 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -637,6 +637,30 @@ "description": "Requires `telemetry.enabled: true`. Inject W3C `traceparent` on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...) AND as a `TRACEPARENT` environment variable in shell child processes (Bash tool, hooks, monitor). When enabled, any existing `TRACEPARENT` in the parent environment is overwritten with qwen-code's own trace context. Default: false — trace context stays internal to the operator's OTLP collector. Set true when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope) or need shell scripts / CLI tools to participate in distributed tracing.", "type": "boolean", "default": false + }, + "sessionIdHeader": { + "description": "Send the qwen-code session ID as a per-request header to the listed HTTPS hosts. Default off with an empty host list — nothing is sent unless you opt in. Use for gateways that require a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session` since 2026-09-06). The value is resolved per request, so `/new` and `/resume` rotate it. Privacy: listed hosts can group every request of one conversation under a stable ID — only list hosts you already send your prompts to.", + "type": "object", + "properties": { + "enabled": { + "description": "Master switch. Default false: no user-configured session header is sent anywhere.", + "type": "boolean", + "default": false + }, + "headerName": { + "description": "Header name to set, e.g. `x-opencode-session`. Must be a valid HTTP header name (letters, digits, dot, underscore, hyphen); an invalid name is ignored with a warning. Default: `session_id`.", + "type": "string" + }, + "trustedHosts": { + "description": "Exact hostnames (no wildcards) allowed to receive the header, e.g. `[\"opencode.ai\"]`. HTTPS requests only; matching is case-insensitive. Default empty — `enabled: true` with no hosts sends nothing.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false } }, "additionalProperties": false, From d3a2f90a52715b8eb4f48a23acae92e095947edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= Date: Mon, 7 Sep 2026 17:27:22 +0900 Subject: [PATCH 2/8] fix(core): keep the built-in session_id header independent of the opt-in branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildSessionIdHeaders` called `getOutboundSessionIdHeaderSettings()` before the built-in short-circuit and inside the shared `try`. A `Config` collaborator without that method threw, the catch swallowed it into a debug warning and returned `{}` — so the first-party Routify `session_id` header that ships today was dropped too. CI caught it: 7 failures across the four provider suites that build partial `as unknown as Config` doubles (`AssertionError: expected null to be 'test-session'`). Rather than teach those four suites about a setting they do not use, make the branches independent by construction: the opt-in branch now resolves through `configuredSessionHeaderName()`, which cannot throw. A setting that ships off is structurally unable to alter the path that ships on. Also in the same seam: - Re-check the flag as `enabled === true`, not `enabled !== false`. The old test treated a settings object with no `enabled` field as ON, which is fail-open — the opposite of what the comment claimed to guarantee. - Match the trusted host before validating the header name, and warn once per distinct name. An invalid name previously logged on every outbound HTTPS request, including ones to hosts that were never listed. - Collapse a configured name that differs from `session_id` only by case, since header names are case-insensitive on the wire. Docs: `docs/design/2026-09-03-outbound-session-id-header.md` still said the behavior is "intentionally not configurable" with "no user-configurable allowlist". Updated, and given the threat-model section that §12.7 of the outbound propagation design asked this follow-up to bring: recipient set, de-anonymization window, redirect forwarding, misconfiguration, and why a per-request UUID is out of scope. `settings.md` gains the redirect, punycode and debug-log caveats; the `headerName` JSDoc no longer contradicts the regex it describes. Tests: the built-in header surviving a Config without the getter, an absent `enabled` field treated as off, and the case-only-difference collapse. Not run locally (this box cannot): typecheck, build and vitest are CI's to confirm. ESLint passes on the changed files. --- .../2026-09-03-outbound-session-id-header.md | 92 +++++++++++++++++- docs/users/configuration/settings.md | 18 ++-- packages/core/src/config/config.ts | 12 ++- .../core/src/core/outbound-session-id.test.ts | 45 +++++++++ packages/core/src/core/outbound-session-id.ts | 94 ++++++++++++++----- 5 files changed, 223 insertions(+), 38 deletions(-) diff --git a/docs/design/2026-09-03-outbound-session-id-header.md b/docs/design/2026-09-03-outbound-session-id-header.md index baf869903d8..db60d67e514 100644 --- a/docs/design/2026-09-03-outbound-session-id-header.md +++ b/docs/design/2026-09-03-outbound-session-id-header.md @@ -1,10 +1,14 @@ -# Routify `session_id` Header +# Outbound `session_id` Header ## Summary Qwen Code attaches its current session ID as the `session_id` HTTP header only when the outbound LLM request hostname is one of the three Routify endpoints documented by ModelRouter: `routify.alibaba-inc.com`, `routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. -The behavior is intentionally not configurable. Qwen Code does not attach the header to subdomains, other `alibaba-inc.com` hosts, or non-Routify providers. Standard fetch redirect behavior applies after the initial destination check, so a Routify response can forward the header by redirecting the request. +That built-in allowlist is fixed and not configurable: Qwen Code does not attach the header to subdomains, other `alibaba-inc.com` hosts, or non-Routify providers, and no setting can add to, remove from, or disable it. + +Since the `outboundCorrelation.sessionIdHeader` follow-up, an **opt-in second branch** can additionally send the session ID to hosts the user lists themselves, under a header name they choose — see [User-configurable hosts](#user-configurable-hosts) below. It ships off with an empty allowlist and is deliberately independent of the built-in branch. + +Standard fetch redirect behavior applies after the initial destination check, so a listed host can forward the header by redirecting the request. ## Motivation @@ -12,10 +16,90 @@ Routify's ModelRouter accepts `session_id` as a session-affinity and traffic-mar ## Security boundary -A session ID is a stable cross-request identifier. The implementation therefore requires HTTPS and compares the parsed request hostname to a fixed set of three ModelRouter hostnames. It does not use suffix matching, wildcards, path matching, or a user-configurable allowlist. Invalid URLs fail closed. +A session ID is a stable cross-request identifier. The implementation therefore requires HTTPS and compares the parsed request hostname to an exact-match allowlist — the fixed set of three ModelRouter hostnames for the built-in branch, plus whatever the user listed for the opt-in branch. Neither branch uses suffix matching, wildcards, or path matching. Invalid URLs fail closed. The Qwen Code session ID replaces any custom `session_id` value on an eligible request so the affinity marker cannot disagree with the active session. All other existing headers, including authorization, are preserved. +## User-configurable hosts + +`outboundCorrelation.sessionIdHeader` lets a user send the same session ID to a +gateway of their own, because some gateways require a stable per-conversation +identifier under a name of their choosing (OpenCode Go rejects requests without +`x-opencode-session`). `outboundCorrelation.customHeaders` cannot express this: +it is baked into the SDK client at construction, while `/new` and `/resume` +rotate the session ID without rebuilding that client. + +```json +{ + "outboundCorrelation": { + "sessionIdHeader": { + "enabled": true, + "headerName": "x-opencode-session", + "trustedHosts": ["opencode.ai"] + } + } +} +``` + +Rails, in the order they are applied: + +- **Off by default, with an empty allowlist.** `enabled: true` with no + `trustedHosts` sends nothing; there is no default allowlist to inherit. +- **HTTPS and exact host only**, matched case-insensitively against the parsed + hostname. The port is not part of the comparison, and internationalized names + must be listed in punycode. +- **Header name validated** against a conservative HTTP token subset + (`/^[A-Za-z0-9][A-Za-z0-9._-]*$/`), which is what keeps CRLF out of the name. + An invalid name drops this branch only, warning once per distinct name. +- **The flag is re-checked at the send site** as `enabled === true`, so a + settings object that arrives without the field is treated as off. + +The two branches are independent by construction. The built-in branch resolves +without consulting any setting, and the opt-in branch is resolved by a helper +that cannot throw — a `Config` collaborator lacking +`getOutboundSessionIdHeaderSettings()` yields "no configured header", never an +error that would suppress the first-party header. That independence is the +property to preserve when editing this seam: a setting that ships off must not +be able to alter a path that ships on. + +When one host matches both branches, both headers are sent with the same value, +unless the configured name differs from `session_id` only by case — header names +are case-insensitive on the wire, so that is emitted once. + +## Threat model + +**Recipients.** Without configuration, exactly the three ModelRouter hosts. +With configuration, those plus the hosts the user listed. There is no path by +which any other host receives the header from the initial request. + +**De-anonymization window.** The session ID is stable for the life of a session +and rotates on `/new` and `/resume`. Every recipient can therefore group all +requests of one conversation, and correlate them with anything else it knows +about the connection (API key, account, source IP). It is not a global user +identifier and does not persist across sessions, so the window is one +conversation per recipient — which is why the guidance is to list only hosts +that already receive the prompt content itself. + +**Redirects.** Both allowlists are checked against the initial destination. +`fetch` strips `Authorization` on a cross-origin redirect but not arbitrary +headers, so a listed host that redirects can forward the session ID to a host +the user never listed. Listing a host is therefore trust in that host's +redirect behavior too, not only in the host itself. + +**Misconfiguration.** A user can list a host they do not actually trust. The +defaults (off, empty list) mean this requires a deliberate edit, and the setting +is flagged security-relevant in `docs/users/configuration/settings.md`. A typo +in `headerName` is only visible as an `OUTBOUND_CORRELATION` warning in the +debug log (`QWEN_DEBUG_LOG_FILE=1`), so the +observable symptom is a gateway that keeps rejecting requests; that is a known +rough edge rather than a safety issue, since the failure direction is "not +sent". + +**Per-request identifiers.** A per-request UUID would remove the grouping +ability, but also the session affinity and prompt-cache routing that are the +entire point of the header. It is out of scope here and remains available as a +separate setting if a recipient ever needs correlation without grouping. + ## Request lifecycle OpenAI-compatible and Anthropic clients receive a fetch wrapper. The wrapper reads `Config.getSessionId()` immediately before each HTTP request. This matters because `/clear` starts a new session without rebuilding the SDK client. @@ -33,4 +117,4 @@ Non-LLM traffic, other domains, MCP requests, tool fetches, subprocesses, `trace ## Verification -Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID. +Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. For the opt-in branch they additionally cover the default-off and empty-allowlist cases, an absent `enabled` field, plaintext and suffix-lookalike hosts, CRLF and other invalid header names, coexistence with the built-in branch, the case-only-difference collapse, and a `Config` without the settings getter still producing the built-in header. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index df3a625bc7f..d1d29e072c2 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -665,12 +665,12 @@ Configures logging and metrics collection for Qwen Code. For more information, s ⚠️ **Security-relevant.** Controls what client-side correlation data qwen-code writes into outbound LLM API requests — a separate consent decision from `telemetry.*` (which governs data flowing into your OWN observability backend). All values default to off. -| Setting | Type | Description | Default | -| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` | -| `outboundCorrelation.sessionIdHeader.enabled` | boolean | Master switch for sending the qwen-code session ID as a per-request header to the hosts you list. When off (default), nothing is sent beyond the built-in first-party allowlist. | `false` | -| `outboundCorrelation.sessionIdHeader.headerName` | string | Header name to set. Must be a valid HTTP header name (letters, digits, `.`, `_`, `-`); an invalid name is ignored with a warning. | `session_id` | -| `outboundCorrelation.sessionIdHeader.trustedHosts` | string[] | Exact hostnames (no wildcards) allowed to receive the header. HTTPS requests only; matching is case-insensitive. An empty list sends nothing even when enabled. | `[]` | +| Setting | Type | Description | Default | +| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | +| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` | +| `outboundCorrelation.sessionIdHeader.enabled` | boolean | Master switch for sending the qwen-code session ID as a per-request header to the hosts you list. When off (default), nothing is sent beyond the built-in first-party allowlist. | `false` | +| `outboundCorrelation.sessionIdHeader.headerName` | string | Header name to set. Must be a valid HTTP header name (letters, digits, `.`, `_`, `-`); an invalid name is ignored with a warning. | `session_id` | +| `outboundCorrelation.sessionIdHeader.trustedHosts` | string[] | Exact hostnames (no wildcards) allowed to receive the header. HTTPS requests only; matching is case-insensitive and ignores the port. An empty list sends nothing even when enabled. | `[]` | The session ID is resolved per request, so `/new` and `/resume` rotate the value without restarting. Some gateways require such a per-conversation identifier — for example OpenCode Go rejects requests without `x-opencode-session` since 2026-09-06: @@ -688,6 +688,12 @@ The session ID is resolved per request, so `/new` and `/resume` rotate the value **Privacy note:** the header is a stable identifier that lets every listed host group all requests of one conversation. Only list hosts you already send your prompt content to. +Three things worth knowing before you list a host: + +- **Only the initial destination is checked.** `fetch` keeps non-authorization headers across a redirect, so a host you list can forward the session ID to one you did not by redirecting. Listing a host means trusting its redirect behavior too. +- **Internationalized domains must be written in punycode** (`xn--…`), because the request hostname is compared as the URL parser produced it. +- **A typo in `headerName` is only reported in the debug log.** If the gateway keeps rejecting your requests, run with `QWEN_DEBUG_LOG_FILE=1` and look for an `OUTBOUND_CORRELATION` warning in the session debug log — an invalid name is dropped rather than sent. + ### Example `settings.json` Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0: diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ceb8c81776a..c4d798940bd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -676,14 +676,20 @@ export interface OutboundSessionIdHeaderSettings { */ enabled?: boolean; /** - * Header name to set, e.g. `x-opencode-session`. Must be a valid - * HTTP token (letters, digits, `-`); an invalid name is ignored with + * Header name to set, e.g. `x-opencode-session`. Must match the + * conservative HTTP token subset accepted by + * `core/outbound-session-id.ts` (letters, digits, `.`, `_`, `-`, + * starting with a letter or digit); an invalid name is ignored with * a debug warning rather than sent. Default: `session_id`. */ headerName?: string; /** * Exact hostnames (no wildcards) that may receive the header, e.g. - * `["opencode.ai"]`. HTTPS only. Matching is case-insensitive. + * `["opencode.ai"]`. HTTPS only. Matching is case-insensitive and + * ignores the port; internationalized names must be listed in + * punycode, because the request hostname is compared as parsed. + * Only the initial destination is checked — a listed host that + * redirects elsewhere forwards the header with it. * Default: empty — with `enabled: true` but no hosts, nothing is * sent (fail-closed rather than "all hosts"). */ diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts index fa9cd1d643e..5f488722701 100644 --- a/packages/core/src/core/outbound-session-id.test.ts +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -294,4 +294,49 @@ describe('outbound session ID — user-configured header', () => { buildSessionIdHeaders(config('', OPENCODE), 'https://opencode.ai/v1'), ).toEqual({}); }); + + // The opt-in branch ships off; the built-in first-party branch ships + // on. A Config that predates the new getter must not be able to turn + // the shipping-on branch off by throwing — the four provider suites + // build exactly such partial `as unknown as Config` doubles. + it('keeps the built-in header when Config has no sessionIdHeader getter', () => { + const legacyConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + + expect( + buildSessionIdHeaders( + legacyConfig, + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); + expect( + buildSessionIdHeaders(legacyConfig, 'https://opencode.ai/zen/go/v1'), + ).toEqual({}); + }); + + it('treats settings with no enabled field as off', () => { + expect( + buildSessionIdHeaders( + config('session-1', { + headerName: 'x-opencode-session', + trustedHosts: ['opencode.ai'], + }), + 'https://opencode.ai/zen/go/v1', + ), + ).toEqual({}); + }); + + it('emits one header when the configured name only differs in case', () => { + expect( + buildSessionIdHeaders( + config('session-1', { + enabled: true, + headerName: 'Session_ID', + trustedHosts: ['routify-pub.alibaba-inc.com'], + }), + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); + }); }); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts index 901e80e9f8c..2ee79630962 100644 --- a/packages/core/src/core/outbound-session-id.ts +++ b/packages/core/src/core/outbound-session-id.ts @@ -22,6 +22,10 @@ export const SESSION_ID_HEADER_HOSTS: readonly string[] = [ // smuggle a second header, out of the configured value. const VALID_HEADER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +// An invalid header name is a static misconfiguration, so warn once per +// distinct name rather than on every request to the trusted host. +const warnedInvalidHeaderNames = new Set(); + type FetchLike = ( input: string | URL | Request, init?: RequestInit, @@ -37,6 +41,58 @@ function requestUrl(input: string | URL | Request): URL | undefined { } } +/** + * The user-configured header name to send to `hostname`, or `undefined` + * when the opt-in feature is off, the host is not trusted, or the name + * is unusable. + * + * Never throws. `outboundCorrelation.sessionIdHeader` ships OFF, and the + * built-in first-party branch ships ON — so this function is kept + * structurally unable to affect that branch, including by raising on a + * `Config` collaborator that predates `getOutboundSessionIdHeaderSettings`. + */ +function configuredSessionHeaderName( + config: Config, + hostname: string, +): string | undefined { + try { + const settings = config.getOutboundSessionIdHeaderSettings(); + // Explicitly `=== true`, not `!== false`: a settings object that + // reaches here without an `enabled` field is treated as OFF. The + // Config getter already gates on the flag; this re-check exists to + // fail closed if that gate is ever refactored away, which it only + // does if the absent case is off too. + if (settings?.enabled !== true) return undefined; + + // Host match before name validation: a misconfigured header name + // must not log on every outbound HTTPS request, only on the ones + // actually destined for a host the user listed. + const trusted = (settings.trustedHosts ?? []).some( + (host) => host.trim().toLowerCase() === hostname, + ); + if (!trusted) return undefined; + + const headerName = (settings.headerName ?? SESSION_ID_HEADER).trim(); + if (!VALID_HEADER_NAME.test(headerName)) { + if (!warnedInvalidHeaderNames.has(headerName)) { + warnedInvalidHeaderNames.add(headerName); + debugLogger.warn( + `Ignoring outboundCorrelation.sessionIdHeader.headerName "${headerName}": not a valid HTTP header name.`, + ); + } + return undefined; + } + return headerName; + } catch (error) { + debugLogger.warn( + `Unable to resolve outboundCorrelation.sessionIdHeader: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } +} + export function buildSessionIdHeaders( config: Config, destination: string | URL | Request, @@ -48,31 +104,13 @@ export function buildSessionIdHeaders( } const hostname = url.hostname.toLowerCase(); + // Note: both allowlists are checked against the *initial* request + // destination only. Standard fetch redirect behavior applies after + // that, so a listed host can forward the header elsewhere by + // redirecting — see the threat model in + // docs/design/2026-09-03-outbound-session-id-header.md. const builtIn = SESSION_ID_HEADER_HOSTS.includes(hostname); - - // User-configured branch (`outboundCorrelation.sessionIdHeader`): - // exact-host allowlist, HTTPS only, disabled by default. `enabled` - // is re-checked here even though the Config getter already gates on - // it — this is a security-relevant send path, so it fails closed - // against a settings object that says off. An invalid header name - // skips this branch only — the built-in one still runs. - const settings = config.getOutboundSessionIdHeaderSettings(); - let configuredHeader: string | undefined; - if (settings && settings.enabled !== false) { - const headerName = (settings.headerName ?? SESSION_ID_HEADER).trim(); - if (!VALID_HEADER_NAME.test(headerName)) { - debugLogger.warn( - `Ignoring outboundCorrelation.sessionIdHeader.headerName "${headerName}": not a valid HTTP header name.`, - ); - } else { - const trustedHosts = (settings.trustedHosts ?? []) - .map((host) => host.trim().toLowerCase()) - .filter((host) => host.length > 0); - if (trustedHosts.includes(hostname)) { - configuredHeader = headerName; - } - } - } + const configuredHeader = configuredSessionHeaderName(config, hostname); if (!builtIn && configuredHeader === undefined) { return {}; @@ -85,7 +123,13 @@ export function buildSessionIdHeaders( if (builtIn) { headers[SESSION_ID_HEADER] = sessionId; } - if (configuredHeader !== undefined) { + // Header names are case-insensitive on the wire, so a configured + // name that only differs in case from the built-in one would be the + // same header — emit it once rather than as two record entries. + if ( + configuredHeader !== undefined && + !(builtIn && configuredHeader.toLowerCase() === SESSION_ID_HEADER) + ) { headers[configuredHeader] = sessionId; } return headers; From 3e2761b946c6ecff9c141efcb0de27eeb35ed7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= Date: Mon, 7 Sep 2026 18:44:51 +0900 Subject: [PATCH 3/8] feat(core): expand ${session_id} in per-provider customHeaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces this PR's implementation. The previous approach added a global `outboundCorrelation.sessionIdHeader` with its own `trustedHosts` allowlist; this delivers the same user need through the mechanism #10995 actually asked for. Why the change of approach: - **`outboundCorrelation` is the wrong home.** It exists (from #4390) for correlation data *qwen-code itself* injects, is documented only in the developer telemetry docs, and defaults everything to off because it is optional. #10995 is the opposite on all three counts: the user configures it, they need it in the settings docs, and without it the gateway rejects every request. Filing it there repeats the category error #4390's review objected to, one level down. - **A single global `headerName` cannot serve two gateways.** One string and one host list mean a user with two gateways wanting different header names cannot express it, and two model entries on the same host cannot be distinguished at all. - **`trustedHosts` re-encodes `modelProviders[].baseUrl`.** A global setting has no scope of its own, so it has to rebuild one from the endpoint the user already wrote down. `customHeaders` is already per-provider, so scope comes for free: which hosts may receive the value is answered by the provider entry the header hangs on, and which providers need it by which entries carry it. No allowlist, and none of the host normalization, punycode conversion or wildcard rejection it required. What is kept from the #4390 review: the consent decision stays in `outboundCorrelation`, as `allowDynamicHeaderValues` (default false), because an expanded value carries live session state to a third party. It also stops a provider preset or extension from quietly promoting a `customHeaders` entry it ships into an identity header — provenance is lost once presets and user settings are merged. Fail-closed throughout: gate off, empty session ID, or a Config that cannot answer all drop the header rather than putting a literal `${session_id}` on the wire. Whether any placeholder exists is decided once at client construction, so providers without one keep the fetch wrapper's existing early return. Gemini needs its own path: its customHeaders are frozen into the SDK client options at construction, so placeholder-bearing entries are kept out of the client entirely and supplied per request instead. The built-in first-party Routify branch is untouched, and docs/design/2026-09-03-outbound-session-id-header.md stays accurate as written — the two mechanisms are complementary, which is how #10995 described them. Verified locally: 421 tests pass across the six affected core suites, including the four provider suites whose session_id assertions the earlier approach broke. Typecheck, build and the settings-schema regeneration are CI's to confirm — the schema entry was hand-written with its description extracted from settingsSchema.ts, so CI's drift check is the authority. Rationale and the comparison in full: docs/plans/2026-09-07-session-id-header-shape-comparison.md --- .../2026-09-03-outbound-session-id-header.md | 92 +------- ...9-07-session-id-header-shape-comparison.md | 198 ++++++++++++++++++ docs/users/configuration/model-providers.md | 35 +++- docs/users/configuration/settings.md | 35 ++-- packages/cli/src/config/settingsSchema.ts | 28 +-- packages/core/src/config/config.test.ts | 43 ---- packages/core/src/config/config.ts | 79 ++----- .../anthropicContentGenerator.ts | 1 + .../llm-content-generator.ts | 35 +++- .../provider/dashscope.ts | 6 +- .../provider/default.ts | 6 +- .../src/core/outbound-dynamic-headers.test.ts | 139 ++++++++++++ .../core/src/core/outbound-dynamic-headers.ts | 135 ++++++++++++ .../core/src/core/outbound-session-id.test.ts | 176 +--------------- packages/core/src/core/outbound-session-id.ts | 125 +++-------- .../schemas/settings.schema.json | 27 +-- 16 files changed, 626 insertions(+), 534 deletions(-) create mode 100644 docs/plans/2026-09-07-session-id-header-shape-comparison.md create mode 100644 packages/core/src/core/outbound-dynamic-headers.test.ts create mode 100644 packages/core/src/core/outbound-dynamic-headers.ts diff --git a/docs/design/2026-09-03-outbound-session-id-header.md b/docs/design/2026-09-03-outbound-session-id-header.md index db60d67e514..baf869903d8 100644 --- a/docs/design/2026-09-03-outbound-session-id-header.md +++ b/docs/design/2026-09-03-outbound-session-id-header.md @@ -1,14 +1,10 @@ -# Outbound `session_id` Header +# Routify `session_id` Header ## Summary Qwen Code attaches its current session ID as the `session_id` HTTP header only when the outbound LLM request hostname is one of the three Routify endpoints documented by ModelRouter: `routify.alibaba-inc.com`, `routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. -That built-in allowlist is fixed and not configurable: Qwen Code does not attach the header to subdomains, other `alibaba-inc.com` hosts, or non-Routify providers, and no setting can add to, remove from, or disable it. - -Since the `outboundCorrelation.sessionIdHeader` follow-up, an **opt-in second branch** can additionally send the session ID to hosts the user lists themselves, under a header name they choose — see [User-configurable hosts](#user-configurable-hosts) below. It ships off with an empty allowlist and is deliberately independent of the built-in branch. - -Standard fetch redirect behavior applies after the initial destination check, so a listed host can forward the header by redirecting the request. +The behavior is intentionally not configurable. Qwen Code does not attach the header to subdomains, other `alibaba-inc.com` hosts, or non-Routify providers. Standard fetch redirect behavior applies after the initial destination check, so a Routify response can forward the header by redirecting the request. ## Motivation @@ -16,90 +12,10 @@ Routify's ModelRouter accepts `session_id` as a session-affinity and traffic-mar ## Security boundary -A session ID is a stable cross-request identifier. The implementation therefore requires HTTPS and compares the parsed request hostname to an exact-match allowlist — the fixed set of three ModelRouter hostnames for the built-in branch, plus whatever the user listed for the opt-in branch. Neither branch uses suffix matching, wildcards, or path matching. Invalid URLs fail closed. +A session ID is a stable cross-request identifier. The implementation therefore requires HTTPS and compares the parsed request hostname to a fixed set of three ModelRouter hostnames. It does not use suffix matching, wildcards, path matching, or a user-configurable allowlist. Invalid URLs fail closed. The Qwen Code session ID replaces any custom `session_id` value on an eligible request so the affinity marker cannot disagree with the active session. All other existing headers, including authorization, are preserved. -## User-configurable hosts - -`outboundCorrelation.sessionIdHeader` lets a user send the same session ID to a -gateway of their own, because some gateways require a stable per-conversation -identifier under a name of their choosing (OpenCode Go rejects requests without -`x-opencode-session`). `outboundCorrelation.customHeaders` cannot express this: -it is baked into the SDK client at construction, while `/new` and `/resume` -rotate the session ID without rebuilding that client. - -```json -{ - "outboundCorrelation": { - "sessionIdHeader": { - "enabled": true, - "headerName": "x-opencode-session", - "trustedHosts": ["opencode.ai"] - } - } -} -``` - -Rails, in the order they are applied: - -- **Off by default, with an empty allowlist.** `enabled: true` with no - `trustedHosts` sends nothing; there is no default allowlist to inherit. -- **HTTPS and exact host only**, matched case-insensitively against the parsed - hostname. The port is not part of the comparison, and internationalized names - must be listed in punycode. -- **Header name validated** against a conservative HTTP token subset - (`/^[A-Za-z0-9][A-Za-z0-9._-]*$/`), which is what keeps CRLF out of the name. - An invalid name drops this branch only, warning once per distinct name. -- **The flag is re-checked at the send site** as `enabled === true`, so a - settings object that arrives without the field is treated as off. - -The two branches are independent by construction. The built-in branch resolves -without consulting any setting, and the opt-in branch is resolved by a helper -that cannot throw — a `Config` collaborator lacking -`getOutboundSessionIdHeaderSettings()` yields "no configured header", never an -error that would suppress the first-party header. That independence is the -property to preserve when editing this seam: a setting that ships off must not -be able to alter a path that ships on. - -When one host matches both branches, both headers are sent with the same value, -unless the configured name differs from `session_id` only by case — header names -are case-insensitive on the wire, so that is emitted once. - -## Threat model - -**Recipients.** Without configuration, exactly the three ModelRouter hosts. -With configuration, those plus the hosts the user listed. There is no path by -which any other host receives the header from the initial request. - -**De-anonymization window.** The session ID is stable for the life of a session -and rotates on `/new` and `/resume`. Every recipient can therefore group all -requests of one conversation, and correlate them with anything else it knows -about the connection (API key, account, source IP). It is not a global user -identifier and does not persist across sessions, so the window is one -conversation per recipient — which is why the guidance is to list only hosts -that already receive the prompt content itself. - -**Redirects.** Both allowlists are checked against the initial destination. -`fetch` strips `Authorization` on a cross-origin redirect but not arbitrary -headers, so a listed host that redirects can forward the session ID to a host -the user never listed. Listing a host is therefore trust in that host's -redirect behavior too, not only in the host itself. - -**Misconfiguration.** A user can list a host they do not actually trust. The -defaults (off, empty list) mean this requires a deliberate edit, and the setting -is flagged security-relevant in `docs/users/configuration/settings.md`. A typo -in `headerName` is only visible as an `OUTBOUND_CORRELATION` warning in the -debug log (`QWEN_DEBUG_LOG_FILE=1`), so the -observable symptom is a gateway that keeps rejecting requests; that is a known -rough edge rather than a safety issue, since the failure direction is "not -sent". - -**Per-request identifiers.** A per-request UUID would remove the grouping -ability, but also the session affinity and prompt-cache routing that are the -entire point of the header. It is out of scope here and remains available as a -separate setting if a recipient ever needs correlation without grouping. - ## Request lifecycle OpenAI-compatible and Anthropic clients receive a fetch wrapper. The wrapper reads `Config.getSessionId()` immediately before each HTTP request. This matters because `/clear` starts a new session without rebuilding the SDK client. @@ -117,4 +33,4 @@ Non-LLM traffic, other domains, MCP requests, tool fetches, subprocesses, `trace ## Verification -Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. For the opt-in branch they additionally cover the default-off and empty-allowlist cases, an absent `enabled` field, plaintext and suffix-lookalike hosts, CRLF and other invalid header names, coexistence with the built-in branch, the case-only-difference collapse, and a `Config` without the settings getter still producing the built-in header. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID. +Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID. diff --git a/docs/plans/2026-09-07-session-id-header-shape-comparison.md b/docs/plans/2026-09-07-session-id-header-shape-comparison.md new file mode 100644 index 00000000000..373f7d50396 --- /dev/null +++ b/docs/plans/2026-09-07-session-id-header-shape-comparison.md @@ -0,0 +1,198 @@ +# Where should a per-conversation session header be configured? + +Two shapes for the same user need (#10995): a gateway that requires a stable +per-conversation identifier, e.g. OpenCode Go rejecting requests without +`x-opencode-session`. + +- **Shape A** — `outboundCorrelation.sessionIdHeader: { enabled, headerName, trustedHosts }`, a + new global setting with its own host allowlist. This is what PR #11282 implements. +- **Shape B** — a `${session_id}` placeholder in the existing per-provider + `modelProviders[].generationConfig.customHeaders`, plus one global consent + switch. This branch implements it, for comparison. + +Nothing here argues against the feature. Both shapes ship it. The question is +only where the configuration lives. + +## The deciding argument: Shape A cannot express two gateways + +`sessionIdHeader.headerName` is a single global string, and `trustedHosts` is a +list of hosts that all receive **that one** header name. So: + +| Scenario | Shape A | Shape B | +| ----------------------------------------------------------------- | ----------------------------------------- | ----------------------------------- | +| Provider X needs `x-opencode-session` | ✅ | ✅ | +| …and provider Y needs `x-session-id` at the same time | ❌ **inexpressible** | ✅ | +| Provider Z must not send it | via `trustedHosts` omission | automatic — no header on that entry | +| Two model entries on the _same host_, only one behind the gateway | ❌ host allowlist cannot distinguish them | ✅ per-entry | + +The second row is the one that matters. It is not a style preference: a user +with two gateways has to pick one header name and let the other provider receive +the wrong one. + +## `trustedHosts` re-encodes information the config already has + +You write the endpoint once in `modelProviders[].baseUrl`. Shape A asks you to +write the same host again in `trustedHosts`, and then compares the two copies you +wrote. That duplication exists only because the setting is global: a global +setting has no scope of its own, so it has to build one, and the only material +available is the `baseUrl` you already supplied. + +The one advantage of a global setting — "configure it once instead of per +provider" — is exactly the case where the session ID reaches hosts you did not +think about, which is what the allowlist then exists to prevent. It creates the +problem it spends most of its code solving. In this branch, roughly 90 lines of +host normalization, punycode conversion, wildcard/scheme/port rejection and +allowlist validation are simply absent, because the scoping is inherited. + +## What Shape B keeps from the reviewer's objection + +LaZzyMan's round-8 objection to PR #4390 — recorded in +`docs/design/telemetry-outbound-propagation-design.md` §12.1 — was that +`telemetry.*` implies "recipient is your own OTLP collector", while these headers +go to a third-party LLM provider, and the two deserve different consent decisions. +That argument is correct and Shape B keeps it: `outboundCorrelation.allowDynamicHeaderValues` +is a consent switch under the same namespace, default off. + +What Shape B does _not_ put in the global namespace is scoping and naming, which +the provider entry already answers. + +The switch earns its place for a second reason: `customHeaders` can arrive from a +provider preset (`packages/core/src/providers/presets/*.ts` ship them) or an +extension, and provenance is lost once presets and user settings are merged. The +gate is what separates "I typed this" from "something shipped this", so a preset +cannot silently promote a static header into an identity header. + +## Where the `trustedHosts` allowlist actually comes from + +It is not arbitrary, and it has a named origin. In his round-8 follow-up review +of PR #4390 (2026-05-25T03:24), LaZzyMan listed what the outbound-correlation +follow-up PR should cover, including: + +> - **Override semantics**: same `trustedHosts` allowlist mechanism you've built +> here **can carry over unchanged** + +Two things about the context matter: + +1. **It was said about the built-in, automatic header.** In R3 the mechanism + attached the session ID to every provider; the allowlist is what removed that + "dangerous default", narrowing it to three first-party hosts. For a header the + client attaches _by itself_, an allowlist is the only possible source of + scope, and it is plainly right. +2. **"Can carry over", not "must".** For a header the _user_ writes into a + provider entry, the scope already exists — they chose the `baseUrl`. The + allowlist was permission to reuse working code, not a derivation that the + user-configured case needs one. + +So Shape B does not contradict LaZzyMan's position. What he asked for was scope +discipline and a consent decision outside `telemetry.*`: "its own settings tree, +its own threat model, and its own user consent flow", default off. Shape B keeps +all of that in `outboundCorrelation.allowDynamicHeaderValues`. What it declines +to carry over is the allowlist, for the case where it duplicates scope rather +than creating it. + +## Provenance: one argument was never engaged with + +§12.2 of `docs/design/telemetry-outbound-propagation-design.md` says: + +> 经过几轮内部讨论(含 yiliang 提出的 customHeader 模板替代方案,最终判定 customHeader 不能携带 runtime-dynamic 值),决定走 **方案 C** + +Against the record of PR #4390 (all of it public — the discussion happened in +that PR's review thread, not off-GitHub): + +| Time (UTC) | Who | What | +| --------------- | ---------- | ---------------------------------------------------------------------------------------------------- | +| 05-22 08:52 | LaZzyMan | CHANGES_REQUESTED, escalating from the earlier read | +| 05-22 **10:59** | yiliang114 | [The `customHeaders` comment](https://github.com/QwenLM/qwen-code/pull/4390#issuecomment-4518059628) | +| 05-22 15:11 | doudouOUC | Replies **to @LaZzyMan** | +| 05-25 03:24 | LaZzyMan | "Review (round 8 follow-up)" — the text §12.1 quotes | +| 05-25 05:44 | doudouOUC | "accepting the split" (方案 C) | +| 05-25 13:26 | LaZzyMan | APPROVED | +| 05-25 | jinye | Commit `62ed44e1f` adds the design doc | + +Note that `doudouOUC`'s GitHub display name is _jinye_: the PR author and the +design doc's author are the same person. + +Findings: + +- **The proposal is real**, at the timestamp above. +- **It was not a "template" proposal.** The word does not appear in it. The + argument was that `customHeaders` is _per-provider_ — "without leaking + identifiers indiscriminately to all third-party providers" — at "zero + additional complexity". The scoping point is the one that turns out to decide + the design, and it is the one the recorded rejection does not address. +- **The recorded rejection describes the code, not a constraint.** + `buildHeaders()` does run once inside `buildClient()`, but the per-request hook + already exists in `wrapFetchWithSessionId`, which sets headers _after_ the + SDK's — which is what this branch uses. +- **No reply to it exists anywhere in the public record.** Searching all three + surfaces of #4390 — issue comments, inline review comments, and formal review + bodies — the string `customHeader` appears exactly once, in that comment. + LaZzyMan never mentioned `customHeaders` in any of his three reviews, and his + round-8 closing line addresses `@wenshao @doudouOUC`, so the per-provider + argument was never in front of the reviewer whose objection shaped the outcome. + +The likeliest reconstruction is ordinary: the comment landed in the middle of a +LaZzyMan↔doudouOUC argument, nobody picked it up, and three days later it was +summarized into the design doc as discussed-and-rejected. Nothing here suggests +bad faith. It matters only because #11282 → §12.7 → §12.2 is the chain that makes +Shape A look pre-decided, and the root of that chain is a conclusion no one +publicly answered. + +## What this branch changes + +| File | Change | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `core/src/core/outbound-dynamic-headers.ts` | New. Placeholder set, per-value expansion, fail-closed drops, `Headers` rewrite, per-request subset for Gemini. | +| `core/src/core/outbound-session-id.ts` | Fetch wrapper expands placeholders before writing the built-in header, preserving `correlation > customHeaders` precedence. Whether any placeholder exists is decided once at client construction, so providers without one cost exactly what they cost today. | +| `openaiContentGenerator/provider/{default,dashscope}.ts`, `anthropicContentGenerator.ts` | Pass the provider's `customHeaders` to the wrapper. | +| `llm-content-generator.ts` | Re-emit the placeholder-bearing subset per request, overriding the client-level copy the SDK froze at construction. | +| `config/config.ts`, `cli/src/config/settingsSchema.ts` | `outboundCorrelation.allowDynamicHeaderValues`, default false. | +| docs | `model-providers.md` (where the header goes), `settings.md` (the consent switch). | + +Not implemented here, deliberately: removing Shape A. If Shape B is chosen, +#11282's opt-in branch comes out and its built-in first-party allowlist stays +untouched. + +## Verification status + +Run locally in a sandbox worktree (workspace `node_modules` and `dist` overlaid +by symlink; four missing `exports` entries stubbed to satisfy the vitest global +setup — none of them imported by these suites): + +``` +✓ src/core/outbound-dynamic-headers.test.ts (20 tests) +✓ src/core/outbound-session-id.test.ts (14 tests) +✓ src/core/openaiContentGenerator/provider/default.test.ts (38 tests) +✓ src/core/openaiContentGenerator/provider/dashscope.test.ts (162 tests) +✓ src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts (165 tests) +✓ src/core/llm-content-generator/llm-content-generator.test.ts (22 tests) + +Test Files 6 passed (6) + Tests 421 passed (421) +``` + +The last four are the suites whose built-in `session_id` assertions #11282's +first commit broke; they pass here unmodified, which is the regression this +shape has to clear too. ESLint passes on every changed file. + +Still **not** verified here, and CI's to confirm: + +- `npm run typecheck` and a full build were not run. +- `npm run generate:settings-schema` could not run (it loads the built core + through `dist/`, and this box cannot build it). The `settings.schema.json` + entry was added by hand, with the description string extracted + programmatically from `settingsSchema.ts` so the two cannot disagree. + **CI's schema-drift check is the authority** — if it reports a diff, re-run + the generator and commit the result. +- No test covers the four provider call sites actually forwarding + `customHeaders` into `buildSessionAwareFetch`; that wiring is verified only by + reading. + +To reproduce on a build-capable machine: + +```bash +npm run build +npm run test --workspace=@qwen-code/qwen-code-core -- src/core/outbound-dynamic-headers.test.ts +npm run generate:settings-schema && git diff --exit-code packages/vscode-ide-companion/schemas/settings.schema.json +npm run typecheck && npm run lint +``` diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 7cdd19c7303..25eaab49c02 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -568,12 +568,45 @@ The configuration resolution follows a strict layering model with one crucial ru | 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) | | 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models | +### Dynamic values in `customHeaders` + +A `customHeaders` value may contain the placeholder `${session_id}`, which is +expanded per request with the current Qwen Code session ID. Use it for gateways +that require a stable per-conversation identifier — OpenCode Go, for example, +rejects requests without `x-opencode-session`: + +```json +{ + "generationConfig": { + "customHeaders": { + "x-opencode-session": "${session_id}" + } + } +} +``` + +Because the value is resolved per request rather than baked into the SDK client, +`/new` and `/resume` rotate it without a restart. + +⚠️ **This is off by default and must be enabled explicitly** with +[`outboundCorrelation.allowDynamicHeaderValues`](settings.md#outboundcorrelation). +Until you do, a value containing a placeholder is dropped rather than sent — the +header is never sent with a literal `${session_id}` in it. The switch is global +because it is a consent decision: an expanded value carries live session state to +whoever receives it, and the switch also stops a provider preset or an extension +from quietly turning a `customHeaders` entry it ships into an identity header. + +**Privacy note:** the session ID is a stable identifier for the life of a +conversation, so any host you send it to can group every request of that +conversation. Which hosts those are is decided by which provider entries carry +the header — there is no separate host list to keep in sync with your `baseUrl`. + ### Atomic field treatment The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs: - `samplingParams` - Temperature, top_p, max_tokens, etc. -- `customHeaders` - Custom HTTP headers +- `customHeaders` - Custom HTTP headers (may contain `${session_id}`; see [Dynamic values](#dynamic-values-in-customheaders)) - `extra_body` - Extra request body parameters ### Example diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index d1d29e072c2..328d3ae5c77 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -663,36 +663,31 @@ Configures logging and metrics collection for Qwen Code. For more information, s #### outboundCorrelation -⚠️ **Security-relevant.** Controls what client-side correlation data qwen-code writes into outbound LLM API requests — a separate consent decision from `telemetry.*` (which governs data flowing into your OWN observability backend). All values default to off. +⚠️ **Security-relevant.** Controls what client-side correlation data Qwen Code writes into outbound LLM API requests — a separate consent decision from `telemetry.*`, which governs data flowing into your OWN observability backend. All values default to off. -| Setting | Type | Description | Default | -| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | -| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` | -| `outboundCorrelation.sessionIdHeader.enabled` | boolean | Master switch for sending the qwen-code session ID as a per-request header to the hosts you list. When off (default), nothing is sent beyond the built-in first-party allowlist. | `false` | -| `outboundCorrelation.sessionIdHeader.headerName` | string | Header name to set. Must be a valid HTTP header name (letters, digits, `.`, `_`, `-`); an invalid name is ignored with a warning. | `session_id` | -| `outboundCorrelation.sessionIdHeader.trustedHosts` | string[] | Exact hostnames (no wildcards) allowed to receive the header. HTTPS requests only; matching is case-insensitive and ignores the port. An empty list sends nothing even when enabled. | `[]` | - -The session ID is resolved per request, so `/new` and `/resume` rotate the value without restarting. Some gateways require such a per-conversation identifier — for example OpenCode Go rejects requests without `x-opencode-session` since 2026-09-06: +| Setting | Type | Description | Default | +| ---------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` | +| `outboundCorrelation.allowDynamicHeaderValues` | boolean | Allow `customHeaders` values to contain runtime placeholders such as `${session_id}`, expanded per request. When off, such a value is dropped rather than sent. | `false` | ```json { "outboundCorrelation": { - "sessionIdHeader": { - "enabled": true, - "headerName": "x-opencode-session", - "trustedHosts": ["opencode.ai"] - } + "allowDynamicHeaderValues": true } } ``` -**Privacy note:** the header is a stable identifier that lets every listed host group all requests of one conversation. Only list hosts you already send your prompt content to. - -Three things worth knowing before you list a host: +`allowDynamicHeaderValues` is only the consent switch. _Which_ hosts receive the +value and _what_ the header is called are decided where the header lives — +`modelProviders[].generationConfig.customHeaders`, see +[Dynamic values in `customHeaders`](model-providers.md#dynamic-values-in-customheaders). +That scoping is why there is no host allowlist here: you already chose the +endpoint when you wrote the provider's `baseUrl`, and providers that should not +send the header simply do not carry it. -- **Only the initial destination is checked.** `fetch` keeps non-authorization headers across a redirect, so a host you list can forward the session ID to one you did not by redirecting. Listing a host means trusting its redirect behavior too. -- **Internationalized domains must be written in punycode** (`xn--…`), because the request hostname is compared as the URL parser produced it. -- **A typo in `headerName` is only reported in the debug log.** If the gateway keeps rejecting your requests, run with `QWEN_DEBUG_LOG_FILE=1` and look for an `OUTBOUND_CORRELATION` warning in the session debug log — an invalid name is dropped rather than sent. +**Privacy note:** an expanded value is a stable per-conversation identifier. +Only put one on a provider you already send your prompt content to. ### Example `settings.json` diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index bcbedfe1d0d..246268745dc 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1458,31 +1458,11 @@ const SETTINGS_SCHEMA = { type: 'boolean', default: false, }, - sessionIdHeader: { + allowDynamicHeaderValues: { description: - 'Send the qwen-code session ID as a per-request header to the listed HTTPS hosts. Default off with an empty host list — nothing is sent unless you opt in. Use for gateways that require a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session` since 2026-09-06). The value is resolved per request, so `/new` and `/resume` rotate it. Privacy: listed hosts can group every request of one conversation under a stable ID — only list hosts you already send your prompts to.', - type: 'object', - properties: { - enabled: { - description: - 'Master switch. Default false: no user-configured session header is sent anywhere.', - type: 'boolean', - default: false, - }, - headerName: { - description: - 'Header name to set, e.g. `x-opencode-session`. Must be a valid HTTP header name (letters, digits, dot, underscore, hyphen); an invalid name is ignored with a warning. Default: `session_id`.', - type: 'string', - }, - trustedHosts: { - description: - 'Exact hostnames (no wildcards) allowed to receive the header, e.g. `["opencode.ai"]`. HTTPS requests only; matching is case-insensitive. Default empty — `enabled: true` with no hosts sends nothing.', - type: 'array', - items: { type: 'string' }, - default: [], - }, - }, - additionalProperties: false, + 'SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether live session state may be interpolated at all, and prevents a preset or extension from silently turning a shipped `customHeaders` entry into an identity header.', + type: 'boolean', + default: false, }, }, additionalProperties: false, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 6435cab42be..961337246e7 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -9385,49 +9385,6 @@ describe('Server Config (config.ts)', () => { ); }, ); - - // Default-to-undefined is security-relevant — controls whether the - // session ID is written as a header onto user-chosen hosts. - it('getOutboundSessionIdHeaderSettings is undefined unless enabled', () => { - expect( - new Config({ - ...baseParams, - outboundCorrelation: undefined, - }).getOutboundSessionIdHeaderSettings(), - ).toBeUndefined(); - expect( - new Config({ - ...baseParams, - outboundCorrelation: { - sessionIdHeader: { enabled: false, trustedHosts: ['opencode.ai'] }, - }, - }).getOutboundSessionIdHeaderSettings(), - ).toBeUndefined(); - expect( - new Config({ - ...baseParams, - outboundCorrelation: { sessionIdHeader: { enabled: true } }, - }).getOutboundSessionIdHeaderSettings(), - ).toEqual({ enabled: true }); - }); - - it('getOutboundSessionIdHeaderSettings passes the configured shape through', () => { - const config = new Config({ - ...baseParams, - outboundCorrelation: { - sessionIdHeader: { - enabled: true, - headerName: 'x-opencode-session', - trustedHosts: ['opencode.ai'], - }, - }, - }); - expect(config.getOutboundSessionIdHeaderSettings()).toEqual({ - enabled: true, - headerName: 'x-opencode-session', - trustedHosts: ['opencode.ai'], - }); - }); }); describe('UseRipgrep Configuration', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c4d798940bd..427ade11e5c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -646,54 +646,21 @@ export interface OutboundCorrelationSettings { */ propagateTraceContext?: boolean; /** - * Send the qwen-code session ID as a per-request header to - * user-chosen HTTPS hosts. Default: disabled with an empty host - * list — nothing is sent anywhere unless the operator opts in. + * Allow `modelProviders[].generationConfig.customHeaders` values to + * carry runtime placeholders such as `${session_id}`, expanded per + * request. Default: disabled — a value with a placeholder is dropped + * rather than sent. * - * Some OpenAI-compatible gateways require a stable per-conversation - * identifier for prompt-cache routing (e.g. OpenCode Go rejects - * requests without `x-opencode-session` since 2026-09-06). The value - * is resolved per request from `getSessionId()`, so `/new` and - * `/resume` rotate it without rebuilding the client. - * - * Privacy: the header is a stable identifier that lets the listed - * hosts group every request of one conversation. Only list hosts you - * already send your prompts to; see - * docs/design/telemetry-outbound-propagation-design.md §12.7. - */ - sessionIdHeader?: OutboundSessionIdHeaderSettings; -} - -/** - * User-configurable session-ID header for outbound LLM requests. - * Lives under `outboundCorrelation.*` — see the parent interface for - * why this namespace is separate from `telemetry.*`. - */ -export interface OutboundSessionIdHeaderSettings { - /** - * Master switch. Default `false`: no user-configured header is sent, - * and `trustedHosts`/`headerName` are ignored. - */ - enabled?: boolean; - /** - * Header name to set, e.g. `x-opencode-session`. Must match the - * conservative HTTP token subset accepted by - * `core/outbound-session-id.ts` (letters, digits, `.`, `_`, `-`, - * starting with a letter or digit); an invalid name is ignored with - * a debug warning rather than sent. Default: `session_id`. - */ - headerName?: string; - /** - * Exact hostnames (no wildcards) that may receive the header, e.g. - * `["opencode.ai"]`. HTTPS only. Matching is case-insensitive and - * ignores the port; internationalized names must be listed in - * punycode, because the request hostname is compared as parsed. - * Only the initial destination is checked — a listed host that - * redirects elsewhere forwards the header with it. - * Default: empty — with `enabled: true` but no hosts, nothing is - * sent (fail-closed rather than "all hosts"). - */ - trustedHosts?: string[]; + * This is the consent decision, and it is the only part of the feature + * that is global: *which* hosts may receive the value, and *what* the + * header is called, are already answered by the provider entry the + * header is attached to. The gate also means a preset or extension + * that ships a `customHeaders` entry cannot quietly turn it into an + * identity header — provenance is lost once presets and user settings + * are merged, so the switch is what separates "I typed this" from + * "something shipped this". + */ + allowDynamicHeaderValues?: boolean; } export interface OutputSettings { @@ -2666,7 +2633,8 @@ export class Config { this.outboundCorrelationSettings = { propagateTraceContext: params.outboundCorrelation?.propagateTraceContext ?? false, - sessionIdHeader: params.outboundCorrelation?.sessionIdHeader, + allowDynamicHeaderValues: + params.outboundCorrelation?.allowDynamicHeaderValues ?? false, }; this.gitCoAuthor = { ...normalizeGitCoAuthor(params.gitCoAuthor), @@ -7561,17 +7529,12 @@ export class Config { } /** - * The user-configured session-ID header settings, or `undefined` - * when the feature is off (the default). Consumers treat `undefined` - * as "send nothing beyond the built-in first-party allowlist" — see - * `core/outbound-session-id.ts`. + * Whether `customHeaders` values may carry runtime placeholders. See + * {@link OutboundCorrelationSettings.allowDynamicHeaderValues}; consumed by + * `core/outbound-dynamic-headers.ts`. */ - getOutboundSessionIdHeaderSettings(): - | OutboundSessionIdHeaderSettings - | undefined { - const settings = this.outboundCorrelationSettings.sessionIdHeader; - if (!settings?.enabled) return undefined; - return settings; + getOutboundAllowDynamicHeaderValues(): boolean { + return this.outboundCorrelationSettings.allowDynamicHeaderValues ?? false; } getTelemetryOutfile(): string | undefined { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 07c13f957d1..39d4e4699a2 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -364,6 +364,7 @@ export class AnthropicContentGenerator implements ContentGenerator { fetch: buildSessionAwareFetch( runtimeOptions.fetch, this.cliConfig, + this.contentGeneratorConfig.customHeaders, ) as unknown as AnthropicFetch, }); diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 65390d13f4e..5900f77ef10 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -29,6 +29,10 @@ import { } from '../../telemetry/gen-ai-request.js'; import type { Config } from '../../config/config.js'; import { buildSessionIdHeaders } from '../outbound-session-id.js'; +import { + expandDynamicHeaders, + hasDynamicPlaceholder, +} from '../outbound-dynamic-headers.js'; const debugLogger = createDebugLogger('GEMINI'); @@ -79,7 +83,18 @@ export class LlmContentGenerator implements ContentGenerator { contentGeneratorConfig?: ContentGeneratorConfig, cliConfig?: Config, ) { - const customHeaders = contentGeneratorConfig?.customHeaders; + // Only placeholder-free entries may be baked into the client: a + // placeholder value put here would be frozen at whatever the session + // was when the client was built, and overriding it later would rely + // on the SDK letting request-level headers win over client-level + // ones. `buildHttpOptions` supplies the placeholder-bearing entries + // per request instead, so the literal never reaches the client. + const allCustomHeaders = contentGeneratorConfig?.customHeaders; + const staticEntries = Object.entries(allCustomHeaders ?? {}).filter( + ([, value]) => typeof value !== 'string' || !hasDynamicPlaceholder(value), + ); + const customHeaders = + staticEntries.length > 0 ? Object.fromEntries(staticEntries) : undefined; const finalOptions = customHeaders ? (() => { const baseHttpOptions = options.httpOptions; @@ -108,13 +123,29 @@ export class LlmContentGenerator implements ContentGenerator { const destination = httpOptions?.baseUrl ?? this.clientBaseUrl; if (!this.cliConfig || !destination) return httpOptions; + // The placeholder-bearing entries were deliberately kept out of the + // client options (see the constructor), so this is the only place + // they are supplied — resolved fresh for each request. + const dynamicHeaders = expandDynamicHeaders( + this.contentGeneratorConfig?.customHeaders, + this.cliConfig, + ); const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination); - if (Object.keys(sessionHeaders).length === 0) return httpOptions; + if ( + Object.keys(sessionHeaders).length === 0 && + Object.keys(dynamicHeaders).length === 0 + ) { + return httpOptions; + } return { ...httpOptions, headers: { ...httpOptions?.headers, + // Existing merge order for this path is + // customHeaders > correlation; keep it. ...sessionHeaders, + ...dynamicHeaders, + // (dynamicHeaders last: they are customHeaders entries.) }, }; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index d6886165495..318875e719a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -322,7 +322,11 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr maxRetries, defaultHeaders, ...(runtimeOptions || {}), - fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig), + fetch: buildSessionAwareFetch( + runtimeOptions?.fetch, + this.cliConfig, + this.contentGeneratorConfig.customHeaders, + ), }); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 33b95fa6edc..0a1c5b86ff4 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -128,7 +128,11 @@ export class DefaultOpenAICompatibleProvider maxRetries, defaultHeaders, ...(runtimeOptions || {}), - fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig), + fetch: buildSessionAwareFetch( + runtimeOptions?.fetch, + this.cliConfig, + this.contentGeneratorConfig.customHeaders, + ), }); } diff --git a/packages/core/src/core/outbound-dynamic-headers.test.ts b/packages/core/src/core/outbound-dynamic-headers.test.ts new file mode 100644 index 00000000000..926a725d2ed --- /dev/null +++ b/packages/core/src/core/outbound-dynamic-headers.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { + applyDynamicHeaderValues, + expandDynamicHeaders, + hasDynamicPlaceholder, + resolveDynamicHeaderValue, +} from './outbound-dynamic-headers.js'; + +function config({ + sessionId = 'session-1', + allow = true, +}: { sessionId?: string; allow?: boolean } = {}): Config { + return { + getSessionId: vi.fn().mockReturnValue(sessionId), + getOutboundAllowDynamicHeaderValues: vi.fn().mockReturnValue(allow), + } as unknown as Config; +} + +describe('hasDynamicPlaceholder', () => { + it.each([ + '${session_id}', + 'sess-${session_id}', + '${session_id}-${session_id}', + ])('detects a placeholder in %s', (value) => { + expect(hasDynamicPlaceholder(value)).toBe(true); + }); + + it.each(['req-123', '', '$session_id', '${SESSION_ID}', '{session_id}'])( + 'leaves %j alone', + (value) => { + expect(hasDynamicPlaceholder(value)).toBe(false); + }, + ); +}); + +describe('resolveDynamicHeaderValue', () => { + it('returns a placeholder-free value untouched', () => { + // The no-op path for every customHeaders entry configured today. + const cliConfig = config({ allow: false }); + expect(resolveDynamicHeaderValue('req-123', cliConfig)).toBe('req-123'); + expect( + cliConfig.getOutboundAllowDynamicHeaderValues, + ).not.toHaveBeenCalled(); + }); + + it('expands the session ID when the gate is open', () => { + expect(resolveDynamicHeaderValue('${session_id}', config())).toBe( + 'session-1', + ); + }); + + it('expands a placeholder embedded in a larger value, repeatedly', () => { + expect( + resolveDynamicHeaderValue('a-${session_id}-b-${session_id}', config()), + ).toBe('a-session-1-b-session-1'); + }); + + // The gate is the consent decision; default-off must drop the header + // rather than put a literal `${session_id}` on the wire. + it('drops the value when the gate is closed', () => { + expect( + resolveDynamicHeaderValue('${session_id}', config({ allow: false })), + ).toBeUndefined(); + }); + + it('drops the value when the session ID is empty', () => { + expect( + resolveDynamicHeaderValue('${session_id}', config({ sessionId: '' })), + ).toBeUndefined(); + }); + + it('drops the value when Config cannot answer', () => { + const broken = { + getOutboundAllowDynamicHeaderValues: vi.fn(() => { + throw new TypeError('not a function'); + }), + } as unknown as Config; + expect(resolveDynamicHeaderValue('${session_id}', broken)).toBeUndefined(); + }); +}); + +describe('applyDynamicHeaderValues', () => { + it('rewrites only the placeholder-bearing headers', () => { + const headers = new Headers({ + 'x-opencode-session': '${session_id}', + 'x-static': 'req-123', + }); + applyDynamicHeaderValues(headers, config()); + expect(headers.get('x-opencode-session')).toBe('session-1'); + expect(headers.get('x-static')).toBe('req-123'); + }); + + it('deletes the header instead of sending a literal placeholder', () => { + const headers = new Headers({ + 'x-opencode-session': '${session_id}', + 'x-static': 'req-123', + }); + applyDynamicHeaderValues(headers, config({ allow: false })); + expect(headers.has('x-opencode-session')).toBe(false); + expect(headers.get('x-static')).toBe('req-123'); + }); + + it('is a no-op when nothing carries a placeholder', () => { + const headers = new Headers({ 'x-static': 'req-123' }); + applyDynamicHeaderValues(headers, config({ allow: false })); + expect([...headers.entries()]).toEqual([['x-static', 'req-123']]); + }); +}); + +describe('expandDynamicHeaders', () => { + it('returns only the entries that needed expanding', () => { + expect( + expandDynamicHeaders( + { 'x-opencode-session': '${session_id}', 'x-static': 'req-123' }, + config(), + ), + ).toEqual({ 'x-opencode-session': 'session-1' }); + }); + + it('omits an entry the gate refuses rather than emitting the literal', () => { + expect( + expandDynamicHeaders( + { 'x-opencode-session': '${session_id}' }, + config({ allow: false }), + ), + ).toEqual({}); + }); + + it('returns an empty object for no customHeaders', () => { + expect(expandDynamicHeaders(undefined, config())).toEqual({}); + }); +}); diff --git a/packages/core/src/core/outbound-dynamic-headers.ts b/packages/core/src/core/outbound-dynamic-headers.ts new file mode 100644 index 00000000000..4763d14993a --- /dev/null +++ b/packages/core/src/core/outbound-dynamic-headers.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('OUTBOUND_CORRELATION'); + +/** + * Placeholders a user may write into a `customHeaders` value, e.g. + * + * ```json + * "customHeaders": { "x-opencode-session": "${session_id}" } + * ``` + * + * `customHeaders` is already scoped to one model-provider entry whose + * `baseUrl` the user chose, so a placeholder needs no host allowlist of + * its own: "which hosts may receive this" is answered by the provider + * the header is attached to, and "which providers need it" by which + * entries carry the header at all. + * + * Deliberately a closed set. Each entry is a per-session identifier with + * a reviewed privacy story; this is not a general environment- or + * process-state interpolation facility, and it should not grow into one + * without the same review. + */ +const PLACEHOLDERS: ReadonlyArray<{ + readonly token: string; + readonly resolve: (config: Config) => string | undefined; +}> = [{ token: '${session_id}', resolve: (config) => config.getSessionId() }]; + +/** True when `value` asks for at least one runtime-resolved placeholder. */ +export function hasDynamicPlaceholder(value: string): boolean { + return PLACEHOLDERS.some(({ token }) => value.includes(token)); +} + +/** + * Expands placeholders in one user-configured header value, or returns + * `undefined` when the header must not be sent at all. + * + * Fail-closed in three ways, all of which drop the header rather than + * putting a wrong value on the wire: + * + * - The `outboundCorrelation.allowDynamicHeaderValues` gate is off (the + * default). This is the consent decision: a static header is a string + * the user typed, while an expanded one carries live process state to + * a third party. It also means a preset or extension that ships a + * `customHeaders` entry cannot turn it into an identity header behind + * the user's back — provenance is lost once presets and user settings + * are merged, so the gate is what distinguishes them. + * - A placeholder resolves to nothing (no session yet). + * - `Config` cannot answer at all. + * + * A value with no placeholder is returned untouched, so this is a no-op + * for every existing `customHeaders` entry. + */ +export function resolveDynamicHeaderValue( + value: string, + config: Config, +): string | undefined { + if (!hasDynamicPlaceholder(value)) return value; + try { + if (!config.getOutboundAllowDynamicHeaderValues()) { + debugLogger.warn( + `Dropping a customHeaders value containing a runtime placeholder: ` + + `outboundCorrelation.allowDynamicHeaderValues is not enabled.`, + ); + return undefined; + } + let expanded = value; + for (const { token, resolve } of PLACEHOLDERS) { + if (!expanded.includes(token)) continue; + const resolved = resolve(config); + if (!resolved) return undefined; + expanded = expanded.split(token).join(resolved); + } + return expanded; + } catch (error) { + debugLogger.warn( + `Unable to expand a customHeaders placeholder: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } +} + +/** + * Rewrites placeholder-bearing entries of an outgoing `Headers` object in + * place. Used by the shared fetch wrapper, which is the one point every + * OpenAI-compatible and Anthropic request passes through per request — + * the SDK clients bake `customHeaders` in at construction, so this is + * where a value that must change per request gets its chance. + */ +export function applyDynamicHeaderValues( + headers: Headers, + config: Config, +): void { + const pending: Array<[string, string]> = []; + headers.forEach((value, key) => { + if (hasDynamicPlaceholder(value)) pending.push([key, value]); + }); + for (const [key, value] of pending) { + const resolved = resolveDynamicHeaderValue(value, config); + if (resolved === undefined) { + headers.delete(key); + } else { + headers.set(key, resolved); + } + } +} + +/** + * The subset of `customHeaders` that carries placeholders, expanded for + * this request. Used by the Gemini path, whose `customHeaders` live in + * the SDK client options rather than passing through a fetch wrapper; + * re-emitting just this subset at request level overrides the stale + * client-level copy. + */ +export function expandDynamicHeaders( + customHeaders: Record | undefined, + config: Config, +): Record { + if (!customHeaders) return {}; + const expanded: Record = {}; + for (const [key, value] of Object.entries(customHeaders)) { + if (!hasDynamicPlaceholder(value)) continue; + const resolved = resolveDynamicHeaderValue(value, config); + if (resolved !== undefined) expanded[key] = resolved; + } + return expanded; +} diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts index 5f488722701..6082546e6af 100644 --- a/packages/core/src/core/outbound-session-id.test.ts +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -5,10 +5,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { - Config, - OutboundSessionIdHeaderSettings, -} from '../config/config.js'; +import type { Config } from '../config/config.js'; import { buildSessionAwareFetch, buildSessionIdHeaders, @@ -16,15 +13,9 @@ import { wrapFetchWithSessionId, } from './outbound-session-id.js'; -function config( - sessionId = 'session-1', - sessionIdHeader?: OutboundSessionIdHeaderSettings, -): Config { +function config(sessionId = 'session-1'): Config { return { getSessionId: vi.fn().mockReturnValue(sessionId), - getOutboundSessionIdHeaderSettings: vi - .fn() - .mockReturnValue(sessionIdHeader), } as unknown as Config; } @@ -177,166 +168,3 @@ describe('outbound session ID', () => { expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); }); }); - -describe('outbound session ID — user-configured header', () => { - const OPENCODE = { - enabled: true, - headerName: 'x-opencode-session', - trustedHosts: ['opencode.ai'], - }; - - it('sends the configured header to a trusted host', () => { - expect( - buildSessionIdHeaders( - config('session-1', OPENCODE), - 'https://opencode.ai/zen/go/v1/chat/completions', - ), - ).toEqual({ 'x-opencode-session': 'session-1' }); - }); - - it('sends nothing when the feature is off, whatever the hosts say', () => { - expect( - buildSessionIdHeaders( - config('session-1', { ...OPENCODE, enabled: false }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({}); - expect( - buildSessionIdHeaders(config(), 'https://opencode.ai/zen/go/v1'), - ).toEqual({}); - }); - - it.each([ - 'https://api.openai.com/v1', - 'https://evil-opencode.ai/zen/go/v1', - 'https://opencode.ai.evil.example/v1', - 'http://opencode.ai/zen/go/v1', - ])('does not send the configured header to %s', (url) => { - expect(buildSessionIdHeaders(config('session-1', OPENCODE), url)).toEqual( - {}, - ); - }); - - it('sends nothing when enabled with an empty host list', () => { - expect( - buildSessionIdHeaders( - config('session-1', { ...OPENCODE, trustedHosts: [] }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({}); - expect( - buildSessionIdHeaders( - config('session-1', { enabled: true, trustedHosts: undefined }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({}); - }); - - it('defaults the header name to session_id and matches hosts case-insensitively', () => { - expect( - buildSessionIdHeaders( - config('session-1', { enabled: true, trustedHosts: ['OpenCode.AI '] }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); - }); - - it('skips an invalid header name but keeps the built-in branch', () => { - for (const headerName of ['x opencode', 'x:y', 'x\r\nInjected: 1', '']) { - expect( - buildSessionIdHeaders( - config('session-1', { ...OPENCODE, headerName }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({}); - } - expect( - buildSessionIdHeaders( - config('session-1', { ...OPENCODE, headerName: 'x:y' }), - 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', - ), - ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); - }); - - it('emits both headers when a built-in host is also user-trusted under another name', () => { - expect( - buildSessionIdHeaders( - config('session-1', { - enabled: true, - headerName: 'x-session', - trustedHosts: ['routify-pub.alibaba-inc.com'], - }), - 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', - ), - ).toEqual({ [SESSION_ID_HEADER]: 'session-1', 'x-session': 'session-1' }); - }); - - it('re-resolves the session ID per request across rotations', async () => { - const cliConfig = config('session-1', OPENCODE); - const baseFetch = vi.fn( - async (_input: string | URL | Request, _init?: RequestInit) => - new Response(), - ); - const wrappedFetch = wrapFetchWithSessionId(baseFetch, cliConfig); - - await wrappedFetch('https://opencode.ai/zen/go/v1/chat/completions'); - vi.mocked(cliConfig.getSessionId).mockReturnValue('session-2'); - await wrappedFetch('https://opencode.ai/zen/go/v1/chat/completions'); - - const firstHeaders = new Headers(baseFetch.mock.calls[0][1]?.headers); - const secondHeaders = new Headers(baseFetch.mock.calls[1][1]?.headers); - expect(firstHeaders.get('x-opencode-session')).toBe('session-1'); - expect(secondHeaders.get('x-opencode-session')).toBe('session-2'); - }); - - it('does not send an empty session ID through the configured header', () => { - expect( - buildSessionIdHeaders(config('', OPENCODE), 'https://opencode.ai/v1'), - ).toEqual({}); - }); - - // The opt-in branch ships off; the built-in first-party branch ships - // on. A Config that predates the new getter must not be able to turn - // the shipping-on branch off by throwing — the four provider suites - // build exactly such partial `as unknown as Config` doubles. - it('keeps the built-in header when Config has no sessionIdHeader getter', () => { - const legacyConfig = { - getSessionId: vi.fn().mockReturnValue('session-1'), - } as unknown as Config; - - expect( - buildSessionIdHeaders( - legacyConfig, - 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', - ), - ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); - expect( - buildSessionIdHeaders(legacyConfig, 'https://opencode.ai/zen/go/v1'), - ).toEqual({}); - }); - - it('treats settings with no enabled field as off', () => { - expect( - buildSessionIdHeaders( - config('session-1', { - headerName: 'x-opencode-session', - trustedHosts: ['opencode.ai'], - }), - 'https://opencode.ai/zen/go/v1', - ), - ).toEqual({}); - }); - - it('emits one header when the configured name only differs in case', () => { - expect( - buildSessionIdHeaders( - config('session-1', { - enabled: true, - headerName: 'Session_ID', - trustedHosts: ['routify-pub.alibaba-inc.com'], - }), - 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', - ), - ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); - }); -}); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts index 2ee79630962..7d086db5d67 100644 --- a/packages/core/src/core/outbound-session-id.ts +++ b/packages/core/src/core/outbound-session-id.ts @@ -6,6 +6,10 @@ import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { + applyDynamicHeaderValues, + hasDynamicPlaceholder, +} from './outbound-dynamic-headers.js'; const debugLogger = createDebugLogger('OUTBOUND_CORRELATION'); @@ -16,16 +20,6 @@ export const SESSION_ID_HEADER_HOSTS: readonly string[] = [ 'routify-pub.alibaba-inc.com', ]; -// Conservative HTTP token subset (letters, digits, dot, underscore, -// hyphen) — covers real header names like `x-opencode-session` while -// keeping anything a Headers implementation could reject, or that could -// smuggle a second header, out of the configured value. -const VALID_HEADER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; - -// An invalid header name is a static misconfiguration, so warn once per -// distinct name rather than on every request to the trusted host. -const warnedInvalidHeaderNames = new Set(); - type FetchLike = ( input: string | URL | Request, init?: RequestInit, @@ -41,98 +35,20 @@ function requestUrl(input: string | URL | Request): URL | undefined { } } -/** - * The user-configured header name to send to `hostname`, or `undefined` - * when the opt-in feature is off, the host is not trusted, or the name - * is unusable. - * - * Never throws. `outboundCorrelation.sessionIdHeader` ships OFF, and the - * built-in first-party branch ships ON — so this function is kept - * structurally unable to affect that branch, including by raising on a - * `Config` collaborator that predates `getOutboundSessionIdHeaderSettings`. - */ -function configuredSessionHeaderName( - config: Config, - hostname: string, -): string | undefined { - try { - const settings = config.getOutboundSessionIdHeaderSettings(); - // Explicitly `=== true`, not `!== false`: a settings object that - // reaches here without an `enabled` field is treated as OFF. The - // Config getter already gates on the flag; this re-check exists to - // fail closed if that gate is ever refactored away, which it only - // does if the absent case is off too. - if (settings?.enabled !== true) return undefined; - - // Host match before name validation: a misconfigured header name - // must not log on every outbound HTTPS request, only on the ones - // actually destined for a host the user listed. - const trusted = (settings.trustedHosts ?? []).some( - (host) => host.trim().toLowerCase() === hostname, - ); - if (!trusted) return undefined; - - const headerName = (settings.headerName ?? SESSION_ID_HEADER).trim(); - if (!VALID_HEADER_NAME.test(headerName)) { - if (!warnedInvalidHeaderNames.has(headerName)) { - warnedInvalidHeaderNames.add(headerName); - debugLogger.warn( - `Ignoring outboundCorrelation.sessionIdHeader.headerName "${headerName}": not a valid HTTP header name.`, - ); - } - return undefined; - } - return headerName; - } catch (error) { - debugLogger.warn( - `Unable to resolve outboundCorrelation.sessionIdHeader: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return undefined; - } -} - export function buildSessionIdHeaders( config: Config, destination: string | URL | Request, ): Record { try { const url = requestUrl(destination); - if (url?.protocol !== 'https:') { - return {}; - } - const hostname = url.hostname.toLowerCase(); - - // Note: both allowlists are checked against the *initial* request - // destination only. Standard fetch redirect behavior applies after - // that, so a listed host can forward the header elsewhere by - // redirecting — see the threat model in - // docs/design/2026-09-03-outbound-session-id-header.md. - const builtIn = SESSION_ID_HEADER_HOSTS.includes(hostname); - const configuredHeader = configuredSessionHeaderName(config, hostname); - - if (!builtIn && configuredHeader === undefined) { - return {}; - } - const sessionId = config.getSessionId(); - if (!sessionId) { - return {}; - } - const headers: Record = {}; - if (builtIn) { - headers[SESSION_ID_HEADER] = sessionId; - } - // Header names are case-insensitive on the wire, so a configured - // name that only differs in case from the built-in one would be the - // same header — emit it once rather than as two record entries. if ( - configuredHeader !== undefined && - !(builtIn && configuredHeader.toLowerCase() === SESSION_ID_HEADER) + url?.protocol !== 'https:' || + !SESSION_ID_HEADER_HOSTS.includes(url.hostname.toLowerCase()) ) { - headers[configuredHeader] = sessionId; + return {}; } - return headers; + const sessionId = config.getSessionId(); + return sessionId ? { [SESSION_ID_HEADER]: sessionId } : {}; } catch (error) { debugLogger.warn( `Unable to add ${SESSION_ID_HEADER} to outbound request: ${ @@ -146,20 +62,30 @@ export function buildSessionIdHeaders( export function wrapFetchWithSessionId( baseFetch: TFetch, config: Config, + customHeaders?: Record, ): TFetch { const fetchLike = baseFetch as FetchLike; + // Decided once, at client construction: whether this provider has any + // `customHeaders` value carrying a runtime placeholder. When it does + // not — every provider configured today — the wrapper keeps its + // existing early return and costs exactly what it did before. + const expandsHeaders = Object.values(customHeaders ?? {}).some( + (value) => typeof value === 'string' && hasDynamicPlaceholder(value), + ); const wrapped: FetchLike = async (input, init) => { const sessionHeaders = buildSessionIdHeaders(config, input); - const entries = Object.entries(sessionHeaders); - if (entries.length === 0) return fetchLike(input, init); + const sessionId = sessionHeaders[SESSION_ID_HEADER]; + if (!sessionId && !expandsHeaders) return fetchLike(input, init); const headers = new Headers( input instanceof Request ? input.headers : undefined, ); new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); - for (const [name, value] of entries) { - headers.set(name, value); - } + // Expand before the built-in header is written, so first-party + // correlation still wins over anything the user configured — the + // precedence documented for this wrapper (correlation > customHeaders). + if (expandsHeaders) applyDynamicHeaderValues(headers, config); + if (sessionId) headers.set(SESSION_ID_HEADER, sessionId); return fetchLike(input, { ...init, headers }); }; @@ -169,8 +95,9 @@ export function wrapFetchWithSessionId( export function buildSessionAwareFetch( runtimeFetch: unknown, config: Config, + customHeaders?: Record, ): typeof globalThis.fetch { const baseFetch = (runtimeFetch as typeof globalThis.fetch | undefined) ?? globalThis.fetch; - return wrapFetchWithSessionId(baseFetch, config); + return wrapFetchWithSessionId(baseFetch, config, customHeaders); } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 062bcd7a4ed..0c246f76647 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -638,29 +638,10 @@ "type": "boolean", "default": false }, - "sessionIdHeader": { - "description": "Send the qwen-code session ID as a per-request header to the listed HTTPS hosts. Default off with an empty host list — nothing is sent unless you opt in. Use for gateways that require a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session` since 2026-09-06). The value is resolved per request, so `/new` and `/resume` rotate it. Privacy: listed hosts can group every request of one conversation under a stable ID — only list hosts you already send your prompts to.", - "type": "object", - "properties": { - "enabled": { - "description": "Master switch. Default false: no user-configured session header is sent anywhere.", - "type": "boolean", - "default": false - }, - "headerName": { - "description": "Header name to set, e.g. `x-opencode-session`. Must be a valid HTTP header name (letters, digits, dot, underscore, hyphen); an invalid name is ignored with a warning. Default: `session_id`.", - "type": "string" - }, - "trustedHosts": { - "description": "Exact hostnames (no wildcards) allowed to receive the header, e.g. `[\"opencode.ai\"]`. HTTPS requests only; matching is case-insensitive. Default empty — `enabled: true` with no hosts sends nothing.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false + "allowDynamicHeaderValues": { + "description": "SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether live session state may be interpolated at all, and prevents a preset or extension from silently turning a shipped `customHeaders` entry into an identity header.", + "type": "boolean", + "default": false } }, "additionalProperties": false, From baabfe6e04d16014c90207783fc563dc032abf8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= Date: Mon, 7 Sep 2026 18:49:51 +0900 Subject: [PATCH 4/8] docs: drop the shape-comparison plan from this PR The rationale it carried is already in the PR description and the implementation commit message. The rest of it was review archaeology that does not belong in the repo. --- ...9-07-session-id-header-shape-comparison.md | 198 ------------------ 1 file changed, 198 deletions(-) delete mode 100644 docs/plans/2026-09-07-session-id-header-shape-comparison.md diff --git a/docs/plans/2026-09-07-session-id-header-shape-comparison.md b/docs/plans/2026-09-07-session-id-header-shape-comparison.md deleted file mode 100644 index 373f7d50396..00000000000 --- a/docs/plans/2026-09-07-session-id-header-shape-comparison.md +++ /dev/null @@ -1,198 +0,0 @@ -# Where should a per-conversation session header be configured? - -Two shapes for the same user need (#10995): a gateway that requires a stable -per-conversation identifier, e.g. OpenCode Go rejecting requests without -`x-opencode-session`. - -- **Shape A** — `outboundCorrelation.sessionIdHeader: { enabled, headerName, trustedHosts }`, a - new global setting with its own host allowlist. This is what PR #11282 implements. -- **Shape B** — a `${session_id}` placeholder in the existing per-provider - `modelProviders[].generationConfig.customHeaders`, plus one global consent - switch. This branch implements it, for comparison. - -Nothing here argues against the feature. Both shapes ship it. The question is -only where the configuration lives. - -## The deciding argument: Shape A cannot express two gateways - -`sessionIdHeader.headerName` is a single global string, and `trustedHosts` is a -list of hosts that all receive **that one** header name. So: - -| Scenario | Shape A | Shape B | -| ----------------------------------------------------------------- | ----------------------------------------- | ----------------------------------- | -| Provider X needs `x-opencode-session` | ✅ | ✅ | -| …and provider Y needs `x-session-id` at the same time | ❌ **inexpressible** | ✅ | -| Provider Z must not send it | via `trustedHosts` omission | automatic — no header on that entry | -| Two model entries on the _same host_, only one behind the gateway | ❌ host allowlist cannot distinguish them | ✅ per-entry | - -The second row is the one that matters. It is not a style preference: a user -with two gateways has to pick one header name and let the other provider receive -the wrong one. - -## `trustedHosts` re-encodes information the config already has - -You write the endpoint once in `modelProviders[].baseUrl`. Shape A asks you to -write the same host again in `trustedHosts`, and then compares the two copies you -wrote. That duplication exists only because the setting is global: a global -setting has no scope of its own, so it has to build one, and the only material -available is the `baseUrl` you already supplied. - -The one advantage of a global setting — "configure it once instead of per -provider" — is exactly the case where the session ID reaches hosts you did not -think about, which is what the allowlist then exists to prevent. It creates the -problem it spends most of its code solving. In this branch, roughly 90 lines of -host normalization, punycode conversion, wildcard/scheme/port rejection and -allowlist validation are simply absent, because the scoping is inherited. - -## What Shape B keeps from the reviewer's objection - -LaZzyMan's round-8 objection to PR #4390 — recorded in -`docs/design/telemetry-outbound-propagation-design.md` §12.1 — was that -`telemetry.*` implies "recipient is your own OTLP collector", while these headers -go to a third-party LLM provider, and the two deserve different consent decisions. -That argument is correct and Shape B keeps it: `outboundCorrelation.allowDynamicHeaderValues` -is a consent switch under the same namespace, default off. - -What Shape B does _not_ put in the global namespace is scoping and naming, which -the provider entry already answers. - -The switch earns its place for a second reason: `customHeaders` can arrive from a -provider preset (`packages/core/src/providers/presets/*.ts` ship them) or an -extension, and provenance is lost once presets and user settings are merged. The -gate is what separates "I typed this" from "something shipped this", so a preset -cannot silently promote a static header into an identity header. - -## Where the `trustedHosts` allowlist actually comes from - -It is not arbitrary, and it has a named origin. In his round-8 follow-up review -of PR #4390 (2026-05-25T03:24), LaZzyMan listed what the outbound-correlation -follow-up PR should cover, including: - -> - **Override semantics**: same `trustedHosts` allowlist mechanism you've built -> here **can carry over unchanged** - -Two things about the context matter: - -1. **It was said about the built-in, automatic header.** In R3 the mechanism - attached the session ID to every provider; the allowlist is what removed that - "dangerous default", narrowing it to three first-party hosts. For a header the - client attaches _by itself_, an allowlist is the only possible source of - scope, and it is plainly right. -2. **"Can carry over", not "must".** For a header the _user_ writes into a - provider entry, the scope already exists — they chose the `baseUrl`. The - allowlist was permission to reuse working code, not a derivation that the - user-configured case needs one. - -So Shape B does not contradict LaZzyMan's position. What he asked for was scope -discipline and a consent decision outside `telemetry.*`: "its own settings tree, -its own threat model, and its own user consent flow", default off. Shape B keeps -all of that in `outboundCorrelation.allowDynamicHeaderValues`. What it declines -to carry over is the allowlist, for the case where it duplicates scope rather -than creating it. - -## Provenance: one argument was never engaged with - -§12.2 of `docs/design/telemetry-outbound-propagation-design.md` says: - -> 经过几轮内部讨论(含 yiliang 提出的 customHeader 模板替代方案,最终判定 customHeader 不能携带 runtime-dynamic 值),决定走 **方案 C** - -Against the record of PR #4390 (all of it public — the discussion happened in -that PR's review thread, not off-GitHub): - -| Time (UTC) | Who | What | -| --------------- | ---------- | ---------------------------------------------------------------------------------------------------- | -| 05-22 08:52 | LaZzyMan | CHANGES_REQUESTED, escalating from the earlier read | -| 05-22 **10:59** | yiliang114 | [The `customHeaders` comment](https://github.com/QwenLM/qwen-code/pull/4390#issuecomment-4518059628) | -| 05-22 15:11 | doudouOUC | Replies **to @LaZzyMan** | -| 05-25 03:24 | LaZzyMan | "Review (round 8 follow-up)" — the text §12.1 quotes | -| 05-25 05:44 | doudouOUC | "accepting the split" (方案 C) | -| 05-25 13:26 | LaZzyMan | APPROVED | -| 05-25 | jinye | Commit `62ed44e1f` adds the design doc | - -Note that `doudouOUC`'s GitHub display name is _jinye_: the PR author and the -design doc's author are the same person. - -Findings: - -- **The proposal is real**, at the timestamp above. -- **It was not a "template" proposal.** The word does not appear in it. The - argument was that `customHeaders` is _per-provider_ — "without leaking - identifiers indiscriminately to all third-party providers" — at "zero - additional complexity". The scoping point is the one that turns out to decide - the design, and it is the one the recorded rejection does not address. -- **The recorded rejection describes the code, not a constraint.** - `buildHeaders()` does run once inside `buildClient()`, but the per-request hook - already exists in `wrapFetchWithSessionId`, which sets headers _after_ the - SDK's — which is what this branch uses. -- **No reply to it exists anywhere in the public record.** Searching all three - surfaces of #4390 — issue comments, inline review comments, and formal review - bodies — the string `customHeader` appears exactly once, in that comment. - LaZzyMan never mentioned `customHeaders` in any of his three reviews, and his - round-8 closing line addresses `@wenshao @doudouOUC`, so the per-provider - argument was never in front of the reviewer whose objection shaped the outcome. - -The likeliest reconstruction is ordinary: the comment landed in the middle of a -LaZzyMan↔doudouOUC argument, nobody picked it up, and three days later it was -summarized into the design doc as discussed-and-rejected. Nothing here suggests -bad faith. It matters only because #11282 → §12.7 → §12.2 is the chain that makes -Shape A look pre-decided, and the root of that chain is a conclusion no one -publicly answered. - -## What this branch changes - -| File | Change | -| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `core/src/core/outbound-dynamic-headers.ts` | New. Placeholder set, per-value expansion, fail-closed drops, `Headers` rewrite, per-request subset for Gemini. | -| `core/src/core/outbound-session-id.ts` | Fetch wrapper expands placeholders before writing the built-in header, preserving `correlation > customHeaders` precedence. Whether any placeholder exists is decided once at client construction, so providers without one cost exactly what they cost today. | -| `openaiContentGenerator/provider/{default,dashscope}.ts`, `anthropicContentGenerator.ts` | Pass the provider's `customHeaders` to the wrapper. | -| `llm-content-generator.ts` | Re-emit the placeholder-bearing subset per request, overriding the client-level copy the SDK froze at construction. | -| `config/config.ts`, `cli/src/config/settingsSchema.ts` | `outboundCorrelation.allowDynamicHeaderValues`, default false. | -| docs | `model-providers.md` (where the header goes), `settings.md` (the consent switch). | - -Not implemented here, deliberately: removing Shape A. If Shape B is chosen, -#11282's opt-in branch comes out and its built-in first-party allowlist stays -untouched. - -## Verification status - -Run locally in a sandbox worktree (workspace `node_modules` and `dist` overlaid -by symlink; four missing `exports` entries stubbed to satisfy the vitest global -setup — none of them imported by these suites): - -``` -✓ src/core/outbound-dynamic-headers.test.ts (20 tests) -✓ src/core/outbound-session-id.test.ts (14 tests) -✓ src/core/openaiContentGenerator/provider/default.test.ts (38 tests) -✓ src/core/openaiContentGenerator/provider/dashscope.test.ts (162 tests) -✓ src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts (165 tests) -✓ src/core/llm-content-generator/llm-content-generator.test.ts (22 tests) - -Test Files 6 passed (6) - Tests 421 passed (421) -``` - -The last four are the suites whose built-in `session_id` assertions #11282's -first commit broke; they pass here unmodified, which is the regression this -shape has to clear too. ESLint passes on every changed file. - -Still **not** verified here, and CI's to confirm: - -- `npm run typecheck` and a full build were not run. -- `npm run generate:settings-schema` could not run (it loads the built core - through `dist/`, and this box cannot build it). The `settings.schema.json` - entry was added by hand, with the description string extracted - programmatically from `settingsSchema.ts` so the two cannot disagree. - **CI's schema-drift check is the authority** — if it reports a diff, re-run - the generator and commit the result. -- No test covers the four provider call sites actually forwarding - `customHeaders` into `buildSessionAwareFetch`; that wiring is verified only by - reading. - -To reproduce on a build-capable machine: - -```bash -npm run build -npm run test --workspace=@qwen-code/qwen-code-core -- src/core/outbound-dynamic-headers.test.ts -npm run generate:settings-schema && git diff --exit-code packages/vscode-ide-companion/schemas/settings.schema.json -npm run typecheck && npm run lint -``` From b9e79df007c6c7fd3ba0d49c57c2ea644d8478f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= Date: Mon, 7 Sep 2026 18:53:08 +0900 Subject: [PATCH 5/8] fix(core): warn when a configured placeholder is refused by the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10995 treats writing `${session_id}` into a provider entry as the opt-in ("the header is only sent when the user configures it"). With the consent switch defaulting to off, a user following the issue's example got silence: the header was dropped and the only trace was a debug log that is normally disabled. Their observable symptom would be a gateway rejecting every request with nothing on screen to explain it — the same failure mode this PR's earlier approach was criticized for. Warn on the console at client construction instead, naming both the header and the setting to flip, once per distinct header set. The check cannot throw: it runs while a provider client is being built, and a partial Config must not break that. Docs now lead with the fact that setup is two steps, and say what the warning looks like. Also adds the end-to-end coverage that was missing: expansion through the seam the providers actually construct, the gate dropping the header rather than emitting the literal, rotation across sessions without a new client, and a provider with no placeholder keeping the untouched early-return path. 428 tests pass across the six affected core suites. --- docs/users/configuration/model-providers.md | 27 +++- docs/users/configuration/settings.md | 4 + .../src/core/outbound-dynamic-headers.test.ts | 132 ++++++++++++++++++ .../core/src/core/outbound-dynamic-headers.ts | 43 ++++++ packages/core/src/core/outbound-session-id.ts | 2 + 5 files changed, 201 insertions(+), 7 deletions(-) diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 25eaab49c02..5184eba7e2e 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -588,13 +588,26 @@ rejects requests without `x-opencode-session`: Because the value is resolved per request rather than baked into the SDK client, `/new` and `/resume` rotate it without a restart. -⚠️ **This is off by default and must be enabled explicitly** with -[`outboundCorrelation.allowDynamicHeaderValues`](settings.md#outboundcorrelation). -Until you do, a value containing a placeholder is dropped rather than sent — the -header is never sent with a literal `${session_id}` in it. The switch is global -because it is a consent decision: an expanded value carries live session state to -whoever receives it, and the switch also stops a provider preset or an extension -from quietly turning a `customHeaders` entry it ships into an identity header. +⚠️ **Two steps are required.** The provider entry above is only half of it — a +placeholder is inert until you also switch on +[`outboundCorrelation.allowDynamicHeaderValues`](settings.md#outboundcorrelation): + +```json +{ + "outboundCorrelation": { + "allowDynamicHeaderValues": true + } +} +``` + +Until you do, a value containing a placeholder is **dropped** rather than sent, +and Qwen Code prints a warning at startup naming the header and this setting. +The header is never sent with a literal `${session_id}` in it. + +The switch is global because it is a consent decision, separate from _where_ the +value goes: an expanded value carries live session state to whoever receives it, +and the switch also stops a provider preset or an extension from quietly turning +a `customHeaders` entry it ships into an identity header. **Privacy note:** the session ID is a stable identifier for the life of a conversation, so any host you send it to can group every request of that diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 328d3ae5c77..622cec01bd2 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -686,6 +686,10 @@ That scoping is why there is no host allowlist here: you already chose the endpoint when you wrote the provider's `baseUrl`, and providers that should not send the header simply do not carry it. +If a provider entry has a placeholder while this is off, the header is dropped +and a warning naming both is printed at startup — so a gateway rejecting your +requests should never be a silent mystery. + **Privacy note:** an expanded value is a stable per-conversation identifier. Only put one on a provider you already send your prompt content to. diff --git a/packages/core/src/core/outbound-dynamic-headers.test.ts b/packages/core/src/core/outbound-dynamic-headers.test.ts index 926a725d2ed..430212d22d2 100644 --- a/packages/core/src/core/outbound-dynamic-headers.test.ts +++ b/packages/core/src/core/outbound-dynamic-headers.test.ts @@ -11,7 +11,9 @@ import { expandDynamicHeaders, hasDynamicPlaceholder, resolveDynamicHeaderValue, + warnIfDynamicHeadersDisabled, } from './outbound-dynamic-headers.js'; +import { wrapFetchWithSessionId } from './outbound-session-id.js'; function config({ sessionId = 'session-1', @@ -137,3 +139,133 @@ describe('expandDynamicHeaders', () => { expect(expandDynamicHeaders(undefined, config())).toEqual({}); }); }); + +describe('warnIfDynamicHeadersDisabled', () => { + // #10995 treats writing the placeholder into a provider entry as the + // opt-in, so a user can reasonably arrive with the gate still off. + // Their symptom is otherwise a gateway rejecting every request with + // nothing on screen to explain it. + it('names the header and the switch when the gate is closed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + warnIfDynamicHeadersDisabled( + { 'x-opencode-session': '${session_id}' }, + config({ allow: false }), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('x-opencode-session'), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('outboundCorrelation.allowDynamicHeaderValues'), + ); + } finally { + warn.mockRestore(); + } + }); + + it('stays quiet when the gate is open or nothing has a placeholder', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + warnIfDynamicHeadersDisabled( + { 'x-only-here-open': '${session_id}' }, + config({ allow: true }), + ); + warnIfDynamicHeadersDisabled( + { 'x-static': 'req-123' }, + config({ allow: false }), + ); + warnIfDynamicHeadersDisabled(undefined, config({ allow: false })); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('does not throw on a Config that cannot answer', () => { + const broken = {} as unknown as Config; + expect(() => + warnIfDynamicHeadersDisabled({ 'x-a': '${session_id}' }, broken), + ).not.toThrow(); + }); +}); + +// End-to-end through the seam the providers actually construct, which is +// the part that makes the issue's config work rather than just the +// resolver in isolation. +describe('placeholder expansion through the provider fetch wrapper', () => { + async function send( + customHeaders: Record, + cliConfig: Config, + ): Promise { + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrapped = wrapFetchWithSessionId(baseFetch, cliConfig, customHeaders); + // The SDK merges `defaultHeaders` into each request before calling + // the custom fetch, so the placeholder arrives here in `init`. + await wrapped('https://opencode.ai/zen/go/v1/chat/completions', { + headers: customHeaders, + }); + return new Headers(baseFetch.mock.calls[0][1]?.headers); + } + + it('sends the expanded value to a non-first-party gateway', async () => { + const headers = await send( + { 'x-opencode-session': '${session_id}', 'x-static': 'req-123' }, + config(), + ); + expect(headers.get('x-opencode-session')).toBe('session-1'); + expect(headers.get('x-static')).toBe('req-123'); + }); + + it('drops the header, never the literal, when the gate is closed', async () => { + const headers = await send( + { 'x-opencode-session': '${session_id}' }, + config({ allow: false }), + ); + expect(headers.has('x-opencode-session')).toBe(false); + }); + + it('rotates the value when the session changes, without a new client', async () => { + const cliConfig = config(); + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const custom = { 'x-opencode-session': '${session_id}' }; + const wrapped = wrapFetchWithSessionId(baseFetch, cliConfig, custom); + + await wrapped('https://opencode.ai/v1', { headers: custom }); + vi.mocked(cliConfig.getSessionId).mockReturnValue('session-2'); + await wrapped('https://opencode.ai/v1', { headers: custom }); + + expect( + new Headers(baseFetch.mock.calls[0][1]?.headers).get( + 'x-opencode-session', + ), + ).toBe('session-1'); + expect( + new Headers(baseFetch.mock.calls[1][1]?.headers).get( + 'x-opencode-session', + ), + ).toBe('session-2'); + }); + + it('leaves a provider with no placeholder on the untouched path', async () => { + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrapped = wrapFetchWithSessionId( + baseFetch, + config({ allow: false }), + { + 'x-static': 'req-123', + }, + ); + await wrapped('https://api.openai.com/v1'); + // Same early return as before this feature existed: init passed through. + expect(baseFetch.mock.calls[0][1]).toBeUndefined(); + }); +}); diff --git a/packages/core/src/core/outbound-dynamic-headers.ts b/packages/core/src/core/outbound-dynamic-headers.ts index 4763d14993a..169537f606f 100644 --- a/packages/core/src/core/outbound-dynamic-headers.ts +++ b/packages/core/src/core/outbound-dynamic-headers.ts @@ -32,6 +32,49 @@ const PLACEHOLDERS: ReadonlyArray<{ readonly resolve: (config: Config) => string | undefined; }> = [{ token: '${session_id}', resolve: (config) => config.getSessionId() }]; +/** + * Reports a configured placeholder that the gate is currently refusing, + * once per distinct header set, on the console. + * + * The issue this feature answers (#10995) treats writing the placeholder + * into a provider entry as the opt-in, so a user following the docs can + * reasonably arrive here with the gate still off. Their symptom would + * otherwise be a gateway rejecting every request with nothing to explain + * it — the failure has to name the switch to flip, and a debug log that + * is normally off does not count. + * + * Never throws: it is called while building a provider client, and a + * partial `Config` must not be able to break client construction. + */ +const warnedGateOff = new Set(); +export function warnIfDynamicHeadersDisabled( + customHeaders: Record | undefined, + config: Config, +): void { + try { + const names = Object.entries(customHeaders ?? {}) + .filter( + ([, value]) => + typeof value === 'string' && hasDynamicPlaceholder(value), + ) + .map(([name]) => name); + if (names.length === 0) return; + if (config.getOutboundAllowDynamicHeaderValues()) return; + const key = names.slice().sort().join(','); + if (warnedGateOff.has(key)) return; + warnedGateOff.add(key); + // eslint-disable-next-line no-console -- operator-facing misconfiguration breadcrumb; the alternative symptom is a gateway rejecting every request + console.warn( + `customHeaders ${names.join(', ')} contain a runtime placeholder, but ` + + `outboundCorrelation.allowDynamicHeaderValues is not enabled — the ` + + `header(s) will not be sent. Set it to true in settings.json to ` + + `allow the value to be filled in per request.`, + ); + } catch { + // A Config that cannot answer is not worth failing client construction. + } +} + /** True when `value` asks for at least one runtime-resolved placeholder. */ export function hasDynamicPlaceholder(value: string): boolean { return PLACEHOLDERS.some(({ token }) => value.includes(token)); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts index 7d086db5d67..c1257ddcd53 100644 --- a/packages/core/src/core/outbound-session-id.ts +++ b/packages/core/src/core/outbound-session-id.ts @@ -9,6 +9,7 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import { applyDynamicHeaderValues, hasDynamicPlaceholder, + warnIfDynamicHeadersDisabled, } from './outbound-dynamic-headers.js'; const debugLogger = createDebugLogger('OUTBOUND_CORRELATION'); @@ -72,6 +73,7 @@ export function wrapFetchWithSessionId( const expandsHeaders = Object.values(customHeaders ?? {}).some( (value) => typeof value === 'string' && hasDynamicPlaceholder(value), ); + if (expandsHeaders) warnIfDynamicHeadersDisabled(customHeaders, config); const wrapped: FetchLike = async (input, init) => { const sessionHeaders = buildSessionIdHeaders(config, input); const sessionId = sessionHeaders[SESSION_ID_HEADER]; From f1a52b6e906ed26cd125e1672ac6e68d259b1f68 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 7 Sep 2026 21:49:23 +0800 Subject: [PATCH 6/8] fix(core): close dynamic header consent gaps --- .../2026-09-03-outbound-session-id-header.md | 9 +++++ packages/cli/src/config/settingsUtils.ts | 1 + packages/core/src/config/config.test.ts | 16 +++++++++ packages/core/src/config/config.ts | 2 +- .../llm-content-generator.test.ts | 23 +++++++++++++ .../llm-content-generator.ts | 7 ++-- packages/core/src/tools/web-search.test.ts | 33 +++++++++++++++++++ packages/core/src/tools/web-search.ts | 12 ++++++- .../core/src/utils/envVarResolver.test.ts | 6 ++++ packages/core/src/utils/envVarResolver.ts | 3 +- 10 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/design/2026-09-03-outbound-session-id-header.md b/docs/design/2026-09-03-outbound-session-id-header.md index baf869903d8..b42818826cf 100644 --- a/docs/design/2026-09-03-outbound-session-id-header.md +++ b/docs/design/2026-09-03-outbound-session-id-header.md @@ -34,3 +34,12 @@ Non-LLM traffic, other domains, MCP requests, tool fetches, subprocesses, `trace ## Verification Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID. + +## Follow-up: user-configurable `${session_id}` in `customHeaders` + +A later change (for #10995) lets a `modelProviders[].generationConfig.customHeaders` value contain the placeholder `${session_id}`, expanded per request from the same `Config.getSessionId()`. The built-in header above is unchanged and keeps precedence over customHeaders entries on every path. The follow-up answers the threat-model questions §12.7 of the outbound-propagation design asked for: + +- **Consent.** The placeholder is inert until the global `outboundCorrelation.allowDynamicHeaderValues` switch is on (default off). A gate-off, empty, or unresolvable value drops the header — the literal `${session_id}` is never sent. +- **Recipient set.** Whichever hosts the user attached the header to. Scope comes from the provider entry rather than a separate allowlist: the user already chose the endpoint when writing `baseUrl`, and providers that must not receive the value simply do not carry the header. +- **De-anonymization window.** One session: the value is stable for the life of a conversation (that stability is the feature — gateways key on it) and rotates on `/new` and `/resume`. A recipient can group the requests of one conversation, never across conversations. +- **Per-request UUID companion.** Deliberately not provided: a per-request value would defeat the session affinity the gateway requires. The placeholder set is closed at one entry and should not grow without the same review this seam received. diff --git a/packages/cli/src/config/settingsUtils.ts b/packages/cli/src/config/settingsUtils.ts index 0fec26dda34..0245c013d21 100644 --- a/packages/cli/src/config/settingsUtils.ts +++ b/packages/cli/src/config/settingsUtils.ts @@ -269,6 +269,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [ { section: 'security', key: 'allowPrivateNetworkHooks' }, { section: 'security', key: 'allowedInsecureVoiceBaseUrls' }, { section: 'goals', key: 'modelProposed' }, + { section: 'outboundCorrelation', key: 'allowDynamicHeaderValues' }, ] as const satisfies ReadonlyArray<{ readonly section: keyof Settings; readonly key: string; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 961337246e7..e436c8fca91 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -9385,6 +9385,22 @@ describe('Server Config (config.ts)', () => { ); }, ); + + it('only enables dynamic header values for boolean true', () => { + for (const value of [undefined, false, 'false', 1, {}, []]) { + const outboundCorrelation = { + allowDynamicHeaderValues: value, + } as unknown as ConfigParameters['outboundCorrelation']; + const config = new Config({ ...baseParams, outboundCorrelation }); + expect(config.getOutboundAllowDynamicHeaderValues()).toBe(false); + } + + const config = new Config({ + ...baseParams, + outboundCorrelation: { allowDynamicHeaderValues: true }, + }); + expect(config.getOutboundAllowDynamicHeaderValues()).toBe(true); + }); }); describe('UseRipgrep Configuration', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 427ade11e5c..9796ea56579 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2634,7 +2634,7 @@ export class Config { propagateTraceContext: params.outboundCorrelation?.propagateTraceContext ?? false, allowDynamicHeaderValues: - params.outboundCorrelation?.allowDynamicHeaderValues ?? false, + params.outboundCorrelation?.allowDynamicHeaderValues === true, }; this.gitCoAuthor = { ...normalizeGitCoAuthor(params.gitCoAuthor), diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index e04066f71e0..b37df4c450d 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -118,6 +118,7 @@ describe('LlmContentGenerator', () => { const getSessionId = vi.fn().mockReturnValue('session-1'); const cliConfig = { getSessionId, + getOutboundAllowDynamicHeaderValues: () => true, } as unknown as Config; const sessionGenerator = new LlmContentGenerator( { @@ -129,6 +130,7 @@ describe('LlmContentGenerator', () => { { model: 'gemini-1.5-flash', baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + customHeaders: { session_id: 'custom-${session_id}' }, }, cliConfig, ); @@ -155,6 +157,27 @@ describe('LlmContentGenerator', () => { ).toEqual({ session_id: 'session-2' }); }); + it('warns when Gemini dynamic headers are disabled', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + void new LlmContentGenerator( + { apiKey: 'test-api-key' }, + { + model: 'gemini-1.5-flash', + customHeaders: { 'X-Gemini-Session': '${session_id}' }, + }, + { + getOutboundAllowDynamicHeaderValues: () => false, + } as unknown as Config, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('X-Gemini-Session'), + ); + } finally { + warn.mockRestore(); + } + }); + it('uses the constructor base URL for Gemini session ID injection', async () => { const cliConfig = { getSessionId: vi.fn().mockReturnValue('session-1'), diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 5900f77ef10..70b7f27b645 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -32,6 +32,7 @@ import { buildSessionIdHeaders } from '../outbound-session-id.js'; import { expandDynamicHeaders, hasDynamicPlaceholder, + warnIfDynamicHeadersDisabled, } from '../outbound-dynamic-headers.js'; const debugLogger = createDebugLogger('GEMINI'); @@ -90,6 +91,7 @@ export class LlmContentGenerator implements ContentGenerator { // ones. `buildHttpOptions` supplies the placeholder-bearing entries // per request instead, so the literal never reaches the client. const allCustomHeaders = contentGeneratorConfig?.customHeaders; + if (cliConfig) warnIfDynamicHeadersDisabled(allCustomHeaders, cliConfig); const staticEntries = Object.entries(allCustomHeaders ?? {}).filter( ([, value]) => typeof value !== 'string' || !hasDynamicPlaceholder(value), ); @@ -141,11 +143,8 @@ export class LlmContentGenerator implements ContentGenerator { ...httpOptions, headers: { ...httpOptions?.headers, - // Existing merge order for this path is - // customHeaders > correlation; keep it. - ...sessionHeaders, ...dynamicHeaders, - // (dynamicHeaders last: they are customHeaders entries.) + ...sessionHeaders, }, }; } diff --git a/packages/core/src/tools/web-search.test.ts b/packages/core/src/tools/web-search.test.ts index 33711699a26..e99f54cd646 100644 --- a/packages/core/src/tools/web-search.test.ts +++ b/packages/core/src/tools/web-search.test.ts @@ -25,6 +25,7 @@ const TEST_ENV_KEY = 'WEB_SEARCH_TEST_DS_KEY'; const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; interface ConfigOverrides { + allowDynamicHeaderValues?: boolean; settings?: { enabled?: boolean; model?: string; @@ -74,6 +75,8 @@ function makeConfig(overrides: ConfigOverrides = {}): Config { : undefined; }, getSessionId: () => 'session-1', + getOutboundAllowDynamicHeaderValues: () => + overrides.allowDynamicHeaderValues ?? false, getCliVersion: () => '0.0.0-test', getProxy: () => undefined, getModel: () => 'main-model', @@ -663,6 +666,36 @@ describe('WebSearchTool execute', () => { expect(opts.defaultHeaders['User-Agent']).toContain('QwenCode/'); }); + it('installs dynamic header expansion on the search client fetch', async () => { + mockCreate.mockResolvedValueOnce( + makeStream(completedEvents([SEARCH_ITEM, MESSAGE_ITEM])), + ); + const config = makeConfig({ + allowDynamicHeaderValues: true, + models: [ + { + id: 'qwen3.6-plus', + authType: 'openai', + envKey: TEST_ENV_KEY, + baseUrl: DASHSCOPE_BASE_URL, + generationConfig: { + customHeaders: { 'X-Session': '${session_id}' }, + }, + }, + ], + }); + const getSessionId = vi.spyOn(config, 'getSessionId'); + await runSearch(config); + const opts = mockCtorOpts.current as { + defaultHeaders: Record; + fetch: typeof globalThis.fetch; + }; + + await opts.fetch('data:text/plain,ok', { headers: opts.defaultHeaders }); + + expect(getSessionId).toHaveBeenCalledOnce(); + }); + it('truncates an oversized answer while preserving source URLs and the safety footer', async () => { const bigText = 'x'.repeat(150_000); mockCreate.mockResolvedValueOnce( diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 30d6185498e..2aec725127c 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -13,6 +13,7 @@ import { resolveRequestTimeout, } from '../core/openaiContentGenerator/constants.js'; import { DASHSCOPE_REGIONAL_HOSTS } from '../core/openaiContentGenerator/provider/dashscope.js'; +import { buildSessionAwareFetch } from '../core/outbound-session-id.js'; import { buildRuntimeFetchOptions, preloadRuntimeFetchModule, @@ -649,6 +650,10 @@ class WebSearchToolInvocation extends BaseToolInvocation< const startedAt = Date.now(); const apiKey = process.env[backend.apiKeyEnvKey]; + const runtimeOptions = buildRuntimeFetchOptions( + 'openai', + this.config.getProxy(), + ); const client = new OpenAI({ apiKey, baseURL: backend.baseUrl, @@ -659,7 +664,12 @@ class WebSearchToolInvocation extends BaseToolInvocation< // Entry-declared headers win, matching the providers' merge order. ...(backend.customHeaders ?? {}), }, - ...(buildRuntimeFetchOptions('openai', this.config.getProxy()) || {}), + ...(runtimeOptions || {}), + fetch: buildSessionAwareFetch( + runtimeOptions?.fetch, + this.config, + backend.customHeaders, + ), }); // One total timeout across both attempts, combined with the caller's diff --git a/packages/core/src/utils/envVarResolver.test.ts b/packages/core/src/utils/envVarResolver.test.ts index 75a668203f8..28d7184b684 100644 --- a/packages/core/src/utils/envVarResolver.test.ts +++ b/packages/core/src/utils/envVarResolver.test.ts @@ -58,6 +58,12 @@ describe('resolveEnvVarsInString', () => { expect(result).toBe('Value is ${UNDEFINED_VAR}'); }); + it('preserves the runtime session ID placeholder', () => { + process.env['session_id'] = 'environment-session'; + + expect(resolveEnvVarsInString('${session_id}')).toBe('${session_id}'); + }); + describe('Qwen-internal secrets', () => { beforeEach(() => { process.env['QWEN_SERVER_TOKEN'] = 'daemon-secret'; diff --git a/packages/core/src/utils/envVarResolver.ts b/packages/core/src/utils/envVarResolver.ts index 192e73754d4..d4e08bfb5da 100644 --- a/packages/core/src/utils/envVarResolver.ts +++ b/packages/core/src/utils/envVarResolver.ts @@ -17,6 +17,7 @@ import { isInternalSecretEnvVar } from './sanitize-child-env.js'; * value is baked into hook commands, URLs or MCP configs before child-env * sanitization ever applies. Their placeholders are preserved exactly like * an unset variable's. + * `${session_id}` is also reserved for per-request custom header expansion. * * @param value - The string that may contain environment variable placeholders * @returns The string with environment variables resolved @@ -33,7 +34,7 @@ export function resolveEnvVarsInString( const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; - if (isInternalSecretEnvVar(varName)) { + if (match === '${session_id}' || isInternalSecretEnvVar(varName)) { return match; } if (customEnv && typeof customEnv[varName] === 'string') { From 808cbc56ba8780130c1368ff1063b367501172b5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 8 Sep 2026 08:27:35 +0800 Subject: [PATCH 7/8] fix(core): close remaining dynamic header gaps --- docs/users/configuration/model-providers.md | 4 +-- packages/cli/src/config/settingsSchema.ts | 2 +- packages/core/src/config/config.ts | 7 ++--- .../llm-content-generator.test.ts | 26 +++++++++++++++++++ .../llm-content-generator.ts | 8 +++--- .../src/core/outbound-dynamic-headers.test.ts | 14 ++++++++++ .../core/src/core/outbound-dynamic-headers.ts | 12 ++++----- .../core/src/utils/envVarResolver.test.ts | 13 ++++++---- packages/core/src/utils/envVarResolver.ts | 6 ++++- .../schemas/settings.schema.json | 2 +- 10 files changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/users/configuration/model-providers.md b/docs/users/configuration/model-providers.md index 5184eba7e2e..23a562ad298 100644 --- a/docs/users/configuration/model-providers.md +++ b/docs/users/configuration/model-providers.md @@ -606,8 +606,8 @@ The header is never sent with a literal `${session_id}` in it. The switch is global because it is a consent decision, separate from _where_ the value goes: an expanded value carries live session state to whoever receives it, -and the switch also stops a provider preset or an extension from quietly turning -a `customHeaders` entry it ships into an identity header. +and the switch controls only whether `${session_id}` may be expanded. It does not +identify which settings source supplied the header. **Privacy note:** the session ID is a stable identifier for the life of a conversation, so any host you send it to can group every request of that diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 246268745dc..41851b5361b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1460,7 +1460,7 @@ const SETTINGS_SCHEMA = { }, allowDynamicHeaderValues: { description: - 'SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether live session state may be interpolated at all, and prevents a preset or extension from silently turning a shipped `customHeaders` entry into an identity header.', + 'SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether `${session_id}` may be expanded from live session state and does not identify which settings source supplied the header.', type: 'boolean', default: false, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9796ea56579..bce01d81af4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -654,11 +654,8 @@ export interface OutboundCorrelationSettings { * This is the consent decision, and it is the only part of the feature * that is global: *which* hosts may receive the value, and *what* the * header is called, are already answered by the provider entry the - * header is attached to. The gate also means a preset or extension - * that ships a `customHeaders` entry cannot quietly turn it into an - * identity header — provenance is lost once presets and user settings - * are merged, so the switch is what separates "I typed this" from - * "something shipped this". + * header is attached to. It controls only `${session_id}` expansion and + * cannot recover a header's provenance after settings are merged. */ allowDynamicHeaderValues?: boolean; } diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index b37df4c450d..2a629efc889 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -178,6 +178,32 @@ describe('LlmContentGenerator', () => { } }); + it('expands Gemini dynamic headers without a base URL', async () => { + const sessionGenerator = new LlmContentGenerator( + { apiKey: 'test-api-key' }, + { + model: 'gemini-1.5-flash', + customHeaders: { 'X-Gemini-Session': '${session_id}' }, + }, + { + getSessionId: () => 'session-1', + getOutboundAllowDynamicHeaderValues: () => true, + } as unknown as Config, + ); + const googleGenAI = vi.mocked(GoogleGenAI).mock.results.at(-1)?.value; + googleGenAI.models.generateContent.mockResolvedValue({}); + + await sessionGenerator.generateContent( + { model: 'gemini-1.5-flash', contents: [] }, + 'prompt-1', + ); + + expect( + googleGenAI.models.generateContent.mock.calls[0][0].config.httpOptions + .headers, + ).toEqual({ 'X-Gemini-Session': 'session-1' }); + }); + it('uses the constructor base URL for Gemini session ID injection', async () => { const cliConfig = { getSessionId: vi.fn().mockReturnValue('session-1'), diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 70b7f27b645..b811296da5f 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -122,8 +122,7 @@ export class LlmContentGenerator implements ContentGenerator { } private buildHttpOptions(httpOptions?: HttpOptions): HttpOptions | undefined { - const destination = httpOptions?.baseUrl ?? this.clientBaseUrl; - if (!this.cliConfig || !destination) return httpOptions; + if (!this.cliConfig) return httpOptions; // The placeholder-bearing entries were deliberately kept out of the // client options (see the constructor), so this is the only place @@ -132,7 +131,10 @@ export class LlmContentGenerator implements ContentGenerator { this.contentGeneratorConfig?.customHeaders, this.cliConfig, ); - const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination); + const destination = httpOptions?.baseUrl ?? this.clientBaseUrl; + const sessionHeaders = destination + ? buildSessionIdHeaders(this.cliConfig, destination) + : {}; if ( Object.keys(sessionHeaders).length === 0 && Object.keys(dynamicHeaders).length === 0 diff --git a/packages/core/src/core/outbound-dynamic-headers.test.ts b/packages/core/src/core/outbound-dynamic-headers.test.ts index 430212d22d2..9b96e7bd6d0 100644 --- a/packages/core/src/core/outbound-dynamic-headers.test.ts +++ b/packages/core/src/core/outbound-dynamic-headers.test.ts @@ -126,6 +126,20 @@ describe('expandDynamicHeaders', () => { ).toEqual({ 'x-opencode-session': 'session-1' }); }); + it('ignores non-string runtime values', () => { + const numericValue = 30 as unknown as string; + expect( + expandDynamicHeaders( + { + 'x-timeout': numericValue, + 'x-opencode-session': '${session_id}', + }, + config(), + ), + ).toEqual({ 'x-opencode-session': 'session-1' }); + expect(resolveDynamicHeaderValue(numericValue, config())).toBeUndefined(); + }); + it('omits an entry the gate refuses rather than emitting the literal', () => { expect( expandDynamicHeaders( diff --git a/packages/core/src/core/outbound-dynamic-headers.ts b/packages/core/src/core/outbound-dynamic-headers.ts index 169537f606f..baa95ba1e3d 100644 --- a/packages/core/src/core/outbound-dynamic-headers.ts +++ b/packages/core/src/core/outbound-dynamic-headers.ts @@ -88,12 +88,9 @@ export function hasDynamicPlaceholder(value: string): boolean { * putting a wrong value on the wire: * * - The `outboundCorrelation.allowDynamicHeaderValues` gate is off (the - * default). This is the consent decision: a static header is a string - * the user typed, while an expanded one carries live process state to - * a third party. It also means a preset or extension that ships a - * `customHeaders` entry cannot turn it into an identity header behind - * the user's back — provenance is lost once presets and user settings - * are merged, so the gate is what distinguishes them. + * default). The gate controls whether `${session_id}` may be expanded + * from live process state; it cannot recover a header's provenance after + * settings are merged. * - A placeholder resolves to nothing (no session yet). * - `Config` cannot answer at all. * @@ -104,6 +101,7 @@ export function resolveDynamicHeaderValue( value: string, config: Config, ): string | undefined { + if (typeof value !== 'string') return undefined; if (!hasDynamicPlaceholder(value)) return value; try { if (!config.getOutboundAllowDynamicHeaderValues()) { @@ -170,7 +168,7 @@ export function expandDynamicHeaders( if (!customHeaders) return {}; const expanded: Record = {}; for (const [key, value] of Object.entries(customHeaders)) { - if (!hasDynamicPlaceholder(value)) continue; + if (typeof value !== 'string' || !hasDynamicPlaceholder(value)) continue; const resolved = resolveDynamicHeaderValue(value, config); if (resolved !== undefined) expanded[key] = resolved; } diff --git a/packages/core/src/utils/envVarResolver.test.ts b/packages/core/src/utils/envVarResolver.test.ts index 28d7184b684..a26aebc98d3 100644 --- a/packages/core/src/utils/envVarResolver.test.ts +++ b/packages/core/src/utils/envVarResolver.test.ts @@ -58,11 +58,14 @@ describe('resolveEnvVarsInString', () => { expect(result).toBe('Value is ${UNDEFINED_VAR}'); }); - it('preserves the runtime session ID placeholder', () => { - process.env['session_id'] = 'environment-session'; - - expect(resolveEnvVarsInString('${session_id}')).toBe('${session_id}'); - }); + it.each(['session_id', 'QWEN_CODE_SESSION_ID'])( + 'preserves the runtime session ID placeholder %s', + (name) => { + process.env[name] = 'environment-session'; + + expect(resolveEnvVarsInString(`\${${name}}`)).toBe(`\${${name}}`); + }, + ); describe('Qwen-internal secrets', () => { beforeEach(() => { diff --git a/packages/core/src/utils/envVarResolver.ts b/packages/core/src/utils/envVarResolver.ts index d4e08bfb5da..481c7e1c03c 100644 --- a/packages/core/src/utils/envVarResolver.ts +++ b/packages/core/src/utils/envVarResolver.ts @@ -34,7 +34,11 @@ export function resolveEnvVarsInString( const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; - if (match === '${session_id}' || isInternalSecretEnvVar(varName)) { + if ( + match === '${session_id}' || + match === '${QWEN_CODE_SESSION_ID}' || + isInternalSecretEnvVar(varName) + ) { return match; } if (customEnv && typeof customEnv[varName] === 'string') { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 0c246f76647..bbcebf23ea6 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -639,7 +639,7 @@ "default": false }, "allowDynamicHeaderValues": { - "description": "SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether live session state may be interpolated at all, and prevents a preset or extension from silently turning a shipped `customHeaders` entry into an identity header.", + "description": "SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether `${session_id}` may be expanded from live session state and does not identify which settings source supplied the header.", "type": "boolean", "default": false } From c7fbe88c2320ec75b65101e1fec49953bcebe88a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 8 Sep 2026 13:24:47 +0800 Subject: [PATCH 8/8] fix(core): gate session environment aliases --- .../src/core/outbound-dynamic-headers.test.ts | 33 ++++++++++++++++++- .../core/src/core/outbound-dynamic-headers.ts | 12 +++++-- .../core/src/utils/envVarResolver.test.ts | 19 ++++++----- packages/core/src/utils/envVarResolver.ts | 8 +++-- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/packages/core/src/core/outbound-dynamic-headers.test.ts b/packages/core/src/core/outbound-dynamic-headers.test.ts index 9b96e7bd6d0..7f1f29bb606 100644 --- a/packages/core/src/core/outbound-dynamic-headers.test.ts +++ b/packages/core/src/core/outbound-dynamic-headers.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; +import { resolveEnvVarsInString } from '../utils/envVarResolver.js'; import { applyDynamicHeaderValues, expandDynamicHeaders, @@ -28,13 +29,18 @@ function config({ describe('hasDynamicPlaceholder', () => { it.each([ '${session_id}', + '$session_id', + '${SESSION_ID}', + '${QWEN_CODE_SESSION_ID}', + '$QWEN_CODE_SESSION_ID', + '${qwen_code_session_id}', 'sess-${session_id}', '${session_id}-${session_id}', ])('detects a placeholder in %s', (value) => { expect(hasDynamicPlaceholder(value)).toBe(true); }); - it.each(['req-123', '', '$session_id', '${SESSION_ID}', '{session_id}'])( + it.each(['req-123', '', '$session_id_suffix', '{session_id}'])( 'leaves %j alone', (value) => { expect(hasDynamicPlaceholder(value)).toBe(false); @@ -58,6 +64,31 @@ describe('resolveDynamicHeaderValue', () => { ); }); + it.each([ + ['$session_id', 'session-1'], + ['${SESSION_ID}', 'session-1'], + ['$QWEN_CODE_SESSION_ID', 'session-1'], + ['${qwen_code_session_id}', 'session-1'], + ['sess=$QWEN_CODE_SESSION_ID', 'sess=session-1'], + ])('expands the session ID alias %s through the gate', (value, expected) => { + expect(resolveDynamicHeaderValue(value, config())).toBe(expected); + expect( + resolveDynamicHeaderValue(value, config({ allow: false })), + ).toBeUndefined(); + }); + + it('keeps runtime session environment values behind the consent gate', () => { + const value = resolveEnvVarsInString('sess=$QWEN_CODE_SESSION_ID', { + QWEN_CODE_SESSION_ID: 'ambient-session', + }); + + expect(value).toBe('sess=$QWEN_CODE_SESSION_ID'); + expect( + resolveDynamicHeaderValue(value, config({ allow: false })), + ).toBeUndefined(); + expect(resolveDynamicHeaderValue(value, config())).toBe('sess=session-1'); + }); + it('expands a placeholder embedded in a larger value, repeatedly', () => { expect( resolveDynamicHeaderValue('a-${session_id}-b-${session_id}', config()), diff --git a/packages/core/src/core/outbound-dynamic-headers.ts b/packages/core/src/core/outbound-dynamic-headers.ts index baa95ba1e3d..13663c277cc 100644 --- a/packages/core/src/core/outbound-dynamic-headers.ts +++ b/packages/core/src/core/outbound-dynamic-headers.ts @@ -32,6 +32,11 @@ const PLACEHOLDERS: ReadonlyArray<{ readonly resolve: (config: Config) => string | undefined; }> = [{ token: '${session_id}', resolve: (config) => config.getSessionId() }]; +// Normalize equivalent runtime session spellings before applying the consent +// gate so settings interpolation cannot turn one into an unguarded static value. +const SESSION_ID_PLACEHOLDER_PATTERN = + /\$(?:session_id|QWEN_CODE_SESSION_ID)(?!\w)|\$\{(?:session_id|QWEN_CODE_SESSION_ID)\}/gi; + /** * Reports a configured placeholder that the gate is currently refusing, * once per distinct header set, on the console. @@ -77,7 +82,7 @@ export function warnIfDynamicHeadersDisabled( /** True when `value` asks for at least one runtime-resolved placeholder. */ export function hasDynamicPlaceholder(value: string): boolean { - return PLACEHOLDERS.some(({ token }) => value.includes(token)); + return value.search(SESSION_ID_PLACEHOLDER_PATTERN) !== -1; } /** @@ -111,7 +116,10 @@ export function resolveDynamicHeaderValue( ); return undefined; } - let expanded = value; + let expanded = value.replace( + SESSION_ID_PLACEHOLDER_PATTERN, + '${session_id}', + ); for (const { token, resolve } of PLACEHOLDERS) { if (!expanded.includes(token)) continue; const resolved = resolve(config); diff --git a/packages/core/src/utils/envVarResolver.test.ts b/packages/core/src/utils/envVarResolver.test.ts index a26aebc98d3..61ac270902a 100644 --- a/packages/core/src/utils/envVarResolver.test.ts +++ b/packages/core/src/utils/envVarResolver.test.ts @@ -58,14 +58,17 @@ describe('resolveEnvVarsInString', () => { expect(result).toBe('Value is ${UNDEFINED_VAR}'); }); - it.each(['session_id', 'QWEN_CODE_SESSION_ID'])( - 'preserves the runtime session ID placeholder %s', - (name) => { - process.env[name] = 'environment-session'; - - expect(resolveEnvVarsInString(`\${${name}}`)).toBe(`\${${name}}`); - }, - ); + it.each([ + ['session_id', '${session_id}'], + ['session_id', '$session_id'], + ['QWEN_CODE_SESSION_ID', '${QWEN_CODE_SESSION_ID}'], + ['QWEN_CODE_SESSION_ID', '$QWEN_CODE_SESSION_ID'], + ['qwen_code_session_id', '${qwen_code_session_id}'], + ])('preserves the runtime session ID placeholder %s', (name, input) => { + process.env[name] = 'environment-session'; + + expect(resolveEnvVarsInString(input)).toBe(input); + }); describe('Qwen-internal secrets', () => { beforeEach(() => { diff --git a/packages/core/src/utils/envVarResolver.ts b/packages/core/src/utils/envVarResolver.ts index 481c7e1c03c..7e55cc33f76 100644 --- a/packages/core/src/utils/envVarResolver.ts +++ b/packages/core/src/utils/envVarResolver.ts @@ -17,7 +17,8 @@ import { isInternalSecretEnvVar } from './sanitize-child-env.js'; * value is baked into hook commands, URLs or MCP configs before child-env * sanitization ever applies. Their placeholders are preserved exactly like * an unset variable's. - * `${session_id}` is also reserved for per-request custom header expansion. + * Session-ID placeholders are also reserved for per-request custom header + * expansion, including bare, braced, and case-insensitive spellings. * * @param value - The string that may contain environment variable placeholders * @returns The string with environment variables resolved @@ -34,9 +35,10 @@ export function resolveEnvVarsInString( const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; + const normalizedVarName = varName.toUpperCase(); if ( - match === '${session_id}' || - match === '${QWEN_CODE_SESSION_ID}' || + normalizedVarName === 'SESSION_ID' || + normalizedVarName === 'QWEN_CODE_SESSION_ID' || isInternalSecretEnvVar(varName) ) { return match;