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
package/README.md CHANGED
@@ -126,7 +126,7 @@ later.
126
126
  **Unusable accounts are excluded, not rotated into.** An account that cannot serve a
127
127
  request is dropped from selection before anything else is considered — it is not eligible
128
128
  as the all-limited fallback either, because a limit-marked account is *degraded* while
129
- this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
129
+ this one is *down*, and handing work to it guarantees a hard failure. Reported states include:
130
130
 
131
131
  - **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
132
132
  came back *"OAuth session expired and could not be refreshed"*. Fixed by
@@ -135,6 +135,11 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
135
135
  prove inference works. Before a portable token carries its first real command on a
136
136
  machine, the shim privately verifies it. A 401 parks that exact account and reselects,
137
137
  so direct, TUI, and `--resume` calls do not expose the rejected token's failure.
138
+ - **Wrong token organization** (`TOKEN INVALID`) — an authenticated API response identifies
139
+ a different organization from the slot's saved OAuth profile. The shim excludes that
140
+ portable token, including on retries; a healthy local OAuth login remains usable.
141
+ Replace it with `claude-accounts login acct-NN --token` and sync. See
142
+ [token identity checks and recovery](docs/claude-token-identity.md).
138
143
  - **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
139
144
  *"Your organization has disabled Claude subscription access for Claude Code"*. Handled
140
145
  like any other dead login (`claude-accounts relogin` re-issues the grant and normally
@@ -142,7 +147,13 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
142
147
  Code for that account. Telemetry cannot see this — those accounts' usage endpoint
143
148
  answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
144
149
  park deliberately survives credential refreshes (a new access token says nothing about
145
- an org policy).
150
+ an org policy). Every org park carries a 6-hour `soft_until` (a re-observed block
151
+ renews it), and a `verify` that the **usage limiter** refuses ("You've hit your weekly
152
+ limit") lifts it at once: the provider checks the org's Claude Code gate before the
153
+ limiter, so a limit answer proves the org lets the account through (acct-15,
154
+ 2026-09-27: a lapsed subscription paid again stayed BLOCKED until this rule existed).
155
+ The line still reads `FAIL` — the account cannot work until the limit resets — with a
156
+ `usage-limited` note saying which park it lifted.
146
157
 
147
158
  `claude-accounts expired` lists what is excluded and also labels a token-only account
148
159
  `UNVERIFIED` until `claude-accounts verify` or the shim's first-use preflight proves it.
@@ -150,11 +161,13 @@ Exclusion self-heals, but only against the evidence it was based on: a **credent
150
161
  park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
151
162
  by another process — or when a usage fetch authenticates; a **policy** park (org block)
152
163
  ignores both, because a fresh access token says nothing about whether the org re-enabled
153
- Claude Code, and only a passing real call or a re-login lifts it. Parks the **shim**
154
- writes from a single failed run additionally carry a `soft_until` stamp and expire on
155
- their own — the shim's guess never outlives the evidence for it — while parks
156
- `claude-accounts` writes from proof (an `invalid_grant` refresh response, a failed real
157
- call) stay until the account works again. If *every* account is unusable the shim still
164
+ Claude Code: only a real call that got past the org gate (a passing `verify`, or one
165
+ refused only by a usage limit), a re-login, or its own 6-hour `soft_until` lifts it.
166
+ Parks the **shim** writes from a single failed run additionally carry a `soft_until`
167
+ stamp and expire on their own — the shim's guess never outlives the evidence for it —
168
+ while parks `claude-accounts` writes from proof of a dead credential (an `invalid_grant`
169
+ refresh response, a real call that failed to authenticate) stay until the account works
170
+ again. If *every* account is unusable the shim still
158
171
  fails open into stock `claude`, and logs why to `selection.log`.
159
172
 
160
173
  The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
package/bin/claude CHANGED
@@ -1581,8 +1581,10 @@ creds_dead() { # $1 = acct dir
1581
1581
  # A marker written by the SHIM (a guess from one failed run) also carries
1582
1582
  # `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
1583
1583
  # a misread never costs an account permanently. Markers written by claude-accounts —
1584
- # a refresh grant that answered "revoked", a real verify call — carry no soft_until and
1585
- # stay until the account provably works again.
1584
+ # a refresh grant that answered "revoked", a real verify call that failed to
1585
+ # authenticate — carry no soft_until and stay until the account provably works again.
1586
+ # An org block is the exception: verify's carries the shim's 6 h soft_until too, and a
1587
+ # verify the usage limiter refused lifts it (the limiter answers only past the org gate).
1586
1588
  expired_marked() { # $1 = acct dir
1587
1589
  local m="$1/.expired" mt f soft reason
1588
1590
  [ -f "$m" ] || return 1
@@ -1646,6 +1648,12 @@ expired_marked() { # $1 = acct dir
1646
1648
  # OAuth credential beside it is dead — the token is exported instead.
1647
1649
  auth_dead() { # $1 = acct dir
1648
1650
  expired_marked "$1" && return 0
1651
+ if [ -s "$1/server.token" ] && { ! has_oauth "$1" || creds_dead "$1"; }; then
1652
+ case ":${CLAUDE_MULTIACC_PREFLIGHT_SKIP:-}:" in *":${1##*/}:"*) return 0 ;; esac
1653
+ if [ -f "$1/.server-token-identity.json" ]; then
1654
+ [ "$(token_identity_state cached "$1")" = mismatch ] && return 0
1655
+ fi
1656
+ fi
1649
1657
  [ -s "$1/server.token" ] && return 1
1650
1658
  creds_dead "$1"
1651
1659
  }
@@ -1658,10 +1666,21 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
1658
1666
  fi
1659
1667
  }
1660
1668
 
1661
- # A setup-token has no identity or readable expiry, and `claude auth status` only
1662
- # checks its shape. Test each token once on this machine with a real inference before
1663
- # allowing it to carry a user's command. This closes the direct/TUI/--resume gap where
1664
- # a revoked token exposed one 401 and was only parked on the following invocation.
1669
+ # auth status cannot identify a setup-token, but authenticated API response headers
1670
+ # name its organization, even on a quota rejection. Check that binding independently
1671
+ # of the old inference proof: a token can answer OK while spending another account.
1672
+ token_identity_state() { # $1 check|cached, $2 account dir, $3 exact token for check
1673
+ local helper="$SELF_DIR/../lib/claude_token_identity.py" py="${CLAUDE_MULTIACC_PYTHON:-python3}"
1674
+ [ -r "$helper" ] || return 0
1675
+ if [ "$1" = check ]; then
1676
+ printf '%s' "${3:-}" | "$py" "$helper" check "$2" --token-stdin --real "$REAL" --format state 2>/dev/null
1677
+ else
1678
+ "$py" "$helper" cached "$2" --format state 2>/dev/null
1679
+ fi
1680
+ }
1681
+
1682
+ # Test authentication independently; organization matching cannot prove a token is
1683
+ # still valid. The token and identity receipts are both bound to its exact digest.
1665
1684
  token_digest() { # $1 token; prints a non-secret sha256 digest
1666
1685
  local h=""
1667
1686
  if command -v shasum >/dev/null 2>&1; then
@@ -1672,8 +1691,16 @@ token_digest() { # $1 token; prints a non-secret sha256 digest
1672
1691
  printf '%s' "$h" | cut -d ' ' -f1
1673
1692
  }
1674
1693
 
1675
- token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid auth
1694
+ TOKEN_PREFLIGHT_FAILURE=""
1695
+ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven unusable auth
1676
1696
  local d="$1" tok="$2" digest marker tmpd token_auth_pat rc=0
1697
+ TOKEN_PREFLIGHT_FAILURE="invalid portable OAuth token (401)"
1698
+ if [ "$(token_identity_state check "$d" "$tok")" = mismatch ]; then
1699
+ TOKEN_PREFLIGHT_FAILURE="portable setup-token for a different organization; run: "
1700
+ TOKEN_PREFLIGHT_FAILURE="${TOKEN_PREFLIGHT_FAILURE}claude-accounts login ${d##*/} --token"
1701
+ sel_log "${d##*/} skipped (setup-token identity mismatch)"
1702
+ return 1
1703
+ fi
1677
1704
  digest="$(token_digest "$tok")"
1678
1705
  [ -n "$digest" ] || return 0
1679
1706
  marker="$d/.server-token-verified"
@@ -1723,8 +1750,8 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1723
1750
  tok="$(acct_token "$d")"
1724
1751
  if [ -n "$tok" ]; then
1725
1752
  if ! token_preflight "$d" "$tok"; then
1726
- printf 'claude-multiacc: pinned account %s has an invalid portable OAuth token (401)\n' \
1727
- "$CLAUDE_ACCOUNT" >&2
1753
+ printf 'claude-multiacc: pinned account %s has a %s\n' \
1754
+ "$CLAUDE_ACCOUNT" "$TOKEN_PREFLIGHT_FAILURE" >&2
1728
1755
  exit 1
1729
1756
  fi
1730
1757
  export CLAUDE_CODE_OAUTH_TOKEN="$tok"
@@ -2058,13 +2085,22 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
2058
2085
  num_ok "$preflight_depth" || preflight_depth=0
2059
2086
  if [ "$preflight_depth" -lt 64 ]; then
2060
2087
  export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
2088
+ # Persistence can fail on a read-only pool. Exclude this token for the re-entry
2089
+ # anyway, and never fall through into a token the preflight just rejected.
2090
+ preflight_skip="${CLAUDE_MULTIACC_PREFLIGHT_SKIP:+$CLAUDE_MULTIACC_PREFLIGHT_SKIP:}"
2091
+ export CLAUDE_MULTIACC_PREFLIGHT_SKIP="${preflight_skip}${pick##*/}"
2061
2092
  # An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
2062
2093
  # outside every chain field's charset, so it separates them unambiguously.
2063
2094
  [ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
2064
2095
  exec "$SELF" "$@"
2065
2096
  fi
2097
+ printf 'claude-multiacc: portable token preflight retry limit reached\n' >&2
2098
+ exit 1
2066
2099
  fi
2067
- unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
2100
+ unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH CLAUDE_MULTIACC_PREFLIGHT_SKIP
2101
+ # Use the exact token that passed preflight. A concurrent mint/sync may replace the
2102
+ # file before exec; rereading it here would run an unchecked credential.
2103
+ PICK_TOKEN="$tok"
2068
2104
 
2069
2105
  # The picked account may be eligible only because its exhausted bucket belongs to a
2070
2106
  # model this run did not ask for. Handing it the default model anyway would open the
@@ -2165,7 +2201,7 @@ export CLAUDE_SHIM_ACTIVE=1
2165
2201
  # mirrored to every other one (exits inside); anything else falls through to exec.
2166
2202
  if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
2167
2203
  export CLAUDE_CONFIG_DIR="$pick"
2168
- tok="$(acct_token "$pick")"
2204
+ tok="$PICK_TOKEN"
2169
2205
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2170
2206
  mcp_mirror_run "$pick" "$@" || true
2171
2207
  fi
@@ -2197,7 +2233,7 @@ fi
2197
2233
 
2198
2234
  if [ "$wants_retry" = "0" ]; then
2199
2235
  export CLAUDE_CONFIG_DIR="$pick"
2200
- tok="$(acct_token "$pick")"
2236
+ tok="$PICK_TOKEN"
2201
2237
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2202
2238
  sel_capture_session "$pick"
2203
2239
  ar_trust "$pick"
@@ -2209,7 +2245,7 @@ fi
2209
2245
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
2210
2246
  tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
2211
2247
  export CLAUDE_CONFIG_DIR="$pick"
2212
- tok="$(acct_token "$pick")"
2248
+ tok="$PICK_TOKEN"
2213
2249
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2214
2250
  sel_capture_session "$pick"
2215
2251
  exec "$REAL" "$@"
@@ -2221,7 +2257,7 @@ trap 'rm -rf "$tmpd"' EXIT
2221
2257
  # nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
2222
2258
  if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
2223
2259
  export CLAUDE_CONFIG_DIR="$pick"
2224
- tok="$(acct_token "$pick")"
2260
+ tok="$PICK_TOKEN"
2225
2261
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2226
2262
  sel_capture_session "$pick"
2227
2263
  exec "$REAL" "$@"
@@ -2242,11 +2278,13 @@ fi
2242
2278
  # PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
2243
2279
  # is parked until a re-login / a fetch that authenticates clears it.
2244
2280
  # PARK_ORG — the account's organization turned Claude Code access off; no re-login
2245
- # fixes that.
2281
+ # fixes that. "ask your admin to enable ACCESS", never the bare prefix:
2282
+ # the same client ends a 404 model_not_found with "…or ask your admin to
2283
+ # enable this model", which says nothing about the organization.
2246
2284
  # Both shim-written parks carry a soft_until stamp, so even a false positive returns to
2247
2285
  # the pool on its own — the shim's guess must never outlive the evidence for it.
2248
2286
  PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
2249
- PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
2287
+ PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable access|not authorized to use claude code'
2250
2288
  # A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
2251
2289
  # Switch to another model, or manage usage credits..." — the account still has
2252
2290
  # capacity for every other model, and the endpoint says so in the message itself.
@@ -2306,6 +2344,7 @@ stream_reported_error() {
2306
2344
 
2307
2345
  attempt=1
2308
2346
  cur="$pick"
2347
+ cur_token="$PICK_TOKEN"
2309
2348
  rc=0
2310
2349
  stream_err=0 # the run exited 0 but the stream said otherwise
2311
2350
  rotated=0 # at most one account rotation, exactly as before
@@ -2315,7 +2354,7 @@ while :; do
2315
2354
  # Per ATTEMPT: a stream error seen on an earlier account must never decide the
2316
2355
  # exit status of a later one that failed for its own, real reason.
2317
2356
  stream_err=0
2318
- tok="$(acct_token "$cur")"
2357
+ tok="$cur_token"
2319
2358
  if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
2320
2359
  if [ -n "$tok" ]; then
2321
2360
  CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
@@ -2391,14 +2430,22 @@ while :; do
2391
2430
  n="${#eligible[@]}"
2392
2431
  start=$((RANDOM % n))
2393
2432
  i=0
2394
- while [ "$i" -lt "$n" ]; do
2433
+ while [ "$rotated" = 0 ] && [ "$i" -lt "$n" ]; do
2395
2434
  c="${eligible[$(((start + i) % n))]}"
2396
- if [ "$c" != "$cur" ]; then next="$c"; break; fi
2435
+ if [ "$c" != "$cur" ] && ! auth_dead "$c"; then
2436
+ candidate_token="$(acct_token "$c")"
2437
+ if [ -z "$candidate_token" ] || token_preflight "$c" "$candidate_token"; then
2438
+ next="$c"
2439
+ next_token="$candidate_token"
2440
+ break
2441
+ fi
2442
+ fi
2397
2443
  i=$((i+1))
2398
2444
  done
2399
2445
  if [ -n "$next" ] && [ "$rotated" = 0 ]; then
2400
2446
  sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
2401
2447
  cur="$next"
2448
+ cur_token="$next_token"
2402
2449
  # The account that actually serves the work is the one the next run should rotate
2403
2450
  # away from — not the one that bounced.
2404
2451
  remember_pick "$cur"