claude-multiacc 1.0.4 → 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
@@ -47,8 +47,11 @@ USAGE
47
47
  claude-accounts sync push manifest+tokens to the server (Mac only)
48
48
  claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
49
49
  claude-accounts limits [--quiet] [--force]
50
- refresh usage buckets, apply >=90% markers. Skips accounts fetched in the
51
- 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.
52
55
  claude-accounts health limits + full verify; logs to health.log
53
56
  claude-accounts self-update update the addon (npm i -g @latest, or git
54
57
  pull + reinstall); logs to update.log
@@ -740,24 +743,9 @@ cmd_limits() {
740
743
  # shellcheck disable=SC2064
741
744
  trap "rm -rf '$lock'" EXIT
742
745
  rotate_log limits.log
743
- # Expired-bearer self-heal: on a quiet machine nothing refreshes OAuth creds,
744
- # which would silently stall limits telemetry (fail-open keeps selection working,
745
- # but data goes stale). `claude auth status` refreshes creds without inference.
746
- local real="" d
747
- real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
748
- if [ -n "$real" ]; then
749
- for d in "$ACC_ROOT"/acct-*; do
750
- [ -d "$d" ] || continue
751
- [ -f "$d/.credentials.json" ] || continue
752
- [ -s "$d/server.token" ] && continue
753
- if "$PYBIN" -c '
754
- import json, sys, time
755
- c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
756
- sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
757
- CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
758
- fi
759
- done
760
- 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.)
761
749
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
762
750
  # never loosen it, or an account could sit at 95% and still be selected.
763
751
  local threshold
@@ -773,6 +761,21 @@ now = time.time()
773
761
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
774
762
  MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
775
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
+
776
779
  def say(msg):
777
780
  if not quiet:
778
781
  print(msg)
@@ -793,34 +796,108 @@ try:
793
796
  except Exception as e:
794
797
  sys.exit(f'cannot read manifest: {e}')
795
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
+
796
891
  for acct in manifest.get('accounts', []):
797
892
  aid = acct['id']
798
893
  d = os.path.join(root, aid)
799
894
  if not os.path.isdir(d):
800
895
  continue
801
- bearer = None
802
- source = None
803
- cpath = os.path.join(d, '.credentials.json')
804
- if os.path.isfile(cpath):
805
- try:
806
- c = json.load(open(cpath)).get('claudeAiOauth', {})
807
- if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
808
- bearer, source = c['accessToken'], 'oauth'
809
- except Exception:
810
- pass
811
- tpath = os.path.join(d, 'server.token')
812
- if not bearer and os.path.isfile(tpath):
813
- t = open(tpath).read().strip()
814
- if t:
815
- bearer, source = t, 'token'
816
- if not bearer:
817
- # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
818
- say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
819
- continue
820
896
 
821
897
  # The usage endpoint rate-limits per account. Several callers can fire at once
822
898
  # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
823
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.
824
901
  lpath = os.path.join(d, 'limits.json')
825
902
  prev = {}
826
903
  if os.path.isfile(lpath):
@@ -837,6 +914,36 @@ for acct in manifest.get('accounts', []):
837
914
  say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
838
915
  continue
839
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
+
840
947
  req = urllib.request.Request(url, headers={
841
948
  'Authorization': 'Bearer ' + bearer,
842
949
  'anthropic-beta': 'oauth-2025-04-20',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.4",
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
 
@@ -618,15 +621,107 @@ d = json.load(open('$ACC/acct-01/limits.json'))
618
621
  sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
619
622
  && t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
620
623
 
621
- # ---- 16b. expired-bearer account skipped gracefully (fail open) -----------------
624
+ # ---- 16b. expired-bearer account: oauth refresh attempted; fail-open when it fails ----
622
625
  mkdir -p "$ACC/acct-05"
623
- 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"
624
627
  claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
625
628
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
626
629
  rc=$?
630
+ check "failed oauth refresh logged with backoff" "acct-05: oauth refresh failed" "$out"
627
631
  check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
628
632
  [ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
629
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"
630
725
  claude-accounts remove acct-05 --yes >/dev/null 2>&1
631
726
 
632
727
  # ---- 16c. codex-review: security hardening -----------------------------------------