@indigoai-us/hq-cli 5.345.109 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +17 -5
  3. package/dist/command-catalog.generated.js +20 -5
  4. package/dist/commands/capacity.d.ts +26 -0
  5. package/dist/commands/capacity.js +64 -2
  6. package/dist/commands/core-policy.d.ts +4 -1
  7. package/dist/commands/core-policy.js +21 -43
  8. package/dist/commands/core.js +2 -7
  9. package/dist/commands/db-provision.js +31 -10
  10. package/dist/commands/db-remote-deps.js +12 -0
  11. package/dist/commands/db-sql.js +42 -9
  12. package/dist/commands/db-status.d.ts +2 -1
  13. package/dist/commands/db-status.js +33 -7
  14. package/dist/fast-scaffold.js +0 -12
  15. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +1414 -923
  16. package/dist/hq-cloud-sync-runner/package.json +1 -1
  17. package/dist/lib/bot/brief-job.d.ts +22 -0
  18. package/dist/lib/bot/brief-job.js +38 -0
  19. package/dist/lib/bot/run.d.ts +10 -0
  20. package/dist/lib/bot/run.js +54 -9
  21. package/dist/lib/capacity/collectors/claude-oauth.d.ts +225 -2
  22. package/dist/lib/capacity/collectors/claude-oauth.js +1053 -43
  23. package/dist/lib/capacity/ledger.d.ts +10 -1
  24. package/dist/lib/capacity/ledger.js +8 -1
  25. package/dist/lib/capacity/project.js +3 -0
  26. package/dist/lib/db/control-plane.d.ts +22 -0
  27. package/dist/lib/db/control-plane.js +50 -0
  28. package/dist/lib/db/remote-engine.d.ts +9 -1
  29. package/dist/lib/db/remote-engine.js +32 -4
  30. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  31. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  32. package/package.json +2 -2
  33. package/assets/scaffold/core/scripts/policy-age-report.sh +0 -199
  34. package/assets/scaffold/core/scripts/policy-retire.sh +0 -199
  35. package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +0 -39
@@ -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 refresh the token, write the Keychain or
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
- if (account.blocked) {
194
- failures.push(failure(mapContext, credentialBlockReason(account.blocked)));
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
- const token = account.token;
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(token));
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, "signed out: auth rejected"));
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, "signed out: auth rejected"));
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:${claudeKeychainServiceName(group.home)}`
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 service = claudeKeychainServiceName(group.home);
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", service, "-a", username, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
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", service, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
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:${service}`, nowMs, "underlying");
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) => classifyAccount(account, nowMs)),
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 || (options.platform ?? process.platform) !== "darwin") {
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 read = readCredentialFile(file, seat, nowMs);
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 read = readCredentialFile(file, seat, nowMs);
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({ token, accountId, expiresAt, scopes, planType }, nowMs)];
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