From aa25dca27c0e281e1731fe1c0b144d4963c2ebce Mon Sep 17 00:00:00 2001 From: Hex Sullivan Date: Tue, 4 Aug 2026 11:55:24 +0000 Subject: [PATCH 1/2] fix(claude): scope CLAUDE_CC_ENTRYPOINT to OAuth call sites only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects introduced when CLAUDE_CLI_USER_AGENT was made dynamic in #7: 1. CLAUDE_CLI_USER_AGENT was computed via claudeCliUserAgent() at module load, snapshotting CLAUDE_CC_ENTRYPOINT into the static registry constant used by getClaudeCliHeaders(). API-key connections (not OAuth) would then carry the sdk-cli User-Agent even though CLAUDE_CC_ENTRYPOINT is documented as OAuth-only. Fix: hardcode the static constant to always use "cli"; dynamic entrypoint stays in the OAuth call sites only. 2. mergeUpstreamExtraHeaders runs after the OAuth ccHeaders block sets User-Agent via claudeCliUserAgent(). Operator-configured upstream extra headers can include a custom User-Agent that overrides it, desynchronising cc_entrypoint from the actual wire value. Fix: reassert claudeCliUserAgent(CLAUDE_CODE_VERSION) after the merge for native Claude OAuth requests (provider=claude + hasClaudeOAuthToken). Tests: two new regression guards in claude-entrypoint.test.ts — one asserting CLAUDE_CLI_USER_AGENT is always "cli" regardless of env, one documenting the mergeUpstreamExtraHeaders override mechanism and the reassertion fix. --- open-sse/config/anthropicHeaders.ts | 5 +++- open-sse/executors/base.ts | 16 +++++++----- tests/unit/claude-entrypoint.test.ts | 37 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index b782ac05593c..76a8a3e8e902 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -163,6 +163,9 @@ export function claudeCliUserAgent(version: string): string { return `claude-cli/${version} (external, ${getClaudeEntrypoint()})`; } -export const CLAUDE_CLI_USER_AGENT = claudeCliUserAgent(CLAUDE_CLI_VERSION); +// Static registry constant — always "cli" regardless of CLAUDE_CC_ENTRYPOINT. +// getClaudeCliHeaders() uses this for API-key connections; the dynamic entrypoint +// only applies to native Claude OAuth call sites (see base.ts ccHeaders block). +export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0"; export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0"; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index e0378e9e175a..10ca4ef9b4ff 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -939,14 +939,11 @@ export class BaseExecutor { const seed = activeCredentials?.accessToken || activeCredentials?.apiKey || "anon"; const psd = activeCredentials?.providerSpecificData as - | Record - | undefined; + Record | undefined; let identitySource: - | "upstream-metadata" - | "upstream-header" - | "synthesized" - | "synthesized-cloaked" = "synthesized"; + "upstream-metadata" | "upstream-header" | "synthesized" | "synthesized-cloaked" = + "synthesized"; let sessionId: string; let deviceId: string; let accountUUID: string; @@ -1181,6 +1178,13 @@ export class BaseExecutor { } mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); + // Reassert the configured OAuth billing UA after extra-headers merge. + // Operators can set a custom User-Agent in upstreamExtraHeaders, which + // mergeUpstreamExtraHeaders will apply — but for native Claude OAuth the + // User-Agent must match cc_entrypoint to keep billing header + UA in sync. + if (this.provider === "claude" && hasClaudeOAuthToken) { + finalHeaders["User-Agent"] = claudeCliUserAgent(CLAUDE_CODE_VERSION); + } const serializedBody = prl.parseBody(bodyString); // #4307 — Preserve the non-enumerable tool-name cloak/remap reverse map // (`_toolNameMap`, set on the live `transformedBody` by diff --git a/tests/unit/claude-entrypoint.test.ts b/tests/unit/claude-entrypoint.test.ts index 0e7e506ceec5..f2b2b493409a 100644 --- a/tests/unit/claude-entrypoint.test.ts +++ b/tests/unit/claude-entrypoint.test.ts @@ -3,7 +3,10 @@ import assert from "node:assert/strict"; import { getClaudeEntrypoint, claudeCliUserAgent, + CLAUDE_CLI_USER_AGENT, + CLAUDE_CLI_VERSION, } from "../../open-sse/config/anthropicHeaders.ts"; +import { mergeUpstreamExtraHeaders } from "../../open-sse/executors/base/headers.ts"; const ORIGINAL = process.env.CLAUDE_CC_ENTRYPOINT; @@ -50,3 +53,37 @@ test("getClaudeEntrypoint falls back to cli on an invalid value", () => { assert.equal(claudeCliUserAgent("2.1.158"), "claude-cli/2.1.158 (external, cli)"); }); }); + +// Regression guard: CLAUDE_CLI_USER_AGENT is used in getClaudeCliHeaders() for +// API-key connections. It must always be "cli" — never reflect CLAUDE_CC_ENTRYPOINT, +// which is an OAuth-only billing identity knob. Changing this to use claudeCliUserAgent() +// (dynamic) would leak the sdk-cli entrypoint into non-OAuth credential surfaces. +test("CLAUDE_CLI_USER_AGENT is always cli regardless of CLAUDE_CC_ENTRYPOINT", () => { + assert.equal(CLAUDE_CLI_USER_AGENT, `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`); + assert.ok( + !CLAUDE_CLI_USER_AGENT.includes("sdk-cli"), + "static registry UA must never carry sdk-cli" + ); +}); + +// Regression guard: mergeUpstreamExtraHeaders can override User-Agent with a custom +// value set by the operator. For native Claude OAuth, the cc_entrypoint and User-Agent +// must remain in sync — so the dynamic claudeCliUserAgent() must be reasserted after +// the extra-headers merge (see base.ts). This test documents the override mechanism. +test("mergeUpstreamExtraHeaders can override User-Agent; claudeCliUserAgent reassertion restores OAuth UA", () => { + const version = "4.0.0"; + const headers: Record = { + "User-Agent": claudeCliUserAgent(version), + }; + const expected = `claude-cli/${version} (external, ${getClaudeEntrypoint()})`; + assert.equal(headers["User-Agent"], expected); + + // Operator sets a custom User-Agent via upstream extra headers — mergeUpstreamExtraHeaders + // will apply it, overriding the OAuth billing UA. + mergeUpstreamExtraHeaders(headers, { "User-Agent": "custom-proxy/1.0" }); + assert.equal(headers["User-Agent"], "custom-proxy/1.0"); + + // The fix in base.ts reasserts claudeCliUserAgent after the merge for claude+OAuth paths. + headers["User-Agent"] = claudeCliUserAgent(version); + assert.equal(headers["User-Agent"], expected, "reassertion restores the OAuth billing UA"); +}); From 1fed58ffc53b5e4c80d01b9c9faff4a57cf5ec78 Mon Sep 17 00:00:00 2001 From: Hex Sullivan Date: Tue, 4 Aug 2026 12:17:18 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Preserve=20explicit=20OAuth?= =?UTF-8?q?=20User-Agent=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- open-sse/executors/base.ts | 8 +------- tests/unit/claude-entrypoint.test.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 10ca4ef9b4ff..7ff12e8b09a3 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1178,13 +1178,7 @@ export class BaseExecutor { } mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); - // Reassert the configured OAuth billing UA after extra-headers merge. - // Operators can set a custom User-Agent in upstreamExtraHeaders, which - // mergeUpstreamExtraHeaders will apply — but for native Claude OAuth the - // User-Agent must match cc_entrypoint to keep billing header + UA in sync. - if (this.provider === "claude" && hasClaudeOAuthToken) { - finalHeaders["User-Agent"] = claudeCliUserAgent(CLAUDE_CODE_VERSION); - } + const serializedBody = prl.parseBody(bodyString); // #4307 — Preserve the non-enumerable tool-name cloak/remap reverse map // (`_toolNameMap`, set on the live `transformedBody` by diff --git a/tests/unit/claude-entrypoint.test.ts b/tests/unit/claude-entrypoint.test.ts index f2b2b493409a..550d2c53dce3 100644 --- a/tests/unit/claude-entrypoint.test.ts +++ b/tests/unit/claude-entrypoint.test.ts @@ -66,11 +66,9 @@ test("CLAUDE_CLI_USER_AGENT is always cli regardless of CLAUDE_CC_ENTRYPOINT", ( ); }); -// Regression guard: mergeUpstreamExtraHeaders can override User-Agent with a custom -// value set by the operator. For native Claude OAuth, the cc_entrypoint and User-Agent -// must remain in sync — so the dynamic claudeCliUserAgent() must be reasserted after -// the extra-headers merge (see base.ts). This test documents the override mechanism. -test("mergeUpstreamExtraHeaders can override User-Agent; claudeCliUserAgent reassertion restores OAuth UA", () => { +// Upstream headers are an explicit operator override. OAuth uses the dynamic default +// UA, while mergeUpstreamExtraHeaders keeps its documented last-writer-wins behavior. +test("mergeUpstreamExtraHeaders preserves an explicit User-Agent override", () => { const version = "4.0.0"; const headers: Record = { "User-Agent": claudeCliUserAgent(version), @@ -83,7 +81,5 @@ test("mergeUpstreamExtraHeaders can override User-Agent; claudeCliUserAgent reas mergeUpstreamExtraHeaders(headers, { "User-Agent": "custom-proxy/1.0" }); assert.equal(headers["User-Agent"], "custom-proxy/1.0"); - // The fix in base.ts reasserts claudeCliUserAgent after the merge for claude+OAuth paths. - headers["User-Agent"] = claudeCliUserAgent(version); - assert.equal(headers["User-Agent"], expected, "reassertion restores the OAuth billing UA"); + assert.equal(headers["User-Agent"], "custom-proxy/1.0"); });