@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/README.md +2 -2
- 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 +10 -0
- package/dist/config.js +25 -15
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +575 -57
- 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 +41 -19
- package/dist/transform.js +592 -329
- package/dist/version-rejection.d.ts +17 -0
- package/dist/version-rejection.js +75 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,11 +1,104 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
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
|
-
|
|
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
|
|
129
|
-
if (!
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
597
|
+
if (!hasTransformedOAuthShape(event.request))
|
|
158
598
|
return;
|
|
159
|
-
|
|
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
|
});
|