@ex-machina/opencode-anthropic-auth 2.0.0-next.1 → 2.0.0-next.3

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.
package/dist/index.js CHANGED
@@ -1,11 +1,104 @@
1
- import { Plugin } from '@opencode-ai/plugin';
1
+ import { createHash, createHmac, randomBytes } from 'node:crypto';
2
+ import { Plugin } from '@opencode/plugin';
2
3
  import { authorize, exchange, refreshToken } from "./auth.js";
3
- import { resolveClaudeCodeVersion } from "./config.js";
4
- import { CLAUDE_CODE_VERSION, REQUIRED_BETAS } from "./constants.js";
5
- import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
+ import { BodyLimitError, contentLength, readBoundedText } from "./bounded.js";
5
+ import { ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR, compareClaudeCodeVersions, isValidClaudeCodeVersion, resolveClaudeCodeVersion, } from "./config.js";
6
+ import { CLAUDE_CODE_VERSION, formatUserAgent, REQUIRED_BETAS, } from "./constants.js";
7
+ import { createConnectionLabel, describeConnection, enhanceRateLimitResponse, } from "./rate-limit.js";
8
+ import { createStrippedStream, headersAfterBodyTransform, isInsecure, isTrustedAnthropicUrl, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, ToolNameAliasTable, } from "./transform.js";
9
+ import { detectClaudeCodeVersionRejection } from "./version-rejection.js";
6
10
  const PLUGIN_ID = 'ex-machina.anthropic-auth';
7
11
  const INTEGRATION_ID = 'anthropic';
8
12
  const REFRESH_CACHE_GRACE_MS = 30_000;
13
+ const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
14
+ const MAX_ACTIVE_ALIAS_REQUESTS = 256;
15
+ const MAX_REQUEST_ALIAS_ENTRIES = 256;
16
+ const MAX_REQUEST_ALIAS_BYTES = 16 * 1024;
17
+ const ALIAS_REQUEST_TTL_MS = 5 * 60_000;
18
+ const MAX_BLOCKED_REFRESH_TOKENS = 1024;
19
+ const BLOCKED_REFRESH_FILTER_BYTES = 8 * 1024;
20
+ const BLOCKED_REFRESH_FILTER_HASHES = 4;
21
+ const MAX_REFRESH_TOKEN_BYTES = 8 * 1024;
22
+ const MAX_REFRESH_IN_FLIGHT = 256;
23
+ const MAX_REFRESH_CACHE_ENTRIES = 256;
24
+ const MAX_TRACKED_CONNECTIONS = 256;
25
+ const CONNECTION_TRACKING_TTL_MS = 5 * 60_000;
26
+ const MAX_ACTIVE_RESPONSE_TRANSFORMS = 256;
27
+ const MAX_RECONSTRUCTED_ALIAS_LOOKUPS = 32;
28
+ const MAX_TRACKED_REQUEST_URL_BYTES = 8 * 1024;
29
+ const MAX_VERSION_GATE_RECOVERIES = 256;
30
+ const UNKNOWN_CONNECTION = 'Unknown OAuth connection';
31
+ const AMBIGUOUS_CONNECTION = 'Ambiguous OAuth connection';
32
+ // setup() is location-scoped while the credential store is process-global.
33
+ // Share only active rotations; settled credentials remain location-local.
34
+ const refreshInFlight = new Map();
35
+ const blockedRefreshTokens = new Map();
36
+ // Overflow identities intentionally never expire, rotate, or lose bits:
37
+ // forgetting one could replay a refresh token whose prior outcome was
38
+ // ambiguous. The fixed-size filter bounds memory and fails closed; its
39
+ // false-positive probability therefore grows over the process lifetime.
40
+ const blockedRefreshTokenFilter = new Uint8Array(BLOCKED_REFRESH_FILTER_BYTES);
41
+ let activeResponseTransforms = 0;
42
+ function acquireResponseTransform() {
43
+ if (activeResponseTransforms >= MAX_ACTIVE_RESPONSE_TRANSFORMS) {
44
+ throw new Error('Too many active Anthropic response transforms');
45
+ }
46
+ activeResponseTransforms += 1;
47
+ let active = true;
48
+ return () => {
49
+ if (!active)
50
+ return;
51
+ active = false;
52
+ activeResponseTransforms -= 1;
53
+ };
54
+ }
55
+ function refreshTokenKey(refreshToken) {
56
+ if (refreshToken.length === 0 ||
57
+ refreshToken.length > MAX_REFRESH_TOKEN_BYTES) {
58
+ return undefined;
59
+ }
60
+ const bytes = new TextEncoder().encode(refreshToken);
61
+ if (bytes.byteLength > MAX_REFRESH_TOKEN_BYTES)
62
+ return undefined;
63
+ return createHash('sha256').update(bytes).digest('base64url');
64
+ }
65
+ function isAmbiguousRefreshFailure(status) {
66
+ return status === 0 || status >= 500 || (status >= 200 && status < 300);
67
+ }
68
+ function blockedRefreshFilterIndexes(key) {
69
+ const digest = createHash('sha256').update(`blocked:${key}`).digest();
70
+ const bits = BLOCKED_REFRESH_FILTER_BYTES * 8;
71
+ return Array.from({ length: BLOCKED_REFRESH_FILTER_HASHES }, (_, index) => digest.readUInt32BE(index * 4) % bits);
72
+ }
73
+ function addBlockedRefreshToFilter(key) {
74
+ for (const index of blockedRefreshFilterIndexes(key)) {
75
+ const byteIndex = index >> 3;
76
+ blockedRefreshTokenFilter[byteIndex] =
77
+ (blockedRefreshTokenFilter[byteIndex] ?? 0) | (1 << (index & 7));
78
+ }
79
+ }
80
+ function blockedRefreshFilterHas(key) {
81
+ return blockedRefreshFilterIndexes(key).every((index) => ((blockedRefreshTokenFilter[index >> 3] ?? 0) & (1 << (index & 7))) !== 0);
82
+ }
83
+ function blockRefreshToken(key, status) {
84
+ if (blockedRefreshTokens.has(key) || blockedRefreshFilterHas(key))
85
+ return;
86
+ if (blockedRefreshTokens.size >= MAX_BLOCKED_REFRESH_TOKENS) {
87
+ addBlockedRefreshToFilter(key);
88
+ return;
89
+ }
90
+ blockedRefreshTokens.set(key, status);
91
+ }
92
+ function blockedRefreshError(key) {
93
+ const status = blockedRefreshTokens.get(key);
94
+ if (status !== undefined) {
95
+ return new Error(`Anthropic token refresh blocked after ambiguous failure: ${status}`);
96
+ }
97
+ if (blockedRefreshFilterHas(key)) {
98
+ return new Error('Anthropic token refresh blocked after an ambiguous failure');
99
+ }
100
+ return undefined;
101
+ }
9
102
  // `methodID` is a branded `Integration.MethodID` at the type level (a
10
103
  // compile-time-only tag — there's no runtime representation), so a plain
11
104
  // string literal needs a cast to satisfy the branded field.
@@ -25,10 +118,57 @@ async function resolveActiveOAuth(ctx) {
25
118
  return undefined;
26
119
  const credential = await ctx.integration.connection.resolve(connection);
27
120
  if (credential?.type === 'oauth' && credential.methodID === METHOD_ID) {
28
- return credential;
121
+ return { connection, credential };
29
122
  }
30
123
  return undefined;
31
124
  }
125
+ function hasTransformedOAuthShape(request) {
126
+ const url = new URL(request.url);
127
+ const betas = new Set((request.headers.get('anthropic-beta') ?? '')
128
+ .split(',')
129
+ .map((beta) => beta.trim()));
130
+ return (request.method === 'POST' &&
131
+ isTrustedAnthropicUrl(url) &&
132
+ request.headers.get('authorization')?.startsWith('Bearer ') === true &&
133
+ REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
134
+ url.pathname === '/v1/messages');
135
+ }
136
+ const ownershipEncoder = new TextEncoder();
137
+ function updateFramed(hmac, bytes) {
138
+ const length = new Uint8Array(8);
139
+ new DataView(length.buffer).setBigUint64(0, BigInt(bytes.byteLength));
140
+ hmac.update(length);
141
+ hmac.update(bytes);
142
+ }
143
+ function requestLeaseKey(ownershipKey, request, body) {
144
+ const authorizationKey = requestAuthorizationKey(request);
145
+ if (!authorizationKey)
146
+ return undefined;
147
+ const urlBytes = ownershipEncoder.encode(request.url);
148
+ if (urlBytes.byteLength > MAX_TRACKED_REQUEST_URL_BYTES)
149
+ return undefined;
150
+ const bodyBytes = ownershipEncoder.encode(body);
151
+ const hmac = createHmac('sha256', ownershipKey);
152
+ hmac.update('opencode-anthropic-auth/request-ownership/v1');
153
+ updateFramed(hmac, ownershipEncoder.encode(request.method));
154
+ updateFramed(hmac, urlBytes);
155
+ updateFramed(hmac, ownershipEncoder.encode(authorizationKey));
156
+ updateFramed(hmac, bodyBytes);
157
+ return `${bodyBytes.byteLength}:${hmac.digest('base64url')}`;
158
+ }
159
+ function requestAuthorizationKey(request) {
160
+ const authorization = request.headers.get('authorization');
161
+ if (!authorization?.startsWith('Bearer '))
162
+ return undefined;
163
+ const token = authorization.slice('Bearer '.length);
164
+ if (token.length === 0 || token.length > MAX_REFRESH_TOKEN_BYTES) {
165
+ return undefined;
166
+ }
167
+ const bytes = new TextEncoder().encode(token);
168
+ if (bytes.byteLength > MAX_REFRESH_TOKEN_BYTES)
169
+ return undefined;
170
+ return createHash('sha256').update(bytes).digest('base64url');
171
+ }
32
172
  function warnIfInsecureUnsupported() {
33
173
  if (!isInsecure())
34
174
  return;
@@ -37,63 +177,299 @@ function warnIfInsecureUnsupported() {
37
177
  'ANTHROPIC_BASE_URL endpoint. TLS verification remains enabled — ' +
38
178
  'requests to an untrusted/self-signed endpoint will fail.');
39
179
  }
40
- function isTransformedOAuthRequest(request) {
41
- const betas = new Set((request.headers.get('anthropic-beta') ?? '')
42
- .split(',')
43
- .map((beta) => beta.trim()));
44
- return (request.headers.get('authorization')?.startsWith('Bearer ') === true &&
45
- REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
46
- new URL(request.url).searchParams.get('beta') === 'true');
180
+ function versionGateRecoveryKey(sessionID, agent, providerID, modelID) {
181
+ return `${sessionID}\u0000${agent}\u0000${providerID}\u0000${modelID}`;
182
+ }
183
+ /** Read the exact Claude Code version carried by a plugin-owned request. */
184
+ function sentClaudeCodeVersion(request) {
185
+ const userAgent = request.headers.get('user-agent');
186
+ if (!userAgent?.startsWith('claude-cli/'))
187
+ return undefined;
188
+ const suffix = ' (external, cli)';
189
+ if (!userAgent.endsWith(suffix))
190
+ return undefined;
191
+ const version = userAgent.slice('claude-cli/'.length, -suffix.length);
192
+ return isValidClaudeCodeVersion(version) &&
193
+ formatUserAgent(version) === userAgent
194
+ ? version
195
+ : undefined;
47
196
  }
48
197
  export default Plugin.define({
49
198
  id: PLUGIN_ID,
50
199
  setup: async (ctx) => {
51
200
  warnIfInsecureUnsupported();
52
- // Resolved once per plugin instance so every request reports the same
53
- // version in both the user-agent and the billing header.
54
- const versionResolution = resolveClaudeCodeVersion();
201
+ // Resolve once so user-agent and billing metadata agree for every request
202
+ // handled by this plugin generation.
203
+ const rawVersionOverride = process.env[ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR];
204
+ const versionResolution = resolveClaudeCodeVersion(rawVersionOverride);
55
205
  if (versionResolution.type === 'invalid') {
56
206
  console.error(`[ex-machina.anthropic-auth] ${versionResolution.error}`);
57
207
  }
58
208
  else if (versionResolution.type === 'outdated') {
59
209
  console.warn(`[ex-machina.anthropic-auth] ${versionResolution.warning}`);
60
210
  }
61
- // Only a malformed override lacks a usable version; an outdated one was
62
- // set deliberately, so it is reported as configured.
63
- const claudeCodeVersion = versionResolution.type === 'invalid'
211
+ let claudeCodeVersion = versionResolution.type === 'invalid'
64
212
  ? CLAUDE_CODE_VERSION
65
213
  : versionResolution.version;
66
- // Retain successful refreshes for this plugin generation so a host call
214
+ // A valid explicit override is absolute. Automatic adoption is only the
215
+ // fallback path for an unset (or malformed and therefore ignored) value.
216
+ const hasExplicitVersionOverride = rawVersionOverride !== undefined && versionResolution.type !== 'invalid';
217
+ // This set is only an at-most-once limiter. The exact response is marked
218
+ // retryable below, so this key never authorizes an unrelated retry event.
219
+ // On overflow, recovery fails closed rather than forgetting old entries.
220
+ const versionGateRecoveries = new Set();
221
+ const aliasesByRequest = new WeakMap();
222
+ const aliasesByFingerprint = new Map();
223
+ const transformedRequests = new WeakSet();
224
+ const ownershipKey = randomBytes(32);
225
+ let reconstructedAliasLookups = 0;
226
+ let setupActive = true;
227
+ const connectionByRequest = new WeakMap();
228
+ const connectionByAuthorization = new Map();
229
+ const rememberConnection = (request, description) => {
230
+ connectionByRequest.set(request, description);
231
+ const key = requestAuthorizationKey(request);
232
+ if (!key)
233
+ return;
234
+ const existing = connectionByAuthorization.get(key);
235
+ if (!existing &&
236
+ connectionByAuthorization.size >= MAX_TRACKED_CONNECTIONS) {
237
+ return;
238
+ }
239
+ if (existing)
240
+ clearTimeout(existing.timer);
241
+ const retainedDescription = !existing || existing.description === description
242
+ ? description
243
+ : AMBIGUOUS_CONNECTION;
244
+ let entry;
245
+ const timer = setTimeout(() => {
246
+ if (connectionByAuthorization.get(key) === entry) {
247
+ connectionByAuthorization.delete(key);
248
+ }
249
+ }, CONNECTION_TRACKING_TTL_MS);
250
+ timer.unref?.();
251
+ entry = { description: retainedDescription, timer };
252
+ connectionByAuthorization.set(key, entry);
253
+ };
254
+ const connectionForRequest = (request) => {
255
+ const direct = connectionByRequest.get(request);
256
+ if (direct)
257
+ return direct;
258
+ const key = requestAuthorizationKey(request);
259
+ if (!key)
260
+ return UNKNOWN_CONNECTION;
261
+ return (connectionByAuthorization.get(key)?.description ?? UNKNOWN_CONNECTION);
262
+ };
263
+ const clearAliasLeaseTimer = (lease) => {
264
+ if (lease.timer === undefined)
265
+ return;
266
+ clearTimeout(lease.timer);
267
+ lease.timer = undefined;
268
+ };
269
+ const expireAliasLease = (lease) => {
270
+ if (!lease.active)
271
+ return;
272
+ lease.active = false;
273
+ if (aliasesByFingerprint.get(lease.key) === lease) {
274
+ aliasesByFingerprint.delete(lease.key);
275
+ }
276
+ clearAliasLeaseTimer(lease);
277
+ lease.aliases.dispose();
278
+ };
279
+ const scheduleAliasLeaseExpiry = (lease) => {
280
+ if (!lease.active || lease.retired || lease.activeResponses > 0)
281
+ return;
282
+ clearAliasLeaseTimer(lease);
283
+ const timer = setTimeout(() => {
284
+ if (lease.timer !== timer)
285
+ return;
286
+ lease.timer = undefined;
287
+ if (lease.activeResponses === 0)
288
+ expireAliasLease(lease);
289
+ }, ALIAS_REQUEST_TTL_MS);
290
+ timer.unref?.();
291
+ lease.timer = timer;
292
+ };
293
+ const beginAliasResponse = (lease) => {
294
+ if (!lease.active || lease.retired)
295
+ return;
296
+ if (lease.references <= lease.activeResponses)
297
+ lease.references += 1;
298
+ lease.activeResponses += 1;
299
+ clearAliasLeaseTimer(lease);
300
+ };
301
+ const retireAliasLease = (lease) => {
302
+ if (!lease.active || lease.retired)
303
+ return;
304
+ lease.retired = true;
305
+ if (aliasesByFingerprint.get(lease.key) === lease) {
306
+ aliasesByFingerprint.delete(lease.key);
307
+ }
308
+ clearAliasLeaseTimer(lease);
309
+ if (lease.activeResponses === 0) {
310
+ expireAliasLease(lease);
311
+ }
312
+ else {
313
+ lease.references = lease.activeResponses;
314
+ }
315
+ };
316
+ const releaseAliasLease = (request, lease) => {
317
+ aliasesByRequest.delete(request);
318
+ if (!lease.active)
319
+ return;
320
+ if (lease.activeResponses > 0)
321
+ lease.activeResponses -= 1;
322
+ lease.references -= 1;
323
+ if (lease.references <= 0 ||
324
+ (lease.retired && lease.activeResponses === 0)) {
325
+ expireAliasLease(lease);
326
+ }
327
+ else {
328
+ scheduleAliasLeaseExpiry(lease);
329
+ }
330
+ };
331
+ const registerAliasLease = (request, body, aliases) => {
332
+ const key = requestLeaseKey(ownershipKey, request, body);
333
+ if (!key) {
334
+ aliases.dispose();
335
+ throw new Error('Unable to track transformed Anthropic request');
336
+ }
337
+ const existing = aliasesByFingerprint.get(key);
338
+ if (existing?.active && !existing.retired) {
339
+ aliases.dispose();
340
+ existing.references += 1;
341
+ aliasesByRequest.set(request, existing);
342
+ scheduleAliasLeaseExpiry(existing);
343
+ return;
344
+ }
345
+ if (aliasesByFingerprint.size >= MAX_ACTIVE_ALIAS_REQUESTS) {
346
+ aliases.dispose();
347
+ throw new Error('Too many active Anthropic tool-name alias mappings');
348
+ }
349
+ const lease = {
350
+ key,
351
+ aliases,
352
+ references: 1,
353
+ activeResponses: 0,
354
+ active: true,
355
+ retired: false,
356
+ timer: undefined,
357
+ };
358
+ aliasesByFingerprint.set(key, lease);
359
+ aliasesByRequest.set(request, lease);
360
+ scheduleAliasLeaseExpiry(lease);
361
+ };
362
+ const resolveAliasLease = async (request) => {
363
+ const direct = aliasesByRequest.get(request);
364
+ if (direct?.active && !direct.retired) {
365
+ beginAliasResponse(direct);
366
+ return direct;
367
+ }
368
+ if (direct)
369
+ aliasesByRequest.delete(request);
370
+ if (!request.body)
371
+ return undefined;
372
+ if (request.bodyUsed || request.body.locked)
373
+ return undefined;
374
+ if (reconstructedAliasLookups >= MAX_RECONSTRUCTED_ALIAS_LOOKUPS) {
375
+ return undefined;
376
+ }
377
+ reconstructedAliasLookups += 1;
378
+ try {
379
+ const declaredLength = contentLength(request.headers);
380
+ if (declaredLength !== undefined &&
381
+ declaredLength > MAX_REQUEST_BODY_BYTES) {
382
+ throw new BodyLimitError('Anthropic request body', MAX_REQUEST_BODY_BYTES);
383
+ }
384
+ const body = await readBoundedText(request.clone().body, MAX_REQUEST_BODY_BYTES, 'Anthropic request body');
385
+ const key = requestLeaseKey(ownershipKey, request, body);
386
+ if (!key)
387
+ return undefined;
388
+ const lease = aliasesByFingerprint.get(key);
389
+ if (!lease?.active)
390
+ return undefined;
391
+ beginAliasResponse(lease);
392
+ aliasesByRequest.set(request, lease);
393
+ return lease;
394
+ }
395
+ finally {
396
+ reconstructedAliasLookups -= 1;
397
+ }
398
+ };
399
+ // Retain successful refreshes for this location so a host call
67
400
  // holding the rotated token cannot submit it again before persistence.
68
- const refreshInFlight = new Map();
401
+ const refreshCache = new Map();
402
+ const refreshCacheTimers = new Map();
403
+ const removeRefreshCacheEntry = (key, expected) => {
404
+ if (expected && refreshCache.get(key) !== expected)
405
+ return;
406
+ refreshCache.delete(key);
407
+ const timer = refreshCacheTimers.get(key);
408
+ if (timer)
409
+ clearTimeout(timer);
410
+ refreshCacheTimers.delete(key);
411
+ };
412
+ const makeRefreshCacheRoom = () => {
413
+ if (refreshCache.size < MAX_REFRESH_CACHE_ENTRIES)
414
+ return;
415
+ throw new Error('Anthropic token refresh blocked until the consumed-token cache expires');
416
+ };
69
417
  const refreshCredential = async (credential) => {
70
- const existing = refreshInFlight.get(credential.refresh);
418
+ const key = refreshTokenKey(credential.refresh);
419
+ if (!key)
420
+ throw new Error('Anthropic token refresh failed: 400');
421
+ const existing = refreshCache.get(key);
71
422
  if (existing)
72
423
  return existing;
73
- const pending = (async () => {
74
- const result = await refreshToken(credential.refresh);
75
- if (result.type === 'failed') {
76
- throw new Error(`Anthropic token refresh failed: ${result.status}`);
77
- }
78
- return toCredential(result);
79
- })();
80
- refreshInFlight.set(credential.refresh, pending);
424
+ const shared = refreshInFlight.get(key);
425
+ if (!shared) {
426
+ const blocked = blockedRefreshError(key);
427
+ if (blocked)
428
+ throw blocked;
429
+ }
430
+ if (!shared && refreshInFlight.size >= MAX_REFRESH_IN_FLIGHT) {
431
+ throw new Error('Too many active Anthropic token refreshes');
432
+ }
433
+ makeRefreshCacheRoom();
434
+ const pending = shared ??
435
+ (async () => {
436
+ let result;
437
+ try {
438
+ result = await refreshToken(credential.refresh);
439
+ }
440
+ catch (error) {
441
+ blockRefreshToken(key, 0);
442
+ throw error;
443
+ }
444
+ if (result.type === 'failed') {
445
+ if (isAmbiguousRefreshFailure(result.status)) {
446
+ blockRefreshToken(key, result.status);
447
+ }
448
+ throw new Error(`Anthropic token refresh failed: ${result.status}`);
449
+ }
450
+ return toCredential(result);
451
+ })();
452
+ refreshCache.set(key, pending);
453
+ if (!shared)
454
+ refreshInFlight.set(key, pending);
81
455
  try {
82
456
  const rotated = await pending;
83
- const timer = setTimeout(() => {
84
- if (refreshInFlight.get(credential.refresh) === pending) {
85
- refreshInFlight.delete(credential.refresh);
86
- }
87
- }, REFRESH_CACHE_GRACE_MS);
88
- timer.unref?.();
457
+ if (refreshCache.get(key) === pending) {
458
+ const timer = setTimeout(() => removeRefreshCacheEntry(key, pending), REFRESH_CACHE_GRACE_MS);
459
+ timer.unref?.();
460
+ refreshCacheTimers.set(key, timer);
461
+ }
89
462
  return rotated;
90
463
  }
91
464
  catch (error) {
92
- if (refreshInFlight.get(credential.refresh) === pending) {
93
- refreshInFlight.delete(credential.refresh);
94
- }
465
+ removeRefreshCacheEntry(key, pending);
95
466
  throw error;
96
467
  }
468
+ finally {
469
+ if (!shared && refreshInFlight.get(key) === pending) {
470
+ refreshInFlight.delete(key);
471
+ }
472
+ }
97
473
  };
98
474
  await ctx.integration.transform((draft) => {
99
475
  draft.method.update({
@@ -120,43 +496,185 @@ export default Plugin.define({
120
496
  };
121
497
  },
122
498
  refresh: refreshCredential,
499
+ label: () => createConnectionLabel(),
123
500
  });
124
501
  });
125
502
  await ctx.session.hook('http.request', async (event) => {
503
+ if (!setupActive)
504
+ return;
126
505
  if (event.model.providerID !== INTEGRATION_ID)
127
506
  return;
128
- const credential = await resolveActiveOAuth(ctx);
129
- if (!credential)
507
+ const active = await resolveActiveOAuth(ctx);
508
+ if (!setupActive || !active)
130
509
  return;
510
+ const { credential } = active;
511
+ const connectionDescription = describeConnection(active.connection);
131
512
  const request = event.request;
132
- const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
133
- const bodyText = hasBody ? await request.clone().text() : undefined;
134
- const rewrittenBody = bodyText !== undefined
135
- ? rewriteRequestBody(bodyText, claudeCodeVersion)
513
+ const rewritten = rewriteUrl(request);
514
+ if (!rewritten.url || !isTrustedAnthropicUrl(rewritten.url)) {
515
+ throw new Error('Refusing to send Anthropic OAuth credentials to an untrusted origin');
516
+ }
517
+ const pathname = rewritten.url.pathname;
518
+ const transformsBody = request.method === 'POST' &&
519
+ (pathname === '/v1/messages' ||
520
+ pathname === '/v1/messages/count_tokens');
521
+ if (!transformsBody) {
522
+ const headers = mergeHeaders(request);
523
+ setOAuthHeaders(headers, credential.access, claudeCodeVersion);
524
+ const routedRequest = rewritten.input instanceof Request
525
+ ? rewritten.input
526
+ : new Request(rewritten.url.toString(), request);
527
+ event.request = new Request(routedRequest, {
528
+ headers,
529
+ signal: request.signal,
530
+ redirect: 'error',
531
+ });
532
+ rememberConnection(event.request, connectionDescription);
533
+ return;
534
+ }
535
+ const hasBody = request.body !== null;
536
+ const declaredLength = contentLength(request.headers);
537
+ if (hasBody &&
538
+ declaredLength !== undefined &&
539
+ declaredLength > MAX_REQUEST_BODY_BYTES) {
540
+ throw new BodyLimitError('Anthropic request body', MAX_REQUEST_BODY_BYTES);
541
+ }
542
+ const bodyText = hasBody
543
+ ? await readBoundedText(request.clone().body, MAX_REQUEST_BODY_BYTES, 'Anthropic request body')
136
544
  : undefined;
137
- const headers = mergeHeaders(request);
545
+ if (!setupActive)
546
+ return;
547
+ const aliases = new ToolNameAliasTable({
548
+ maxEntries: MAX_REQUEST_ALIAS_ENTRIES,
549
+ maxBytes: MAX_REQUEST_ALIAS_BYTES,
550
+ });
551
+ let rewrittenBody;
552
+ try {
553
+ rewrittenBody =
554
+ bodyText !== undefined
555
+ ? rewriteRequestBody(bodyText, claudeCodeVersion, aliases)
556
+ : undefined;
557
+ }
558
+ catch (error) {
559
+ aliases.dispose();
560
+ throw error;
561
+ }
562
+ const bodyChanged = bodyText !== undefined && rewrittenBody !== bodyText;
563
+ const headers = bodyChanged
564
+ ? headersAfterBodyTransform(mergeHeaders(request))
565
+ : mergeHeaders(request);
138
566
  setOAuthHeaders(headers, credential.access, claudeCodeVersion);
139
- if (rewrittenBody !== undefined)
140
- headers.delete('content-length');
141
- const { input: rewrittenInput } = rewriteUrl(request.url);
142
- const url = typeof rewrittenInput === 'string'
143
- ? rewrittenInput
144
- : rewrittenInput instanceof Request
145
- ? rewrittenInput.url
146
- : rewrittenInput.toString();
147
- event.request = new Request(url, {
567
+ const rewrittenRequest = new Request(rewritten.url.toString(), {
148
568
  method: request.method,
149
569
  headers,
150
570
  body: rewrittenBody,
151
571
  signal: request.signal,
572
+ redirect: 'error',
152
573
  });
574
+ event.request = rewrittenRequest;
575
+ if (pathname === '/v1/messages' && rewrittenBody !== undefined) {
576
+ try {
577
+ registerAliasLease(event.request, rewrittenBody, aliases);
578
+ }
579
+ catch (error) {
580
+ event.request = request;
581
+ throw error;
582
+ }
583
+ }
584
+ else {
585
+ aliases.dispose();
586
+ }
587
+ if (pathname === '/v1/messages') {
588
+ transformedRequests.add(event.request);
589
+ }
590
+ rememberConnection(event.request, connectionDescription);
153
591
  });
154
- await ctx.session.hook('http.response', (event) => {
592
+ await ctx.session.hook('http.response', async (event) => {
593
+ if (!setupActive)
594
+ return;
155
595
  if (event.model.providerID !== INTEGRATION_ID)
156
596
  return;
157
- if (!isTransformedOAuthRequest(event.request))
597
+ if (!hasTransformedOAuthShape(event.request))
158
598
  return;
159
- event.response = createStrippedStream(event.response);
599
+ const ownedDirectly = transformedRequests.has(event.request);
600
+ let lease;
601
+ try {
602
+ lease = await resolveAliasLease(event.request);
603
+ }
604
+ catch {
605
+ if (!ownedDirectly)
606
+ return;
607
+ // A direct plugin-owned response survives a best-effort lease failure.
608
+ }
609
+ if (!ownedDirectly && !lease)
610
+ return;
611
+ if (!event.response.ok) {
612
+ if (lease)
613
+ releaseAliasLease(event.request, lease);
614
+ if (!hasExplicitVersionOverride && event.response.status === 400) {
615
+ const sentVersion = sentClaudeCodeVersion(event.request);
616
+ const rejection = sentVersion
617
+ ? await detectClaudeCodeVersionRejection(event.response, sentVersion)
618
+ : undefined;
619
+ if (rejection) {
620
+ if (compareClaudeCodeVersions(rejection.requiredVersion, claudeCodeVersion) === 1) {
621
+ claudeCodeVersion = rejection.requiredVersion;
622
+ }
623
+ const key = versionGateRecoveryKey(event.sessionID, event.agent, event.model.providerID, event.model.id);
624
+ if (!versionGateRecoveries.has(key) &&
625
+ versionGateRecoveries.size < MAX_VERSION_GATE_RECOVERIES) {
626
+ versionGateRecoveries.add(key);
627
+ const headers = new Headers(event.response.headers);
628
+ headers.set('x-should-retry', 'true');
629
+ event.response = new Response(event.response.body, {
630
+ status: event.response.status,
631
+ statusText: event.response.statusText,
632
+ headers,
633
+ });
634
+ }
635
+ }
636
+ }
637
+ if (event.response.status === 429) {
638
+ const enhanced = await enhanceRateLimitResponse(event.response, connectionForRequest(event.request));
639
+ event.response = enhanced.response;
640
+ }
641
+ return;
642
+ }
643
+ const aliases = lease?.aliases ?? new ToolNameAliasTable({ maxEntries: 0, maxBytes: 0 });
644
+ const releaseAliases = () => {
645
+ if (lease)
646
+ releaseAliasLease(event.request, lease);
647
+ else
648
+ aliases.dispose();
649
+ };
650
+ let releaseTransform;
651
+ try {
652
+ releaseTransform = acquireResponseTransform();
653
+ event.response = createStrippedStream(event.response, aliases, () => {
654
+ releaseAliases();
655
+ releaseTransform?.();
656
+ });
657
+ }
658
+ catch (error) {
659
+ releaseTransform?.();
660
+ releaseAliases();
661
+ throw error;
662
+ }
160
663
  });
664
+ return () => {
665
+ setupActive = false;
666
+ for (const lease of aliasesByFingerprint.values())
667
+ retireAliasLease(lease);
668
+ aliasesByFingerprint.clear();
669
+ for (const entry of connectionByAuthorization.values()) {
670
+ clearTimeout(entry.timer);
671
+ }
672
+ connectionByAuthorization.clear();
673
+ versionGateRecoveries.clear();
674
+ for (const timer of refreshCacheTimers.values())
675
+ clearTimeout(timer);
676
+ refreshCacheTimers.clear();
677
+ refreshCache.clear();
678
+ };
161
679
  },
162
680
  });