claude-multiacc 2.0.43 → 2.0.45

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.
Files changed (44) hide show
  1. package/README.md +20 -7
  2. package/bin/claude +66 -19
  3. package/bin/claude-accounts +186 -54
  4. package/bin/codex +4 -3
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/VERIFICATION.md +8 -0
  8. package/docs/claude-token-identity.md +147 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +42 -4
  21. package/lib/claude_token_identity.py +233 -0
  22. package/lib/codex_audit.py +8 -1
  23. package/lib/common.sh +3 -1
  24. package/lib/credential.py +9 -2
  25. package/package.json +1 -1
  26. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  38. package/tests/run-tests.sh +254 -3
  39. package/tests/test_autoresume.py +36 -5
  40. package/tests/test_claude_token_identity.py +259 -0
  41. package/tests/test_token_identity_ceremony.py +167 -0
  42. package/tests/test_token_identity_integration.py +207 -0
  43. package/tests/test_token_identity_retry.py +95 -0
  44. package/tests/test_token_identity_sync.py +139 -0
@@ -635,7 +635,7 @@ cmd_add() {
635
635
  # Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
636
636
  echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
637
637
  run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
638
- commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
638
+ commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id" captured "$email"
639
639
  got="$(token_email "$CEREMONY_TOKEN")"
640
640
  else
641
641
  # Default: full Claude Code login (full scopes, no long-lived-token step-up).
@@ -749,6 +749,12 @@ cmd_import() {
749
749
  fi
750
750
  local d="$ACC_ROOT/$id"
751
751
  seed_account_dir "$d"
752
+ if [ -n "$tok" ]; then
753
+ ceremony_token_identity "$d" "$tok" "$email"
754
+ if [ "$CEREMONY_IDENTITY_STATE" = "mismatch" ]; then
755
+ die "the token's organization does not match $id — nothing imported; mint a token for the named account"
756
+ fi
757
+ fi
752
758
  if [ -n "$creds" ]; then
753
759
  case "$mode" in
754
760
  copy) cp "$creds" "$d/.credentials.json" ;;
@@ -1033,11 +1039,27 @@ ceremony_token_check() { # $1 = token
1033
1039
  esac
1034
1040
  }
1035
1041
 
1036
- # The ONE place a ceremony's token is written: only after ceremony_token_check, so a
1037
- # token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
1042
+ # A candidate must not replace the current token's identity receipt until it is saved.
1043
+ CEREMONY_IDENTITY_STATE="unknown"
1044
+ ceremony_token_identity() { # $1 = account dir, $2 = candidate token, $3 = optional named email
1045
+ local d="$1" tok="$2" email="${3:-}" real
1046
+ real="$(find_real_claude "$_self")" || real=""
1047
+ set -- check "$d" --token-stdin --real "$real" --format state --no-store
1048
+ [ -z "$email" ] || set -- "$@" --expected-email "$email"
1049
+ CEREMONY_IDENTITY_STATE="$(printf '%s' "$tok" \
1050
+ | "$PYBIN" "$LIB_DIR/claude_token_identity.py" "$@" 2>/dev/null)"
1051
+ [ -n "$CEREMONY_IDENTITY_STATE" ] || CEREMONY_IDENTITY_STATE="unknown"
1052
+ }
1053
+
1054
+ # The ONE place a ceremony's token is written: only after organization and auth checks,
1055
+ # so a known wrong-organization or rejected token is never saved or distributed. $1 = acct dir,
1038
1056
  # $2 = token, $3 = acct id (messages only).
1039
1057
  commit_ceremony_token() {
1040
1058
  local d="$1" tok="$2" id="$3" src="${4:-captured}" rc=0
1059
+ ceremony_token_identity "$d" "$tok" "${5:-}"
1060
+ if [ "$CEREMONY_IDENTITY_STATE" = "mismatch" ]; then
1061
+ die "the token's organization does not match $id — nothing saved; sign in as the named account and mint again"
1062
+ fi
1041
1063
  ceremony_token_check "$tok" || rc=$?
1042
1064
  # A short token is an incomplete one unless Claude itself has just answered with it:
1043
1065
  # the length alone convicts it, so an inconclusive probe (429, network) must not let
@@ -1055,6 +1077,7 @@ commit_ceremony_token() {
1055
1077
  fi
1056
1078
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
1057
1079
  chmod 600 "$d/server.token"
1080
+ [ "$CEREMONY_IDENTITY_STATE" != "match" ] || echo "Token organization matches the account's saved OAuth profile."
1058
1081
  if [ "$rc" -eq 0 ]; then
1059
1082
  record_token_verified "$d" "$tok"
1060
1083
  echo "Token verified by a real inference."
@@ -1231,10 +1254,9 @@ TIP
1231
1254
 
1232
1255
  cmd_mint() {
1233
1256
  # The minted token is the ONLY credential that reaches the server and every peer, so a
1234
- # mint under the wrong browser session hands this slot another account's subscription —
1235
- # and nothing downstream can notice (a setup token reports no identity, see
1236
- # token_email). Naming the expected account before the ceremony opens is the only guard
1237
- # that exists; the old prompt said "THIS account" and named nobody.
1257
+ # mint under the wrong browser session can hand this slot another subscription. Name the
1258
+ # expected account before the ceremony and verify the token's response organization
1259
+ # against its saved OAuth profile before accepting it. The token cannot report an email.
1238
1260
  require_manifest
1239
1261
  local id="" paste=0
1240
1262
  while [ $# -gt 0 ]; do
@@ -1279,7 +1301,9 @@ cmd_mint() {
1279
1301
  clear_auth_markers "$d"
1280
1302
  log_to ops.log "mint $id"
1281
1303
  echo "Token saved to $d/server.token"
1282
- [ -n "$got" ] || warn "a setup token carries no identity — $id now runs whichever account approved that grant${email:+, trusted to be $email}"
1304
+ if [ -z "$got" ] && [ "$CEREMONY_IDENTITY_STATE" != "match" ]; then
1305
+ warn "the token's organization could not be checked — $id is trusted to hold ${email:-the named account}"
1306
+ fi
1283
1307
  auto_sync
1284
1308
  }
1285
1309
 
@@ -1314,7 +1338,9 @@ cmd_login() {
1314
1338
  fi
1315
1339
  commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
1316
1340
  clear_auth_markers "$d"
1317
- [ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
1341
+ if [ -z "$got" ] && [ "$CEREMONY_IDENTITY_STATE" != "match" ]; then
1342
+ warn "the token's organization could not be checked — $id is trusted to hold $email as requested"
1343
+ fi
1318
1344
  echo "$id token saved (portable — works on Mac and server)."
1319
1345
  else
1320
1346
  run_login_ceremony "$d" "$email" \
@@ -1546,6 +1572,26 @@ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
1546
1572
  return 0
1547
1573
  }
1548
1574
 
1575
+ token_identities_check() {
1576
+ # A source may always use Keychain OAuth locally while its peers use setup tokens.
1577
+ # Prove those tokens before distributing telemetry, even if nobody launched them here.
1578
+ local id d real
1579
+ real="$(find_real_claude "$_self")" || real=""
1580
+ for id in $(account_ids); do
1581
+ d="$ACC_ROOT/$id"
1582
+ [ -s "$d/server.token" ] || continue
1583
+ "$PYBIN" "$LIB_DIR/claude_token_identity.py" check "$d" --real "$real" >/dev/null 2>&1 || true
1584
+ done
1585
+ }
1586
+
1587
+ token_identity_receipt() { # Unknown results must never overwrite a peer's proven mismatch.
1588
+ local verdict
1589
+ [ -f "$1/.server-token-identity.json" ] || return 1
1590
+ verdict="$("$PYBIN" "$LIB_DIR/claude_token_identity.py" cached "$1" 2>/dev/null)"
1591
+ printf '%s' "$verdict" | grep -q '"cached": true' || return 1
1592
+ printf '%s' "$verdict" | grep -qE '"state": "(match|mismatch)"'
1593
+ }
1594
+
1549
1595
  limits_distribute_now() {
1550
1596
  local lock="$ACC_ROOT/tmp/limits-push.lock"
1551
1597
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
@@ -1570,6 +1616,7 @@ limits_distribute_now() {
1570
1616
  list="$ACC_ROOT/tmp/limits-push.$$"
1571
1617
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
1572
1618
  : > "$list" 2>/dev/null || return 0
1619
+ token_identities_check
1573
1620
  for id in $(account_ids); do
1574
1621
  d="$ACC_ROOT/$id"
1575
1622
  [ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
@@ -1577,6 +1624,7 @@ limits_distribute_now() {
1577
1624
  # .limited on its own, so a stale one costs an account some eligibility and
1578
1625
  # never grants any. Erring toward exclusion is the safe direction here.
1579
1626
  [ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
1627
+ token_identity_receipt "$d" && printf '%s/.server-token-identity.json\n' "$id" >> "$list"
1580
1628
  done
1581
1629
  # The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
1582
1630
  # add` reaches every peer within one limits cadence, with no explicit sync. The
@@ -1667,7 +1715,7 @@ cmd_limits() {
1667
1715
  import hashlib, json, os, sys, time, urllib.request
1668
1716
  sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
1669
1717
  import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
1670
- from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
1718
+ from audit import creds_doc_state, org_block_in_force # noqa: E402 (one rule for "can this credential work")
1671
1719
  import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
1672
1720
 
1673
1721
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
@@ -1764,11 +1812,12 @@ def weekly_marker(txt):
1764
1812
  def mark_expired(d, slug, detail=''):
1765
1813
  mpath = os.path.join(d, '.expired')
1766
1814
  # An org-blocked marker is the strongest statement there is about an account and
1767
- # only `verify` or a re-login may lift it (see clear_expired). Overwriting its
1768
- # REASON with a weaker one is how it gets lifted by accident: the next successful
1769
- # fetch sees a reason clear_expired is willing to drop, and the block vanishes.
1815
+ # telemetry can never lift it (see clear_expired). Overwriting its REASON with a
1816
+ # weaker one is how it gets lifted by accident: the next successful fetch sees a
1817
+ # reason clear_expired is willing to drop, and the block vanishes. One whose
1818
+ # soft_until has run out binds nothing any more and outranks nothing either.
1770
1819
  try:
1771
- if 'reason=org-blocked' in open(mpath, errors='replace').read():
1820
+ if org_block_in_force(open(mpath, errors='replace').read(), now):
1772
1821
  return
1773
1822
  except OSError:
1774
1823
  pass
@@ -1792,7 +1841,9 @@ def clear_expired(d):
1792
1841
  un-parked every dead-token account minutes after the shim parked it, and every
1793
1842
  fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
1794
1843
  token (the shim's newer-file rule) or a real call that used the token lifts it.
1795
- Only a passing `verify` (a real call) or a re-login lifts the excepted ones."""
1844
+ Only a real call lifts the excepted ones — a passing `verify`, or one the usage
1845
+ limiter refused (the limiter answers only past the credential and the org gate) —
1846
+ or a re-login; an org block also lapses at its soft_until (6 h)."""
1796
1847
  mpath = os.path.join(d, '.expired')
1797
1848
  try:
1798
1849
  body = open(mpath, errors='replace').read()
@@ -2740,8 +2791,11 @@ import hashlib, json, os, re, subprocess, sys, time
2740
2791
 
2741
2792
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
2742
2793
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
2743
- from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
2794
+ from audit import (audit_account, creds_state, creds_doc_state, # noqa: E402 (shared with the shim's rule)
2795
+ marker_slug, org_block_in_force)
2744
2796
  import keychain # noqa: E402
2797
+ from claude_token_identity import check_identity # noqa: E402
2798
+ from claude_reset import cli_version # noqa: E402
2745
2799
  machine = sys.argv[5]
2746
2800
  now = time.time()
2747
2801
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
@@ -2753,14 +2807,43 @@ AUTH_ERR = re.compile(
2753
2807
  r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
2754
2808
  r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
2755
2809
  r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
2810
+ # `ask your admin to enable ACCESS`, never the bare prefix: the same client ends a 404
2811
+ # model_not_found with "…or ask your admin to enable this model", which is about the
2812
+ # model and never about the organization (the real refusal still matches three ways).
2756
2813
  ORG_ERR = re.compile(
2757
2814
  r'organization has disabled|subscription access.*disabl|disabled claude subscription'
2758
- r'|ask your admin to enable|not authorized to use claude code', re.I)
2815
+ r'|ask your admin to enable access|not authorized to use claude code', re.I)
2816
+ # A USAGE-LIMIT refusal, tested after BROKEN_CLI and BEFORE ORG_ERR/AUTH_ERR — bin/claude's
2817
+ # rule "a rate limit wins the classification". It is also EXCULPATORY: the provider checks
2818
+ # the credential, then the organization's Claude Code gate, and only then the usage
2819
+ # limiter. acct-15 (2026-09-27) proved the order: already at weekly_all 100%, a real call
2820
+ # came back with the ORG refusal, and once the subscription was paid again the same
2821
+ # call came back "You've hit your weekly limit". So a limit refusal proves the credential
2822
+ # that answered authenticated AND its organization lets Claude Code through: verify lifts
2823
+ # a park exactly as a PASS would. Before this, that answer failed with no hint, the
2824
+ # org-blocked park stayed, and every re-check on that Mac kept a paid account BLOCKED.
2825
+ #
2826
+ # The shim's LIMITPAT/MODEL_LIMITPAT, minus what proves nothing about the account: the
2827
+ # shim only picks a cooldown over a park and can afford "overloaded"/529 there; here a
2828
+ # match LIFTS a park. The client's usage-limiter texts ("You've hit your weekly limit ·
2829
+ # resets …", "You've reached your Fable limit. Switch to another model…", "You're out of
2830
+ # usage credits") are covered; a bare 429 is not — a headerless 429 is the throttle the
2831
+ # client itself prints as "Server is temporarily limiting requests (not your usage
2832
+ # limit)", which can answer before any credential is looked at. NOT_A_LIMIT vetoes those
2833
+ # (that text contains "usage limit") and the overload family outright.
2834
+ LIMIT_ERR = re.compile(
2835
+ r"(hit|reached) your [^.\n]{0,40}(limit|budget)|out of usage|usage limit"
2836
+ r"|limit (reached|exceeded)|rate[ _-]?limit|switch to another model", re.I)
2837
+ NOT_A_LIMIT = re.compile(
2838
+ r'not your usage limit|temporarily limiting|overloaded|\b529\b|high load', re.I)
2839
+ # bin/claude PARK_SOFT_ORG: an org policy will not change in minutes, but a verdict that
2840
+ # never expires kept a paid-again account out of the pool until a person noticed.
2841
+ PARK_SOFT_ORG = 21600
2759
2842
  # EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
2760
2843
  # it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
2761
2844
  # machine fault whose output happens to contain those three digits parks a live
2762
- # login. `mark_expired` writes no `soft_until`, so that verdict is permanent
2763
- # until a person clears it. These patterns say the CLI never reached the API,
2845
+ # login. `mark_expired` writes no `soft_until` for an auth park, so that verdict is
2846
+ # permanent until a person clears it. These patterns say the CLI never reached the API,
2764
2847
  # which is a fact about the machine and never about the login.
2765
2848
  # See health.log 2026-08-24: `env: node: No such file or directory` failed
2766
2849
  # every account in one pass.
@@ -2776,23 +2859,60 @@ def mark_expired(d, slug, detail=''):
2776
2859
  """Park an account the shim must stop selecting. Verify is the strongest signal
2777
2860
  there is — a real inference call that came back 'not authenticated'."""
2778
2861
  mpath = os.path.join(d, '.expired')
2779
- # Same rule as the limits path: an org block outranks every other reason and only
2780
- # a PASSING verify or a re-login may lift it. Rewriting its reason with a weaker
2781
- # one is how it gets lifted by accident later.
2782
- try:
2783
- if 'reason=org-blocked' in open(mpath, errors='replace').read():
2784
- return
2785
- except OSError:
2786
- pass
2862
+ t = time.time()
2863
+ # Same rule as the limits path: an org block outranks every other reason while it
2864
+ # binds. Rewriting its reason with a weaker one is how it gets lifted by accident
2865
+ # later. Only the org refusal itself may rewrite it — and does: a re-observed block
2866
+ # is a fresh park with a fresh soft_until, not a stale one left to run out.
2867
+ if slug != 'org-blocked':
2868
+ try:
2869
+ if org_block_in_force(open(mpath, errors='replace').read(), t):
2870
+ return
2871
+ except OSError:
2872
+ pass
2873
+ # The shim's field order (bin/claude park_line), so every reader parses one shape.
2874
+ soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
2787
2875
  try:
2788
2876
  with open(mpath + '.tmp', 'w') as f:
2789
- f.write(f'{int(time.time())}\n')
2790
- f.write(f"reason={slug} marked_at="
2791
- f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
2877
+ f.write(f'{int(t)}\n')
2878
+ f.write(f"reason={slug} {soft}marked_at="
2879
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
2792
2880
  os.replace(mpath + '.tmp', mpath)
2793
2881
  except Exception:
2794
2882
  pass
2795
2883
 
2884
+ def lift_park(d, uses_token):
2885
+ """A real call reached the account: drop its park, and return the reason it had
2886
+ ('' when nothing was removed). The PASS rule, shared with a usage-limit refusal.
2887
+ WHICH credential answered matters: an answer on the OAuth login says nothing about
2888
+ a portable token a real call rejected; clearing that park re-opened the 401 loop
2889
+ the marker exists to stop (the shim re-picked the account, exported the dead
2890
+ token, parked it again — 2026-08-29)."""
2891
+ mpath = os.path.join(d, '.expired')
2892
+ try:
2893
+ marker = open(mpath, errors='replace').read()
2894
+ except OSError:
2895
+ marker = ''
2896
+ if not uses_token and 'reason=setup-token-invalid' in marker:
2897
+ return ''
2898
+ try:
2899
+ os.remove(mpath)
2900
+ except OSError:
2901
+ return ''
2902
+ return marker_slug(marker) or 'unlabelled'
2903
+
2904
+ def record_token_verified(d, token):
2905
+ """Proof that a real call authenticated with exactly this portable token."""
2906
+ try:
2907
+ digest = hashlib.sha256(token.encode()).hexdigest()
2908
+ temp = os.path.join(d, '.server-token-verified.tmp')
2909
+ with open(temp, 'w') as f:
2910
+ f.write(digest + '\n')
2911
+ os.chmod(temp, 0o600)
2912
+ os.replace(temp, os.path.join(d, '.server-token-verified'))
2913
+ except OSError:
2914
+ pass
2915
+
2796
2916
  for acct in manifest.get('accounts', []):
2797
2917
  aid = acct['id']
2798
2918
  d = os.path.join(root, aid)
@@ -2862,6 +2982,15 @@ for acct in manifest.get('accounts', []):
2862
2982
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
2863
2983
  t0 = time.time()
2864
2984
  uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
2985
+ identity = {'state': 'unknown'}
2986
+ if has_token:
2987
+ portable = env['CLAUDE_CODE_OAUTH_TOKEN'] if uses_token else open(tpath).read().strip()
2988
+ identity = check_identity(d, portable, version=cli_version(real))
2989
+ if uses_token and identity['state'] == 'mismatch':
2990
+ print(f'{aid} {acct["email"]}: FAIL (portable setup-token belongs to a different '
2991
+ f'organization — run: claude-accounts login {aid} --token)')
2992
+ failures += 1
2993
+ continue
2865
2994
  try:
2866
2995
  r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
2867
2996
  env=env, capture_output=True, text=True, timeout=240,
@@ -2874,44 +3003,44 @@ for acct in manifest.get('accounts', []):
2874
3003
  out = (r.stdout or '').strip()
2875
3004
  if r.returncode == 0 and 'ok' in out.lower():
2876
3005
  # A real call succeeded: this account is definitively alive — but WHICH
2877
- # credential answered matters. A pass on the OAuth login says nothing about a
2878
- # portable token a real call rejected; clearing that park here re-opened the
2879
- # 401 loop the marker exists to stop (the shim re-picked the account, exported
2880
- # the dead token, parked it again — 2026-08-29).
2881
- try:
2882
- marker = open(os.path.join(d, '.expired'), errors='replace').read()
2883
- except OSError:
2884
- marker = ''
2885
- if uses_token or 'reason=setup-token-invalid' not in marker:
2886
- try:
2887
- os.remove(os.path.join(d, '.expired'))
2888
- except OSError:
2889
- pass
3006
+ # credential answered matters (lift_park).
3007
+ lift_park(d, uses_token)
2890
3008
  if uses_token:
2891
- try:
2892
- digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
2893
- temp = os.path.join(d, '.server-token-verified.tmp')
2894
- with open(temp, 'w') as f:
2895
- f.write(digest + '\n')
2896
- os.chmod(temp, 0o600)
2897
- os.replace(temp, os.path.join(d, '.server-token-verified'))
2898
- except OSError:
2899
- pass
3009
+ record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
2900
3010
  print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
3011
+ if not uses_token and identity['state'] == 'mismatch':
3012
+ print(f' portable setup-token belongs to a different organization; OAuth login passed; '
3013
+ f'replace the token with: claude-accounts login {aid} --token')
2901
3014
  else:
2902
3015
  raw = (r.stderr or '').strip()
2903
3016
  err = raw[:200]
2904
3017
  hint = ''
3018
+ # The limiter's refusal is printed on STDOUT (health.log 2026-09-28: out="You've
3019
+ # hit your weekly limit · resets 1am (Europe/Istanbul)" err=''), so it is read
3020
+ # from both streams, whole.
3021
+ said = f'{out}\n{raw}'
2905
3022
  # STDERR only, and the WHOLE of it: stdout is the model's own answer,
2906
3023
  # and a session that merely discusses "Cannot find module" must not be
2907
3024
  # able to excuse a real dead login.
2908
3025
  if BROKEN_CLI.search(raw):
2909
3026
  hint = (' — the Claude CLI cannot run on this machine; not a login '
2910
3027
  'problem, nothing was parked')
3028
+ elif LIMIT_ERR.search(said) and not NOT_A_LIMIT.search(said):
3029
+ # Still a FAIL — the account cannot serve work right now, and the runner
3030
+ # counts it — but the usage limiter only answers a credential that
3031
+ # authenticated for an organization that lets Claude Code through (see
3032
+ # LIMIT_ERR), so this is the PASS path's lift, word for word.
3033
+ lifted = lift_park(d, uses_token)
3034
+ if uses_token:
3035
+ record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
3036
+ hint = ' — usage-limited, not a login or org problem'
3037
+ if lifted:
3038
+ hint += f'; lifted its {lifted} park'
2911
3039
  elif ORG_ERR.search(out) or ORG_ERR.search(err):
2912
3040
  # Not an auth problem: the account authenticates fine, its organization
2913
3041
  # has simply turned Claude Code subscription access off. Park it — a
2914
- # re-login changes nothing — and say what actually helps.
3042
+ # re-login changes nothing — and say what actually helps. The park carries
3043
+ # the shim's 6 h soft_until, and a re-observed block refreshes it.
2915
3044
  mark_expired(d, 'org-blocked',
2916
3045
  "the account's organization has disabled Claude Code access")
2917
3046
  hint = (f' — ORG BLOCKED, excluded from the pool; '
@@ -2985,7 +3114,8 @@ sync_push_target() {
2985
3114
  done
2986
3115
  # Advisory limit state for accounts the target may lack a bearer for.
2987
3116
  local extra
2988
- for extra in limits.json .limited; do
3117
+ for extra in limits.json .limited .server-token-identity.json; do
3118
+ if [ "$extra" = .server-token-identity.json ]; then token_identity_receipt "$d" || continue; fi
2989
3119
  if [ -f "$d/$extra" ]; then
2990
3120
  rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
2991
3121
  fi
@@ -3078,6 +3208,7 @@ cmd_sync() {
3078
3208
  sync_is_replica && role="replica"
3079
3209
  log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
3080
3210
  local_pool_fixup
3211
+ [ "$role" != source ] || token_identities_check
3081
3212
  if [ "$role" = "replica" ]; then
3082
3213
  echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
3083
3214
  else
@@ -3127,6 +3258,7 @@ EOF
3127
3258
  fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
3128
3259
  fi
3129
3260
 
3261
+ token_identities_check
3130
3262
  sync_push_target "$server" "$sroot" "$srepo"
3131
3263
  local npeers=0
3132
3264
  if [ -n "$peers" ]; then
package/bin/codex CHANGED
@@ -700,7 +700,8 @@ has_auth() {
700
700
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
701
701
  # a misread never costs an account permanently. Markers written by codex-accounts —
702
702
  # a refresh grant that answered invalid_grant, a failed real call — carry no soft_until
703
- # and stay until the account provably works again.
703
+ # and stay until the account provably works again; an org block is the exception and
704
+ # carries the shim's 6 h soft_until wherever it was written.
704
705
  expired_marked() { # $1 = acct dir
705
706
  local m="$1/.expired" mt soft reason
706
707
  [ -f "$m" ] || return 1
@@ -708,8 +709,8 @@ expired_marked() { # $1 = acct dir
708
709
  # CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
709
710
  # evidence they were about. A POLICY park (org-blocked: a workspace admin turned
710
711
  # Codex off for the account) is about the account, not the credential: refreshing
711
- # its token does not re-enable Codex, so only a passing real call or a re-login
712
- # lifts it.
712
+ # its token does not re-enable Codex, so only a passing real call, a re-login or
713
+ # its own soft_until lifts it.
713
714
  if [ "$reason" != "org-blocked" ]; then
714
715
  mt="$(file_mtime "$m")"
715
716
  if [ -f "$1/auth.json" ] && [ "$(file_mtime "$1/auth.json")" -gt "$mt" ]; then
@@ -1245,7 +1245,8 @@ def clear_expired(d):
1245
1245
  """A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
1246
1246
  EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
1247
1247
  they are just barred from Codex inference, so telemetry says nothing about them.
1248
- Only a passing `verify` (a real call) or a re-login lifts that."""
1248
+ Only a passing `verify` (a real call) or a re-login lifts that, or its own
1249
+ soft_until (6 h) runs out."""
1249
1250
  mpath = os.path.join(d, '.expired')
1250
1251
  try:
1251
1252
  if 'reason=org-blocked' in open(mpath, errors='replace').read():
@@ -1886,9 +1887,9 @@ ORG_ERR = re.compile(
1886
1887
  # API at all — a missing native package, a missing node, an unrunnable binary —
1887
1888
  # so they are facts about this MACHINE, not about the login. Parking on one
1888
1889
  # converts an indeterminate probe into "this login is dead", and that verdict
1889
- # does not self-heal: `mark_expired` here writes no `soft_until`, so the shim
1890
- # reads it as proven dead and keeps it, while the panel refuses to re-import
1891
- # the token until its fingerprint changes.
1890
+ # does not self-heal: `mark_expired` here writes no `soft_until` for an auth
1891
+ # park, so the shim reads it as proven dead and keeps it, while the panel
1892
+ # refuses to re-import the token until its fingerprint changes.
1892
1893
  #
1893
1894
  # Not hypothetical. health.log 2026-08-24T06:37:02Z: all four accounts
1894
1895
  # `FAIL rc=127 err='env: node: No such file or directory'` — one broken PATH
@@ -1907,15 +1908,24 @@ BROKEN_CLI = re.compile(
1907
1908
  r'|cannot execute binary file|bad CPU type in executable|exec format error',
1908
1909
  re.I | re.M)
1909
1910
 
1911
+ # bin/codex PARK_SOFT_ORG: a workspace policy will not change in minutes, but a verdict
1912
+ # that never expires keeps a re-enabled account out of the pool until a person notices
1913
+ # (claude side, acct-15, 2026-09-27: an org block outlived the org's own repair).
1914
+ PARK_SOFT_ORG = 21600
1915
+
1910
1916
  def mark_expired(d, slug, detail=''):
1911
1917
  """Park an account the shim must stop selecting. Verify is the strongest signal
1912
- there is — a real inference call that came back 'not authenticated'."""
1918
+ there is — a real inference call that came back 'not authenticated'. An org block
1919
+ carries the shim's soft_until, and a re-observed one is rewritten with a fresh one."""
1913
1920
  mpath = os.path.join(d, '.expired')
1921
+ t = time.time()
1922
+ # The shim's field order (bin/codex park line), so every reader parses one shape.
1923
+ soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
1914
1924
  try:
1915
1925
  with open(mpath + '.tmp', 'w') as f:
1916
- f.write(f'{int(time.time())}\n')
1917
- f.write(f"reason={slug} marked_at="
1918
- f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
1926
+ f.write(f'{int(t)}\n')
1927
+ f.write(f"reason={slug} {soft}marked_at="
1928
+ f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
1919
1929
  os.replace(mpath + '.tmp', mpath)
1920
1930
  except Exception:
1921
1931
  pass
@@ -33,7 +33,11 @@ claude-accounts relogin acct-01 --token # portable token instead (Mac + server
33
33
 
34
34
  `relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
35
35
  clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
36
- one at any time; a PASS clears the park automatically.
36
+ one at any time; a PASS clears the park automatically, and so does a call the usage
37
+ limiter refuses (`FAIL … — usage-limited, not a login or org problem; lifted its
38
+ org-blocked park`): the org's Claude Code gate answers before the limiter, so a limit
39
+ answer proves the org lets the account through. A `BLOCKED` park also lapses on its own
40
+ after 6 hours (`soft_until`), and the pool tries the account again.
37
41
 
38
42
  `relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
39
43
  the wrong email), one account at a time, and syncs to the server once at the end.
@@ -50,6 +50,14 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
50
50
  before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
51
51
  check every token immediately; `claude-accounts expired` reports token-only accounts
52
52
  as `UNVERIFIED` until that proof exists.
53
+ - **An account reads `BLOCKED` although its subscription is active again** — the park came
54
+ from a real call the org refused ("Your organization has disabled Claude subscription
55
+ access for Claude Code"), e.g. while a lapsed subscription was unpaid. Run
56
+ `claude-accounts verify`: a PASS lifts it, and so does a refusal from the usage limiter
57
+ (`FAIL … — usage-limited, not a login or org problem; lifted its org-blocked park`),
58
+ because the provider answers the org gate before the limiter. Without either, the park
59
+ lapses after its 6-hour `soft_until` and the pool tries the account again. The 404
60
+ "…or ask your admin to enable this model" is about a model, never an org block.
53
61
  - **A session restarted itself, or did not** — that is [auto-resume](AUTORESUME.md).
54
62
  `grep autoresume ~/.claude-accounts/selection.log` shows each step: `hold` means no
55
63
  other account had headroom, and `giveup` gives the reason nothing was relaunched. A