claude-multiacc 2.0.9 → 2.0.11

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/bin/claude CHANGED
@@ -559,17 +559,26 @@ client_auth_scan() { # $1 acct dir
559
559
  return 1
560
560
  }
561
561
 
562
- mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail
563
- local marked
562
+ mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reason slug
563
+ local marked slug="${3:-auth-error}"
564
564
  marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
565
- { echo "$now"; echo "reason=auth-error marked_at=$marked detail=$2"; } \
565
+ { echo "$now"; echo "reason=$slug marked_at=$marked detail=$2"; } \
566
566
  2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
567
567
  || rm -f "$1/.expired.$$" 2>/dev/null || true
568
568
  }
569
569
 
570
570
  mark_client_auth_dead() { # $1 acct dir
571
- mark_proven_auth_dead "$1" "client session failed to authenticate"
572
- sel_log "$(basename "$1") parked (auth-error) — client-reported"
571
+ local tok
572
+ tok="$(acct_token "$1")"
573
+ if [ -n "$tok" ]; then
574
+ rm -f "$1/.server-token-verified" 2>/dev/null || true
575
+ mark_proven_auth_dead "$1" "client rejected the portable OAuth token" \
576
+ "setup-token-invalid"
577
+ sel_log "$(basename "$1") parked (setup-token-invalid) — client-reported"
578
+ else
579
+ mark_proven_auth_dead "$1" "client session failed to authenticate"
580
+ sel_log "$(basename "$1") parked (auth-error) — client-reported"
581
+ fi
573
582
  }
574
583
 
575
584
  mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
@@ -750,8 +759,9 @@ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid
750
759
  if [ "$rc" -ne 0 ] \
751
760
  && grep -qiE "$token_auth_pat" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
752
761
  rm -f "$marker" 2>/dev/null || true
753
- mark_proven_auth_dead "$d" "portable OAuth token rejected by inference preflight"
754
- sel_log "$(basename "$d") parked (auth-error) — setup-token preflight returned 401"
762
+ mark_proven_auth_dead "$d" "portable OAuth token rejected by inference preflight" \
763
+ "setup-token-invalid"
764
+ sel_log "$(basename "$d") parked (setup-token-invalid) — preflight returned 401"
755
765
  rm -rf "$tmpd"
756
766
  return 1
757
767
  fi
@@ -1244,9 +1254,14 @@ while :; do
1244
1254
  park_detail="the account's organization has disabled Claude Code subscription access"
1245
1255
  park_soft=$((now + PARK_SOFT_ORG))
1246
1256
  elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
1247
- park_reason="auth-error"
1248
- park_detail="run failed to authenticate"
1249
- park_soft=$((now + PARK_SOFT_AUTH))
1257
+ if [ -n "$tok" ]; then
1258
+ park_reason="setup-token-invalid"
1259
+ park_detail="run rejected the portable OAuth token"
1260
+ else
1261
+ park_reason="auth-error"
1262
+ park_detail="run failed to authenticate"
1263
+ park_soft=$((now + PARK_SOFT_AUTH))
1264
+ fi
1250
1265
  fi
1251
1266
  # An account that ran out of ONE model has not run out. Cooling it down would
1252
1267
  # take a perfectly usable account out of the pool for every other model too —
@@ -1254,13 +1269,20 @@ while :; do
1254
1269
  model_scoped=0
1255
1270
  grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null && model_scoped=1
1256
1271
  if [ -n "$park_reason" ]; then
1257
- {
1258
- echo "$now"
1259
- echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
1260
- } 2>/dev/null > "$cur/.expired.$$" \
1272
+ if [ "$park_reason" = "setup-token-invalid" ]; then
1273
+ rm -f "$cur/.server-token-verified" 2>/dev/null || true
1274
+ park_line="reason=$park_reason marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
1275
+ else
1276
+ park_line="reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
1277
+ fi
1278
+ { echo "$now"; echo "$park_line"; } 2>/dev/null > "$cur/.expired.$$" \
1261
1279
  && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
1262
1280
  || rm -f "$cur/.expired.$$" 2>/dev/null || true
1263
- sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
1281
+ if [ "$park_reason" = "setup-token-invalid" ]; then
1282
+ sel_log "$(basename "$cur") parked ($park_reason, durable) — see: claude-accounts expired"
1283
+ else
1284
+ sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
1285
+ fi
1264
1286
  elif [ "$model_scoped" = 0 ]; then
1265
1287
  {
1266
1288
  echo $((now + 600))
@@ -227,6 +227,8 @@ for a in accounts:
227
227
  flags = []
228
228
  if st['state'] == 'expired':
229
229
  flags.append('EXPIRED-LOGIN')
230
+ elif st['state'] == 'token-invalid':
231
+ flags.append('INVALID-TOKEN')
230
232
  elif st['state'] == 'blocked':
231
233
  flags.append('ORG-BLOCKED')
232
234
  elif st['state'] == 'missing':
@@ -239,7 +241,7 @@ for a in accounts:
239
241
  f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
240
242
  bad = [a for a in accounts
241
243
  if audit_account(root, a, machine=machine)['state']
242
- in ('expired', 'blocked', 'missing')]
244
+ in ('expired', 'token-invalid', 'blocked', 'missing')]
243
245
  if bad:
244
246
  print()
245
247
  print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
@@ -317,13 +319,15 @@ for a in doc.get('accounts', []):
317
319
  aid = a['id']
318
320
  d = os.path.join(root, aid)
319
321
  st = audit_account(root, a, machine=machine)
320
- if st['state'] in ('expired', 'blocked', 'missing'):
322
+ if st['state'] in ('expired', 'token-invalid', 'blocked', 'missing'):
321
323
  needs_login.append(aid)
322
324
  else:
323
325
  selectable_ids.append(aid)
324
326
  banner = {'ok': '', 'remote': ' [not logged in here — grant lives elsewhere]',
325
327
  'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
326
328
  'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
329
+ 'token-invalid': (' ** SETUP-TOKEN INVALID — '
330
+ 'claude-accounts login %s --token **' % aid),
327
331
  'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
328
332
  'locked': ' [login is in the macOS Keychain — locked for THIS session, fine from the Mac itself]',
329
333
  }[st['state']]
@@ -504,8 +508,7 @@ cmd_add() {
504
508
  # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
505
509
  echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
506
510
  run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
507
- ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
508
- chmod 600 "$d/server.token"
511
+ commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
509
512
  got="$(token_email "$CEREMONY_TOKEN")"
510
513
  else
511
514
  # Default: full Claude Code login (full scopes, no long-lived-token step-up).
@@ -567,7 +570,9 @@ EOF
567
570
 
568
571
  add_cleanup_reserved() {
569
572
  # EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
570
- # we died mid-critical-section, release the lock).
573
+ # we died mid-critical-section, release the lock). A ceremony interrupted mid-way
574
+ # must also hand the terminal back at its own width.
575
+ restore_ceremony_tty 2>/dev/null || true
571
576
  [ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
572
577
  mutate_unlock 2>/dev/null || true
573
578
  }
@@ -790,6 +795,137 @@ valid_subscription_token() {
790
795
  esac
791
796
  }
792
797
 
798
+ # A complete subscription setup-token is 108 characters (sk-ant-oat01- + 95). The
799
+ # ceremony scrapes it from a terminal transcript, and the client's TUI HARD-WRAPS at
800
+ # the pty's width — an unsized pty (the panel's) renders as 80 columns. Every
801
+ # panel-driven mint of 2026-08-28 therefore saved the first 79 characters of a
802
+ # 108-character token; the fleet rejected all seven with 401 while the minting Mac,
803
+ # still holding its OAuth login, looked healthy. Two guards, belt and braces: the
804
+ # terminal is widened for the ceremony, and the capture must pass a real inference
805
+ # before it is saved anywhere.
806
+ SETUP_TOKEN_FULL_LEN=108
807
+ CEREMONY_TTY_MIN_COLS=160
808
+ CEREMONY_TTY_COLS=400
809
+ CEREMONY_TTY_ORIG=""
810
+
811
+ widen_ceremony_tty() {
812
+ CEREMONY_TTY_ORIG=""
813
+ [ -t 0 ] || return 0
814
+ local size rows cols
815
+ size="$(stty size 2>/dev/null)" || return 0
816
+ rows="${size%% *}"; cols="${size##* }"
817
+ case "$cols" in ''|*[!0-9]*) return 0 ;; esac
818
+ case "$rows" in ''|*[!0-9]*) rows=0 ;; esac
819
+ [ "$cols" -ge "$CEREMONY_TTY_MIN_COLS" ] && return 0
820
+ CEREMONY_TTY_ORIG="$rows $cols"
821
+ # `script` copies stdin's window size to the pty it opens for the client, so
822
+ # widening here is what the client sees. A zero row count is an unsized pty too.
823
+ stty cols "$CEREMONY_TTY_COLS" rows "$([ "$rows" -gt 0 ] && echo "$rows" || echo 50)" 2>/dev/null \
824
+ || CEREMONY_TTY_ORIG=""
825
+ }
826
+
827
+ restore_ceremony_tty() {
828
+ [ -n "$CEREMONY_TTY_ORIG" ] || return 0
829
+ stty rows "${CEREMONY_TTY_ORIG%% *}" cols "${CEREMONY_TTY_ORIG##* }" 2>/dev/null || true
830
+ CEREMONY_TTY_ORIG=""
831
+ }
832
+
833
+ token_digest() { # $1 token; prints a non-secret sha256 digest (same as the shim's)
834
+ local h=""
835
+ if command -v shasum >/dev/null 2>&1; then
836
+ h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
837
+ elif command -v sha256sum >/dev/null 2>&1; then
838
+ h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"
839
+ fi
840
+ printf '%s' "$h" | cut -d ' ' -f1
841
+ }
842
+
843
+ record_token_verified() { # $1 acct dir, $2 token — the shim's own proof marker (token_preflight)
844
+ local digest
845
+ digest="$(token_digest "$2")"
846
+ [ -n "$digest" ] || return 0
847
+ { umask 077; printf '%s\n' "$digest" > "$1/.server-token-verified.$$"; } 2>/dev/null \
848
+ && mv -f "$1/.server-token-verified.$$" "$1/.server-token-verified" 2>/dev/null \
849
+ || rm -f "$1/.server-token-verified.$$" 2>/dev/null || true
850
+ }
851
+
852
+ # Prove a captured token with ONE real inference before it is saved. rc 0 = Claude
853
+ # answered with it; rc 1 = Claude REJECTED it (401: revoked, wrong account's grant,
854
+ # or captured incomplete); rc 2 = inconclusive (network, 429, timeout). The probe runs
855
+ # in an EMPTY config dir: the account dir may hold an OAuth login the client would
856
+ # silently prefer, and this must exercise the captured token and nothing else. The
857
+ # token travels by environment, never argv.
858
+ CEREMONY_CHECK_DETAIL=""
859
+ ceremony_probe() { # $1 = real claude, $2 = empty config dir; token in CEREMONY_TOKEN_UNDER_TEST
860
+ "$PYBIN" - "$1" "$2" "$ACC_ROOT" <<'PYEOF'
861
+ import os, re, subprocess, sys
862
+ real, cfg, root = sys.argv[1], sys.argv[2], sys.argv[3]
863
+ env = {k: v for k, v in os.environ.items()
864
+ if k not in ('ANTHROPIC_API_KEY', 'CLAUDE_ACCOUNT', 'CEREMONY_TOKEN_UNDER_TEST')}
865
+ env['CLAUDE_CONFIG_DIR'] = cfg
866
+ env['CLAUDE_CODE_OAUTH_TOKEN'] = os.environ['CEREMONY_TOKEN_UNDER_TEST']
867
+ env['CLAUDE_SHIM_ACTIVE'] = '1'
868
+ # The shim's own vocabulary for a rejected token (bin/claude token_preflight).
869
+ AUTH = re.compile(r'failed to authenticate|oauth (access )?token is invalid|oauth session expired'
870
+ r'|please run /login|invalid bearer token|authentication_error|\b401\b', re.I)
871
+ try:
872
+ r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
873
+ env=env, capture_output=True, text=True, timeout=180,
874
+ input='Reply with exactly: OK\n', cwd=root)
875
+ except subprocess.TimeoutExpired:
876
+ print('inconclusive\ttimed out after 180s'); sys.exit(0)
877
+ except OSError as exc:
878
+ print(f'inconclusive\t{exc}'); sys.exit(0)
879
+ out, err = (r.stdout or '').strip(), (r.stderr or '').strip()
880
+ if r.returncode == 0 and 'ok' in out.lower():
881
+ print('ok\t'); sys.exit(0)
882
+ if AUTH.search(out) or AUTH.search(err):
883
+ print('rejected\t' + (err or out)[:160].replace('\n', ' ')); sys.exit(0)
884
+ print('inconclusive\t' + f'rc={r.returncode} ' + (err or out)[:160].replace('\n', ' '))
885
+ PYEOF
886
+ }
887
+
888
+ ceremony_token_check() { # $1 = token
889
+ local tok="$1" real tmpd verdict
890
+ CEREMONY_CHECK_DETAIL=""
891
+ real="$(find_real_claude "$_self")" || { CEREMONY_CHECK_DETAIL="real claude binary not found"; return 2; }
892
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
893
+ tmpd="$(mktemp -d "$ACC_ROOT/tmp/mint-check.XXXXXX" 2>/dev/null)" \
894
+ || { CEREMONY_CHECK_DETAIL="cannot create a scratch config dir"; return 2; }
895
+ chmod 700 "$tmpd" 2>/dev/null || true
896
+ verdict="$(CEREMONY_TOKEN_UNDER_TEST="$tok" ceremony_probe "$real" "$tmpd")"
897
+ rm -rf "$tmpd" 2>/dev/null || true
898
+ case "$verdict" in
899
+ ok*) return 0 ;;
900
+ rejected*) CEREMONY_CHECK_DETAIL="${verdict#rejected }"; return 1 ;;
901
+ inconclusive*) CEREMONY_CHECK_DETAIL="${verdict#inconclusive }"; return 2 ;;
902
+ *) CEREMONY_CHECK_DETAIL="no verdict"; return 2 ;;
903
+ esac
904
+ }
905
+
906
+ # The ONE place a ceremony's token is written: only after ceremony_token_check, so a
907
+ # token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
908
+ # $2 = token, $3 = acct id (messages only).
909
+ commit_ceremony_token() {
910
+ local d="$1" tok="$2" id="$3" rc=0
911
+ ceremony_token_check "$tok" || rc=$?
912
+ if [ "$rc" -eq 1 ]; then
913
+ if [ "${#tok}" -lt "$SETUP_TOKEN_FULL_LEN" ]; then
914
+ die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — it is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN: the sign-in terminal wrapped it and only its first line was captured. Nothing saved for $id. Mint again from a terminal at least $CEREMONY_TTY_MIN_COLS columns wide, or from an updated panel."
915
+ fi
916
+ die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — nothing saved for $id; sign in again as the right account and retry"
917
+ fi
918
+ ( umask 077; printf '%s' "$tok" > "$d/server.token" )
919
+ chmod 600 "$d/server.token"
920
+ if [ "$rc" -eq 0 ]; then
921
+ record_token_verified "$d" "$tok"
922
+ echo "Token verified by a real inference."
923
+ else
924
+ rm -f "$d/.server-token-verified" 2>/dev/null || true
925
+ warn "the token could not be verified right now (${CEREMONY_CHECK_DETAIL:-no answer}) — saved as UNVERIFIED; the shim proves it on first use, or run: claude-accounts verify"
926
+ fi
927
+ }
928
+
793
929
  CEREMONY_TOKEN=""
794
930
  run_token_ceremony() { # $1 = config dir
795
931
  CEREMONY_TOKEN=""
@@ -813,11 +949,16 @@ If you do see "Sign in again to continue", that is Claude's security step, not a
813
949
  error — just sign in to that account and approve; the code still appears.
814
950
  TIP
815
951
  if [ -t 0 ]; then
952
+ # The client's TUI hard-wraps at the pty's width, and the token is scraped from
953
+ # the transcript below — see SETUP_TOKEN_FULL_LEN for the 79-character tokens
954
+ # this produced. `script` copies the (widened) window size to the client's pty.
955
+ widen_ceremony_tty
816
956
  if [ "$(machine_kind)" = "mac" ]; then
817
957
  script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
818
958
  else
819
959
  script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
820
960
  fi
961
+ restore_ceremony_tty
821
962
  else
822
963
  # Headless (tests / piped code): capture into the 0600 file only. Never tee the
823
964
  # raw token to stdout — a redirected run would write the secret to a plain log.
@@ -825,7 +966,10 @@ TIP
825
966
  sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
826
967
  fi
827
968
  umask "$old_umask"
828
- CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" | tail -1)"
969
+ # The LONGEST match, not the last: a TUI repaints its frame many times, and a
970
+ # frame rendered while the terminal was still narrow holds a wrapped fragment.
971
+ CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" \
972
+ | awk '{ if (length($0) > length(best)) best = $0 } END { if (best != "") print best }')"
829
973
  rm -f "$cap"
830
974
  [ -n "$CEREMONY_TOKEN" ] || return 1
831
975
  valid_subscription_token "$CEREMONY_TOKEN" || {
@@ -937,6 +1081,8 @@ cmd_mint() {
937
1081
  local email tok="" got
938
1082
  email="$(account_email "$id")"
939
1083
  [ -n "$email" ] || die "$id has no email in the manifest — refusing to mint a token nobody could attribute"
1084
+ trap 'restore_ceremony_tty; exit 130' INT TERM
1085
+ trap 'restore_ceremony_tty' EXIT
940
1086
  if [ "$paste" = "1" ]; then
941
1087
  printf 'Paste the sk-ant-oat... token for %s (%s): ' "$id" "${email:-unknown email}"
942
1088
  read -r tok
@@ -953,8 +1099,7 @@ cmd_mint() {
953
1099
  if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
954
1100
  die "that token authenticates as $got but $id is $email — nothing saved"
955
1101
  fi
956
- ( umask 077; printf '%s' "$tok" > "$d/server.token" )
957
- chmod 600 "$d/server.token"
1102
+ commit_ceremony_token "$d" "$tok" "$id"
958
1103
  clear_auth_markers "$d"
959
1104
  log_to ops.log "mint $id"
960
1105
  echo "Token saved to $d/server.token"
@@ -984,13 +1129,14 @@ cmd_login() {
984
1129
  email="$(account_email "$id")"
985
1130
  echo "Sign in as $email for $id."
986
1131
  if [ "$token" = "1" ]; then
1132
+ trap 'restore_ceremony_tty; exit 130' INT TERM
1133
+ trap 'restore_ceremony_tty' EXIT
987
1134
  run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
988
1135
  got="$(token_email "$CEREMONY_TOKEN")"
989
1136
  if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
990
1137
  die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
991
1138
  fi
992
- ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
993
- chmod 600 "$d/server.token"
1139
+ commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
994
1140
  clear_auth_markers "$d"
995
1141
  [ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
996
1142
  echo "$id token saved (portable — works on Mac and server)."
@@ -1040,14 +1186,17 @@ cmd_expired() {
1040
1186
  die "the manifest lists accounts but none could be audited — check $MANIFEST"
1041
1187
  fi
1042
1188
  bad="$(printf '%s\n' "$rows" \
1043
- | awk -F'\t' 'NF >= 4 && $1 != "" && $4 ~ /^(expired|blocked|missing|unverified)$/ { print $1 }')"
1189
+ | awk -F'\t' '
1190
+ NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing|unverified)$/ {
1191
+ print $1
1192
+ }')"
1044
1193
  if [ "$quiet" = "1" ]; then
1045
1194
  [ -n "$bad" ] || return 0
1046
1195
  printf '%s\n' "$bad"
1047
1196
  return 1
1048
1197
  fi
1049
1198
  printf '%s\n' "$rows" | awk -F'\t' '
1050
- BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; unverified = 0 }
1199
+ BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0 }
1051
1200
  NF < 4 || $1 == "" { next } # never invent an account from a blank line
1052
1201
  $4 == "ok" { ok++; next }
1053
1202
  $4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
@@ -1058,6 +1207,13 @@ cmd_expired() {
1058
1207
  printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
1059
1208
  next
1060
1209
  }
1210
+ $4 == "token-invalid" {
1211
+ bad++
1212
+ retoken++
1213
+ printf " %-9s %-28s %-13s %s\n", $1, $2, $5, $6
1214
+ printf " %-9s %-28s %-13s fix: %s\n", "", "", "", $7
1215
+ next
1216
+ }
1061
1217
  {
1062
1218
  bad++
1063
1219
  relogin++
@@ -1075,6 +1231,10 @@ cmd_expired() {
1075
1231
  printf "\nVerify portable tokens:\n"
1076
1232
  printf " claude-accounts verify\n"
1077
1233
  }
1234
+ if (retoken > 0) {
1235
+ printf "\nReplace invalid portable setup-tokens:\n"
1236
+ printf " claude-accounts login acct-NN --token\n"
1237
+ }
1078
1238
  if (relogin > 0) {
1079
1239
  printf "\nRe-authenticate them:\n"
1080
1240
  printf " claude-accounts relogin # every account that needs it\n"
@@ -2175,8 +2335,14 @@ for acct in manifest.get('accounts', []):
2175
2335
  os.remove(os.path.join(d, '.server-token-verified'))
2176
2336
  except OSError:
2177
2337
  pass
2178
- mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
2179
- hint = f' — login is dead, run: claude-accounts relogin {aid}'
2338
+ if uses_token:
2339
+ mark_expired(d, 'setup-token-invalid',
2340
+ 'portable setup-token failed a real inference')
2341
+ hint = (f' — portable setup-token is invalid, run: '
2342
+ f'claude-accounts login {aid} --token')
2343
+ else:
2344
+ mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
2345
+ hint = f' — login is dead, run: claude-accounts relogin {aid}'
2180
2346
  print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
2181
2347
  f'out={out[:120]!r} err={err!r}{hint}')
2182
2348
  failures += 1
@@ -215,7 +215,8 @@ providers (`lib/report.py`), so a consumer writes one parser:
215
215
  "accounts": [{
216
216
  "id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
217
217
  "home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
218
- "status": "active", // active|limited|expired|blocked|missing|remote
218
+ "status": "active", // active|limited|expired|token-invalid|blocked
219
+ // missing|remote
219
220
  "state": "ok", "label": "OK", "reason": "…", "fix": "…",
220
221
  "selectable": true, "needs_login": false,
221
222
  "credential_class": "portable", // portable|machine-local|none
package/lib/audit.py CHANGED
@@ -5,6 +5,7 @@ shim's selection rule (bin/claude: creds_dead / expired_marked / auth_dead).
5
5
  States:
6
6
  ok usable right now (live OAuth credential, or a portable server.token)
7
7
  expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
8
+ token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
8
9
  blocked authenticates, but its organization has disabled Claude Code subscription
9
10
  access — a re-login cannot fix it, so it needs an admin (or removal)
10
11
  locked the OAuth login is in the macOS Keychain and THIS session cannot open it
@@ -193,8 +194,9 @@ def oauth_login(d, now):
193
194
  return {'store': None, 'state': None, 'reason': '', 'doc': None}
194
195
 
195
196
 
196
- LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED',
197
- 'missing': 'NO LOGIN', 'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}
197
+ LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'token-invalid': 'TOKEN INVALID',
198
+ 'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED', 'missing': 'NO LOGIN',
199
+ 'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}
198
200
 
199
201
  # `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
200
202
  # two vocabularies — normalize, or an un-authenticated account on the very machine that
@@ -216,6 +218,8 @@ def _elsewhere(home, machine):
216
218
 
217
219
 
218
220
  def _fix_for(state, aid):
221
+ if state == 'token-invalid':
222
+ return f'claude-accounts login {aid} --token'
219
223
  if state == 'blocked':
220
224
  # A fresh sign-in re-issues the grant and in practice clears this, so it is
221
225
  # handled like any other dead login. If it comes back BLOCKED after a re-login,
@@ -244,6 +248,12 @@ def _token_verified(d, tpath):
244
248
  return False
245
249
 
246
250
 
251
+ # The report (`list --json`) tells the panel the same thing per Mac: a Mac reads
252
+ # "active" for any token FILE it can see, and only this says whether a real call
253
+ # ever succeeded with it here.
254
+ token_verified = _token_verified
255
+
256
+
247
257
  def audit_account(root, acct, now=None, machine=None, require_verified_token=False):
248
258
  now = time.time() if now is None else now
249
259
  aid = acct.get('id', '')
@@ -266,6 +276,9 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
266
276
  row['state'] = 'blocked'
267
277
  row['reason'] = ('this account\'s organization has disabled Claude Code '
268
278
  'subscription access')
279
+ elif slug == 'setup-token-invalid':
280
+ row['state'] = 'token-invalid'
281
+ row['reason'] = 'portable setup-token was rejected by a real inference'
269
282
  else:
270
283
  row['state'] = 'expired'
271
284
  row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
package/lib/common.sh CHANGED
@@ -495,7 +495,8 @@ clear_auth_markers() { # $1 = acct dir
495
495
  rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
496
496
  }
497
497
 
498
- # TSV rows: id, email, home, state (ok|expired|blocked|missing|remote), label, reason, fix.
498
+ # TSV rows: id, email, home, state
499
+ # (ok|expired|token-invalid|blocked|missing|remote|locked|unverified), label, reason, fix.
499
500
  # Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
500
501
  # silently empty audit reads exactly like a perfectly healthy pool.
501
502
  account_audit() {
@@ -518,7 +519,8 @@ account_audit() {
518
519
  # Ids the shim will NOT select — everything a human has to look at.
519
520
  accounts_unusable() {
520
521
  account_audit \
521
- | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
522
+ | awk -F'\t' \
523
+ 'NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing)$/ { print $1 }'
522
524
  }
523
525
 
524
526
  # Ids a re-login should be pointed at. Org-blocked accounts are included: a fresh
package/lib/report.py CHANGED
@@ -46,6 +46,7 @@ import time
46
46
 
47
47
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
48
  import keychain # noqa: E402
49
+ from audit import token_verified # noqa: E402
49
50
 
50
51
  SCHEMA = 'claude-multiacc/pool.v1'
51
52
 
@@ -172,7 +173,10 @@ def _claude_credentials(d):
172
173
  has_token = _size(tpath) > 0
173
174
  detail = {'oauth': has_oauth, 'token': has_token, 'oauth_store': None, 'keychain': None,
174
175
  'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
175
- 'token_minted_at': None, 'token_age_days': None}
176
+ 'token_minted_at': None, 'token_age_days': None,
177
+ # True once THIS exact token passed a real inference on this machine
178
+ # (.server-token-verified); False = present but never proven here.
179
+ 'token_verified': token_verified(d, tpath) if has_token else None}
176
180
  o = None
177
181
  if has_oauth:
178
182
  detail['oauth_store'] = 'file'
@@ -347,6 +351,9 @@ def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind
347
351
  'credential_class': cclass,
348
352
  'portable': cclass == 'portable',
349
353
  'credentials': cdetail,
354
+ # Flat copy for heartbeat consumers: a portable token this Mac has proven
355
+ # (True), holds unproven (False), or does not hold (None).
356
+ 'token_verified': cdetail.get('token_verified') if provider == 'claude' else None,
350
357
  'limited': bool(limited),
351
358
  'limit_reset_at': _iso(reset_epoch) if limited and reset_epoch else None,
352
359
  'limit_reset_epoch': reset_epoch if limited and reset_epoch else None,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,19 @@ fi
74
74
  if [ "${1:-}" = "setup-token" ]; then
75
75
  echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
76
76
  [ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
77
+ if [ -n "${FAKE_TOKEN_FULL:-}" ]; then
78
+ # The real client's shape: a 108-character token that the TUI HARD-WRAPS at the
79
+ # terminal's width — an unsized pty (0 columns) renders as 80, and a 79-character
80
+ # first line is exactly what every panel mint of 2026-08-28 saved.
81
+ tok="sk-ant-oat01-$(printf '%66s' '' | tr ' ' W)$(printf '%23s' '' | tr ' ' T)TAILOK"
82
+ cols="$(stty size 2>/dev/null | awk '{print $2}')"
83
+ if [ -n "${FAKE_TOKEN_WRAP:-}" ] || { [ -n "$cols" ] && [ "$cols" -lt 108 ]; }; then
84
+ printf ' %s\n%s\n' "${tok:0:79}" "${tok:79}"
85
+ else
86
+ printf ' %s\n' "$tok"
87
+ fi
88
+ exit 0
89
+ fi
77
90
  echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
78
91
  exit 0
79
92
  fi
@@ -88,6 +101,13 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
88
101
  *REVOKED*)
89
102
  echo "Please run /login · API Error: 401 OAuth access token is invalid." >&2
90
103
  exit 1 ;;
104
+ sk-ant-oat01-WWW*)
105
+ # The wrapped fixture above: only the COMPLETE token (with its tail) authenticates;
106
+ # its 79-character first line is what Claude answered 401 to, fleet-wide.
107
+ case "$CLAUDE_CODE_OAUTH_TOKEN" in
108
+ *TAILOK) : ;;
109
+ *) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
110
+ esac ;;
91
111
  esac
92
112
  case " $* " in
93
113
  *" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
@@ -515,7 +535,7 @@ case "$out" in
515
535
  *"API Error: 401"*) t_fail "revoked setup-token hides preflight 401" "401 reached caller" ;;
516
536
  *) t_ok "revoked setup-token hides preflight 401" ;;
517
537
  esac
518
- grep -q 'reason=auth-error' "$ACC/acct-01/.expired" 2>/dev/null \
538
+ grep -q 'reason=setup-token-invalid' "$ACC/acct-01/.expired" 2>/dev/null \
519
539
  && t_ok "revoked setup-token writes .expired" \
520
540
  || t_fail "revoked setup-token marker" ".expired missing"
521
541
  if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
@@ -523,6 +543,37 @@ if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
523
543
  else
524
544
  t_ok "proven token rejection is durable"
525
545
  fi
546
+ out="$(claude-accounts expired 2>&1)"
547
+ check "revoked setup-token is not mislabeled as a dead login" "TOKEN INVALID" "$out"
548
+ check "revoked setup-token gets the token replacement fix" \
549
+ "claude-accounts login acct-01 --token" "$out"
550
+ case "$out" in
551
+ *"claude-accounts relogin acct-01"*)
552
+ t_fail "revoked setup-token does not recommend re-login" "wrong recovery shown" ;;
553
+ *) t_ok "revoked setup-token does not recommend re-login" ;;
554
+ esac
555
+ # A token can be revoked after it passed preflight. The captured -p path must retire
556
+ # that exact verified token too, rather than giving it a one-hour generic login park.
557
+ rm -f "$ACC/acct-01/.expired"
558
+ token_digest="$(printf '%s' 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' \
559
+ | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)"
560
+ printf '%s\n' "$token_digest" > "$ACC/acct-01/.server-token-verified"
561
+ out="$(claude -p 'Reply OK' < /dev/null 2>&1)"
562
+ case "$out" in
563
+ *"API Error: 401"*) t_fail "revoked verified token hides runtime 401" "401 reached caller" ;;
564
+ *) t_ok "revoked verified token hides runtime 401" ;;
565
+ esac
566
+ grep -q 'reason=setup-token-invalid' "$ACC/acct-01/.expired" 2>/dev/null \
567
+ && t_ok "revoked verified token keeps token classification" \
568
+ || t_fail "revoked verified token marker" ".expired missing"
569
+ [ ! -f "$ACC/acct-01/.server-token-verified" ] \
570
+ && t_ok "revoked verified token clears its proof" \
571
+ || t_fail "revoked verified token proof" "verification marker survived"
572
+ if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
573
+ t_fail "revoked verified token is durably quarantined" "marker has a soft expiry"
574
+ else
575
+ t_ok "revoked verified token is durably quarantined"
576
+ fi
526
577
  out="$(CLAUDE_ACCOUNT=acct-01 claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
527
578
  rc=$?
528
579
  check "pinned revoked setup-token is classified before --resume" \
@@ -1232,6 +1283,86 @@ esac
1232
1283
  [ -s "$ACC/acct-04/server.token" ] && t_ok "add --token writes server.token" || t_fail "add --token" "missing"
1233
1284
  out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
1234
1285
  check "token account exports CLAUDE_CODE_OAUTH_TOKEN" "TOK=sk-ant-oat01-FAKE" "$out"
1286
+
1287
+ # ---- 15c1a. a ceremony's token is PROVEN before it is saved --------------------------
1288
+ # add --token above ran the inference probe: the exact token is on record as verified
1289
+ # on this machine, so the audit never calls a fresh mint UNVERIFIED — and the panel
1290
+ # can tell a proven token from one a Mac merely holds.
1291
+ [ -f "$ACC/acct-04/.server-token-verified" ] \
1292
+ && t_ok "add --token proves the token with a real inference before saving" \
1293
+ || t_fail "add --token verification marker" ".server-token-verified missing"
1294
+ out="$(claude-accounts list --json 2>/dev/null | python3 -c '
1295
+ import json, sys
1296
+ rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
1297
+ print(rows["acct-04"].get("token_verified"))')"
1298
+ [ "$out" = "True" ] && t_ok "list --json reports the token as verified here" \
1299
+ || t_fail "list --json token_verified" "expected True, got: $out"
1300
+ # The 2026-08-28 shape: the client's TUI wrapped the 108-character token at 80 columns
1301
+ # and only its 79-character first line was captured. Claude rejects the fragment; the
1302
+ # mint must refuse to save it and say WHY (seven fleet-wide 401s were the old answer).
1303
+ orig="$(cat "$ACC/acct-04/server.token")"
1304
+ out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_WRAP=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1305
+ rc=$?
1306
+ [ "$rc" != "0" ] && t_ok "a wrapped (79-char) token capture is refused" || t_fail "wrapped token rc" "rc=0"
1307
+ check "the refusal names the truncation" "79 characters" "$out"
1308
+ check "the refusal names the cause" "terminal wrapped it" "$out"
1309
+ [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a refused capture leaves the old token in place" \
1310
+ || t_fail "refused capture" "server.token was overwritten"
1311
+ # A pasted token gets the same proof: a revoked one is refused, not saved.
1312
+ out="$(printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' | claude-accounts mint acct-04 --paste 2>&1)"
1313
+ rc=$?
1314
+ [ "$rc" != "0" ] && t_ok "mint --paste refuses a token Claude rejects" || t_fail "paste rejected rc" "rc=0"
1315
+ check "the paste refusal says Claude rejected it" "Claude rejected the captured token" "$out"
1316
+ [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a rejected paste saves nothing" \
1317
+ || t_fail "rejected paste" "server.token was overwritten"
1318
+
1319
+ # ---- 15c1b. the ceremony widens an unsized pty, so the whole token is captured ------
1320
+ # The panel drives the ceremony over a pty it never sized (0x0 -> the TUI renders 80
1321
+ # columns wide). Run the mint under exactly such a pty: the fake wraps its token
1322
+ # whenever the terminal is narrower than the token, so only a widened terminal yields
1323
+ # all 108 characters — and the capture must then pass the inference probe.
1324
+ cat > "$WORK/pty-run.py" <<'EOF'
1325
+ import os, pty, select, sys
1326
+ pid, fd = pty.fork() # a fresh pty: 0 rows, 0 columns, like the panel's
1327
+ if pid == 0:
1328
+ os.execvp(sys.argv[1], sys.argv[1:])
1329
+ buf = b''
1330
+ while True:
1331
+ try:
1332
+ ready, _, _ = select.select([fd], [], [], 60)
1333
+ if not ready:
1334
+ break
1335
+ chunk = os.read(fd, 4096)
1336
+ except OSError:
1337
+ break
1338
+ if not chunk:
1339
+ break
1340
+ buf += chunk
1341
+ _, status = os.waitpid(pid, 0)
1342
+ sys.stdout.write(buf.decode('utf-8', 'ignore'))
1343
+ sys.exit(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
1344
+ EOF
1345
+ size="$(python3 "$WORK/pty-run.py" stty size | tr -d '\r' | tail -1)"
1346
+ [ "$size" = "0 0" ] && t_ok "the test pty really is unsized (the panel's shape)" \
1347
+ || t_fail "test pty size" "expected '0 0', got '$size'"
1348
+ if command -v script >/dev/null 2>&1; then
1349
+ out="$(FAKE_TOKEN_FULL=1 python3 "$WORK/pty-run.py" claude-accounts mint acct-04 2>&1)"
1350
+ rc=$?
1351
+ [ "$rc" = "0" ] && t_ok "mint under an unsized pty succeeds" \
1352
+ || t_fail "unsized-pty mint rc" "rc=$rc: $(printf '%s' "$out" | tail -c 300)"
1353
+ n="$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')"
1354
+ [ "$n" = "108" ] && t_ok "the unsized pty is widened: all 108 characters captured" \
1355
+ || t_fail "unsized-pty capture" "saved $n characters"
1356
+ check "the widened-pty mint verifies its token" "Token verified by a real inference" "$out"
1357
+ case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
1358
+ *TAILOK) t_ok "the saved token is the complete one" ;;
1359
+ *) t_fail "saved token" "tail missing" ;;
1360
+ esac
1361
+ [ -f "$ACC/acct-04/.server-token-verified" ] && t_ok "the widened-pty mint records its proof" \
1362
+ || t_fail "widened-pty proof" "marker missing"
1363
+ else
1364
+ printf 'skip unsized-pty mint (no script(1) here)\n'
1365
+ fi
1235
1366
  claude-accounts remove acct-04 --yes >/dev/null 2>&1
1236
1367
 
1237
1368
  # ---- 15c0. add with NO email: derives it from the verified sign-in -------------------
@@ -2480,7 +2611,19 @@ state="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" "$(uname -s | tr '[:upper:]' '[
2480
2611
  strict-tokens | awk -F'\t' '$1=="acct-01"{print $4}')"
2481
2612
  [ "$state" = "ok" ] && t_ok "verified setup-token is no longer false-unknown" \
2482
2613
  || t_fail "verified setup-token audit" "expected ok, got: $state"
2614
+ printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' \
2615
+ > "$ACC/acct-01/server.token"
2616
+ rm -f "$ACC/acct-01/.server-token-verified"
2617
+ out="$(claude-accounts verify 2>&1)"
2618
+ check "verify calls a rejected setup-token what it is" "portable setup-token is invalid" "$out"
2619
+ check "verify recommends replacing a rejected setup-token" \
2620
+ "claude-accounts login acct-01 --token" "$out"
2621
+ out="$(claude-accounts expired 2>&1)"
2622
+ check "verify marker retains setup-token classification" "TOKEN INVALID" "$out"
2623
+ check "verify marker retains setup-token recovery" \
2624
+ "claude-accounts login acct-01 --token" "$out"
2483
2625
  rm -f "$ACC/acct-01/server.token"
2626
+ rm -f "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
2484
2627
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
2485
2628
 
2486
2629
  # ---- 17c. audit: machine vocabulary (home=server vs machine_kind=linux) ---------------