claude-multiacc 2.0.43 → 2.0.44
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 +6 -1
- package/bin/claude +58 -15
- package/bin/claude-accounts +76 -11
- 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 +10 -0
- package/lib/claude_token_identity.py +233 -0
- 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 +11 -2
- 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
|
package/bin/claude
CHANGED
|
@@ -1646,6 +1646,12 @@ expired_marked() { # $1 = acct dir
|
|
|
1646
1646
|
# OAuth credential beside it is dead — the token is exported instead.
|
|
1647
1647
|
auth_dead() { # $1 = acct dir
|
|
1648
1648
|
expired_marked "$1" && return 0
|
|
1649
|
+
if [ -s "$1/server.token" ] && { ! has_oauth "$1" || creds_dead "$1"; }; then
|
|
1650
|
+
case ":${CLAUDE_MULTIACC_PREFLIGHT_SKIP:-}:" in *":${1##*/}:"*) return 0 ;; esac
|
|
1651
|
+
if [ -f "$1/.server-token-identity.json" ]; then
|
|
1652
|
+
[ "$(token_identity_state cached "$1")" = mismatch ] && return 0
|
|
1653
|
+
fi
|
|
1654
|
+
fi
|
|
1649
1655
|
[ -s "$1/server.token" ] && return 1
|
|
1650
1656
|
creds_dead "$1"
|
|
1651
1657
|
}
|
|
@@ -1658,10 +1664,21 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
|
|
|
1658
1664
|
fi
|
|
1659
1665
|
}
|
|
1660
1666
|
|
|
1661
|
-
#
|
|
1662
|
-
#
|
|
1663
|
-
#
|
|
1664
|
-
|
|
1667
|
+
# auth status cannot identify a setup-token, but authenticated API response headers
|
|
1668
|
+
# name its organization, even on a quota rejection. Check that binding independently
|
|
1669
|
+
# of the old inference proof: a token can answer OK while spending another account.
|
|
1670
|
+
token_identity_state() { # $1 check|cached, $2 account dir, $3 exact token for check
|
|
1671
|
+
local helper="$SELF_DIR/../lib/claude_token_identity.py" py="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
1672
|
+
[ -r "$helper" ] || return 0
|
|
1673
|
+
if [ "$1" = check ]; then
|
|
1674
|
+
printf '%s' "${3:-}" | "$py" "$helper" check "$2" --token-stdin --real "$REAL" --format state 2>/dev/null
|
|
1675
|
+
else
|
|
1676
|
+
"$py" "$helper" cached "$2" --format state 2>/dev/null
|
|
1677
|
+
fi
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
# Test authentication independently; organization matching cannot prove a token is
|
|
1681
|
+
# still valid. The token and identity receipts are both bound to its exact digest.
|
|
1665
1682
|
token_digest() { # $1 token; prints a non-secret sha256 digest
|
|
1666
1683
|
local h=""
|
|
1667
1684
|
if command -v shasum >/dev/null 2>&1; then
|
|
@@ -1672,8 +1689,16 @@ token_digest() { # $1 token; prints a non-secret sha256 digest
|
|
|
1672
1689
|
printf '%s' "$h" | cut -d ' ' -f1
|
|
1673
1690
|
}
|
|
1674
1691
|
|
|
1675
|
-
|
|
1692
|
+
TOKEN_PREFLIGHT_FAILURE=""
|
|
1693
|
+
token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven unusable auth
|
|
1676
1694
|
local d="$1" tok="$2" digest marker tmpd token_auth_pat rc=0
|
|
1695
|
+
TOKEN_PREFLIGHT_FAILURE="invalid portable OAuth token (401)"
|
|
1696
|
+
if [ "$(token_identity_state check "$d" "$tok")" = mismatch ]; then
|
|
1697
|
+
TOKEN_PREFLIGHT_FAILURE="portable setup-token for a different organization; run: "
|
|
1698
|
+
TOKEN_PREFLIGHT_FAILURE="${TOKEN_PREFLIGHT_FAILURE}claude-accounts login ${d##*/} --token"
|
|
1699
|
+
sel_log "${d##*/} skipped (setup-token identity mismatch)"
|
|
1700
|
+
return 1
|
|
1701
|
+
fi
|
|
1677
1702
|
digest="$(token_digest "$tok")"
|
|
1678
1703
|
[ -n "$digest" ] || return 0
|
|
1679
1704
|
marker="$d/.server-token-verified"
|
|
@@ -1723,8 +1748,8 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
|
|
|
1723
1748
|
tok="$(acct_token "$d")"
|
|
1724
1749
|
if [ -n "$tok" ]; then
|
|
1725
1750
|
if ! token_preflight "$d" "$tok"; then
|
|
1726
|
-
printf 'claude-multiacc: pinned account %s has
|
|
1727
|
-
"$CLAUDE_ACCOUNT" >&2
|
|
1751
|
+
printf 'claude-multiacc: pinned account %s has a %s\n' \
|
|
1752
|
+
"$CLAUDE_ACCOUNT" "$TOKEN_PREFLIGHT_FAILURE" >&2
|
|
1728
1753
|
exit 1
|
|
1729
1754
|
fi
|
|
1730
1755
|
export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
@@ -2058,13 +2083,22 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
|
|
|
2058
2083
|
num_ok "$preflight_depth" || preflight_depth=0
|
|
2059
2084
|
if [ "$preflight_depth" -lt 64 ]; then
|
|
2060
2085
|
export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
|
|
2086
|
+
# Persistence can fail on a read-only pool. Exclude this token for the re-entry
|
|
2087
|
+
# anyway, and never fall through into a token the preflight just rejected.
|
|
2088
|
+
preflight_skip="${CLAUDE_MULTIACC_PREFLIGHT_SKIP:+$CLAUDE_MULTIACC_PREFLIGHT_SKIP:}"
|
|
2089
|
+
export CLAUDE_MULTIACC_PREFLIGHT_SKIP="${preflight_skip}${pick##*/}"
|
|
2061
2090
|
# An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
|
|
2062
2091
|
# outside every chain field's charset, so it separates them unambiguously.
|
|
2063
2092
|
[ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
|
|
2064
2093
|
exec "$SELF" "$@"
|
|
2065
2094
|
fi
|
|
2095
|
+
printf 'claude-multiacc: portable token preflight retry limit reached\n' >&2
|
|
2096
|
+
exit 1
|
|
2066
2097
|
fi
|
|
2067
|
-
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
|
|
2098
|
+
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH CLAUDE_MULTIACC_PREFLIGHT_SKIP
|
|
2099
|
+
# Use the exact token that passed preflight. A concurrent mint/sync may replace the
|
|
2100
|
+
# file before exec; rereading it here would run an unchecked credential.
|
|
2101
|
+
PICK_TOKEN="$tok"
|
|
2068
2102
|
|
|
2069
2103
|
# The picked account may be eligible only because its exhausted bucket belongs to a
|
|
2070
2104
|
# model this run did not ask for. Handing it the default model anyway would open the
|
|
@@ -2165,7 +2199,7 @@ export CLAUDE_SHIM_ACTIVE=1
|
|
|
2165
2199
|
# mirrored to every other one (exits inside); anything else falls through to exec.
|
|
2166
2200
|
if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
|
|
2167
2201
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2168
|
-
tok="$
|
|
2202
|
+
tok="$PICK_TOKEN"
|
|
2169
2203
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2170
2204
|
mcp_mirror_run "$pick" "$@" || true
|
|
2171
2205
|
fi
|
|
@@ -2197,7 +2231,7 @@ fi
|
|
|
2197
2231
|
|
|
2198
2232
|
if [ "$wants_retry" = "0" ]; then
|
|
2199
2233
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2200
|
-
tok="$
|
|
2234
|
+
tok="$PICK_TOKEN"
|
|
2201
2235
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2202
2236
|
sel_capture_session "$pick"
|
|
2203
2237
|
ar_trust "$pick"
|
|
@@ -2209,7 +2243,7 @@ fi
|
|
|
2209
2243
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
2210
2244
|
tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
|
|
2211
2245
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2212
|
-
tok="$
|
|
2246
|
+
tok="$PICK_TOKEN"
|
|
2213
2247
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2214
2248
|
sel_capture_session "$pick"
|
|
2215
2249
|
exec "$REAL" "$@"
|
|
@@ -2221,7 +2255,7 @@ trap 'rm -rf "$tmpd"' EXIT
|
|
|
2221
2255
|
# nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
|
|
2222
2256
|
if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
|
|
2223
2257
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2224
|
-
tok="$
|
|
2258
|
+
tok="$PICK_TOKEN"
|
|
2225
2259
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
2226
2260
|
sel_capture_session "$pick"
|
|
2227
2261
|
exec "$REAL" "$@"
|
|
@@ -2306,6 +2340,7 @@ stream_reported_error() {
|
|
|
2306
2340
|
|
|
2307
2341
|
attempt=1
|
|
2308
2342
|
cur="$pick"
|
|
2343
|
+
cur_token="$PICK_TOKEN"
|
|
2309
2344
|
rc=0
|
|
2310
2345
|
stream_err=0 # the run exited 0 but the stream said otherwise
|
|
2311
2346
|
rotated=0 # at most one account rotation, exactly as before
|
|
@@ -2315,7 +2350,7 @@ while :; do
|
|
|
2315
2350
|
# Per ATTEMPT: a stream error seen on an earlier account must never decide the
|
|
2316
2351
|
# exit status of a later one that failed for its own, real reason.
|
|
2317
2352
|
stream_err=0
|
|
2318
|
-
tok="$
|
|
2353
|
+
tok="$cur_token"
|
|
2319
2354
|
if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
|
|
2320
2355
|
if [ -n "$tok" ]; then
|
|
2321
2356
|
CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
@@ -2391,14 +2426,22 @@ while :; do
|
|
|
2391
2426
|
n="${#eligible[@]}"
|
|
2392
2427
|
start=$((RANDOM % n))
|
|
2393
2428
|
i=0
|
|
2394
|
-
while [ "$i" -lt "$n" ]; do
|
|
2429
|
+
while [ "$rotated" = 0 ] && [ "$i" -lt "$n" ]; do
|
|
2395
2430
|
c="${eligible[$(((start + i) % n))]}"
|
|
2396
|
-
if [ "$c" != "$cur" ]
|
|
2431
|
+
if [ "$c" != "$cur" ] && ! auth_dead "$c"; then
|
|
2432
|
+
candidate_token="$(acct_token "$c")"
|
|
2433
|
+
if [ -z "$candidate_token" ] || token_preflight "$c" "$candidate_token"; then
|
|
2434
|
+
next="$c"
|
|
2435
|
+
next_token="$candidate_token"
|
|
2436
|
+
break
|
|
2437
|
+
fi
|
|
2438
|
+
fi
|
|
2397
2439
|
i=$((i+1))
|
|
2398
2440
|
done
|
|
2399
2441
|
if [ -n "$next" ] && [ "$rotated" = 0 ]; then
|
|
2400
2442
|
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
2401
2443
|
cur="$next"
|
|
2444
|
+
cur_token="$next_token"
|
|
2402
2445
|
# The account that actually serves the work is the one the next run should rotate
|
|
2403
2446
|
# away from — not the one that bounced.
|
|
2404
2447
|
remember_pick "$cur"
|
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
|
|
@@ -2742,6 +2790,8 @@ root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
|
2742
2790
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
2743
2791
|
from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
|
|
2744
2792
|
import keychain # noqa: E402
|
|
2793
|
+
from claude_token_identity import check_identity # noqa: E402
|
|
2794
|
+
from claude_reset import cli_version # noqa: E402
|
|
2745
2795
|
machine = sys.argv[5]
|
|
2746
2796
|
now = time.time()
|
|
2747
2797
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
@@ -2862,6 +2912,15 @@ for acct in manifest.get('accounts', []):
|
|
|
2862
2912
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
2863
2913
|
t0 = time.time()
|
|
2864
2914
|
uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
|
|
2915
|
+
identity = {'state': 'unknown'}
|
|
2916
|
+
if has_token:
|
|
2917
|
+
portable = env['CLAUDE_CODE_OAUTH_TOKEN'] if uses_token else open(tpath).read().strip()
|
|
2918
|
+
identity = check_identity(d, portable, version=cli_version(real))
|
|
2919
|
+
if uses_token and identity['state'] == 'mismatch':
|
|
2920
|
+
print(f'{aid} {acct["email"]}: FAIL (portable setup-token belongs to a different '
|
|
2921
|
+
f'organization — run: claude-accounts login {aid} --token)')
|
|
2922
|
+
failures += 1
|
|
2923
|
+
continue
|
|
2865
2924
|
try:
|
|
2866
2925
|
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
2867
2926
|
env=env, capture_output=True, text=True, timeout=240,
|
|
@@ -2889,7 +2948,7 @@ for acct in manifest.get('accounts', []):
|
|
|
2889
2948
|
pass
|
|
2890
2949
|
if uses_token:
|
|
2891
2950
|
try:
|
|
2892
|
-
digest = hashlib.sha256(
|
|
2951
|
+
digest = hashlib.sha256(env['CLAUDE_CODE_OAUTH_TOKEN'].encode()).hexdigest()
|
|
2893
2952
|
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2894
2953
|
with open(temp, 'w') as f:
|
|
2895
2954
|
f.write(digest + '\n')
|
|
@@ -2898,6 +2957,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2898
2957
|
except OSError:
|
|
2899
2958
|
pass
|
|
2900
2959
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2960
|
+
if not uses_token and identity['state'] == 'mismatch':
|
|
2961
|
+
print(f' portable setup-token belongs to a different organization; OAuth login passed; '
|
|
2962
|
+
f'replace the token with: claude-accounts login {aid} --token')
|
|
2901
2963
|
else:
|
|
2902
2964
|
raw = (r.stderr or '').strip()
|
|
2903
2965
|
err = raw[:200]
|
|
@@ -2985,7 +3047,8 @@ sync_push_target() {
|
|
|
2985
3047
|
done
|
|
2986
3048
|
# Advisory limit state for accounts the target may lack a bearer for.
|
|
2987
3049
|
local extra
|
|
2988
|
-
for extra in limits.json .limited; do
|
|
3050
|
+
for extra in limits.json .limited .server-token-identity.json; do
|
|
3051
|
+
if [ "$extra" = .server-token-identity.json ]; then token_identity_receipt "$d" || continue; fi
|
|
2989
3052
|
if [ -f "$d/$extra" ]; then
|
|
2990
3053
|
rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
|
|
2991
3054
|
fi
|
|
@@ -3078,6 +3141,7 @@ cmd_sync() {
|
|
|
3078
3141
|
sync_is_replica && role="replica"
|
|
3079
3142
|
log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
|
|
3080
3143
|
local_pool_fixup
|
|
3144
|
+
[ "$role" != source ] || token_identities_check
|
|
3081
3145
|
if [ "$role" = "replica" ]; then
|
|
3082
3146
|
echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
|
|
3083
3147
|
else
|
|
@@ -3127,6 +3191,7 @@ EOF
|
|
|
3127
3191
|
fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
|
|
3128
3192
|
fi
|
|
3129
3193
|
|
|
3194
|
+
token_identities_check
|
|
3130
3195
|
sync_push_target "$server" "$sroot" "$srepo"
|
|
3131
3196
|
local npeers=0
|
|
3132
3197
|
if [ -n "$peers" ]; then
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Claude setup-token identity and telemetry
|
|
2
|
+
|
|
3
|
+
A pool slot's telemetry and its execution credential must describe the same
|
|
4
|
+
organization. Otherwise the selector can correctly rank an empty account and launch
|
|
5
|
+
Claude against another account's exhausted subscription.
|
|
6
|
+
|
|
7
|
+
## Session 23: the confirmed cause
|
|
8
|
+
|
|
9
|
+
The September 2026 investigation compared the actual `server.token` credentials with
|
|
10
|
+
their slots' saved OAuth profiles. Authenticated responses identified acct-14's token
|
|
11
|
+
as belonging to acct-18's organization, and acct-13's token as belonging to acct-15's
|
|
12
|
+
organization. The complete pool audit also found acct-19's token belonged to acct-16's
|
|
13
|
+
organization. Anthropic returned `anthropic-organization-id` even on HTTP 429, allowing
|
|
14
|
+
the comparison without exposing tokens or relying on an inference succeeding.
|
|
15
|
+
|
|
16
|
+
The scheduled OAuth usage response really reported 0% for acct-13 and acct-14. The
|
|
17
|
+
native CLI instead reported 94% weekly usage for acct-13's portable token and a weekly
|
|
18
|
+
rejection for acct-14's. Those measurements described different organizations. Changing
|
|
19
|
+
the weekly band, treating every zero as unknown, or overwriting OAuth usage with the
|
|
20
|
+
token's usage would hide the credential mismatch and could exclude healthy accounts.
|
|
21
|
+
|
|
22
|
+
The existing `.server-token-verified` file only proved that the exact token had once
|
|
23
|
+
completed an inference. It could therefore bless a working token from the wrong
|
|
24
|
+
organization indefinitely. A successful response of `OK` is still useful evidence of
|
|
25
|
+
authentication; it is not evidence that a token belongs to the configured slot.
|
|
26
|
+
|
|
27
|
+
## The identity check
|
|
28
|
+
|
|
29
|
+
`lib/claude_token_identity.py` sends a small inference request and compares its
|
|
30
|
+
`anthropic-organization-id` response header with
|
|
31
|
+
`<account>/.claude.json` → `oauthAccount.organizationUuid`. This uses no usage GET and
|
|
32
|
+
does not change the telemetry polling cadence. Response bodies and credentials are
|
|
33
|
+
never written into the receipt or diagnostic output. Redirects are refused.
|
|
34
|
+
|
|
35
|
+
The independent `<account>/.server-token-identity.json` receipt records the exact
|
|
36
|
+
token digest, expected and observed organizations, account id, manifest email, verdict,
|
|
37
|
+
and check time. A match or mismatch remains reusable only while those bindings agree.
|
|
38
|
+
Replacing a token or changing its configured identity requires a new check. Updating
|
|
39
|
+
the same token's timestamp, refreshing OAuth, redeeming a limit reset, or clearing
|
|
40
|
+
ordinary auth/limit markers cannot invalidate a proven mismatch.
|
|
41
|
+
|
|
42
|
+
The shim checks identity before accepting the old `.server-token-verified` shortcut,
|
|
43
|
+
including retry candidates, and executes the exact token it checked. Concurrent checks
|
|
44
|
+
share an account lock so a delayed inconclusive result cannot replace proven identity.
|
|
45
|
+
A cached mismatch removes a candidate when execution would use its portable token;
|
|
46
|
+
healthy local OAuth may still use that slot. Explicit account pins also reject a known
|
|
47
|
+
wrong-organization token. Full `claude-accounts verify` performs the identity check,
|
|
48
|
+
and token ceremonies and imports reject a known mismatch before saving a candidate.
|
|
49
|
+
An inconclusive candidate check must not overwrite the current token's receipt.
|
|
50
|
+
|
|
51
|
+
Source sync and telemetry distribution check every portable token, even when local
|
|
52
|
+
OAuth works. Full verification likewise checks both credentials and reports them
|
|
53
|
+
separately. Sync distributes only current, confirmed identity receipts alongside the
|
|
54
|
+
token and telemetry; uncertainty cannot overwrite a peer's proven mismatch. A replica
|
|
55
|
+
without a saved OAuth profile can use the receipt's expected organization only when
|
|
56
|
+
its token digest and manifest binding still match. A changed local OAuth profile takes
|
|
57
|
+
precedence and invalidates an outdated receipt; an unrelated receipt cannot establish
|
|
58
|
+
the expectation for a newly created slot.
|
|
59
|
+
|
|
60
|
+
## Recovery
|
|
61
|
+
|
|
62
|
+
Replace the misfiled token by running the supported ceremony on the source machine:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude-accounts login acct-NN --token
|
|
66
|
+
claude-accounts verify
|
|
67
|
+
claude-accounts sync
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Approve the ceremony in a browser signed into the named account. A different token
|
|
71
|
+
invalidates the previous receipt and is checked against the saved expected organization.
|
|
72
|
+
Do not clear a mismatch by deleting markers or touching `server.token`: neither repairs
|
|
73
|
+
the credential. A valid local OAuth login can keep that machine's slot usable while its
|
|
74
|
+
portable token is replaced. Peers that depend on the bad token remain excluded.
|
|
75
|
+
|
|
76
|
+
The plugin does not automatically reassign a token to a different slot. The observed
|
|
77
|
+
organization does not prove which human account minted it, and moving credentials
|
|
78
|
+
could silently change the intended account mapping.
|
|
79
|
+
|
|
80
|
+
## What the check proves
|
|
81
|
+
|
|
82
|
+
The response header proves organization, not email, user identity, subscription seat,
|
|
83
|
+
remaining quota, or continuing authentication. Users sharing an organization cannot
|
|
84
|
+
be distinguished by this header. Keep the normal authentication preflight and quota
|
|
85
|
+
selection policy independent.
|
|
86
|
+
|
|
87
|
+
Setup tokens lack `user:profile`; `claude auth status` therefore reports no email for
|
|
88
|
+
them. An empty token email must not make the supported `add <email> --token` path require
|
|
89
|
+
`--force`, and the test CLI must not invent an email for token authentication. Name the
|
|
90
|
+
expected account before every ceremony.
|
|
91
|
+
|
|
92
|
+
No expected organization, a missing/malformed header, or a network failure produces an
|
|
93
|
+
inconclusive result. Execution retains its existing fallback behavior rather than
|
|
94
|
+
parking an account on uncertainty; inconclusive network checks back off for one minute.
|
|
95
|
+
Consequently the guard prevents a proved organization mismatch. It cannot promise
|
|
96
|
+
identity verification where Anthropic or the local account profile supplies no evidence.
|
|
97
|
+
|
|
98
|
+
## Telemetry rules that remain necessary
|
|
99
|
+
|
|
100
|
+
Portable setup tokens can run inference but cannot call the usage endpoint. A polling
|
|
101
|
+
source needs working OAuth logins; token-only peers receive `limits.json` through sync.
|
|
102
|
+
Use `claude-accounts status` to find `RANKING IS BLIND` or `<< STALE`, and repair source
|
|
103
|
+
OAuth with `claude-accounts login acct-NN` without `--token`. The usage endpoint has
|
|
104
|
+
returned an approximately hourly budget with `Retry-After: 3600`; retrying its permanent
|
|
105
|
+
scope rejection more frequently creates rate limits rather than telemetry.
|
|
106
|
+
|
|
107
|
+
Earlier fixes addressed separate missing-data and marker-recovery cases. They must
|
|
108
|
+
remain intact after adding identity validation:
|
|
109
|
+
|
|
110
|
+
- A weekly 0% bucket without a reset carries no informative weekly answer. Aggregate
|
|
111
|
+
only informative buckets, separately for each signal. Ignore an empty model bucket
|
|
112
|
+
beside informative ones; retain a measured zero with a real reset.
|
|
113
|
+
- If the entire payload is uninformative, write `no_data: true` without percentage
|
|
114
|
+
fields. Ranking and BLIND detection require both weekly and session signals; a recent
|
|
115
|
+
timestamp alone must not hide missing data.
|
|
116
|
+
- An otherwise informative payload may have no open five-hour window. Both writers
|
|
117
|
+
synthesize session 0% with `session_inferred: true` so idle accounts can rank. This
|
|
118
|
+
exception never invents weekly usage or turns a wholly empty payload into data.
|
|
119
|
+
- An inferred session zero cannot clear a client five-hour rejection. Only a measured
|
|
120
|
+
low reading of that window can recover it before its reset. Never substitute the
|
|
121
|
+
weekly `max_percent`. Claude must enforce this in both shim and writer; Codex's
|
|
122
|
+
telemetry recovery exists only in its writer.
|
|
123
|
+
- A client weekly rejection survives later telemetry until its reset, except when the
|
|
124
|
+
reset protocol confirms an applicable redemption. Marker names must preserve the
|
|
125
|
+
provider's actual window: Claude `client:seven_day`; Codex `client:7d` or `client:5h`
|
|
126
|
+
according to rollout window minutes.
|
|
127
|
+
|
|
128
|
+
The earlier observations of zero OAuth usage alongside client rejections did not
|
|
129
|
+
establish that both credentials belonged to the same organization. Session 23 supplies
|
|
130
|
+
direct evidence of that missing assumption. Identity validation addresses it without
|
|
131
|
+
weakening the conservative recovery rules or changing the 50% session gate and
|
|
132
|
+
30-point weekly peer band.
|
|
133
|
+
|
|
134
|
+
## Verification boundaries
|
|
135
|
+
|
|
136
|
+
Regression coverage must exercise the shim and account commands, not just compare
|
|
137
|
+
helper return values. Include old auth proofs, explicit pins, fallback selection,
|
|
138
|
+
unchanged token timestamps, replacement tokens, healthy OAuth beside a bad token,
|
|
139
|
+
receipt persistence through telemetry/reset cleanup, missing headers, and replica
|
|
140
|
+
bindings. Full verification must fail a wrong-organization token even if a fake native
|
|
141
|
+
CLI would answer `OK`.
|
|
142
|
+
|
|
143
|
+
Real checks should compare the exact token's response organization with its configured
|
|
144
|
+
slot, then launch an independently selected account through the updated shim. Use
|
|
145
|
+
minimal prompts with tools disabled and keep tokens and raw account identifiers out
|
|
146
|
+
of task logs. Passing those checks establishes the tested behavior; it does not remove
|
|
147
|
+
the inconclusive and shared-organization limitations described above.
|
|
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
|
@@ -39,6 +39,7 @@ import time
|
|
|
39
39
|
# helper sits beside it either way.
|
|
40
40
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
41
|
import keychain # noqa: E402
|
|
42
|
+
from claude_token_identity import cached_identity # noqa: E402
|
|
42
43
|
|
|
43
44
|
VALID_ID = re.compile(r'acct-\d{2}')
|
|
44
45
|
|
|
@@ -249,6 +250,8 @@ def _fix_for(state, aid):
|
|
|
249
250
|
|
|
250
251
|
def _token_verified(d, tpath):
|
|
251
252
|
"""Whether this exact setup-token passed an inference on this machine."""
|
|
253
|
+
if cached_identity(d)['state'] == 'mismatch':
|
|
254
|
+
return False
|
|
252
255
|
try:
|
|
253
256
|
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
254
257
|
saved = open(os.path.join(d, '.server-token-verified')).read().strip()
|
|
@@ -305,6 +308,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
305
308
|
return done(row)
|
|
306
309
|
login = oauth_login(d, now)
|
|
307
310
|
row['store'] = login['store']
|
|
311
|
+
if has_token and cached_identity(d)['state'] == 'mismatch':
|
|
312
|
+
if login['state'] != 'ok':
|
|
313
|
+
row['state'] = 'token-invalid'
|
|
314
|
+
row['reason'] = 'portable setup-token belongs to a different organization'
|
|
315
|
+
return done(row)
|
|
316
|
+
row['reason'] = f"{login['reason']}; portable setup-token identity mismatch, using the local login"
|
|
317
|
+
return done(row)
|
|
308
318
|
if login['state'] in ('ok', 'expired'):
|
|
309
319
|
state, reason = login['state'], login['reason']
|
|
310
320
|
if state == 'ok' or has_token:
|