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.
- package/README.md +20 -7
- package/bin/claude +66 -19
- package/bin/claude-accounts +186 -54
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -0
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +42 -4
- package/lib/claude_token_identity.py +233 -0
- package/lib/codex_audit.py +8 -1
- package/lib/common.sh +3 -1
- package/lib/credential.py +9 -2
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +254 -3
- package/tests/test_autoresume.py +36 -5
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_token_identity_ceremony.py +167 -0
- package/tests/test_token_identity_integration.py +207 -0
- package/tests/test_token_identity_retry.py +95 -0
- 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.
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
#
|
|
1662
|
-
#
|
|
1663
|
-
#
|
|
1664
|
-
|
|
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
|
-
|
|
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
|
|
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="$
|
|
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="$
|
|
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="$
|
|
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="$
|
|
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="$
|
|
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" ]
|
|
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"
|