claude-multiacc 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,9 +74,16 @@ code cannot read at all degrades that one account (fail open), never the run.
74
74
  Telemetry failures never block work: no fresh data ⇒ account treated as available. The
75
75
  endpoint rate-limits per account, so the refresher skips accounts fetched in the last 45s
76
76
  and backs off exponentially (honoring `Retry-After`) on a 429 — `limits --force` overrides
77
- both. If an account's OAuth access token has expired (quiet machine, nothing ran claude for
78
- hours), the refresher first runs a zero-cost `claude auth status` under that account to let
79
- the app refresh its own credentials, then fetches; if that fails it logs and fails open.
77
+ both. If an account's OAuth access token has been expired for a while (idle account,
78
+ nothing ran claude under it for hours), the refresher renews it directly via the OAuth
79
+ **refresh-token grant** the same endpoint and public client id Claude Code itself uses
80
+ and atomically persists the rotated credential (0600) back to that account's
81
+ `.credentials.json`. This is what keeps idle accounts' telemetry fresh so they win
82
+ selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
83
+ account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
84
+ `<acct>/.oauth-refresh.json` (10 min transient, 6 h when the grant looks revoked — the log
85
+ then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
86
+ `CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
80
87
 
81
88
  **Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
82
89
  auth/rate-limit-looking failure the shim marks the account with a 10-minute error
@@ -40,13 +40,18 @@ USAGE
40
40
  default ~/.claude login (dir symlink —
41
41
  single credential file, no grant fork)
42
42
  claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
43
+ claude-accounts dedupe [--yes] remove any account registered twice
44
+ (same email), keeping one per email
43
45
  claude-accounts mint <acct-NN> mint server token via `claude setup-token`
44
46
  --paste paste an already-minted token instead of running setup-token
45
47
  claude-accounts sync push manifest+tokens to the server (Mac only)
46
48
  claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
47
49
  claude-accounts limits [--quiet] [--force]
48
- refresh usage buckets, apply >=90% markers. Skips accounts fetched in the
49
- last 45s and honors 429 backoff; --force ignores both.
50
+ refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
51
+ OAuth access tokens via the refresh-token grant (rotated credential is
52
+ persisted), so idle accounts keep fresh telemetry and stay selectable.
53
+ Skips accounts fetched in the last 45s and honors 429/refresh backoff;
54
+ --force ignores all three.
50
55
  claude-accounts health limits + full verify; logs to health.log
51
56
  claude-accounts self-update update the addon (npm i -g @latest, or git
52
57
  pull + reinstall); logs to update.log
@@ -149,6 +154,14 @@ for a in accounts:
149
154
  auth.append('token')
150
155
  limited = os.path.isfile(os.path.join(d, '.limited'))
151
156
  print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
157
+ seen = {}
158
+ for a in accounts:
159
+ seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
160
+ dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
161
+ if dups:
162
+ print()
163
+ for e, ids in dups.items():
164
+ print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'claude-accounts dedupe'")
152
165
  PYEOF
153
166
  }
154
167
 
@@ -249,89 +262,94 @@ cmd_add() {
249
262
  else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
250
263
  esac
251
264
  done
252
- # If an email was named, fast-fail on a duplicate now. If not, we learn (and dedup)
253
- # the real email from the sign-in below one account per email either way.
265
+ # If an email was named and it's already in the pool, SKIP before any sign-in — no
266
+ # duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
267
+ # a no-op, not an error. Re-auth an existing account with 'login'.)
254
268
  local owner
255
269
  if [ -n "$email" ]; then
256
270
  owner="$(email_owner "$email")"
257
- [ -n "$owner" ] && die "$email is already registered as $owner — nothing created (run 'claude-accounts login $owner' to re-authenticate it)"
271
+ if [ -n "$owner" ]; then
272
+ echo "$email is already added as $owner — skipping (nothing to do; run 'claude-accounts login $owner' to re-authenticate it)."
273
+ return 0
274
+ fi
258
275
  fi
259
276
  if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
260
277
  die "add is interactive (it completes sign-in before registering) — run it from a terminal"
261
278
  fi
262
- # Serialize the whole check->allocate->authenticate->register sequence: two concurrent
263
- # adds must not race the duplicate check, collide on an id, or clobber the manifest.
264
- local lock="$ACC_ROOT/.locks/mutate"
265
- mkdir -p "$ACC_ROOT/.locks"
266
- if ! mkdir "$lock" 2>/dev/null; then
267
- if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 900 ]; then
268
- rm -rf "$lock"; mkdir "$lock" 2>/dev/null || die "cannot acquire the account lock"
269
- else
270
- die "another claude-accounts add/login is in progress — try again when it finishes"
271
- fi
272
- fi
273
- # shellcheck disable=SC2064
274
- trap "rm -rf '$lock'" EXIT
275
279
  local real
276
280
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
281
+
282
+ # Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
283
+ # add gets the next one). The long browser sign-in below runs WITHOUT the lock, so
284
+ # several `add` in different terminals proceed in parallel. RESERVED_DIR drives a trap
285
+ # that removes the half-made dir on any failure/abort BEFORE registration.
277
286
  local id d got elabel
278
287
  elabel="${email:-the account you sign in as}"
288
+ RESERVED_DIR=""
289
+ trap 'add_cleanup_reserved; exit 130' INT TERM
290
+ trap 'add_cleanup_reserved' EXIT
291
+ mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
279
292
  id="$(next_id)"
280
293
  d="$ACC_ROOT/$id"
281
294
  seed_account_dir "$d"
295
+ RESERVED_DIR="$d"
296
+ mutate_unlock
297
+
282
298
  if [ "$token" = "1" ]; then
283
299
  # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
284
300
  echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
285
- if ! run_token_ceremony "$d"; then
286
- rm -rf "${ACC_ROOT:?}/${id:?}"
287
- die "sign-in failed or aborted — nothing was created"
288
- fi
301
+ run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
289
302
  ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
290
303
  chmod 600 "$d/server.token"
291
304
  got="$(token_email "$CEREMONY_TOKEN")"
292
305
  else
293
- # Default: full Claude Code login (the flow that just works — full scopes, no
294
- # long-lived-token step-up). Writes auto-refreshing .credentials.json, valid on
295
- # THIS machine (creds are never synced, so no cross-machine refresh races).
306
+ # Default: full Claude Code login (full scopes, no long-lived-token step-up).
307
+ # Writes auto-refreshing .credentials.json, valid on THIS machine.
296
308
  echo "Preparing $id for $elabel — NOTHING is registered until login completes."
297
- if ! run_login_ceremony "$d" "$email"; then
298
- rm -rf "${ACC_ROOT:?}/${id:?}"
299
- die "login failed or aborted — nothing was created"
300
- fi
309
+ run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
301
310
  got="$(config_dir_email "$d")"
302
311
  fi
303
312
  if [ -z "$got" ]; then
304
- # Signed in, but we could not read back WHICH account. Without a verified email we
305
- # cannot label or dedup the account, so refuse — unless an email was named AND
306
- # --force was given (then we trust the named email).
307
313
  if [ -n "$email" ] && [ "$force" = "1" ]; then
308
314
  warn "identity unverified — registering as $email because --force was given"
309
315
  got="$email"
310
316
  else
311
- rm -rf "${ACC_ROOT:?}/${id:?}"
312
317
  die "signed in, but the account identity could not be read back — nothing was created (retry; or 'add <email> --force' to trust a named email)"
313
318
  fi
314
319
  fi
315
- # `got` is now the authoritative signed-in email. Dedup against it, warn on mismatch.
320
+ # Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
321
+ # registered the same email meanwhile — the loser skips gracefully). This is the
322
+ # guarantee that a code pasted for an already-added account never yields a second entry.
323
+ mutate_lock || die "could not acquire the account lock — try again"
316
324
  owner="$(email_owner "$got")"
317
325
  if [ -n "$owner" ]; then
318
- rm -rf "${ACC_ROOT:?}/${id:?}"
319
- die "you signed in as $got, which is already registered as $owner — nothing was created"
326
+ mutate_unlock
327
+ echo "$got is already added as $owner — skipping (nothing added)."
328
+ return 0 # RESERVED_DIR still set -> trap removes the temp dir
320
329
  fi
321
330
  if [ -n "$email" ] && [ "$got" != "$email" ]; then
322
331
  warn "signed in as $got (you named $email) — registering the account that actually authenticated"
323
332
  fi
324
- email="$got"
325
- manifest_add_account "$id" "$email" "$(machine_kind)"
326
- log_to ops.log "add $id $email (auth-verified)"
333
+ manifest_add_account "$id" "$got" "$(machine_kind)"
334
+ mutate_unlock
335
+ RESERVED_DIR="" # committed the trap must not delete it now
336
+ trap - EXIT INT TERM
337
+ log_to ops.log "add $id $got (auth-verified)"
327
338
  auto_sync
328
339
  cat <<EOF
329
- Registered $id for $email (sign-in verified) — usable immediately.
340
+ Registered $id for $got (sign-in verified) — usable immediately.
330
341
  Optional:
331
342
  claude-accounts verify # confirm the 100% matrix
332
343
  EOF
333
344
  }
334
345
 
346
+ add_cleanup_reserved() {
347
+ # EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
348
+ # we died mid-critical-section, release the lock).
349
+ [ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
350
+ mutate_unlock 2>/dev/null || true
351
+ }
352
+
335
353
  cmd_import() {
336
354
  require_manifest
337
355
  local email="${1:-}"
@@ -393,6 +411,70 @@ cmd_import() {
393
411
  [ "$no_sync" = "1" ] || auto_sync
394
412
  }
395
413
 
414
+ # Prints duplicate account ids to REMOVE, one per line: for every email that appears
415
+ # more than once, keep exactly one (prefer an account that has auth on this machine,
416
+ # then the lowest id) and list the rest. Empty output => pool is already clean.
417
+ dup_ids_to_remove() {
418
+ [ -f "$MANIFEST" ] || return 0
419
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
420
+ import json, os, re, sys
421
+ manifest, root = sys.argv[1], sys.argv[2]
422
+ accts = [a for a in json.load(open(manifest)).get('accounts', [])
423
+ if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
424
+ def has_auth(aid):
425
+ d = os.path.join(root, aid)
426
+ c = os.path.join(d, '.credentials.json')
427
+ t = os.path.join(d, 'server.token')
428
+ return (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0)
429
+ by_email = {}
430
+ for a in accts:
431
+ by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
432
+ for email, ids in by_email.items():
433
+ if len(ids) < 2:
434
+ continue
435
+ # keep: authed first, then lowest id
436
+ keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
437
+ for i in ids:
438
+ if i != keep:
439
+ print(i)
440
+ PYEOF
441
+ }
442
+
443
+ cmd_dedupe() {
444
+ require_manifest
445
+ local yes=0
446
+ [ "${1:-}" = "--yes" ] && yes=1
447
+ local dups
448
+ dups="$(dup_ids_to_remove)"
449
+ if [ -z "$dups" ]; then
450
+ echo "No duplicate accounts — every email appears once."
451
+ return 0
452
+ fi
453
+ echo "Duplicate accounts (same email registered more than once):"
454
+ local id email
455
+ for id in $dups; do
456
+ email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
457
+ import json, sys
458
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
459
+ if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
460
+ PYEOF
461
+ )"
462
+ echo " will remove $id ($email)"
463
+ done
464
+ if [ "$yes" != "1" ]; then
465
+ printf 'Remove these duplicates (keeps one per email)? [y/N] '
466
+ read -r ans
467
+ case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
468
+ fi
469
+ for id in $dups; do
470
+ if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
471
+ manifest_del_account "$id"
472
+ log_to ops.log "dedupe removed $id"
473
+ done
474
+ echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
475
+ auto_sync
476
+ }
477
+
396
478
  cmd_adopt() {
397
479
  # Make <acct-NN> THIS machine's existing default login (~/.claude) without
398
480
  # forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
@@ -661,24 +743,9 @@ cmd_limits() {
661
743
  # shellcheck disable=SC2064
662
744
  trap "rm -rf '$lock'" EXIT
663
745
  rotate_log limits.log
664
- # Expired-bearer self-heal: on a quiet machine nothing refreshes OAuth creds,
665
- # which would silently stall limits telemetry (fail-open keeps selection working,
666
- # but data goes stale). `claude auth status` refreshes creds without inference.
667
- local real="" d
668
- real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
669
- if [ -n "$real" ]; then
670
- for d in "$ACC_ROOT"/acct-*; do
671
- [ -d "$d" ] || continue
672
- [ -f "$d/.credentials.json" ] || continue
673
- [ -s "$d/server.token" ] && continue
674
- if "$PYBIN" -c '
675
- import json, sys, time
676
- c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
677
- sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
678
- CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
679
- fi
680
- done
681
- fi
746
+ # NB: expired OAuth access tokens are refreshed inside the Python below via the
747
+ # refresh-token grant. (`claude auth status` was tried for this and does NOT
748
+ # refresh credentials it only reports the on-disk state.)
682
749
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
683
750
  # never loosen it, or an account could sit at 95% and still be selected.
684
751
  local threshold
@@ -694,6 +761,21 @@ now = time.time()
694
761
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
695
762
  MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
696
763
 
764
+ # OAuth refresh-token grant — the same endpoint + public client id Claude Code
765
+ # itself uses to keep .credentials.json alive. An account that sits idle past its
766
+ # access-token TTL would otherwise drop out of telemetry forever (stale data ranks
767
+ # neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
768
+ TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
769
+ 'https://console.anthropic.com/v1/oauth/token')
770
+ CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
771
+ '9d1c250a-e61b-44d9-88ed-5944d1962f5e')
772
+ # Only refresh a token that has been expired for a while: a LIVE session refreshes
773
+ # its own credential within moments of expiry, so a long-expired one proves no
774
+ # other writer is active (refresh tokens rotate; two racing refreshers strand one).
775
+ REFRESH_MIN_EXPIRED = 300
776
+ REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
777
+ REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
778
+
697
779
  def say(msg):
698
780
  if not quiet:
699
781
  print(msg)
@@ -714,34 +796,108 @@ try:
714
796
  except Exception as e:
715
797
  sys.exit(f'cannot read manifest: {e}')
716
798
 
799
+ def refresh_oauth(aid, d, cpath):
800
+ """Refresh a long-expired OAuth access token via the refresh-token grant and
801
+ persist the ROTATED credential atomically (0600). Returns the new bearer, or
802
+ None (fail open: the on-disk credential is never touched on failure).
803
+ Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
804
+ because telemetry state must only ever reflect real usage fetches."""
805
+ spath = os.path.join(d, '.oauth-refresh.json')
806
+ try:
807
+ doc = json.load(open(cpath))
808
+ o = doc.get('claudeAiOauth', {})
809
+ # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
810
+ # write) must degrade THIS account only, like every other malformed input.
811
+ if not isinstance(doc, dict) or not isinstance(o, dict):
812
+ return None
813
+ except Exception:
814
+ return None
815
+ if not o.get('refreshToken') or not o.get('accessToken'):
816
+ return None
817
+ if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
818
+ return None # not expired long enough to prove no live session owns it
819
+ if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
820
+ say(f'{aid}: refresh token expired — re-login needed (claude-accounts login {aid})')
821
+ return None
822
+ if not force:
823
+ try:
824
+ if json.load(open(spath)).get('retry_after', 0) > now:
825
+ return None # earlier refresh failure still backing off
826
+ except Exception:
827
+ pass
828
+
829
+ def back_off(wait, why):
830
+ try:
831
+ with open(spath + '.tmp', 'w') as f:
832
+ json.dump({'retry_after': int(now + wait), 'error': why,
833
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
834
+ os.replace(spath + '.tmp', spath)
835
+ except Exception:
836
+ pass
837
+ say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
838
+
839
+ body = json.dumps({'grant_type': 'refresh_token',
840
+ 'refresh_token': o['refreshToken'],
841
+ 'client_id': CLIENT_ID}).encode()
842
+ req = urllib.request.Request(TOKEN_URL, data=body, headers={
843
+ 'Content-Type': 'application/json',
844
+ 'User-Agent': 'claude-multiacc/1.0',
845
+ })
846
+ try:
847
+ data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
848
+ except urllib.error.HTTPError as e:
849
+ if e.code in (400, 401, 403):
850
+ back_off(REFRESH_DENIED_BACKOFF,
851
+ f'HTTP {e.code} — refresh token may be revoked; re-login needed')
852
+ else:
853
+ back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
854
+ return None
855
+ except Exception as e:
856
+ back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
857
+ return None
858
+ tok = data.get('access_token') if isinstance(data, dict) else None
859
+ if not tok:
860
+ back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
861
+ return None
862
+ o['accessToken'] = tok
863
+ # The grant ROTATES the refresh token: persist it (and both expiries) or the
864
+ # account is stranded — hence atomic write, and a loud message if it fails.
865
+ if data.get('refresh_token'):
866
+ o['refreshToken'] = data['refresh_token']
867
+ if data.get('expires_in'):
868
+ o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
869
+ else:
870
+ # No expires_in in the response: assume a conservative 1h. Leaving the old
871
+ # (past) expiresAt would make every later pass re-run the grant in a loop.
872
+ o['expiresAt'] = int((now + 3600) * 1000)
873
+ if data.get('refresh_token_expires_in'):
874
+ o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
875
+ doc['claudeAiOauth'] = o
876
+ try:
877
+ fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
878
+ with os.fdopen(fd, 'w') as f:
879
+ json.dump(doc, f)
880
+ os.replace(cpath + '.tmp', cpath)
881
+ except Exception as e:
882
+ say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
883
+ return None
884
+ try:
885
+ os.remove(spath)
886
+ except OSError:
887
+ pass
888
+ say(f'{aid}: oauth access token refreshed via refresh-token grant')
889
+ return tok
890
+
717
891
  for acct in manifest.get('accounts', []):
718
892
  aid = acct['id']
719
893
  d = os.path.join(root, aid)
720
894
  if not os.path.isdir(d):
721
895
  continue
722
- bearer = None
723
- source = None
724
- cpath = os.path.join(d, '.credentials.json')
725
- if os.path.isfile(cpath):
726
- try:
727
- c = json.load(open(cpath)).get('claudeAiOauth', {})
728
- if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
729
- bearer, source = c['accessToken'], 'oauth'
730
- except Exception:
731
- pass
732
- tpath = os.path.join(d, 'server.token')
733
- if not bearer and os.path.isfile(tpath):
734
- t = open(tpath).read().strip()
735
- if t:
736
- bearer, source = t, 'token'
737
- if not bearer:
738
- # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
739
- say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
740
- continue
741
896
 
742
897
  # The usage endpoint rate-limits per account. Several callers can fire at once
743
898
  # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
744
899
  # this account's data is already fresh, and honor any backoff a 429 set earlier.
900
+ # Checked FIRST so a skipped account never burns an oauth refresh for nothing.
745
901
  lpath = os.path.join(d, 'limits.json')
746
902
  prev = {}
747
903
  if os.path.isfile(lpath):
@@ -758,6 +914,36 @@ for acct in manifest.get('accounts', []):
758
914
  say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
759
915
  continue
760
916
 
917
+ bearer = None
918
+ source = None
919
+ cpath = os.path.join(d, '.credentials.json')
920
+ if os.path.isfile(cpath):
921
+ try:
922
+ c = json.load(open(cpath)).get('claudeAiOauth', {})
923
+ if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
924
+ bearer, source = c['accessToken'], 'oauth'
925
+ except Exception:
926
+ pass
927
+ tpath = os.path.join(d, 'server.token')
928
+ if not bearer and os.path.isfile(tpath):
929
+ t = open(tpath).read().strip()
930
+ if t:
931
+ bearer, source = t, 'token'
932
+ if not bearer and os.path.isfile(cpath):
933
+ # Hard fail-open guard: NOTHING a single account's refresh does may abort
934
+ # the loop — every account after it would silently starve of telemetry.
935
+ try:
936
+ tok = refresh_oauth(aid, d, cpath)
937
+ except Exception as e:
938
+ say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
939
+ tok = None
940
+ if tok:
941
+ bearer, source = tok, 'oauth'
942
+ if not bearer:
943
+ # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
944
+ say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
945
+ continue
946
+
761
947
  req = urllib.request.Request(url, headers={
762
948
  'Authorization': 'Bearer ' + bearer,
763
949
  'anthropic-beta': 'oauth-2025-04-20',
@@ -1165,6 +1351,7 @@ case "${1:-help}" in
1165
1351
  add) shift; cmd_add "$@" ;;
1166
1352
  import) shift; cmd_import "$@" ;;
1167
1353
  adopt) shift; cmd_adopt "$@" ;;
1354
+ dedupe) shift; cmd_dedupe "$@" ;;
1168
1355
  remove) shift; cmd_remove "$@" ;;
1169
1356
  mint) shift; cmd_mint "$@" ;;
1170
1357
  login) shift; cmd_login "$@" ;;
package/lib/common.sh CHANGED
@@ -188,6 +188,34 @@ PYEOF
188
188
  fi
189
189
  }
190
190
 
191
+ # Short-lived mutation lock: serialize only the brief critical sections (id reservation,
192
+ # manifest write), NOT long interactive work like a browser sign-in — so several
193
+ # `add`/`login` in different terminals run in parallel and only briefly wait on each other.
194
+ # Held for milliseconds; a lock older than 30s is assumed abandoned (holder died) and reclaimed.
195
+ MUTATE_LOCK_HELD=0
196
+ mutate_lock() {
197
+ local lock="$ACC_ROOT/.locks/mutate" tries=0
198
+ mkdir -p "$ACC_ROOT/.locks" 2>/dev/null
199
+ while ! mkdir "$lock" 2>/dev/null; do
200
+ if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 30 ]; then
201
+ rm -rf "$lock" 2>/dev/null
202
+ continue
203
+ fi
204
+ tries=$((tries + 1))
205
+ [ "$tries" -gt 600 ] && return 1 # ~60s ceiling; contention should clear in ms
206
+ sleep 0.1
207
+ done
208
+ MUTATE_LOCK_HELD=1
209
+ return 0
210
+ }
211
+ # Only removes the lock dir when THIS process holds it — so a normal release, or a
212
+ # cleanup trap firing after release, can never delete a lock a parallel process just took.
213
+ mutate_unlock() {
214
+ [ "${MUTATE_LOCK_HELD:-0}" = 1 ] || return 0
215
+ rm -rf "$ACC_ROOT/.locks/mutate" 2>/dev/null
216
+ MUTATE_LOCK_HELD=0
217
+ }
218
+
191
219
  rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
192
220
  local f="$ACC_ROOT/$1"
193
221
  [ -f "$f" ] || return 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,6 +75,9 @@ export CLAUDE_MULTIACC_NO_SYNC=1
75
75
  # Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
76
76
  # is off by default here; the throttle test re-enables it explicitly.
77
77
  export CLAUDE_MULTIACC_MIN_FETCH=0
78
+ # The oauth token endpoint must NEVER be hit for real from tests: default to a missing
79
+ # file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
80
+ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
78
81
 
79
82
  now="$(date +%s)"
80
83
 
@@ -353,11 +356,13 @@ check "remove account" "Removed acct-04" "$out"
353
356
  [ ! -d "$ACC/acct-04" ] && t_ok "remove deleted dir" || t_fail "remove deleted dir" "dir still there"
354
357
 
355
358
  # ---- 15b. duplicate-email guards ------------------------------------------------
359
+ # add of a named, already-present email => graceful SKIP before any sign-in (exit 0)
356
360
  out="$(claude-accounts add a@test 2>&1)"
357
361
  rc=$?
358
- check "add refuses duplicate email" "already registered as acct-01" "$out"
359
- [ "$rc" != "0" ] && t_ok "duplicate add exits nonzero" || t_fail "duplicate add rc" "rc=0"
362
+ check "add skips a duplicate email with a message" "already added as acct-01 — skipping" "$out"
363
+ [ "$rc" = "0" ] && t_ok "duplicate add exits 0 (graceful skip)" || t_fail "duplicate add rc" "rc=$rc"
360
364
  [ ! -d "$ACC/acct-04" ] && t_ok "duplicate add created nothing" || t_fail "duplicate add" "dir created"
365
+ # import stays strict (it's the lower-level command): refuses a duplicate
361
366
  out="$(claude-accounts import a@test --id acct-09 --no-sync 2>&1)"
362
367
  rc=$?
363
368
  check "import refuses duplicate email" "already registered as acct-01" "$out"
@@ -391,8 +396,8 @@ claude-accounts remove acct-04 --yes >/dev/null 2>&1
391
396
  # add with no email, but the signed-in email is already registered => refuse + clean up
392
397
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add 2>&1)"
393
398
  rc=$?
394
- check "email-less add refuses a duplicate signed-in email" "already registered as acct-01" "$out"
395
- [ "$rc" != "0" ] && t_ok "email-less duplicate exits nonzero" || t_fail "email-less dup rc" "rc=0"
399
+ check "email-less add skips a duplicate signed-in email" "a@test is already added as acct-01 — skipping" "$out"
400
+ [ "$rc" = "0" ] && t_ok "email-less duplicate skip exits 0" || t_fail "email-less dup rc" "rc=$rc"
396
401
  [ ! -d "$ACC/acct-04" ] && t_ok "email-less duplicate cleaned up" || t_fail "email-less dup cleanup" "dir left"
397
402
  # add with no email but identity can't be read back => refuse (can't label the account)
398
403
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_AUTH_FAIL=1 claude-accounts add 2>&1)"
@@ -422,7 +427,8 @@ check "aborted --token add detected" "sign-in failed or aborted" "$out"
422
427
  # ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
423
428
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
424
429
  rc=$?
425
- check "wrong-account sign-in rejected" "already registered as acct-01" "$out"
430
+ check "wrong-account sign-in skips (already added)" "a@test is already added as acct-01 — skipping" "$out"
431
+ [ "$rc" = "0" ] && t_ok "wrong-account skip exits 0" || t_fail "wrong-account rc" "rc=$rc"
426
432
  [ ! -d "$ACC/acct-04" ] && t_ok "wrong-account sign-in cleaned up" || t_fail "wrong-account cleanup" "dir left behind"
427
433
 
428
434
  # ---- 15f. login command completes auth for an existing auth-less account -------------
@@ -440,6 +446,62 @@ check "login --token saves a portable token" "acct-08 token saved" "$out"
440
446
  [ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
441
447
  claude-accounts remove acct-08 --yes >/dev/null 2>&1
442
448
 
449
+ # ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
450
+ ( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
451
+ pA=$!
452
+ ( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par2@test claude-accounts add >"$WORK/p2.out" 2>&1 ) &
453
+ pB=$!
454
+ wait "$pA"; wait "$pB"
455
+ both="$(cat "$WORK/p1.out" "$WORK/p2.out" 2>/dev/null)"
456
+ printf '%s' "$both" | grep -q "in progress" \
457
+ && t_fail "parallel add: no lock-busy error" "got the 'another add in progress' error" \
458
+ || t_ok "parallel add: neither reports 'another add in progress'"
459
+ regA="$(claude-accounts list 2>&1 | grep par1@test | awk '{print $1}')"
460
+ regB="$(claude-accounts list 2>&1 | grep par2@test | awk '{print $1}')"
461
+ { [ -n "$regA" ] && [ -n "$regB" ] && [ "$regA" != "$regB" ]; } \
462
+ && t_ok "parallel add: both registered on distinct ids ($regA, $regB)" \
463
+ || t_fail "parallel add ids" "regA=$regA regB=$regB"
464
+ # and no duplicate/second entry crept in for either email
465
+ { [ "$(claude-accounts list 2>&1 | grep -c par1@test)" = "1" ] && [ "$(claude-accounts list 2>&1 | grep -c par2@test)" = "1" ]; } \
466
+ && t_ok "parallel add: exactly one entry per email" || t_fail "parallel add dup" "duplicate created"
467
+ [ -n "$regA" ] && claude-accounts remove "$regA" --yes >/dev/null 2>&1
468
+ [ -n "$regB" ] && claude-accounts remove "$regB" --yes >/dev/null 2>&1
469
+
470
+ # ---- 15h. two parallel adds signing into the SAME email: exactly one wins ------------
471
+ ( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s1.out" 2>&1 ) &
472
+ sA=$!
473
+ ( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s2.out" 2>&1 ) &
474
+ sB=$!
475
+ wait "$sA"; wait "$sB"
476
+ n="$(claude-accounts list 2>&1 | grep -c same@test)"
477
+ [ "$n" = "1" ] && t_ok "same-email parallel add: exactly one registered (other skipped)" \
478
+ || t_fail "same-email parallel add" "count=$n (expected 1)"
479
+ cat "$WORK/s1.out" "$WORK/s2.out" 2>/dev/null | grep -q "already added as\|skipping" \
480
+ && t_ok "same-email parallel add: loser skipped gracefully" || t_fail "same-email skip msg" "no skip message"
481
+ regS="$(claude-accounts list 2>&1 | grep same@test | awk '{print $1}')"
482
+ [ -n "$regS" ] && claude-accounts remove "$regS" --yes >/dev/null 2>&1
483
+
484
+ # ---- 15i. dedupe removes accounts registered twice (keeps one per email) --------------
485
+ out="$(claude-accounts dedupe 2>&1)"
486
+ check "dedupe on a clean pool is a no-op" "No duplicate accounts" "$out"
487
+ # manufacture a duplicate directly in the manifest (a pre-fix leftover) + a dir for it
488
+ claude-accounts import twin@test --id acct-06 --creds "$WORK/import-creds.json" --mode copy --no-sync >/dev/null 2>&1
489
+ python3 - "$ACC/accounts.json" <<'EOF'
490
+ import json, os, sys
491
+ d = json.load(open(sys.argv[1]))
492
+ d['accounts'].append({'id': 'acct-07', 'email': 'twin@test', 'home': 'mac'})
493
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
494
+ EOF
495
+ mkdir -p "$ACC/acct-07"
496
+ out="$(claude-accounts list 2>&1)"
497
+ check "list warns about a duplicate email" "twin@test is registered 2x" "$out"
498
+ out="$(claude-accounts dedupe --yes 2>&1)"
499
+ check "dedupe reports removal" "Removed 1 duplicate" "$out"
500
+ [ "$(claude-accounts list 2>&1 | grep -c twin@test)" = "1" ] && t_ok "dedupe keeps exactly one per email" || t_fail "dedupe count" "not 1"
501
+ # it kept the authed one (acct-06 has creds), removed the bare acct-07
502
+ claude-accounts list 2>&1 | grep -q "acct-06.*twin@test" && t_ok "dedupe kept the authenticated account" || t_fail "dedupe keep-authed" "kept the wrong one"
503
+ claude-accounts remove acct-06 --yes >/dev/null 2>&1
504
+
443
505
  # ---- 16. CLI: limits marking via fixture endpoint ------------------------------
444
506
  cat > "$WORK/usage-high.json" <<'EOF'
445
507
  {"limits":[
@@ -559,15 +621,107 @@ d = json.load(open('$ACC/acct-01/limits.json'))
559
621
  sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
560
622
  && t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
561
623
 
562
- # ---- 16b. expired-bearer account skipped gracefully (fail open) -----------------
624
+ # ---- 16b. expired-bearer account: oauth refresh attempted; fail-open when it fails ----
563
625
  mkdir -p "$ACC/acct-05"
564
- printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
626
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-oldrefresh","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
565
627
  claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
566
628
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
567
629
  rc=$?
630
+ check "failed oauth refresh logged with backoff" "acct-05: oauth refresh failed" "$out"
568
631
  check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
569
632
  [ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
570
633
  [ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
634
+ [ -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "refresh failure recorded in .oauth-refresh.json" || t_fail "refresh backoff file" "missing"
635
+
636
+ # ---- 16b2. refresh backoff honored: even a now-working endpoint is not retried early --
637
+ cat > "$WORK/token-ok.json" <<'EOF'
638
+ {"access_token":"sk-ant-oat01-refreshednew","refresh_token":"sk-ant-ort01-rotatednew","expires_in":28800,"refresh_token_expires_in":2592000}
639
+ EOF
640
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
641
+ check "refresh backoff honored (no early retry)" "acct-05: no fresh bearer" "$out"
642
+ grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
643
+ && t_fail "backoff prevented refresh" "credentials rewritten inside the backoff window" \
644
+ || t_ok "no refresh inside the backoff window"
645
+
646
+ # ---- 16b3. --force bypasses refresh backoff: rotated credential persisted + fetch ok --
647
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
648
+ check "--force refreshes the expired oauth token" "acct-05: oauth access token refreshed" "$out"
649
+ check "refreshed account fetches telemetry" "acct-05: ok" "$out"
650
+ python3 - "$ACC/acct-05/.credentials.json" <<'EOF'
651
+ import json, os, stat, sys, time
652
+ p = sys.argv[1]
653
+ o = json.load(open(p))['claudeAiOauth']
654
+ assert o['accessToken'] == 'sk-ant-oat01-refreshednew', o['accessToken']
655
+ assert o['refreshToken'] == 'sk-ant-ort01-rotatednew', 'refresh token was not rotated'
656
+ assert o['expiresAt'] / 1000.0 > time.time() + 3600, 'expiresAt not advanced'
657
+ assert o['refreshTokenExpiresAt'] / 1000.0 > time.time() + 86400, 'refreshTokenExpiresAt not advanced'
658
+ mode = stat.S_IMODE(os.stat(p).st_mode)
659
+ assert mode == 0o600, oct(mode)
660
+ EOF
661
+ [ $? -eq 0 ] && t_ok "rotated credential persisted with 0600" || t_fail "credential rotation" "see assertions above"
662
+ [ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "successful refresh clears the backoff file" || t_fail "refresh backoff clear" "file still present"
663
+ [ -f "$ACC/acct-05/limits.json" ] && t_ok "telemetry written right after refresh" || t_fail "limits.json after refresh" "missing"
664
+ grep -qE "sk-ant-ort01|sk-ant-oat01-refreshednew" "$ACC/limits.log" \
665
+ && t_fail "limits.log leaks no tokens" "a token leaked into limits.log" \
666
+ || t_ok "limits.log leaks no tokens"
667
+
668
+ # ---- 16b4. steady state: fresh data means no refresh and no fetch (quiet skip) --------
669
+ out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
670
+ printf '%s' "$out" | grep -q "acct-05" \
671
+ && t_fail "fresh account skipped silently" "unexpected acct-05 output: $out" \
672
+ || t_ok "fresh account skipped silently (no refresh, no fetch)"
673
+
674
+ # ---- 16b5. an EXPIRED refresh token is never sent: clear re-login message -------------
675
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-dead","expiresAt":1000,"refreshTokenExpiresAt":1000}}' > "$ACC/acct-05/.credentials.json"
676
+ rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
677
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
678
+ check "expired refresh token => re-login message" "re-login needed" "$out"
679
+ grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
680
+ && t_fail "dead refresh token never used" "credentials rewritten from a dead refresh token" \
681
+ || t_ok "dead refresh token never used"
682
+
683
+ # ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
684
+ # The 5-min REFRESH_MIN_EXPIRED gate is the rotation-safety core: a token that expired
685
+ # moments ago may be mid-refresh by a live claude session; grants must not race it.
686
+ # Not even --force may bypass this.
687
+ recent_ms="$(python3 -c 'import time; print(int((time.time()-100)*1000))')"
688
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-recent","refreshToken":"sk-ant-ort01-live","expiresAt":%s,"refreshTokenExpiresAt":9999999999999}}' "$recent_ms" > "$ACC/acct-05/.credentials.json"
689
+ rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
690
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
691
+ check "recently-expired token is not refreshed (even --force)" "acct-05: no fresh bearer" "$out"
692
+ grep -q "sk-ant-oat01-recent" "$ACC/acct-05/.credentials.json" \
693
+ && t_ok "recently-expired credential left untouched" \
694
+ || t_fail "REFRESH_MIN_EXPIRED gate" "credential was rewritten within the 5-min grace window"
695
+ [ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "no backoff recorded for a gated (skipped) refresh" \
696
+ || t_fail "gated refresh backoff" ".oauth-refresh.json written despite the gate"
697
+
698
+ # ---- 16b7. server.token accounts are NEVER oauth-refreshed (token bearer wins) ---------
699
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
700
+ printf 'sk-ant-oat01-portable-token-05' > "$ACC/acct-05/server.token"
701
+ rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
702
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
703
+ check "token-bearer account fetches without refresh" "acct-05: ok" "$out"
704
+ grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
705
+ && t_fail "server.token exempts oauth refresh" "oauth creds were rotated despite a portable token" \
706
+ || t_ok "server.token account never oauth-refreshed (grant not run)"
707
+ python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='token' else 1)" \
708
+ && t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
709
+ rm -f "$ACC/acct-05/server.token"
710
+
711
+ # ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
712
+ # {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
713
+ # must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
714
+ # acct-01 is first in the manifest, so corrupting it exercises the loop guarantee.
715
+ cp "$ACC/acct-01/.credentials.json" "$WORK/acct01-creds.bak"
716
+ printf '{"claudeAiOauth": null}' > "$ACC/acct-01/.credentials.json"
717
+ rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.oauth-refresh.json" "$ACC/acct-02/limits.json"
718
+ out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
719
+ rc=$?
720
+ [ "$rc" = "0" ] && t_ok "null claudeAiOauth exits 0 (fail open)" || t_fail "null claudeAiOauth rc" "rc=$rc: $out"
721
+ check "null claudeAiOauth degrades only that account" "acct-01: no fresh bearer" "$out"
722
+ [ -f "$ACC/acct-02/limits.json" ] && t_ok "accounts after a malformed one still refresh" \
723
+ || t_fail "fail-open loop guarantee" "acct-02 was starved by acct-01's malformed creds"
724
+ cp "$WORK/acct01-creds.bak" "$ACC/acct-01/.credentials.json"
571
725
  claude-accounts remove acct-05 --yes >/dev/null 2>&1
572
726
 
573
727
  # ---- 16c. codex-review: security hardening -----------------------------------------