claude-multiacc 1.0.16 → 1.0.18

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 CHANGED
@@ -435,6 +435,26 @@ Use `--token` when you specifically want one account usable on the server via th
435
435
  sync (the ~1-year inference-only token that mirrors over). For everyday adds, the default
436
436
  full login is smoother. `status` warns when a token nears end of life; re-run `login`.
437
437
 
438
+ ### A setup token has no identity — name the account yourself
439
+
440
+ A setup token is minted with scope `user:inference` **alone**, so `claude auth status`
441
+ answers `{loggedIn, authMethod, apiProvider}` and no email — only an OAuth login reports
442
+ `email`/`orgId`/`subscriptionType`. Nothing on this side can therefore learn which account
443
+ approved a `setup-token` grant, and no future check will change that.
444
+
445
+ What follows, and what the CLI does about it:
446
+
447
+ - **`add <email> --token` requires the email** — there is nothing to read the account back
448
+ from. It registers under the name you gave and says so; naming no email is refused
449
+ outright rather than registering an anonymous slot.
450
+ - **`mint <acct-NN>` names the account in its prompt** (`approve in a browser signed in as
451
+ …`) and warns afterwards that the identity is unverifiable. Approving in a window signed
452
+ into a *different* account silently binds that account's subscription to the slot —
453
+ every machine then runs it under the wrong name. Sign in to the right account **first**,
454
+ in a fresh private window, then paste the link.
455
+ - `login <acct-NN> --token` compares against the manifest when an identity is readable
456
+ (it is not, today) and otherwise says what it is trusting.
457
+
438
458
  Both accounts currently in the pool are live and verified: acct-01 (support@gowalkae.com,
439
459
  Mac) and acct-02 (hasan@gowalkqa.com, server) — each adopted from its machine's existing
440
460
  login, so each runs on the machine that holds its credential.
package/bin/claude CHANGED
@@ -999,25 +999,70 @@ fi
999
999
  # the pool on its own — the shim's guess must never outlive the evidence for it.
1000
1000
  PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
1001
1001
  PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
1002
- LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|credit balance'
1002
+ # A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
1003
+ # Switch to another model, or manage usage credits..." — the account still has
1004
+ # capacity for every other model, and the endpoint says so in the message itself.
1005
+ #
1006
+ # This phrasing matched NOTHING below: "reached your <model> limit" is not "limit
1007
+ # reached", and "usage credits" is not "credit balance". So on 2026-08-24, when
1008
+ # every account crossed that scoped bucket inside one hour, the shim did not even
1009
+ # enter its retry branch — every task died in under a second having done no work,
1010
+ # and the pool looked healthy the whole time.
1011
+ MODEL_LIMITPAT="reached your [^.]{0,40} limit|switch to another model"
1012
+ LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|credit balance'"|$MODEL_LIMITPAT"
1003
1013
  ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
1014
+ # Deliberately a full id, not an alias: the whole point of pinning --model is that
1015
+ # an unpinned run inherits whatever the operator was last using.
1016
+ FALLBACK_MODEL="${CLAUDE_MULTIACC_FALLBACK_MODEL:-claude-opus-5}"
1017
+
1018
+ # Read the --model the caller pinned (both spellings). Empty = unpinned.
1019
+ argv_model() {
1020
+ local i=0 n="${#ARGV[@]}"
1021
+ while [ "$i" -lt "$n" ]; do
1022
+ case "${ARGV[$i]}" in
1023
+ --model) i=$((i+1)); [ "$i" -lt "$n" ] && printf '%s\n' "${ARGV[$i]}"; return 0 ;;
1024
+ --model=*) printf '%s\n' "${ARGV[$i]#--model=}"; return 0 ;;
1025
+ esac
1026
+ i=$((i+1))
1027
+ done
1028
+ return 0
1029
+ }
1030
+
1031
+ # Rewrite ARGV onto a different model, preserving the caller's spelling. An
1032
+ # unpinned run gets the flag appended rather than left to inherit.
1033
+ argv_set_model() { # $1 = model id
1034
+ local i=0 n="${#ARGV[@]}" found=0
1035
+ ARGV_FB=()
1036
+ while [ "$i" -lt "$n" ]; do
1037
+ case "${ARGV[$i]}" in
1038
+ --model) ARGV_FB+=("--model" "$1"); i=$((i+2)); found=1; continue ;;
1039
+ --model=*) ARGV_FB+=("--model=$1"); i=$((i+1)); found=1; continue ;;
1040
+ esac
1041
+ ARGV_FB+=("${ARGV[$i]}"); i=$((i+1))
1042
+ done
1043
+ [ "$found" = 0 ] && ARGV_FB+=("--model" "$1")
1044
+ }
1045
+
1004
1046
  PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
1005
1047
  PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
1006
1048
 
1007
1049
  attempt=1
1008
1050
  cur="$pick"
1009
1051
  rc=0
1052
+ rotated=0 # at most one account rotation, exactly as before
1053
+ model_fb_used=0 # ...and at most one model fallback after it
1054
+ ARGV=("$@")
1010
1055
  while :; do
1011
1056
  tok="$(acct_token "$cur")"
1012
1057
  if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
1013
1058
  if [ -n "$tok" ]; then
1014
- CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
1059
+ CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
1015
1060
  else
1016
- CLAUDE_CONFIG_DIR="$cur" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
1061
+ CLAUDE_CONFIG_DIR="$cur" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
1017
1062
  fi
1018
1063
  rc=$?
1019
1064
  exec 3<&-
1020
- if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
1065
+ if [ "$rc" -ne 0 ] && { [ "$rotated" = 0 ] || [ "$model_fb_used" = 0 ]; } \
1021
1066
  && grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
1022
1067
  # Atomic marker writes: a reader must never observe a half-written marker
1023
1068
  # (it would parse as garbage and, before, could be deleted as "expired").
@@ -1036,6 +1081,11 @@ while :; do
1036
1081
  park_detail="run failed to authenticate"
1037
1082
  park_soft=$((now + PARK_SOFT_AUTH))
1038
1083
  fi
1084
+ # An account that ran out of ONE model has not run out. Cooling it down would
1085
+ # take a perfectly usable account out of the pool for every other model too —
1086
+ # and if the whole pool shares the bucket, cool the ENTIRE pool down at once.
1087
+ model_scoped=0
1088
+ grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null && model_scoped=1
1039
1089
  if [ -n "$park_reason" ]; then
1040
1090
  {
1041
1091
  echo "$now"
@@ -1044,7 +1094,7 @@ while :; do
1044
1094
  && mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
1045
1095
  || rm -f "$cur/.expired.$$" 2>/dev/null || true
1046
1096
  sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
1047
- else
1097
+ elif [ "$model_scoped" = 0 ]; then
1048
1098
  {
1049
1099
  echo $((now + 600))
1050
1100
  echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
@@ -1061,15 +1111,32 @@ while :; do
1061
1111
  if [ "$c" != "$cur" ]; then next="$c"; break; fi
1062
1112
  i=$((i+1))
1063
1113
  done
1064
- if [ -n "$next" ]; then
1114
+ if [ -n "$next" ] && [ "$rotated" = 0 ]; then
1065
1115
  sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
1066
1116
  cur="$next"
1067
1117
  # The account that actually serves the work is the one the next run should rotate
1068
1118
  # away from — not the one that bounced.
1069
1119
  remember_pick "$cur"
1120
+ rotated=1
1070
1121
  attempt=2
1071
1122
  continue
1072
1123
  fi
1124
+ # Another ACCOUNT could not help. If what ran out was one MODEL, the pool still
1125
+ # has capacity — switch to it rather than failing a task that has done no work.
1126
+ # Last resort by design: a healthy account must still serve the model the caller
1127
+ # pinned, so this only fires once rotation has already been tried and refused.
1128
+ if [ "$model_fb_used" = 0 ] \
1129
+ && grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
1130
+ cm="$(argv_model)"
1131
+ if [ "$cm" != "$FALLBACK_MODEL" ]; then
1132
+ argv_set_model "$FALLBACK_MODEL"
1133
+ ARGV=("${ARGV_FB[@]}")
1134
+ model_fb_used=1
1135
+ attempt=2
1136
+ sel_log "model fallback ${cm:-<unpinned>} -> $FALLBACK_MODEL on $(basename "$cur") (scoped limit)"
1137
+ continue
1138
+ fi
1139
+ fi
1073
1140
  fi
1074
1141
  break
1075
1142
  done
@@ -461,6 +461,13 @@ cmd_add() {
461
461
  return 0
462
462
  fi
463
463
  fi
464
+ # A setup token reports no identity, so an unnamed --token add can never attribute the
465
+ # account. Refuse BEFORE the ceremony: running it first would mint a real 1-year grant
466
+ # on the account's behalf and then throw the token away, leaving a live credential
467
+ # issued for nothing.
468
+ if [ "$token" = "1" ] && [ -z "$email" ]; then
469
+ die "a setup token carries no identity, so the account cannot be read back — name it: claude-accounts add <email> --token"
470
+ fi
464
471
  if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
465
472
  die "add is interactive (it completes sign-in before registering) — run it from a terminal"
466
473
  fi
@@ -498,7 +505,14 @@ cmd_add() {
498
505
  got="$(config_dir_email "$d")"
499
506
  fi
500
507
  if [ -z "$got" ]; then
501
- if [ -n "$email" ] && [ "$force" = "1" ]; then
508
+ if [ "$token" = "1" ] && [ -n "$email" ]; then
509
+ # A setup token NEVER reports an identity (see token_email) — that is the grant's
510
+ # design, not a read that failed, so demanding --force here demanded a ceremony
511
+ # nobody can perform: it made the documented `add <email> --token` path die every
512
+ # single time. The named email is all there is; say so plainly and register it.
513
+ warn "a setup token carries no identity — registering as $email, the account you were asked to approve as"
514
+ got="$email"
515
+ elif [ -n "$email" ] && [ "$force" = "1" ]; then
502
516
  warn "identity unverified — registering as $email because --force was given"
503
517
  got="$email"
504
518
  else
@@ -523,10 +537,18 @@ cmd_add() {
523
537
  mutate_unlock
524
538
  RESERVED_DIR="" # committed — the trap must not delete it now
525
539
  trap - EXIT INT TERM
526
- log_to ops.log "add $id $got (auth-verified)"
540
+ # Only the full-login path actually READ the identity back. Saying "verified" for a
541
+ # setup token would relaunder the very assumption this flow just warned about.
542
+ local verdict="sign-in verified"
543
+ if [ "$token" = "1" ]; then
544
+ verdict="identity unverifiable — trusted as the name you gave"
545
+ log_to ops.log "add $id $got (token; identity unverifiable)"
546
+ else
547
+ log_to ops.log "add $id $got (auth-verified)"
548
+ fi
527
549
  auto_sync
528
550
  cat <<EOF
529
- Registered $id for $got (sign-in verified) — usable immediately.
551
+ Registered $id for $got ($verdict) — usable immediately.
530
552
  Optional:
531
553
  claude-accounts verify # confirm the 100% matrix
532
554
  EOF
@@ -783,7 +805,23 @@ TIP
783
805
  }
784
806
  }
785
807
 
786
- token_email() { # $1 = token; prints the authenticated email (best effort)
808
+ account_email() { # $1 = acct id; prints the email the manifest holds for it
809
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
810
+ import json, sys
811
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
812
+ if a['id'] == sys.argv[2]:
813
+ print(a.get('email', ''))
814
+ break
815
+ PYEOF
816
+ }
817
+
818
+ token_email() { # $1 = token; prints the authenticated email — EMPTY for a setup token
819
+ # A setup token is minted with scope user:inference ALONE, so `claude auth status`
820
+ # answers {loggedIn, authMethod, apiProvider} and nothing else; an OAuth login also
821
+ # answers email/orgId/subscriptionType. There is therefore no way to learn which
822
+ # account approved a setup-token grant. Callers must read "" as "unknowable by
823
+ # design" — never as a transient failure worth retrying — and guard the ceremony by
824
+ # NAMING the expected account up front instead.
787
825
  local real
788
826
  real="$(find_real_claude "$_self")" || { echo ""; return 0; }
789
827
  CLAUDE_CODE_OAUTH_TOKEN="$1" CLAUDE_SHIM_ACTIVE=1 "$real" auth status 2>/dev/null | "$PYBIN" -c '
@@ -838,30 +876,55 @@ TIP
838
876
  }
839
877
 
840
878
  cmd_mint() {
879
+ # The minted token is the ONLY credential that reaches the server and every peer, so a
880
+ # mint under the wrong browser session hands this slot another account's subscription —
881
+ # and nothing downstream can notice (a setup token reports no identity, see
882
+ # token_email). Naming the expected account before the ceremony opens is the only guard
883
+ # that exists; the old prompt said "THIS account" and named nobody.
841
884
  require_manifest
842
- local id="${1:-}" paste="${2:-}"
885
+ local id="" paste=0
886
+ while [ $# -gt 0 ]; do
887
+ case "$1" in
888
+ --paste) paste=1; shift ;;
889
+ --*) die "unknown option: $1" ;;
890
+ *) if [ -z "$id" ]; then id="$1"; shift
891
+ else die "unexpected argument: $1 (usage: claude-accounts mint <acct-NN> [--paste])"; fi ;;
892
+ esac
893
+ done
843
894
  [ -n "$id" ] || die "usage: claude-accounts mint <acct-NN> [--paste]"
844
895
  valid_acct_id "$id" || die "not a valid account id: $id"
896
+ # A bare directory is NOT enough: removed accounts and killed `add` runs leave acct-NN
897
+ # dirs behind (this Mac carries nine), and minting into one binds a live token to a slot
898
+ # the manifest cannot name — which is exactly the unattributable state this guards.
899
+ account_ids | grep -qx "$id" \
900
+ || die "unknown account: $id — mint binds a token to a REGISTERED account ('claude-accounts list' shows them)"
845
901
  local d="$ACC_ROOT/$id"
846
- [ -d "$d" ] || die "unknown account dir: $d"
847
- local tok=""
848
- if [ "$paste" = "--paste" ]; then
849
- printf 'Paste the sk-ant-oat... token: '
902
+ seed_account_dir "$d"
903
+ local email tok="" got
904
+ email="$(account_email "$id")"
905
+ [ -n "$email" ] || die "$id has no email in the manifest — refusing to mint a token nobody could attribute"
906
+ if [ "$paste" = "1" ]; then
907
+ printf 'Paste the sk-ant-oat... token for %s (%s): ' "$id" "${email:-unknown email}"
850
908
  read -r tok
851
909
  tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
852
910
  else
853
- echo "Running 'claude setup-token' for $id — approve in a browser signed into THIS account."
911
+ echo "Running 'claude setup-token' for $id — approve in a browser signed in as ${email:-THIS account}."
854
912
  run_token_ceremony "$d" || die "no token captured — mint failed"
855
913
  tok="$CEREMONY_TOKEN"
856
914
  fi
857
915
  [ -n "$tok" ] || die "no token captured — mint failed"
858
916
  valid_subscription_token "$tok" \
859
917
  || die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
918
+ got="$(token_email "$tok")"
919
+ if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
920
+ die "that token authenticates as $got but $id is $email — nothing saved"
921
+ fi
860
922
  ( umask 077; printf '%s' "$tok" > "$d/server.token" )
861
923
  chmod 600 "$d/server.token"
862
924
  clear_auth_markers "$d"
863
925
  log_to ops.log "mint $id"
864
926
  echo "Token saved to $d/server.token"
927
+ [ -n "$got" ] || warn "a setup token carries no identity — $id now runs whichever account approved that grant${email:+, trusted to be $email}"
865
928
  auto_sync
866
929
  }
867
930
 
@@ -884,14 +947,7 @@ cmd_login() {
884
947
  account_ids | grep -qx "$id" || die "unknown account: $id"
885
948
  local d="$ACC_ROOT/$id" email got
886
949
  seed_account_dir "$d"
887
- email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
888
- import json, sys
889
- for a in json.load(open(sys.argv[1])).get('accounts', []):
890
- if a['id'] == sys.argv[2]:
891
- print(a.get('email', ''))
892
- break
893
- PYEOF
894
- )"
950
+ email="$(account_email "$id")"
895
951
  echo "Sign in as $email for $id."
896
952
  if [ "$token" = "1" ]; then
897
953
  run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
@@ -902,6 +958,7 @@ PYEOF
902
958
  ( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
903
959
  chmod 600 "$d/server.token"
904
960
  clear_auth_markers "$d"
961
+ [ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
905
962
  echo "$id token saved (portable — works on Mac and server)."
906
963
  else
907
964
  run_login_ceremony "$d" "$email" \
@@ -1054,6 +1111,85 @@ cmd_relogin() {
1054
1111
  [ -z "$failed" ]
1055
1112
  }
1056
1113
 
1114
+ # Hand freshly fetched telemetry to every machine that cannot fetch its own.
1115
+ #
1116
+ # Only ONE machine in a pool can read the usage endpoint: it needs an OAuth grant
1117
+ # carrying the user:profile scope, and a setup token is minted WITHOUT it. Every
1118
+ # other machine fetches, is refused, and therefore knows nothing about which
1119
+ # accounts are drained. gas-mini ranked its whole pool BLIND for 5.7 hours and
1120
+ # handed two tasks to an account sitting at 100% — its newest telemetry had arrived
1121
+ # at 16:38 on a mutation-triggered sync, and nothing refreshed it afterwards.
1122
+ #
1123
+ # So distribution rides the REFRESH rather than the mutation: whoever can fetch
1124
+ # hands the answer to everyone who cannot, every pass. One rsync per target carries
1125
+ # every account (a few KB). Strictly best effort with hard timeouts and BatchMode —
1126
+ # a peer that is asleep, rebooting or off the tailnet must never fail a refresh, and
1127
+ # must never hang the 15-minute job long enough to collide with the next one.
1128
+ # The push is DETACHED. A refresh that waits on ssh is a refresh that blocks the
1129
+ # 15-minute job for as long as a sleeping peer takes to time out, and telemetry
1130
+ # that arrives late is the whole problem we are fixing — delaying the NEXT fetch to
1131
+ # deliver this one trades the fault for itself. It also made four time-window tests
1132
+ # fail, which is the same defect wearing a smaller hat: seconds spent here move
1133
+ # every deadline downstream.
1134
+ #
1135
+ # One at a time: the lock means a slow or unreachable target can never stack pushes
1136
+ # up faster than they drain.
1137
+ limits_distribute() {
1138
+ sync_is_replica && return 0
1139
+ command -v rsync >/dev/null 2>&1 || return 0
1140
+ [ "${CLAUDE_MULTIACC_NO_DISTRIBUTE:-0}" = "1" ] && return 0
1141
+ ( limits_distribute_now >/dev/null 2>&1 & ) >/dev/null 2>&1
1142
+ return 0
1143
+ }
1144
+
1145
+ limits_distribute_now() {
1146
+ local lock="$ACC_ROOT/tmp/limits-push.lock"
1147
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
1148
+ mkdir "$lock" 2>/dev/null || return 0
1149
+ trap 'rmdir "$lock" 2>/dev/null || true' EXIT
1150
+ local server sroot list id d
1151
+ list="$ACC_ROOT/tmp/limits-push.$$"
1152
+ mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
1153
+ : > "$list" 2>/dev/null || return 0
1154
+ for id in $(account_ids); do
1155
+ d="$ACC_ROOT/$id"
1156
+ [ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
1157
+ # A marker is pushed but never un-pushed: the shim expires a cleanly-reset
1158
+ # .limited on its own, so a stale one costs an account some eligibility and
1159
+ # never grants any. Erring toward exclusion is the safe direction here.
1160
+ [ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
1161
+ done
1162
+ if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
1163
+
1164
+ server="$(sync_target)"
1165
+ sroot="$(sync_target_root)"
1166
+ if [ -n "$server" ] && ! sync_target_is_local "$server" \
1167
+ && valid_ssh_target "$server" && valid_remote_path "$sroot"; then
1168
+ limits_push_to "$server" "$sroot" "$list"
1169
+ fi
1170
+ # Peers are the OTHER Macs — the ones actually running tasks, and so the ones
1171
+ # whose selection goes blind without this.
1172
+ manifest_peers | while IFS="$(printf '\t')" read -r pt pr pp; do
1173
+ [ "$pt" = "MALFORMED" ] && continue
1174
+ [ -n "$pt" ] && [ -n "$pr" ] || continue
1175
+ valid_ssh_target "$pt" || continue
1176
+ valid_remote_path "$pr" || continue
1177
+ limits_push_to "$pt" "$pr" "$list"
1178
+ done
1179
+ rm -f "$list"
1180
+ rmdir "$lock" 2>/dev/null || true
1181
+ trap - EXIT
1182
+ return 0
1183
+ }
1184
+
1185
+ limits_push_to() { # $1 target, $2 remote root, $3 file list
1186
+ rsync -az --timeout=20 \
1187
+ -e 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new' \
1188
+ --files-from="$3" "$ACC_ROOT/" "$1:$2/" >>"$ACC_ROOT/sync.log" 2>&1 \
1189
+ || log_to sync.log "limits push to $1 failed (telemetry there will age)"
1190
+ return 0
1191
+ }
1192
+
1057
1193
  cmd_limits() {
1058
1194
  require_manifest
1059
1195
  local quiet=0 force=0 json=0
@@ -1709,6 +1845,9 @@ PYEOF
1709
1845
  # lock while a consumer reads its output.
1710
1846
  limits_lock_release
1711
1847
  trap - EXIT
1848
+ # Whoever just refreshed is the only machine that CAN — pass it on before the
1849
+ # report, so a blind peer stops picking drained accounts within one cycle.
1850
+ limits_distribute
1712
1851
  # The refresher fails open per account; a NON-zero status means the pass itself
1713
1852
  # broke (unreadable manifest, dead python). Report the state anyway — stale data
1714
1853
  # beats silence — but hand the caller the failure, exactly as before --json existed.
@@ -2126,6 +2265,20 @@ cmd_health() {
2126
2265
  return $rc
2127
2266
  }
2128
2267
 
2268
+ # Every KNOWN subcommand answers `--help`/`-h` with the usage text and exit 0. This is not
2269
+ # a nicety: app-robot's runner probes for a verb with `<verb> --help` and reads a non-zero
2270
+ # exit as "this build predates the verb", which silently parked panel-to-Mac credential
2271
+ # distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
2272
+ # FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
2273
+ # the dispatcher below implements, and a test pins that both ways.
2274
+ _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update"
2275
+ case " $_KNOWN_VERBS " in
2276
+ *" ${1:-help} "*)
2277
+ for _arg in "$@"; do
2278
+ case "$_arg" in --help|-h) usage; exit 0 ;; esac
2279
+ done ;;
2280
+ esac
2281
+
2129
2282
  case "${1:-help}" in
2130
2283
  list) shift; cmd_list "$@" ;;
2131
2284
  status) shift; cmd_status "$@" ;;
@@ -1935,6 +1935,20 @@ PYEOF
1935
1935
  echo "codex account pool: $ACC_ROOT (manifest ready)"
1936
1936
  }
1937
1937
 
1938
+ # Every KNOWN subcommand answers `--help`/`-h` with the usage text and exit 0. This is not
1939
+ # a nicety: app-robot's runner probes for a verb with `<verb> --help` and reads a non-zero
1940
+ # exit as "this build predates the verb", which silently parked panel-to-Mac credential
1941
+ # distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
1942
+ # FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
1943
+ # the dispatcher below implements, and a test pins that both ways.
1944
+ _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove login expired relogin re-login sync verify limits post-sync health self-update init-pool"
1945
+ case " $_KNOWN_VERBS " in
1946
+ *" ${1:-help} "*)
1947
+ for _arg in "$@"; do
1948
+ case "$_arg" in --help|-h) usage; exit 0 ;; esac
1949
+ done ;;
1950
+ esac
1951
+
1938
1952
  case "${1:-help}" in
1939
1953
  list) shift; cmd_list "$@" ;;
1940
1954
  status) shift; cmd_status "$@" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,14 @@ cat > "$FAKEBIN/claude" <<'EOF'
43
43
  # fake real claude for tests (not a multiacc shim)
44
44
  if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
45
45
  if [ -n "${FAKE_AUTH_FAIL:-}" ]; then echo '{"loggedIn": false}'; exit 0; fi
46
+ # A setup token is minted with scope user:inference ALONE, so the real CLI answers
47
+ # {loggedIn, authMethod, apiProvider} and NO email — only an OAuth login (config dir)
48
+ # reports one. The fake used to hand an email to both, which is precisely why every
49
+ # --token identity path passed here and died in the field.
50
+ if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -z "${CLAUDE_CONFIG_DIR:-}" ]; then
51
+ echo '{"loggedIn": true, "authMethod": "oauth_token", "apiProvider": "firstParty"}'
52
+ exit 0
53
+ fi
46
54
  printf '{"loggedIn": true, "email": "%s"}\n' "${FAKE_EMAIL:-fake@test}"
47
55
  exit 0
48
56
  fi
@@ -80,6 +88,15 @@ if [ -f "$ctl" ] && grep -qx "orgfail:$acct" "$ctl" 2>/dev/null; then
80
88
  echo "Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
81
89
  exit 1
82
90
  fi
91
+ # A limit scoped to ONE MODEL, on EVERY account: rotating cannot help, only
92
+ # switching model can. Satisfied the moment the run carries the fallback model.
93
+ if [ -f "$ctl" ] && grep -qx "modellimit" "$ctl" 2>/dev/null; then
94
+ case " $* " in
95
+ *" claude-opus-5 "*|*"--model=claude-opus-5"*) : ;;
96
+ *) echo "You've reached your Fable 5 limit. Switch to another model, or manage usage credits at claude.ai/settings/usage to continue." >&2
97
+ exit 1 ;;
98
+ esac
99
+ fi
83
100
  for a in "$@"; do
84
101
  case "$a" in
85
102
  --exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
@@ -102,7 +119,7 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
102
119
  sleep "${FAKE_SESSION_HOLD:-4}"
103
120
  rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
104
121
  fi
105
- echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
122
+ echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
106
123
  EOF
107
124
  chmod +x "$FAKEBIN/claude"
108
125
 
@@ -554,6 +571,36 @@ done
554
571
  [ -f "$ACC/acct-01/.limited" ] && t_ok "failed account got error-cooldown marker" || t_fail "cooldown marker" "missing"
555
572
  rm -f "$FAKE_CTL" "$ACC/acct-01/.limited"
556
573
 
574
+ # ---- 12a. every account out of ONE model: switch model, do not fail ----------
575
+ # 2026-08-24: all four accounts crossed the Fable weekly bucket inside an hour and
576
+ # every task died in under a second having done no work. Rotating accounts cannot
577
+ # fix a limit scoped to a model — the endpoint says so itself ("Switch to another
578
+ # model"). The pool still had capacity for every other model.
579
+ rm -f "$ACC"/acct-*/.limited
580
+ echo "modellimit" > "$FAKE_CTL"
581
+ out="$(claude -p --model claude-fable-5 hello < /dev/null 2>/dev/null)"
582
+ rc=$?
583
+ case "$out" in
584
+ *"ARGS="*"claude-opus-5"*) t_ok "a model-scoped limit falls back to another model" ;;
585
+ *) t_fail "model fallback" "rc=$rc out=$out" ;;
586
+ esac
587
+ [ "$rc" = "0" ] && t_ok "...and the task succeeds instead of failing" \
588
+ || t_fail "model fallback rc" "rc=$rc"
589
+ grep -q "model fallback" "$ACC/selection.log" 2>/dev/null \
590
+ && t_ok "the model switch is recorded in the selection log" \
591
+ || t_fail "model fallback log" "nothing logged"
592
+
593
+ # The pinned model is the caller's choice and must survive a recoverable failure:
594
+ # fall back only once ROTATION has been tried and could not help.
595
+ rm -f "$ACC"/acct-*/.limited; : > "$ACC/selection.log"
596
+ echo "fail:acct-01" > "$FAKE_CTL"
597
+ out="$(claude -p --model claude-fable-5 hello < /dev/null 2>/dev/null)"
598
+ case "$out" in
599
+ *"claude-fable-5"*) t_ok "an ordinary rate limit rotates account and KEEPS the model" ;;
600
+ *) t_fail "model preserved" "out=$out" ;;
601
+ esac
602
+ rm -f "$FAKE_CTL" "$ACC"/acct-*/.limited
603
+
557
604
  # ---- 12b. pipe stdin skips retry buffering but passes bytes through -----------
558
605
  out="$(printf 'pipe-data' | claude -p --echo-stdin 2>/dev/null)"
559
606
  [ "$out" = "pipe-data" ] && t_ok "pipe stdin passes through (no retry buffering)" || t_fail "pipe stdin passthrough" "got: $out"
@@ -1002,6 +1049,16 @@ claude-accounts remove acct-04 --yes >/dev/null 2>&1
1002
1049
  # ---- 15c1. add --token: portable setup-token instead of creds ------------------------
1003
1050
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=tok@test claude-accounts add tok@test --token 2>&1)"
1004
1051
  check "add --token registers via portable token" "Registered acct-04 for tok@test" "$out"
1052
+ check "add --token says the identity cannot be verified" "a setup token carries no identity" "$out"
1053
+ case "$out" in
1054
+ *"sign-in verified"*) t_fail "add --token must not claim a verified sign-in" "it says 'sign-in verified' for an identity nothing can read" ;;
1055
+ *) t_ok "add --token does not claim a verified sign-in" ;;
1056
+ esac
1057
+ case "$(grep 'add acct-04 tok@test' "$ACC/ops.log" 2>/dev/null)" in
1058
+ *auth-verified*) t_fail "ops.log must not record --token as auth-verified" "the audit trail would relaunder the assumption" ;;
1059
+ *"identity unverifiable"*) t_ok "ops.log records the --token add as unverifiable" ;;
1060
+ *) t_fail "ops.log line for the --token add" "not found" ;;
1061
+ esac
1005
1062
  [ -s "$ACC/acct-04/server.token" ] && t_ok "add --token writes server.token" || t_fail "add --token" "missing"
1006
1063
  out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
1007
1064
  check "token account exports CLAUDE_CODE_OAUTH_TOKEN" "TOK=sk-ant-oat01-FAKE" "$out"
@@ -3872,6 +3929,74 @@ assert any('manifest' in w for w in d['warnings']), d['warnings']
3872
3929
  && t_ok "limits --json still emits a document (with a warning) when the pool is broken" \
3873
3930
  || t_fail "limits --json on a broken pool" "no usable document"
3874
3931
 
3932
+ # ---- 46. a setup token has no identity: the --token paths must not pretend --------
3933
+ # Regression for two field failures on 2026-08-24: `add <email> --token` died every time
3934
+ # with "identity could not be read back" (it demanded --force for a ceremony that cannot
3935
+ # exist), and `mint` named no account at all — so approving in the wrong browser session
3936
+ # silently pinned another account's subscription to the slot, undetectably.
3937
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 claude-accounts add --token 2>&1)"
3938
+ rc=$?
3939
+ check "add --token with no email refuses precisely" "name it: claude-accounts add <email> --token" "$out"
3940
+ [ "$rc" != "0" ] && t_ok "add --token with no email exits nonzero" || t_fail "add --token no email" "exited 0"
3941
+ [ ! -d "$ACC/acct-04" ] && t_ok "refused --token add leaves no dir behind" || t_fail "add --token cleanup" "dir left"
3942
+ # ...and it must refuse BEFORE the ceremony: minting a real 1-year grant only to discard
3943
+ # it would leave a live credential issued for nothing.
3944
+ case "$out" in
3945
+ *"sign-in link"*) t_fail "add --token refuses before the ceremony" "setup-token already ran for an account it cannot name" ;;
3946
+ *) t_ok "add --token with no email never opens the ceremony" ;;
3947
+ esac
3948
+
3949
+ # mint must refuse a bare acct-NN directory the manifest does not know: removed accounts
3950
+ # and killed `add` runs leave those behind, and a token bound to one is unattributable.
3951
+ mkdir -p "$ACC/acct-77"
3952
+ out="$(printf 'sk-ant-oat01-ORPHANORPHANORPHANORPHANORPHANORPHAN\n' \
3953
+ | claude-accounts mint acct-77 --paste 2>&1)"
3954
+ check "mint refuses an unregistered account dir" "unknown account: acct-77" "$out"
3955
+ [ ! -s "$ACC/acct-77/server.token" ] && t_ok "no token is written into an orphan dir" \
3956
+ || t_fail "mint orphan dir" "server.token was written to an unregistered slot"
3957
+ rm -rf "$ACC/acct-77"
3958
+
3959
+ # mint must NAME the account it is about to bind a token to (the only guard there is).
3960
+ printf 'sk-ant-oat01-MINTNAMEDMINTNAMEDMINTNAMEDMINTNAMEDMINTNAMED\n' \
3961
+ | claude-accounts mint acct-01 --paste > "$WORK/mint-named.out" 2>&1
3962
+ out="$(cat "$WORK/mint-named.out")"
3963
+ check "mint --paste names the account in its prompt" "for acct-01" "$out"
3964
+ check "mint warns that a setup token carries no identity" "carries no identity" "$out"
3965
+
3966
+ # ---- 47. every known verb answers --help with exit 0 (app-robot probes with it) ----
3967
+ # app-robot's runner asks `<verb> --help` to decide whether a Mac's build has the verb;
3968
+ # a non-zero exit reads as "too old" and parked panel-to-Mac credential distribution.
3969
+ # _KNOWN_VERBS must hold EXACTLY what the dispatcher implements. A verb missing from it
3970
+ # hides a real verb from the probe; a verb listed but unimplemented makes --help answer 0
3971
+ # for something that does not exist, which is how the probe stops meaning anything. The
3972
+ # first cut of this gate got both wrong (mint listed in codex, init-pool in claude), so
3973
+ # the parity is checked from the source, both directions, for both binaries.
3974
+ for _bin in claude-accounts codex-accounts; do
3975
+ _src="$REPO_DIR/bin/$_bin"
3976
+ _dispatch="$(grep -oE '^ [a-z0-9|_-]+\) shift; cmd_' "$_src" | sed -e 's/) shift; cmd_//' -e 's/^ //' | tr '|\n' ' ')"
3977
+ _gate="$(grep -m1 '^_KNOWN_VERBS=' "$_src" | sed -e 's/^_KNOWN_VERBS="//' -e 's/"$//')"
3978
+ _parity=1
3979
+ for _v in $_dispatch; do
3980
+ case " $_gate " in
3981
+ *" $_v "*) ;;
3982
+ *) _parity=0; t_fail "$_bin: dispatcher has '$_v', _KNOWN_VERBS does not" "app-robot's probe would read the verb as absent" ;;
3983
+ esac
3984
+ "$_bin" "$_v" --help >/dev/null 2>&1
3985
+ [ "$?" = "0" ] && t_ok "$_bin $_v --help exits 0" \
3986
+ || t_fail "$_bin $_v --help" "non-zero exit — app-robot would read the verb as missing"
3987
+ done
3988
+ for _v in $_gate; do
3989
+ case " $_dispatch " in
3990
+ *" $_v "*) ;;
3991
+ *) _parity=0; t_fail "$_bin: _KNOWN_VERBS lists '$_v', the dispatcher does not implement it" "--help would answer 0 for a verb that does not exist" ;;
3992
+ esac
3993
+ done
3994
+ [ "$_parity" = "1" ] && t_ok "$_bin: the help gate and the dispatcher list the same verbs"
3995
+ "$_bin" frobnicate --help >/dev/null 2>&1
3996
+ [ "$?" != "0" ] && t_ok "$_bin: an UNKNOWN verb still fails --help (the probe keeps its meaning)" \
3997
+ || t_fail "$_bin unknown verb --help" "exited 0 — the probe would accept a verb that does not exist"
3998
+ done
3999
+
3875
4000
  # ---- summary ---------------------------------------------------------------------
3876
4001
  echo
3877
4002
  echo "passed: $PASS failed: $FAIL"