diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md index fe47c0b3ae1..e5c2b1f38da 100644 --- a/docs-site/src/content/docs/guides/protocol-paths.md +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -144,6 +144,11 @@ These paths still use the internal Responses bridge or are not covered: - `previous_response_id`, `store`, `background`, and compaction stay Responses features. - Adapters whose wire is none of the three APIs (Gemini, Kiro, Cursor, and others) are translated through the IR with no feature claims. +- Managed native Messages to first-party Anthropic preserve a coherent observed Claude Code CLI + user agent, session UUID and allowlisted SDK headers across token refresh and account selection. + Credential placement remains proxy-owned and beta allowlisting still applies. Missing or invalid + identity uses the existing compatibility headers; compatible hosts and generated Responses retain + their current behavior. Headers alone do not authorize requests or establish official support. - Native Chat over OAuth is not planned. Native Messages over Anthropic OAuth covers only an unpooled account; a pooled account set stays on the bridge. - The managed native Messages path forwards a caller's `anthropic-beta` values only from a short diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index a919cf46fd9..e9fc0cde0ca 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -580,7 +580,7 @@ modes, the preview, and the per-request trace. | `protocols.unrepresentable?` | `"legacy" \| "reject"` | `"legacy"` | `legacy` sends a request whose path drops a feature and records the loss in the trace. `reject` refuses it with HTTP 400 before any send, naming only the feature keys. | | `protocols.rollout.nativeChatCombos?` | `boolean` | `false` | Send an eligible Chat candidate inside a combo natively from its own copy of the client body. | | `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. | -| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. | +| `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Read as off unless `managedMessagesNative` is on; a pooled account set stays on the bridge. Recognized JSON-string `metadata.user_id` account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved. | | `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. | | `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dce0e7d90da..5edc547276d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -122,6 +122,8 @@ "always-on-429-failover.test.ts": "routing", "ambiguous-resend-composition.test.ts": "lib", "ambiguous-resend-gate.test.ts": "lib", + "anthropic-client-identity.test.ts": "adapters/anthropic", + "anthropic-account-metadata.test.ts": "adapters/anthropic", "anthropic-account-pool.test.ts": "adapters/anthropic", "anthropic-account-pause-outbound.test.ts": "adapters/anthropic", "anthropic-account-pause.test.ts": "adapters/anthropic", diff --git a/src/adapters/anthropic/account-metadata.ts b/src/adapters/anthropic/account-metadata.ts new file mode 100644 index 00000000000..7eab3fd868b --- /dev/null +++ b/src/adapters/anthropic/account-metadata.ts @@ -0,0 +1,17 @@ +/** Serving-account alignment for observed JSON-string Claude metadata. No body-wide matching. */ +const PROVIDER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const MAX_USER_ID_BYTES = 4096; +type Rec = Record; +const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value); + +/** Unknown formats stay untouched; the argument is the provider UUID, never the local pool id. */ +export function bindAnthropicAccountMetadata(body: Rec, providerAccountUuid: string | undefined): Rec { + if (!providerAccountUuid || !PROVIDER_UUID.test(providerAccountUuid) || !isRec(body.metadata)) return body; + const userId = body.metadata.user_id; + if (typeof userId !== "string" || Buffer.byteLength(userId) > MAX_USER_ID_BYTES) return body; + let identity: unknown; + try { identity = JSON.parse(userId); } catch { return body; } + if (!isRec(identity) || typeof identity.account_uuid !== "string" || !PROVIDER_UUID.test(identity.account_uuid) + || identity.account_uuid === providerAccountUuid) return body; + return { ...body, metadata: { ...body.metadata, user_id: JSON.stringify({ ...identity, account_uuid: providerAccountUuid }) } }; +} diff --git a/src/adapters/anthropic/client-identity.ts b/src/adapters/anthropic/client-identity.ts new file mode 100644 index 00000000000..b224bdd4b88 --- /dev/null +++ b/src/adapters/anthropic/client-identity.ts @@ -0,0 +1,69 @@ +/** Request-local compatibility data. Client headers never grant authorization or credential authority. */ +import { classifyInterceptClient } from "../../claude/intercept/client-class"; + +const MAX_IDENTITY_BYTES = 4096; +const MAX_VALUE_BYTES = 512; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SDK_TOKEN = /^[A-Za-z0-9._+-]{1,128}$/; +const NAMES = [ + "User-Agent", "X-App", "X-Claude-Code-Session-Id", "x-client-request-id", + "X-Stainless-Lang", "X-Stainless-Runtime", "X-Stainless-Runtime-Version", + "X-Stainless-Package-Version", "X-Stainless-OS", "X-Stainless-Arch", + "X-Stainless-Retry-Count", "X-Stainless-Timeout", +] as const; +const REQUIRED = ["User-Agent", "X-App", "X-Claude-Code-Session-Id", "X-Stainless-Lang", "X-Stainless-Runtime"] as const; +declare const identityBrand: unique symbol; +/** Opaque in-memory handle: serializing it cannot publish caller headers. */ +export interface AnthropicClientIdentity { readonly [identityBrand]: true } +const bundles = new WeakMap>>(); + +function valid(name: typeof NAMES[number], value: string): boolean { + if (!value || Buffer.byteLength(value) > MAX_VALUE_BYTES || /[^\x20-\x7e]/.test(value)) return false; + if (name === "User-Agent") return classifyInterceptClient(value) === "cli"; + if (name === "X-App") return value === "cli"; + if (name === "X-Claude-Code-Session-Id" || name === "x-client-request-id") return UUID.test(value); + if (name === "X-Stainless-Lang") return value === "js"; + if (name === "X-Stainless-Runtime") return value === "node"; + if (name === "X-Stainless-Retry-Count" || name === "X-Stainless-Timeout") return /^\d{1,6}$/.test(value); + return SDK_TOKEN.test(value); +} + +/** Select only a coherent observed CLI bundle; a UA alone does not select this compatibility lane. */ +export function captureAnthropicClientIdentity(headers: Headers): AnthropicClientIdentity | undefined { + const connection = headers.get("connection") ?? ""; + if (Buffer.byteLength(connection) > MAX_VALUE_BYTES) return undefined; + const hopNames = new Set(connection.toLowerCase().split(",").map(name => name.trim())); + const selected: Record = {}; + let bytes = 0; + for (const name of NAMES) { + const value = headers.get(name); + if (value === null) continue; + // Headers joins duplicate occurrences. Every scalar validator rejects joined values; + // the anchored CLI UA parser also rejects a second appended user agent. + if (hopNames.has(name.toLowerCase()) || !valid(name, value)) { + if ((REQUIRED as readonly string[]).includes(name)) return undefined; + continue; + } + bytes += Buffer.byteLength(name) + Buffer.byteLength(value); + if (bytes > MAX_IDENTITY_BYTES) return undefined; + selected[name] = value; + } + if (REQUIRED.some(name => selected[name] === undefined)) return undefined; + const handle = Object.freeze({}) as AnthropicClientIdentity; + bundles.set(handle, Object.freeze(selected)); + return handle; +} + +/** Called only after the builder verifies a first-party destination. Unknown/forged handles do nothing. */ +export function applyAnthropicClientIdentity(headers: Record, identity: AnthropicClientIdentity | undefined, operatorHeaders?: Readonly>): void { + const values = identity && bundles.get(identity); + if (!values) return; + const configured = new Set(Object.keys(operatorHeaders ?? {}).map(name => name.toLowerCase())); + for (const [name, value] of Object.entries(values)) { + if (configured.has(name.toLowerCase())) continue; + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing]; + } + headers[name] = value; + } +} diff --git a/src/adapters/anthropic/passthrough.ts b/src/adapters/anthropic/passthrough.ts index 7e226144d97..00af4870fcc 100644 --- a/src/adapters/anthropic/passthrough.ts +++ b/src/adapters/anthropic/passthrough.ts @@ -8,8 +8,8 @@ * * Authority: only the provider's own credential is ever placed on the request — a configured * key, or (PF-10) the access token of the OAuth account the lane resolved, which is sent only to - * `api.anthropic.com`. The one caller header this builder sees is `anthropic-beta`, handed over - * explicitly and reduced to `beta-allowlist.ts`; no other caller header is read here at all. The + * `api.anthropic.com`. Caller betas are reduced to `beta-allowlist.ts`; a bounded opaque + * CLI identity handle carries only observed compatibility headers for first-party destinations. The * caller-forward passthrough in `src/server/claude-messages.ts` is the only place a caller's * Anthropic credential may travel, and it does not come through here. * @@ -27,6 +27,8 @@ import { applyAnthropicOAuthAuth, resolveAnthropicMessagesUrl, } from "../anthropic"; +import { applyAnthropicClientIdentity, type AnthropicClientIdentity } from "./client-identity"; +import { bindAnthropicAccountMetadata } from "./account-metadata"; import { allowlistAnthropicBetas } from "./beta-allowlist"; /** @@ -75,6 +77,10 @@ export interface AnthropicMessagesPassthroughRequest { export interface AnthropicMessagesPassthroughOptions { /** The caller's `anthropic-beta` header, handed over by the ingress. */ callerAnthropicBeta?: string | null; + /** Provider UUID captured with the serving OAuth credential; never a local account slot id. */ + providerAccountUuid?: string; + /** Internal request-local handle; never part of the serialized Messages body. */ + clientIdentity?: AnthropicClientIdentity; } /** The allowlisted copy of `body` with `model` set to the wire model. Shallow: nothing is cloned. */ @@ -201,12 +207,25 @@ export function buildAnthropicMessagesPassthroughRequest( : "anthropic provider requires a non-empty apiKey (authMode: key)"); } const url = resolveAnthropicMessagesUrl(provider); - const { wireBody, strippedOpaqueState, oauthToolNames } = anthropicMessagesNativeWireBody(provider, modelId, body); + const native = anthropicMessagesNativeWireBody(provider, modelId, body); + const { strippedOpaqueState, oauthToolNames } = native; + const wireBody = oauth ? bindAnthropicAccountMetadata(native.wireBody, options.providerAccountUuid) : native.wireBody; const headers = anthropicBaseRequestHeaders(wireBody.stream === true); if (oauth) applyAnthropicOAuthAuth(headers, provider.apiKey); else applyAnthropicKeyAuth(headers, provider); // Operator-configured provider headers apply exactly as the adapter applies them. - if (provider.headers) Object.assign(headers, provider.headers); + if (provider.headers) { + const configured = new Set(Object.keys(provider.headers).map(name => name.toLowerCase())); + for (const name of Object.keys(headers)) if (configured.has(name.toLowerCase())) delete headers[name]; + Object.assign(headers, provider.headers); + } + if (oauth) { + const wireHeaders = new Headers(headers); + if (wireHeaders.get("authorization") !== `Bearer ${provider.apiKey}` || wireHeaders.has("x-api-key")) { + throw new Error("native OAuth serving credential was overridden by provider headers"); + } + } + if (domain?.firstPartyAnthropic) applyAnthropicClientIdentity(headers, options.clientIdentity, provider.headers); const betas = allowlistAnthropicBetas(options.callerAnthropicBeta, domain?.firstPartyAnthropic ? "first-party" : "compatible"); mergeAnthropicBetaHeader(headers, betas.betas); return { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 476dd3463f0..b0753e3ef12 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -85,6 +85,7 @@ import { requestPathForLane } from "../protocols/path"; import { resolveApiSurfaceSettings, resolveProtocolSettings } from "../protocols/settings"; import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace"; import { recordProtocolShadowPlan } from "../protocols/shadow-plan"; +import { captureAnthropicClientIdentity } from "../adapters/anthropic/client-identity"; import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility"; import { isApiAuthRequired, @@ -1202,7 +1203,8 @@ async function handleClaudeMessagesWithBudget( return await handleNativeMessages({ req, config, logCtx, ...(logIds ? { logIds } : {}), route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector, - // The one caller header the native lane is given; the builder allowlists it. + // Compatibility identity is an opaque request-local handle, separate from credentials. + clientIdentity: captureAnthropicClientIdentity(req.headers), callerAnthropicBeta: req.headers.get("anthropic-beta"), }); } diff --git a/src/server/messages-native-oauth.ts b/src/server/messages-native-oauth.ts index d6b5f3faa5b..47b67f28b36 100644 --- a/src/server/messages-native-oauth.ts +++ b/src/server/messages-native-oauth.ts @@ -55,6 +55,8 @@ function isRec(value: unknown): value is Rec { export interface NativeOAuthBinding { readonly selection: Selection; readonly snapshot: OAuthAccessSnapshot; + /** Anthropic account UUID from the same stored generation, distinct from snapshot.accountId. */ + readonly providerAccountUuid?: string; } /** The selection moved or became pooled while it was being resolved. Maps to a 409 retry. */ @@ -95,7 +97,9 @@ export async function resolveNativeOAuthBinding(config: OcxConfig): Promise Date.now() && !getAnthropicAccountHealthSnapshot(binding.snapshot.accountId) - && credentialGeneration(row.credential) === binding.snapshot.generation; + && credentialGeneration(row.credential) === binding.snapshot.generation + && row.credential.accountId === binding.providerAccountUuid; } /** Map a `tool_use` block's wire name back to the caller's name; other blocks are untouched. */ diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index b593af2d1b4..89ad5a431e5 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -10,8 +10,8 @@ * key selection, key failover and 429 replay, connection policy — so nothing the Responses * pipeline enforces is bypassed. * - * Authority. This lane reads no caller header. The ingress hands over one value, the caller's - * `anthropic-beta`, which the builder reduces to an allowlist. The caller-forward passthrough in + * Authority. The ingress hands over caller `anthropic-beta` for allowlisting and an opaque + * observed CLI identity handle for first-party compatibility. Neither grants credential authority. The caller-forward passthrough in * `claude-messages.ts` (the caller's own Anthropic credential) is a different branch decided * before this one, and nothing here can reach it or be reached from it. * @@ -67,6 +67,7 @@ import type { OcxProviderTransport } from "../providers/xai-transport"; import { preservesPhysicalComboProvider, resolveComboId } from "../combos"; import { captureRouteStaticPolicy, routeModel, type RouteResult } from "../router"; import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile"; +import type { AnthropicClientIdentity } from "../adapters/anthropic/client-identity"; import type { OcxConfig, OcxProviderConfig, OcxUsage } from "../types"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import { @@ -145,9 +146,10 @@ export interface HandleNativeMessagesOptions { selector?: NativeMessagesSelector; /** * The caller's `anthropic-beta` header, handed over by the ingress. The builder keeps only - * allowlisted values; no other caller header reaches this lane. + * allowlisted values. Client identity is a separate bounded opaque handle. */ callerAnthropicBeta?: string | null; + clientIdentity?: AnthropicClientIdentity; } type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void; @@ -410,6 +412,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) recordAttemptCredentialSource(attempt, route.providerName, activeProvider, "anthropic"); const built = buildAnthropicMessagesPassthroughRequest(activeProvider, route.modelId, body, config, { callerAnthropicBeta: options.callerAnthropicBeta, + providerAccountUuid: oauthBinding?.providerAccountUuid, + clientIdentity: options.clientIdentity, }); if (built.strippedOpaqueState && rejectUnrepresentable) throw new NativeOpaqueStateRefusal(); if (built.droppedBetas) addProtocolEntryReason(logCtx, "anthropic-beta-dropped"); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index de920493e53..8e33465978b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -430,3 +430,7 @@ Unicode pattern normalization uses [copy-on-write traversal](../transports/byte- Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). The [compaction routing override](../transports/responses-failover.md#compaction-routing-overrides) requires original Responses ingress; translated Chat and Messages calls retain their own routing. + +Managed native Anthropic OAuth metadata follows [the native Messages binding contract](protocol-paths.md#managed-native-messages): the serving credential's provider UUID replaces only recognized account metadata, with each attempt rebuilt from the source. + +Managed native Messages retain a coherent observed CLI identity bundle only for first-party Anthropic; [native Messages](protocol-paths.md#managed-native-messages) owns its bounds and credential separation. Header identity never selects an account or authorizes a request. diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 3f11882f0e5..3206e5acfe8 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -295,8 +295,8 @@ placement the Anthropic adapter uses (`resolveAnthropicMessagesUrl`, `anthropicB builder never mutates its input. A dropped field has no name in the feature vocabulary, so it records no feature effect. -Caller betas. The only caller header the lane receives is `anthropic-beta`, which the ingress -hands over explicitly; `Authorization` and `x-api-key` never reach the provider. +Caller betas. The ingress hands over `anthropic-beta` explicitly; caller `Authorization` and +`x-api-key` never reach the managed provider. `src/adapters/anthropic/beta-allowlist.ts` keeps a value only when it is on the list for the destination's class and re-emits it in the list's own spelling: `interleaved-thinking-2025-05-14` for `api.anthropic.com`, nothing for an Anthropic-compatible host. Proxy-owned betas (the OAuth @@ -314,6 +314,26 @@ dropped, a message left empty dropped) and the trace gains `opaque-state-strippe fails closed. Because the source body is never mutated, each build — including one after a key re-selection moves to another domain — decides from the full envelope copy the lane was given. +Client identity. `src/adapters/anthropic/client-identity.ts` captures a bounded observed CLI bundle +on an opaque request-local handle: CLI-class UA plus valid session UUID, `X-App: cli`, JS and Node +SDK markers are required. Optional allowlisted SDK and request-id fields retain valid scalars. +Duplicates, oversized/invalid values and headers named by `Connection` cannot gain forwarding +authority. The handle stores its headers privately in a WeakMap and serializes without them. +The native builder applies it only for first-party Anthropic, independently of bearer/UUID +selection. Operator `provider.headers` names take precedence case-insensitively over the observed +identity bundle without duplicate spelling; unconfigured names retain the observed client values. +Caller credentials, proxy/hop headers, arbitrary SDK names and betas are never part of the bundle. +These are compatibility observations, not authorization or proof of client provenance. Missing +identity, generated Responses, caller-forward and compatible destinations keep their contracts. +The accepted native first-party behavior preserves one genuine Claude Code session id and its +metadata device/session components across token refresh and an eligible unpooled account switch, +matching a genuine client on a manual account switch. Consequently, accounts serving that session +are linkable upstream. Pooled accounts stay on the Responses bridge described below. Traffic without +a genuine client identity retains per-credential synthesized session ids. +`tests/adapters/anthropic/anthropic-client-identity.test.ts` and +`tests/claude-integration/messages-native-oauth.test.ts` cover header continuity through refresh +and account switch, destination isolation, bounded parsing and credential exclusion. + OAuth. Behind `managedMessagesNativeOAuth`, which `resolveProtocolSettings` treats as off unless `managedMessagesNative` is on. Only the `anthropic` provider the OAuth store serves, only to `api.anthropic.com` (the builder refuses any other host for an OAuth token), and only an unpooled @@ -325,7 +345,14 @@ dispatch by the same steps that transport takes for an unpooled route (capture t resolve the active snapshot, commit against the capture) and re-checks the binding before every physical send, re-resolving through the same owner if it moved. Planning and `count_tokens` read config and the read-only account set only; nothing selects, refreshes or writes. The body gets the -Claude Code identity block and declared client tool names under the OAuth prefix; the answer's +Claude Code identity block and declared client tool names under the OAuth prefix. +`src/adapters/anthropic/account-metadata.ts` copy-on-write aligns a valid JSON-string +`metadata.user_id.account_uuid` with the provider UUID captured alongside the native binding. +The local pool id is never used; malformed, absent and unknown metadata stays unchanged. +Every rebuild starts from the source body; the binding also checks UUID equality before send. +Conflicting provider credential headers fail before dispatch on every OAuth build, including +builds without a provider UUID. +Key-auth and caller-forward requests retain their metadata. The answer's `tool_use` names are mapped back for exactly those names. A 401 or 429 is answered as the bridge answers an unpooled account: no refresh replay, no same-token replay, no rotation. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 529c10f51fc..c3b48f895ee 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -2,6 +2,8 @@ Anthropic account pause, model routes, and quota labels follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. +Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. + Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). An Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 3c592e7bb1a..7ba6043f666 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -375,3 +375,5 @@ is left to the HTTP agent, which may pool or destroy it. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). The [compaction routing override](responses-failover.md#compaction-routing-overrides) selects a target before the existing native compact or routed Responses transport is resolved. + +First-party managed native Messages retain the [serving UUID and observed CLI identity contract](../data-planes/protocol-paths.md#managed-native-messages). Identity headers do not authorize credentials or extend their destination scope. diff --git a/tests/adapters/anthropic/anthropic-account-metadata.test.ts b/tests/adapters/anthropic/anthropic-account-metadata.test.ts new file mode 100644 index 00000000000..812610e59a2 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-account-metadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { bindAnthropicAccountMetadata } from "../../../src/adapters/anthropic/account-metadata"; +import { buildAnthropicMessagesPassthroughRequest } from "../../../src/adapters/anthropic/passthrough"; +import type { OcxProviderConfig } from "../../../src/types"; + +const A = "11111111-1111-4111-8111-111111111111"; +const B = "22222222-2222-4222-8222-222222222222"; +const identity = { device_id: "fixture-device", session_id: "fixture-session", account_uuid: A }; +const source = { + metadata: { user_id: JSON.stringify(identity), extra: "fixture" }, + messages: [ + { role: "user", content: `account_uuid: ${A}` }, + { role: "assistant", content: [{ type: "tool_use", name: "fixture", id: "toolu_fixture", input: { account_uuid: A } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_fixture", content: { account_uuid: A } }] }, + ], + tools: [{ name: "fixture", input_schema: { account_uuid: A } }], +}; +const provider = { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com", apiKey: "fixture-access" } as OcxProviderConfig; + +describe("Anthropic serving-account metadata", () => { + test("rewrites only JSON-string metadata and preserves device, session, text and tool payloads", () => { + const before = JSON.stringify(source); + const result = bindAnthropicAccountMetadata(source, B); + expect(JSON.parse((result.metadata as typeof source.metadata).user_id)).toEqual({ ...identity, account_uuid: B }); + expect(result.metadata).not.toBe(source.metadata); + expect(result.messages).toBe(source.messages); + expect(result.tools).toBe(source.tools); + expect(JSON.stringify(source)).toBe(before); + }); + test("each A to B retry starts from the same immutable source", () => { + const a = buildAnthropicMessagesPassthroughRequest(provider, "m", source, undefined, { providerAccountUuid: A }); + const b = buildAnthropicMessagesPassthroughRequest({ ...provider, apiKey: "fixture-b-access" }, "m", source, undefined, { providerAccountUuid: B }); + expect(a.headers.Authorization).toBe("Bearer fixture-access"); + expect(b.headers.Authorization).toBe("Bearer fixture-b-access"); + expect(JSON.parse((a.wireBody.metadata as typeof source.metadata).user_id).account_uuid).toBe(A); + expect(JSON.parse((b.wireBody.metadata as typeof source.metadata).user_id).account_uuid).toBe(B); + expect(JSON.parse(source.metadata.user_id).account_uuid).toBe(A); + }); + test("absent, malformed, unsupported and oversized metadata stay unchanged", () => { + for (const metadata of [undefined, null, [], { user_id: {} }, { user_id: "{" }, + { user_id: "fixture-legacy-unobserved" }, { user_id: "[]" }, + { user_id: JSON.stringify({ device_id: "fixture" }) }, + { user_id: JSON.stringify({ account_uuid: 42 }) }, + { user_id: JSON.stringify({ account_uuid: "not-a-uuid" }) }, + { user_id: JSON.stringify({ account_uuid: A, device_id: "x".repeat(4096) }) }]) { + const body = { metadata }; + expect(bindAnthropicAccountMetadata(body, B)).toBe(body); + } + }); + test("unknown provider UUID and local slot ids are never used", () => { + for (const uuid of [undefined, "", "anthropic:local-slot", "a".repeat(64)]) { + expect(bindAnthropicAccountMetadata(source, uuid)).toBe(source); + } + expect(bindAnthropicAccountMetadata(source, A)).toBe(source); + }); + test("a credential override cannot send metadata bound to another bearer", () => { + for (const headers of [{ Authorization: "Bearer fixture-other" }, { authorization: "Bearer fixture-other" }, { "x-api-key": "fixture-other" }]) { + expect(() => buildAnthropicMessagesPassthroughRequest({ ...provider, headers }, "m", source, undefined, { providerAccountUuid: B })).toThrow("serving credential was overridden"); + } + }); + test("OAuth rejects provider credential overrides without a provider UUID", () => { + for (const headers of [{ Authorization: "Bearer fixture-other" }, { authorization: "Bearer fixture-other" }, { "X-API-Key": "fixture-other" }]) { + expect(() => buildAnthropicMessagesPassthroughRequest({ ...provider, headers }, "m", source)).toThrow("serving credential was overridden"); + } + expect(buildAnthropicMessagesPassthroughRequest(provider, "m", source).headers.Authorization).toBe("Bearer fixture-access"); + }); + test("key-auth first-party and compatible hosts preserve caller metadata", () => { + for (const baseUrl of ["https://api.anthropic.com", "https://anthropic.example"]) { + const built = buildAnthropicMessagesPassthroughRequest({ ...provider, authMode: "key", baseUrl }, "m", source, undefined, { providerAccountUuid: B }); + expect(built.wireBody.metadata).toBe(source.metadata); + } + expect(() => buildAnthropicMessagesPassthroughRequest({ ...provider, baseUrl: "https://anthropic.example" }, "m", source, undefined, { providerAccountUuid: B })).toThrow(); + }); +}); diff --git a/tests/adapters/anthropic/anthropic-client-identity.test.ts b/tests/adapters/anthropic/anthropic-client-identity.test.ts new file mode 100644 index 00000000000..f5d5483eb34 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-client-identity.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { captureAnthropicClientIdentity, type AnthropicClientIdentity } from "../../../src/adapters/anthropic/client-identity"; +import { buildAnthropicMessagesPassthroughRequest } from "../../../src/adapters/anthropic/passthrough"; +import { createAnthropicAdapter } from "../../../src/adapters/anthropic"; +import { claudeCodeSessionId } from "../../../src/adapters/client-fingerprint"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +const SESSION = "11111111-1111-4111-8111-111111111111"; +const OTHER = "22222222-2222-4222-8222-222222222222"; +const UA = "claude-cli/2.1.282 (external, cli)"; +const observed = { + "User-Agent": UA, "X-App": "cli", "X-Claude-Code-Session-Id": SESSION, + "X-Stainless-Lang": "js", "X-Stainless-Runtime": "node", + "X-Stainless-Package-Version": "9.9.9", "X-Stainless-Runtime-Version": "22.1.0", + "X-Stainless-OS": "fixture-os", "X-Stainless-Arch": "fixture-arch", + "X-Stainless-Retry-Count": "1", "X-Stainless-Timeout": "420", + "x-client-request-id": OTHER, +}; +const provider = { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com", apiKey: "fixture-a-access" } as OcxProviderConfig; +const body = { messages: [{ role: "user", content: "fixture" }], max_tokens: 64 }; +const build = (clientIdentity: AnthropicClientIdentity | undefined, overrides: Partial = {}) => + buildAnthropicMessagesPassthroughRequest({ ...provider, ...overrides }, "m", body, undefined, { clientIdentity }); + +describe("observed Claude Code identity", () => { + test("account switch and refresh change only serving auth, not genuine session headers", () => { + const identity = captureAnthropicClientIdentity(new Headers(observed)); + expect(identity).toBeDefined(); + for (const apiKey of ["fixture-a-access", "fixture-b-access", "fixture-refreshed-access"]) { + const built = build(identity, { apiKey }); + for (const [name, value] of Object.entries(observed)) expect(new Headers(built.headers).get(name)).toBe(value); + expect(new Headers(built.headers).get("authorization")).toBe(`Bearer ${apiKey}`); + expect(built.body).not.toContain(SESSION); + } + expect(JSON.stringify(identity)).toBe("{}"); + }); + test("different client sessions remain distinct", () => { + const a = captureAnthropicClientIdentity(new Headers(observed)); + const b = captureAnthropicClientIdentity(new Headers({ ...observed, "X-Claude-Code-Session-Id": OTHER })); + expect(new Headers(build(a).headers).get("x-claude-code-session-id")).toBe(SESSION); + expect(new Headers(build(b).headers).get("x-claude-code-session-id")).toBe(OTHER); + }); + test("credential, proxy, unknown SDK and hop-by-hop headers gain no authority", () => { + const identity = captureAnthropicClientIdentity(new Headers({ ...observed, + Authorization: "Bearer fixture-caller", "x-api-key": "fixture-caller-key", "proxy-authorization": "fixture-proxy", + "x-stainless-custom": "fixture-extra", "x-ocx-admin": "fixture-admin", host: "fixture.invalid", + connection: "X-Stainless-OS", "anthropic-beta": "fixture-unlisted-beta", + })); + const built = new Headers(build(identity).headers); + expect(built.get("authorization")).toBe("Bearer fixture-a-access"); + for (const name of ["x-api-key", "proxy-authorization", "x-stainless-custom", "x-ocx-admin", "host", "connection"]) expect(built.has(name)).toBe(false); + expect(built.get("x-stainless-os")).not.toBe("fixture-os"); + expect(built.get("anthropic-beta")).not.toContain("fixture-unlisted-beta"); + }); + test("UA alone, non-CLI, malformed, oversized and duplicate required values fall back", () => { + expect(captureAnthropicClientIdentity(new Headers({ "User-Agent": UA }))).toBeUndefined(); + for (const [name, value] of [["User-Agent", "third-party/1.0"], ["User-Agent", "claude-cli/2.1.282 (external, claude-desktop)"], + ["User-Agent", "x".repeat(513)], ["X-App", "web"], ["X-Claude-Code-Session-Id", "fixture-not-uuid"], ["X-Stainless-Runtime", "python"]]) { + expect(captureAnthropicClientIdentity(new Headers({ ...observed, [name!]: value! }))).toBeUndefined(); + } + for (const name of ["User-Agent", "X-App", "X-Claude-Code-Session-Id", "X-Stainless-Runtime"]) { + const headers = new Headers(observed); + headers.append(name, headers.get(name)!); + expect(captureAnthropicClientIdentity(headers)).toBeUndefined(); + } + expect(captureAnthropicClientIdentity(new Headers({ ...observed, connection: "X-App" }))).toBeUndefined(); + expect(build(undefined).headers["X-Claude-Code-Session-Id"]).toBe(claudeCodeSessionId(provider.apiKey)); + }); + test("duplicate/oversized optional SDK fields are dropped and forged handles do nothing", () => { + const headers = new Headers({ ...observed, "X-Stainless-Runtime-Version": "x".repeat(513) }); + headers.append("X-Stainless-Package-Version", "1.0.0"); + const built = build(captureAnthropicClientIdentity(headers)); + expect(built.headers["X-Stainless-Runtime-Version"]).not.toBe("x".repeat(513)); + expect(built.headers["X-Stainless-Package-Version"]).toBe("0.74.0"); + expect(build({} as AnthropicClientIdentity).headers["User-Agent"]).toBe("@anthropic-ai/sdk/0.74.0"); + }); + test("compatible destinations keep defaults; first-party keys retain real caller identity", () => { + const identity = captureAnthropicClientIdentity(new Headers(observed)); + expect(build(identity, { authMode: "key", baseUrl: "https://anthropic.example" }).headers["User-Agent"]).toBe("@anthropic-ai/sdk/0.74.0"); + const key = new Headers(build(identity, { authMode: "key" }).headers); + expect(key.get("user-agent")).toBe(UA); + expect(key.get("x-api-key")).toBe(provider.apiKey!); + expect(key.has("authorization")).toBe(false); + }); + test("operator identity header spellings do not duplicate observed fields", () => { + const built = build(captureAnthropicClientIdentity(new Headers(observed)), { headers: { "user-agent": "fixture-operator", "x-claude-code-session-id": OTHER } }); + expect(new Headers(built.headers).get("user-agent")).toBe("fixture-operator"); + expect(new Headers(built.headers).get("x-claude-code-session-id")).toBe(OTHER); + expect(Object.keys(built.headers).filter(name => name.toLowerCase() === "user-agent")).toHaveLength(1); + expect(Object.keys(built.headers).filter(name => name.toLowerCase() === "x-claude-code-session-id")).toHaveLength(1); + }); + test("operator headers win case-insensitively while unconfigured identity fields survive", () => { + const headers = { "USER-AGENT": "fixture-operator", "X-Stainless-Package-Version": "8.8.8", "x-app": "fixture-app" }; + const built = new Headers(build(captureAnthropicClientIdentity(new Headers(observed)), { headers }).headers); + for (const [name, value] of Object.entries(headers)) expect(built.get(name)).toBe(value); + expect(built.get("x-claude-code-session-id")).toBe(SESSION); + expect(built.get("x-stainless-runtime-version")).toBe("22.1.0"); + }); + test("generated Responses adapter traffic keeps its existing compatibility fingerprint", async () => { + const parsed = { modelId: "m", stream: false, options: {}, context: { messages: [{ role: "user", content: "fixture" }], systemPrompt: [] } } as unknown as OcxParsedRequest; + const built = await createAnthropicAdapter(provider).buildRequest(parsed); + expect(built.headers["User-Agent"]).toBe("@anthropic-ai/sdk/0.74.0"); + expect(built.headers["X-Claude-Code-Session-Id"]).toBe(claudeCodeSessionId(provider.apiKey)); + }); +}); diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index b41ee40bf5b..5c1a738a656 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -79,6 +79,7 @@ const OAUTH_HEADERS = { "authorization": "Bearer sk-ant-oat01-tst", "user-agent": "claude-cli/2.1.200", "x-app": "cli", + "x-claude-code-session-id": "44444444-4444-4444-8444-444444444444", }; function claudeBody(): Record { @@ -121,6 +122,7 @@ test("unmapped claude model + sk-ant credential passes through verbatim", async expect(hit.headers.get("anthropic-beta")).toBe(OAUTH_HEADERS["anthropic-beta"]); expect(hit.headers.get("user-agent")).toBe("claude-cli/2.1.200"); expect(hit.headers.get("x-app")).toBe("cli"); + expect(hit.headers.get("x-claude-code-session-id")).toBe(OAUTH_HEADERS["x-claude-code-session-id"]); // Body untouched: thinking signature, cache_control, max_tokens all intact. expect(hit.body).toEqual(claudeBody()); diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 18f03268a78..a0e5a00462a 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -160,11 +160,11 @@ const CALLER_HEADERS = { "anthropic-beta": `${ALLOWED_BETA},${UNKNOWN_BETA}`, }; -async function send(config: OcxConfig, body: Record) { +async function send(config: OcxConfig, body: Record, identityHeaders: Record = {}) { const requestId = `pf10-oauth-${crypto.randomUUID()}`; const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { method: "POST", - headers: { "content-type": "application/json", ...CALLER_HEADERS }, + headers: { "content-type": "application/json", ...CALLER_HEADERS, ...identityHeaders }, body: JSON.stringify(body), }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); const text = await response.text(); @@ -303,6 +303,58 @@ describe("managed native Messages over Anthropic OAuth", () => { expect(serialized).not.toContain(SIGNATURE); }); + test("genuine client identity survives credential refresh and selected account switch", async () => { + const ids = await seed(2); + await markAccountNeedsReauth("anthropic", ids[1]!, true); + const uuidA = "11111111-1111-4111-8111-111111111111"; + const uuidB = "22222222-2222-4222-8222-222222222222"; + await saveAccountCredential("anthropic", ids[0]!, { ...credential(0), accountId: uuidA }); + const headers = { + "User-Agent": "claude-cli/2.1.282 (external, cli)", "X-App": "cli", + "X-Claude-Code-Session-Id": "33333333-3333-4333-8333-333333333333", + "X-Stainless-Lang": "js", "X-Stainless-Runtime": "node", "X-Stainless-Package-Version": "9.9.9", + }; + const metadata = { user_id: JSON.stringify({ account_uuid: uuidA, device_id: "fixture-device", session_id: "fixture-session" }) }; + const cfg = fixtureConfig(); + forgetAnthropicFailoverQuorum(); + expect((await send(cfg, { ...BODY, stream: false, metadata }, headers)).response.status).toBe(200); + const refreshed = "fixture-refreshed-access"; + await saveAccountCredential("anthropic", ids[0]!, { ...credential(0), accountId: uuidA, access: refreshed }); + expect((await send(cfg, { ...BODY, stream: false, metadata }, headers)).response.status).toBe(200); + await markAccountNeedsReauth("anthropic", ids[0]!, true); + await saveAccountCredential("anthropic", ids[1]!, { ...credential(1), accountId: uuidB }); + await markAccountNeedsReauth("anthropic", ids[1]!, false); + await setActiveAccount("anthropic", ids[1]!); + forgetAnthropicFailoverQuorum(); + expect((await send(cfg, { ...BODY, stream: false, metadata }, headers)).response.status).toBe(200); + expect(sent.map(entry => entry.headers.get("authorization"))).toEqual([ + `Bearer ${credential(0).access}`, `Bearer ${refreshed}`, `Bearer ${credential(1).access}`, + ]); + expect(sent.map(entry => JSON.parse((entry.body.metadata as typeof metadata).user_id).account_uuid)).toEqual([uuidA, uuidA, uuidB]); + for (const entry of sent) { + for (const [name, value] of Object.entries(headers)) expect(entry.headers.get(name)).toBe(value); + expect(entry.headers.has("x-api-key")).toBe(false); + expect(entry.headers.get("anthropic-beta")).not.toContain(UNKNOWN_BETA); + expect(entry.body).not.toHaveProperty("clientIdentity"); + } + expect(JSON.parse(metadata.user_id).account_uuid).toBe(uuidA); + }); + + test("physical send binds metadata to the UUID in the serving credential, never the local slot", async () => { + const [id] = await seed(1); + const uuid = "22222222-2222-4222-8222-222222222222"; + await saveAccountCredential("anthropic", id!, { ...credential(0), accountId: uuid }); + const metadata = { user_id: JSON.stringify({ account_uuid: "11111111-1111-4111-8111-111111111111", device_id: "fixture-device", session_id: "fixture-session" }) }; + const { response } = await send(fixtureConfig(), { ...BODY, stream: false, metadata }); + expect(response.status).toBe(200); + expect(sent).toHaveLength(1); + expect(sent[0]!.headers.get("authorization")).toBe(`Bearer ${credential(0).access}`); + const wire = sent[0]!.body.metadata as typeof metadata; + expect(JSON.parse(wire.user_id)).toEqual({ account_uuid: uuid, device_id: "fixture-device", session_id: "fixture-session" }); + expect(wire.user_id).not.toContain(id!); + expect(JSON.parse(metadata.user_id).account_uuid).not.toBe(uuid); + }); + test("a JSON answer maps the tool name back too", async () => { await seed(1); const { response, text } = await send(fixtureConfig(), { ...BODY, stream: false }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 66db2637f24..efa9b6928a0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -128,6 +128,8 @@ "always-on-429-failover.test.ts": "routing", "ambiguous-resend-composition.test.ts": "lib", "ambiguous-resend-gate.test.ts": "lib", + "anthropic-client-identity.test.ts": "adapters/anthropic", + "anthropic-account-metadata.test.ts": "adapters/anthropic", "anthropic-account-pool.test.ts": "adapters/anthropic", "anthropic-account-pause-outbound.test.ts": "adapters/anthropic", "anthropic-account-pause.test.ts": "adapters/anthropic",