claude-multiacc 2.0.11 → 2.0.13

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
@@ -280,6 +280,48 @@ sel_score_of() { # $1 = acct dir
280
280
  printf '%s\n' $((w * 1000 + s))
281
281
  }
282
282
 
283
+ # The .limited marker's own fields (line 1: reset epoch; line 2: "bucket=…
284
+ # percent=… … reason=…"). All tolerant: an unreadable or bare marker answers
285
+ # "unknown", never an error — these feed the all-limited fallback only.
286
+ limited_reset_of() { # $1 acct dir -> the marker's reset epoch, or 0 (unknown)
287
+ local m="$1/.limited" r=""
288
+ [ -f "$m" ] && { IFS= read -r r < "$m" 2>/dev/null || r=""; }
289
+ if num_ok "$r"; then printf '%s\n' "$r"; else printf '0\n'; fi
290
+ }
291
+
292
+ limited_percent_of() { # $1 acct dir -> the marked bucket's percent, or -1 (unknown)
293
+ local m="$1/.limited" line=""
294
+ [ -f "$m" ] && line="$(sed -n 2p "$m" 2>/dev/null)"
295
+ case "$line" in
296
+ *percent=*) line="${line#*percent=}"; line="${line%% *}" ;;
297
+ *) line="" ;;
298
+ esac
299
+ if num_ok "$line"; then printf '%s\n' "$line"; else printf '%s\n' "-1"; fi
300
+ }
301
+
302
+ # Rejected RIGHT NOW, not merely near the threshold: the marked bucket is exhausted
303
+ # (100%), or the marker records a real client rejection (a 429 the server sent).
304
+ # A bucket at 90-99% still answers requests — the difference the all-limited
305
+ # fallback lives on, because "degraded service beats a hard failure" only holds
306
+ # for an account that can actually serve.
307
+ limited_hard_blocked() { # $1 acct dir
308
+ local m="$1/.limited" line="" p
309
+ if [ -f "$m" ]; then
310
+ line="$(sed -n 2p "$m" 2>/dev/null)"
311
+ case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
312
+ p="$(limited_percent_of "$1")"
313
+ if [ "$p" -ge 0 ] 2>/dev/null; then
314
+ [ "$p" -ge 100 ]
315
+ return
316
+ fi
317
+ return 1
318
+ fi
319
+ # No marker (the over-threshold backstop put it in valid-but-not-eligible):
320
+ # fresh telemetry's peak decides; stale/unknown reads as still serving.
321
+ p="$(cutoff_field "$1" max_percent)" || return 1
322
+ [ "$p" -ge 100 ]
323
+ }
324
+
283
325
  # Peak of ALL buckets (session included) — the EXCLUSION signal. Stale/unknown => 50.
284
326
  util_of() {
285
327
  local v
@@ -682,6 +724,30 @@ expired_marked() { # $1 = acct dir
682
724
  local m="$1/.expired" mt f soft reason
683
725
  [ -f "$m" ] || return 1
684
726
  reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
727
+ if [ "$reason" = "setup-token-invalid" ]; then
728
+ # A TOKEN-scoped park: it says the portable token is dead and nothing about the
729
+ # login beside it. Only a NEW token heals it — a login refresh rewrites
730
+ # .credentials.json / the Keychain item every few hours and used to lift it, so the
731
+ # next session that had to use the token exported the dead one, got 401 and parked
732
+ # it again (2026-08-29). And it only EXCLUDES the account from a session that would
733
+ # authenticate by that token: one that can use a live login here (a GUI terminal,
734
+ # the runner's LaunchAgent) runs the account on the login, token park or not.
735
+ mt="$(file_mtime "$m")"
736
+ if [ -f "$1/server.token" ] && [ "$(file_mtime "$1/server.token")" -gt "$mt" ]; then
737
+ rm -f "$m" 2>/dev/null
738
+ return 1
739
+ fi
740
+ if has_oauth "$1" && ! creds_dead "$1"; then
741
+ return 1 # the login carries this session
742
+ fi
743
+ soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
744
+ case "$soft" in
745
+ ''|*[!0-9]*) return 0 ;;
746
+ *) [ "$now" -lt "$soft" ] && return 0
747
+ rm -f "$m" 2>/dev/null
748
+ return 1 ;;
749
+ esac
750
+ fi
685
751
  # CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
686
752
  # evidence they were about. A POLICY park (org-blocked) is about the account, not the
687
753
  # credential: refreshing its token does not re-enable Claude Code for it. Letting a
@@ -985,19 +1051,50 @@ if [ "${#eligible[@]}" -gt 0 ]; then
985
1051
  pick_best "${eligible[@]}"
986
1052
  fi
987
1053
  else
988
- # Every account is limit-marked: degraded service beats a hard failure (100% rule).
989
- assess_telemetry "${valid[@]}"
990
- [ "$degraded" = 1 ] && SEL_DEGRADED=1
991
- pick_best "${valid[@]}"
1054
+ # Every account is limit-marked: degraded service beats a hard failure (100% rule)
1055
+ # but not every limited account is equally dead. A bucket at 90-99% still answers;
1056
+ # one at 100% (or a real client 429) rejects every request until its reset. Ranking
1057
+ # the fallback on weekly headroom alone handed out a session-exhausted account
1058
+ # (weekly=7%, session=100%) over one still serving at weekly 99% — a guaranteed
1059
+ # rejection chosen over a working session (operator report, 2026-08-29).
1060
+ soft=()
1061
+ hard=()
1062
+ for d in "${valid[@]}"; do
1063
+ if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
1064
+ done
1065
+ if [ "${#soft[@]}" -gt 0 ]; then
1066
+ assess_telemetry "${soft[@]}"
1067
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
1068
+ pick_best "${soft[@]}"
1069
+ else
1070
+ # Every account is exhausted RIGHT NOW: nothing serves, so hand out the one
1071
+ # that unblocks first — its rejection window is the shortest.
1072
+ assess_telemetry "${valid[@]}"
1073
+ [ "$degraded" = 1 ] && SEL_DEGRADED=1
1074
+ PICK_DIR=""
1075
+ best_reset=0
1076
+ for d in "${hard[@]}"; do
1077
+ r="$(limited_reset_of "$d")"
1078
+ if [ -z "$PICK_DIR" ]; then
1079
+ PICK_DIR="$d"; best_reset="$r"; continue
1080
+ fi
1081
+ if [ "$r" -gt 0 ] && { [ "$best_reset" -eq 0 ] || [ "$r" -lt "$best_reset" ]; }; then
1082
+ PICK_DIR="$d"; best_reset="$r"
1083
+ fi
1084
+ done
1085
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
1086
+ fi
992
1087
  # Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
993
1088
  # `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
994
1089
  # reading only this event concluded the choice had no usage input at all.
995
- if [ "$degraded" = 1 ]; then
996
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
997
- elif [ "$blind" = 1 ]; then
998
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=?% ranking=BLIND"
999
- else
1000
- sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
1090
+ if [ "${#soft[@]}" -gt 0 ]; then
1091
+ if [ "$degraded" = 1 ]; then
1092
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
1093
+ elif [ "$blind" = 1 ]; then
1094
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=?% ranking=BLIND"
1095
+ else
1096
+ sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
1097
+ fi
1001
1098
  fi
1002
1099
  fi
1003
1100
  pick="$PICK_DIR"
@@ -907,12 +907,20 @@ ceremony_token_check() { # $1 = token
907
907
  # token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
908
908
  # $2 = token, $3 = acct id (messages only).
909
909
  commit_ceremony_token() {
910
- local d="$1" tok="$2" id="$3" rc=0
910
+ local d="$1" tok="$2" id="$3" src="${4:-captured}" rc=0
911
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."
912
+ # A short token is an incomplete one unless Claude itself has just answered with it:
913
+ # the length alone convicts it, so an inconclusive probe (429, network) must not let
914
+ # it through as merely "unverified"that is the exact token the fleet then rejects.
915
+ # The cause named depends on where the token came from: a CAPTURED one was wrapped by
916
+ # the ceremony's terminal; a PASTED one was cut before it reached the clipboard.
917
+ if [ "$rc" -ne 0 ] && [ "${#tok}" -lt "$SETUP_TOKEN_FULL_LEN" ]; then
918
+ if [ "$src" = "pasted" ]; then
919
+ die "the pasted token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}) — it was cut before it reached the clipboard (a narrow terminal wraps the token when it is shown). Nothing saved for $id. Copy the WHOLE token and paste again."
915
920
  fi
921
+ die "the captured token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}): 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."
922
+ fi
923
+ if [ "$rc" -eq 1 ]; then
916
924
  die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — nothing saved for $id; sign in again as the right account and retry"
917
925
  fi
918
926
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
@@ -1099,7 +1107,7 @@ cmd_mint() {
1099
1107
  if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
1100
1108
  die "that token authenticates as $got but $id is $email — nothing saved"
1101
1109
  fi
1102
- commit_ceremony_token "$d" "$tok" "$id"
1110
+ commit_ceremony_token "$d" "$tok" "$id" "$([ "$paste" = "1" ] && echo pasted || echo captured)"
1103
1111
  clear_auth_markers "$d"
1104
1112
  log_to ops.log "mint $id"
1105
1113
  echo "Token saved to $d/server.token"
@@ -1150,7 +1158,7 @@ cmd_login() {
1150
1158
  if [ -z "$got" ] && [ "$force" != "1" ]; then
1151
1159
  die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
1152
1160
  fi
1153
- clear_auth_markers "$d"
1161
+ clear_auth_markers "$d" keep-token-park
1154
1162
  if [ -s "$d/.credentials.json" ]; then
1155
1163
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
1156
1164
  else
@@ -1515,12 +1523,20 @@ def mark_expired(d, slug, detail=''):
1515
1523
 
1516
1524
  def clear_expired(d):
1517
1525
  """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
1518
- EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
1519
- they are just barred from Claude Code inference, so telemetry says nothing about
1520
- them. Only a passing `verify` (a real call) or a re-login lifts that."""
1526
+ EXCEPT:
1527
+ - org-blocked: those accounts authenticate perfectly (telemetry works), they are
1528
+ just barred from Claude Code inference, so telemetry says nothing about them.
1529
+ - setup-token-invalid: the bearer behind a usage fetch or a refresh grant is the
1530
+ machine-local OAUTH login, and its success says nothing about the PORTABLE
1531
+ token a real inference rejected. On the one Mac holding both, this probe
1532
+ un-parked every dead-token account minutes after the shim parked it, and every
1533
+ fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
1534
+ token (the shim's newer-file rule) or a real call that used the token lifts it.
1535
+ Only a passing `verify` (a real call) or a re-login lifts the excepted ones."""
1521
1536
  mpath = os.path.join(d, '.expired')
1522
1537
  try:
1523
- if 'reason=org-blocked' in open(mpath, errors='replace').read():
1538
+ body = open(mpath, errors='replace').read()
1539
+ if 'reason=org-blocked' in body or 'reason=setup-token-invalid' in body:
1524
1540
  return False
1525
1541
  except OSError:
1526
1542
  return False
@@ -2303,11 +2319,20 @@ for acct in manifest.get('accounts', []):
2303
2319
  dt = time.time() - t0
2304
2320
  out = (r.stdout or '').strip()
2305
2321
  if r.returncode == 0 and 'ok' in out.lower():
2306
- # A real call succeeded: this account is definitively alive.
2322
+ # A real call succeeded: this account is definitively alive — but WHICH
2323
+ # credential answered matters. A pass on the OAuth login says nothing about a
2324
+ # portable token a real call rejected; clearing that park here re-opened the
2325
+ # 401 loop the marker exists to stop (the shim re-picked the account, exported
2326
+ # the dead token, parked it again — 2026-08-29).
2307
2327
  try:
2308
- os.remove(os.path.join(d, '.expired'))
2328
+ marker = open(os.path.join(d, '.expired'), errors='replace').read()
2309
2329
  except OSError:
2310
- pass
2330
+ marker = ''
2331
+ if uses_token or 'reason=setup-token-invalid' not in marker:
2332
+ try:
2333
+ os.remove(os.path.join(d, '.expired'))
2334
+ except OSError:
2335
+ pass
2311
2336
  if uses_token:
2312
2337
  try:
2313
2338
  digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
package/lib/audit.py CHANGED
@@ -83,7 +83,16 @@ def expired_marked(d, now=None):
83
83
  if not os.path.isfile(mpath):
84
84
  return False
85
85
  detail = marker_reason(mpath)
86
- if marker_slug(detail) != 'org-blocked':
86
+ slug = marker_slug(detail)
87
+ if slug == 'setup-token-invalid':
88
+ # A TOKEN park heals only when a NEW token lands (bin/claude expired_marked):
89
+ # a login refresh rewriting .credentials.json or the Keychain item says
90
+ # nothing about the portable token.
91
+ mt = _mtime(mpath)
92
+ p = os.path.join(d, 'server.token')
93
+ if os.path.isfile(p) and _mtime(p) > mt:
94
+ return False
95
+ elif slug != 'org-blocked':
87
96
  mt = _mtime(mpath)
88
97
  for name in ('.credentials.json', 'server.token'):
89
98
  p = os.path.join(d, name)
@@ -277,6 +286,17 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
277
286
  row['reason'] = ('this account\'s organization has disabled Claude Code '
278
287
  'subscription access')
279
288
  elif slug == 'setup-token-invalid':
289
+ login = oauth_login(d, now)
290
+ if login['state'] == 'ok':
291
+ # A token park says nothing about the working login beside it: the
292
+ # shim runs this account on the login here (bin/claude
293
+ # expired_marked), so this machine reads it as usable — while the
294
+ # token stays unproven for the fleet (token_verified False).
295
+ row['store'] = login['store']
296
+ row['state'] = 'ok'
297
+ row['reason'] = (f"{login['reason']}; portable setup-token was rejected "
298
+ "here and is not used")
299
+ return done(row)
280
300
  row['state'] = 'token-invalid'
281
301
  row['reason'] = 'portable setup-token was rejected by a real inference'
282
302
  else:
package/lib/common.sh CHANGED
@@ -491,7 +491,15 @@ mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
491
491
  # Called wherever fresh auth lands (login/add/mint, successful usage fetch): the
492
492
  # account is provably alive again, so both the dead-auth marker and any refresh
493
493
  # backoff must go, or it would stay parked until the next re-login.
494
- clear_auth_markers() { # $1 = acct dir
494
+ clear_auth_markers() { # $1 = acct dir, $2 = "keep-token-park" after a LOGIN-only ceremony
495
+ # A fresh login proves the login; it says nothing about a portable token a real
496
+ # call rejected, so that park outlives it — only a new token (mint / login --token)
497
+ # or a real call through the token lifts it.
498
+ if [ "${2:-}" = "keep-token-park" ] \
499
+ && grep -q 'reason=setup-token-invalid' "$1/.expired" 2>/dev/null; then
500
+ rm -f "$1/.oauth-refresh.json" 2>/dev/null || true
501
+ return 0
502
+ fi
495
503
  rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
496
504
  }
497
505
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -104,6 +104,10 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
104
104
  sk-ant-oat01-WWW*)
105
105
  # The wrapped fixture above: only the COMPLETE token (with its tail) authenticates;
106
106
  # its 79-character first line is what Claude answered 401 to, fleet-wide.
107
+ if [ -n "${FAKE_PROBE_FLAKY:-}" ]; then
108
+ # Not an auth verdict at all — the API is busy. The probe is inconclusive.
109
+ echo "API Error: 529 Overloaded" >&2; exit 1
110
+ fi
107
111
  case "$CLAUDE_CODE_OAUTH_TOKEN" in
108
112
  *TAILOK) : ;;
109
113
  *) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
@@ -432,6 +436,88 @@ grep -q "all-limited fallback=acct-02" "$ACC/selection.log" \
432
436
  && t_ok "fallback logged" || t_fail "fallback logged" "no all-limited line in selection.log"
433
437
  rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
434
438
 
439
+ # ---- 9a2. the fallback tells "still serving" from "rejected right now" --------------
440
+ # acct-01: weekly at 99% — worse headroom, but still answering requests.
441
+ # acct-02: session at 100% — far better weekly (7%), but every request bounces until
442
+ # the reset. Ranking on headroom alone handed out the guaranteed rejection
443
+ # (operator report 2026-08-29: "claude keeps starting on an out-of-limits account").
444
+ printf '%s\nbucket=weekly_scoped:Fable percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
445
+ printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
446
+ printf '{"fetched_at":%s,"max_percent":99,"weekly_percent":99,"session_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
447
+ printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":7,"session_percent":100,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
448
+ out="$(claude 2>&1)"
449
+ check "a still-serving limited account beats an exhausted one with more headroom" "CFG=acct-01" "$out"
450
+ # A real client rejection (a 429 the server sent) is exhausted whatever percent says.
451
+ printf '%s\nbucket=five_hour marked_at=x reason=client-rate-limit\n' "$((now+600))" > "$ACC/acct-02/.limited"
452
+ out="$(claude 2>&1)"
453
+ check "a client-rejected account is not the fallback while another still serves" "CFG=acct-01" "$out"
454
+ # Every account exhausted RIGHT NOW: hand out the one that unblocks first.
455
+ printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+7200))" > "$ACC/acct-01/.limited"
456
+ printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
457
+ out="$(claude 2>&1)"
458
+ check "all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
459
+ grep -q "all-exhausted resets_in=" "$ACC/selection.log" \
460
+ && t_ok "the all-exhausted pick is logged with its reset" || t_fail "all-exhausted log" "no line"
461
+ rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
462
+ printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
463
+ printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
464
+
465
+ # ---- 9a3. a token park is credential-scoped ----------------------------------------
466
+ # A dead portable token beside a LIVE login: this session runs the account on the
467
+ # login — the park neither excludes it nor exports the token. Only a session that
468
+ # would have to use the token is kept away, and only a NEW token heals the park (a
469
+ # login refresh rewriting the credential must not — that rewrite happened every few
470
+ # hours and re-opened the 401 loop, 2026-08-29).
471
+ rm -rf "$WORK/bak01"; mkdir -p "$WORK/bak01"
472
+ for f in server.token .credentials.json .expired .server-token-verified limits.json; do
473
+ [ -e "$ACC/acct-01/$f" ] && cp -p "$ACC/acct-01/$f" "$WORK/bak01/$f"
474
+ done
475
+ printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited" # acct-01 is the only free one
476
+ printf '{"fetched_at":%s,"max_percent":10,"weekly_percent":10,"session_percent":5,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
477
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
478
+ printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
479
+ touch -t 202001010000 "$ACC/acct-01/server.token" # older than the park
480
+ rm -f "$ACC/acct-01/.server-token-verified"
481
+ printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
482
+ out="$(claude 2>&1)"
483
+ check "a live login runs the account despite a token park" "CFG=acct-01" "$out"
484
+ check "the parked token is not exported beside a live login" "TOK=none" "$out"
485
+ [ -f "$ACC/acct-01/.expired" ] && t_ok "the token park stays on record for token-only sessions" \
486
+ || t_fail "token park record" "marker removed"
487
+ out="$(claude-accounts list --json 2>/dev/null | python3 -c '
488
+ import json, sys
489
+ rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
490
+ print(rows["acct-01"]["status"], rows["acct-01"].get("token_verified"))')"
491
+ [ "$out" = "active False" ] && t_ok "the audit reads a live login beside a parked token as usable, token unproven" \
492
+ || t_fail "audit token park" "expected 'active False', got '$out'"
493
+ # The login dies: now the token would carry the session, and the park excludes it.
494
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
495
+ touch -t 202101010000 "$ACC/acct-01/.expired" # the credential above is NEWER
496
+ rm -f "$ACC/acct-02/.limited"
497
+ out="$(claude 2>&1)"
498
+ check "a dead login makes the token park bite" "CFG=acct-02" "$out"
499
+ [ -f "$ACC/acct-01/.expired" ] && t_ok "a newer credential does not heal a token park" \
500
+ || t_fail "credential heal" "the login refresh lifted the token park"
501
+ out="$(claude-accounts list --json 2>/dev/null | python3 -c '
502
+ import json, sys
503
+ rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
504
+ print(rows["acct-01"]["status"])')"
505
+ [ "$out" = "token-invalid" ] && t_ok "the audit reads a parked token beside a dead login as token-invalid" \
506
+ || t_fail "audit token-invalid" "got '$out'"
507
+ # A NEW token heals it — and the healed account is selected again, on the token.
508
+ printf 'sk-ant-oat01-%s' "$(printf '%95s' '' | tr ' ' N)" > "$ACC/acct-01/server.token"
509
+ printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
510
+ out="$(claude 2>&1)"
511
+ check "a new token heals the park" "CFG=acct-01" "$out"
512
+ check "the healed account runs on its new token" "TOK=sk-ant-oat01-NNN" "$out"
513
+ [ ! -f "$ACC/acct-01/.expired" ] && t_ok "the healed park is gone" || t_fail "healed park" "marker still present"
514
+ # Hand acct-01 back exactly as it was.
515
+ rm -f "$ACC/acct-02/.limited" "$ACC/acct-01/server.token" "$ACC/acct-01/.credentials.json" \
516
+ "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified" "$ACC/acct-01/limits.json"
517
+ for f in server.token .credentials.json .expired .server-token-verified limits.json; do
518
+ [ -e "$WORK/bak01/$f" ] && cp -p "$WORK/bak01/$f" "$ACC/acct-01/$f"
519
+ done
520
+
435
521
  # ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
436
522
  # empty .credentials.json must NOT count as auth (interrupted write)
437
523
  mkdir -p "$ACC/acct-06"
@@ -1308,13 +1394,48 @@ check "the refusal names the truncation" "79 characters" "$out"
1308
1394
  check "the refusal names the cause" "terminal wrapped it" "$out"
1309
1395
  [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a refused capture leaves the old token in place" \
1310
1396
  || t_fail "refused capture" "server.token was overwritten"
1397
+ # An INCONCLUSIVE probe (the API is busy) must not let a wrapped capture through as
1398
+ # merely "unverified": its length alone convicts it, and that is the exact token the
1399
+ # fleet then rejects on every Mac.
1400
+ out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_WRAP=1 FAKE_PROBE_FLAKY=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1401
+ rc=$?
1402
+ [ "$rc" != "0" ] && t_ok "a wrapped capture is refused even when the probe is inconclusive" \
1403
+ || t_fail "wrapped+inconclusive rc" "rc=0"
1404
+ check "the inconclusive refusal still names the cause" "terminal wrapped it" "$out"
1405
+ [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "an unproven fragment saves nothing" \
1406
+ || t_fail "unproven fragment" "server.token was overwritten"
1407
+ # …while a COMPLETE token the probe cannot reach right now is saved as UNVERIFIED — the
1408
+ # shim proves it on first use — rather than blocking the operator on a busy API.
1409
+ out="$(FAKE_TOKEN_FULL=1 FAKE_PROBE_FLAKY=1 claude-accounts mint acct-04 2>&1 </dev/null)"
1410
+ rc=$?
1411
+ [ "$rc" = "0" ] && t_ok "a complete token survives an inconclusive probe" || t_fail "complete+inconclusive rc" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
1412
+ check "the inconclusive save says so" "saved as UNVERIFIED" "$out"
1413
+ [ "$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')" = "108" ] \
1414
+ && t_ok "the complete token was saved" || t_fail "complete token save" "wrong length"
1415
+ [ ! -f "$ACC/acct-04/.server-token-verified" ] && t_ok "an unproven save records no proof" \
1416
+ || t_fail "unproven proof marker" "marker present"
1417
+ printf '%s' "$orig" > "$ACC/acct-04/server.token"
1311
1418
  # 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)"
1419
+ # (A complete-length token, so the verdict is the rejection itself and not the
1420
+ # length rule that catches wrapped fragments first.)
1421
+ out="$(printf 'sk-ant-oat01-REVOKED%s' "$(printf '%88s' '' | tr ' ' R)" | claude-accounts mint acct-04 --paste 2>&1)"
1313
1422
  rc=$?
1314
1423
  [ "$rc" != "0" ] && t_ok "mint --paste refuses a token Claude rejects" || t_fail "paste rejected rc" "rc=0"
1315
1424
  check "the paste refusal says Claude rejected it" "Claude rejected the captured token" "$out"
1316
1425
  [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a rejected paste saves nothing" \
1317
1426
  || t_fail "rejected paste" "server.token was overwritten"
1427
+ # A SHORT rejected paste blames the paste — not a ceremony terminal this command
1428
+ # never opened.
1429
+ out="$(printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' | claude-accounts mint acct-04 --paste 2>&1)"
1430
+ rc=$?
1431
+ [ "$rc" != "0" ] && t_ok "a short rejected paste is refused" || t_fail "short paste rc" "rc=0"
1432
+ check "the short-paste refusal blames the paste" "cut before it reached the clipboard" "$out"
1433
+ case "$out" in
1434
+ *"sign-in terminal wrapped it"*) t_fail "short paste must not blame the ceremony terminal" "wrong provenance" ;;
1435
+ *) t_ok "short paste must not blame the ceremony terminal" ;;
1436
+ esac
1437
+ [ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a short rejected paste saves nothing" \
1438
+ || t_fail "short paste" "server.token was overwritten"
1318
1439
 
1319
1440
  # ---- 15c1b. the ceremony widens an unsized pty, so the whole token is captured ------
1320
1441
  # The panel drives the ceremony over a pty it never sized (0x0 -> the TUI renders 80
@@ -1411,9 +1532,15 @@ check "wrong-account sign-in skips (already added)" "a@test is already added as
1411
1532
 
1412
1533
  # ---- 15f. login command completes auth for an existing auth-less account -------------
1413
1534
  claude-accounts import pending@test --id acct-08 --no-sync >/dev/null 2>&1
1535
+ # A parked (dead) token beside the account: a LOGIN-only ceremony must not lift that
1536
+ # park — it proves the login, not the token.
1537
+ printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-08/server.token"
1538
+ printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-08/.expired"
1414
1539
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 2>&1)"
1415
1540
  check "login completes existing account (full login)" "acct-08 login saved" "$out"
1416
1541
  [ -f "$ACC/acct-08/.credentials.json" ] && t_ok "login writes .credentials.json" || t_fail "login creds" "missing"
1542
+ [ -f "$ACC/acct-08/.expired" ] && t_ok "a login-only ceremony keeps the token park" \
1543
+ || t_fail "login keeps token park" "marker removed"
1417
1544
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=other@test claude-accounts login acct-08 2>&1)"
1418
1545
  rc=$?
1419
1546
  check "login email mismatch refused" "nothing saved" "$out"
@@ -1422,6 +1549,7 @@ check "login email mismatch refused" "nothing saved" "$out"
1422
1549
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 --token 2>&1)"
1423
1550
  check "login --token saves a portable token" "acct-08 token saved" "$out"
1424
1551
  [ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
1552
+ [ ! -f "$ACC/acct-08/.expired" ] && t_ok "a new token lifts the token park" || t_fail "token lifts park" "marker still present"
1425
1553
  claude-accounts remove acct-08 --yes >/dev/null 2>&1
1426
1554
 
1427
1555
  # ---- 15f2. expired: the re-login worklist, and relogin fixes it ----------------------
@@ -2618,6 +2746,49 @@ out="$(claude-accounts verify 2>&1)"
2618
2746
  check "verify calls a rejected setup-token what it is" "portable setup-token is invalid" "$out"
2619
2747
  check "verify recommends replacing a rejected setup-token" \
2620
2748
  "claude-accounts login acct-01 --token" "$out"
2749
+
2750
+ # ---- 17c. a login-proven pass never lifts a TOKEN park -------------------------------
2751
+ # One Mac can hold both credentials: a working OAuth login beside a dead portable
2752
+ # token. The limits probe's bearer is the LOGIN, and its success used to clear the
2753
+ # token's park ("dead-auth marker cleared") — the next selection exported the dead
2754
+ # token, got 401, parked it again, all day (2026-08-29).
2755
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
2756
+ printf 'sk-ant-oat01-REVOKED%s' "$(printf '%88s' '' | tr ' ' R)" > "$ACC/acct-01/server.token"
2757
+ printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
2758
+ out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
2759
+ case "$out" in
2760
+ *"acct-01: dead-auth marker cleared"*) t_fail "limits probe must not lift a token park" "the login's success cleared the token's marker" ;;
2761
+ *) t_ok "limits probe must not lift a token park" ;;
2762
+ esac
2763
+ [ -f "$ACC/acct-01/.expired" ] && grep -q 'setup-token-invalid' "$ACC/acct-01/.expired" \
2764
+ && t_ok "the token park survives a login-proven probe" || t_fail "token park survival" ".expired gone"
2765
+ # ...but an ordinary login park IS lifted — a working bearer proves exactly that.
2766
+ printf '%s\nreason=auth-error marked_at=x detail=a real call came back not-authenticated\n' "$now" > "$ACC/acct-01/.expired"
2767
+ out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
2768
+ check "an auth-error park is still lifted by a working bearer" "dead-auth marker cleared" "$out"
2769
+ [ ! -f "$ACC/acct-01/.expired" ] && t_ok "the login park was lifted" || t_fail "login park" "still present"
2770
+ # verify: a PASS on the LOGIN keeps the token park; a PASS on the TOKEN lifts it.
2771
+ printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
2772
+ out="$(claude-accounts verify 2>&1)"
2773
+ case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify passes on the login" ;; \
2774
+ *) t_fail "verify login pass" "$(printf '%s' "$out" | grep acct-01 | head -1)" ;; esac
2775
+ [ -f "$ACC/acct-01/.expired" ] && t_ok "a login pass does not lift the token park" \
2776
+ || t_fail "verify token park" ".expired gone"
2777
+ # A dead login beside a fresh valid token: the pass rides the TOKEN and lifts its park.
2778
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
2779
+ printf 'sk-ant-oat01-%s' "$(printf '%95s' '' | tr ' ' G)" > "$ACC/acct-01/server.token"
2780
+ rm -f "$ACC/acct-01/.server-token-verified"
2781
+ out="$(claude-accounts verify 2>&1)"
2782
+ case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify passes on the token" ;; \
2783
+ *) t_fail "verify token pass" "$(printf '%s' "$out" | grep acct-01 | head -1)" ;; esac
2784
+ [ ! -f "$ACC/acct-01/.expired" ] && t_ok "a token pass lifts the token park" \
2785
+ || t_fail "token pass park" "still present"
2786
+ # Hand back the state the next checks expect: a dead login beside a REVOKED token,
2787
+ # parked and unproven — exactly where 17b left acct-01.
2788
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
2789
+ printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
2790
+ rm -f "$ACC/acct-01/.server-token-verified"
2791
+ printf '%s\nreason=setup-token-invalid marked_at=x detail=portable setup-token failed a real inference\n' "$now" > "$ACC/acct-01/.expired"
2621
2792
  out="$(claude-accounts expired 2>&1)"
2622
2793
  check "verify marker retains setup-token classification" "TOKEN INVALID" "$out"
2623
2794
  check "verify marker retains setup-token recovery" \