@rikcodes/teamclaude 1.1.13-rik.1
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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
4
|
+
import { exec, execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import { proxyFetch } from './upstream-fetch.js';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CREDENTIALS_PATH = '~/.claude/.credentials.json';
|
|
13
|
+
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read Claude Code credentials from the macOS Keychain, where Claude Code
|
|
17
|
+
* stores them on darwin (there is no ~/.claude/.credentials.json on macOS).
|
|
18
|
+
*/
|
|
19
|
+
async function readKeychainCredentials() {
|
|
20
|
+
const { stdout } = await execFileAsync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
|
|
21
|
+
return JSON.parse(stdout.trim());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Import OAuth credentials from a Claude Code credentials file.
|
|
26
|
+
* On macOS the default credentials location is the Keychain, not a file, so
|
|
27
|
+
* when the default path is missing the Keychain is tried before giving up.
|
|
28
|
+
*/
|
|
29
|
+
export async function importCredentials(filePath, {
|
|
30
|
+
home = homedir(), platform = process.platform, readKeychain = readKeychainCredentials } = {}) {
|
|
31
|
+
const resolvedPath = filePath.replace(/^~/, home);
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(await readFile(resolvedPath, 'utf-8'));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const isDefaultPath = resolvedPath === DEFAULT_CREDENTIALS_PATH.replace(/^~/, home);
|
|
37
|
+
if (err.code !== 'ENOENT' || platform !== 'darwin' || !isDefaultPath) throw err;
|
|
38
|
+
try {
|
|
39
|
+
raw = await readKeychain();
|
|
40
|
+
} catch (kcErr) {
|
|
41
|
+
throw new Error(`${err.message}; macOS Keychain lookup for "${KEYCHAIN_SERVICE}" also failed: ${kcErr.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Claude Code stores credentials nested under "claudeAiOauth"
|
|
46
|
+
const data = raw.claudeAiOauth || raw;
|
|
47
|
+
return {
|
|
48
|
+
accessToken: data.accessToken,
|
|
49
|
+
refreshToken: data.refreshToken,
|
|
50
|
+
expiresAt: data.expiresAt,
|
|
51
|
+
subscriptionType: data.subscriptionType,
|
|
52
|
+
rateLimitTier: data.rateLimitTier,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
|
|
57
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
58
|
+
const OAUTH_USAGE_BETA = 'oauth-2025-04-20';
|
|
59
|
+
const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
60
|
+
const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refresh an expired OAuth access token using the refresh token.
|
|
64
|
+
* Retries on 5xx and network errors with exponential backoff.
|
|
65
|
+
*/
|
|
66
|
+
export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
|
|
67
|
+
const maxRetries = 2;
|
|
68
|
+
const baseDelayMs = 500;
|
|
69
|
+
// Bound each attempt so a dead pooled socket (after a network drop/reconnect)
|
|
70
|
+
// can't hang the refresh forever. A hung refresh is especially harmful here:
|
|
71
|
+
// ensureTokenFresh coalesces callers into a single _refreshPromise, so one
|
|
72
|
+
// stuck refresh wedges every request for that account until a restart.
|
|
73
|
+
const timeoutMs = Number(process.env.TEAMCLAUDE_REFRESH_TIMEOUT_MS) || 30_000;
|
|
74
|
+
|
|
75
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
76
|
+
try {
|
|
77
|
+
if (attempt > 0) {
|
|
78
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const res = await proxyFetch(endpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
'Accept': 'application/json, text/plain, */*',
|
|
87
|
+
'User-Agent': 'axios/1.13.6',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
grant_type: 'refresh_token',
|
|
91
|
+
refresh_token: refreshToken,
|
|
92
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
93
|
+
}),
|
|
94
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
if (res.status >= 500 && attempt < maxRetries) {
|
|
99
|
+
await res.body?.cancel();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const text = await res.text();
|
|
103
|
+
const err = new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
104
|
+
// Surface the HTTP status so callers can distinguish a genuine auth
|
|
105
|
+
// rejection (the refresh token is dead — re-login needed) from a
|
|
106
|
+
// transient server error. 5xx is retried above; reaching here with a 5xx
|
|
107
|
+
// means retries were exhausted, which is still transient, not auth.
|
|
108
|
+
err.status = res.status;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
return {
|
|
114
|
+
accessToken: data.access_token,
|
|
115
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
116
|
+
expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const isNetworkError = err instanceof Error &&
|
|
120
|
+
(err.name === 'TimeoutError' || err.name === 'AbortError' ||
|
|
121
|
+
err.message.includes('fetch failed') ||
|
|
122
|
+
(err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
123
|
+
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
|
|
124
|
+
|
|
125
|
+
if (attempt < maxRetries && isNetworkError) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize an expires_at value to milliseconds.
|
|
135
|
+
* OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeExpiresAt(expiresAt) {
|
|
138
|
+
if (!expiresAt) return expiresAt;
|
|
139
|
+
// If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
|
|
140
|
+
// convert to milliseconds
|
|
141
|
+
return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if an OAuth token is expiring within the given threshold.
|
|
146
|
+
*/
|
|
147
|
+
export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
|
|
148
|
+
if (!expiresAt) return false;
|
|
149
|
+
return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if an OAuth token has ALREADY expired (no safety margin). Used to decide
|
|
154
|
+
* when a token must be refreshed synchronously before it can be injected — a
|
|
155
|
+
* still-valid-but-expiring-soon token is fine to use now and refresh in the
|
|
156
|
+
* background, but an expired one would 401.
|
|
157
|
+
*/
|
|
158
|
+
export function isTokenExpired(expiresAt) {
|
|
159
|
+
if (!expiresAt) return false;
|
|
160
|
+
return Date.now() >= normalizeExpiresAt(expiresAt);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fetch account profile for an OAuth token.
|
|
165
|
+
* Returns { email, name, orgName, orgType, ... } on success,
|
|
166
|
+
* or { error: 'reason' } on failure.
|
|
167
|
+
*/
|
|
168
|
+
export async function fetchProfile(accessToken) {
|
|
169
|
+
try {
|
|
170
|
+
const res = await proxyFetch(PROFILE_URL, {
|
|
171
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
let detail = '';
|
|
175
|
+
try {
|
|
176
|
+
const body = await res.json();
|
|
177
|
+
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
|
|
178
|
+
} catch {
|
|
179
|
+
detail = await res.text().catch(() => '');
|
|
180
|
+
}
|
|
181
|
+
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
|
|
182
|
+
}
|
|
183
|
+
const data = await res.json();
|
|
184
|
+
return {
|
|
185
|
+
accountUuid: data.account?.uuid,
|
|
186
|
+
email: data.account?.email,
|
|
187
|
+
name: data.account?.display_name,
|
|
188
|
+
orgUuid: data.organization?.uuid,
|
|
189
|
+
orgName: data.organization?.name,
|
|
190
|
+
orgType: data.organization?.organization_type,
|
|
191
|
+
hasClaudeMax: data.account?.has_claude_max,
|
|
192
|
+
hasClaudePro: data.account?.has_claude_pro,
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { error: err.message || String(err) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Pull a per-model weekly limit out of the payload's `limits[]` array, which is
|
|
200
|
+
// where the endpoint now reports model-scoped quota (a `weekly_scoped` entry
|
|
201
|
+
// carrying `scope.model.display_name`). Returns a bucket-shaped object
|
|
202
|
+
// { utilization, resets_at } ready for normalizeUsageBucket, or null if absent.
|
|
203
|
+
// The legacy top-level `seven_day_<model>` keys read null on current plans.
|
|
204
|
+
export function findScopedWeeklyLimit(data, modelNamePattern) {
|
|
205
|
+
const limits = Array.isArray(data?.limits) ? data.limits : [];
|
|
206
|
+
const entry = limits.find((l) =>
|
|
207
|
+
l && l.group === 'weekly' && l.scope?.model?.display_name
|
|
208
|
+
&& modelNamePattern.test(l.scope.model.display_name));
|
|
209
|
+
if (!entry) return null;
|
|
210
|
+
return { utilization: entry.percent, resets_at: entry.resets_at };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Normalize one usage bucket from the /api/oauth/usage payload into
|
|
214
|
+
// { utilization: 0-1, resetAt: ms-epoch }. The endpoint reports utilization
|
|
215
|
+
// as a percentage in the 0-100 range, so 1 means 1%, not 100%.
|
|
216
|
+
export function normalizeUsageBucket(bucket) {
|
|
217
|
+
if (!bucket || typeof bucket !== 'object') return null;
|
|
218
|
+
|
|
219
|
+
const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
|
|
220
|
+
const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
|
|
221
|
+
const utilization = Number.isFinite(parsedPct)
|
|
222
|
+
? parsedPct / 100
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
|
|
226
|
+
let resetAt = null;
|
|
227
|
+
if (typeof rawReset === 'number') {
|
|
228
|
+
resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
|
|
229
|
+
} else if (typeof rawReset === 'string') {
|
|
230
|
+
const asNum = Number(rawReset);
|
|
231
|
+
if (Number.isFinite(asNum) && rawReset.trim() !== '') {
|
|
232
|
+
resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
|
|
233
|
+
} else {
|
|
234
|
+
const parsed = Date.parse(rawReset);
|
|
235
|
+
if (Number.isFinite(parsed)) resetAt = parsed;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { utilization, resetAt };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch OAuth subscription usage from the usage endpoint. This reports quota
|
|
244
|
+
* utilization WITHOUT spending message quota, which is what makes it safe to
|
|
245
|
+
* poll. Returns normalized { fiveHour, sevenDay, sevenDaySonnet, sevenDayFable } buckets, or
|
|
246
|
+
* { error, status } on failure.
|
|
247
|
+
*/
|
|
248
|
+
export async function fetchUsage(accessToken) {
|
|
249
|
+
try {
|
|
250
|
+
const res = await proxyFetch(USAGE_URL, {
|
|
251
|
+
headers: {
|
|
252
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
253
|
+
'anthropic-beta': OAUTH_USAGE_BETA,
|
|
254
|
+
'Accept': 'application/json',
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
let detail = '';
|
|
260
|
+
try {
|
|
261
|
+
const body = await res.json();
|
|
262
|
+
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
|
|
263
|
+
} catch {
|
|
264
|
+
detail = await res.text().catch(() => '');
|
|
265
|
+
}
|
|
266
|
+
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const data = await res.json();
|
|
270
|
+
return {
|
|
271
|
+
fiveHour: normalizeUsageBucket(data?.five_hour),
|
|
272
|
+
sevenDay: normalizeUsageBucket(data?.seven_day),
|
|
273
|
+
sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
|
|
274
|
+
sevenDayFable: normalizeUsageBucket(findScopedWeeklyLimit(data, /fable/i)),
|
|
275
|
+
};
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return { error: err.message || String(err), status: null };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// OAuth config (extracted from Claude Code). Client id + token endpoint are
|
|
282
|
+
// shared with the refresh path — see DEFAULT_CLIENT_ID / DEFAULT_TOKEN_ENDPOINT.
|
|
283
|
+
const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
|
|
284
|
+
const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Perform OAuth login via browser with PKCE flow.
|
|
288
|
+
* Opens the user's browser, waits for the callback, exchanges the code for tokens.
|
|
289
|
+
*/
|
|
290
|
+
export async function loginOAuth() {
|
|
291
|
+
// Generate PKCE
|
|
292
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
293
|
+
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
|
294
|
+
const state = randomBytes(32).toString('base64url');
|
|
295
|
+
|
|
296
|
+
// Start local callback server on a random port
|
|
297
|
+
const { port, codePromise, server } = await startCallbackServer(state);
|
|
298
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
299
|
+
|
|
300
|
+
// Build authorization URL
|
|
301
|
+
const authUrl = new URL(OAUTH_AUTHORIZE);
|
|
302
|
+
authUrl.searchParams.set('code', 'true');
|
|
303
|
+
authUrl.searchParams.set('client_id', DEFAULT_CLIENT_ID);
|
|
304
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
305
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
306
|
+
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
|
307
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
308
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
309
|
+
authUrl.searchParams.set('state', state);
|
|
310
|
+
|
|
311
|
+
// Open browser
|
|
312
|
+
console.log('Opening browser for authentication...');
|
|
313
|
+
console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
314
|
+
openBrowser(authUrl.toString());
|
|
315
|
+
|
|
316
|
+
// Wait for either the callback server or manual paste from stdin
|
|
317
|
+
let code;
|
|
318
|
+
try {
|
|
319
|
+
code = await raceWithStdinCode(codePromise, state);
|
|
320
|
+
} finally {
|
|
321
|
+
server.close();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exchange code for tokens
|
|
325
|
+
console.log('Exchanging authorization code for tokens...');
|
|
326
|
+
const tokenRes = await proxyFetch(DEFAULT_TOKEN_ENDPOINT, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: { 'Content-Type': 'application/json' },
|
|
329
|
+
body: JSON.stringify({
|
|
330
|
+
code,
|
|
331
|
+
state,
|
|
332
|
+
grant_type: 'authorization_code',
|
|
333
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
334
|
+
redirect_uri: redirectUri,
|
|
335
|
+
code_verifier: codeVerifier,
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (!tokenRes.ok) {
|
|
340
|
+
const text = await tokenRes.text();
|
|
341
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tokens = await tokenRes.json();
|
|
345
|
+
return {
|
|
346
|
+
accessToken: tokens.access_token,
|
|
347
|
+
refreshToken: tokens.refresh_token,
|
|
348
|
+
expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Race the callback server promise against manual code entry from stdin.
|
|
354
|
+
* The user can paste the full callback URL or just the authorization code.
|
|
355
|
+
*/
|
|
356
|
+
function raceWithStdinCode(callbackPromise, expectedState) {
|
|
357
|
+
if (!process.stdin.isTTY) return callbackPromise;
|
|
358
|
+
|
|
359
|
+
return new Promise((resolve, reject) => {
|
|
360
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
361
|
+
let settled = false;
|
|
362
|
+
|
|
363
|
+
const settle = (fn, val) => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
rl.close();
|
|
367
|
+
fn(val);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
|
|
371
|
+
const trimmed = answer.trim();
|
|
372
|
+
if (!trimmed) return; // empty input, keep waiting for callback
|
|
373
|
+
|
|
374
|
+
// Try to parse as a URL with ?code= parameter
|
|
375
|
+
try {
|
|
376
|
+
const url = new URL(trimmed);
|
|
377
|
+
const code = url.searchParams.get('code');
|
|
378
|
+
const state = url.searchParams.get('state');
|
|
379
|
+
if (code) {
|
|
380
|
+
if (expectedState && state && state !== expectedState) {
|
|
381
|
+
settle(reject, new Error('OAuth state mismatch'));
|
|
382
|
+
} else {
|
|
383
|
+
settle(resolve, code);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
|
|
389
|
+
// Treat raw input as the authorization code
|
|
390
|
+
settle(resolve, trimmed);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
callbackPromise.then(
|
|
394
|
+
code => settle(resolve, code),
|
|
395
|
+
err => settle(reject, err),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function startCallbackServer(expectedState) {
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
let resolveCode, rejectCode;
|
|
403
|
+
const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
|
|
404
|
+
|
|
405
|
+
const server = http.createServer((req, res) => {
|
|
406
|
+
const url = new URL(req.url, `http://localhost`);
|
|
407
|
+
|
|
408
|
+
if (url.pathname === '/callback') {
|
|
409
|
+
const code = url.searchParams.get('code');
|
|
410
|
+
const error = url.searchParams.get('error');
|
|
411
|
+
const state = url.searchParams.get('state');
|
|
412
|
+
|
|
413
|
+
if (error) {
|
|
414
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
415
|
+
res.end('<html><body><h2>Authentication failed</h2><p>You can close this tab.</p></body></html>');
|
|
416
|
+
rejectCode(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') || ''}`));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (expectedState && state !== expectedState) {
|
|
421
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
422
|
+
res.end('<html><body><h2>Authentication failed</h2><p>State mismatch. You can close this tab.</p></body></html>');
|
|
423
|
+
rejectCode(new Error('OAuth state mismatch'));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (code) {
|
|
428
|
+
res.writeHead(302, { 'Location': 'https://platform.claude.com/oauth/code/success?app=claude-code' });
|
|
429
|
+
res.end();
|
|
430
|
+
resolveCode(code);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
res.writeHead(404);
|
|
436
|
+
res.end('Not found');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
server.listen(0, () => {
|
|
440
|
+
resolve({ port: server.address().port, codePromise, server });
|
|
441
|
+
});
|
|
442
|
+
server.on('error', reject);
|
|
443
|
+
|
|
444
|
+
// Timeout after 2 minutes (unref so it doesn't keep the process alive)
|
|
445
|
+
const timer = setTimeout(() => {
|
|
446
|
+
rejectCode(new Error('Login timed out after 2 minutes'));
|
|
447
|
+
server.close();
|
|
448
|
+
}, 120_000);
|
|
449
|
+
timer.unref();
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function openBrowser(url) {
|
|
454
|
+
const platform = process.platform;
|
|
455
|
+
const cmd = platform === 'darwin' ? 'open'
|
|
456
|
+
: platform === 'win32' ? 'start'
|
|
457
|
+
: 'xdg-open';
|
|
458
|
+
exec(`${cmd} ${JSON.stringify(url)}`, () => {});
|
|
459
|
+
}
|
package/src/prober.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Opt-in background quota probe.
|
|
2
|
+
//
|
|
3
|
+
// DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
|
|
4
|
+
// reads an OAuth account's quota zero-spend /api/oauth/usage endpoint so idle
|
|
5
|
+
// accounts' utilization/reset stay fresh without waiting to rotate onto them.
|
|
6
|
+
// A sanctioned active-upstream feature (the other is the opt-in keep-warm
|
|
7
|
+
// scheduler, warmer.js); the proxy is otherwise passive. Unlike keep-warm, this
|
|
8
|
+
// probe reads a zero-spend endpoint and never consumes message quota.
|
|
9
|
+
|
|
10
|
+
import { fetchUsage } from './oauth.js';
|
|
11
|
+
|
|
12
|
+
export class Prober {
|
|
13
|
+
constructor(accountManager, { intervalMs = 0, probeFn = fetchUsage, timeoutMs = 10_000, log = console.log } = {}) {
|
|
14
|
+
this.am = accountManager;
|
|
15
|
+
this.intervalMs = intervalMs;
|
|
16
|
+
this.probeFn = probeFn;
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
18
|
+
this.log = log;
|
|
19
|
+
this.timer = null;
|
|
20
|
+
this._running = false;
|
|
21
|
+
this.lastRunStartedAt = null;
|
|
22
|
+
this.lastRunFinishedAt = null;
|
|
23
|
+
this.nextRunAt = intervalMs > 0 ? Date.now() + intervalMs : null;
|
|
24
|
+
this.accountStatus = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start() {
|
|
28
|
+
if (this.intervalMs > 0) this.reschedule(this.intervalMs);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Change interval at runtime (0 = off). Probes once immediately when on. */
|
|
32
|
+
reschedule(intervalMs) {
|
|
33
|
+
const wasOn = this.intervalMs > 0 && this.timer;
|
|
34
|
+
this.intervalMs = intervalMs;
|
|
35
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
36
|
+
|
|
37
|
+
if (intervalMs > 0) {
|
|
38
|
+
this.nextRunAt = Date.now() + intervalMs;
|
|
39
|
+
// Immediate probe only on an off→on transition — not on every interval
|
|
40
|
+
// change (mirrors warmer.js; avoids an extra burst when the interval is edited).
|
|
41
|
+
if (!wasOn) this.probeAll().catch(() => {});
|
|
42
|
+
this.timer = setInterval(() => this.probeAll().catch(() => {}), intervalMs);
|
|
43
|
+
this.timer.unref?.();
|
|
44
|
+
this.log(`[TeamClaude] Quota probe enabled (every ${Math.round(intervalMs / 1000)}s)`);
|
|
45
|
+
} else if (wasOn) {
|
|
46
|
+
this.nextRunAt = null;
|
|
47
|
+
this.log('[TeamClaude] Quota probe disabled');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stop() {
|
|
52
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
53
|
+
this.nextRunAt = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Probe every OAuth account once. Overlapping cycles are skipped. */
|
|
57
|
+
async probeAll() {
|
|
58
|
+
if (this._running) return;
|
|
59
|
+
this._running = true;
|
|
60
|
+
this.lastRunStartedAt = Date.now();
|
|
61
|
+
this.nextRunAt = this.intervalMs > 0 ? this.lastRunStartedAt + this.intervalMs : null;
|
|
62
|
+
try {
|
|
63
|
+
const accounts = this.am.accounts.filter(account => account.type === 'oauth' && account.credential);
|
|
64
|
+
await Promise.all(accounts.map(account => this.probeAccount(account)));
|
|
65
|
+
} finally {
|
|
66
|
+
this.lastRunFinishedAt = Date.now();
|
|
67
|
+
this._running = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async probeAccount(account) {
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
this._recordAccount(account, { status: 'running', startedAt });
|
|
74
|
+
try {
|
|
75
|
+
await this.am.ensureTokenFresh(account.index);
|
|
76
|
+
let usage = await this._withTimeout(this.probeFn(account.credential));
|
|
77
|
+
if (usage?.status === 401) {
|
|
78
|
+
// Token rejected: force refresh and retry once.
|
|
79
|
+
await this.am.ensureTokenFresh(account.index, true);
|
|
80
|
+
usage = await this._withTimeout(this.probeFn(account.credential));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!usage || usage.error) {
|
|
84
|
+
const finishedAt = Date.now();
|
|
85
|
+
this._recordAccount(account, {
|
|
86
|
+
status: usage?.error ? 'error' : 'timeout',
|
|
87
|
+
error: usage?.error || 'probe timed out',
|
|
88
|
+
startedAt,
|
|
89
|
+
finishedAt,
|
|
90
|
+
durationMs: finishedAt - startedAt,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.am.applyUsageData(account.index, usage);
|
|
96
|
+
const finishedAt = Date.now();
|
|
97
|
+
this._recordAccount(account, {
|
|
98
|
+
status: 'ok',
|
|
99
|
+
error: null,
|
|
100
|
+
startedAt,
|
|
101
|
+
finishedAt,
|
|
102
|
+
durationMs: finishedAt - startedAt,
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const finishedAt = Date.now();
|
|
106
|
+
this._recordAccount(account, {
|
|
107
|
+
status: 'error',
|
|
108
|
+
error: err?.message || String(err),
|
|
109
|
+
startedAt,
|
|
110
|
+
finishedAt,
|
|
111
|
+
durationMs: finishedAt - startedAt,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getStatus() {
|
|
117
|
+
return {
|
|
118
|
+
enabled: this.intervalMs > 0,
|
|
119
|
+
intervalSeconds: Math.round(this.intervalMs / 1000),
|
|
120
|
+
running: this._running,
|
|
121
|
+
lastRunStartedAt: iso(this.lastRunStartedAt),
|
|
122
|
+
lastRunFinishedAt: iso(this.lastRunFinishedAt),
|
|
123
|
+
nextRunAt: iso(this.nextRunAt),
|
|
124
|
+
accounts: this.am.accounts.map(account => {
|
|
125
|
+
const status = this.accountStatus.get(account.name);
|
|
126
|
+
return {
|
|
127
|
+
name: account.name,
|
|
128
|
+
status: account.type === 'oauth' ? (status?.status || 'never') : 'not-applicable',
|
|
129
|
+
lastProbedAt: iso(status?.finishedAt),
|
|
130
|
+
startedAt: iso(status?.startedAt),
|
|
131
|
+
durationMs: status?.durationMs ?? null,
|
|
132
|
+
error: status?.error || null,
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_recordAccount(account, status) {
|
|
139
|
+
this.accountStatus.set(account.name, {
|
|
140
|
+
...(this.accountStatus.get(account.name) || {}),
|
|
141
|
+
...status,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_withTimeout(promise) {
|
|
146
|
+
return Promise.race([
|
|
147
|
+
promise,
|
|
148
|
+
new Promise(resolve => {
|
|
149
|
+
const t = setTimeout(() => resolve(null), this.timeoutMs);
|
|
150
|
+
t.unref?.();
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function iso(ts) {
|
|
157
|
+
return ts ? new Date(ts).toISOString() : null;
|
|
158
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Streaming body writer for the request logger (used by the reverse-proxy /
|
|
2
|
+
// MITM forward path in server.js). JSON bodies are pretty-printed on the fly via
|
|
3
|
+
// a streaming state machine (src/json-format-stream.js) — never buffered whole,
|
|
4
|
+
// so even ~1M-token bodies cost only the current chunk, and a request that
|
|
5
|
+
// blocks mid-stream leaves its partial (readable) body on disk so you can see
|
|
6
|
+
// exactly how far it got. No size caps.
|
|
7
|
+
|
|
8
|
+
import { JsonStreamFormatter } from './json-format-stream.js';
|
|
9
|
+
|
|
10
|
+
// Tracks how one direction's body is written: decide formatter-vs-raw on the
|
|
11
|
+
// first chunk (event-stream → raw; otherwise pretty-print if it looks like JSON,
|
|
12
|
+
// i.e. the first non-whitespace byte is { or [). Writes the section header once.
|
|
13
|
+
export class BodyWriter {
|
|
14
|
+
constructor(write, label, contentType) {
|
|
15
|
+
this.write = write;
|
|
16
|
+
this.label = label;
|
|
17
|
+
this.isStream = /event-stream/.test(contentType);
|
|
18
|
+
this.decided = false;
|
|
19
|
+
this.fmt = null;
|
|
20
|
+
this.headerWritten = false;
|
|
21
|
+
}
|
|
22
|
+
chunk(buf) {
|
|
23
|
+
if (!buf.length) return;
|
|
24
|
+
if (!this.headerWritten) { this.write(`\n\n=== ${this.label} ===\n`); this.headerWritten = true; }
|
|
25
|
+
if (!this.decided) {
|
|
26
|
+
const first = buf.toString('latin1').trimStart()[0];
|
|
27
|
+
if (!this.isStream && (first === '{' || first === '[')) this.fmt = new JsonStreamFormatter();
|
|
28
|
+
this.decided = true;
|
|
29
|
+
}
|
|
30
|
+
this.write(this.fmt ? this.fmt.push(buf) : buf.toString('latin1'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { importCredentials } from './oauth.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turn configured accounts into the objects the AccountManager is built from:
|
|
5
|
+
* `importFrom` entries have their credentials read from disk, and entries with
|
|
6
|
+
* no usable credential are dropped with a message.
|
|
7
|
+
*
|
|
8
|
+
* Config fields are carried through verbatim — the import supplies ONLY the
|
|
9
|
+
* credential fields. Rebuilding an imported account as `{ name, type, ...creds }`
|
|
10
|
+
* used to discard everything else on it (`disabled`, `priority`, `upstream`,
|
|
11
|
+
* `modelMap`, `models`), so an account disabled on disk silently rejoined
|
|
12
|
+
* rotation on every restart and a third-party backend lost its upstream.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveAccounts(config) {
|
|
15
|
+
const accounts = [];
|
|
16
|
+
for (const acct of config.accounts) {
|
|
17
|
+
if (acct.type === 'oauth') {
|
|
18
|
+
if (acct.importFrom) {
|
|
19
|
+
try {
|
|
20
|
+
const creds = await importCredentials(acct.importFrom);
|
|
21
|
+
// A readable file with no token is as unusable as a missing one; the
|
|
22
|
+
// non-import branch below already refuses that case, and pushing it
|
|
23
|
+
// anyway would send `Bearer undefined` upstream on every request.
|
|
24
|
+
if (!creds.accessToken) {
|
|
25
|
+
console.error(`No token in ${acct.importFrom} for "${acct.name}", skipping`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
accounts.push({ ...acct, ...creds });
|
|
29
|
+
console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error(`Failed to import "${acct.name}": ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
} else if (acct.accessToken) {
|
|
34
|
+
accounts.push(acct);
|
|
35
|
+
} else {
|
|
36
|
+
console.error(`No token for "${acct.name}", skipping`);
|
|
37
|
+
}
|
|
38
|
+
} else if (acct.type === 'apikey' && acct.apiKey) {
|
|
39
|
+
accounts.push(acct);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return accounts;
|
|
43
|
+
}
|