claude-multiacc 1.0.4 → 1.0.6

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.
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  # claude-accounts — manage the claude-multiacc account pool.
3
3
  # Subcommands: list status add import remove mint sync verify limits post-sync health
4
+ # expired relogin
4
5
  set -u
6
+ # lib/audit.py is imported by several subcommands; keep the install tree free of
7
+ # __pycache__ (it may be root-owned, read-only, or an npm global prefix).
8
+ export PYTHONDONTWRITEBYTECODE=1
5
9
 
6
10
  _self="$0"
7
11
  while [ -L "$_self" ]; do
@@ -29,6 +33,14 @@ USAGE
29
33
  recent sign-in — use it when you want the account usable on the server too).
30
34
  claude-accounts login <acct-NN> [--token] [--force]
31
35
  complete/refresh auth for an existing account (full login, or --token)
36
+ claude-accounts expired [--quiet]
37
+ which accounts CANNOT authenticate (expired refresh token, revoked grant, no
38
+ login on this machine) and why. These are excluded from selection — `claude`
39
+ never runs under them. Exits 1 when any account needs a human. --quiet prints
40
+ bare ids for scripts.
41
+ claude-accounts relogin [acct-NN ...] [--all] [--token] [--yes]
42
+ sign in again, one account at a time. With no arguments it re-authenticates
43
+ exactly what `expired` lists; --all covers every account. Syncs once at the end.
32
44
  claude-accounts import <email> [opts] register an account, optionally with credentials
33
45
  --id acct-NN explicit id (default: next free)
34
46
  --home mac|server which machine owns the OAuth grant (default: this one)
@@ -47,8 +59,11 @@ USAGE
47
59
  claude-accounts sync push manifest+tokens to the server (Mac only)
48
60
  claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
49
61
  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.
62
+ refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
63
+ OAuth access tokens via the refresh-token grant (rotated credential is
64
+ persisted), so idle accounts keep fresh telemetry and stay selectable.
65
+ Skips accounts fetched in the last 45s and honors 429/refresh backoff;
66
+ --force ignores all three.
52
67
  claude-accounts health limits + full verify; logs to health.log
53
68
  claude-accounts self-update update the addon (npm i -g @latest, or git
54
69
  pull + reinstall); logs to update.log
@@ -133,10 +148,13 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
133
148
 
134
149
  cmd_list() {
135
150
  require_manifest
136
- "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
151
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
137
152
  import json, os, re, sys
138
153
  doc = json.load(open(sys.argv[1]))
139
154
  root = sys.argv[2]
155
+ sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
156
+ from audit import audit_account # noqa: E402 (shared with the shim's rule)
157
+ machine = sys.argv[4]
140
158
  # Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
141
159
  accounts = [a for a in doc.get('accounts', [])
142
160
  if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
@@ -150,7 +168,24 @@ for a in accounts:
150
168
  if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
151
169
  auth.append('token')
152
170
  limited = os.path.isfile(os.path.join(d, '.limited'))
153
- print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
171
+ st = audit_account(root, a, machine=machine)
172
+ flags = []
173
+ if st['state'] == 'expired':
174
+ flags.append('EXPIRED-LOGIN')
175
+ elif st['state'] == 'blocked':
176
+ flags.append('ORG-BLOCKED')
177
+ elif st['state'] == 'missing':
178
+ flags.append('NO-LOGIN')
179
+ if limited:
180
+ flags.append('LIMITED')
181
+ print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} "
182
+ f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
183
+ bad = [a for a in accounts
184
+ if audit_account(root, a, machine=machine)['state']
185
+ in ('expired', 'blocked', 'missing')]
186
+ if bad:
187
+ print()
188
+ print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
154
189
  seen = {}
155
190
  for a in accounts:
156
191
  seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
@@ -164,10 +199,13 @@ PYEOF
164
199
 
165
200
  cmd_status() {
166
201
  require_manifest
167
- "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
202
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
168
203
  import json, os, sys, time
169
204
  doc = json.load(open(sys.argv[1]))
170
205
  root = sys.argv[2]
206
+ sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
207
+ from audit import audit_account # noqa: E402 (shared with the shim's rule)
208
+ machine = sys.argv[4]
171
209
  now = time.time()
172
210
 
173
211
  def last_pick(aid):
@@ -189,10 +227,20 @@ print(f"pool root : {root}")
189
227
  print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
190
228
  print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
191
229
  print()
230
+ needs_login = []
192
231
  for a in doc.get('accounts', []):
193
232
  aid = a['id']
194
233
  d = os.path.join(root, aid)
195
- print(f"{aid} {a['email']} [home={a.get('home','?')}]")
234
+ st = audit_account(root, a, machine=machine)
235
+ if st['state'] in ('expired', 'blocked', 'missing'):
236
+ needs_login.append(aid)
237
+ banner = {'ok': '', 'remote': ' [not logged in here — grant lives elsewhere]',
238
+ 'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
239
+ 'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
240
+ 'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
241
+ }[st['state']]
242
+ print(f"{aid} {a['email']} [home={a.get('home','?')}]{banner}")
243
+ print(f" selectable : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
196
244
  cpath = os.path.join(d, '.credentials.json')
197
245
  if os.path.isfile(cpath):
198
246
  try:
@@ -238,6 +286,9 @@ for a in doc.get('accounts', []):
238
286
  print(" marker : none (eligible)")
239
287
  print(f" last picked : {last_pick(aid)}")
240
288
  print()
289
+ if needs_login:
290
+ print(f"{len(needs_login)} account(s) are EXCLUDED from selection: {', '.join(needs_login)}")
291
+ print("What each one needs: claude-accounts expired")
241
292
  PYEOF
242
293
  }
243
294
 
@@ -328,6 +379,7 @@ cmd_add() {
328
379
  warn "signed in as $got (you named $email) — registering the account that actually authenticated"
329
380
  fi
330
381
  manifest_add_account "$id" "$got" "$(machine_kind)"
382
+ clear_auth_markers "$d"
331
383
  mutate_unlock
332
384
  RESERVED_DIR="" # committed — the trap must not delete it now
333
385
  trap - EXIT INT TERM
@@ -637,7 +689,12 @@ TIP
637
689
  else
638
690
  CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
639
691
  fi
640
- [ -f "$d/.credentials.json" ]
692
+ # Success is a credential that can AUTHENTICATE — not merely a file that exists.
693
+ # Re-login targets already have a (dead) .credentials.json on disk, so a bare
694
+ # existence test would call an aborted sign-in a success, clear the dead-auth
695
+ # marker, and hand the account straight back to the pool.
696
+ [ -f "$d/.credentials.json" ] || return 1
697
+ creds_alive "$d"
641
698
  }
642
699
 
643
700
  cmd_mint() {
@@ -662,6 +719,7 @@ cmd_mint() {
662
719
  || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
663
720
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
664
721
  chmod 600 "$d/server.token"
722
+ clear_auth_markers "$d"
665
723
  log_to ops.log "mint $id"
666
724
  echo "Token saved to $d/server.token"
667
725
  auto_sync
@@ -703,19 +761,159 @@ PYEOF
703
761
  fi
704
762
  ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
705
763
  chmod 600 "$d/server.token"
764
+ clear_auth_markers "$d"
706
765
  echo "$id token saved (portable — works on Mac and server)."
707
766
  else
708
- run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing changed"
767
+ run_login_ceremony "$d" "$email" \
768
+ || die "login failed or aborted (no working credential landed) — nothing changed"
709
769
  got="$(config_dir_email "$d")"
710
770
  if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
711
771
  die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
712
772
  fi
773
+ if [ -z "$got" ] && [ "$force" != "1" ]; then
774
+ die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
775
+ fi
776
+ clear_auth_markers "$d"
713
777
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
714
778
  fi
715
779
  log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
716
780
  auto_sync
717
781
  }
718
782
 
783
+ cmd_expired() {
784
+ # Which accounts CANNOT authenticate right now — the pool's re-login worklist.
785
+ # Same rule the shim selects by (lib/audit.py), so what is listed here is exactly
786
+ # what is excluded from selection.
787
+ require_manifest
788
+ local quiet=0
789
+ while [ $# -gt 0 ]; do
790
+ case "$1" in
791
+ --quiet|--ids) quiet=1; shift ;; # ids only, for scripts
792
+ *) die "unknown option: $1 (usage: claude-accounts expired [--quiet])" ;;
793
+ esac
794
+ done
795
+ local rows bad
796
+ rows="$(account_audit)" || die "the account audit failed — cannot say which logins are dead"
797
+ if [ -z "$rows" ]; then
798
+ # No rows at all: either the pool is genuinely empty, or the manifest lost its
799
+ # accounts. Never render that as "all clear".
800
+ if [ -z "$(account_ids)" ]; then
801
+ echo "No accounts registered yet — add one with: claude-accounts add"
802
+ return 0
803
+ fi
804
+ die "the manifest lists accounts but none could be audited — check $MANIFEST"
805
+ fi
806
+ bad="$(printf '%s\n' "$rows" | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }')"
807
+ if [ "$quiet" = "1" ]; then
808
+ [ -n "$bad" ] || return 0
809
+ printf '%s\n' "$bad"
810
+ return 1
811
+ fi
812
+ printf '%s\n' "$rows" | awk -F'\t' '
813
+ BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0 }
814
+ NF < 4 || $1 == "" { next } # never invent an account from a blank line
815
+ $4 == "ok" { ok++; next }
816
+ $4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
817
+ {
818
+ bad++
819
+ relogin++
820
+ printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
821
+ printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
822
+ }
823
+ END {
824
+ if (bad == 0) printf "All %d account(s) with a login on this machine can authenticate.\n", ok
825
+ else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
826
+ if (remote > 0) {
827
+ printf "\nNot logged in here on purpose (another machine owns the grant):\n"
828
+ printf "%s", rem
829
+ }
830
+ if (relogin > 0) {
831
+ printf "\nRe-authenticate them:\n"
832
+ printf " claude-accounts relogin # every account that needs it\n"
833
+ printf " claude-accounts relogin acct-NN # just one\n"
834
+ }
835
+ }'
836
+ # Exit 1 when something needs a human, so cron/health checks can alert on it.
837
+ [ -z "$bad" ]
838
+ }
839
+
840
+ cmd_relogin() {
841
+ # Re-authenticate accounts whose login died. With no arguments it targets exactly
842
+ # what `expired` lists; ids (or --all) override that. Runs the same verified login
843
+ # ceremony as `login`, one account at a time, and syncs ONCE at the end.
844
+ require_manifest
845
+ local all=0 yes=0 token=0 ids=""
846
+ while [ $# -gt 0 ]; do
847
+ case "$1" in
848
+ --all) all=1; shift ;;
849
+ --yes|-y) yes=1; shift ;;
850
+ --token) token=1; shift ;;
851
+ --*) die "unknown option: $1" ;;
852
+ *)
853
+ valid_acct_id "$1" || die "not a valid account id: $1"
854
+ account_ids | grep -qx "$1" || die "unknown account: $1"
855
+ ids="$ids $1"; shift ;;
856
+ esac
857
+ done
858
+ if [ -n "$ids" ] && [ "$all" = "1" ]; then
859
+ die "give account ids OR --all, not both"
860
+ fi
861
+ if [ -z "$ids" ]; then
862
+ if [ "$all" = "1" ]; then
863
+ ids="$(account_ids | tr '\n' ' ')"
864
+ else
865
+ # Only what a sign-in can actually fix — org-blocked accounts are listed by
866
+ # `expired` but signing into them again would fail exactly the same way.
867
+ # A FAILED audit must not read as "nothing to do".
868
+ account_audit >/dev/null || die "the account audit failed — refusing to guess what needs a re-login"
869
+ ids="$(accounts_needing_login | tr '\n' ' ')"
870
+ fi
871
+ fi
872
+ ids="$(printf '%s' "$ids" | tr -s ' ' | sed 's/^ //; s/ $//')"
873
+ if [ -z "$ids" ]; then
874
+ echo "Nothing to re-authenticate — every account on this machine can be used."
875
+ return 0
876
+ fi
877
+ local count rows
878
+ count="$(printf '%s\n' "$ids" | tr ' ' '\n' | grep -c .)"
879
+ rows="$(account_audit)"
880
+ echo "Accounts to re-authenticate ($count):"
881
+ local id
882
+ for id in $ids; do
883
+ printf ' %s %s\n' "$id" \
884
+ "$(printf '%s\n' "$rows" | awk -F'\t' -v i="$id" '$1 == i { print $2 " (" $5 ")" }')"
885
+ done
886
+ if [ "$yes" != "1" ]; then
887
+ if [ ! -t 0 ]; then
888
+ die "relogin is interactive (each account needs a browser sign-in) — run it from a terminal, or pass --yes"
889
+ fi
890
+ printf 'Sign in to each of them now? [y/N] '
891
+ read -r ans
892
+ case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
893
+ fi
894
+ # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
895
+ local prev_no_sync="${CLAUDE_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
896
+ export CLAUDE_MULTIACC_NO_SYNC=1
897
+ for id in $ids; do
898
+ echo
899
+ echo "=== $id ==============================================================="
900
+ if [ "$token" = "1" ]; then
901
+ ( cmd_login "$id" --token ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
902
+ else
903
+ ( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
904
+ fi
905
+ done
906
+ export CLAUDE_MULTIACC_NO_SYNC="$prev_no_sync"
907
+ [ "$prev_no_sync" = "0" ] && unset CLAUDE_MULTIACC_NO_SYNC
908
+ echo
909
+ echo "re-authenticated $done_ok of $count account(s)."
910
+ if [ -n "$failed" ]; then
911
+ warn "still failing:$failed (re-run: claude-accounts relogin$failed)"
912
+ fi
913
+ [ "$done_ok" -gt 0 ] && auto_sync
914
+ [ -z "$failed" ]
915
+ }
916
+
719
917
  cmd_limits() {
720
918
  require_manifest
721
919
  local quiet=0 force=0
@@ -740,24 +938,9 @@ cmd_limits() {
740
938
  # shellcheck disable=SC2064
741
939
  trap "rm -rf '$lock'" EXIT
742
940
  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
941
+ # NB: expired OAuth access tokens are refreshed inside the Python below via the
942
+ # refresh-token grant. (`claude auth status` was tried for this and does NOT
943
+ # refresh credentials it only reports the on-disk state.)
761
944
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
762
945
  # never loosen it, or an account could sit at 95% and still be selected.
763
946
  local threshold
@@ -773,6 +956,21 @@ now = time.time()
773
956
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
774
957
  MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
775
958
 
959
+ # OAuth refresh-token grant — the same endpoint + public client id Claude Code
960
+ # itself uses to keep .credentials.json alive. An account that sits idle past its
961
+ # access-token TTL would otherwise drop out of telemetry forever (stale data ranks
962
+ # neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
963
+ TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
964
+ 'https://console.anthropic.com/v1/oauth/token')
965
+ CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
966
+ '9d1c250a-e61b-44d9-88ed-5944d1962f5e')
967
+ # Only refresh a token that has been expired for a while: a LIVE session refreshes
968
+ # its own credential within moments of expiry, so a long-expired one proves no
969
+ # other writer is active (refresh tokens rotate; two racing refreshers strand one).
970
+ REFRESH_MIN_EXPIRED = 300
971
+ REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
972
+ REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
973
+
776
974
  def say(msg):
777
975
  if not quiet:
778
976
  print(msg)
@@ -788,39 +986,179 @@ def parse_iso(s):
788
986
  except Exception:
789
987
  return None
790
988
 
989
+ # `.expired` — the persistent "this account cannot authenticate" marker the shim
990
+ # honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
991
+ # 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
992
+ def mark_expired(d, slug, detail=''):
993
+ mpath = os.path.join(d, '.expired')
994
+ try:
995
+ with open(mpath + '.tmp', 'w') as f:
996
+ f.write(f'{int(now)}\n')
997
+ f.write(f"reason={slug} marked_at="
998
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
999
+ os.replace(mpath + '.tmp', mpath)
1000
+ except Exception:
1001
+ pass
1002
+
1003
+ def clear_expired(d):
1004
+ """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
1005
+ EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
1006
+ they are just barred from Claude Code inference, so telemetry says nothing about
1007
+ them. Only a passing `verify` (a real call) or a re-login lifts that."""
1008
+ mpath = os.path.join(d, '.expired')
1009
+ try:
1010
+ if 'reason=org-blocked' in open(mpath, errors='replace').read():
1011
+ return False
1012
+ except OSError:
1013
+ return False
1014
+ try:
1015
+ os.remove(mpath)
1016
+ return True
1017
+ except OSError:
1018
+ return False
1019
+
791
1020
  try:
792
1021
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
793
1022
  except Exception as e:
794
1023
  sys.exit(f'cannot read manifest: {e}')
795
1024
 
1025
+ def refresh_oauth(aid, d, cpath):
1026
+ """Refresh a long-expired OAuth access token via the refresh-token grant and
1027
+ persist the ROTATED credential atomically (0600). Returns the new bearer, or
1028
+ None (fail open: the on-disk credential is never touched on failure).
1029
+ Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
1030
+ because telemetry state must only ever reflect real usage fetches."""
1031
+ spath = os.path.join(d, '.oauth-refresh.json')
1032
+ try:
1033
+ doc = json.load(open(cpath))
1034
+ o = doc.get('claudeAiOauth', {})
1035
+ # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
1036
+ # write) must degrade THIS account only, like every other malformed input.
1037
+ if not isinstance(doc, dict) or not isinstance(o, dict):
1038
+ return None
1039
+ except Exception:
1040
+ return None
1041
+ if not o.get('accessToken'):
1042
+ return None
1043
+ if not o.get('refreshToken'):
1044
+ mark_expired(d, 'no-refresh-token',
1045
+ 'credential has no refresh token and its access token expired')
1046
+ return None
1047
+ if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
1048
+ return None # not expired long enough to prove no live session owns it
1049
+ if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
1050
+ # Nothing can revive this account: park it so the shim stops selecting it
1051
+ # (every run under it would fail with "OAuth session expired").
1052
+ mark_expired(d, 'refresh-token-expired',
1053
+ 'the refresh token itself expired; only a re-login can fix it')
1054
+ say(f'{aid}: refresh token expired — re-login needed (claude-accounts relogin {aid})')
1055
+ return None
1056
+ if not force:
1057
+ try:
1058
+ if json.load(open(spath)).get('retry_after', 0) > now:
1059
+ return None # earlier refresh failure still backing off
1060
+ except Exception:
1061
+ pass
1062
+
1063
+ def back_off(wait, why, denials=0):
1064
+ try:
1065
+ with open(spath + '.tmp', 'w') as f:
1066
+ json.dump({'retry_after': int(now + wait), 'error': why,
1067
+ 'denials': denials,
1068
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
1069
+ os.replace(spath + '.tmp', spath)
1070
+ except Exception:
1071
+ pass
1072
+ say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
1073
+
1074
+ body = json.dumps({'grant_type': 'refresh_token',
1075
+ 'refresh_token': o['refreshToken'],
1076
+ 'client_id': CLIENT_ID}).encode()
1077
+ req = urllib.request.Request(TOKEN_URL, data=body, headers={
1078
+ 'Content-Type': 'application/json',
1079
+ 'User-Agent': 'claude-multiacc/1.0',
1080
+ })
1081
+ try:
1082
+ data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
1083
+ except urllib.error.HTTPError as e:
1084
+ if e.code in (400, 401, 403):
1085
+ # A 4xx from the TOKEN endpoint is only proof of a dead grant when the
1086
+ # server says so (OAuth's invalid_grant). Everything else 4xx — a bad
1087
+ # client_id, an endpoint change, a WAF page, a provider incident — would
1088
+ # hit EVERY account at once, so it must never park the whole pool on the
1089
+ # first try: back off, and only park after this account has been refused
1090
+ # repeatedly.
1091
+ body = ''
1092
+ try:
1093
+ body = e.read().decode('utf-8', 'replace')[:400]
1094
+ except Exception:
1095
+ pass
1096
+ denials = 1
1097
+ try:
1098
+ denials = int(json.load(open(spath)).get('denials', 0)) + 1
1099
+ except Exception:
1100
+ pass
1101
+ if 'invalid_grant' in body:
1102
+ mark_expired(d, f'refresh-denied-http-{e.code}',
1103
+ 'the refresh grant was refused as invalid_grant (revoked or rotated away)')
1104
+ elif denials >= 3:
1105
+ mark_expired(d, f'refresh-denied-http-{e.code}',
1106
+ f'the refresh grant was refused {denials} times in a row')
1107
+ back_off(REFRESH_DENIED_BACKOFF,
1108
+ f'HTTP {e.code} — refresh token may be revoked; re-login needed',
1109
+ denials=denials)
1110
+ else:
1111
+ back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
1112
+ return None
1113
+ except Exception as e:
1114
+ back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
1115
+ return None
1116
+ tok = data.get('access_token') if isinstance(data, dict) else None
1117
+ if not tok:
1118
+ back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
1119
+ return None
1120
+ o['accessToken'] = tok
1121
+ # The grant ROTATES the refresh token: persist it (and both expiries) or the
1122
+ # account is stranded — hence atomic write, and a loud message if it fails.
1123
+ if data.get('refresh_token'):
1124
+ o['refreshToken'] = data['refresh_token']
1125
+ if data.get('expires_in'):
1126
+ o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
1127
+ else:
1128
+ # No expires_in in the response: assume a conservative 1h. Leaving the old
1129
+ # (past) expiresAt would make every later pass re-run the grant in a loop.
1130
+ o['expiresAt'] = int((now + 3600) * 1000)
1131
+ if data.get('refresh_token_expires_in'):
1132
+ o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
1133
+ doc['claudeAiOauth'] = o
1134
+ try:
1135
+ fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1136
+ with os.fdopen(fd, 'w') as f:
1137
+ json.dump(doc, f)
1138
+ os.replace(cpath + '.tmp', cpath)
1139
+ except Exception as e:
1140
+ say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
1141
+ return None
1142
+ try:
1143
+ os.remove(spath)
1144
+ except OSError:
1145
+ pass
1146
+ # The grant answered: whatever parked this account before, it authenticates now.
1147
+ if clear_expired(d):
1148
+ say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
1149
+ say(f'{aid}: oauth access token refreshed via refresh-token grant')
1150
+ return tok
1151
+
796
1152
  for acct in manifest.get('accounts', []):
797
1153
  aid = acct['id']
798
1154
  d = os.path.join(root, aid)
799
1155
  if not os.path.isdir(d):
800
1156
  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
1157
 
821
1158
  # The usage endpoint rate-limits per account. Several callers can fire at once
822
1159
  # (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
823
1160
  # this account's data is already fresh, and honor any backoff a 429 set earlier.
1161
+ # Checked FIRST so a skipped account never burns an oauth refresh for nothing.
824
1162
  lpath = os.path.join(d, 'limits.json')
825
1163
  prev = {}
826
1164
  if os.path.isfile(lpath):
@@ -837,6 +1175,36 @@ for acct in manifest.get('accounts', []):
837
1175
  say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
838
1176
  continue
839
1177
 
1178
+ bearer = None
1179
+ source = None
1180
+ cpath = os.path.join(d, '.credentials.json')
1181
+ if os.path.isfile(cpath):
1182
+ try:
1183
+ c = json.load(open(cpath)).get('claudeAiOauth', {})
1184
+ if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
1185
+ bearer, source = c['accessToken'], 'oauth'
1186
+ except Exception:
1187
+ pass
1188
+ tpath = os.path.join(d, 'server.token')
1189
+ if not bearer and os.path.isfile(tpath):
1190
+ t = open(tpath).read().strip()
1191
+ if t:
1192
+ bearer, source = t, 'token'
1193
+ if not bearer and os.path.isfile(cpath):
1194
+ # Hard fail-open guard: NOTHING a single account's refresh does may abort
1195
+ # the loop — every account after it would silently starve of telemetry.
1196
+ try:
1197
+ tok = refresh_oauth(aid, d, cpath)
1198
+ except Exception as e:
1199
+ say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
1200
+ tok = None
1201
+ if tok:
1202
+ bearer, source = tok, 'oauth'
1203
+ if not bearer:
1204
+ # Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
1205
+ say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
1206
+ continue
1207
+
840
1208
  req = urllib.request.Request(url, headers={
841
1209
  'Authorization': 'Bearer ' + bearer,
842
1210
  'anthropic-beta': 'oauth-2025-04-20',
@@ -958,6 +1326,9 @@ for acct in manifest.get('accounts', []):
958
1326
  with open(tmp, 'w') as f:
959
1327
  json.dump(out, f, indent=1)
960
1328
  os.replace(tmp, lpath)
1329
+ # The fetch went through with this account's own bearer => its auth is alive.
1330
+ if clear_expired(d):
1331
+ say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
961
1332
  offenders = [b for b in buckets if b['percent'] >= threshold]
962
1333
  mpath = os.path.join(d, '.limited')
963
1334
  if offenders:
@@ -1000,15 +1371,40 @@ cmd_verify() {
1000
1371
  if [ "$quick" = "0" ]; then
1001
1372
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
1002
1373
  fi
1003
- "$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
1004
- import json, os, subprocess, sys, time
1374
+ "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
1375
+ import json, os, re, subprocess, sys, time
1005
1376
 
1006
1377
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
1378
+ sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
1379
+ from audit import audit_account, creds_state # noqa: E402 (shared with the shim's rule)
1380
+ machine = sys.argv[5]
1007
1381
  now = time.time()
1008
1382
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
1009
1383
  failures = 0
1010
1384
  tested = 0
1011
1385
 
1386
+ # Same failure vocabulary the shim retries on (bin/claude: AUTHPAT / ORGPAT).
1387
+ AUTH_ERR = re.compile(
1388
+ r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
1389
+ r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
1390
+ r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
1391
+ ORG_ERR = re.compile(
1392
+ r'organization has disabled|subscription access.*disabl|disabled claude subscription'
1393
+ r'|ask your admin to enable|not authorized to use claude code', re.I)
1394
+
1395
+ def mark_expired(d, slug, detail=''):
1396
+ """Park an account the shim must stop selecting. Verify is the strongest signal
1397
+ there is — a real inference call that came back 'not authenticated'."""
1398
+ mpath = os.path.join(d, '.expired')
1399
+ try:
1400
+ with open(mpath + '.tmp', 'w') as f:
1401
+ f.write(f'{int(time.time())}\n')
1402
+ f.write(f"reason={slug} marked_at="
1403
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
1404
+ os.replace(mpath + '.tmp', mpath)
1405
+ except Exception:
1406
+ pass
1407
+
1012
1408
  for acct in manifest.get('accounts', []):
1013
1409
  aid = acct['id']
1014
1410
  d = os.path.join(root, aid)
@@ -1024,8 +1420,11 @@ for acct in manifest.get('accounts', []):
1024
1420
  try:
1025
1421
  c = json.load(open(cpath)).get('claudeAiOauth', {})
1026
1422
  rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
1027
- if rexp and rexp < now:
1028
- print(f'{aid} {acct["email"]}: FAIL (refresh token expired — re-login)')
1423
+ if rexp and rexp < now and not has_token:
1424
+ mark_expired(d, 'refresh-token-expired',
1425
+ 'the refresh token itself expired; only a re-login can fix it')
1426
+ print(f'{aid} {acct["email"]}: FAIL (refresh token expired — '
1427
+ f'run: claude-accounts relogin {aid})')
1029
1428
  failures += 1
1030
1429
  continue
1031
1430
  except Exception as e:
@@ -1033,8 +1432,16 @@ for acct in manifest.get('accounts', []):
1033
1432
  failures += 1
1034
1433
  continue
1035
1434
  if quick:
1435
+ # Quick mode must agree with what the shim will actually do — presence of a
1436
+ # credential file is not proof it can authenticate.
1437
+ st = audit_account(root, acct, machine=machine)
1036
1438
  kind = 'oauth' if has_creds else 'token'
1037
- print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
1439
+ if st['state'] == 'ok':
1440
+ print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
1441
+ else:
1442
+ print(f'{aid} {acct["email"]}: FAIL ({st["label"]} — {st["reason"]})'
1443
+ + (f'; fix: {st["fix"]}' if st['fix'] else ''))
1444
+ failures += 1
1038
1445
  continue
1039
1446
  env = dict(os.environ)
1040
1447
  env['CLAUDE_CONFIG_DIR'] = d
@@ -1042,7 +1449,10 @@ for acct in manifest.get('accounts', []):
1042
1449
  env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
1043
1450
  env.pop('CLAUDE_ACCOUNT', None)
1044
1451
  env['CLAUDE_SHIM_ACTIVE'] = '1'
1045
- if not has_creds:
1452
+ # Mirror the shim's acct_token(): a portable token is what actually authenticates
1453
+ # whenever there is no credential OR the credential beside it is dead. Testing such
1454
+ # an account with the dead credential would fail it — and park a healthy account.
1455
+ if has_token and (not has_creds or creds_state(cpath, now)[0] != 'ok'):
1046
1456
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
1047
1457
  t0 = time.time()
1048
1458
  try:
@@ -1056,10 +1466,28 @@ for acct in manifest.get('accounts', []):
1056
1466
  dt = time.time() - t0
1057
1467
  out = (r.stdout or '').strip()
1058
1468
  if r.returncode == 0 and 'ok' in out.lower():
1469
+ # A real call succeeded: this account is definitively alive.
1470
+ try:
1471
+ os.remove(os.path.join(d, '.expired'))
1472
+ except OSError:
1473
+ pass
1059
1474
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
1060
1475
  else:
1061
1476
  err = (r.stderr or '').strip()[:200]
1062
- print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} out={out[:120]!r} err={err!r}')
1477
+ hint = ''
1478
+ if ORG_ERR.search(out) or ORG_ERR.search(err):
1479
+ # Not an auth problem: the account authenticates fine, its organization
1480
+ # has simply turned Claude Code subscription access off. Park it — a
1481
+ # re-login changes nothing — and say what actually helps.
1482
+ mark_expired(d, 'org-blocked',
1483
+ "the account's organization has disabled Claude Code access")
1484
+ hint = (f' — ORG BLOCKED, excluded from the pool; '
1485
+ f'try: claude-accounts relogin {aid}')
1486
+ elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
1487
+ mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
1488
+ hint = f' — login is dead, run: claude-accounts relogin {aid}'
1489
+ print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
1490
+ f'out={out[:120]!r} err={err!r}{hint}')
1063
1491
  failures += 1
1064
1492
 
1065
1493
  print()
@@ -1118,8 +1546,12 @@ PYEOF
1118
1546
  ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
1119
1547
  || fail "mkdir $id failed"
1120
1548
  if [ -s "$d/server.token" ]; then
1121
- rsync -az --chmod=F600 "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1549
+ # NB: no --chmod macOS 26 ships openrsync, which rejects it (the push would
1550
+ # fail outright). The mode is fixed with an explicit remote chmod instead.
1551
+ rsync -az "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1122
1552
  || fail "token push for $id failed"
1553
+ ssh -o BatchMode=yes "$server" "chmod 600 '$sroot/$id/server.token'" \
1554
+ >>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id failed"
1123
1555
  fi
1124
1556
  local seed
1125
1557
  for seed in .claude.json settings.json; do
@@ -1248,6 +1680,8 @@ case "${1:-help}" in
1248
1680
  remove) shift; cmd_remove "$@" ;;
1249
1681
  mint) shift; cmd_mint "$@" ;;
1250
1682
  login) shift; cmd_login "$@" ;;
1683
+ expired) shift; cmd_expired "$@" ;;
1684
+ relogin|re-login) shift; cmd_relogin "$@" ;;
1251
1685
  sync) shift; cmd_sync "$@" ;;
1252
1686
  verify) shift; cmd_verify "$@" ;;
1253
1687
  limits) shift; cmd_limits "$@" ;;