claude-multiacc 2.0.6 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,8 +70,9 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
70
70
  (Claude Code disabled for that account). Those can only fail, so they never enter
71
71
  selection.
72
72
  4. Among the accounts that remain valid **on this machine** (an OAuth login —
73
- `.credentials.json`, or a macOS Keychain item this session can open — or
74
- `server.token` present) and not limit-excluded, pick the one with the **most remaining
73
+ `.credentials.json`, or a macOS Keychain item this session can open — or a
74
+ `server.token` that passes its first inference preflight) and not limit-excluded,
75
+ pick the one with the **most remaining
75
76
  headroom** (see below). On equal headroom the pool **rotates away from the account it
76
77
  just handed out** and samples the rest at random — so quitting a session and starting
77
78
  another one moves you along the pool, while a burst of parallel `claude -p` runs still
@@ -103,6 +104,10 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
103
104
  - **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
104
105
  came back *"OAuth session expired and could not be refreshed"*. Fixed by
105
106
  `claude-accounts relogin`.
107
+ - **Rejected setup-token** (`EXPIRED`) — token presence and `claude auth status` do not
108
+ prove inference works. Before a portable token carries its first real command on a
109
+ machine, the shim privately verifies it. A 401 parks that exact account and reselects,
110
+ so direct, TUI, and `--resume` calls do not expose the rejected token's failure.
106
111
  - **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
107
112
  *"Your organization has disabled Claude subscription access for Claude Code"*. Handled
108
113
  like any other dead login (`claude-accounts relogin` re-issues the grant and normally
@@ -112,7 +117,8 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
112
117
  park deliberately survives credential refreshes (a new access token says nothing about
113
118
  an org policy).
114
119
 
115
- `claude-accounts expired` lists exactly what is excluded, why, and the fix for each.
120
+ `claude-accounts expired` lists what is excluded and also labels a token-only account
121
+ `UNVERIFIED` until `claude-accounts verify` or the shim's first-use preflight proves it.
116
122
  Exclusion self-heals, but only against the evidence it was based on: a **credential**
117
123
  park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
118
124
  by another process — or when a usage fetch authenticates; a **policy** park (org block)
package/bin/claude CHANGED
@@ -559,14 +559,17 @@ client_auth_scan() { # $1 acct dir
559
559
  return 1
560
560
  }
561
561
 
562
- mark_client_auth_dead() { # $1 acct dir
563
- local soft=$((now + 3600)) marked
562
+ mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail
563
+ local marked
564
564
  marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
565
- { echo "$now"; echo "reason=auth-error soft_until=$soft marked_at=$marked" \
566
- "detail=client session failed to authenticate"; } \
565
+ { echo "$now"; echo "reason=auth-error marked_at=$marked detail=$2"; } \
567
566
  2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
568
567
  || rm -f "$1/.expired.$$" 2>/dev/null || true
569
- sel_log "$(basename "$1") parked (auth-error until $soft) — client-reported"
568
+ }
569
+
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"
570
573
  }
571
574
 
572
575
  mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
@@ -717,6 +720,50 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
717
720
  fi
718
721
  }
719
722
 
723
+ # A setup-token has no identity or readable expiry, and `claude auth status` only
724
+ # checks its shape. Test each token once on this machine with a real inference before
725
+ # allowing it to carry a user's command. This closes the direct/TUI/--resume gap where
726
+ # a revoked token exposed one 401 and was only parked on the following invocation.
727
+ token_digest() { # $1 token; prints a non-secret sha256 digest
728
+ local h=""
729
+ if command -v shasum >/dev/null 2>&1; then
730
+ h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
731
+ elif command -v sha256sum >/dev/null 2>&1; then
732
+ h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"
733
+ fi
734
+ printf '%s' "$h" | cut -d ' ' -f1
735
+ }
736
+
737
+ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid auth
738
+ local d="$1" tok="$2" digest marker tmpd token_auth_pat rc=0
739
+ digest="$(token_digest "$tok")"
740
+ [ -n "$digest" ] || return 0
741
+ marker="$d/.server-token-verified"
742
+ [ -f "$marker" ] && grep -qx "$digest" "$marker" 2>/dev/null && return 0
743
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
744
+ tmpd="$(mktemp -d "$ACC_ROOT/tmp/token-check.XXXXXX" 2>/dev/null)" || return 0
745
+ printf 'Reply with exactly: OK\n' \
746
+ | CLAUDE_CONFIG_DIR="$d" CLAUDE_CODE_OAUTH_TOKEN="$tok" CLAUDE_SHIM_ACTIVE=1 \
747
+ "$REAL" -p --output-format text --max-turns 1 > "$tmpd/out" 2> "$tmpd/err" || rc=$?
748
+ token_auth_pat='failed to authenticate|oauth (access )?token is invalid|oauth session expired'
749
+ token_auth_pat="$token_auth_pat|please run /login|invalid bearer token|authentication_error"
750
+ if [ "$rc" -ne 0 ] \
751
+ && grep -qiE "$token_auth_pat" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
752
+ 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"
755
+ rm -rf "$tmpd"
756
+ return 1
757
+ fi
758
+ if [ "$rc" -eq 0 ] && LC_ALL=C grep -qx 'OK' "$tmpd/out" 2>/dev/null; then
759
+ { umask 077; printf '%s\n' "$digest" > "$marker.$$"; } 2>/dev/null \
760
+ && mv -f "$marker.$$" "$marker" 2>/dev/null \
761
+ || rm -f "$marker.$$" 2>/dev/null || true
762
+ fi
763
+ rm -rf "$tmpd"
764
+ return 0
765
+ }
766
+
720
767
  # Explicit pin wins over everything — markers, and even missing auth: the
721
768
  # add/login ceremony pins to a dir that has no credentials yet, and the login
722
769
  # must land exactly there, never in a randomly selected account's dir.
@@ -731,7 +778,14 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
731
778
  # pinned account with a stale login fail outright ("OAuth session expired") while
732
779
  # the very same account worked unpinned.
733
780
  tok="$(acct_token "$d")"
734
- [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
781
+ if [ -n "$tok" ]; then
782
+ if ! token_preflight "$d" "$tok"; then
783
+ printf 'claude-multiacc: pinned account %s has an invalid portable OAuth token (401)\n' \
784
+ "$CLAUDE_ACCOUNT" >&2
785
+ exit 1
786
+ fi
787
+ export CLAUDE_CODE_OAUTH_TOKEN="$tok"
788
+ fi
735
789
  sel_capture_session "$d"
736
790
  exec "$REAL" "$@"
737
791
  fi
@@ -937,6 +991,21 @@ else
937
991
  fi
938
992
  fi
939
993
  pick="$PICK_DIR"
994
+
995
+ # The preflight can prove the selected token dead without exposing its 401 to the
996
+ # caller. Re-enter selection so the new .expired marker is applied and another
997
+ # account serves the original command. The bounded depth also handles several revoked
998
+ # fleet tokens in a row without a loop when no usable account remains.
999
+ tok="$(acct_token "$pick")"
1000
+ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
1001
+ preflight_depth="${CLAUDE_MULTIACC_PREFLIGHT_DEPTH:-0}"
1002
+ num_ok "$preflight_depth" || preflight_depth=0
1003
+ if [ "$preflight_depth" -lt 64 ]; then
1004
+ export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
1005
+ exec "$SELF" "$@"
1006
+ fi
1007
+ fi
1008
+ unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
940
1009
  # Remember the pick so the NEXT run does not hand back the same account. An explicit
941
1010
  # CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
942
1011
  # not a turn in the rotation.
@@ -1016,9 +1016,9 @@ cmd_login() {
1016
1016
  }
1017
1017
 
1018
1018
  cmd_expired() {
1019
- # Which accounts CANNOT authenticate right now the pool's re-login worklist.
1020
- # Same rule the shim selects by (lib/audit.py), so what is listed here is exactly
1021
- # what is excluded from selection.
1019
+ # Accounts proven dead plus token-only accounts that have never passed a real call.
1020
+ # Presence/shape is not authentication: without strict token evidence, `expired`
1021
+ # falsely rendered a revoked setup-token as healthy.
1022
1022
  require_manifest
1023
1023
  local quiet=0
1024
1024
  while [ $# -gt 0 ]; do
@@ -1028,7 +1028,8 @@ cmd_expired() {
1028
1028
  esac
1029
1029
  done
1030
1030
  local rows bad
1031
- rows="$(account_audit)" || die "the account audit failed — cannot say which logins are dead"
1031
+ rows="$(account_audit strict-tokens)" \
1032
+ || die "the account audit failed — cannot say which logins are dead"
1032
1033
  if [ -z "$rows" ]; then
1033
1034
  # No rows at all: either the pool is genuinely empty, or the manifest lost its
1034
1035
  # accounts. Never render that as "all clear".
@@ -1038,7 +1039,8 @@ cmd_expired() {
1038
1039
  fi
1039
1040
  die "the manifest lists accounts but none could be audited — check $MANIFEST"
1040
1041
  fi
1041
- bad="$(printf '%s\n' "$rows" | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }')"
1042
+ bad="$(printf '%s\n' "$rows" \
1043
+ | awk -F'\t' 'NF >= 4 && $1 != "" && $4 ~ /^(expired|blocked|missing|unverified)$/ { print $1 }')"
1042
1044
  if [ "$quiet" = "1" ]; then
1043
1045
  [ -n "$bad" ] || return 0
1044
1046
  printf '%s\n' "$bad"
@@ -1056,8 +1058,8 @@ cmd_expired() {
1056
1058
  printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
1057
1059
  }
1058
1060
  END {
1059
- if (bad == 0) printf "All %d account(s) with a login on this machine can authenticate.\n", ok
1060
- else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
1061
+ if (bad == 0) printf "All %d account(s) with auth on this machine can authenticate (verified).\n", ok
1062
+ else printf "\n%d account(s) are dead or unverified, %d verified.\n", bad, ok
1061
1063
  if (remote > 0) {
1062
1064
  printf "\nNot logged in here on purpose (another machine owns the grant):\n"
1063
1065
  printf "%s", rem
@@ -1273,6 +1275,7 @@ cmd_limits() {
1273
1275
  import hashlib, json, os, sys, time, urllib.request
1274
1276
  sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
1275
1277
  import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
1278
+ from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
1276
1279
 
1277
1280
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
1278
1281
  now = time.time()
@@ -1655,6 +1658,18 @@ for acct in manifest.get('accounts', []):
1655
1658
  locked = store == 'locked'
1656
1659
  if locked:
1657
1660
  store = None
1661
+ # "This account has an OAuth login that could still work." A Keychain-held one
1662
+ # only the Mac's own launchd probe can open counts: it is unusable HERE, not
1663
+ # broken. A provably DEAD grant (no refresh token, or a refresh token that has
1664
+ # itself expired) does not count — for that account the setup token really is
1665
+ # the only bearer left, and it must still get its one try.
1666
+ oauth_alive = locked
1667
+ if store is not None:
1668
+ try:
1669
+ oauth_alive = creds_doc_state(store.read(), now)[0] == 'ok'
1670
+ except Exception:
1671
+ oauth_alive = True # unreadable is not proof of death: do not spend the token
1672
+ has_oauth = oauth_alive
1658
1673
  if store is not None:
1659
1674
  try:
1660
1675
  c = store.read().get('claudeAiOauth', {})
@@ -1672,7 +1687,19 @@ for acct in manifest.get('accounts', []):
1672
1687
  tok = None
1673
1688
  if tok:
1674
1689
  bearer, source = tok, 'oauth'
1675
- if not bearer and os.path.isfile(tpath):
1690
+ # A setup token is a LAST RESORT and only for an account that has no OAuth
1691
+ # credential at all. Where one exists but could not be used this pass — its
1692
+ # access token expired moments ago (refresh_oauth waits REFRESH_MIN_EXPIRED to
1693
+ # prove no live session owns it), a refresh backoff, or a Keychain this session
1694
+ # cannot open — spending the token is guaranteed to earn a 403 it can never not
1695
+ # earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
1696
+ # froze for an account whose OAuth would have worked on the very next pass, and
1697
+ # the shim ranked the pool on hour-old readings. Waiting for the next pass costs
1698
+ # minutes; the token costs six hours and answers nothing.
1699
+ if not bearer and has_oauth:
1700
+ say(f'{aid}: oauth credential not usable this pass; NOT spending the setup '
1701
+ f'token on a usage endpoint that always refuses it — retrying next pass')
1702
+ elif not bearer and os.path.isfile(tpath):
1676
1703
  # Once this endpoint has refused THIS token file for lacking a scope, asking
1677
1704
  # again is guaranteed to fail and only spends the account's hourly budget —
1678
1705
  # which is how a permanent authorization problem disguised itself as a rate
@@ -1717,6 +1744,14 @@ for acct in manifest.get('accounts', []):
1717
1744
  except Exception:
1718
1745
  pass
1719
1746
 
1747
+ def save_prev():
1748
+ """Persist the account's telemetry state as-is (no backoff, no invented
1749
+ freshness) — used when what failed says nothing about the ACCOUNT."""
1750
+ tmp = lpath + '.tmp'
1751
+ with open(tmp, 'w') as f:
1752
+ json.dump(prev, f, indent=1)
1753
+ os.replace(tmp, lpath)
1754
+
1720
1755
  def park(wait, note):
1721
1756
  """Record a failed fetch WITHOUT inventing freshness: fetched_at is left
1722
1757
  exactly as it was, so a parked account still reads as stale everywhere."""
@@ -1724,10 +1759,7 @@ for acct in manifest.get('accounts', []):
1724
1759
  prev['backoff'] = wait
1725
1760
  prev['last_error'] = note
1726
1761
  prev['last_error_at'] = int(now)
1727
- tmp = lpath + '.tmp'
1728
- with open(tmp, 'w') as f:
1729
- json.dump(prev, f, indent=1)
1730
- os.replace(tmp, lpath)
1762
+ save_prev()
1731
1763
 
1732
1764
  if e.code == 429:
1733
1765
  # Respect Retry-After; otherwise exponential backoff capped at 30 min.
@@ -1765,8 +1797,16 @@ for acct in manifest.get('accounts', []):
1765
1797
  tdigest = token_digest(tpath)
1766
1798
  if tdigest:
1767
1799
  prev['token_scope_denied'] = tdigest
1768
- park(wait, f'HTTP {e.code} (source={source})'
1769
- + (' permanent, server said do not retry' if denied else ''))
1800
+ if scope_denied and source == 'token':
1801
+ # A refusal of the TOKEN is a fact about that credential, not about
1802
+ # the account: parking the account here also blocked the OAuth path,
1803
+ # so an account that merely missed one refresh went dark for six
1804
+ # hours. The digest above already stops this token being spent again;
1805
+ # leave the account free to try its OAuth login next pass.
1806
+ save_prev()
1807
+ else:
1808
+ park(wait, f'HTTP {e.code} (source={source})'
1809
+ + (' — permanent, server said do not retry' if denied else ''))
1770
1810
  if scope_denied and source == 'token':
1771
1811
  # The exact shape of this outage: a setup token is minted WITHOUT the
1772
1812
  # user:profile scope the usage endpoint requires, so an account whose
@@ -1775,7 +1815,8 @@ for acct in manifest.get('accounts', []):
1775
1815
  say(f'{aid}: usage endpoint refuses the setup token (HTTP {e.code} — a '
1776
1816
  f'setup token has no user:profile scope). Telemetry is DEAD for this '
1777
1817
  f'account until it has an OAuth login here: claude-accounts login {aid}. '
1778
- f'Backing off {wait}s.')
1818
+ f'This token will not be offered again; the ACCOUNT is not parked, so '
1819
+ f'an OAuth login works the moment it lands.')
1779
1820
  elif denied:
1780
1821
  say(f'{aid}: usage fetch refused for good (HTTP {e.code}, source={source}); '
1781
1822
  f'backing off {wait}s — re-login needed: claude-accounts login {aid}')
@@ -1972,7 +2013,7 @@ cmd_verify() {
1972
2013
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
1973
2014
  fi
1974
2015
  "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
1975
- import json, os, re, subprocess, sys, time
2016
+ import hashlib, json, os, re, subprocess, sys, time
1976
2017
 
1977
2018
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
1978
2019
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
@@ -2079,10 +2120,11 @@ for acct in manifest.get('accounts', []):
2079
2120
  if has_token and (not has_creds or cred_state != 'ok'):
2080
2121
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
2081
2122
  t0 = time.time()
2123
+ uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
2082
2124
  try:
2083
- r = subprocess.run([real, '-p', 'Reply with exactly: OK'],
2125
+ r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
2084
2126
  env=env, capture_output=True, text=True, timeout=240,
2085
- stdin=subprocess.DEVNULL, cwd=root)
2127
+ input='Reply with exactly: OK\n', cwd=root)
2086
2128
  except subprocess.TimeoutExpired:
2087
2129
  print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
2088
2130
  failures += 1
@@ -2095,6 +2137,16 @@ for acct in manifest.get('accounts', []):
2095
2137
  os.remove(os.path.join(d, '.expired'))
2096
2138
  except OSError:
2097
2139
  pass
2140
+ if uses_token:
2141
+ try:
2142
+ digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
2143
+ temp = os.path.join(d, '.server-token-verified.tmp')
2144
+ with open(temp, 'w') as f:
2145
+ f.write(digest + '\n')
2146
+ os.chmod(temp, 0o600)
2147
+ os.replace(temp, os.path.join(d, '.server-token-verified'))
2148
+ except OSError:
2149
+ pass
2098
2150
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
2099
2151
  else:
2100
2152
  err = (r.stderr or '').strip()[:200]
@@ -2108,6 +2160,10 @@ for acct in manifest.get('accounts', []):
2108
2160
  hint = (f' — ORG BLOCKED, excluded from the pool; '
2109
2161
  f'try: claude-accounts relogin {aid}')
2110
2162
  elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
2163
+ try:
2164
+ os.remove(os.path.join(d, '.server-token-verified'))
2165
+ except OSError:
2166
+ pass
2111
2167
  mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
2112
2168
  hint = f' — login is dead, run: claude-accounts relogin {aid}'
2113
2169
  print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
@@ -373,6 +373,12 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
373
373
  automatically, so this only surfaces when *every* account needs a re-login (the shim
374
374
  then falls back to the machine's own `~/.claude` login) or when it was pinned with
375
375
  `CLAUDE_ACCOUNT`.
376
+ - **"Please run /login · API Error: 401 OAuth access token is invalid"** — the portable
377
+ setup-token is rejected even though its presence and `claude auth status` look healthy.
378
+ The shim now runs a private first-use inference, parks a rejected token, and reselects
379
+ before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
380
+ check every token immediately; `claude-accounts expired` reports token-only accounts
381
+ as `UNVERIFIED` until that proof exists.
376
382
  - **Everything marked limited** — the shim still runs (least-utilized fallback);
377
383
  check `selection.log` for `all-limited` lines.
378
384
  - **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
package/lib/audit.py CHANGED
@@ -12,6 +12,7 @@ States:
12
12
  neither dead nor missing; just not usable from here
13
13
  missing no auth on this machine, and this machine is supposed to own the grant
14
14
  remote no auth here, but the manifest says another machine owns it (informational)
15
+ unverified a setup-token exists but has not passed inference on this machine
15
16
 
16
17
  Only 'ok' accounts are selectable; everything else is excluded by the shim.
17
18
 
@@ -25,6 +26,7 @@ Run directly for a TSV dump: python3 lib/audit.py <acc-root> [mac|linux]
25
26
  Columns: id, email, home, state, label, reason, fix
26
27
  """
27
28
 
29
+ import hashlib
28
30
  import json
29
31
  import os
30
32
  import re
@@ -192,7 +194,7 @@ def oauth_login(d, now):
192
194
 
193
195
 
194
196
  LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED',
195
- 'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
197
+ 'missing': 'NO LOGIN', 'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}
196
198
 
197
199
  # `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
198
200
  # two vocabularies — normalize, or an un-authenticated account on the very machine that
@@ -227,10 +229,22 @@ def _fix_for(state, aid):
227
229
  # stuck in an ssh session CAN do is mint the portable token from here — the
228
230
  # setup-token ceremony is a fresh browser grant and never reads the keychain.
229
231
  return f'claude-accounts mint {aid} (portable; or use it from the Mac\'s GUI session)'
232
+ if state == 'unverified':
233
+ return 'claude-accounts verify'
230
234
  return ''
231
235
 
232
236
 
233
- def audit_account(root, acct, now=None, machine=None):
237
+ def _token_verified(d, tpath):
238
+ """Whether this exact setup-token passed an inference on this machine."""
239
+ try:
240
+ digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
241
+ saved = open(os.path.join(d, '.server-token-verified')).read().strip()
242
+ return bool(digest) and saved == digest
243
+ except OSError:
244
+ return False
245
+
246
+
247
+ def audit_account(root, acct, now=None, machine=None, require_verified_token=False):
234
248
  now = time.time() if now is None else now
235
249
  aid = acct.get('id', '')
236
250
  d = os.path.join(root, aid)
@@ -263,14 +277,23 @@ def audit_account(root, acct, now=None, machine=None):
263
277
  if state == 'ok' or has_token:
264
278
  # A portable token authenticates on its own, so a dead credential beside it
265
279
  # is not fatal — same rule the shim applies.
266
- row['state'] = 'ok'
267
- row['reason'] = reason if state == 'ok' else f'{reason}; using server.token'
280
+ if state != 'ok' and require_verified_token and not _token_verified(d, tpath):
281
+ row['state'] = 'unverified'
282
+ row['reason'] = 'server.token has not passed an inference on this machine'
283
+ else:
284
+ row['state'] = 'ok'
285
+ row['reason'] = reason if state == 'ok' else f'{reason}; using server.token'
268
286
  else:
269
287
  row['state'], row['reason'] = 'expired', reason
270
288
  return done(row)
271
289
  if has_token:
272
290
  age = int((now - _mtime(tpath)) / 86400)
273
- row['reason'] = f'server.token present (minted ~{age}d ago)'
291
+ if require_verified_token and not _token_verified(d, tpath):
292
+ row['state'] = 'unverified'
293
+ row['reason'] = (f'server.token present (minted ~{age}d ago) but has not passed '
294
+ 'an inference on this machine')
295
+ else:
296
+ row['reason'] = f'server.token present (minted ~{age}d ago)'
274
297
  return done(row)
275
298
  if login['state'] == 'locked':
276
299
  row['state'], row['reason'] = 'locked', login['reason']
@@ -299,7 +322,11 @@ def audit_all(root, machine=None, now=None):
299
322
  if __name__ == '__main__':
300
323
  root = sys.argv[1]
301
324
  machine = sys.argv[2] if len(sys.argv) > 2 else None
302
- for r in audit_all(root, machine=machine):
325
+ strict_tokens = len(sys.argv) > 3 and sys.argv[3] == 'strict-tokens'
326
+ for a in json.load(open(os.path.join(root, 'accounts.json'))).get('accounts', []):
327
+ if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
328
+ continue
329
+ r = audit_account(root, a, machine=machine, require_verified_token=strict_tokens)
303
330
  # Tabs are the field separator, so no field may contain one.
304
331
  print('\t'.join(str(r[k]).replace('\t', ' ')
305
332
  for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
package/lib/common.sh CHANGED
@@ -499,11 +499,11 @@ clear_auth_markers() { # $1 = acct dir
499
499
  # Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
500
500
  # silently empty audit reads exactly like a perfectly healthy pool.
501
501
  account_audit() {
502
- local out rc err="$ACC_ROOT/tmp/audit.$$.err"
502
+ local mode="${1:-}" out rc err="$ACC_ROOT/tmp/audit.$$.err"
503
503
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
504
504
  # stderr goes to a file, never into the rows — a stray warning must not become a
505
505
  # bogus account line.
506
- out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
506
+ out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" "$mode" 2>"$err")"
507
507
  rc=$?
508
508
  if [ "$rc" -ne 0 ]; then
509
509
  printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,6 +84,14 @@ if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}"
84
84
  fi
85
85
  ctl="${FAKE_CTL:-/nonexistent}"
86
86
  acct="$(basename "${CLAUDE_CONFIG_DIR:-none}")"
87
+ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
88
+ *REVOKED*)
89
+ echo "Please run /login · API Error: 401 OAuth access token is invalid." >&2
90
+ exit 1 ;;
91
+ esac
92
+ case " $* " in
93
+ *" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
94
+ esac
87
95
  if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
88
96
  echo "API Error: 429 rate limit exceeded" >&2
89
97
  exit 1
@@ -492,6 +500,40 @@ out="$(claude 2>&1)"
492
500
  check "dead creds + portable token still authenticate" "TOK=sk-ant-oat01-rescue-token" "$out"
493
501
  rm -f "$ACC/acct-01/server.token"
494
502
 
503
+ # A revoked setup-token must be rejected BEFORE it carries the user's real command.
504
+ # The 401 stays inside the preflight, the account gets a durable auth marker, and the
505
+ # untouched --resume invocation continues on the next healthy account.
506
+ printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
507
+ printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
508
+ printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
509
+ printf '{"fetched_at":%s,"max_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
510
+ printf '{"fetched_at":%s,"max_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
511
+ rm -f "$ACC/.last-pick" "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
512
+ out="$(claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
513
+ check "revoked setup-token fails over before --resume" "CFG=acct-02" "$out"
514
+ case "$out" in
515
+ *"API Error: 401"*) t_fail "revoked setup-token hides preflight 401" "401 reached caller" ;;
516
+ *) t_ok "revoked setup-token hides preflight 401" ;;
517
+ esac
518
+ grep -q 'reason=auth-error' "$ACC/acct-01/.expired" 2>/dev/null \
519
+ && t_ok "revoked setup-token writes .expired" \
520
+ || t_fail "revoked setup-token marker" ".expired missing"
521
+ if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
522
+ t_fail "proven token rejection is durable" "marker has a soft expiry"
523
+ else
524
+ t_ok "proven token rejection is durable"
525
+ fi
526
+ out="$(CLAUDE_ACCOUNT=acct-01 claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
527
+ rc=$?
528
+ check "pinned revoked setup-token is classified before --resume" \
529
+ "invalid portable OAuth token (401)" "$out"
530
+ [ "$rc" -ne 0 ] && t_ok "pinned revoked setup-token exits nonzero" \
531
+ || t_fail "pinned revoked setup-token rc" "rc=$rc"
532
+ rm -f "$ACC/acct-01/server.token" "$ACC/acct-01/.expired" \
533
+ "$ACC/acct-01/.server-token-verified" "$ACC"/acct-*/limits.json
534
+ printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
535
+ printf '%s' "$DEAD_CREDS" > "$ACC/acct-02/.credentials.json"
536
+
495
537
  # every account dead => stock passthrough. The reason lands in selection.log; the
496
538
  # stderr hint is terminal-only, so a service-spawned `claude -p` stays byte-clean.
497
539
  out="$(claude 2>&1)"
@@ -1791,17 +1833,21 @@ EOF
1791
1833
  claude-accounts limits --force 2>&1)"
1792
1834
  check "a scope-denied 403 names the missing scope" "user:profile" "$out"
1793
1835
  check "a scope-denied 403 names the ceremony that fixes it" "claude-accounts login acct-01" "$out"
1794
- check "a scope-denied 403 backs off instead of retrying" "Backing off" "$out"
1836
+ # The CREDENTIAL is refused for good (the digest below stops it being offered
1837
+ # again); the ACCOUNT is deliberately NOT parked, because parking it also blocked
1838
+ # the OAuth path and froze telemetry for hours on accounts whose login was fine.
1839
+ check "a scope-denied 403 retires the token, not the account" \
1840
+ "This token will not be offered again" "$out"
1795
1841
  python3 - "$SD/acct-01/limits.json" "$now" <<'EOF'
1796
1842
  import json, sys
1797
1843
  lim = json.load(open(sys.argv[1]))
1798
1844
  now = int(sys.argv[2])
1799
- assert lim['retry_after'] > now + 3600, lim # parked for hours, not minutes
1845
+ assert lim.get('token_scope_denied'), lim # THIS token is retired
1846
+ assert not lim.get('retry_after'), lim # ...but the account is not parked
1800
1847
  assert lim['fetched_at'] == now - 950000, lim # a FAILURE never invents freshness
1801
- assert 'HTTP 403' in lim['last_error'], lim
1802
1848
  EOF
1803
- [ $? -eq 0 ] && t_ok "a refused fetch records a long backoff and keeps its stale fetched_at" \
1804
- || t_fail "403 backoff state" "see $SD/acct-01/limits.json"
1849
+ [ $? -eq 0 ] && t_ok "a refused token is retired without parking the account, keeping its stale fetched_at" \
1850
+ || t_fail "403 credential-scoped state" "see $SD/acct-01/limits.json"
1805
1851
 
1806
1852
  # The whole point: the next scheduled pass must NOT spend another request. Before the
1807
1853
  # fix this retried every five minutes, from every machine, forever.
@@ -1809,9 +1855,12 @@ EOF
1809
1855
  out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1810
1856
  claude-accounts limits 2>&1)"
1811
1857
  after="$(wc -l < "$uhits")"
1812
- [ "$before" = "$after" ] && t_ok "a parked account is not re-fetched on the next pass" \
1858
+ [ "$before" = "$after" ] && t_ok "a retired token is not re-offered on the next pass" \
1813
1859
  || t_fail "403 retry storm" "endpoint hit again ($before -> $after requests)"
1814
- check "the parked account says why it is waiting" "backing off after HTTP 403" "$out"
1860
+ # It is the CREDENTIAL that is spent, so the message names the ceremony that
1861
+ # replaces it rather than a clock the operator would otherwise sit and watch.
1862
+ check "and says what would fix it, not how long to wait" \
1863
+ "telemetry stays dark until: claude-accounts login acct-01" "$out"
1815
1864
 
1816
1865
  # Any other non-2xx backs off too — a 5xx retried every pass is the same storm.
1817
1866
  rm -f "$SD/acct-01/limits.json"
@@ -1990,6 +2039,57 @@ EOF
1990
2039
  [ "$before" = "$after" ] && t_ok "a token already refused for scope is not offered again" \
1991
2040
  || t_fail "token re-offered" "endpoint hit again ($before -> $after)"
1992
2041
  check "and the message says what would fix it" "claude-accounts login acct-01" "$out"
2042
+ # ---- an OAuth account NEVER spends its setup token here, and a token refusal
2043
+ # ---- never parks the ACCOUNT ------------------------------------------------
2044
+ # Live symptom (my-mini, 2026-08-28): acct-02/acct-05 sat 1-5 HOURS stale with
2045
+ # "backing off after HTTP 403 (source=token) — permanent" while their OAuth
2046
+ # credentials were fine. The chain: the access token had expired minutes ago, so
2047
+ # refresh_oauth declined (REFRESH_MIN_EXPIRED proves no live session owns it),
2048
+ # the probe fell through to the setup token, the endpoint refused it for scope,
2049
+ # and that parked the whole ACCOUNT for six hours — blocking the OAuth path that
2050
+ # would have worked on the very next pass. The shim then ranked the pool on
2051
+ # hour-old readings and said so ("usage telemetry is 1h old").
2052
+ SD2="$WORK/token-poison"
2053
+ mkdir -p "$SD2/acct-01" "$SD2/tmp"
2054
+ : > "$SD2/.limits-kick"
2055
+ cat > "$SD2/accounts.json" <<EOF
2056
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
2057
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
2058
+ "accounts": [ {"id": "acct-01", "email": "poison@test", "home": "mac",
2059
+ "added_at": "2026-08-28T00:00:00Z"} ] }
2060
+ EOF
2061
+ # An OAuth credential whose ACCESS token expired a moment ago: too recent for
2062
+ # refresh_oauth to touch, so this pass has no bearer it may use.
2063
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-justexpired","refreshToken":"r","expiresAt":%s000,"refreshTokenExpiresAt":9999999999999}}' \
2064
+ "$((now - 30))" > "$SD2/acct-01/.credentials.json"
2065
+ printf 'sk-ant-oat01-PORTABLE0001\n' > "$SD2/acct-01/server.token"
2066
+ before="$(wc -l < "$uhits")"
2067
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
2068
+ claude-accounts limits 2>&1)"
2069
+ after="$(wc -l < "$uhits")"
2070
+ [ "$before" = "$after" ] \
2071
+ && t_ok "an OAuth account never spends its setup token on the usage endpoint" \
2072
+ || t_fail "token spent" "the endpoint was called ($before -> $after) with a token that can only 403"
2073
+ check "and it says it will simply retry" "retrying next pass" "$out"
2074
+ [ ! -f "$SD2/acct-01/limits.json" ] \
2075
+ && t_ok "no six-hour park is written for an account that merely missed a refresh" \
2076
+ || t_fail "account parked" "$(cat "$SD2/acct-01/limits.json")"
2077
+
2078
+ # A token-only account still tries once (that is the only way to learn), but the
2079
+ # refusal must park the CREDENTIAL, not the account: the digest is remembered and
2080
+ # no retry_after is written, so a later OAuth login is free to work immediately.
2081
+ rm -f "$SD2/acct-01/.credentials.json" "$SD2/acct-01/limits.json"
2082
+ CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
2083
+ claude-accounts limits >/dev/null 2>&1
2084
+ python3 - "$SD2/acct-01/limits.json" <<'EOF'
2085
+ import json, sys
2086
+ d = json.load(open(sys.argv[1]))
2087
+ assert d.get('token_scope_denied'), d
2088
+ assert not d.get('retry_after'), f"the account was parked by a credential refusal: {d}"
2089
+ EOF
2090
+ [ $? -eq 0 ] && t_ok "a token scope refusal parks the credential, never the account" \
2091
+ || t_fail "token refusal parked the account" "see $SD2/acct-01/limits.json"
2092
+
1993
2093
  # Re-minting the token is a new credential, so it earns a fresh try.
1994
2094
  printf 'sk-ant-oat01-REMINTED01\n' > "$SD/acct-01/server.token"
1995
2095
  before="$(wc -l < "$uhits")"
@@ -2358,12 +2458,23 @@ check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
2358
2458
  # account and park it.
2359
2459
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
2360
2460
  printf 'sk-ant-oat01-token-for-01' > "$ACC/acct-01/server.token"
2361
- rm -f "$ACC/acct-01/.expired"
2461
+ rm -f "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
2462
+ out="$(claude-accounts expired 2>&1)"
2463
+ check "expired does not call an unverified setup-token healthy" "acct-01" "$out"
2464
+ check "expired labels an unverified setup-token" "UNVERIFIED" "$out"
2465
+ check "expired gives the setup-token verification fix" "claude-accounts verify" "$out"
2362
2466
  out="$(claude-accounts verify 2>&1)"
2363
2467
  case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
2364
2468
  *) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
2365
2469
  [ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
2366
2470
  || t_fail "verify token fallback" "healthy token-auth account was parked"
2471
+ [ -f "$ACC/acct-01/.server-token-verified" ] \
2472
+ && t_ok "verify records proof for the exact setup-token" \
2473
+ || t_fail "verify setup-token proof" ".server-token-verified missing"
2474
+ state="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" "$(uname -s | tr '[:upper:]' '[:lower:]')" \
2475
+ strict-tokens | awk -F'\t' '$1=="acct-01"{print $4}')"
2476
+ [ "$state" = "ok" ] && t_ok "verified setup-token is no longer false-unknown" \
2477
+ || t_fail "verified setup-token audit" "expected ok, got: $state"
2367
2478
  rm -f "$ACC/acct-01/server.token"
2368
2479
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
2369
2480