claude-multiacc 2.0.42 → 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 (42) hide show
  1. package/README.md +17 -1
  2. package/bin/claude +78 -18
  3. package/bin/claude-accounts +112 -22
  4. package/docs/ACCOUNT_OPERATIONS.md +5 -0
  5. package/docs/claude-token-identity.md +147 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  17. package/lib/audit.py +10 -0
  18. package/lib/claude_token_identity.py +233 -0
  19. package/lib/common.sh +27 -2
  20. package/lib/credential.py +9 -2
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  35. package/tests/fake_security.sh +90 -0
  36. package/tests/run-tests.sh +254 -66
  37. package/tests/test_claude_token_identity.py +259 -0
  38. package/tests/test_keychain.py +323 -50
  39. package/tests/test_token_identity_ceremony.py +167 -0
  40. package/tests/test_token_identity_integration.py +207 -0
  41. package/tests/test_token_identity_retry.py +95 -0
  42. 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
@@ -274,6 +279,17 @@ account's `.credentials.json` (0600), or its macOS Keychain item.
274
279
  > login made over ssh migrates into the Keychain the first time a GUI-session process
275
280
  > refreshes its token — the pool reads both places (`lib/keychain.py`), and an account
276
281
  > that must work from everywhere should carry a portable token (`claude-accounts mint`).
282
+ > The item's **account name** is the client's `$USER` — and a client started without USER
283
+ > files it under `unknown`, beside the older item, where the next client never looks. So
284
+ > `claude-accounts`/`codex-accounts` and the shim fill `USER`/`LOGNAME` from `id -un` when
285
+ > the caller's env has none, and the pool judges only the item under that canonical name —
286
+ > the one the client reads (one `security` call per lookup). A login found only under
287
+ > another name counts nowhere until the scheduled limits pass moves it under the canonical
288
+ > name. A token refresh writes back under the canonical name and drops the siblings it
289
+ > made stale (the same grant, a dead one) but keeps another live grant; a sign-in whose
290
+ > identity checked out prunes every sibling; `remove` deletes every item.
291
+ > `python3 lib/keychain.py prune <acct dir>` prunes by hand (only when the canonical item
292
+ > is a live login; it never writes).
277
293
  > Override: `CLAUDE_MULTIACC_KEYCHAIN=0` disables the lookup. This is what keeps idle accounts' telemetry fresh so they win
278
294
  selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
279
295
  account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
package/bin/claude CHANGED
@@ -12,6 +12,17 @@
12
12
 
13
13
  set -u
14
14
 
15
+ # Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
16
+ # USER calls the user "unknown" — so a caller whose env lacks it (app-robot's panel
17
+ # ceremony, 2026-09-24) gets a login filed under a name the next client never reads
18
+ # (lib/keychain.py). Every client this shim starts, passthrough included, inherits a
19
+ # real name; lib/common.sh does the same for claude-accounts.
20
+ if [ -z "${USER:-}" ]; then
21
+ USER="$(id -un 2>/dev/null || true)"
22
+ if [ -n "$USER" ]; then export USER; else unset USER; fi
23
+ fi
24
+ if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
25
+
15
26
  # ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
16
27
  # must still fail OPEN into plain passthrough, never abort on an unbound variable.
17
28
  # CLAUDE_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CLAUDE_ACCOUNTS_DIR
@@ -177,7 +188,8 @@ mcp_nested_mirror() {
177
188
  }
178
189
 
179
190
  # Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
180
- # guard, or no account data yet. Byte-identical behavior to stock claude.
191
+ # guard, or no account data yet. Byte-identical behavior to stock claude (a USER the
192
+ # caller left out is filled at the top — see there).
181
193
  if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
182
194
  || [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
183
195
  || [ ! -f "$MANIFEST" ]; then
@@ -216,6 +228,11 @@ case "${CLAUDE_MULTIACC_KEYCHAIN:-}" in
216
228
  *) if [ "$(uname -s)" = "Darwin" ]; then KC_ON=1; else KC_ON=0; fi ;;
217
229
  esac
218
230
  if [ "$KC_ON" = "1" ] && ! command -v security >/dev/null 2>&1; then KC_ON=0; fi
231
+ # The client reads ONLY the item under its own account name — $USER (filled above),
232
+ # anything outside [A-Za-z0-9._-] becoming "claude-code-user" — so that one item is what
233
+ # this shim judges: a sibling under another name is invisible to the client it launches.
234
+ KC_ACCOUNT="${USER:-}"
235
+ case "$KC_ACCOUNT" in ''|*[!A-Za-z0-9._-]*) KC_ACCOUNT=claude-code-user ;; esac
219
236
  kc_service() { # $1 = acct dir -> the keychain service name the client uses for it
220
237
  local h=""
221
238
  if command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
@@ -230,7 +247,7 @@ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but
230
247
  [ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
231
248
  if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
232
249
  local out rc
233
- out="$(security find-generic-password -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
250
+ out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
234
251
  KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
235
252
  case "$rc" in
236
253
  0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
@@ -242,7 +259,7 @@ kc_mtime() { # $1 = acct dir -> epoch of the item's last write (0 when none); at
242
259
  # are readable even when the secret is locked, so a marker still self-heals
243
260
  [ "$KC_ON" = "1" ] || { echo 0; return 0; }
244
261
  local stamp
245
- stamp="$(security find-generic-password -s "$(kc_service "$1")" 2>/dev/null \
262
+ stamp="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" 2>/dev/null \
246
263
  | LC_ALL=C sed -n 's/.*"mdat"<timedate>=0x[0-9A-Fa-f]* *"\([0-9]\{14\}\)Z.*/\1/p' | head -1)"
247
264
  case "$stamp" in
248
265
  [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])
@@ -1629,6 +1646,12 @@ expired_marked() { # $1 = acct dir
1629
1646
  # OAuth credential beside it is dead — the token is exported instead.
1630
1647
  auth_dead() { # $1 = acct dir
1631
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
1632
1655
  [ -s "$1/server.token" ] && return 1
1633
1656
  creds_dead "$1"
1634
1657
  }
@@ -1641,10 +1664,21 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
1641
1664
  fi
1642
1665
  }
1643
1666
 
1644
- # A setup-token has no identity or readable expiry, and `claude auth status` only
1645
- # checks its shape. Test each token once on this machine with a real inference before
1646
- # allowing it to carry a user's command. This closes the direct/TUI/--resume gap where
1647
- # 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.
1648
1682
  token_digest() { # $1 token; prints a non-secret sha256 digest
1649
1683
  local h=""
1650
1684
  if command -v shasum >/dev/null 2>&1; then
@@ -1655,8 +1689,16 @@ token_digest() { # $1 token; prints a non-secret sha256 digest
1655
1689
  printf '%s' "$h" | cut -d ' ' -f1
1656
1690
  }
1657
1691
 
1658
- 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
1659
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
1660
1702
  digest="$(token_digest "$tok")"
1661
1703
  [ -n "$digest" ] || return 0
1662
1704
  marker="$d/.server-token-verified"
@@ -1706,8 +1748,8 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
1706
1748
  tok="$(acct_token "$d")"
1707
1749
  if [ -n "$tok" ]; then
1708
1750
  if ! token_preflight "$d" "$tok"; then
1709
- printf 'claude-multiacc: pinned account %s has an invalid portable OAuth token (401)\n' \
1710
- "$CLAUDE_ACCOUNT" >&2
1751
+ printf 'claude-multiacc: pinned account %s has a %s\n' \
1752
+ "$CLAUDE_ACCOUNT" "$TOKEN_PREFLIGHT_FAILURE" >&2
1711
1753
  exit 1
1712
1754
  fi
1713
1755
  export CLAUDE_CODE_OAUTH_TOKEN="$tok"
@@ -2041,13 +2083,22 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
2041
2083
  num_ok "$preflight_depth" || preflight_depth=0
2042
2084
  if [ "$preflight_depth" -lt 64 ]; then
2043
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##*/}"
2044
2090
  # An auto-resume chain survives the re-entry (see ---- auto-resume ----). '|' is
2045
2091
  # outside every chain field's charset, so it separates them unambiguously.
2046
2092
  [ -n "$AR_CHAIN" ] && export CLAUDE_MULTIACC_AR_CARRY="$AR_DEPTH|$AR_AVOID|$AR_HIST|$AR_CHAIN"
2047
2093
  exec "$SELF" "$@"
2048
2094
  fi
2095
+ printf 'claude-multiacc: portable token preflight retry limit reached\n' >&2
2096
+ exit 1
2049
2097
  fi
2050
- 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"
2051
2102
 
2052
2103
  # The picked account may be eligible only because its exhausted bucket belongs to a
2053
2104
  # model this run did not ask for. Handing it the default model anyway would open the
@@ -2148,7 +2199,7 @@ export CLAUDE_SHIM_ACTIVE=1
2148
2199
  # mirrored to every other one (exits inside); anything else falls through to exec.
2149
2200
  if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
2150
2201
  export CLAUDE_CONFIG_DIR="$pick"
2151
- tok="$(acct_token "$pick")"
2202
+ tok="$PICK_TOKEN"
2152
2203
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2153
2204
  mcp_mirror_run "$pick" "$@" || true
2154
2205
  fi
@@ -2180,7 +2231,7 @@ fi
2180
2231
 
2181
2232
  if [ "$wants_retry" = "0" ]; then
2182
2233
  export CLAUDE_CONFIG_DIR="$pick"
2183
- tok="$(acct_token "$pick")"
2234
+ tok="$PICK_TOKEN"
2184
2235
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2185
2236
  sel_capture_session "$pick"
2186
2237
  ar_trust "$pick"
@@ -2192,7 +2243,7 @@ fi
2192
2243
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
2193
2244
  tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
2194
2245
  export CLAUDE_CONFIG_DIR="$pick"
2195
- tok="$(acct_token "$pick")"
2246
+ tok="$PICK_TOKEN"
2196
2247
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2197
2248
  sel_capture_session "$pick"
2198
2249
  exec "$REAL" "$@"
@@ -2204,7 +2255,7 @@ trap 'rm -rf "$tmpd"' EXIT
2204
2255
  # nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
2205
2256
  if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
2206
2257
  export CLAUDE_CONFIG_DIR="$pick"
2207
- tok="$(acct_token "$pick")"
2258
+ tok="$PICK_TOKEN"
2208
2259
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
2209
2260
  sel_capture_session "$pick"
2210
2261
  exec "$REAL" "$@"
@@ -2289,6 +2340,7 @@ stream_reported_error() {
2289
2340
 
2290
2341
  attempt=1
2291
2342
  cur="$pick"
2343
+ cur_token="$PICK_TOKEN"
2292
2344
  rc=0
2293
2345
  stream_err=0 # the run exited 0 but the stream said otherwise
2294
2346
  rotated=0 # at most one account rotation, exactly as before
@@ -2298,7 +2350,7 @@ while :; do
2298
2350
  # Per ATTEMPT: a stream error seen on an earlier account must never decide the
2299
2351
  # exit status of a later one that failed for its own, real reason.
2300
2352
  stream_err=0
2301
- tok="$(acct_token "$cur")"
2353
+ tok="$cur_token"
2302
2354
  if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
2303
2355
  if [ -n "$tok" ]; then
2304
2356
  CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
@@ -2374,14 +2426,22 @@ while :; do
2374
2426
  n="${#eligible[@]}"
2375
2427
  start=$((RANDOM % n))
2376
2428
  i=0
2377
- while [ "$i" -lt "$n" ]; do
2429
+ while [ "$rotated" = 0 ] && [ "$i" -lt "$n" ]; do
2378
2430
  c="${eligible[$(((start + i) % n))]}"
2379
- 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
2380
2439
  i=$((i+1))
2381
2440
  done
2382
2441
  if [ -n "$next" ] && [ "$rotated" = 0 ]; then
2383
2442
  sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
2384
2443
  cur="$next"
2444
+ cur_token="$next_token"
2385
2445
  # The account that actually serves the work is the one the next run should rotate
2386
2446
  # away from — not the one that bounced.
2387
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).
@@ -678,6 +678,8 @@ cmd_add() {
678
678
  mutate_unlock
679
679
  RESERVED_DIR="" # committed — the trap must not delete it now
680
680
  trap - EXIT INT TERM
681
+ # Registered under the identity it read back: leave one Keychain item for the dir.
682
+ [ "$token" = "1" ] || keychain_prune "$d"
681
683
  # Only the full-login path actually READ the identity back. Saying "verified" for a
682
684
  # setup token would relaunder the very assumption this flow just warned about.
683
685
  local verdict="sign-in verified"
@@ -747,6 +749,12 @@ cmd_import() {
747
749
  fi
748
750
  local d="$ACC_ROOT/$id"
749
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
750
758
  if [ -n "$creds" ]; then
751
759
  case "$mode" in
752
760
  copy) cp "$creds" "$d/.credentials.json" ;;
@@ -784,9 +792,10 @@ def has_auth(aid):
784
792
  if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
785
793
  return True
786
794
  # A login the client moved into the macOS Keychain still counts — deduping must
787
- # not throw away the one duplicate that actually holds the grant.
795
+ # not throw away the one duplicate that actually holds the grant, even one filed
796
+ # under a name its client no longer reads (the limits pass moves those back).
788
797
  try:
789
- return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
798
+ return keychain.probe(d, legacy=True)['state'] in ('present', 'locked', 'corrupt')
790
799
  except Exception:
791
800
  return False
792
801
  by_email = {}
@@ -1030,11 +1039,27 @@ ceremony_token_check() { # $1 = token
1030
1039
  esac
1031
1040
  }
1032
1041
 
1033
- # The ONE place a ceremony's token is written: only after ceremony_token_check, so a
1034
- # 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,
1035
1056
  # $2 = token, $3 = acct id (messages only).
1036
1057
  commit_ceremony_token() {
1037
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
1038
1063
  ceremony_token_check "$tok" || rc=$?
1039
1064
  # A short token is an incomplete one unless Claude itself has just answered with it:
1040
1065
  # the length alone convicts it, so an inconclusive probe (429, network) must not let
@@ -1052,6 +1077,7 @@ commit_ceremony_token() {
1052
1077
  fi
1053
1078
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
1054
1079
  chmod 600 "$d/server.token"
1080
+ [ "$CEREMONY_IDENTITY_STATE" != "match" ] || echo "Token organization matches the account's saved OAuth profile."
1055
1081
  if [ "$rc" -eq 0 ]; then
1056
1082
  record_token_verified "$d" "$tok"
1057
1083
  echo "Token verified by a real inference."
@@ -1228,10 +1254,9 @@ TIP
1228
1254
 
1229
1255
  cmd_mint() {
1230
1256
  # The minted token is the ONLY credential that reaches the server and every peer, so a
1231
- # mint under the wrong browser session hands this slot another account's subscription —
1232
- # and nothing downstream can notice (a setup token reports no identity, see
1233
- # token_email). Naming the expected account before the ceremony opens is the only guard
1234
- # 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.
1235
1260
  require_manifest
1236
1261
  local id="" paste=0
1237
1262
  while [ $# -gt 0 ]; do
@@ -1276,7 +1301,9 @@ cmd_mint() {
1276
1301
  clear_auth_markers "$d"
1277
1302
  log_to ops.log "mint $id"
1278
1303
  echo "Token saved to $d/server.token"
1279
- [ -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
1280
1307
  auto_sync
1281
1308
  }
1282
1309
 
@@ -1311,7 +1338,9 @@ cmd_login() {
1311
1338
  fi
1312
1339
  commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
1313
1340
  clear_auth_markers "$d"
1314
- [ -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
1315
1344
  echo "$id token saved (portable — works on Mac and server)."
1316
1345
  else
1317
1346
  run_login_ceremony "$d" "$email" \
@@ -1323,6 +1352,9 @@ cmd_login() {
1323
1352
  if [ -z "$got" ] && [ "$force" != "1" ]; then
1324
1353
  die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
1325
1354
  fi
1355
+ # Only now, with the identity confirmed, may the stale Keychain items go: a sign-in
1356
+ # as the wrong account (died above) must leave every item as it was.
1357
+ keychain_prune "$d"
1326
1358
  clear_auth_markers "$d" keep-token-park
1327
1359
  if [ -s "$d/.credentials.json" ]; then
1328
1360
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
@@ -1540,6 +1572,26 @@ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
1540
1572
  return 0
1541
1573
  }
1542
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
+
1543
1595
  limits_distribute_now() {
1544
1596
  local lock="$ACC_ROOT/tmp/limits-push.lock"
1545
1597
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
@@ -1564,6 +1616,7 @@ limits_distribute_now() {
1564
1616
  list="$ACC_ROOT/tmp/limits-push.$$"
1565
1617
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
1566
1618
  : > "$list" 2>/dev/null || return 0
1619
+ token_identities_check
1567
1620
  for id in $(account_ids); do
1568
1621
  d="$ACC_ROOT/$id"
1569
1622
  [ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
@@ -1571,6 +1624,7 @@ limits_distribute_now() {
1571
1624
  # .limited on its own, so a stale one costs an account some eligibility and
1572
1625
  # never grants any. Erring toward exclusion is the safe direction here.
1573
1626
  [ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
1627
+ token_identity_receipt "$d" && printf '%s/.server-token-identity.json\n' "$id" >> "$list"
1574
1628
  done
1575
1629
  # The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
1576
1630
  # add` reaches every peer within one limits cadence, with no explicit sync. The
@@ -1859,36 +1913,49 @@ class FileStore:
1859
1913
 
1860
1914
  class KeychainStore:
1861
1915
  """The same credential, held in the macOS Keychain by the client itself (see
1862
- lib/keychain.py). Read and written in place — NEVER copied out to a file: a file
1863
- beside a Keychain item is a second copy of a ROTATING refresh grant, and the
1864
- client reads the Keychain first, so the two would drift apart and strand one."""
1916
+ lib/keychain.py). Read from the item the client reads (the canonical name; this
1917
+ pass alone also accepts the legacy first match, which oauth_store moves) and written
1918
+ back under the canonical name, which also drops the siblings holding the grant the
1919
+ refresh started from — NEVER copied out to a file: a file beside a Keychain item is
1920
+ a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
1921
+ so the two would drift apart and strand one."""
1865
1922
  kind = 'keychain'
1866
1923
 
1867
1924
  def __init__(self, d, probe):
1868
1925
  self.d = d
1869
- self.account = probe.get('account')
1926
+ self.grant = None
1927
+ self.moved = False
1870
1928
 
1871
1929
  def read(self):
1872
- p = keychain.probe(self.d)
1930
+ p = keychain.probe(self.d, legacy=True)
1873
1931
  if p['state'] != 'present':
1874
1932
  raise ValueError(f'keychain credential {p["state"]}')
1933
+ grant = p['doc']['claudeAiOauth'].get('refreshToken')
1934
+ self.grant = grant if isinstance(grant, str) else None
1875
1935
  return p['doc']
1876
1936
 
1877
1937
  def write(self, doc):
1878
- if not keychain.write(self.d, doc, account=self.account):
1938
+ if not keychain.write(self.d, doc, supersedes=self.grant):
1879
1939
  raise OSError('keychain write refused')
1880
1940
 
1881
1941
 
1882
1942
  def oauth_store(d):
1883
1943
  """Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
1884
1944
  the string 'locked' (Keychain item exists but this session cannot open it), or
1885
- None. The file wins when both exist — same rule as lib/audit.oauth_login."""
1945
+ None. The file wins when both exist — same rule as lib/audit.oauth_login.
1946
+
1947
+ A Keychain login found only under a name its client no longer reads (a USER-less
1948
+ sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
1949
+ until then the client, the shim and the audit all see no login."""
1886
1950
  cpath = os.path.join(d, '.credentials.json')
1887
1951
  if os.path.isfile(cpath):
1888
1952
  return FileStore(cpath)
1889
- p = keychain.probe(d)
1953
+ p = keychain.probe(d, legacy=True)
1890
1954
  if p['state'] == 'present':
1891
- return KeychainStore(d, p)
1955
+ store = KeychainStore(d, p)
1956
+ if p['account'] != p['canonical']:
1957
+ store.moved = keychain.write(d, p['doc'])
1958
+ return store
1892
1959
  if p['state'] == 'locked':
1893
1960
  return 'locked'
1894
1961
  return None
@@ -2096,6 +2163,9 @@ for acct in manifest.get('accounts', []):
2096
2163
  except Exception as e:
2097
2164
  say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
2098
2165
  store = None
2166
+ if getattr(store, 'moved', False):
2167
+ say(f'{aid}: Keychain login moved under {keychain.canonical_account()}, '
2168
+ f'the name its client reads')
2099
2169
  locked = store == 'locked'
2100
2170
  if locked:
2101
2171
  store = None
@@ -2720,6 +2790,8 @@ root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
2720
2790
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
2721
2791
  from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
2722
2792
  import keychain # noqa: E402
2793
+ from claude_token_identity import check_identity # noqa: E402
2794
+ from claude_reset import cli_version # noqa: E402
2723
2795
  machine = sys.argv[5]
2724
2796
  now = time.time()
2725
2797
  manifest = json.load(open(os.path.join(root, 'accounts.json')))
@@ -2784,6 +2856,9 @@ for acct in manifest.get('accounts', []):
2784
2856
  # The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
2785
2857
  # session cannot open is not a failure of the ACCOUNT: the real call below
2786
2858
  # would fail for the session, not the grant, so it is a skip with a reason.
2859
+ # Only the canonical item counts — the one the client below reads; testing an
2860
+ # item under another name would run the client on a login it cannot see and
2861
+ # park an account its token serves.
2787
2862
  kc = keychain.probe(d)
2788
2863
  if kc['state'] == 'present':
2789
2864
  has_creds, cred_doc = True, kc['doc']
@@ -2837,6 +2912,15 @@ for acct in manifest.get('accounts', []):
2837
2912
  env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
2838
2913
  t0 = time.time()
2839
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
2840
2924
  try:
2841
2925
  r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
2842
2926
  env=env, capture_output=True, text=True, timeout=240,
@@ -2864,7 +2948,7 @@ for acct in manifest.get('accounts', []):
2864
2948
  pass
2865
2949
  if uses_token:
2866
2950
  try:
2867
- digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
2951
+ digest = hashlib.sha256(env['CLAUDE_CODE_OAUTH_TOKEN'].encode()).hexdigest()
2868
2952
  temp = os.path.join(d, '.server-token-verified.tmp')
2869
2953
  with open(temp, 'w') as f:
2870
2954
  f.write(digest + '\n')
@@ -2873,6 +2957,9 @@ for acct in manifest.get('accounts', []):
2873
2957
  except OSError:
2874
2958
  pass
2875
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')
2876
2963
  else:
2877
2964
  raw = (r.stderr or '').strip()
2878
2965
  err = raw[:200]
@@ -2960,7 +3047,8 @@ sync_push_target() {
2960
3047
  done
2961
3048
  # Advisory limit state for accounts the target may lack a bearer for.
2962
3049
  local extra
2963
- 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
2964
3052
  if [ -f "$d/$extra" ]; then
2965
3053
  rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
2966
3054
  fi
@@ -3053,6 +3141,7 @@ cmd_sync() {
3053
3141
  sync_is_replica && role="replica"
3054
3142
  log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
3055
3143
  local_pool_fixup
3144
+ [ "$role" != source ] || token_identities_check
3056
3145
  if [ "$role" = "replica" ]; then
3057
3146
  echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
3058
3147
  else
@@ -3102,6 +3191,7 @@ EOF
3102
3191
  fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
3103
3192
  fi
3104
3193
 
3194
+ token_identities_check
3105
3195
  sync_push_target "$server" "$sroot" "$srepo"
3106
3196
  local npeers=0
3107
3197
  if [ -n "$peers" ]; then
@@ -73,6 +73,11 @@ auto-refreshing login — `.credentials.json`, or on macOS the login Keychain wh
73
73
  the session can open it (Claude Code migrates the file into the Keychain on the first
74
74
  refresh from a keychain-capable session; ssh sessions then see the account as
75
75
  `KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
76
+ The Keychain item is named after `$USER`; the CLI fills it from `id -un` when the caller's
77
+ env has none (a USER-less client files its login under `unknown`, where the next client
78
+ never reads it). The pool judges only the item under that name, as the client does; a
79
+ login found only under another name is moved there by the next limits pass, and a
80
+ sign-in whose identity matched leaves exactly one item, under that name.
76
81
  That credential is **machine-local** (never synced),
77
82
  which is what keeps two machines from invalidating each other's refresh token — so an
78
83
  account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if