Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2617,6 +2660,7 @@ export class Config {
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
sessionIdHeader: params.outboundCorrelation?.sessionIdHeader,
};
this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor),
Expand Down Expand Up @@ -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;
}
Expand Down
131 changes: 129 additions & 2 deletions packages/core/src/core/outbound-session-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@
*/

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,
SESSION_ID_HEADER,
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;
}

Expand Down Expand Up @@ -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({});
});
});
Loading
Loading