fix(executors): forward X-Session-ID/X-Title agent metadata headers (#7104)

* fix(executors): forward X-Session-ID/X-Title agent metadata headers (port from 9router#2413)

Custom agent clients (e.g. non-OpenCode providers) commonly send X-Session-ID and X-Title headers for upstream request tracking/attribution, but forwardOpencodeClientHeaders() only forwarded x-opencode-* keys plus User-Agent, silently dropping these for every client. Extends the existing case-insensitive allowlist forwarding path with x-session-id/x-title.

Reported-by: Atikur Rahman Chitholian (@chitholian) (https://github.com/decolua/9router/issues/2413)

* chore(changelog): move #2413 entry to changelog.d fragment

Consistency with the repo's canonical changelog.d/fixes/ workflow (avoids
merge-storm re-conflicts from editing CHANGELOG.md directly).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-16 14:13:26 -03:00
committed by GitHub
parent 39222525ea
commit 60448d4f31
3 changed files with 57 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(executors):** forward agent-supplied `X-Session-ID`/`X-Title` metadata headers to upstream providers — previously dropped for every client outside the `x-opencode-*` allowlist. (thanks @chitholian) (#7104)

View File

@@ -12,6 +12,15 @@ const OPENCODE_HEADER_KEYS = [
"x-opencode-client",
] as const;
/**
* Common agent-metadata headers used by non-OpenCode clients (custom agents/
* providers) for upstream request tracking and attribution. Forwarded the same
* way as the x-opencode-* set: case-insensitive lookup, client value wins.
* Added for 9router#2413 — these were previously dropped for every client
* outside the OpenCode allowlist.
*/
const AGENT_METADATA_HEADER_KEYS = ["x-session-id", "x-title"] as const;
/**
* Case-insensitive lookup for a header in a headers record.
*/
@@ -26,6 +35,8 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* 1. Forwards User-Agent from clientHeaders via `setUserAgentHeader()`
* 2. Forwards x-opencode-session, x-opencode-request, x-opencode-project,
* x-opencode-client headers (case-insensitive match)
* 3. Forwards x-session-id, x-title agent-metadata headers (case-insensitive
* match) — common conventions used by non-OpenCode agent clients (9router#2413)
*
* @param headers - The outbound headers record to mutate
* @param clientHeaders - The client-provided headers to forward from
@@ -60,6 +71,14 @@ export function forwardOpencodeClientHeaders(
}
}
// 2b. Forward agent-metadata headers (x-session-id, x-title) — 9router#2413
for (const headerName of AGENT_METADATA_HEADER_KEYS) {
const value = findHeader(clientHeaders, headerName);
if (value) {
headers[headerName] = value;
}
}
// 3. OpencodeExecutor-only: synthesize session/request id from fallback headers
if (options?.synthesizeRequestId && !headers["x-opencode-session"]) {
const sessionAffinity =

View File

@@ -116,6 +116,43 @@ describe("forwardOpencodeClientHeaders x-opencode-* headers", () => {
});
});
// ── agent metadata headers (X-Session-ID / X-Title) — 9router#2413 ─────────
// Non-OpenCode agent clients (e.g. custom providers) commonly send X-Session-ID
// and X-Title for upstream request tracking/attribution. These were previously
// dropped for every client outside the x-opencode-* allowlist.
describe("forwardOpencodeClientHeaders X-Session-ID / X-Title", () => {
it("forwards X-Session-ID from client headers", () => {
const headers = h();
const clientHeaders = { "X-Session-ID": "sess-xyz" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-xyz");
});
it("forwards X-Title from client headers", () => {
const headers = h();
const clientHeaders = { "X-Title": "My Agent" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-title"], "My Agent");
});
it("matches X-Session-ID / X-Title case-insensitively", () => {
const headers = h();
const clientHeaders = { "x-session-id": "sess-lower", "x-title": "lower title" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-lower");
assert.equal(headers["x-title"], "lower title");
});
it("still does NOT forward unrelated unknown headers", () => {
const headers = h();
const clientHeaders = { "X-Session-ID": "sess-1", "X-Random-Other": "nope" };
forwardOpencodeClientHeaders(headers, clientHeaders);
assert.equal(headers["x-session-id"], "sess-1");
assert.equal(headers["X-Random-Other"], undefined);
});
});
// ── synthesizeRequestId ─────────────────────────────────────────────────────
describe("forwardOpencodeClientHeaders synthesizeRequestId", () => {