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