claude-multiacc 2.0.45 → 2.0.47
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 +29 -23
- package/bin/claude +48 -31
- package/bin/claude-accounts +30 -16
- package/bin/codex +14 -1
- package/docs/ACCOUNT_OPERATIONS.md +13 -9
- package/docs/AUTORESUME.md +14 -1
- package/docs/VERIFICATION.md +5 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.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 +8 -26
- package/lib/autoresume.py +12 -7
- package/lib/claude_client_error.py +81 -0
- package/lib/common.sh +11 -0
- 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 +69 -38
- package/tests/test_autoresume.py +22 -3
- package/tests/test_claude_client_error.py +119 -0
- package/tests/test_claude_org_block.py +277 -0
package/README.md
CHANGED
|
@@ -141,17 +141,15 @@ this one is *down*, and handing work to it guarantees a hard failure. Reported s
|
|
|
141
141
|
Replace it with `claude-accounts login acct-NN --token` and sync. See
|
|
142
142
|
[token identity checks and recovery](docs/claude-token-identity.md).
|
|
143
143
|
- **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
|
|
144
|
-
*"Your organization has disabled Claude subscription access for Claude Code"*.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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).
|
|
144
|
+
*"Your organization has disabled Claude subscription access for Claude Code"*. The
|
|
145
|
+
shim also detects `oauth_org_not_allowed` in that account's interactive transcript.
|
|
146
|
+
An admin must enable Claude Code access, or restore the subscription, then run
|
|
147
|
+
`claude-accounts verify`. Usage telemetry cannot see this policy, so the account stays
|
|
148
|
+
excluded until a real verification call proves access. A PASS lifts the park, and so
|
|
149
|
+
does a `verify` that the **usage limiter** refuses ("You've hit your weekly limit"):
|
|
150
|
+
the provider checks the org's Claude Code gate before the limiter, so a limit answer
|
|
151
|
+
proves the org lets the account through (acct-15, 2026-09-27: a lapsed subscription
|
|
152
|
+
paid again stayed BLOCKED until this rule existed).
|
|
155
153
|
The line still reads `FAIL` — the account cannot work until the limit resets — with a
|
|
156
154
|
`usage-limited` note saying which park it lifted.
|
|
157
155
|
|
|
@@ -161,13 +159,14 @@ Exclusion self-heals, but only against the evidence it was based on: a **credent
|
|
|
161
159
|
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
|
|
162
160
|
by another process — or when a usage fetch authenticates; a **policy** park (org block)
|
|
163
161
|
ignores both, because a fresh access token says nothing about whether the org re-enabled
|
|
164
|
-
Claude Code
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
Claude Code. Re-login, token replacement and elapsed time also leave a Claude org block
|
|
163
|
+
intact, including old markers whose `soft_until` has passed. Only full
|
|
164
|
+
`claude-accounts verify` proving access, normally or with a usage-limit refusal, lifts it.
|
|
165
|
+
Recovery writes `.client-org-cleared` so old transcript errors cannot recreate the park.
|
|
166
|
+
OAuth-login parks the **shim** guesses from a single failed run carry a `soft_until` stamp and
|
|
167
|
+
expire on their own, while parks `claude-accounts` writes from proof of a dead credential
|
|
168
|
+
(an `invalid_grant` refresh response, a real call that failed to authenticate) stay until
|
|
169
|
+
the account works again. If *every* account is unusable the shim still
|
|
171
170
|
fails open into stock `claude`, and logs why to `selection.log`.
|
|
172
171
|
|
|
173
172
|
The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
|
|
@@ -345,7 +344,13 @@ wrongly parked account is worse than a missed limit:
|
|
|
345
344
|
hostile or unreadable index entry is skipped; `CLAUDE_MULTIACC_CLIENT_LIMITS=0` turns
|
|
346
345
|
the whole scan off.
|
|
347
346
|
|
|
348
|
-
The
|
|
347
|
+
The same ownership and handover checks detect Claude's interactive
|
|
348
|
+
`oauth_org_not_allowed` refusal and persist an org-blocked `.expired` marker before the
|
|
349
|
+
next selection. It has no automatic expiry; full `claude-accounts verify` must prove
|
|
350
|
+
access after the organization enables it. `.client-org-cleared` suppresses transcript
|
|
351
|
+
errors recorded before that verification.
|
|
352
|
+
|
|
353
|
+
The resulting usage marker is tagged `reason=client-rate-limit` and outlives a clean telemetry
|
|
349
354
|
pass while its window is still open — first-hand evidence beats a usage payload that
|
|
350
355
|
disagrees. A **weekly** rejection is absolute: nothing — neither a shim invocation nor the
|
|
351
356
|
scheduled `limits` pass — retires it before its reset epoch, since a weekly bucket cannot
|
|
@@ -415,10 +420,11 @@ handing the link back would hand the damage back.
|
|
|
415
420
|
auth- or rate-limit-looking failure the shim retries once on a different account and
|
|
416
421
|
marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
|
|
417
422
|
or an **`.expired`** park for an authentication failure / org block (they do not). The
|
|
418
|
-
park patterns are deliberately narrow
|
|
419
|
-
|
|
420
|
-
an account
|
|
421
|
-
|
|
423
|
+
park patterns are deliberately narrow, because this grep also sees the model's own
|
|
424
|
+
answer: a `-p` run that merely *mentions* a 403 must never cost
|
|
425
|
+
an account. Authentication parks carry a `soft_until`; Claude org blocks persist until
|
|
426
|
+
full verification proves access. Output is buffered so a retried call never double-emits.
|
|
427
|
+
Only engages when stdin is finite (tty / regular file / `/dev/null`)
|
|
422
428
|
and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
|
|
423
429
|
Interactive sessions are not retried this way. They get auto-resume, described next.
|
|
424
430
|
|
package/bin/claude
CHANGED
|
@@ -356,7 +356,7 @@ ar_relaunch_load() { # $1 token -> 0 with AR_ARGV and the AR_R_*/chain fields se
|
|
|
356
356
|
class) case "$v" in ''|*[!a-z_]*) ;; *) cls="$v" ;; esac ;;
|
|
357
357
|
acct) case "$v" in acct-*[!0-9]*|acct-) ;; acct-*) acct="$v" ;; esac ;;
|
|
358
358
|
reset) num_ok "$v" && reset="$v" ;;
|
|
359
|
-
rtype) case "$v" in five_hour|seven_day) rtype="$v" ;; esac ;;
|
|
359
|
+
rtype) case "$v" in five_hour|seven_day|oauth_org_not_allowed) rtype="$v" ;; esac ;;
|
|
360
360
|
marked) case "$v" in ''|*[!0-9TZ:.+-]*) ;; *) marked="$v" ;; esac ;;
|
|
361
361
|
sid) case "$v" in ''|-*|*[!0-9a-fA-F-]*) ;; *) sid="$v" ;; esac ;;
|
|
362
362
|
cwd) case "$v" in /*) cwd="$v" ;; esac ;;
|
|
@@ -422,7 +422,7 @@ ar_token_dead() { # $1 session id (the token's, else the file's) or empty
|
|
|
422
422
|
# The verdict the watcher saw, written through the SAME writers the transcript scans use
|
|
423
423
|
# (never shortening a longer park), so the old account is out of the pool before this
|
|
424
424
|
# very selection runs — and for every other launch too. Other classes (model-scoped,
|
|
425
|
-
# transient,
|
|
425
|
+
# transient, billing/hold, crash) leave no marker: the watcher's AVOID list covers them.
|
|
426
426
|
ar_relaunch_apply() {
|
|
427
427
|
local d=""
|
|
428
428
|
[ "$AR_LOADED" = 1 ] || return 0
|
|
@@ -434,6 +434,10 @@ ar_relaunch_apply() {
|
|
|
434
434
|
mark_client_limit "$d" "$AR_R_RESET" "$AR_R_RTYPE" "$AR_R_MARKED"
|
|
435
435
|
fi ;;
|
|
436
436
|
auth) mark_client_auth_dead "$d" ;;
|
|
437
|
+
blocked)
|
|
438
|
+
if [ "$AR_R_RTYPE" = "oauth_org_not_allowed" ]; then
|
|
439
|
+
mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
|
|
440
|
+
fi ;;
|
|
437
441
|
esac
|
|
438
442
|
fi
|
|
439
443
|
# Field 2 is the word `autoresume`, never an account id: lib/report.py and the
|
|
@@ -1428,13 +1432,21 @@ client_limit_scan() { # $1 acct dir
|
|
|
1428
1432
|
return 1
|
|
1429
1433
|
}
|
|
1430
1434
|
|
|
1431
|
-
# A TUI
|
|
1432
|
-
#
|
|
1433
|
-
#
|
|
1434
|
-
|
|
1435
|
-
|
|
1435
|
+
# A TUI rejection cannot use the -p retry path because the client owns the terminal.
|
|
1436
|
+
# Read only structured API errors belonging to this account's session claim. The JSON
|
|
1437
|
+
# helper runs only when the bounded grep finds an auth or organization error code.
|
|
1438
|
+
CLIENT_ERROR_REASON=""
|
|
1439
|
+
client_error_scan() { # $1 acct dir; sets CLIENT_ERROR_REASON
|
|
1440
|
+
local idx="$1/.sessions-index" ln id claim p line read_n=0 i cleared=0 parser auth=""
|
|
1436
1441
|
local ids=() claims=()
|
|
1442
|
+
CLIENT_ERROR_REASON=""
|
|
1443
|
+
{ [ -L "$1" ] || [ -L "$1/sessions" ]; } && return 1
|
|
1444
|
+
parser="$SELF_DIR/../lib/claude_client_error.py"
|
|
1445
|
+
[ -f "$parser" ] || return 1
|
|
1446
|
+
[ -L "$idx" ] && return 1
|
|
1437
1447
|
[ -f "$idx" ] || return 1
|
|
1448
|
+
[ -f "$1/.client-org-cleared" ] \
|
|
1449
|
+
&& { IFS= read -r cleared < "$1/.client-org-cleared" 2>/dev/null || cleared=0; }
|
|
1438
1450
|
while IFS= read -r ln; do
|
|
1439
1451
|
id="${ln%% *}"; claim=""
|
|
1440
1452
|
case "$ln" in *' '*) claim="${ln#* }" ;; esac
|
|
@@ -1446,24 +1458,31 @@ client_auth_scan() { # $1 acct dir
|
|
|
1446
1458
|
sess_transcript "$1" "$id" || continue
|
|
1447
1459
|
p="$SESS_TRANSCRIPT"
|
|
1448
1460
|
[ $((now - $(file_mtime "$p"))) -le "$QUOTA_SCAN_MAX_AGE" ] || continue
|
|
1461
|
+
[ -n "$claim" ] || continue
|
|
1449
1462
|
line="$(tail -c "$QUOTA_SCAN_BYTES" "$p" 2>/dev/null \
|
|
1450
|
-
| LC_ALL=C grep -
|
|
1463
|
+
| LC_ALL=C grep -aE '"error"[[:space:]]*:[[:space:]]*"(authentication_failed|oauth_org_not_allowed)"')"
|
|
1451
1464
|
[ -n "$line" ] || continue
|
|
1452
|
-
[ -n "$claim" ] || continue
|
|
1453
|
-
ts="$(printf '%s' "$line" | LC_ALL=C sed -n 's/.*"timestamp"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1454
|
-
ck="$(iso_key "$ts")"; ak="$(iso_key "$claim")"
|
|
1455
|
-
num_ok "$ck" || continue; num_ok "$ak" || continue
|
|
1456
|
-
[ "$ck" -lt "$ak" ] && continue
|
|
1457
1465
|
claim_conflicted "$1" "$id" "$claim" && continue
|
|
1458
|
-
|
|
1466
|
+
line="$(printf '%s\n' "$line" | "${CLAUDE_MULTIACC_PYTHON:-python3}" -I "$parser" \
|
|
1467
|
+
--claim "$claim" --org-cleared "$cleared" 2>/dev/null)" || continue
|
|
1468
|
+
case "$line" in
|
|
1469
|
+
org-blocked\ *) CLIENT_ERROR_REASON="org-blocked"; return 0 ;;
|
|
1470
|
+
auth\ *) auth=1 ;;
|
|
1471
|
+
esac
|
|
1459
1472
|
done
|
|
1473
|
+
[ -n "$auth" ] && { CLIENT_ERROR_REASON="auth"; return 0; }
|
|
1460
1474
|
return 1
|
|
1461
1475
|
}
|
|
1462
1476
|
|
|
1463
1477
|
mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reason slug
|
|
1464
|
-
local marked slug="${3:-auth-error}"
|
|
1478
|
+
local marked slug="${3:-auth-error}" soft="${4:-0}" stamp=""
|
|
1479
|
+
if [ "$slug" != "org-blocked" ] \
|
|
1480
|
+
&& grep -q 'reason=org-blocked' "$1/.expired" 2>/dev/null; then
|
|
1481
|
+
return 0
|
|
1482
|
+
fi
|
|
1483
|
+
num_ok "$soft" && [ "$soft" -gt 0 ] && stamp="soft_until=$soft "
|
|
1465
1484
|
marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
1466
|
-
{ echo "$now"; echo "reason=$slug marked_at=$marked detail=$2"; } \
|
|
1485
|
+
{ echo "$now"; echo "reason=$slug ${stamp}marked_at=$marked detail=$2"; } \
|
|
1467
1486
|
2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
|
|
1468
1487
|
|| rm -f "$1/.expired.$$" 2>/dev/null || true
|
|
1469
1488
|
}
|
|
@@ -1583,12 +1602,13 @@ creds_dead() { # $1 = acct dir
|
|
|
1583
1602
|
# a misread never costs an account permanently. Markers written by claude-accounts —
|
|
1584
1603
|
# a refresh grant that answered "revoked", a real verify call that failed to
|
|
1585
1604
|
# authenticate — carry no soft_until and stay until the account provably works again.
|
|
1586
|
-
# An org block
|
|
1587
|
-
#
|
|
1605
|
+
# An org block stays until verify proves access, including a usage-limiter refusal.
|
|
1606
|
+
# Time and credential refreshes cannot prove the organization's policy changed.
|
|
1588
1607
|
expired_marked() { # $1 = acct dir
|
|
1589
1608
|
local m="$1/.expired" mt f soft reason
|
|
1590
1609
|
[ -f "$m" ] || return 1
|
|
1591
1610
|
reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
1611
|
+
[ "$reason" = "org-blocked" ] && return 0
|
|
1592
1612
|
if [ "$reason" = "setup-token-invalid" ]; then
|
|
1593
1613
|
# A TOKEN-scoped park: it says the portable token is dead and nothing about the
|
|
1594
1614
|
# login beside it. Only a NEW token heals it — a login refresh rewrites
|
|
@@ -1781,8 +1801,13 @@ for d in "$ACC_ROOT"/acct-*; do
|
|
|
1781
1801
|
fi
|
|
1782
1802
|
valid+=("$d")
|
|
1783
1803
|
sess_index_refresh "$d"
|
|
1784
|
-
if
|
|
1785
|
-
|
|
1804
|
+
if client_error_scan "$d"; then
|
|
1805
|
+
if [ "$CLIENT_ERROR_REASON" = "org-blocked" ]; then
|
|
1806
|
+
mark_proven_auth_dead "$d" "organization disabled Claude Code subscription access" "org-blocked"
|
|
1807
|
+
sel_log "${d##*/} parked (org-blocked) — client-reported"
|
|
1808
|
+
else
|
|
1809
|
+
mark_client_auth_dead "$d"
|
|
1810
|
+
fi
|
|
1786
1811
|
valid=("${valid[@]:0:${#valid[@]}-1}")
|
|
1787
1812
|
expired+=("$d")
|
|
1788
1813
|
continue
|
|
@@ -2281,8 +2306,7 @@ fi
|
|
|
2281
2306
|
# fixes that. "ask your admin to enable ACCESS", never the bare prefix:
|
|
2282
2307
|
# the same client ends a 404 model_not_found with "…or ask your admin to
|
|
2283
2308
|
# enable this model", which says nothing about the organization.
|
|
2284
|
-
#
|
|
2285
|
-
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
2309
|
+
# Credential guesses carry a soft expiry; an organization refusal needs verified recovery.
|
|
2286
2310
|
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'
|
|
2287
2311
|
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'
|
|
2288
2312
|
# A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
|
|
@@ -2330,7 +2354,6 @@ argv_set_model() { # $1 = model id
|
|
|
2330
2354
|
}
|
|
2331
2355
|
|
|
2332
2356
|
PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
|
|
2333
|
-
PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
|
|
2334
2357
|
|
|
2335
2358
|
# True when the tail of stdout carries a stream-json result marked is_error. Bounded
|
|
2336
2359
|
# to the tail because that is where the final object lands, and because a task log
|
|
@@ -2387,7 +2410,6 @@ while :; do
|
|
|
2387
2410
|
elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
2388
2411
|
park_reason="org-blocked"
|
|
2389
2412
|
park_detail="the account's organization has disabled Claude Code subscription access"
|
|
2390
|
-
park_soft=$((now + PARK_SOFT_ORG))
|
|
2391
2413
|
elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
2392
2414
|
if [ -n "$tok" ]; then
|
|
2393
2415
|
park_reason="setup-token-invalid"
|
|
@@ -2406,14 +2428,9 @@ while :; do
|
|
|
2406
2428
|
if [ -n "$park_reason" ]; then
|
|
2407
2429
|
if [ "$park_reason" = "setup-token-invalid" ]; then
|
|
2408
2430
|
rm -f "$cur/.server-token-verified" 2>/dev/null || true
|
|
2409
|
-
park_line="reason=$park_reason marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
2410
|
-
else
|
|
2411
|
-
park_line="reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
2412
2431
|
fi
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
2416
|
-
if [ "$park_reason" = "setup-token-invalid" ]; then
|
|
2432
|
+
mark_proven_auth_dead "$cur" "$park_detail" "$park_reason" "$park_soft"
|
|
2433
|
+
if [ "$park_reason" = "setup-token-invalid" ] || [ "$park_reason" = "org-blocked" ]; then
|
|
2417
2434
|
sel_log "$(basename "$cur") parked ($park_reason, durable) — see: claude-accounts expired"
|
|
2418
2435
|
else
|
|
2419
2436
|
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
|
package/bin/claude-accounts
CHANGED
|
@@ -1401,7 +1401,7 @@ cmd_expired() {
|
|
|
1401
1401
|
return 1
|
|
1402
1402
|
fi
|
|
1403
1403
|
printf '%s\n' "$rows" | awk -F'\t' '
|
|
1404
|
-
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0 }
|
|
1404
|
+
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0; blocked = 0 }
|
|
1405
1405
|
NF < 4 || $1 == "" { next } # never invent an account from a blank line
|
|
1406
1406
|
$4 == "ok" { ok++; next }
|
|
1407
1407
|
$4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
|
|
@@ -1419,6 +1419,13 @@ cmd_expired() {
|
|
|
1419
1419
|
printf " %-9s %-28s %-13s fix: %s\n", "", "", "", $7
|
|
1420
1420
|
next
|
|
1421
1421
|
}
|
|
1422
|
+
$4 == "blocked" {
|
|
1423
|
+
bad++
|
|
1424
|
+
blocked++
|
|
1425
|
+
printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
|
|
1426
|
+
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
1427
|
+
next
|
|
1428
|
+
}
|
|
1422
1429
|
{
|
|
1423
1430
|
bad++
|
|
1424
1431
|
relogin++
|
|
@@ -1440,6 +1447,10 @@ cmd_expired() {
|
|
|
1440
1447
|
printf "\nReplace invalid portable setup-tokens:\n"
|
|
1441
1448
|
printf " claude-accounts login acct-NN --token\n"
|
|
1442
1449
|
}
|
|
1450
|
+
if (blocked > 0) {
|
|
1451
|
+
printf "\nEnable Claude Code subscription access through the organization admin, then verify:\n"
|
|
1452
|
+
printf " claude-accounts verify\n"
|
|
1453
|
+
}
|
|
1443
1454
|
if (relogin > 0) {
|
|
1444
1455
|
printf "\nRe-authenticate them:\n"
|
|
1445
1456
|
printf " claude-accounts relogin # every account that needs it\n"
|
|
@@ -1814,8 +1825,8 @@ def mark_expired(d, slug, detail=''):
|
|
|
1814
1825
|
# An org-blocked marker is the strongest statement there is about an account and
|
|
1815
1826
|
# telemetry can never lift it (see clear_expired). Overwriting its REASON with a
|
|
1816
1827
|
# 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.
|
|
1818
|
-
#
|
|
1828
|
+
# reason clear_expired is willing to drop, and the block vanishes. Legacy soft
|
|
1829
|
+
# expiry stamps cannot prove organization access was enabled again either.
|
|
1819
1830
|
try:
|
|
1820
1831
|
if org_block_in_force(open(mpath, errors='replace').read(), now):
|
|
1821
1832
|
return
|
|
@@ -1841,9 +1852,8 @@ def clear_expired(d):
|
|
|
1841
1852
|
un-parked every dead-token account minutes after the shim parked it, and every
|
|
1842
1853
|
fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
|
|
1843
1854
|
token (the shim's newer-file rule) or a real call that used the token lifts it.
|
|
1844
|
-
Only
|
|
1845
|
-
limiter refused (the limiter answers only past the credential and
|
|
1846
|
-
or a re-login; an org block also lapses at its soft_until (6 h)."""
|
|
1855
|
+
Only inference verification lifts an org block: a passing `verify`, or one the
|
|
1856
|
+
usage limiter refused (the limiter answers only past the credential and org gate)."""
|
|
1847
1857
|
mpath = os.path.join(d, '.expired')
|
|
1848
1858
|
try:
|
|
1849
1859
|
body = open(mpath, errors='replace').read()
|
|
@@ -2836,9 +2846,6 @@ LIMIT_ERR = re.compile(
|
|
|
2836
2846
|
r"|limit (reached|exceeded)|rate[ _-]?limit|switch to another model", re.I)
|
|
2837
2847
|
NOT_A_LIMIT = re.compile(
|
|
2838
2848
|
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
|
|
2842
2849
|
# EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
|
|
2843
2850
|
# it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
|
|
2844
2851
|
# machine fault whose output happens to contain those three digits parks a live
|
|
@@ -2862,8 +2869,7 @@ def mark_expired(d, slug, detail=''):
|
|
|
2862
2869
|
t = time.time()
|
|
2863
2870
|
# Same rule as the limits path: an org block outranks every other reason while it
|
|
2864
2871
|
# binds. Rewriting its reason with a weaker one is how it gets lifted by accident
|
|
2865
|
-
# later. Only
|
|
2866
|
-
# is a fresh park with a fresh soft_until, not a stale one left to run out.
|
|
2872
|
+
# later. Only another org refusal may refresh it; access must be verified to lift it.
|
|
2867
2873
|
if slug != 'org-blocked':
|
|
2868
2874
|
try:
|
|
2869
2875
|
if org_block_in_force(open(mpath, errors='replace').read(), t):
|
|
@@ -2871,11 +2877,10 @@ def mark_expired(d, slug, detail=''):
|
|
|
2871
2877
|
except OSError:
|
|
2872
2878
|
pass
|
|
2873
2879
|
# 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 ''
|
|
2875
2880
|
try:
|
|
2876
2881
|
with open(mpath + '.tmp', 'w') as f:
|
|
2877
2882
|
f.write(f'{int(t)}\n')
|
|
2878
|
-
f.write(f"reason={slug}
|
|
2883
|
+
f.write(f"reason={slug} marked_at="
|
|
2879
2884
|
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
|
|
2880
2885
|
os.replace(mpath + '.tmp', mpath)
|
|
2881
2886
|
except Exception:
|
|
@@ -2893,6 +2898,16 @@ def lift_park(d, uses_token):
|
|
|
2893
2898
|
marker = open(mpath, errors='replace').read()
|
|
2894
2899
|
except OSError:
|
|
2895
2900
|
marker = ''
|
|
2901
|
+
# Record recovery even before a transcript rejection was harvested. Persist the
|
|
2902
|
+
# watermark before removing a park so a failed write cannot cause a re-park loop.
|
|
2903
|
+
try:
|
|
2904
|
+
cleared = os.path.join(d, '.client-org-cleared')
|
|
2905
|
+
temp = cleared + f'.tmp.{os.getpid()}'
|
|
2906
|
+
with open(temp, 'w') as f:
|
|
2907
|
+
f.write(f'{time.time()}\n')
|
|
2908
|
+
os.replace(temp, cleared)
|
|
2909
|
+
except OSError:
|
|
2910
|
+
return ''
|
|
2896
2911
|
if not uses_token and 'reason=setup-token-invalid' in marker:
|
|
2897
2912
|
return ''
|
|
2898
2913
|
try:
|
|
@@ -3039,12 +3054,11 @@ for acct in manifest.get('accounts', []):
|
|
|
3039
3054
|
elif ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
3040
3055
|
# Not an auth problem: the account authenticates fine, its organization
|
|
3041
3056
|
# has simply turned Claude Code subscription access off. Park it — a
|
|
3042
|
-
# re-login changes nothing
|
|
3043
|
-
# the shim's 6 h soft_until, and a re-observed block refreshes it.
|
|
3057
|
+
# re-login changes nothing. Keep it parked until access is verified again.
|
|
3044
3058
|
mark_expired(d, 'org-blocked',
|
|
3045
3059
|
"the account's organization has disabled Claude Code access")
|
|
3046
3060
|
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
3047
|
-
|
|
3061
|
+
'ask an admin to enable Claude Code subscription access, then run: claude-accounts verify')
|
|
3048
3062
|
elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
|
|
3049
3063
|
try:
|
|
3050
3064
|
os.remove(os.path.join(d, '.server-token-verified'))
|
package/bin/codex
CHANGED
|
@@ -1363,8 +1363,21 @@ fi
|
|
|
1363
1363
|
pick="$PICK_DIR"
|
|
1364
1364
|
# A probe answers here, before anything that would count as a launch: no rotation
|
|
1365
1365
|
# memory, no limits kick, no pick line in selection.log — and never an exec.
|
|
1366
|
+
# A limited tier also names when it lifts (resets_at=<epoch>, only when known): hard is
|
|
1367
|
+
# the soonest reset among the exhausted accounts, soft the picked account's own marker.
|
|
1368
|
+
# This is the machine twin of the tty-only notice above — callers read stdout, and a
|
|
1369
|
+
# service's stderr stays byte-clean.
|
|
1366
1370
|
if [ "$AR_PROBE" = "1" ]; then
|
|
1367
|
-
|
|
1371
|
+
ar_reset=0
|
|
1372
|
+
case "$AR_TIER" in
|
|
1373
|
+
hard) ar_reset="$best_reset" ;;
|
|
1374
|
+
soft) ar_reset="$(limited_reset_of "$pick")" ;;
|
|
1375
|
+
esac
|
|
1376
|
+
if [ "$ar_reset" -gt 0 ] 2>/dev/null; then
|
|
1377
|
+
printf 'pick=%s tier=%s resets_at=%s\n' "${pick##*/}" "$AR_TIER" "$ar_reset"
|
|
1378
|
+
else
|
|
1379
|
+
printf 'pick=%s tier=%s\n' "${pick##*/}" "$AR_TIER"
|
|
1380
|
+
fi
|
|
1368
1381
|
exit 0
|
|
1369
1382
|
fi
|
|
1370
1383
|
# Remember the pick so the NEXT run does not hand back the same account. An explicit
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
```bash
|
|
6
6
|
claude-accounts list # pool at a glance
|
|
7
7
|
claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
|
|
8
|
-
claude-accounts expired #
|
|
9
|
-
claude-accounts relogin #
|
|
8
|
+
claude-accounts expired # accounts with broken credentials or blocked access, and why (exit 1 if any)
|
|
9
|
+
claude-accounts relogin # repair expired logins, one at a time (ids or --all to pick your own)
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Every account in the pool is authenticated — `add` cannot register one otherwise.
|
|
@@ -24,20 +24,24 @@ claude-accounts expired
|
|
|
24
24
|
# fix: claude-accounts relogin acct-01
|
|
25
25
|
# acct-08 dev@example.com BLOCKED this account's organization has disabled
|
|
26
26
|
# Claude Code subscription access
|
|
27
|
-
# fix: claude-accounts
|
|
27
|
+
# fix: enable Claude Code access, then claude-accounts verify
|
|
28
28
|
|
|
29
29
|
claude-accounts relogin # re-authenticate everything a sign-in can fix
|
|
30
30
|
claude-accounts relogin acct-01 # or just one
|
|
31
31
|
claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
For `BLOCKED`, ask the admin to enable Claude Code access or restore the subscription,
|
|
35
|
+
then run `claude-accounts verify` (a real `-p` call per account). A PASS clears the park
|
|
36
|
+
automatically, and so does a call the usage limiter refuses
|
|
37
|
+
(`FAIL … — usage-limited, not a login or org problem; lifted its
|
|
38
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.
|
|
40
|
-
|
|
39
|
+
answer proves the org lets the account through. Claude org blocks stay excluded until
|
|
40
|
+
that proof, including old markers with elapsed `soft_until`. Re-login, credential refresh,
|
|
41
|
+
token replacement and clean usage telemetry cannot lift them. Interactive transcript
|
|
42
|
+
refusals are attributed to the account that ran the session; successful verification
|
|
43
|
+
writes `.client-org-cleared` so old errors cannot park it again. `verify --quick` checks
|
|
44
|
+
credentials only and cannot establish organization recovery.
|
|
41
45
|
|
|
42
46
|
`relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
|
|
43
47
|
the wrong email), one account at a time, and syncs to the server once at the end.
|
package/docs/AUTORESUME.md
CHANGED
|
@@ -180,11 +180,18 @@ The prompt is submitted automatically:
|
|
|
180
180
|
| quota | `rate_limit`, `quotaLimits.status: rejected`, `rateLimitType` `five_hour` / `seven_day` | `.limited` `client:<type>` until `resetsAt`, the marker the transcript scan writes | until that reset |
|
|
181
181
|
| model | `rate_limit` for any other limit type (a per-model weekly), "reached your … limit" / "switch to another model", `model_requires_usage_credits` | nothing: the limit covers one model | 5 h |
|
|
182
182
|
| auth | `authentication_failed` | `.expired`, the shim's client-reported auth park | 1 h |
|
|
183
|
-
| blocked | `oauth_org_not_allowed
|
|
183
|
+
| blocked | `oauth_org_not_allowed` | persistent `.expired` `org-blocked` park | 6 h in this chain |
|
|
184
|
+
| blocked | `account_on_hold`, `billing_error`, `verification_required` | nothing: only this chain avoids it | 6 h |
|
|
184
185
|
| transient | `overloaded`, `server_error`, `unknown`, any other `rate_limit` | nothing | none, so the same account may continue |
|
|
185
186
|
| crash | the process ended on its own after ≥ 60 s, leaving its session registry behind | nothing | none, so normal selection applies |
|
|
186
187
|
| never | anything else (`invalid_request`, `max_output_tokens`, `model_not_found`, …) | nothing | not restarted; logged once |
|
|
187
188
|
|
|
189
|
+
An organization refusal carries `rtype=oauth_org_not_allowed` into the relaunch file,
|
|
190
|
+
so the shim parks the old account even if its earlier session-index capture missed it.
|
|
191
|
+
An admin must enable access, then full `claude-accounts verify` must prove it; a PASS
|
|
192
|
+
or a usage-limit refusal lifts the park and records `.client-org-cleared`. Billing,
|
|
193
|
+
hold and verification errors retain their existing chain avoidance without an org park.
|
|
194
|
+
|
|
188
195
|
**Codex**, from `task_complete` events' `error.codex_error_info`:
|
|
189
196
|
|
|
190
197
|
| Class | `codex_error_info` | Old account | Avoided for |
|
|
@@ -293,6 +300,12 @@ CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_AR_AVOID=acct-07:1790000000 claude
|
|
|
293
300
|
- `soft`: only the all-limited fallback;
|
|
294
301
|
- `hard`: only exhausted accounts are left;
|
|
295
302
|
- `none`: nothing is usable; exit 3.
|
|
303
|
+
- **`resets_at`** (codex only): a `soft` or `hard` answer may end with
|
|
304
|
+
` resets_at=<epoch>`, when that tier's reset is known. For `hard` it is the soonest
|
|
305
|
+
reset among the exhausted accounts. For `soft` it is the picked account's own reset.
|
|
306
|
+
An `eligible` answer never carries it. This is the machine-readable form of the
|
|
307
|
+
terminal-only "every usable account is at a limit" notice; stderr stays empty.
|
|
308
|
+
Parsers must accept trailing `key=value` fields.
|
|
296
309
|
- **What the probe runs:** the same candidate loop as a launch, including marker upkeep
|
|
297
310
|
and its log lines, stopping before exec.
|
|
298
311
|
- **Environment:** run it with no `CLAUDE_CONFIG_DIR`, `CLAUDE_ACCOUNT` or
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -55,8 +55,11 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
55
55
|
access for Claude Code"), e.g. while a lapsed subscription was unpaid. Run
|
|
56
56
|
`claude-accounts verify`: a PASS lifts it, and so does a refusal from the usage limiter
|
|
57
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.
|
|
59
|
-
|
|
58
|
+
because the provider answers the org gate before the limiter. Until full verification
|
|
59
|
+
proves access, the account stays excluded even after re-login, token replacement,
|
|
60
|
+
clean usage telemetry or an old marker's `soft_until`. `verify --quick` cannot lift it.
|
|
61
|
+
Interactive `oauth_org_not_allowed` transcript errors also create this park;
|
|
62
|
+
verification records `.client-org-cleared` to prevent old errors from restoring it. The 404
|
|
60
63
|
"…or ask your admin to enable this model" is about a model, never an org block.
|
|
61
64
|
- **A session restarted itself, or did not** — that is [auto-resume](AUTORESUME.md).
|
|
62
65
|
`grep autoresume ~/.claude-accounts/selection.log` shows each step: `hold` means no
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/audit.py
CHANGED
|
@@ -8,8 +8,7 @@ States:
|
|
|
8
8
|
token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
|
|
9
9
|
blocked authenticates, but its organization has disabled Claude Code subscription
|
|
10
10
|
access — a re-login cannot fix it, so it needs an admin (or removal); a
|
|
11
|
-
park
|
|
12
|
-
and the pool re-tries the account
|
|
11
|
+
park stays until a real verification call proves access was enabled again
|
|
13
12
|
locked the OAuth login is in the macOS Keychain and THIS session cannot open it
|
|
14
13
|
(ssh/tmux/background) — it works from the Mac's own GUI session, so it is
|
|
15
14
|
neither dead nor missing; just not usable from here
|
|
@@ -82,30 +81,24 @@ def marker_soft_until(detail):
|
|
|
82
81
|
|
|
83
82
|
|
|
84
83
|
def org_block_in_force(body, now=None):
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
The writers use it to keep a weaker reason from overwriting a live org block; a
|
|
88
|
-
block whose soft window has run out no longer outranks anything."""
|
|
89
|
-
now = time.time() if now is None else now
|
|
90
|
-
if marker_slug(body) != 'org-blocked':
|
|
91
|
-
return False
|
|
92
|
-
soft = marker_soft_until(body)
|
|
93
|
-
return soft is None or soft > now
|
|
84
|
+
"""Organization policy outlives credentials and legacy soft expiry stamps."""
|
|
85
|
+
return marker_slug(body) == 'org-blocked'
|
|
94
86
|
|
|
95
87
|
|
|
96
88
|
def expired_marked(d, now=None):
|
|
97
89
|
"""True when <d>/.expired is still in force. Mirrors the shim exactly:
|
|
98
90
|
a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
|
|
99
91
|
refresh by another process); a POLICY park (org-blocked) does not, because a token
|
|
100
|
-
refresh says nothing about whether the org re-enabled Claude Code.
|
|
101
|
-
|
|
102
|
-
org-blocked park (the shim's and verify's alike carry six hours)."""
|
|
92
|
+
refresh says nothing about whether the org re-enabled Claude Code. Only credential
|
|
93
|
+
parks expire at their soft_until; legacy org expiry stamps are ignored."""
|
|
103
94
|
now = time.time() if now is None else now
|
|
104
95
|
mpath = os.path.join(d, '.expired')
|
|
105
96
|
if not os.path.isfile(mpath):
|
|
106
97
|
return False
|
|
107
98
|
detail = marker_reason(mpath)
|
|
108
99
|
slug = marker_slug(detail)
|
|
100
|
+
if slug == 'org-blocked':
|
|
101
|
+
return True
|
|
109
102
|
if slug == 'setup-token-invalid':
|
|
110
103
|
# A TOKEN park heals only when a NEW token lands (bin/claude expired_marked):
|
|
111
104
|
# a login refresh rewriting .credentials.json or the Keychain item says
|
|
@@ -252,11 +245,7 @@ def _fix_for(state, aid):
|
|
|
252
245
|
if state == 'token-invalid':
|
|
253
246
|
return f'claude-accounts login {aid} --token'
|
|
254
247
|
if state == 'blocked':
|
|
255
|
-
|
|
256
|
-
# handled like any other dead login. If it comes back BLOCKED after a re-login,
|
|
257
|
-
# the org really has Claude Code switched off and an admin has to enable it.
|
|
258
|
-
return (f'claude-accounts relogin {aid} '
|
|
259
|
-
f'(if it stays BLOCKED, an admin must enable Claude Code for it)')
|
|
248
|
+
return 'ask an admin to enable Claude Code subscription access, then run: claude-accounts verify'
|
|
260
249
|
if state in ('expired', 'missing'):
|
|
261
250
|
return f'claude-accounts relogin {aid}'
|
|
262
251
|
if state == 'locked':
|
|
@@ -309,13 +298,6 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
309
298
|
row['state'] = 'blocked'
|
|
310
299
|
row['reason'] = ('this account\'s organization has disabled Claude Code '
|
|
311
300
|
'subscription access')
|
|
312
|
-
soft = marker_soft_until(detail)
|
|
313
|
-
if soft is not None:
|
|
314
|
-
# When the pool gives it another real call on its own (the park's
|
|
315
|
-
# soft_until). A passing verify, one refused only by a usage limit, or
|
|
316
|
-
# a re-login lifts it sooner.
|
|
317
|
-
row['reason'] += time.strftime(
|
|
318
|
-
'; re-tried by the pool after %Y-%m-%d %H:%M UTC', time.gmtime(soft))
|
|
319
301
|
elif slug == 'setup-token-invalid':
|
|
320
302
|
login = oauth_login(d, now)
|
|
321
303
|
if login['state'] == 'ok':
|