claude-multiacc 1.0.5 → 1.0.7

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)
@@ -136,10 +148,13 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
136
148
 
137
149
  cmd_list() {
138
150
  require_manifest
139
- "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
151
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
140
152
  import json, os, re, sys
141
153
  doc = json.load(open(sys.argv[1]))
142
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]
143
158
  # Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
144
159
  accounts = [a for a in doc.get('accounts', [])
145
160
  if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
@@ -153,7 +168,24 @@ for a in accounts:
153
168
  if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
154
169
  auth.append('token')
155
170
  limited = os.path.isfile(os.path.join(d, '.limited'))
156
- 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")
157
189
  seen = {}
158
190
  for a in accounts:
159
191
  seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
@@ -167,10 +199,13 @@ PYEOF
167
199
 
168
200
  cmd_status() {
169
201
  require_manifest
170
- "$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
202
+ "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
171
203
  import json, os, sys, time
172
204
  doc = json.load(open(sys.argv[1]))
173
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]
174
209
  now = time.time()
175
210
 
176
211
  def last_pick(aid):
@@ -192,10 +227,20 @@ print(f"pool root : {root}")
192
227
  print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
193
228
  print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
194
229
  print()
230
+ needs_login = []
195
231
  for a in doc.get('accounts', []):
196
232
  aid = a['id']
197
233
  d = os.path.join(root, aid)
198
- 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']}")
199
244
  cpath = os.path.join(d, '.credentials.json')
200
245
  if os.path.isfile(cpath):
201
246
  try:
@@ -241,6 +286,9 @@ for a in doc.get('accounts', []):
241
286
  print(" marker : none (eligible)")
242
287
  print(f" last picked : {last_pick(aid)}")
243
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")
244
292
  PYEOF
245
293
  }
246
294
 
@@ -331,6 +379,7 @@ cmd_add() {
331
379
  warn "signed in as $got (you named $email) — registering the account that actually authenticated"
332
380
  fi
333
381
  manifest_add_account "$id" "$got" "$(machine_kind)"
382
+ clear_auth_markers "$d"
334
383
  mutate_unlock
335
384
  RESERVED_DIR="" # committed — the trap must not delete it now
336
385
  trap - EXIT INT TERM
@@ -640,7 +689,12 @@ TIP
640
689
  else
641
690
  CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
642
691
  fi
643
- [ -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"
644
698
  }
645
699
 
646
700
  cmd_mint() {
@@ -665,6 +719,7 @@ cmd_mint() {
665
719
  || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
666
720
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
667
721
  chmod 600 "$d/server.token"
722
+ clear_auth_markers "$d"
668
723
  log_to ops.log "mint $id"
669
724
  echo "Token saved to $d/server.token"
670
725
  auto_sync
@@ -706,19 +761,159 @@ PYEOF
706
761
  fi
707
762
  ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
708
763
  chmod 600 "$d/server.token"
764
+ clear_auth_markers "$d"
709
765
  echo "$id token saved (portable — works on Mac and server)."
710
766
  else
711
- 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"
712
769
  got="$(config_dir_email "$d")"
713
770
  if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
714
771
  die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
715
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"
716
777
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
717
778
  fi
718
779
  log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
719
780
  auto_sync
720
781
  }
721
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
+
722
917
  cmd_limits() {
723
918
  require_manifest
724
919
  local quiet=0 force=0
@@ -791,6 +986,37 @@ def parse_iso(s):
791
986
  except Exception:
792
987
  return None
793
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
+
794
1020
  try:
795
1021
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
796
1022
  except Exception as e:
@@ -812,12 +1038,20 @@ def refresh_oauth(aid, d, cpath):
812
1038
  return None
813
1039
  except Exception:
814
1040
  return None
815
- if not o.get('refreshToken') or not o.get('accessToken'):
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')
816
1046
  return None
817
1047
  if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
818
1048
  return None # not expired long enough to prove no live session owns it
819
1049
  if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
820
- say(f'{aid}: refresh token expired re-login needed (claude-accounts login {aid})')
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})')
821
1055
  return None
822
1056
  if not force:
823
1057
  try:
@@ -826,10 +1060,11 @@ def refresh_oauth(aid, d, cpath):
826
1060
  except Exception:
827
1061
  pass
828
1062
 
829
- def back_off(wait, why):
1063
+ def back_off(wait, why, denials=0):
830
1064
  try:
831
1065
  with open(spath + '.tmp', 'w') as f:
832
1066
  json.dump({'retry_after': int(now + wait), 'error': why,
1067
+ 'denials': denials,
833
1068
  'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
834
1069
  os.replace(spath + '.tmp', spath)
835
1070
  except Exception:
@@ -847,8 +1082,31 @@ def refresh_oauth(aid, d, cpath):
847
1082
  data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
848
1083
  except urllib.error.HTTPError as e:
849
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')
850
1107
  back_off(REFRESH_DENIED_BACKOFF,
851
- f'HTTP {e.code} — refresh token may be revoked; re-login needed')
1108
+ f'HTTP {e.code} — refresh token may be revoked; re-login needed',
1109
+ denials=denials)
852
1110
  else:
853
1111
  back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
854
1112
  return None
@@ -885,6 +1143,9 @@ def refresh_oauth(aid, d, cpath):
885
1143
  os.remove(spath)
886
1144
  except OSError:
887
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)')
888
1149
  say(f'{aid}: oauth access token refreshed via refresh-token grant')
889
1150
  return tok
890
1151
 
@@ -1065,6 +1326,9 @@ for acct in manifest.get('accounts', []):
1065
1326
  with open(tmp, 'w') as f:
1066
1327
  json.dump(out, f, indent=1)
1067
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)')
1068
1332
  offenders = [b for b in buckets if b['percent'] >= threshold]
1069
1333
  mpath = os.path.join(d, '.limited')
1070
1334
  if offenders:
@@ -1107,15 +1371,40 @@ cmd_verify() {
1107
1371
  if [ "$quick" = "0" ]; then
1108
1372
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
1109
1373
  fi
1110
- "$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
1111
- 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
1112
1376
 
1113
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]
1114
1381
  now = time.time()
1115
1382
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
1116
1383
  failures = 0
1117
1384
  tested = 0
1118
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
+
1119
1408
  for acct in manifest.get('accounts', []):
1120
1409
  aid = acct['id']
1121
1410
  d = os.path.join(root, aid)
@@ -1131,8 +1420,11 @@ for acct in manifest.get('accounts', []):
1131
1420
  try:
1132
1421
  c = json.load(open(cpath)).get('claudeAiOauth', {})
1133
1422
  rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
1134
- if rexp and rexp < now:
1135
- 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})')
1136
1428
  failures += 1
1137
1429
  continue
1138
1430
  except Exception as e:
@@ -1140,8 +1432,16 @@ for acct in manifest.get('accounts', []):
1140
1432
  failures += 1
1141
1433
  continue
1142
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)
1143
1438
  kind = 'oauth' if has_creds else 'token'
1144
- 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
1145
1445
  continue
1146
1446
  env = dict(os.environ)
1147
1447
  env['CLAUDE_CONFIG_DIR'] = d
@@ -1149,7 +1449,10 @@ for acct in manifest.get('accounts', []):
1149
1449
  env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
1150
1450
  env.pop('CLAUDE_ACCOUNT', None)
1151
1451
  env['CLAUDE_SHIM_ACTIVE'] = '1'
1152
- 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'):
1153
1456
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
1154
1457
  t0 = time.time()
1155
1458
  try:
@@ -1163,10 +1466,28 @@ for acct in manifest.get('accounts', []):
1163
1466
  dt = time.time() - t0
1164
1467
  out = (r.stdout or '').strip()
1165
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
1166
1474
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
1167
1475
  else:
1168
1476
  err = (r.stderr or '').strip()[:200]
1169
- 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}')
1170
1491
  failures += 1
1171
1492
 
1172
1493
  print()
@@ -1225,8 +1546,12 @@ PYEOF
1225
1546
  ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
1226
1547
  || fail "mkdir $id failed"
1227
1548
  if [ -s "$d/server.token" ]; then
1228
- 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 \
1229
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"
1230
1555
  fi
1231
1556
  local seed
1232
1557
  for seed in .claude.json settings.json; do
@@ -1355,6 +1680,8 @@ case "${1:-help}" in
1355
1680
  remove) shift; cmd_remove "$@" ;;
1356
1681
  mint) shift; cmd_mint "$@" ;;
1357
1682
  login) shift; cmd_login "$@" ;;
1683
+ expired) shift; cmd_expired "$@" ;;
1684
+ relogin|re-login) shift; cmd_relogin "$@" ;;
1358
1685
  sync) shift; cmd_sync "$@" ;;
1359
1686
  verify) shift; cmd_verify "$@" ;;
1360
1687
  limits) shift; cmd_limits "$@" ;;