@indigoai-us/hq-cli 5.345.110 → 5.345.111
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/CHANGELOG.md +26 -0
- package/dist/command-catalog.generated.d.ts +17 -5
- package/dist/command-catalog.generated.js +20 -5
- package/dist/commands/capacity.d.ts +26 -0
- package/dist/commands/capacity.js +64 -2
- package/dist/commands/db-provision.js +31 -10
- package/dist/commands/db-remote-deps.js +12 -0
- package/dist/commands/db-sql.js +42 -9
- package/dist/commands/db-status.d.ts +2 -1
- package/dist/commands/db-status.js +33 -7
- package/dist/lib/bot/brief-job.d.ts +22 -0
- package/dist/lib/bot/brief-job.js +38 -0
- package/dist/lib/bot/run.d.ts +10 -0
- package/dist/lib/bot/run.js +54 -9
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +225 -2
- package/dist/lib/capacity/collectors/claude-oauth.js +1053 -43
- package/dist/lib/capacity/ledger.d.ts +10 -1
- package/dist/lib/capacity/ledger.js +8 -1
- package/dist/lib/capacity/project.js +3 -0
- package/dist/lib/db/control-plane.d.ts +22 -0
- package/dist/lib/db/control-plane.js +50 -0
- package/dist/lib/db/remote-engine.d.ts +9 -1
- package/dist/lib/db/remote-engine.js +32 -4
- package/package.json +1 -1
|
@@ -5,10 +5,26 @@
|
|
|
5
5
|
* `Claude Code-credentials`, plus `Claude Code-credentials-<hash>` seats,
|
|
6
6
|
* or the credentials JSON under ~/.claude/ on Linux). GETs
|
|
7
7
|
* https://api.anthropic.com/api/oauth/usage with the oauth-2025-04-20 beta
|
|
8
|
-
* header. This module does not
|
|
9
|
-
* ~/.claude, call an OAuth token endpoint, or invoke the claude CLI except
|
|
8
|
+
* header. This module does not invoke the claude CLI except
|
|
10
9
|
* `claude --version` when the caller does not pass cliVersion.
|
|
11
10
|
*
|
|
11
|
+
* US-014 wires in a token-refresh gate. The gate uses two seams,
|
|
12
|
+
* `defaultListLiveLanes` and `defaultListProcesses`, to decide whether a
|
|
13
|
+
* refresh is safe (no running Claude Code holds the home). Both seams
|
|
14
|
+
* FAIL CLOSED: an error, a timeout, or an unexpected JSON shape returns an
|
|
15
|
+
* error-signalling throw and the caller records `skipped-live` /
|
|
16
|
+
* `skipped-process` and does NOT rotate the refresh token.
|
|
17
|
+
*
|
|
18
|
+
* Residual blind spot on darwin: `ps -axo pid=,command=` cannot read a
|
|
19
|
+
* child process's environment, so on darwin a running `claude` process is
|
|
20
|
+
* conservatively treated as holding the DEFAULT home AND blocking any
|
|
21
|
+
* non-default home refresh. A sibling that sets `CLAUDE_CONFIG_DIR` to a
|
|
22
|
+
* different home still blocks the gate; this is the safe side of the
|
|
23
|
+
* precision/safety tradeoff. On linux `/proc/<pid>/environ` lets the gate
|
|
24
|
+
* read the per-process `CLAUDE_CONFIG_DIR`, so it only blocks the matching
|
|
25
|
+
* home. Windows and other platforms get no process gate; the lane gate is
|
|
26
|
+
* authoritative there.
|
|
27
|
+
*
|
|
12
28
|
* The legacy shape (seven_day_opus populated, no limits[]) is drift.
|
|
13
29
|
* observedAt is the response time. One collect appends every account's
|
|
14
30
|
* readings and failures in one rebuild.
|
|
@@ -18,7 +34,7 @@ import * as crypto from "node:crypto";
|
|
|
18
34
|
import * as fs from "node:fs";
|
|
19
35
|
import * as os from "node:os";
|
|
20
36
|
import * as path from "node:path";
|
|
21
|
-
import { DIR_MODE, FILE_MODE, appendReadings, capacityRoot, EXPIRED_REASON, readCollectorMark, } from "../ledger.js";
|
|
37
|
+
import { DIR_MODE, FILE_MODE, appendReadings, capacityRoot, EXPIRED_REASON, SIGN_IN_REVOKED_REASON, readCollectorMark, redactProviderTokens, } from "../ledger.js";
|
|
22
38
|
import { hashClaudeAccountId } from "./claude-statusline.js";
|
|
23
39
|
export const CLAUDE_OAUTH_PARSER_VERSION = "claude-oauth-usage.1";
|
|
24
40
|
export const CLAUDE_OAUTH_PROVIDER = "claude";
|
|
@@ -36,6 +52,17 @@ export const CLAUDE_OAUTH_FETCH_TIMEOUT_MS = 10_000;
|
|
|
36
52
|
export const CLAUDE_OAUTH_SECURITY_TIMEOUT_MS = 5_000;
|
|
37
53
|
export const CLAUDE_OAUTH_POLL_FILE = "claude-oauth-last-call.json";
|
|
38
54
|
export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
55
|
+
/** Claude Code's refresh endpoint. The URL and the client_id are the ones Claude Code itself posts. */
|
|
56
|
+
export const CLAUDE_OAUTH_REFRESH_URL = "https://console.anthropic.com/v1/oauth/token";
|
|
57
|
+
export const CLAUDE_OAUTH_REFRESH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
58
|
+
/** Minimum gap between refresh attempts for one home when the previous attempt did not revoke. */
|
|
59
|
+
export const CLAUDE_OAUTH_REFRESH_BACKOFF_MS = 10 * 60 * 1000;
|
|
60
|
+
/** Minimum gap between refresh attempts for one home after an invalid_grant. */
|
|
61
|
+
export const CLAUDE_OAUTH_REFRESH_REVOKED_BACKOFF_MS = 60 * 60 * 1000;
|
|
62
|
+
/** Try a refresh when the token is this close to expiry, so one tick does not land on a stale token. */
|
|
63
|
+
export const CLAUDE_OAUTH_REFRESH_PREFLIGHT_MS = 10 * 60 * 1000;
|
|
64
|
+
export const CLAUDE_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
65
|
+
export const CLAUDE_OAUTH_REFRESH_LAST_FILE = "claude-oauth-refresh-last.json";
|
|
39
66
|
const SESSION_MINUTES = 5 * 60;
|
|
40
67
|
const WEEKLY_MINUTES = 7 * 24 * 60;
|
|
41
68
|
const ACCOUNT_ID_KEYS = [
|
|
@@ -190,19 +217,59 @@ export async function collectClaudeOAuth(options = {}) {
|
|
|
190
217
|
if (pollIsFresh(root, account.accountId, nowMs))
|
|
191
218
|
continue;
|
|
192
219
|
writeAccountPoll(root, account.accountId, new Date(nowMs).toISOString());
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
const meta = account.metadata;
|
|
221
|
+
let currentToken = account.token;
|
|
222
|
+
let currentBlocked = account.blocked;
|
|
223
|
+
// Honour the persisted revoked marker, but only when its fingerprint
|
|
224
|
+
// matches the refresh token we are about to use. A stale marker means the
|
|
225
|
+
// human signed in again and the token rotated; clear it and proceed. A
|
|
226
|
+
// match means the account is still signed out from the perspective of
|
|
227
|
+
// the token we hold.
|
|
228
|
+
const revokedMarker = revokedMarkerForCurrentCredential(root, account.accountId, meta.refreshToken);
|
|
229
|
+
if (revokedMarker === "match") {
|
|
230
|
+
currentBlocked = SIGN_IN_REVOKED_REASON;
|
|
231
|
+
}
|
|
232
|
+
else if (revokedMarker === "stale") {
|
|
233
|
+
clearAccountRevoked(root, account.accountId);
|
|
234
|
+
}
|
|
235
|
+
const needsRefresh = (currentBlocked === EXPIRED_REASON
|
|
236
|
+
|| currentBlocked === "unavailable: token_expired"
|
|
237
|
+
|| shouldAttemptRefresh(meta.expiresAt, nowMs))
|
|
238
|
+
&& !!meta.refreshToken
|
|
239
|
+
&& currentBlocked !== SIGN_IN_REVOKED_REASON;
|
|
240
|
+
if (needsRefresh) {
|
|
241
|
+
const refreshResult = await maybeRefreshAccount({
|
|
242
|
+
options,
|
|
243
|
+
root,
|
|
244
|
+
home: meta.home,
|
|
245
|
+
service: meta.service,
|
|
246
|
+
platform: meta.platform,
|
|
247
|
+
refreshToken: meta.refreshToken,
|
|
248
|
+
expiresAt: meta.expiresAt,
|
|
249
|
+
previousDocument: meta.credentialDocument,
|
|
250
|
+
nowMs,
|
|
251
|
+
});
|
|
252
|
+
if (refreshResult?.outcome === "refreshed" && refreshResult.newAccessToken) {
|
|
253
|
+
currentToken = refreshResult.newAccessToken;
|
|
254
|
+
currentBlocked = undefined;
|
|
255
|
+
}
|
|
256
|
+
else if (refreshResult?.outcome === "revoked" || refreshResult?.outcome === "skipped-revoked") {
|
|
257
|
+
currentBlocked = SIGN_IN_REVOKED_REASON;
|
|
258
|
+
markAccountRevoked(root, account.accountId, nowMs, meta.refreshToken);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (currentBlocked) {
|
|
262
|
+
failures.push(failure(mapContext, credentialBlockReason(currentBlocked)));
|
|
195
263
|
continue;
|
|
196
264
|
}
|
|
197
|
-
|
|
198
|
-
if (!token)
|
|
265
|
+
if (!currentToken)
|
|
199
266
|
continue;
|
|
200
267
|
fetched = true;
|
|
201
268
|
let status;
|
|
202
269
|
let text;
|
|
203
270
|
let retryAfter;
|
|
204
271
|
try {
|
|
205
|
-
const response = await fetchImpl(endpoint, usageInit(
|
|
272
|
+
const response = await fetchImpl(endpoint, usageInit(currentToken));
|
|
206
273
|
status = response.status;
|
|
207
274
|
retryAfter = response.headers?.get("retry-after") ?? null;
|
|
208
275
|
text = status === 200 ? await response.text() : "";
|
|
@@ -214,9 +281,13 @@ export async function collectClaudeOAuth(options = {}) {
|
|
|
214
281
|
if (httpStatus === null)
|
|
215
282
|
httpStatus = status;
|
|
216
283
|
if (status === 401 || status === 403) {
|
|
217
|
-
failures.push(failure(mapContext,
|
|
284
|
+
failures.push(failure(mapContext, SIGN_IN_REVOKED_REASON));
|
|
285
|
+
markAccountRevoked(root, account.accountId, nowMs, meta.refreshToken);
|
|
218
286
|
continue;
|
|
219
287
|
}
|
|
288
|
+
if (status === 200) {
|
|
289
|
+
clearAccountRevoked(root, account.accountId);
|
|
290
|
+
}
|
|
220
291
|
if (status === 429 || status >= 500) {
|
|
221
292
|
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
222
293
|
noteHttpBackoff(root, account.accountId, nowMs, retryAfter);
|
|
@@ -303,6 +374,57 @@ async function collectEnumeratedClaude(options, scoped, root, clock, cliVersion)
|
|
|
303
374
|
accountHome: home,
|
|
304
375
|
organization: null,
|
|
305
376
|
};
|
|
377
|
+
// Honour the persisted revoked marker. A matching fingerprint means the
|
|
378
|
+
// same refresh token is still in play; block before we spend a refresh or
|
|
379
|
+
// usage round trip. A stale fingerprint means the human signed in again
|
|
380
|
+
// (the token rotated); clear the marker and proceed. Without this check
|
|
381
|
+
// the usage-401 writer below would be dead on this path once a marker
|
|
382
|
+
// was set, because every subsequent collect would still land on 401.
|
|
383
|
+
const enumRevokedMarker = revokedMarkerForCurrentCredential(root, account.accountId, account.refreshToken);
|
|
384
|
+
if (enumRevokedMarker === "match") {
|
|
385
|
+
account.blocked = SIGN_IN_REVOKED_REASON;
|
|
386
|
+
}
|
|
387
|
+
else if (enumRevokedMarker === "stale") {
|
|
388
|
+
clearAccountRevoked(root, account.accountId);
|
|
389
|
+
}
|
|
390
|
+
// Refresh gate: fire before the usage fetch when the stored token is
|
|
391
|
+
// expired or close to expiry. Successful refresh clears the block and
|
|
392
|
+
// lets the usage fetch proceed with the new token.
|
|
393
|
+
const needsRefresh = (account.blocked === EXPIRED_REASON
|
|
394
|
+
|| account.blocked === "unavailable: token_expired"
|
|
395
|
+
|| shouldAttemptRefresh(account.expiresAt, nowMs))
|
|
396
|
+
&& account.refreshToken
|
|
397
|
+
&& account.blocked !== SIGN_IN_REVOKED_REASON;
|
|
398
|
+
if (needsRefresh) {
|
|
399
|
+
const refreshResult = await maybeRefreshAccount({
|
|
400
|
+
options,
|
|
401
|
+
root,
|
|
402
|
+
home,
|
|
403
|
+
service: account.service,
|
|
404
|
+
platform: account.platform,
|
|
405
|
+
refreshToken: account.refreshToken,
|
|
406
|
+
expiresAt: account.expiresAt,
|
|
407
|
+
previousDocument: account.credentialDocument,
|
|
408
|
+
nowMs,
|
|
409
|
+
});
|
|
410
|
+
if (refreshResult) {
|
|
411
|
+
if (refreshResult.outcome === "refreshed" && refreshResult.newAccessToken) {
|
|
412
|
+
account.token = refreshResult.newAccessToken;
|
|
413
|
+
account.blocked = null;
|
|
414
|
+
if (refreshResult.newExpiresAt !== undefined)
|
|
415
|
+
account.expiresAt = refreshResult.newExpiresAt;
|
|
416
|
+
if (refreshResult.newRefreshToken)
|
|
417
|
+
account.refreshToken = refreshResult.newRefreshToken;
|
|
418
|
+
if (refreshResult.newScopes && refreshResult.newScopes.length > 0)
|
|
419
|
+
account.scopes = refreshResult.newScopes;
|
|
420
|
+
}
|
|
421
|
+
else if (refreshResult.outcome === "revoked" || refreshResult.outcome === "skipped-revoked") {
|
|
422
|
+
account.blocked = SIGN_IN_REVOKED_REASON;
|
|
423
|
+
markAccountRevoked(root, account.accountId, nowMs, account.refreshToken);
|
|
424
|
+
}
|
|
425
|
+
// Other outcomes (skipped-live/process/race/backoff, error) keep whatever block the parser set.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
306
428
|
if (account.blocked) {
|
|
307
429
|
failures.push(failure(mapContext, account.blocked));
|
|
308
430
|
continue;
|
|
@@ -331,9 +453,13 @@ async function collectEnumeratedClaude(options, scoped, root, clock, cliVersion)
|
|
|
331
453
|
if (httpStatus === null)
|
|
332
454
|
httpStatus = status;
|
|
333
455
|
if (status === 401 || status === 403) {
|
|
334
|
-
failures.push(failure(mapContext,
|
|
456
|
+
failures.push(failure(mapContext, SIGN_IN_REVOKED_REASON));
|
|
457
|
+
markAccountRevoked(root, account.accountId, nowMs, account.refreshToken);
|
|
335
458
|
continue;
|
|
336
459
|
}
|
|
460
|
+
if (status === 200) {
|
|
461
|
+
clearAccountRevoked(root, account.accountId);
|
|
462
|
+
}
|
|
337
463
|
if (status === 429 || status >= 500) {
|
|
338
464
|
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
339
465
|
noteHttpBackoff(root, account.accountId, nowMs, retryAfter);
|
|
@@ -389,8 +515,9 @@ function primaryLabel(labels) {
|
|
|
389
515
|
async function readEnumeratedHome(group, options, nowMs, root) {
|
|
390
516
|
const labels = [...group.labels];
|
|
391
517
|
const platform = options.platform ?? process.platform;
|
|
518
|
+
const service = platform === "darwin" ? claudeKeychainServiceName(group.home) : null;
|
|
392
519
|
const seat = platform === "darwin"
|
|
393
|
-
? `keychain:${
|
|
520
|
+
? `keychain:${service}`
|
|
394
521
|
: `file:${path.resolve(group.home)}`;
|
|
395
522
|
const accountId = hashClaudeAccountId(seat);
|
|
396
523
|
if (homeKeychainBlocked(root, group.home, nowMs)) {
|
|
@@ -402,6 +529,12 @@ async function readEnumeratedHome(group, options, nowMs, root) {
|
|
|
402
529
|
planType: null,
|
|
403
530
|
blocked: "unavailable: keychain",
|
|
404
531
|
keychain: "timeout",
|
|
532
|
+
refreshToken: null,
|
|
533
|
+
expiresAt: null,
|
|
534
|
+
credentialDocument: null,
|
|
535
|
+
scopes: null,
|
|
536
|
+
service,
|
|
537
|
+
platform,
|
|
405
538
|
};
|
|
406
539
|
}
|
|
407
540
|
if (pollIsFresh(root, accountId, nowMs)) {
|
|
@@ -413,55 +546,61 @@ async function readEnumeratedHome(group, options, nowMs, root) {
|
|
|
413
546
|
planType: null,
|
|
414
547
|
blocked: null,
|
|
415
548
|
keychain: "no-prompt",
|
|
549
|
+
refreshToken: null,
|
|
550
|
+
expiresAt: null,
|
|
551
|
+
credentialDocument: null,
|
|
552
|
+
scopes: null,
|
|
553
|
+
service,
|
|
554
|
+
platform,
|
|
416
555
|
};
|
|
417
556
|
}
|
|
418
557
|
if (platform === "darwin")
|
|
419
|
-
return readEnumeratedKeychain(group, labels, options, nowMs, root);
|
|
420
|
-
return readEnumeratedFile(group, labels, nowMs);
|
|
558
|
+
return readEnumeratedKeychain(group, labels, options, nowMs, root, service, platform);
|
|
559
|
+
return readEnumeratedFile(group, labels, nowMs, platform);
|
|
421
560
|
}
|
|
422
|
-
function readEnumeratedFile(group, labels, nowMs) {
|
|
561
|
+
function readEnumeratedFile(group, labels, nowMs, platform) {
|
|
423
562
|
const file = path.join(group.home, ".credentials.json");
|
|
424
563
|
let text;
|
|
425
564
|
try {
|
|
426
565
|
text = fs.readFileSync(file, "utf8");
|
|
427
566
|
}
|
|
428
567
|
catch {
|
|
429
|
-
return blockedCredential(group, labels, "signed out: credential missing", "error");
|
|
568
|
+
return blockedCredential(group, labels, "signed out: credential missing", "error", null, platform);
|
|
430
569
|
}
|
|
431
570
|
const parsed = parseCredentialDocument(text, `file:${path.resolve(group.home)}`, nowMs, "underlying");
|
|
432
571
|
if (!parsed || parsed.length === 0) {
|
|
433
|
-
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
572
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error", null, platform);
|
|
434
573
|
}
|
|
435
|
-
return outcomeCredential(group, labels, parsed[0], "no-prompt");
|
|
574
|
+
return outcomeCredential(group, labels, parsed[0], "no-prompt", null, platform);
|
|
436
575
|
}
|
|
437
|
-
async function readEnumeratedKeychain(group, labels, options, nowMs, root) {
|
|
438
|
-
const
|
|
576
|
+
async function readEnumeratedKeychain(group, labels, options, nowMs, root, service, platform) {
|
|
577
|
+
const keychainService = service ?? claudeKeychainServiceName(group.home);
|
|
439
578
|
const username = os.userInfo().username;
|
|
440
|
-
let read = await callSecurity(options, ["find-generic-password", "-s",
|
|
579
|
+
let read = await callSecurity(options, ["find-generic-password", "-s", keychainService, "-a", username, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
|
|
441
580
|
let status = classifySecurity(read);
|
|
442
581
|
if (status === "prompt" || status === "timeout") {
|
|
443
582
|
noteKeychainBackoff(root, group.home, nowMs);
|
|
444
|
-
return blockedCredential(group, labels, "unavailable: keychain", status);
|
|
583
|
+
return blockedCredential(group, labels, "unavailable: keychain", status, keychainService, platform);
|
|
445
584
|
}
|
|
446
585
|
if (isKeychainMiss(read)) {
|
|
447
|
-
read = await callSecurity(options, ["find-generic-password", "-s",
|
|
586
|
+
read = await callSecurity(options, ["find-generic-password", "-s", keychainService, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
|
|
448
587
|
status = classifySecurity(read);
|
|
449
588
|
if (status === "prompt" || status === "timeout") {
|
|
450
589
|
noteKeychainBackoff(root, group.home, nowMs);
|
|
451
|
-
return blockedCredential(group, labels, "unavailable: keychain", status);
|
|
590
|
+
return blockedCredential(group, labels, "unavailable: keychain", status, keychainService, platform);
|
|
452
591
|
}
|
|
453
592
|
}
|
|
454
593
|
if (status !== "no-prompt") {
|
|
455
594
|
const reason = isKeychainMiss(read) ? "signed out: credential missing" : "signed out: credential unreadable";
|
|
456
|
-
return blockedCredential(group, labels, reason, "error");
|
|
595
|
+
return blockedCredential(group, labels, reason, "error", keychainService, platform);
|
|
457
596
|
}
|
|
458
|
-
const parsed = parseCredentialDocument(read.stdout.replace(/\n$/, ""), `keychain:${
|
|
597
|
+
const parsed = parseCredentialDocument(read.stdout.replace(/\n$/, ""), `keychain:${keychainService}`, nowMs, "underlying");
|
|
459
598
|
if (!parsed || parsed.length === 0) {
|
|
460
|
-
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
599
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error", keychainService, platform);
|
|
461
600
|
}
|
|
462
|
-
return outcomeCredential(group, labels, parsed[0], "no-prompt");
|
|
601
|
+
return outcomeCredential(group, labels, parsed[0], "no-prompt", keychainService, platform);
|
|
463
602
|
}
|
|
464
|
-
function blockedCredential(group, labels, reason, keychain) {
|
|
603
|
+
function blockedCredential(group, labels, reason, keychain, service, platform) {
|
|
465
604
|
return {
|
|
466
605
|
accountId: hashClaudeAccountId(`missing:${path.resolve(group.home)}`),
|
|
467
606
|
labels,
|
|
@@ -470,11 +609,18 @@ function blockedCredential(group, labels, reason, keychain) {
|
|
|
470
609
|
planType: null,
|
|
471
610
|
blocked: reason,
|
|
472
611
|
keychain,
|
|
612
|
+
refreshToken: null,
|
|
613
|
+
expiresAt: null,
|
|
614
|
+
credentialDocument: null,
|
|
615
|
+
scopes: null,
|
|
616
|
+
service,
|
|
617
|
+
platform,
|
|
473
618
|
};
|
|
474
619
|
}
|
|
475
|
-
function outcomeCredential(group, labels, outcome, keychain) {
|
|
476
|
-
if (!outcome)
|
|
477
|
-
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
620
|
+
function outcomeCredential(group, labels, outcome, keychain, service, platform) {
|
|
621
|
+
if (!outcome) {
|
|
622
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error", service, platform);
|
|
623
|
+
}
|
|
478
624
|
const blocked = outcome.blocked ? credentialBlockReason(outcome.blocked) : null;
|
|
479
625
|
return {
|
|
480
626
|
accountId: outcome.accountId,
|
|
@@ -484,6 +630,12 @@ function outcomeCredential(group, labels, outcome, keychain) {
|
|
|
484
630
|
planType: outcome.planType,
|
|
485
631
|
blocked,
|
|
486
632
|
keychain,
|
|
633
|
+
refreshToken: outcome.metadata.refreshToken,
|
|
634
|
+
expiresAt: outcome.metadata.expiresAt,
|
|
635
|
+
credentialDocument: outcome.metadata.credentialDocument,
|
|
636
|
+
scopes: outcome.metadata.scopes,
|
|
637
|
+
service,
|
|
638
|
+
platform,
|
|
487
639
|
};
|
|
488
640
|
}
|
|
489
641
|
function credentialBlockReason(blocked) {
|
|
@@ -518,6 +670,21 @@ function mergeEnumerated(loaded) {
|
|
|
518
670
|
existing.blocked = account.blocked;
|
|
519
671
|
existing.planType = account.planType;
|
|
520
672
|
}
|
|
673
|
+
if (!existing.refreshToken && account.refreshToken) {
|
|
674
|
+
existing.refreshToken = account.refreshToken;
|
|
675
|
+
}
|
|
676
|
+
if (existing.expiresAt === null && account.expiresAt !== null) {
|
|
677
|
+
existing.expiresAt = account.expiresAt;
|
|
678
|
+
}
|
|
679
|
+
if (!existing.credentialDocument && account.credentialDocument) {
|
|
680
|
+
existing.credentialDocument = account.credentialDocument;
|
|
681
|
+
}
|
|
682
|
+
if (!existing.scopes && account.scopes) {
|
|
683
|
+
existing.scopes = account.scopes;
|
|
684
|
+
}
|
|
685
|
+
if (!existing.service && account.service) {
|
|
686
|
+
existing.service = account.service;
|
|
687
|
+
}
|
|
521
688
|
}
|
|
522
689
|
return merged;
|
|
523
690
|
}
|
|
@@ -719,6 +886,7 @@ export function claudeSeatAccountId(seat, orgId) {
|
|
|
719
886
|
return hashClaudeAccountId(material);
|
|
720
887
|
}
|
|
721
888
|
function loadAccounts(options, nowMs) {
|
|
889
|
+
const platform = options.platform ?? process.platform;
|
|
722
890
|
if (options.readCredentials) {
|
|
723
891
|
const read = options.readCredentials();
|
|
724
892
|
return {
|
|
@@ -726,13 +894,19 @@ function loadAccounts(options, nowMs) {
|
|
|
726
894
|
store: "injected",
|
|
727
895
|
seat: "injected",
|
|
728
896
|
failures: [],
|
|
729
|
-
accounts: read.accounts.map((account) =>
|
|
897
|
+
accounts: read.accounts.map((account) => {
|
|
898
|
+
const home = account.home ?? null;
|
|
899
|
+
const service = platform === "darwin" && home ? claudeKeychainServiceName(home) : "";
|
|
900
|
+
return classifyAccount(account, nowMs, home, service, platform);
|
|
901
|
+
}),
|
|
730
902
|
};
|
|
731
903
|
}
|
|
732
|
-
if (options.credentialsPath ||
|
|
904
|
+
if (options.credentialsPath || platform !== "darwin") {
|
|
733
905
|
const file = options.credentialsPath ?? defaultClaudeCredentialsPath(options.ledger?.home);
|
|
734
906
|
const seat = `file:${path.basename(file)}`;
|
|
735
|
-
const
|
|
907
|
+
const home = path.dirname(file);
|
|
908
|
+
const service = platform === "darwin" ? claudeKeychainServiceName(home) : "";
|
|
909
|
+
const read = readCredentialFile(file, seat, nowMs, home, service, platform);
|
|
736
910
|
if ("error" in read) {
|
|
737
911
|
return { keychain: "error", accounts: [], failures: [], store: "file", seat };
|
|
738
912
|
}
|
|
@@ -740,23 +914,34 @@ function loadAccounts(options, nowMs) {
|
|
|
740
914
|
}
|
|
741
915
|
const file = defaultClaudeCredentialsPath(options.ledger?.home);
|
|
742
916
|
const seat = `file:${path.basename(file)}`;
|
|
743
|
-
const
|
|
917
|
+
const home = path.dirname(file);
|
|
918
|
+
const service = platform === "darwin" ? claudeKeychainServiceName(home) : "";
|
|
919
|
+
const read = readCredentialFile(file, seat, nowMs, home, service, platform);
|
|
744
920
|
if ("error" in read) {
|
|
745
921
|
return { keychain: "error", accounts: [], failures: [], store: "file", seat };
|
|
746
922
|
}
|
|
747
923
|
return { keychain: "no-prompt", accounts: read.accounts, failures: [], store: "file", seat };
|
|
748
924
|
}
|
|
749
|
-
function classifyAccount(account, nowMs) {
|
|
925
|
+
function classifyAccount(account, nowMs, home = account.home ?? null, service = "", platform = process.platform) {
|
|
750
926
|
const planType = account.planType ?? null;
|
|
927
|
+
const metadata = {
|
|
928
|
+
refreshToken: account.refreshToken ?? null,
|
|
929
|
+
expiresAt: account.expiresAt ?? null,
|
|
930
|
+
credentialDocument: account.credentialDocument ?? null,
|
|
931
|
+
scopes: account.scopes ?? null,
|
|
932
|
+
home,
|
|
933
|
+
service,
|
|
934
|
+
platform,
|
|
935
|
+
};
|
|
751
936
|
if (isExpired(account.expiresAt ?? null, nowMs)) {
|
|
752
|
-
return { blocked: "unavailable: token_expired", accountId: account.accountId, planType };
|
|
937
|
+
return { blocked: "unavailable: token_expired", accountId: account.accountId, planType, metadata };
|
|
753
938
|
}
|
|
754
939
|
if (!hasProfileScope(account.scopes)) {
|
|
755
|
-
return { blocked: "unavailable: missing_scope", accountId: account.accountId, planType };
|
|
940
|
+
return { blocked: "unavailable: missing_scope", accountId: account.accountId, planType, metadata };
|
|
756
941
|
}
|
|
757
|
-
return { token: account.token, accountId: account.accountId, planType };
|
|
942
|
+
return { token: account.token, accountId: account.accountId, planType, metadata };
|
|
758
943
|
}
|
|
759
|
-
function readCredentialFile(file, seat, nowMs) {
|
|
944
|
+
function readCredentialFile(file, seat, nowMs, home, service, platform) {
|
|
760
945
|
let text;
|
|
761
946
|
try {
|
|
762
947
|
text = fs.readFileSync(file, "utf8");
|
|
@@ -764,12 +949,12 @@ function readCredentialFile(file, seat, nowMs) {
|
|
|
764
949
|
catch {
|
|
765
950
|
return { error: "missing" };
|
|
766
951
|
}
|
|
767
|
-
const accounts = parseCredentialDocument(text, seat, nowMs);
|
|
952
|
+
const accounts = parseCredentialDocument(text, seat, nowMs, "seat", home, service, platform);
|
|
768
953
|
if (!accounts || accounts.length === 0)
|
|
769
954
|
return { error: "invalid" };
|
|
770
955
|
return { accounts };
|
|
771
956
|
}
|
|
772
|
-
function parseCredentialDocument(text, seat, nowMs, identity = "seat") {
|
|
957
|
+
function parseCredentialDocument(text, seat, nowMs, identity = "seat", home = null, service = "", platform = process.platform) {
|
|
773
958
|
let parsed;
|
|
774
959
|
try {
|
|
775
960
|
parsed = JSON.parse(text);
|
|
@@ -790,6 +975,9 @@ function parseCredentialDocument(text, seat, nowMs, identity = "seat") {
|
|
|
790
975
|
: (typeof oauth.access_token === "string" ? oauth.access_token : "");
|
|
791
976
|
if (!token)
|
|
792
977
|
return [];
|
|
978
|
+
const refreshToken = typeof oauth.refreshToken === "string" && oauth.refreshToken.trim()
|
|
979
|
+
? oauth.refreshToken
|
|
980
|
+
: (typeof oauth.refresh_token === "string" && oauth.refresh_token.trim() ? oauth.refresh_token : null);
|
|
793
981
|
const orgId = findAccountId(oauth);
|
|
794
982
|
// A profile or credential account id is the seat. An organization id is
|
|
795
983
|
// not: two people in one org must stay two accounts. With no account id,
|
|
@@ -804,7 +992,16 @@ function parseCredentialDocument(text, seat, nowMs, identity = "seat") {
|
|
|
804
992
|
? oauth.subscriptionType.trim()
|
|
805
993
|
: (typeof oauth.rateLimitTier === "string" && oauth.rateLimitTier.trim() ? oauth.rateLimitTier.trim() : null);
|
|
806
994
|
const expiresAt = typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null;
|
|
807
|
-
return [classifyAccount({
|
|
995
|
+
return [classifyAccount({
|
|
996
|
+
token,
|
|
997
|
+
accountId,
|
|
998
|
+
expiresAt,
|
|
999
|
+
scopes,
|
|
1000
|
+
planType,
|
|
1001
|
+
refreshToken,
|
|
1002
|
+
credentialDocument: root,
|
|
1003
|
+
home,
|
|
1004
|
+
}, nowMs, home, service, platform)];
|
|
808
1005
|
}
|
|
809
1006
|
function underlyingAccountRaw(oauth) {
|
|
810
1007
|
for (const key of ["accountUuid", "accountId", "account_id", "uuid"]) {
|
|
@@ -1208,4 +1405,817 @@ function writePoll(root, state) {
|
|
|
1208
1405
|
function isFiniteNumber(value) {
|
|
1209
1406
|
return typeof value === "number" && Number.isFinite(value);
|
|
1210
1407
|
}
|
|
1408
|
+
const REFRESH_ERROR_CLASS_VALUES = new Set([
|
|
1409
|
+
"invalid_grant",
|
|
1410
|
+
"revoked",
|
|
1411
|
+
"server_error",
|
|
1412
|
+
"timeout",
|
|
1413
|
+
"network",
|
|
1414
|
+
"shape",
|
|
1415
|
+
]);
|
|
1416
|
+
const REFRESH_FINGERPRINT_RE = /^[0-9a-f]{16}$/;
|
|
1417
|
+
export function claudeOAuthRefreshLastPath(root) {
|
|
1418
|
+
return path.join(root, CLAUDE_OAUTH_REFRESH_LAST_FILE);
|
|
1419
|
+
}
|
|
1420
|
+
function refreshLastKey(home) {
|
|
1421
|
+
return path.resolve(home);
|
|
1422
|
+
}
|
|
1423
|
+
/** Not the token: a 16 char sha256 prefix. */
|
|
1424
|
+
export function tokenFingerprint(token) {
|
|
1425
|
+
return crypto.createHash("sha256").update(token, "utf8").digest("hex").slice(0, 16);
|
|
1426
|
+
}
|
|
1427
|
+
function emptyRefreshLast() {
|
|
1428
|
+
return { homes: {}, revokedTokens: {}, revokedAccounts: {} };
|
|
1429
|
+
}
|
|
1430
|
+
function readRefreshLast(root) {
|
|
1431
|
+
try {
|
|
1432
|
+
const parsed = JSON.parse(fs.readFileSync(claudeOAuthRefreshLastPath(root), "utf8"));
|
|
1433
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1434
|
+
return emptyRefreshLast();
|
|
1435
|
+
const record = parsed;
|
|
1436
|
+
const homes = {};
|
|
1437
|
+
if (record.homes && typeof record.homes === "object" && !Array.isArray(record.homes)) {
|
|
1438
|
+
for (const [key, value] of Object.entries(record.homes)) {
|
|
1439
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1440
|
+
continue;
|
|
1441
|
+
const row = value;
|
|
1442
|
+
if (typeof row.attemptedAt !== "string" || typeof row.outcome !== "string")
|
|
1443
|
+
continue;
|
|
1444
|
+
const entry = {
|
|
1445
|
+
attemptedAt: row.attemptedAt,
|
|
1446
|
+
outcome: row.outcome,
|
|
1447
|
+
};
|
|
1448
|
+
if (typeof row.revokedRefreshTokenFingerprint === "string" && REFRESH_FINGERPRINT_RE.test(row.revokedRefreshTokenFingerprint)) {
|
|
1449
|
+
entry.revokedRefreshTokenFingerprint = row.revokedRefreshTokenFingerprint;
|
|
1450
|
+
}
|
|
1451
|
+
if (typeof row.errorMessage === "string")
|
|
1452
|
+
entry.errorMessage = row.errorMessage;
|
|
1453
|
+
if (typeof row.errorClass === "string" && REFRESH_ERROR_CLASS_VALUES.has(row.errorClass)) {
|
|
1454
|
+
entry.errorClass = row.errorClass;
|
|
1455
|
+
}
|
|
1456
|
+
if (typeof row.httpStatus === "number")
|
|
1457
|
+
entry.httpStatus = row.httpStatus;
|
|
1458
|
+
if (typeof row.backoffUntil === "string")
|
|
1459
|
+
entry.backoffUntil = row.backoffUntil;
|
|
1460
|
+
if (typeof row.tokenExpiresAt === "string")
|
|
1461
|
+
entry.tokenExpiresAt = row.tokenExpiresAt;
|
|
1462
|
+
homes[key] = entry;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const revokedTokens = {};
|
|
1466
|
+
if (record.revokedTokens && typeof record.revokedTokens === "object" && !Array.isArray(record.revokedTokens)) {
|
|
1467
|
+
for (const [key, value] of Object.entries(record.revokedTokens)) {
|
|
1468
|
+
if (typeof value === "string" && REFRESH_FINGERPRINT_RE.test(key))
|
|
1469
|
+
revokedTokens[key] = value;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const revokedAccounts = {};
|
|
1473
|
+
if (record.revokedAccounts && typeof record.revokedAccounts === "object" && !Array.isArray(record.revokedAccounts)) {
|
|
1474
|
+
for (const [key, value] of Object.entries(record.revokedAccounts)) {
|
|
1475
|
+
if (typeof value === "string") {
|
|
1476
|
+
revokedAccounts[key] = { at: value };
|
|
1477
|
+
}
|
|
1478
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1479
|
+
const row = value;
|
|
1480
|
+
if (typeof row.at !== "string")
|
|
1481
|
+
continue;
|
|
1482
|
+
const entry = { at: row.at };
|
|
1483
|
+
if (typeof row.fingerprint === "string" && REFRESH_FINGERPRINT_RE.test(row.fingerprint)) {
|
|
1484
|
+
entry.fingerprint = row.fingerprint;
|
|
1485
|
+
}
|
|
1486
|
+
revokedAccounts[key] = entry;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return { homes, revokedTokens, revokedAccounts };
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
return emptyRefreshLast();
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function writeRefreshLast(root, data) {
|
|
1497
|
+
fs.mkdirSync(root, { recursive: true, mode: DIR_MODE });
|
|
1498
|
+
try {
|
|
1499
|
+
fs.chmodSync(root, DIR_MODE);
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
// best-effort on an existing dir
|
|
1503
|
+
}
|
|
1504
|
+
const dest = claudeOAuthRefreshLastPath(root);
|
|
1505
|
+
const body = `${JSON.stringify(data, null, 2)}\n`;
|
|
1506
|
+
const tmp = path.join(root, `.${CLAUDE_OAUTH_REFRESH_LAST_FILE}.${process.pid}.tmp`);
|
|
1507
|
+
fs.writeFileSync(tmp, body, { mode: FILE_MODE });
|
|
1508
|
+
fs.renameSync(tmp, dest);
|
|
1509
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
1510
|
+
}
|
|
1511
|
+
/** Reads the last refresh outcome for a home. Exposed for the doctor command. */
|
|
1512
|
+
export function readClaudeRefreshLast(root, home) {
|
|
1513
|
+
const file = readRefreshLast(root);
|
|
1514
|
+
return file.homes[refreshLastKey(home)] ?? null;
|
|
1515
|
+
}
|
|
1516
|
+
function refreshInBackoff(entry, nowMs) {
|
|
1517
|
+
if (!entry || !entry.backoffUntil)
|
|
1518
|
+
return false;
|
|
1519
|
+
const at = Date.parse(entry.backoffUntil);
|
|
1520
|
+
return !Number.isNaN(at) && nowMs < at;
|
|
1521
|
+
}
|
|
1522
|
+
function refreshTokenPersistentlyRevoked(file, token) {
|
|
1523
|
+
if (!token)
|
|
1524
|
+
return false;
|
|
1525
|
+
return Object.prototype.hasOwnProperty.call(file.revokedTokens, tokenFingerprint(token));
|
|
1526
|
+
}
|
|
1527
|
+
function markAccountRevoked(root, accountId, nowMs, refreshToken) {
|
|
1528
|
+
const file = readRefreshLast(root);
|
|
1529
|
+
const entry = { at: new Date(nowMs).toISOString() };
|
|
1530
|
+
if (refreshToken)
|
|
1531
|
+
entry.fingerprint = tokenFingerprint(refreshToken);
|
|
1532
|
+
file.revokedAccounts[accountId] = entry;
|
|
1533
|
+
writeRefreshLast(root, file);
|
|
1534
|
+
}
|
|
1535
|
+
function clearAccountRevoked(root, accountId) {
|
|
1536
|
+
const file = readRefreshLast(root);
|
|
1537
|
+
if (!(accountId in file.revokedAccounts))
|
|
1538
|
+
return;
|
|
1539
|
+
delete file.revokedAccounts[accountId];
|
|
1540
|
+
writeRefreshLast(root, file);
|
|
1541
|
+
}
|
|
1542
|
+
/** @deprecated prefer {@link revokedMarkerForCurrentCredential}; this is the pre-fingerprint shim. */
|
|
1543
|
+
export function isAccountRevokedPersisted(root, accountId) {
|
|
1544
|
+
const file = readRefreshLast(root);
|
|
1545
|
+
return Object.prototype.hasOwnProperty.call(file.revokedAccounts, accountId);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Compare the persisted revoked marker (if any) against the refresh token the
|
|
1549
|
+
* caller is about to use.
|
|
1550
|
+
*
|
|
1551
|
+
* - `"match"`: the marker was written for this exact refresh token. The caller
|
|
1552
|
+
* must still block (nothing has changed since the account was marked signed
|
|
1553
|
+
* out).
|
|
1554
|
+
* - `"stale"`: a marker exists but the stored fingerprint does not match the
|
|
1555
|
+
* current refresh token, meaning the human signed in again and the token
|
|
1556
|
+
* rotated. The caller should clear the marker and proceed; a fresh 401 will
|
|
1557
|
+
* re-mark it.
|
|
1558
|
+
* - `"none"`: no marker is persisted.
|
|
1559
|
+
*
|
|
1560
|
+
* A marker written by an older version carries no fingerprint. We treat that
|
|
1561
|
+
* as `"stale"` so a current sign-in can recover; if the token really is still
|
|
1562
|
+
* revoked the usage fetch will set a fingerprinted marker on the next 401.
|
|
1563
|
+
*/
|
|
1564
|
+
export function revokedMarkerForCurrentCredential(root, accountId, refreshToken) {
|
|
1565
|
+
const file = readRefreshLast(root);
|
|
1566
|
+
const entry = file.revokedAccounts[accountId];
|
|
1567
|
+
if (!entry)
|
|
1568
|
+
return "none";
|
|
1569
|
+
if (!entry.fingerprint)
|
|
1570
|
+
return "stale";
|
|
1571
|
+
if (!refreshToken)
|
|
1572
|
+
return "match";
|
|
1573
|
+
return entry.fingerprint === tokenFingerprint(refreshToken) ? "match" : "stale";
|
|
1574
|
+
}
|
|
1575
|
+
const defaultRefreshFetch = async (url, init) => {
|
|
1576
|
+
const response = await fetch(url, init);
|
|
1577
|
+
return {
|
|
1578
|
+
status: response.status,
|
|
1579
|
+
text: () => response.text(),
|
|
1580
|
+
headers: { get: (name) => response.headers.get(name) },
|
|
1581
|
+
};
|
|
1582
|
+
};
|
|
1583
|
+
const defaultWriteClaudeCredentials = async (request) => {
|
|
1584
|
+
if (request.platform === "darwin") {
|
|
1585
|
+
const username = os.userInfo().username;
|
|
1586
|
+
const spawnOpts = {
|
|
1587
|
+
encoding: "utf8",
|
|
1588
|
+
timeout: CLAUDE_OAUTH_SECURITY_TIMEOUT_MS,
|
|
1589
|
+
windowsHide: true,
|
|
1590
|
+
};
|
|
1591
|
+
// Try the strict selector first; fall back to -s only. Record which selector found
|
|
1592
|
+
// the item so the write uses the SAME selector and does not create a second entry.
|
|
1593
|
+
let selector = null;
|
|
1594
|
+
let preRead = spawnSync("security", ["find-generic-password", "-s", request.service, "-a", username, "-w"], spawnOpts);
|
|
1595
|
+
if (preRead.status === 0) {
|
|
1596
|
+
selector = "sa";
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
preRead = spawnSync("security", ["find-generic-password", "-s", request.service, "-w"], spawnOpts);
|
|
1600
|
+
if (preRead.status === 0)
|
|
1601
|
+
selector = "s";
|
|
1602
|
+
}
|
|
1603
|
+
if (selector !== null && typeof preRead.stdout === "string") {
|
|
1604
|
+
try {
|
|
1605
|
+
const parsed = JSON.parse(preRead.stdout.replace(/\n$/, ""));
|
|
1606
|
+
const oauth = parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object" && !Array.isArray(parsed.claudeAiOauth)
|
|
1607
|
+
? parsed.claudeAiOauth
|
|
1608
|
+
: null;
|
|
1609
|
+
const current = oauth
|
|
1610
|
+
? (typeof oauth.refreshToken === "string" ? oauth.refreshToken : (typeof oauth.refresh_token === "string" ? oauth.refresh_token : null))
|
|
1611
|
+
: null;
|
|
1612
|
+
if (request.previousRefreshToken && current && current !== request.previousRefreshToken) {
|
|
1613
|
+
return { ok: false, raceDetected: true };
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
catch {
|
|
1617
|
+
if (request.previousRefreshToken)
|
|
1618
|
+
return { ok: false, raceDetected: true };
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
else if (request.previousRefreshToken) {
|
|
1622
|
+
// Fail closed: a refresh race check needs a readable prior item.
|
|
1623
|
+
return { ok: false, raceDetected: true };
|
|
1624
|
+
}
|
|
1625
|
+
const body = JSON.stringify(request.document);
|
|
1626
|
+
const writeArgs = selector === "s"
|
|
1627
|
+
? ["add-generic-password", "-U", "-s", request.service, "-w", body]
|
|
1628
|
+
: ["add-generic-password", "-U", "-s", request.service, "-a", username, "-w", body];
|
|
1629
|
+
const wrote = spawnSync("security", writeArgs, spawnOpts);
|
|
1630
|
+
if (wrote.status !== 0) {
|
|
1631
|
+
const message = redactProviderTokens(`security add-generic-password exit ${wrote.status}: ${wrote.stderr ?? ""}`);
|
|
1632
|
+
return { ok: false, error: message };
|
|
1633
|
+
}
|
|
1634
|
+
return { ok: true };
|
|
1635
|
+
}
|
|
1636
|
+
const file = path.join(request.home, ".credentials.json");
|
|
1637
|
+
let current = null;
|
|
1638
|
+
try {
|
|
1639
|
+
const text = fs.readFileSync(file, "utf8");
|
|
1640
|
+
const parsed = JSON.parse(text);
|
|
1641
|
+
const oauth = parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object" && !Array.isArray(parsed.claudeAiOauth)
|
|
1642
|
+
? parsed.claudeAiOauth
|
|
1643
|
+
: null;
|
|
1644
|
+
current = oauth
|
|
1645
|
+
? (typeof oauth.refreshToken === "string" ? oauth.refreshToken : (typeof oauth.refresh_token === "string" ? oauth.refresh_token : null))
|
|
1646
|
+
: null;
|
|
1647
|
+
}
|
|
1648
|
+
catch {
|
|
1649
|
+
// no prior file is fine, we are writing anew
|
|
1650
|
+
}
|
|
1651
|
+
if (request.previousRefreshToken && current && current !== request.previousRefreshToken) {
|
|
1652
|
+
return { ok: false, raceDetected: true };
|
|
1653
|
+
}
|
|
1654
|
+
try {
|
|
1655
|
+
fs.mkdirSync(request.home, { recursive: true, mode: 0o700 });
|
|
1656
|
+
const body = `${JSON.stringify(request.document, null, 2)}\n`;
|
|
1657
|
+
const tmp = path.join(request.home, `.credentials.json.${process.pid}.tmp`);
|
|
1658
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
1659
|
+
fs.renameSync(tmp, file);
|
|
1660
|
+
try {
|
|
1661
|
+
fs.chmodSync(file, 0o600);
|
|
1662
|
+
}
|
|
1663
|
+
catch {
|
|
1664
|
+
// best-effort on a filesystem that does not honour chmod
|
|
1665
|
+
}
|
|
1666
|
+
return { ok: true };
|
|
1667
|
+
}
|
|
1668
|
+
catch (error) {
|
|
1669
|
+
const message = redactProviderTokens(error instanceof Error ? error.message : "write credentials failed");
|
|
1670
|
+
return { ok: false, error: message };
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
function execHqJson(args) {
|
|
1674
|
+
const result = spawnSync("hq", [...args], {
|
|
1675
|
+
encoding: "utf8",
|
|
1676
|
+
timeout: 5_000,
|
|
1677
|
+
windowsHide: true,
|
|
1678
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1679
|
+
});
|
|
1680
|
+
if (result.error) {
|
|
1681
|
+
const code = result.error.code;
|
|
1682
|
+
if (code === "ETIMEDOUT")
|
|
1683
|
+
throw new Error(`hq ${args.join(" ")} timed out`);
|
|
1684
|
+
throw new Error(`hq ${args.join(" ")} failed to spawn: ${result.error.message}`);
|
|
1685
|
+
}
|
|
1686
|
+
if (result.signal)
|
|
1687
|
+
throw new Error(`hq ${args.join(" ")} killed by ${result.signal}`);
|
|
1688
|
+
if (typeof result.status !== "number")
|
|
1689
|
+
throw new Error(`hq ${args.join(" ")} no exit status`);
|
|
1690
|
+
if (result.status !== 0)
|
|
1691
|
+
throw new Error(`hq ${args.join(" ")} exit ${result.status}`);
|
|
1692
|
+
if (!result.stdout)
|
|
1693
|
+
throw new Error(`hq ${args.join(" ")} empty stdout`);
|
|
1694
|
+
try {
|
|
1695
|
+
return JSON.parse(result.stdout);
|
|
1696
|
+
}
|
|
1697
|
+
catch (error) {
|
|
1698
|
+
throw new Error(`hq ${args.join(" ")} invalid JSON: ${error.message}`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Parse `hq lanes list --json` payload into typed rows.
|
|
1703
|
+
* Exported for tests; fail-closed semantics live in defaultListLiveLanes.
|
|
1704
|
+
*/
|
|
1705
|
+
export function parseLanesListPayload(payload) {
|
|
1706
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1707
|
+
throw new Error("lanes list: payload is not an object");
|
|
1708
|
+
}
|
|
1709
|
+
const lanes = payload.lanes;
|
|
1710
|
+
if (!Array.isArray(lanes))
|
|
1711
|
+
throw new Error("lanes list: missing lanes array");
|
|
1712
|
+
const out = [];
|
|
1713
|
+
for (const item of lanes) {
|
|
1714
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1715
|
+
throw new Error("lanes list: row is not an object");
|
|
1716
|
+
}
|
|
1717
|
+
out.push(item);
|
|
1718
|
+
}
|
|
1719
|
+
return out;
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Parse `hq lanes accounts list --json` payload into typed rows.
|
|
1723
|
+
* Exported for tests; fail-closed semantics live in defaultListLiveLanes.
|
|
1724
|
+
*/
|
|
1725
|
+
export function parseAccountsListPayload(payload) {
|
|
1726
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1727
|
+
throw new Error("lanes accounts list: payload is not an object");
|
|
1728
|
+
}
|
|
1729
|
+
const accounts = payload.accounts;
|
|
1730
|
+
if (!Array.isArray(accounts))
|
|
1731
|
+
throw new Error("lanes accounts list: missing accounts array");
|
|
1732
|
+
const out = [];
|
|
1733
|
+
for (const item of accounts) {
|
|
1734
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1735
|
+
throw new Error("lanes accounts list: row is not an object");
|
|
1736
|
+
}
|
|
1737
|
+
out.push(item);
|
|
1738
|
+
}
|
|
1739
|
+
return out;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Join lanes rows against accounts rows to produce LiveLane records.
|
|
1743
|
+
* Exported for tests.
|
|
1744
|
+
*/
|
|
1745
|
+
export function buildLiveLanesFromPayloads(lanes, accounts) {
|
|
1746
|
+
const homeByAccount = new Map();
|
|
1747
|
+
for (const acct of accounts) {
|
|
1748
|
+
const id = typeof acct.id === "string" ? acct.id : null;
|
|
1749
|
+
if (!id)
|
|
1750
|
+
continue;
|
|
1751
|
+
const effective = typeof acct.effective_home === "string" && acct.effective_home.trim()
|
|
1752
|
+
? acct.effective_home.trim()
|
|
1753
|
+
: (typeof acct.home === "string" && acct.home.trim() ? acct.home.trim() : null);
|
|
1754
|
+
if (effective)
|
|
1755
|
+
homeByAccount.set(id, effective);
|
|
1756
|
+
}
|
|
1757
|
+
const out = [];
|
|
1758
|
+
for (const row of lanes) {
|
|
1759
|
+
const state = typeof row.state === "string" ? row.state : "";
|
|
1760
|
+
const terminalReason = typeof row.terminal_reason === "string" ? row.terminal_reason : null;
|
|
1761
|
+
const detailsObj = row.terminal_details && typeof row.terminal_details === "object" && !Array.isArray(row.terminal_details)
|
|
1762
|
+
? row.terminal_details
|
|
1763
|
+
: null;
|
|
1764
|
+
const detailAccount = detailsObj && typeof detailsObj.account === "string" ? detailsObj.account : null;
|
|
1765
|
+
const detailProvider = detailsObj && typeof detailsObj.provider === "string" ? detailsObj.provider : null;
|
|
1766
|
+
const laneAccount = typeof row.account === "string" ? row.account : null;
|
|
1767
|
+
const laneProvider = typeof row.provider === "string" ? row.provider : "";
|
|
1768
|
+
const isLive = state === "running" || state === "queued" || state === "awaiting_input";
|
|
1769
|
+
const isAuthFailed = terminalReason === "provider_auth_failed";
|
|
1770
|
+
if (!isLive && !isAuthFailed)
|
|
1771
|
+
continue;
|
|
1772
|
+
const account = isAuthFailed ? (detailAccount ?? laneAccount) : laneAccount;
|
|
1773
|
+
const provider = (isAuthFailed ? (detailProvider ?? laneProvider) : laneProvider) || "claude";
|
|
1774
|
+
if (provider !== "claude")
|
|
1775
|
+
continue;
|
|
1776
|
+
const home = account ? homeByAccount.get(account) ?? null : null;
|
|
1777
|
+
out.push({ home, account, provider, state, reason: terminalReason, isLive, isAuthFailed });
|
|
1778
|
+
}
|
|
1779
|
+
return out;
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Fail-closed enumeration of live Claude lanes and auth-failed lanes.
|
|
1783
|
+
* Throws on hq call failure or unexpected JSON shape; callers skip the refresh.
|
|
1784
|
+
*/
|
|
1785
|
+
const defaultListLiveLanes = async () => {
|
|
1786
|
+
const lanesPayload = execHqJson(["lanes", "list", "--json"]);
|
|
1787
|
+
const accountsPayload = execHqJson(["lanes", "accounts", "list", "--json"]);
|
|
1788
|
+
const lanes = parseLanesListPayload(lanesPayload);
|
|
1789
|
+
const accounts = parseAccountsListPayload(accountsPayload);
|
|
1790
|
+
return buildLiveLanesFromPayloads(lanes, accounts);
|
|
1791
|
+
};
|
|
1792
|
+
/**
|
|
1793
|
+
* Parse `ps -axo pid=,command=` into rows that mention the `claude` executable.
|
|
1794
|
+
* Exported for tests. Returned rows carry `probe: "command-only"` so the gate
|
|
1795
|
+
* knows it cannot read per-process CLAUDE_CONFIG_DIR on darwin.
|
|
1796
|
+
*/
|
|
1797
|
+
export function parsePsClaudeOutput(stdout) {
|
|
1798
|
+
const out = [];
|
|
1799
|
+
const self = process.pid;
|
|
1800
|
+
for (const rawLine of stdout.split("\n")) {
|
|
1801
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
1802
|
+
if (!line)
|
|
1803
|
+
continue;
|
|
1804
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
1805
|
+
if (!match)
|
|
1806
|
+
continue;
|
|
1807
|
+
const pid = Number(match[1]);
|
|
1808
|
+
if (!Number.isFinite(pid) || pid === self)
|
|
1809
|
+
continue;
|
|
1810
|
+
const command = match[2];
|
|
1811
|
+
// Match the executable segment only. Avoid "/claude-code/other", "my-claude-wrapper", etc.
|
|
1812
|
+
// Accepts: "claude ...", "/usr/local/bin/claude ...", "node .../claude ..."
|
|
1813
|
+
const exec = /(?:^|\/|\s)claude(?:$|\s)/.test(command) ? "claude" : null;
|
|
1814
|
+
if (!exec)
|
|
1815
|
+
continue;
|
|
1816
|
+
// Skip our own hq subcommands if any ever shell out to `claude ...`
|
|
1817
|
+
if (/\bhq\s+/.test(command))
|
|
1818
|
+
continue;
|
|
1819
|
+
out.push({ pid, env: {}, probe: "command-only" });
|
|
1820
|
+
}
|
|
1821
|
+
return out;
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Fail-closed enumeration of running Claude Code processes.
|
|
1825
|
+
*
|
|
1826
|
+
* linux: /proc/<pid>/environ gives us per-process CLAUDE_CONFIG_DIR; absent
|
|
1827
|
+
* value means the process holds the default home.
|
|
1828
|
+
* darwin: ps cannot read a child process environment, so we return
|
|
1829
|
+
* `probe: "command-only"` rows. The gate treats such a row as holding the
|
|
1830
|
+
* default home AND blocking every non-default home.
|
|
1831
|
+
* other platforms: return []. The lane gate is authoritative there.
|
|
1832
|
+
*
|
|
1833
|
+
* Throws on ps/readdir failure so the caller skips refresh.
|
|
1834
|
+
*/
|
|
1835
|
+
const defaultListProcesses = async () => {
|
|
1836
|
+
if (process.platform === "linux") {
|
|
1837
|
+
const procDir = "/proc";
|
|
1838
|
+
let entries;
|
|
1839
|
+
try {
|
|
1840
|
+
entries = fs.readdirSync(procDir);
|
|
1841
|
+
}
|
|
1842
|
+
catch (error) {
|
|
1843
|
+
throw new Error(`readdir /proc failed: ${error.message}`);
|
|
1844
|
+
}
|
|
1845
|
+
const out = [];
|
|
1846
|
+
for (const entry of entries) {
|
|
1847
|
+
if (!/^\d+$/.test(entry))
|
|
1848
|
+
continue;
|
|
1849
|
+
const pid = Number(entry);
|
|
1850
|
+
if (pid === process.pid)
|
|
1851
|
+
continue;
|
|
1852
|
+
let cmdline;
|
|
1853
|
+
try {
|
|
1854
|
+
cmdline = fs.readFileSync(path.join(procDir, entry, "cmdline"), "utf8");
|
|
1855
|
+
}
|
|
1856
|
+
catch {
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
const argv0 = cmdline.split("\0")[0] ?? "";
|
|
1860
|
+
const base = argv0.split("/").pop() ?? "";
|
|
1861
|
+
if (base !== "claude")
|
|
1862
|
+
continue;
|
|
1863
|
+
let envText;
|
|
1864
|
+
try {
|
|
1865
|
+
envText = fs.readFileSync(path.join(procDir, entry, "environ"), "utf8");
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
// Running process we cannot inspect: fail closed by raising.
|
|
1869
|
+
throw new Error(`read /proc/${entry}/environ failed`);
|
|
1870
|
+
}
|
|
1871
|
+
const match = /(?:^|\0)CLAUDE_CONFIG_DIR=([^\0]+)/.exec(envText);
|
|
1872
|
+
out.push({
|
|
1873
|
+
pid,
|
|
1874
|
+
env: match && match[1] ? { CLAUDE_CONFIG_DIR: match[1] } : {},
|
|
1875
|
+
probe: "env",
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
return out;
|
|
1879
|
+
}
|
|
1880
|
+
if (process.platform === "darwin") {
|
|
1881
|
+
const result = spawnSync("ps", ["-axo", "pid=,command="], {
|
|
1882
|
+
encoding: "utf8",
|
|
1883
|
+
timeout: 5_000,
|
|
1884
|
+
windowsHide: true,
|
|
1885
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1886
|
+
});
|
|
1887
|
+
if (result.error)
|
|
1888
|
+
throw new Error(`ps failed to spawn: ${result.error.message}`);
|
|
1889
|
+
if (result.signal)
|
|
1890
|
+
throw new Error(`ps killed by ${result.signal}`);
|
|
1891
|
+
if (typeof result.status !== "number")
|
|
1892
|
+
throw new Error("ps no exit status");
|
|
1893
|
+
if (result.status !== 0)
|
|
1894
|
+
throw new Error(`ps exit ${result.status}`);
|
|
1895
|
+
return parsePsClaudeOutput(result.stdout ?? "");
|
|
1896
|
+
}
|
|
1897
|
+
return [];
|
|
1898
|
+
};
|
|
1899
|
+
async function attemptClaudeTokenRefresh(ctx) {
|
|
1900
|
+
const preFile = readRefreshLast(ctx.root);
|
|
1901
|
+
const key = refreshLastKey(ctx.home);
|
|
1902
|
+
const entry = preFile.homes[key];
|
|
1903
|
+
if (refreshInBackoff(entry, ctx.nowMs))
|
|
1904
|
+
return { outcome: "skipped-backoff" };
|
|
1905
|
+
if (refreshTokenPersistentlyRevoked(preFile, ctx.refreshToken))
|
|
1906
|
+
return { outcome: "skipped-revoked" };
|
|
1907
|
+
let liveLanes;
|
|
1908
|
+
try {
|
|
1909
|
+
liveLanes = await ctx.listLiveLanes();
|
|
1910
|
+
}
|
|
1911
|
+
catch (error) {
|
|
1912
|
+
const message = redactProviderTokens(error instanceof Error ? error.message : "listLiveLanes failed");
|
|
1913
|
+
recordRefresh(ctx.root, key, {
|
|
1914
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
1915
|
+
outcome: "skipped-live-check-failed",
|
|
1916
|
+
errorMessage: message,
|
|
1917
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
1918
|
+
});
|
|
1919
|
+
return { outcome: "skipped-live-check-failed", errorMessage: message };
|
|
1920
|
+
}
|
|
1921
|
+
const resolvedHome = path.resolve(ctx.home);
|
|
1922
|
+
const sameHome = (candidate) => typeof candidate === "string" && candidate.length > 0 && path.resolve(candidate) === resolvedHome;
|
|
1923
|
+
// Addendum 1: if the lane watcher classified a 401/403 on this home as
|
|
1924
|
+
// provider_auth_failed, treat the account as already signed out and record
|
|
1925
|
+
// revoked immediately. No token endpoint call, no response body stored.
|
|
1926
|
+
const authFailedHere = liveLanes.find((lane) => lane.isAuthFailed && sameHome(lane.home));
|
|
1927
|
+
if (authFailedHere) {
|
|
1928
|
+
const fingerprint = tokenFingerprint(ctx.refreshToken);
|
|
1929
|
+
const now = new Date(ctx.nowMs).toISOString();
|
|
1930
|
+
recordRefresh(ctx.root, key, {
|
|
1931
|
+
attemptedAt: now,
|
|
1932
|
+
outcome: "revoked",
|
|
1933
|
+
errorClass: "revoked",
|
|
1934
|
+
revokedRefreshTokenFingerprint: fingerprint,
|
|
1935
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_REVOKED_BACKOFF_MS).toISOString(),
|
|
1936
|
+
}, (file) => {
|
|
1937
|
+
file.revokedTokens[fingerprint] = now;
|
|
1938
|
+
});
|
|
1939
|
+
return { outcome: "revoked", errorClass: "revoked" };
|
|
1940
|
+
}
|
|
1941
|
+
if (liveLanes.some((lane) => lane.isLive && sameHome(lane.home))) {
|
|
1942
|
+
recordRefresh(ctx.root, key, { attemptedAt: new Date(ctx.nowMs).toISOString(), outcome: "skipped-live" });
|
|
1943
|
+
return { outcome: "skipped-live" };
|
|
1944
|
+
}
|
|
1945
|
+
let procs;
|
|
1946
|
+
try {
|
|
1947
|
+
procs = await ctx.listProcesses();
|
|
1948
|
+
}
|
|
1949
|
+
catch (error) {
|
|
1950
|
+
const message = redactProviderTokens(error instanceof Error ? error.message : "listProcesses failed");
|
|
1951
|
+
recordRefresh(ctx.root, key, {
|
|
1952
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
1953
|
+
outcome: "skipped-process-check-failed",
|
|
1954
|
+
errorMessage: message,
|
|
1955
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
1956
|
+
});
|
|
1957
|
+
return { outcome: "skipped-process-check-failed", errorMessage: message };
|
|
1958
|
+
}
|
|
1959
|
+
const defaultHome = path.join(os.homedir(), ".claude");
|
|
1960
|
+
const isDefaultHome = resolvedHome === path.resolve(defaultHome);
|
|
1961
|
+
const procBlocks = procs.some((proc) => {
|
|
1962
|
+
if (proc.probe === "env") {
|
|
1963
|
+
const dir = typeof proc.env.CLAUDE_CONFIG_DIR === "string" ? proc.env.CLAUDE_CONFIG_DIR : "";
|
|
1964
|
+
// On linux we have a real env read. Default home is held when
|
|
1965
|
+
// CLAUDE_CONFIG_DIR is empty; non-default home only when it points here.
|
|
1966
|
+
if (!dir)
|
|
1967
|
+
return isDefaultHome;
|
|
1968
|
+
return path.resolve(dir) === resolvedHome;
|
|
1969
|
+
}
|
|
1970
|
+
// command-only (darwin): we cannot read the per-process env. Conservatively
|
|
1971
|
+
// treat the row as holding the default home AND blocking non-default homes.
|
|
1972
|
+
return true;
|
|
1973
|
+
});
|
|
1974
|
+
if (procBlocks) {
|
|
1975
|
+
recordRefresh(ctx.root, key, { attemptedAt: new Date(ctx.nowMs).toISOString(), outcome: "skipped-process" });
|
|
1976
|
+
return { outcome: "skipped-process" };
|
|
1977
|
+
}
|
|
1978
|
+
let status;
|
|
1979
|
+
let text;
|
|
1980
|
+
try {
|
|
1981
|
+
const response = await ctx.refreshFetch(ctx.tokenEndpoint, {
|
|
1982
|
+
method: "POST",
|
|
1983
|
+
headers: {
|
|
1984
|
+
"content-type": "application/json",
|
|
1985
|
+
Accept: "application/json",
|
|
1986
|
+
},
|
|
1987
|
+
body: JSON.stringify({
|
|
1988
|
+
grant_type: "refresh_token",
|
|
1989
|
+
refresh_token: ctx.refreshToken,
|
|
1990
|
+
client_id: CLAUDE_OAUTH_REFRESH_CLIENT_ID,
|
|
1991
|
+
}),
|
|
1992
|
+
redirect: "manual",
|
|
1993
|
+
signal: AbortSignal.timeout(CLAUDE_OAUTH_REFRESH_TIMEOUT_MS),
|
|
1994
|
+
});
|
|
1995
|
+
status = response.status;
|
|
1996
|
+
try {
|
|
1997
|
+
text = await response.text();
|
|
1998
|
+
}
|
|
1999
|
+
catch {
|
|
2000
|
+
text = "";
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
catch (error) {
|
|
2004
|
+
const isTimeout = error instanceof Error
|
|
2005
|
+
&& (error.name === "TimeoutError" || error.name === "AbortError" || /timeout/i.test(error.message));
|
|
2006
|
+
const errorClass = isTimeout ? "timeout" : "network";
|
|
2007
|
+
recordRefresh(ctx.root, key, {
|
|
2008
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
2009
|
+
outcome: "error",
|
|
2010
|
+
errorClass,
|
|
2011
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2012
|
+
});
|
|
2013
|
+
return { outcome: "error", errorClass };
|
|
2014
|
+
}
|
|
2015
|
+
// Parse the response body ONLY to classify. The parsed error/error_description
|
|
2016
|
+
// is NEVER persisted; only a fixed classification string and the HTTP status.
|
|
2017
|
+
let parsedErrorField = null;
|
|
2018
|
+
let parsedErrorDescription = null;
|
|
2019
|
+
let bodyIsJson = false;
|
|
2020
|
+
let parsedBody = null;
|
|
2021
|
+
if (text) {
|
|
2022
|
+
try {
|
|
2023
|
+
const maybe = JSON.parse(text);
|
|
2024
|
+
if (maybe && typeof maybe === "object" && !Array.isArray(maybe)) {
|
|
2025
|
+
parsedBody = maybe;
|
|
2026
|
+
bodyIsJson = true;
|
|
2027
|
+
if (typeof parsedBody.error === "string")
|
|
2028
|
+
parsedErrorField = parsedBody.error;
|
|
2029
|
+
if (typeof parsedBody.error_description === "string")
|
|
2030
|
+
parsedErrorDescription = parsedBody.error_description;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
// non-JSON body is not stored; we only know we got a status back
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
if (status === 400 || status === 401) {
|
|
2038
|
+
const errField = (parsedErrorField ?? "").toLowerCase();
|
|
2039
|
+
const descField = (parsedErrorDescription ?? "").toLowerCase();
|
|
2040
|
+
const revokedHit = errField === "invalid_grant"
|
|
2041
|
+
|| /\b(revoked|invalid)\b/.test(errField)
|
|
2042
|
+
|| /\b(revoked|invalid)\b/.test(descField);
|
|
2043
|
+
if (revokedHit) {
|
|
2044
|
+
const fingerprint = tokenFingerprint(ctx.refreshToken);
|
|
2045
|
+
const now = new Date(ctx.nowMs).toISOString();
|
|
2046
|
+
const errorClass = errField === "invalid_grant" ? "invalid_grant" : "revoked";
|
|
2047
|
+
recordRefresh(ctx.root, key, {
|
|
2048
|
+
attemptedAt: now,
|
|
2049
|
+
outcome: "revoked",
|
|
2050
|
+
errorClass,
|
|
2051
|
+
revokedRefreshTokenFingerprint: fingerprint,
|
|
2052
|
+
httpStatus: status,
|
|
2053
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_REVOKED_BACKOFF_MS).toISOString(),
|
|
2054
|
+
}, (file) => {
|
|
2055
|
+
file.revokedTokens[fingerprint] = now;
|
|
2056
|
+
});
|
|
2057
|
+
return { outcome: "revoked", errorClass, httpStatus: status };
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
if (status !== 200) {
|
|
2061
|
+
const errorClass = "server_error";
|
|
2062
|
+
recordRefresh(ctx.root, key, {
|
|
2063
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
2064
|
+
outcome: "error",
|
|
2065
|
+
errorClass,
|
|
2066
|
+
httpStatus: status,
|
|
2067
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2068
|
+
});
|
|
2069
|
+
return { outcome: "error", errorClass, httpStatus: status };
|
|
2070
|
+
}
|
|
2071
|
+
if (!bodyIsJson || !parsedBody) {
|
|
2072
|
+
recordRefresh(ctx.root, key, {
|
|
2073
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
2074
|
+
outcome: "error",
|
|
2075
|
+
errorClass: "shape",
|
|
2076
|
+
httpStatus: status,
|
|
2077
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2078
|
+
});
|
|
2079
|
+
return { outcome: "error", errorClass: "shape", httpStatus: status };
|
|
2080
|
+
}
|
|
2081
|
+
const body = parsedBody;
|
|
2082
|
+
const accessToken = typeof body.access_token === "string" && body.access_token.trim() ? body.access_token : "";
|
|
2083
|
+
if (!accessToken) {
|
|
2084
|
+
recordRefresh(ctx.root, key, {
|
|
2085
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
2086
|
+
outcome: "error",
|
|
2087
|
+
errorClass: "shape",
|
|
2088
|
+
httpStatus: status,
|
|
2089
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2090
|
+
});
|
|
2091
|
+
return { outcome: "error", errorClass: "shape", httpStatus: status };
|
|
2092
|
+
}
|
|
2093
|
+
const newRefresh = typeof body.refresh_token === "string" && body.refresh_token.trim() ? body.refresh_token : ctx.refreshToken;
|
|
2094
|
+
const expiresIn = typeof body.expires_in === "number" && Number.isFinite(body.expires_in) ? body.expires_in : null;
|
|
2095
|
+
if (expiresIn === null) {
|
|
2096
|
+
// Missing expires_in used to fall back to the previous (expired) expiresAt,
|
|
2097
|
+
// which lets a bogus response rotate the stored refresh token forever.
|
|
2098
|
+
// Treat as a failed refresh instead.
|
|
2099
|
+
recordRefresh(ctx.root, key, {
|
|
2100
|
+
attemptedAt: new Date(ctx.nowMs).toISOString(),
|
|
2101
|
+
outcome: "error",
|
|
2102
|
+
errorClass: "shape",
|
|
2103
|
+
httpStatus: status,
|
|
2104
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2105
|
+
});
|
|
2106
|
+
return { outcome: "error", errorClass: "shape", httpStatus: status };
|
|
2107
|
+
}
|
|
2108
|
+
const newExpiresAt = ctx.nowMs + expiresIn * 1000;
|
|
2109
|
+
let scopesOut = null;
|
|
2110
|
+
if (Array.isArray(body.scopes)) {
|
|
2111
|
+
scopesOut = body.scopes.filter((s) => typeof s === "string");
|
|
2112
|
+
}
|
|
2113
|
+
else if (typeof body.scope === "string") {
|
|
2114
|
+
scopesOut = body.scope.split(/\s+/).filter((s) => s.length > 0);
|
|
2115
|
+
}
|
|
2116
|
+
const document = buildRefreshedDocument(ctx.previousDocument, {
|
|
2117
|
+
accessToken,
|
|
2118
|
+
refreshToken: newRefresh,
|
|
2119
|
+
expiresAt: newExpiresAt,
|
|
2120
|
+
scopes: scopesOut,
|
|
2121
|
+
});
|
|
2122
|
+
const writeResult = await ctx.writeCredentials({
|
|
2123
|
+
home: ctx.home,
|
|
2124
|
+
service: ctx.service ?? "",
|
|
2125
|
+
platform: ctx.platform,
|
|
2126
|
+
document,
|
|
2127
|
+
previousRefreshToken: ctx.refreshToken,
|
|
2128
|
+
});
|
|
2129
|
+
const now = new Date(ctx.nowMs).toISOString();
|
|
2130
|
+
if (!writeResult.ok && writeResult.raceDetected) {
|
|
2131
|
+
recordRefresh(ctx.root, key, { attemptedAt: now, outcome: "skipped-race" });
|
|
2132
|
+
return { outcome: "skipped-race" };
|
|
2133
|
+
}
|
|
2134
|
+
if (!writeResult.ok) {
|
|
2135
|
+
const message = writeResult.error ?? "writeCredentials failed";
|
|
2136
|
+
recordRefresh(ctx.root, key, {
|
|
2137
|
+
attemptedAt: now,
|
|
2138
|
+
outcome: "error",
|
|
2139
|
+
errorMessage: message,
|
|
2140
|
+
backoffUntil: new Date(ctx.nowMs + CLAUDE_OAUTH_REFRESH_BACKOFF_MS).toISOString(),
|
|
2141
|
+
});
|
|
2142
|
+
return { outcome: "error", errorMessage: message };
|
|
2143
|
+
}
|
|
2144
|
+
const tokenExpiresAt = newExpiresAt !== null && Number.isFinite(newExpiresAt)
|
|
2145
|
+
? new Date(newExpiresAt < 1e12 ? newExpiresAt * 1000 : newExpiresAt).toISOString()
|
|
2146
|
+
: undefined;
|
|
2147
|
+
recordRefresh(ctx.root, key, { attemptedAt: now, outcome: "refreshed", tokenExpiresAt });
|
|
2148
|
+
return {
|
|
2149
|
+
outcome: "refreshed",
|
|
2150
|
+
newAccessToken: accessToken,
|
|
2151
|
+
newExpiresAt: newExpiresAt ?? undefined,
|
|
2152
|
+
newRefreshToken: newRefresh,
|
|
2153
|
+
newScopes: scopesOut,
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
function recordRefresh(root, key, entry, mutate) {
|
|
2157
|
+
const file = readRefreshLast(root);
|
|
2158
|
+
file.homes[key] = entry;
|
|
2159
|
+
if (mutate)
|
|
2160
|
+
mutate(file);
|
|
2161
|
+
writeRefreshLast(root, file);
|
|
2162
|
+
}
|
|
2163
|
+
function buildRefreshedDocument(previous, update) {
|
|
2164
|
+
const base = previous ? { ...previous } : {};
|
|
2165
|
+
const existing = base.claudeAiOauth && typeof base.claudeAiOauth === "object" && !Array.isArray(base.claudeAiOauth)
|
|
2166
|
+
? { ...base.claudeAiOauth }
|
|
2167
|
+
: {};
|
|
2168
|
+
existing.accessToken = update.accessToken;
|
|
2169
|
+
existing.refreshToken = update.refreshToken;
|
|
2170
|
+
if (update.expiresAt !== null)
|
|
2171
|
+
existing.expiresAt = update.expiresAt;
|
|
2172
|
+
if (update.scopes && update.scopes.length > 0)
|
|
2173
|
+
existing.scopes = [...update.scopes];
|
|
2174
|
+
base.claudeAiOauth = existing;
|
|
2175
|
+
return base;
|
|
2176
|
+
}
|
|
2177
|
+
function expiresAtFromDocument(document) {
|
|
2178
|
+
const oauth = document.claudeAiOauth;
|
|
2179
|
+
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth))
|
|
2180
|
+
return null;
|
|
2181
|
+
const value = oauth.expiresAt;
|
|
2182
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
2183
|
+
}
|
|
2184
|
+
function shouldAttemptRefresh(expiresAt, nowMs) {
|
|
2185
|
+
if (expiresAt === null)
|
|
2186
|
+
return false;
|
|
2187
|
+
const ms = expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
|
|
2188
|
+
return ms <= nowMs + CLAUDE_OAUTH_REFRESH_PREFLIGHT_MS;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Gate wrapper used by both the direct and enumerated collect paths.
|
|
2192
|
+
* Returns null when the gate did not trip (no refresh token, not near expiry,
|
|
2193
|
+
* no home info). Otherwise returns the refresh result.
|
|
2194
|
+
*/
|
|
2195
|
+
async function maybeRefreshAccount(params) {
|
|
2196
|
+
const { options, root, home, service, platform, refreshToken, expiresAt, previousDocument, nowMs } = params;
|
|
2197
|
+
if (!refreshToken || !home)
|
|
2198
|
+
return null;
|
|
2199
|
+
if (!shouldAttemptRefresh(expiresAt, nowMs))
|
|
2200
|
+
return null;
|
|
2201
|
+
const writeCredentials = options.writeCredentials ?? defaultWriteClaudeCredentials;
|
|
2202
|
+
const refreshFetch = options.refreshFetch ?? defaultRefreshFetch;
|
|
2203
|
+
const tokenEndpoint = options.tokenEndpoint ?? CLAUDE_OAUTH_REFRESH_URL;
|
|
2204
|
+
const listLiveLanes = options.listLiveLanes ?? defaultListLiveLanes;
|
|
2205
|
+
const listProcesses = options.listProcesses ?? defaultListProcesses;
|
|
2206
|
+
return attemptClaudeTokenRefresh({
|
|
2207
|
+
root,
|
|
2208
|
+
home,
|
|
2209
|
+
service,
|
|
2210
|
+
platform,
|
|
2211
|
+
refreshToken,
|
|
2212
|
+
previousDocument,
|
|
2213
|
+
writeCredentials,
|
|
2214
|
+
refreshFetch,
|
|
2215
|
+
tokenEndpoint,
|
|
2216
|
+
listLiveLanes,
|
|
2217
|
+
listProcesses,
|
|
2218
|
+
nowMs,
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
1211
2221
|
//# sourceMappingURL=claude-oauth.js.map
|