claude-multiacc 2.0.7 → 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
@@ -2011,7 +2013,7 @@ cmd_verify() {
2011
2013
  real="$(find_real_claude "$_self")" || die "real claude binary not found"
2012
2014
  fi
2013
2015
  "$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
2014
- import json, os, re, subprocess, sys, time
2016
+ import hashlib, json, os, re, subprocess, sys, time
2015
2017
 
2016
2018
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
2017
2019
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
@@ -2118,10 +2120,11 @@ for acct in manifest.get('accounts', []):
2118
2120
  if has_token and (not has_creds or cred_state != 'ok'):
2119
2121
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
2120
2122
  t0 = time.time()
2123
+ uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
2121
2124
  try:
2122
- r = subprocess.run([real, '-p', 'Reply with exactly: OK'],
2125
+ r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
2123
2126
  env=env, capture_output=True, text=True, timeout=240,
2124
- stdin=subprocess.DEVNULL, cwd=root)
2127
+ input='Reply with exactly: OK\n', cwd=root)
2125
2128
  except subprocess.TimeoutExpired:
2126
2129
  print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
2127
2130
  failures += 1
@@ -2134,6 +2137,16 @@ for acct in manifest.get('accounts', []):
2134
2137
  os.remove(os.path.join(d, '.expired'))
2135
2138
  except OSError:
2136
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
2137
2150
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
2138
2151
  else:
2139
2152
  err = (r.stderr or '').strip()[:200]
@@ -2147,6 +2160,10 @@ for acct in manifest.get('accounts', []):
2147
2160
  hint = (f' — ORG BLOCKED, excluded from the pool; '
2148
2161
  f'try: claude-accounts relogin {aid}')
2149
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
2150
2167
  mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
2151
2168
  hint = f' — login is dead, run: claude-accounts relogin {aid}'
2152
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.7",
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)"
@@ -2416,12 +2458,23 @@ check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
2416
2458
  # account and park it.
2417
2459
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
2418
2460
  printf 'sk-ant-oat01-token-for-01' > "$ACC/acct-01/server.token"
2419
- 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"
2420
2466
  out="$(claude-accounts verify 2>&1)"
2421
2467
  case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
2422
2468
  *) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
2423
2469
  [ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
2424
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"
2425
2478
  rm -f "$ACC/acct-01/server.token"
2426
2479
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
2427
2480