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/bin/claude-accounts
CHANGED
|
@@ -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
|
-
#
|
|
1037
|
-
|
|
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
|
|
1235
|
-
#
|
|
1236
|
-
#
|
|
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
|
-
[ -
|
|
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
|
-
[ -
|
|
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
|
-
#
|
|
1768
|
-
#
|
|
1769
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2780
|
-
#
|
|
2781
|
-
# one is how it gets lifted by accident
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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(
|
|
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.
|
|
2878
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/bin/codex-accounts
CHANGED
|
@@ -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
|
|
1890
|
-
# reads it as proven dead and keeps it, while the panel
|
|
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(
|
|
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.
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -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
|