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.
Files changed (38) hide show
  1. package/README.md +6 -1
  2. package/bin/claude +58 -15
  3. package/bin/claude-accounts +76 -11
  4. package/docs/claude-token-identity.md +147 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  16. package/lib/audit.py +10 -0
  17. package/lib/claude_token_identity.py +233 -0
  18. package/lib/common.sh +3 -1
  19. package/lib/credential.py +9 -2
  20. package/package.json +1 -1
  21. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  22. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  23. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  33. package/tests/run-tests.sh +11 -2
  34. package/tests/test_claude_token_identity.py +259 -0
  35. package/tests/test_token_identity_ceremony.py +167 -0
  36. package/tests/test_token_identity_integration.py +207 -0
  37. package/tests/test_token_identity_retry.py +95 -0
  38. package/tests/test_token_identity_sync.py +139 -0
package/README.md CHANGED
@@ -126,7 +126,7 @@ later.
126
126
  **Unusable accounts are excluded, not rotated into.** An account that cannot serve a
127
127
  request is dropped from selection before anything else is considered — it is not eligible
128
128
  as the all-limited fallback either, because a limit-marked account is *degraded* while
129
- this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
129
+ this one is *down*, and handing work to it guarantees a hard failure. Reported states include:
130
130
 
131
131
  - **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
132
132
  came back *"OAuth session expired and could not be refreshed"*. Fixed by
@@ -135,6 +135,11 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
135
135
  prove inference works. Before a portable token carries its first real command on a
136
136
  machine, the shim privately verifies it. A 401 parks that exact account and reselects,
137
137
  so direct, TUI, and `--resume` calls do not expose the rejected token's failure.
138
+ - **Wrong token organization** (`TOKEN INVALID`) — an authenticated API response identifies
139
+ a different organization from the slot's saved OAuth profile. The shim excludes that
140
+ portable token, including on retries; a healthy local OAuth login remains usable.
141
+ Replace it with `claude-accounts login acct-NN --token` and sync. See
142
+ [token identity checks and recovery](docs/claude-token-identity.md).
138
143
  - **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
139
144
  *"Your organization has disabled Claude subscription access for Claude Code"*. Handled
140
145
  like any other dead login (`claude-accounts relogin` re-issues the grant and normally
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
- # A setup-token has no identity or readable expiry, and `claude auth status` only
1662
- # checks its shape. Test each token once on this machine with a real inference before
1663
- # allowing it to carry a user's command. This closes the direct/TUI/--resume gap where
1664
- # a revoked token exposed one 401 and was only parked on the following invocation.
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
- token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid auth
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 an invalid portable OAuth token (401)\n' \
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="$(acct_token "$pick")"
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="$(acct_token "$pick")"
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="$(acct_token "$pick")"
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="$(acct_token "$pick")"
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="$(acct_token "$cur")"
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" ]; then next="$c"; break; fi
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"
@@ -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
- # The ONE place a ceremony's token is written: only after ceremony_token_check, so a
1037
- # token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
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 hands this slot another account's subscription —
1235
- # and nothing downstream can notice (a setup token reports no identity, see
1236
- # token_email). Naming the expected account before the ceremony opens is the only guard
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
- [ -n "$got" ] || warn "a setup token carries no identity — $id now runs whichever account approved that grant${email:+, trusted to be $email}"
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
- [ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
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(open(tpath, 'rb').read().strip()).hexdigest()
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.
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: