@tormentalabs/claude-code-wire-compat 0.1.0-rc.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +101 -2
  2. package/README.md +67 -2
  3. package/dist/betas.d.ts +37 -0
  4. package/dist/betas.d.ts.map +1 -1
  5. package/dist/betas.js +55 -23
  6. package/dist/betas.js.map +1 -1
  7. package/dist/build-request.d.ts +11 -0
  8. package/dist/build-request.d.ts.map +1 -1
  9. package/dist/build-request.js +133 -11
  10. package/dist/build-request.js.map +1 -1
  11. package/dist/contracts.d.ts +53 -0
  12. package/dist/contracts.d.ts.map +1 -1
  13. package/dist/contracts.js.map +1 -1
  14. package/dist/fingerprint.d.ts +29 -2
  15. package/dist/fingerprint.d.ts.map +1 -1
  16. package/dist/fingerprint.js +60 -7
  17. package/dist/fingerprint.js.map +1 -1
  18. package/dist/headers.d.ts.map +1 -1
  19. package/dist/headers.js +12 -4
  20. package/dist/headers.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/model-capabilities.d.ts +31 -3
  26. package/dist/model-capabilities.d.ts.map +1 -1
  27. package/dist/model-capabilities.js +145 -12
  28. package/dist/model-capabilities.js.map +1 -1
  29. package/dist/models.d.ts.map +1 -1
  30. package/dist/models.js +8 -4
  31. package/dist/models.js.map +1 -1
  32. package/dist/profile-behaviors.d.ts +61 -0
  33. package/dist/profile-behaviors.d.ts.map +1 -0
  34. package/dist/profile-behaviors.js +53 -0
  35. package/dist/profile-behaviors.js.map +1 -0
  36. package/dist/profiles/beta-registry-2.1.233.d.ts +140 -0
  37. package/dist/profiles/beta-registry-2.1.233.d.ts.map +1 -0
  38. package/dist/profiles/beta-registry-2.1.233.js +183 -0
  39. package/dist/profiles/beta-registry-2.1.233.js.map +1 -0
  40. package/dist/profiles/claude-code-2.1.195.d.ts.map +1 -1
  41. package/dist/profiles/claude-code-2.1.195.js +14 -0
  42. package/dist/profiles/claude-code-2.1.195.js.map +1 -1
  43. package/dist/profiles/claude-code-2.1.233.d.ts +3 -0
  44. package/dist/profiles/claude-code-2.1.233.d.ts.map +1 -0
  45. package/dist/profiles/claude-code-2.1.233.js +235 -0
  46. package/dist/profiles/claude-code-2.1.233.js.map +1 -0
  47. package/dist/redaction.d.ts.map +1 -1
  48. package/dist/redaction.js +14 -1
  49. package/dist/redaction.js.map +1 -1
  50. package/dist/request-body.d.ts.map +1 -1
  51. package/dist/request-body.js +12 -10
  52. package/dist/request-body.js.map +1 -1
  53. package/dist/thinking.d.ts +33 -7
  54. package/dist/thinking.d.ts.map +1 -1
  55. package/dist/thinking.js +105 -36
  56. package/dist/thinking.js.map +1 -1
  57. package/package.json +10 -2
  58. package/src/anti-verbosity.ts +219 -0
  59. package/src/beta-registry.ts +140 -0
  60. package/src/betas.ts +302 -0
  61. package/src/build-request.ts +1799 -0
  62. package/src/contracts.ts +1286 -0
  63. package/src/count-tokens.ts +84 -0
  64. package/src/fingerprint.ts +155 -0
  65. package/src/headers.ts +448 -0
  66. package/src/index.ts +63 -0
  67. package/src/metadata.ts +331 -0
  68. package/src/model-capabilities.ts +453 -0
  69. package/src/model-identity.ts +45 -0
  70. package/src/models.ts +50 -0
  71. package/src/profile-behaviors.ts +114 -0
  72. package/src/profiles/beta-registry-2.1.233.ts +200 -0
  73. package/src/profiles/claude-code-2.1.195.ts +168 -0
  74. package/src/profiles/claude-code-2.1.233.ts +240 -0
  75. package/src/redaction.ts +536 -0
  76. package/src/request-body.ts +1931 -0
  77. package/src/sha256.ts +114 -0
  78. package/src/system-prompt.ts +222 -0
  79. package/src/thinking.ts +346 -0
  80. package/src/unicode.ts +24 -0
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ import { COUNT_TOKENS_BETAS } from "./beta-registry.js";
4
+ import type { Message, ToolDefinition } from "./contracts.js";
5
+
6
+ /** Upstream SDK `countTokens` endpoint at byte offset 224471633. */
7
+ export const COUNT_TOKENS_ENDPOINT =
8
+ "https://api.anthropic.com/v1/messages/count_tokens?beta=true" as const;
9
+
10
+ /** Upstream SDK `countTokens` beta at byte offset 224471633. */
11
+ export const TOKEN_COUNTING_BETA = "token-counting-2024-11-01" as const;
12
+
13
+ /** Upstream `PMo` used by `P5e` at byte offset 235439559. */
14
+ export const COUNT_TOKENS_THINKING_BUDGET = 1024 as const;
15
+
16
+ /** Upstream `P5e` empty-message fallback at byte offset 235439559. */
17
+ export const COUNT_TOKENS_EMPTY_MESSAGES = Object.freeze([
18
+ Object.freeze({ role: "user", content: "foo" }),
19
+ ]);
20
+
21
+ /**
22
+ * Upstream `Bkl` at byte offset 235438568.
23
+ *
24
+ * Decides whether the count-tokens body carries a `thinking` field. Note the
25
+ * two conjuncts upstream requires and this port preserves: the message role
26
+ * must be `assistant`, and its content must be an ARRAY. A thinking block on a
27
+ * user message, or an assistant message whose content is a plain string, does
28
+ * not qualify.
29
+ *
30
+ * Upstream additionally guards each message and block against being a
31
+ * non-object, because it runs on loosely typed internal history. This port is
32
+ * reached only through `canonicalCountTokensLists`, which has already proven
33
+ * every element well formed, so those guards would be unreachable branches.
34
+ * Restore them only if a caller path is ever added that bypasses that
35
+ * canonicaliser.
36
+ */
37
+ export function containsThinkingBlock(messages: readonly Message[]): boolean {
38
+ return messages.some(
39
+ (message) =>
40
+ message.role === "assistant" &&
41
+ typeof message.content !== "string" &&
42
+ message.content.some(
43
+ (block) =>
44
+ block.type === "thinking" || block.type === "redacted_thinking",
45
+ ),
46
+ );
47
+ }
48
+
49
+ /** Upstream `E2r` filtering in `P5e` at byte offset 235439559. */
50
+ export function filterCountTokensBetas(
51
+ composedBetas: readonly string[],
52
+ ): readonly string[] {
53
+ return Object.freeze(
54
+ composedBetas.filter((beta) => COUNT_TOKENS_BETAS.has(beta)),
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Upstream `P5e` wire-body construction at byte offset 235439559.
60
+ *
61
+ * Both list arguments MUST already have been canonicalised by
62
+ * `canonicalCountTokensLists`. Key order is load-bearing: the vendored SDK
63
+ * destructures `betas` out of the body and object rest preserves the
64
+ * declaration order of the survivors, leaving `model`, `messages`, `tools`,
65
+ * then an optional `thinking` on the wire.
66
+ */
67
+ export function buildCountTokensBody(
68
+ model: string,
69
+ messages: readonly Message[],
70
+ tools: readonly ToolDefinition[],
71
+ ): Readonly<Record<string, unknown>> {
72
+ const body: Record<string, unknown> = {
73
+ model,
74
+ messages: messages.length > 0 ? messages : COUNT_TOKENS_EMPTY_MESSAGES,
75
+ tools,
76
+ };
77
+ if (containsThinkingBlock(messages)) {
78
+ body["thinking"] = {
79
+ type: "enabled",
80
+ budget_tokens: COUNT_TOKENS_THINKING_BUDGET,
81
+ };
82
+ }
83
+ return Object.freeze(body);
84
+ }
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ import type { ClaudeCodeProtocolProfile, TextBlock } from "./contracts.js";
4
+ import { ClaudeCodeWireError } from "./contracts.js";
5
+ import { profileBehaviors } from "./profile-behaviors.js";
6
+
7
+ const FINGERPRINT_PREFIX = "59cf53e54c78";
8
+
9
+ /**
10
+ * Upstream guard on `cc_prev_req`, transcribed verbatim from the 2.1.233
11
+ * billing-header builder: the segment is emitted only when the value is
12
+ * defined AND matches this pattern AND the request is first-party.
13
+ */
14
+ const PREVIOUS_REQUEST_ID_PATTERN = /^req_[A-Za-z0-9_-]{1,36}$/;
15
+
16
+ /**
17
+ * Upstream guard on `cc_prompt_id`, transcribed verbatim from the same builder.
18
+ * The `i` flag is upstream's, not a relaxation: an upper-case UUID IS emitted.
19
+ */
20
+ const PROMPT_ID_PATTERN =
21
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ /**
24
+ * The conversation-chaining inputs of the 2.1.233 billing block. Both are the
25
+ * caller's to supply; see `ClaudeCodeRequestInput.previousRequestId`.
26
+ */
27
+ export interface BillingChain {
28
+ readonly previousRequestId?: string;
29
+ readonly promptId?: string;
30
+ }
31
+
32
+ function isCryptoProvider(value: unknown): value is Pick<Crypto, "subtle"> {
33
+ if (typeof value !== "object" || value === null) {
34
+ return false;
35
+ }
36
+
37
+ const subtle: unknown = Reflect.get(value, "subtle");
38
+ return (
39
+ typeof subtle === "object" &&
40
+ subtle !== null &&
41
+ typeof Reflect.get(subtle, "digest") === "function"
42
+ );
43
+ }
44
+
45
+ function getDefaultCrypto(): Pick<Crypto, "subtle"> {
46
+ const value: unknown = Reflect.get(globalThis, "crypto");
47
+ if (!isCryptoProvider(value)) {
48
+ throw new ClaudeCodeWireError("CRYPTO_UNAVAILABLE");
49
+ }
50
+ return value;
51
+ }
52
+
53
+ export async function createBillingFingerprint(
54
+ firstUserText: string,
55
+ cliVersion: string,
56
+ crypto?: Pick<Crypto, "subtle">,
57
+ ): Promise<string> {
58
+ const cryptoProvider = crypto ?? getDefaultCrypto();
59
+ const material = `${FINGERPRINT_PREFIX}${firstUserText[4] ?? "0"}${firstUserText[7] ?? "0"}${firstUserText[20] ?? "0"}${cliVersion}`;
60
+ const bytes = new TextEncoder().encode(material);
61
+
62
+ let digest: unknown;
63
+ // Keep this try deliberately narrow so our validation errors are not self-masked.
64
+ try {
65
+ digest = await cryptoProvider.subtle.digest("SHA-256", bytes);
66
+ } catch {
67
+ throw new ClaudeCodeWireError("CRYPTO_UNAVAILABLE");
68
+ }
69
+
70
+ let digestBytes: Uint8Array;
71
+ if (digest instanceof ArrayBuffer) {
72
+ digestBytes = new Uint8Array(digest);
73
+ } else if (ArrayBuffer.isView(digest)) {
74
+ digestBytes = new Uint8Array(
75
+ digest.buffer,
76
+ digest.byteOffset,
77
+ digest.byteLength,
78
+ );
79
+ } else {
80
+ // Unvalidated digests silently corrupt billing fingerprints as "" or "000".
81
+ throw new ClaudeCodeWireError("CRYPTO_UNAVAILABLE");
82
+ }
83
+ if (digestBytes.byteLength !== 32) {
84
+ throw new ClaudeCodeWireError("CRYPTO_UNAVAILABLE");
85
+ }
86
+
87
+ return Array.from(digestBytes, (byte) => byte.toString(16).padStart(2, "0"))
88
+ .join("")
89
+ .slice(0, 3);
90
+ }
91
+
92
+ /**
93
+ * Builds the canonical billing block (system index 0).
94
+ *
95
+ * The upstream 2.1.233 builder assembles a fixed prefix followed by five
96
+ * optional segments, each one space-prefixed and semicolon-terminated, in this
97
+ * order: `cch`, `cc_workload`, `cc_is_subagent`, `cc_prev_req`, `cc_prompt_id`.
98
+ *
99
+ * Three of those five are settled for every request this package emits:
100
+ *
101
+ * - `cch=00000;` is always present. Its gate is the first-party predicate,
102
+ * which is true for the Anthropic provider this package targets, and its
103
+ * value is static — the upstream hashed-cache path is dead code.
104
+ * - `cc_workload` and `cc_is_subagent` are never emitted. They describe a
105
+ * background workload and a sub-agent session respectively; this package
106
+ * models the CLI's main session, which has neither. Same position as
107
+ * 2.1.195, which has no such segments at all.
108
+ *
109
+ * The remaining two are conversation state and are the caller's to supply.
110
+ */
111
+ export async function createBillingBlock(
112
+ firstUserText: string,
113
+ profile: ClaudeCodeProtocolProfile,
114
+ crypto?: Pick<Crypto, "subtle">,
115
+ chain?: BillingChain,
116
+ ): Promise<TextBlock> {
117
+ const { cliVersion, entrypoint } = profile;
118
+ const fingerprint = await createBillingFingerprint(
119
+ firstUserText,
120
+ cliVersion,
121
+ crypto,
122
+ );
123
+
124
+ let text = `x-anthropic-billing-header: cc_version=${cliVersion}.${fingerprint}; cc_entrypoint=${entrypoint}; cch=00000;`;
125
+
126
+ /*
127
+ * ---- Demarcated: conversation chaining, upstream 2.1.233. ----
128
+ *
129
+ * The gate is STRUCTURAL, not a capability flag: the 2.1.233 builder takes
130
+ * these two values, the 2.1.195 builder has no parameter for them, so the
131
+ * 195 profile must never emit either segment even when a caller supplies
132
+ * both. They are dropped silently there, exactly as a client without the
133
+ * feature would drop them. Which profiles are on which side is
134
+ * `profile-behaviors.ts`'s question, not this module's.
135
+ *
136
+ * A malformed value is dropped silently too, and never interpolated: these
137
+ * segments are the only caller-controlled bytes in the block, so a value
138
+ * failing its pattern must not reach the wire in any form.
139
+ */
140
+ if (profileBehaviors(profile).billingChainSegments) {
141
+ const previousRequestId = chain?.previousRequestId;
142
+ if (
143
+ previousRequestId !== undefined &&
144
+ PREVIOUS_REQUEST_ID_PATTERN.test(previousRequestId)
145
+ ) {
146
+ text += ` cc_prev_req=${previousRequestId};`;
147
+ }
148
+ const promptId = chain?.promptId;
149
+ if (promptId !== undefined && PROMPT_ID_PATTERN.test(promptId)) {
150
+ text += ` cc_prompt_id=${promptId};`;
151
+ }
152
+ }
153
+
154
+ return { type: "text", text };
155
+ }
package/src/headers.ts ADDED
@@ -0,0 +1,448 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ import type {
4
+ ClaudeCodeExtraHeaderPolicy,
5
+ ClaudeCodeProtocolProfile,
6
+ HeaderPair,
7
+ } from "./contracts.js";
8
+ import { ClaudeCodeWireError } from "./contracts.js";
9
+ import { CLAUDE_CODE_2_1_195_PROFILE } from "./profiles/claude-code-2.1.195.js";
10
+ import { CLAUDE_CODE_2_1_233_PROFILE } from "./profiles/claude-code-2.1.233.js";
11
+
12
+ const HEADER_NAMES = Object.freeze({
13
+ anthropicBeta: "anthropic-beta",
14
+ browserAccess: "anthropic-dangerous-direct-browser-access",
15
+ anthropicVersion: "anthropic-version",
16
+ authorization: "authorization",
17
+ contentType: "content-type",
18
+ userAgent: "user-agent",
19
+ app: "x-app",
20
+ sessionId: "x-claude-code-session-id",
21
+ clientRequestId: "x-client-request-id",
22
+ arch: "x-stainless-arch",
23
+ lang: "x-stainless-lang",
24
+ os: "x-stainless-os",
25
+ packageVersion: "x-stainless-package-version",
26
+ retryCount: "x-stainless-retry-count",
27
+ runtime: "x-stainless-runtime",
28
+ runtimeVersion: "x-stainless-runtime-version",
29
+ timeout: "x-stainless-timeout",
30
+ stainlessHelper: "x-stainless-helper",
31
+ remoteContainerId: "x-claude-remote-container-id",
32
+ remoteSessionId: "x-claude-remote-session-id",
33
+ clientApp: "x-client-app",
34
+ additionalProtection: "x-anthropic-additional-protection",
35
+ } as const);
36
+
37
+ const CANONICAL_NAMES: ReadonlySet<string> = new Set(
38
+ Object.values(HEADER_NAMES),
39
+ );
40
+
41
+ interface HeaderRuntime {
42
+ readonly sessionId: string;
43
+ readonly runtime: string;
44
+ readonly runtimeVersion: string;
45
+ readonly os: string;
46
+ readonly arch: string;
47
+ }
48
+
49
+ interface ValidatedInput {
50
+ readonly accessToken: string;
51
+ readonly runtime: HeaderRuntime;
52
+ readonly clientRequestId: string;
53
+ readonly betaFeatures: readonly string[];
54
+ readonly app: "cli" | "cli-bg";
55
+ readonly stainlessRetryCount: number;
56
+ readonly stainlessHelper?: string;
57
+ readonly claudeRemoteContainerId?: string;
58
+ readonly claudeRemoteSessionId?: string;
59
+ readonly clientApp?: string;
60
+ readonly anthropicAdditionalProtection?: string;
61
+ readonly extraHeaders: readonly HeaderPair[];
62
+ readonly extraHeaderPolicy: ClaudeCodeExtraHeaderPolicy;
63
+ readonly profile: ClaudeCodeProtocolProfile;
64
+ }
65
+
66
+ /** Reports the headers placed on the wire plus what the policy discarded. */
67
+ export interface OrderedHeaderPlan {
68
+ readonly headers: readonly HeaderPair[];
69
+ /**
70
+ * Lists the lowercased names `dropConflicting` discarded, in caller order.
71
+ *
72
+ * Always empty under `strict`, which throws instead of dropping.
73
+ */
74
+ readonly droppedExtraHeaderNames: readonly string[];
75
+ }
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return typeof value === "object" && value !== null && !Array.isArray(value);
79
+ }
80
+
81
+ function hasControlCharacter(value: string): boolean {
82
+ for (const character of value) {
83
+ const codePoint = character.codePointAt(0);
84
+ if (
85
+ codePoint !== undefined &&
86
+ (codePoint <= 31 || (codePoint >= 127 && codePoint <= 159))
87
+ ) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function assertHeaderText(name: string, value: string): void {
95
+ if (hasControlCharacter(name) || hasControlCharacter(value)) {
96
+ throw new ClaudeCodeWireError("HEADER_INJECTION");
97
+ }
98
+ }
99
+
100
+ function requiredString(
101
+ record: Readonly<Record<string, unknown>>,
102
+ key: string,
103
+ ): string {
104
+ const value = record[key];
105
+ if (typeof value !== "string" || value.length === 0) {
106
+ throw new ClaudeCodeWireError("INVALID_INPUT");
107
+ }
108
+ return value;
109
+ }
110
+
111
+ function parseRuntime(value: unknown): HeaderRuntime {
112
+ if (!isRecord(value)) {
113
+ throw new ClaudeCodeWireError("INVALID_INPUT");
114
+ }
115
+ return {
116
+ sessionId: requiredString(value, "sessionId"),
117
+ runtime: requiredString(value, "runtime"),
118
+ runtimeVersion: requiredString(value, "runtimeVersion"),
119
+ os: requiredString(value, "os"),
120
+ arch: requiredString(value, "arch"),
121
+ };
122
+ }
123
+
124
+ function parseBetaFeatures(value: unknown): readonly string[] {
125
+ if (!Array.isArray(value)) {
126
+ throw new ClaudeCodeWireError("INVALID_INPUT");
127
+ }
128
+ const features: string[] = [];
129
+ for (const feature of value) {
130
+ if (typeof feature !== "string" || feature.length === 0) {
131
+ throw new ClaudeCodeWireError("INVALID_INPUT");
132
+ }
133
+ features.push(feature);
134
+ }
135
+ return features;
136
+ }
137
+
138
+ function parseExtraHeaders(value: unknown): readonly HeaderPair[] {
139
+ if (!Array.isArray(value)) {
140
+ throw new ClaudeCodeWireError("INVALID_INPUT");
141
+ }
142
+ const headers: HeaderPair[] = [];
143
+ for (const candidate of value) {
144
+ if (
145
+ !Array.isArray(candidate) ||
146
+ candidate.length !== 2 ||
147
+ typeof candidate[0] !== "string" ||
148
+ typeof candidate[1] !== "string"
149
+ ) {
150
+ throw new ClaudeCodeWireError("INVALID_INPUT");
151
+ }
152
+ headers.push([candidate[0], candidate[1]]);
153
+ }
154
+ return headers;
155
+ }
156
+
157
+ function parseExtraHeaderPolicy(value: unknown): ClaudeCodeExtraHeaderPolicy {
158
+ if (value !== "strict" && value !== "dropConflicting") {
159
+ throw new ClaudeCodeWireError("INVALID_INPUT");
160
+ }
161
+ return value;
162
+ }
163
+
164
+ /**
165
+ * Accepts a pinned profile by REFERENCE, never by shape, and returns the
166
+ * singleton itself so nothing downstream can be handed a look-alike. Adding
167
+ * the second pinned profile widens the accepted set by exactly one object;
168
+ * anything else, including a structural clone, still fails closed.
169
+ */
170
+ function parseProfile(value: unknown): ClaudeCodeProtocolProfile {
171
+ if (value === CLAUDE_CODE_2_1_195_PROFILE) return CLAUDE_CODE_2_1_195_PROFILE;
172
+ if (value === CLAUDE_CODE_2_1_233_PROFILE) return CLAUDE_CODE_2_1_233_PROFILE;
173
+ throw new ClaudeCodeWireError("INVALID_INPUT");
174
+ }
175
+
176
+ function parseApp(value: unknown): "cli" | "cli-bg" {
177
+ if (value !== "cli" && value !== "cli-bg") {
178
+ throw new ClaudeCodeWireError("INVALID_INPUT");
179
+ }
180
+ return value;
181
+ }
182
+
183
+ function parseRetryCount(value: unknown): number {
184
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
185
+ throw new ClaudeCodeWireError("INVALID_INPUT");
186
+ }
187
+ return value;
188
+ }
189
+
190
+ function optionalHeaderString(
191
+ input: Readonly<Record<string, unknown>>,
192
+ key: string,
193
+ ): string | undefined {
194
+ const value = input[key];
195
+ if (value === undefined) return undefined;
196
+ if (typeof value !== "string" || value.length === 0) {
197
+ throw new ClaudeCodeWireError("INVALID_INPUT");
198
+ }
199
+ return value;
200
+ }
201
+
202
+ function parseInput(input: unknown): ValidatedInput {
203
+ if (!isRecord(input)) {
204
+ throw new ClaudeCodeWireError("INVALID_INPUT");
205
+ }
206
+ const stainlessHelper = optionalHeaderString(input, "stainlessHelper");
207
+ const claudeRemoteContainerId = optionalHeaderString(
208
+ input,
209
+ "claudeRemoteContainerId",
210
+ );
211
+ const claudeRemoteSessionId = optionalHeaderString(
212
+ input,
213
+ "claudeRemoteSessionId",
214
+ );
215
+ const clientApp = optionalHeaderString(input, "clientApp");
216
+ const anthropicAdditionalProtection = optionalHeaderString(
217
+ input,
218
+ "anthropicAdditionalProtection",
219
+ );
220
+ return {
221
+ accessToken: requiredString(input, "accessToken"),
222
+ runtime: parseRuntime(input["runtime"]),
223
+ clientRequestId: requiredString(input, "clientRequestId"),
224
+ betaFeatures: parseBetaFeatures(input["betaFeatures"]),
225
+ app: parseApp(input["app"] === undefined ? "cli" : input["app"]),
226
+ stainlessRetryCount: parseRetryCount(
227
+ input["stainlessRetryCount"] === undefined
228
+ ? 0
229
+ : input["stainlessRetryCount"],
230
+ ),
231
+ extraHeaders: parseExtraHeaders(input["extraHeaders"] ?? []),
232
+ extraHeaderPolicy: parseExtraHeaderPolicy(
233
+ input["extraHeaderPolicy"] ?? "strict",
234
+ ),
235
+ profile: parseProfile(input["profile"]),
236
+ ...(stainlessHelper === undefined ? {} : { stainlessHelper }),
237
+ ...(claudeRemoteContainerId === undefined
238
+ ? {}
239
+ : { claudeRemoteContainerId }),
240
+ ...(claudeRemoteSessionId === undefined ? {} : { claudeRemoteSessionId }),
241
+ ...(clientApp === undefined ? {} : { clientApp }),
242
+ ...(anthropicAdditionalProtection === undefined
243
+ ? {}
244
+ : { anthropicAdditionalProtection }),
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Names a caller may never place on the wire through `extraHeaders`.
250
+ *
251
+ * Two disjoint reasons, both non-negotiable.
252
+ *
253
+ * CREDENTIAL AND ROUTING DISCLOSURE. `x-api-key`, `cookie`, `set-cookie`,
254
+ * `proxy-*`, `forwarded` and `x-forwarded-*` carry authentication that would
255
+ * contradict the OAuth bearer this package emits, or disclose the caller's
256
+ * network topology upstream.
257
+ *
258
+ * HOP-BY-HOP AND ENTITY HEADERS (RFC 9110 section 7.6.1). `connection`,
259
+ * `transfer-encoding`, `te`, `upgrade`, `keep-alive` and `host` govern a single
260
+ * connection and belong to the transport, not to the caller. `content-length`
261
+ * is the worst of them: this package RECONSTRUCTS the request body canonically,
262
+ * so a length copied from an inbound request describes a different byte string.
263
+ * A wrong `content-length` corrupts the request SILENTLY — no local error is
264
+ * raised, the peer truncates or stalls. Blocking these is a defect fix, valid
265
+ * independently of any consumer.
266
+ */
267
+ const FORBIDDEN_HEADER_NAMES: ReadonlySet<string> = new Set([
268
+ "x-api-key",
269
+ "cookie",
270
+ "set-cookie",
271
+ "forwarded",
272
+ "content-length",
273
+ "host",
274
+ "connection",
275
+ "transfer-encoding",
276
+ "te",
277
+ "upgrade",
278
+ "keep-alive",
279
+ ]);
280
+
281
+ function isForbiddenHeader(name: string): boolean {
282
+ return (
283
+ FORBIDDEN_HEADER_NAMES.has(name) ||
284
+ name.startsWith("proxy-") ||
285
+ name.startsWith("x-forwarded-")
286
+ );
287
+ }
288
+
289
+ function safeDiagnosticName(name: string, accessToken: string): string {
290
+ return name.includes(accessToken) ? "[redacted]" : name;
291
+ }
292
+
293
+ interface ResolvedExtraHeaders {
294
+ readonly kept: readonly HeaderPair[];
295
+ readonly droppedNames: readonly string[];
296
+ }
297
+
298
+ /**
299
+ * Applies the caller policy to the supplied extra headers.
300
+ *
301
+ * `strict` is the original behaviour, unchanged: the first conflict throws, and
302
+ * nothing reaches the wire. `dropConflicting` discards the offending pair and
303
+ * records its lowercased name, so a consumer forwarding a heterogeneous host
304
+ * header map is not defeated by a single header this package owns.
305
+ *
306
+ * The relaxation covers OWNERSHIP conflicts only — a canonical name or a
307
+ * denylisted name. Two guarantees survive in both policies:
308
+ *
309
+ * - Header syntax is validated FIRST and never relaxed. A control character in
310
+ * a name or a value raises `HEADER_INJECTION` whatever the policy says;
311
+ * smuggling is never silently tolerated.
312
+ * - A caller that duplicates one of ITS OWN extra headers still gets
313
+ * `DUPLICATE_HEADER`. That collision is a caller bug, not an ownership
314
+ * conflict this package is entitled to resolve on the caller's behalf.
315
+ */
316
+ function resolveExtraHeaders(
317
+ extraHeaders: readonly HeaderPair[],
318
+ accessToken: string,
319
+ policy: ClaudeCodeExtraHeaderPolicy,
320
+ ): ResolvedExtraHeaders {
321
+ const seenExtras = new Set<string>();
322
+ const kept: HeaderPair[] = [];
323
+ const droppedNames: string[] = [];
324
+ for (const [name, value] of extraHeaders) {
325
+ assertHeaderText(name, value);
326
+ const normalizedName = name.toLowerCase();
327
+ const safeName = safeDiagnosticName(normalizedName, accessToken);
328
+ const ownershipConflict = isForbiddenHeader(normalizedName)
329
+ ? "FORBIDDEN_HEADER"
330
+ : CANONICAL_NAMES.has(normalizedName)
331
+ ? "DUPLICATE_HEADER"
332
+ : undefined;
333
+ if (ownershipConflict !== undefined) {
334
+ if (policy === "strict") {
335
+ throw new ClaudeCodeWireError(ownershipConflict, {
336
+ headerName: safeName,
337
+ });
338
+ }
339
+ droppedNames.push(normalizedName);
340
+ continue;
341
+ }
342
+ if (seenExtras.has(normalizedName)) {
343
+ throw new ClaudeCodeWireError("DUPLICATE_HEADER", {
344
+ headerName: safeName,
345
+ });
346
+ }
347
+ seenExtras.add(normalizedName);
348
+ kept.push([name, value]);
349
+ }
350
+ return { kept, droppedNames };
351
+ }
352
+
353
+ function freezePair(name: string, value: string): HeaderPair {
354
+ const pair: HeaderPair = [name, value];
355
+ return Object.freeze(pair);
356
+ }
357
+
358
+ function assertTokenIsolation(
359
+ pairs: readonly HeaderPair[],
360
+ accessToken: string,
361
+ ): void {
362
+ for (const [name, value] of pairs) {
363
+ if (name !== HEADER_NAMES.authorization && value.includes(accessToken)) {
364
+ throw new ClaudeCodeWireError("INVALID_INPUT");
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Builds the pinned canonical logical header list together with the audit of
371
+ * whatever the extra-header policy discarded.
372
+ *
373
+ * Transport order is not guaranteed.
374
+ */
375
+ export function buildOrderedHeaderPlan(input: unknown): OrderedHeaderPlan {
376
+ const appendExtraHeaders =
377
+ isRecord(input) &&
378
+ (Object.hasOwn(input, "app") ||
379
+ Object.hasOwn(input, "stainlessRetryCount") ||
380
+ Object.hasOwn(input, "stainlessHelper") ||
381
+ Object.hasOwn(input, "claudeRemoteContainerId") ||
382
+ Object.hasOwn(input, "claudeRemoteSessionId") ||
383
+ Object.hasOwn(input, "clientApp") ||
384
+ Object.hasOwn(input, "anthropicAdditionalProtection"));
385
+ const validated = parseInput(input);
386
+ const resolvedExtras = resolveExtraHeaders(
387
+ validated.extraHeaders,
388
+ validated.accessToken,
389
+ validated.extraHeaderPolicy,
390
+ );
391
+
392
+ const beta = validated.betaFeatures.join(",");
393
+ const values = [
394
+ [HEADER_NAMES.anthropicBeta, beta],
395
+ [HEADER_NAMES.browserAccess, "true"],
396
+ [HEADER_NAMES.anthropicVersion, validated.profile.anthropicVersion],
397
+ [HEADER_NAMES.authorization, `Bearer ${validated.accessToken}`],
398
+ [HEADER_NAMES.contentType, "application/json"],
399
+ [HEADER_NAMES.userAgent, validated.profile.userAgent],
400
+ [HEADER_NAMES.app, validated.app],
401
+ [HEADER_NAMES.sessionId, validated.runtime.sessionId],
402
+ [HEADER_NAMES.clientRequestId, validated.clientRequestId],
403
+ [HEADER_NAMES.arch, validated.runtime.arch],
404
+ [HEADER_NAMES.lang, "js"],
405
+ [HEADER_NAMES.os, validated.runtime.os],
406
+ [HEADER_NAMES.packageVersion, validated.profile.sdkVersion],
407
+ [HEADER_NAMES.retryCount, String(validated.stainlessRetryCount)],
408
+ [HEADER_NAMES.runtime, validated.runtime.runtime],
409
+ [HEADER_NAMES.runtimeVersion, validated.runtime.runtimeVersion],
410
+ [HEADER_NAMES.timeout, "600"],
411
+ ] as const;
412
+
413
+ const pairs: HeaderPair[] = [];
414
+ for (const [name, value] of values) {
415
+ assertHeaderText(name, value);
416
+ pairs.push(freezePair(name, value));
417
+ }
418
+ const dynamicValues = [
419
+ [HEADER_NAMES.stainlessHelper, validated.stainlessHelper],
420
+ [HEADER_NAMES.remoteContainerId, validated.claudeRemoteContainerId],
421
+ [HEADER_NAMES.remoteSessionId, validated.claudeRemoteSessionId],
422
+ [HEADER_NAMES.clientApp, validated.clientApp],
423
+ [
424
+ HEADER_NAMES.additionalProtection,
425
+ validated.anthropicAdditionalProtection,
426
+ ],
427
+ ] as const;
428
+ for (const [name, value] of dynamicValues) {
429
+ if (value === undefined) continue;
430
+ assertHeaderText(name, value);
431
+ pairs.push(freezePair(name, value));
432
+ }
433
+ if (appendExtraHeaders) {
434
+ for (const [name, value] of resolvedExtras.kept) {
435
+ pairs.push(freezePair(name, value));
436
+ }
437
+ }
438
+ assertTokenIsolation(pairs, validated.accessToken);
439
+ return Object.freeze({
440
+ headers: Object.freeze(pairs),
441
+ droppedExtraHeaderNames: Object.freeze([...resolvedExtras.droppedNames]),
442
+ });
443
+ }
444
+
445
+ /** Builds the pinned canonical logical header list. Transport order is not guaranteed. */
446
+ export function buildOrderedHeaders(input: unknown): readonly HeaderPair[] {
447
+ return buildOrderedHeaderPlan(input).headers;
448
+ }