claude-multiacc 1.0.4 → 1.0.6
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 +99 -14
- package/bin/claude +165 -12
- package/bin/claude-accounts +487 -53
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +508 -2
package/bin/claude-accounts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# claude-accounts — manage the claude-multiacc account pool.
|
|
3
3
|
# Subcommands: list status add import remove mint sync verify limits post-sync health
|
|
4
|
+
# expired relogin
|
|
4
5
|
set -u
|
|
6
|
+
# lib/audit.py is imported by several subcommands; keep the install tree free of
|
|
7
|
+
# __pycache__ (it may be root-owned, read-only, or an npm global prefix).
|
|
8
|
+
export PYTHONDONTWRITEBYTECODE=1
|
|
5
9
|
|
|
6
10
|
_self="$0"
|
|
7
11
|
while [ -L "$_self" ]; do
|
|
@@ -29,6 +33,14 @@ USAGE
|
|
|
29
33
|
recent sign-in — use it when you want the account usable on the server too).
|
|
30
34
|
claude-accounts login <acct-NN> [--token] [--force]
|
|
31
35
|
complete/refresh auth for an existing account (full login, or --token)
|
|
36
|
+
claude-accounts expired [--quiet]
|
|
37
|
+
which accounts CANNOT authenticate (expired refresh token, revoked grant, no
|
|
38
|
+
login on this machine) and why. These are excluded from selection — `claude`
|
|
39
|
+
never runs under them. Exits 1 when any account needs a human. --quiet prints
|
|
40
|
+
bare ids for scripts.
|
|
41
|
+
claude-accounts relogin [acct-NN ...] [--all] [--token] [--yes]
|
|
42
|
+
sign in again, one account at a time. With no arguments it re-authenticates
|
|
43
|
+
exactly what `expired` lists; --all covers every account. Syncs once at the end.
|
|
32
44
|
claude-accounts import <email> [opts] register an account, optionally with credentials
|
|
33
45
|
--id acct-NN explicit id (default: next free)
|
|
34
46
|
--home mac|server which machine owns the OAuth grant (default: this one)
|
|
@@ -47,8 +59,11 @@ USAGE
|
|
|
47
59
|
claude-accounts sync push manifest+tokens to the server (Mac only)
|
|
48
60
|
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
49
61
|
claude-accounts limits [--quiet] [--force]
|
|
50
|
-
refresh usage buckets, apply >=90% markers.
|
|
51
|
-
|
|
62
|
+
refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
|
|
63
|
+
OAuth access tokens via the refresh-token grant (rotated credential is
|
|
64
|
+
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
65
|
+
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
66
|
+
--force ignores all three.
|
|
52
67
|
claude-accounts health limits + full verify; logs to health.log
|
|
53
68
|
claude-accounts self-update update the addon (npm i -g @latest, or git
|
|
54
69
|
pull + reinstall); logs to update.log
|
|
@@ -133,10 +148,13 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
|
133
148
|
|
|
134
149
|
cmd_list() {
|
|
135
150
|
require_manifest
|
|
136
|
-
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
151
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
137
152
|
import json, os, re, sys
|
|
138
153
|
doc = json.load(open(sys.argv[1]))
|
|
139
154
|
root = sys.argv[2]
|
|
155
|
+
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
156
|
+
from audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
157
|
+
machine = sys.argv[4]
|
|
140
158
|
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
|
|
141
159
|
accounts = [a for a in doc.get('accounts', [])
|
|
142
160
|
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
@@ -150,7 +168,24 @@ for a in accounts:
|
|
|
150
168
|
if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
|
|
151
169
|
auth.append('token')
|
|
152
170
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
153
|
-
|
|
171
|
+
st = audit_account(root, a, machine=machine)
|
|
172
|
+
flags = []
|
|
173
|
+
if st['state'] == 'expired':
|
|
174
|
+
flags.append('EXPIRED-LOGIN')
|
|
175
|
+
elif st['state'] == 'blocked':
|
|
176
|
+
flags.append('ORG-BLOCKED')
|
|
177
|
+
elif st['state'] == 'missing':
|
|
178
|
+
flags.append('NO-LOGIN')
|
|
179
|
+
if limited:
|
|
180
|
+
flags.append('LIMITED')
|
|
181
|
+
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} "
|
|
182
|
+
f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
|
|
183
|
+
bad = [a for a in accounts
|
|
184
|
+
if audit_account(root, a, machine=machine)['state']
|
|
185
|
+
in ('expired', 'blocked', 'missing')]
|
|
186
|
+
if bad:
|
|
187
|
+
print()
|
|
188
|
+
print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
|
|
154
189
|
seen = {}
|
|
155
190
|
for a in accounts:
|
|
156
191
|
seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
@@ -164,10 +199,13 @@ PYEOF
|
|
|
164
199
|
|
|
165
200
|
cmd_status() {
|
|
166
201
|
require_manifest
|
|
167
|
-
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
202
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
168
203
|
import json, os, sys, time
|
|
169
204
|
doc = json.load(open(sys.argv[1]))
|
|
170
205
|
root = sys.argv[2]
|
|
206
|
+
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
207
|
+
from audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
208
|
+
machine = sys.argv[4]
|
|
171
209
|
now = time.time()
|
|
172
210
|
|
|
173
211
|
def last_pick(aid):
|
|
@@ -189,10 +227,20 @@ print(f"pool root : {root}")
|
|
|
189
227
|
print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
|
|
190
228
|
print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
|
|
191
229
|
print()
|
|
230
|
+
needs_login = []
|
|
192
231
|
for a in doc.get('accounts', []):
|
|
193
232
|
aid = a['id']
|
|
194
233
|
d = os.path.join(root, aid)
|
|
195
|
-
|
|
234
|
+
st = audit_account(root, a, machine=machine)
|
|
235
|
+
if st['state'] in ('expired', 'blocked', 'missing'):
|
|
236
|
+
needs_login.append(aid)
|
|
237
|
+
banner = {'ok': '', 'remote': ' [not logged in here — grant lives elsewhere]',
|
|
238
|
+
'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
|
|
239
|
+
'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
|
|
240
|
+
'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
|
|
241
|
+
}[st['state']]
|
|
242
|
+
print(f"{aid} {a['email']} [home={a.get('home','?')}]{banner}")
|
|
243
|
+
print(f" selectable : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
|
|
196
244
|
cpath = os.path.join(d, '.credentials.json')
|
|
197
245
|
if os.path.isfile(cpath):
|
|
198
246
|
try:
|
|
@@ -238,6 +286,9 @@ for a in doc.get('accounts', []):
|
|
|
238
286
|
print(" marker : none (eligible)")
|
|
239
287
|
print(f" last picked : {last_pick(aid)}")
|
|
240
288
|
print()
|
|
289
|
+
if needs_login:
|
|
290
|
+
print(f"{len(needs_login)} account(s) are EXCLUDED from selection: {', '.join(needs_login)}")
|
|
291
|
+
print("What each one needs: claude-accounts expired")
|
|
241
292
|
PYEOF
|
|
242
293
|
}
|
|
243
294
|
|
|
@@ -328,6 +379,7 @@ cmd_add() {
|
|
|
328
379
|
warn "signed in as $got (you named $email) — registering the account that actually authenticated"
|
|
329
380
|
fi
|
|
330
381
|
manifest_add_account "$id" "$got" "$(machine_kind)"
|
|
382
|
+
clear_auth_markers "$d"
|
|
331
383
|
mutate_unlock
|
|
332
384
|
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
333
385
|
trap - EXIT INT TERM
|
|
@@ -637,7 +689,12 @@ TIP
|
|
|
637
689
|
else
|
|
638
690
|
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
|
|
639
691
|
fi
|
|
640
|
-
|
|
692
|
+
# Success is a credential that can AUTHENTICATE — not merely a file that exists.
|
|
693
|
+
# Re-login targets already have a (dead) .credentials.json on disk, so a bare
|
|
694
|
+
# existence test would call an aborted sign-in a success, clear the dead-auth
|
|
695
|
+
# marker, and hand the account straight back to the pool.
|
|
696
|
+
[ -f "$d/.credentials.json" ] || return 1
|
|
697
|
+
creds_alive "$d"
|
|
641
698
|
}
|
|
642
699
|
|
|
643
700
|
cmd_mint() {
|
|
@@ -662,6 +719,7 @@ cmd_mint() {
|
|
|
662
719
|
|| die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
|
|
663
720
|
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
664
721
|
chmod 600 "$d/server.token"
|
|
722
|
+
clear_auth_markers "$d"
|
|
665
723
|
log_to ops.log "mint $id"
|
|
666
724
|
echo "Token saved to $d/server.token"
|
|
667
725
|
auto_sync
|
|
@@ -703,19 +761,159 @@ PYEOF
|
|
|
703
761
|
fi
|
|
704
762
|
( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
|
|
705
763
|
chmod 600 "$d/server.token"
|
|
764
|
+
clear_auth_markers "$d"
|
|
706
765
|
echo "$id token saved (portable — works on Mac and server)."
|
|
707
766
|
else
|
|
708
|
-
run_login_ceremony "$d" "$email"
|
|
767
|
+
run_login_ceremony "$d" "$email" \
|
|
768
|
+
|| die "login failed or aborted (no working credential landed) — nothing changed"
|
|
709
769
|
got="$(config_dir_email "$d")"
|
|
710
770
|
if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
|
|
711
771
|
die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
|
|
712
772
|
fi
|
|
773
|
+
if [ -z "$got" ] && [ "$force" != "1" ]; then
|
|
774
|
+
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
775
|
+
fi
|
|
776
|
+
clear_auth_markers "$d"
|
|
713
777
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
714
778
|
fi
|
|
715
779
|
log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
|
|
716
780
|
auto_sync
|
|
717
781
|
}
|
|
718
782
|
|
|
783
|
+
cmd_expired() {
|
|
784
|
+
# Which accounts CANNOT authenticate right now — the pool's re-login worklist.
|
|
785
|
+
# Same rule the shim selects by (lib/audit.py), so what is listed here is exactly
|
|
786
|
+
# what is excluded from selection.
|
|
787
|
+
require_manifest
|
|
788
|
+
local quiet=0
|
|
789
|
+
while [ $# -gt 0 ]; do
|
|
790
|
+
case "$1" in
|
|
791
|
+
--quiet|--ids) quiet=1; shift ;; # ids only, for scripts
|
|
792
|
+
*) die "unknown option: $1 (usage: claude-accounts expired [--quiet])" ;;
|
|
793
|
+
esac
|
|
794
|
+
done
|
|
795
|
+
local rows bad
|
|
796
|
+
rows="$(account_audit)" || die "the account audit failed — cannot say which logins are dead"
|
|
797
|
+
if [ -z "$rows" ]; then
|
|
798
|
+
# No rows at all: either the pool is genuinely empty, or the manifest lost its
|
|
799
|
+
# accounts. Never render that as "all clear".
|
|
800
|
+
if [ -z "$(account_ids)" ]; then
|
|
801
|
+
echo "No accounts registered yet — add one with: claude-accounts add"
|
|
802
|
+
return 0
|
|
803
|
+
fi
|
|
804
|
+
die "the manifest lists accounts but none could be audited — check $MANIFEST"
|
|
805
|
+
fi
|
|
806
|
+
bad="$(printf '%s\n' "$rows" | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }')"
|
|
807
|
+
if [ "$quiet" = "1" ]; then
|
|
808
|
+
[ -n "$bad" ] || return 0
|
|
809
|
+
printf '%s\n' "$bad"
|
|
810
|
+
return 1
|
|
811
|
+
fi
|
|
812
|
+
printf '%s\n' "$rows" | awk -F'\t' '
|
|
813
|
+
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0 }
|
|
814
|
+
NF < 4 || $1 == "" { next } # never invent an account from a blank line
|
|
815
|
+
$4 == "ok" { ok++; next }
|
|
816
|
+
$4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
|
|
817
|
+
{
|
|
818
|
+
bad++
|
|
819
|
+
relogin++
|
|
820
|
+
printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
|
|
821
|
+
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
822
|
+
}
|
|
823
|
+
END {
|
|
824
|
+
if (bad == 0) printf "All %d account(s) with a login on this machine can authenticate.\n", ok
|
|
825
|
+
else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
|
|
826
|
+
if (remote > 0) {
|
|
827
|
+
printf "\nNot logged in here on purpose (another machine owns the grant):\n"
|
|
828
|
+
printf "%s", rem
|
|
829
|
+
}
|
|
830
|
+
if (relogin > 0) {
|
|
831
|
+
printf "\nRe-authenticate them:\n"
|
|
832
|
+
printf " claude-accounts relogin # every account that needs it\n"
|
|
833
|
+
printf " claude-accounts relogin acct-NN # just one\n"
|
|
834
|
+
}
|
|
835
|
+
}'
|
|
836
|
+
# Exit 1 when something needs a human, so cron/health checks can alert on it.
|
|
837
|
+
[ -z "$bad" ]
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
cmd_relogin() {
|
|
841
|
+
# Re-authenticate accounts whose login died. With no arguments it targets exactly
|
|
842
|
+
# what `expired` lists; ids (or --all) override that. Runs the same verified login
|
|
843
|
+
# ceremony as `login`, one account at a time, and syncs ONCE at the end.
|
|
844
|
+
require_manifest
|
|
845
|
+
local all=0 yes=0 token=0 ids=""
|
|
846
|
+
while [ $# -gt 0 ]; do
|
|
847
|
+
case "$1" in
|
|
848
|
+
--all) all=1; shift ;;
|
|
849
|
+
--yes|-y) yes=1; shift ;;
|
|
850
|
+
--token) token=1; shift ;;
|
|
851
|
+
--*) die "unknown option: $1" ;;
|
|
852
|
+
*)
|
|
853
|
+
valid_acct_id "$1" || die "not a valid account id: $1"
|
|
854
|
+
account_ids | grep -qx "$1" || die "unknown account: $1"
|
|
855
|
+
ids="$ids $1"; shift ;;
|
|
856
|
+
esac
|
|
857
|
+
done
|
|
858
|
+
if [ -n "$ids" ] && [ "$all" = "1" ]; then
|
|
859
|
+
die "give account ids OR --all, not both"
|
|
860
|
+
fi
|
|
861
|
+
if [ -z "$ids" ]; then
|
|
862
|
+
if [ "$all" = "1" ]; then
|
|
863
|
+
ids="$(account_ids | tr '\n' ' ')"
|
|
864
|
+
else
|
|
865
|
+
# Only what a sign-in can actually fix — org-blocked accounts are listed by
|
|
866
|
+
# `expired` but signing into them again would fail exactly the same way.
|
|
867
|
+
# A FAILED audit must not read as "nothing to do".
|
|
868
|
+
account_audit >/dev/null || die "the account audit failed — refusing to guess what needs a re-login"
|
|
869
|
+
ids="$(accounts_needing_login | tr '\n' ' ')"
|
|
870
|
+
fi
|
|
871
|
+
fi
|
|
872
|
+
ids="$(printf '%s' "$ids" | tr -s ' ' | sed 's/^ //; s/ $//')"
|
|
873
|
+
if [ -z "$ids" ]; then
|
|
874
|
+
echo "Nothing to re-authenticate — every account on this machine can be used."
|
|
875
|
+
return 0
|
|
876
|
+
fi
|
|
877
|
+
local count rows
|
|
878
|
+
count="$(printf '%s\n' "$ids" | tr ' ' '\n' | grep -c .)"
|
|
879
|
+
rows="$(account_audit)"
|
|
880
|
+
echo "Accounts to re-authenticate ($count):"
|
|
881
|
+
local id
|
|
882
|
+
for id in $ids; do
|
|
883
|
+
printf ' %s %s\n' "$id" \
|
|
884
|
+
"$(printf '%s\n' "$rows" | awk -F'\t' -v i="$id" '$1 == i { print $2 " (" $5 ")" }')"
|
|
885
|
+
done
|
|
886
|
+
if [ "$yes" != "1" ]; then
|
|
887
|
+
if [ ! -t 0 ]; then
|
|
888
|
+
die "relogin is interactive (each account needs a browser sign-in) — run it from a terminal, or pass --yes"
|
|
889
|
+
fi
|
|
890
|
+
printf 'Sign in to each of them now? [y/N] '
|
|
891
|
+
read -r ans
|
|
892
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
893
|
+
fi
|
|
894
|
+
# One sync at the end instead of one per account: each auto_sync is an ssh round trip.
|
|
895
|
+
local prev_no_sync="${CLAUDE_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
|
|
896
|
+
export CLAUDE_MULTIACC_NO_SYNC=1
|
|
897
|
+
for id in $ids; do
|
|
898
|
+
echo
|
|
899
|
+
echo "=== $id ==============================================================="
|
|
900
|
+
if [ "$token" = "1" ]; then
|
|
901
|
+
( cmd_login "$id" --token ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
|
|
902
|
+
else
|
|
903
|
+
( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
|
|
904
|
+
fi
|
|
905
|
+
done
|
|
906
|
+
export CLAUDE_MULTIACC_NO_SYNC="$prev_no_sync"
|
|
907
|
+
[ "$prev_no_sync" = "0" ] && unset CLAUDE_MULTIACC_NO_SYNC
|
|
908
|
+
echo
|
|
909
|
+
echo "re-authenticated $done_ok of $count account(s)."
|
|
910
|
+
if [ -n "$failed" ]; then
|
|
911
|
+
warn "still failing:$failed (re-run: claude-accounts relogin$failed)"
|
|
912
|
+
fi
|
|
913
|
+
[ "$done_ok" -gt 0 ] && auto_sync
|
|
914
|
+
[ -z "$failed" ]
|
|
915
|
+
}
|
|
916
|
+
|
|
719
917
|
cmd_limits() {
|
|
720
918
|
require_manifest
|
|
721
919
|
local quiet=0 force=0
|
|
@@ -740,24 +938,9 @@ cmd_limits() {
|
|
|
740
938
|
# shellcheck disable=SC2064
|
|
741
939
|
trap "rm -rf '$lock'" EXIT
|
|
742
940
|
rotate_log limits.log
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
#
|
|
746
|
-
local real="" d
|
|
747
|
-
real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
|
|
748
|
-
if [ -n "$real" ]; then
|
|
749
|
-
for d in "$ACC_ROOT"/acct-*; do
|
|
750
|
-
[ -d "$d" ] || continue
|
|
751
|
-
[ -f "$d/.credentials.json" ] || continue
|
|
752
|
-
[ -s "$d/server.token" ] && continue
|
|
753
|
-
if "$PYBIN" -c '
|
|
754
|
-
import json, sys, time
|
|
755
|
-
c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
|
|
756
|
-
sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
|
|
757
|
-
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
|
|
758
|
-
fi
|
|
759
|
-
done
|
|
760
|
-
fi
|
|
941
|
+
# NB: expired OAuth access tokens are refreshed inside the Python below via the
|
|
942
|
+
# refresh-token grant. (`claude auth status` was tried for this and does NOT
|
|
943
|
+
# refresh credentials — it only reports the on-disk state.)
|
|
761
944
|
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
762
945
|
# never loosen it, or an account could sit at 95% and still be selected.
|
|
763
946
|
local threshold
|
|
@@ -773,6 +956,21 @@ now = time.time()
|
|
|
773
956
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
774
957
|
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
|
|
775
958
|
|
|
959
|
+
# OAuth refresh-token grant — the same endpoint + public client id Claude Code
|
|
960
|
+
# itself uses to keep .credentials.json alive. An account that sits idle past its
|
|
961
|
+
# access-token TTL would otherwise drop out of telemetry forever (stale data ranks
|
|
962
|
+
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
|
|
963
|
+
TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
|
|
964
|
+
'https://console.anthropic.com/v1/oauth/token')
|
|
965
|
+
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
|
|
966
|
+
'9d1c250a-e61b-44d9-88ed-5944d1962f5e')
|
|
967
|
+
# Only refresh a token that has been expired for a while: a LIVE session refreshes
|
|
968
|
+
# its own credential within moments of expiry, so a long-expired one proves no
|
|
969
|
+
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
|
|
970
|
+
REFRESH_MIN_EXPIRED = 300
|
|
971
|
+
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
972
|
+
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
973
|
+
|
|
776
974
|
def say(msg):
|
|
777
975
|
if not quiet:
|
|
778
976
|
print(msg)
|
|
@@ -788,39 +986,179 @@ def parse_iso(s):
|
|
|
788
986
|
except Exception:
|
|
789
987
|
return None
|
|
790
988
|
|
|
989
|
+
# `.expired` — the persistent "this account cannot authenticate" marker the shim
|
|
990
|
+
# honors. Written only for a PROVEN dead grant (expired/absent refresh token, or a
|
|
991
|
+
# 4xx from the refresh endpoint), never for a transient network/5xx/429 hiccup.
|
|
992
|
+
def mark_expired(d, slug, detail=''):
|
|
993
|
+
mpath = os.path.join(d, '.expired')
|
|
994
|
+
try:
|
|
995
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
996
|
+
f.write(f'{int(now)}\n')
|
|
997
|
+
f.write(f"reason={slug} marked_at="
|
|
998
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
999
|
+
os.replace(mpath + '.tmp', mpath)
|
|
1000
|
+
except Exception:
|
|
1001
|
+
pass
|
|
1002
|
+
|
|
1003
|
+
def clear_expired(d):
|
|
1004
|
+
"""A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
|
|
1005
|
+
EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
|
|
1006
|
+
they are just barred from Claude Code inference, so telemetry says nothing about
|
|
1007
|
+
them. Only a passing `verify` (a real call) or a re-login lifts that."""
|
|
1008
|
+
mpath = os.path.join(d, '.expired')
|
|
1009
|
+
try:
|
|
1010
|
+
if 'reason=org-blocked' in open(mpath, errors='replace').read():
|
|
1011
|
+
return False
|
|
1012
|
+
except OSError:
|
|
1013
|
+
return False
|
|
1014
|
+
try:
|
|
1015
|
+
os.remove(mpath)
|
|
1016
|
+
return True
|
|
1017
|
+
except OSError:
|
|
1018
|
+
return False
|
|
1019
|
+
|
|
791
1020
|
try:
|
|
792
1021
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
793
1022
|
except Exception as e:
|
|
794
1023
|
sys.exit(f'cannot read manifest: {e}')
|
|
795
1024
|
|
|
1025
|
+
def refresh_oauth(aid, d, cpath):
|
|
1026
|
+
"""Refresh a long-expired OAuth access token via the refresh-token grant and
|
|
1027
|
+
persist the ROTATED credential atomically (0600). Returns the new bearer, or
|
|
1028
|
+
None (fail open: the on-disk credential is never touched on failure).
|
|
1029
|
+
Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
|
|
1030
|
+
because telemetry state must only ever reflect real usage fetches."""
|
|
1031
|
+
spath = os.path.join(d, '.oauth-refresh.json')
|
|
1032
|
+
try:
|
|
1033
|
+
doc = json.load(open(cpath))
|
|
1034
|
+
o = doc.get('claudeAiOauth', {})
|
|
1035
|
+
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
1036
|
+
# write) must degrade THIS account only, like every other malformed input.
|
|
1037
|
+
if not isinstance(doc, dict) or not isinstance(o, dict):
|
|
1038
|
+
return None
|
|
1039
|
+
except Exception:
|
|
1040
|
+
return None
|
|
1041
|
+
if not o.get('accessToken'):
|
|
1042
|
+
return None
|
|
1043
|
+
if not o.get('refreshToken'):
|
|
1044
|
+
mark_expired(d, 'no-refresh-token',
|
|
1045
|
+
'credential has no refresh token and its access token expired')
|
|
1046
|
+
return None
|
|
1047
|
+
if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
|
|
1048
|
+
return None # not expired long enough to prove no live session owns it
|
|
1049
|
+
if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
|
|
1050
|
+
# Nothing can revive this account: park it so the shim stops selecting it
|
|
1051
|
+
# (every run under it would fail with "OAuth session expired").
|
|
1052
|
+
mark_expired(d, 'refresh-token-expired',
|
|
1053
|
+
'the refresh token itself expired; only a re-login can fix it')
|
|
1054
|
+
say(f'{aid}: refresh token expired — re-login needed (claude-accounts relogin {aid})')
|
|
1055
|
+
return None
|
|
1056
|
+
if not force:
|
|
1057
|
+
try:
|
|
1058
|
+
if json.load(open(spath)).get('retry_after', 0) > now:
|
|
1059
|
+
return None # earlier refresh failure still backing off
|
|
1060
|
+
except Exception:
|
|
1061
|
+
pass
|
|
1062
|
+
|
|
1063
|
+
def back_off(wait, why, denials=0):
|
|
1064
|
+
try:
|
|
1065
|
+
with open(spath + '.tmp', 'w') as f:
|
|
1066
|
+
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
1067
|
+
'denials': denials,
|
|
1068
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
1069
|
+
os.replace(spath + '.tmp', spath)
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1072
|
+
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
1073
|
+
|
|
1074
|
+
body = json.dumps({'grant_type': 'refresh_token',
|
|
1075
|
+
'refresh_token': o['refreshToken'],
|
|
1076
|
+
'client_id': CLIENT_ID}).encode()
|
|
1077
|
+
req = urllib.request.Request(TOKEN_URL, data=body, headers={
|
|
1078
|
+
'Content-Type': 'application/json',
|
|
1079
|
+
'User-Agent': 'claude-multiacc/1.0',
|
|
1080
|
+
})
|
|
1081
|
+
try:
|
|
1082
|
+
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
1083
|
+
except urllib.error.HTTPError as e:
|
|
1084
|
+
if e.code in (400, 401, 403):
|
|
1085
|
+
# A 4xx from the TOKEN endpoint is only proof of a dead grant when the
|
|
1086
|
+
# server says so (OAuth's invalid_grant). Everything else 4xx — a bad
|
|
1087
|
+
# client_id, an endpoint change, a WAF page, a provider incident — would
|
|
1088
|
+
# hit EVERY account at once, so it must never park the whole pool on the
|
|
1089
|
+
# first try: back off, and only park after this account has been refused
|
|
1090
|
+
# repeatedly.
|
|
1091
|
+
body = ''
|
|
1092
|
+
try:
|
|
1093
|
+
body = e.read().decode('utf-8', 'replace')[:400]
|
|
1094
|
+
except Exception:
|
|
1095
|
+
pass
|
|
1096
|
+
denials = 1
|
|
1097
|
+
try:
|
|
1098
|
+
denials = int(json.load(open(spath)).get('denials', 0)) + 1
|
|
1099
|
+
except Exception:
|
|
1100
|
+
pass
|
|
1101
|
+
if 'invalid_grant' in body:
|
|
1102
|
+
mark_expired(d, f'refresh-denied-http-{e.code}',
|
|
1103
|
+
'the refresh grant was refused as invalid_grant (revoked or rotated away)')
|
|
1104
|
+
elif denials >= 3:
|
|
1105
|
+
mark_expired(d, f'refresh-denied-http-{e.code}',
|
|
1106
|
+
f'the refresh grant was refused {denials} times in a row')
|
|
1107
|
+
back_off(REFRESH_DENIED_BACKOFF,
|
|
1108
|
+
f'HTTP {e.code} — refresh token may be revoked; re-login needed',
|
|
1109
|
+
denials=denials)
|
|
1110
|
+
else:
|
|
1111
|
+
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
1112
|
+
return None
|
|
1113
|
+
except Exception as e:
|
|
1114
|
+
back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
|
|
1115
|
+
return None
|
|
1116
|
+
tok = data.get('access_token') if isinstance(data, dict) else None
|
|
1117
|
+
if not tok:
|
|
1118
|
+
back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
|
|
1119
|
+
return None
|
|
1120
|
+
o['accessToken'] = tok
|
|
1121
|
+
# The grant ROTATES the refresh token: persist it (and both expiries) or the
|
|
1122
|
+
# account is stranded — hence atomic write, and a loud message if it fails.
|
|
1123
|
+
if data.get('refresh_token'):
|
|
1124
|
+
o['refreshToken'] = data['refresh_token']
|
|
1125
|
+
if data.get('expires_in'):
|
|
1126
|
+
o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
|
|
1127
|
+
else:
|
|
1128
|
+
# No expires_in in the response: assume a conservative 1h. Leaving the old
|
|
1129
|
+
# (past) expiresAt would make every later pass re-run the grant in a loop.
|
|
1130
|
+
o['expiresAt'] = int((now + 3600) * 1000)
|
|
1131
|
+
if data.get('refresh_token_expires_in'):
|
|
1132
|
+
o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
|
|
1133
|
+
doc['claudeAiOauth'] = o
|
|
1134
|
+
try:
|
|
1135
|
+
fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1136
|
+
with os.fdopen(fd, 'w') as f:
|
|
1137
|
+
json.dump(doc, f)
|
|
1138
|
+
os.replace(cpath + '.tmp', cpath)
|
|
1139
|
+
except Exception as e:
|
|
1140
|
+
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
1141
|
+
return None
|
|
1142
|
+
try:
|
|
1143
|
+
os.remove(spath)
|
|
1144
|
+
except OSError:
|
|
1145
|
+
pass
|
|
1146
|
+
# The grant answered: whatever parked this account before, it authenticates now.
|
|
1147
|
+
if clear_expired(d):
|
|
1148
|
+
say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
|
|
1149
|
+
say(f'{aid}: oauth access token refreshed via refresh-token grant')
|
|
1150
|
+
return tok
|
|
1151
|
+
|
|
796
1152
|
for acct in manifest.get('accounts', []):
|
|
797
1153
|
aid = acct['id']
|
|
798
1154
|
d = os.path.join(root, aid)
|
|
799
1155
|
if not os.path.isdir(d):
|
|
800
1156
|
continue
|
|
801
|
-
bearer = None
|
|
802
|
-
source = None
|
|
803
|
-
cpath = os.path.join(d, '.credentials.json')
|
|
804
|
-
if os.path.isfile(cpath):
|
|
805
|
-
try:
|
|
806
|
-
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
807
|
-
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
808
|
-
bearer, source = c['accessToken'], 'oauth'
|
|
809
|
-
except Exception:
|
|
810
|
-
pass
|
|
811
|
-
tpath = os.path.join(d, 'server.token')
|
|
812
|
-
if not bearer and os.path.isfile(tpath):
|
|
813
|
-
t = open(tpath).read().strip()
|
|
814
|
-
if t:
|
|
815
|
-
bearer, source = t, 'token'
|
|
816
|
-
if not bearer:
|
|
817
|
-
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
818
|
-
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
819
|
-
continue
|
|
820
1157
|
|
|
821
1158
|
# The usage endpoint rate-limits per account. Several callers can fire at once
|
|
822
1159
|
# (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
|
|
823
1160
|
# this account's data is already fresh, and honor any backoff a 429 set earlier.
|
|
1161
|
+
# Checked FIRST so a skipped account never burns an oauth refresh for nothing.
|
|
824
1162
|
lpath = os.path.join(d, 'limits.json')
|
|
825
1163
|
prev = {}
|
|
826
1164
|
if os.path.isfile(lpath):
|
|
@@ -837,6 +1175,36 @@ for acct in manifest.get('accounts', []):
|
|
|
837
1175
|
say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
|
|
838
1176
|
continue
|
|
839
1177
|
|
|
1178
|
+
bearer = None
|
|
1179
|
+
source = None
|
|
1180
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
1181
|
+
if os.path.isfile(cpath):
|
|
1182
|
+
try:
|
|
1183
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
1184
|
+
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
1185
|
+
bearer, source = c['accessToken'], 'oauth'
|
|
1186
|
+
except Exception:
|
|
1187
|
+
pass
|
|
1188
|
+
tpath = os.path.join(d, 'server.token')
|
|
1189
|
+
if not bearer and os.path.isfile(tpath):
|
|
1190
|
+
t = open(tpath).read().strip()
|
|
1191
|
+
if t:
|
|
1192
|
+
bearer, source = t, 'token'
|
|
1193
|
+
if not bearer and os.path.isfile(cpath):
|
|
1194
|
+
# Hard fail-open guard: NOTHING a single account's refresh does may abort
|
|
1195
|
+
# the loop — every account after it would silently starve of telemetry.
|
|
1196
|
+
try:
|
|
1197
|
+
tok = refresh_oauth(aid, d, cpath)
|
|
1198
|
+
except Exception as e:
|
|
1199
|
+
say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
|
|
1200
|
+
tok = None
|
|
1201
|
+
if tok:
|
|
1202
|
+
bearer, source = tok, 'oauth'
|
|
1203
|
+
if not bearer:
|
|
1204
|
+
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
1205
|
+
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
1206
|
+
continue
|
|
1207
|
+
|
|
840
1208
|
req = urllib.request.Request(url, headers={
|
|
841
1209
|
'Authorization': 'Bearer ' + bearer,
|
|
842
1210
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
@@ -958,6 +1326,9 @@ for acct in manifest.get('accounts', []):
|
|
|
958
1326
|
with open(tmp, 'w') as f:
|
|
959
1327
|
json.dump(out, f, indent=1)
|
|
960
1328
|
os.replace(tmp, lpath)
|
|
1329
|
+
# The fetch went through with this account's own bearer => its auth is alive.
|
|
1330
|
+
if clear_expired(d):
|
|
1331
|
+
say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
|
|
961
1332
|
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
962
1333
|
mpath = os.path.join(d, '.limited')
|
|
963
1334
|
if offenders:
|
|
@@ -1000,15 +1371,40 @@ cmd_verify() {
|
|
|
1000
1371
|
if [ "$quick" = "0" ]; then
|
|
1001
1372
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
1002
1373
|
fi
|
|
1003
|
-
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
|
|
1004
|
-
import json, os, subprocess, sys, time
|
|
1374
|
+
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
1375
|
+
import json, os, re, subprocess, sys, time
|
|
1005
1376
|
|
|
1006
1377
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
1378
|
+
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
1379
|
+
from audit import audit_account, creds_state # noqa: E402 (shared with the shim's rule)
|
|
1380
|
+
machine = sys.argv[5]
|
|
1007
1381
|
now = time.time()
|
|
1008
1382
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
1009
1383
|
failures = 0
|
|
1010
1384
|
tested = 0
|
|
1011
1385
|
|
|
1386
|
+
# Same failure vocabulary the shim retries on (bin/claude: AUTHPAT / ORGPAT).
|
|
1387
|
+
AUTH_ERR = re.compile(
|
|
1388
|
+
r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
|
|
1389
|
+
r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
|
|
1390
|
+
r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
|
|
1391
|
+
ORG_ERR = re.compile(
|
|
1392
|
+
r'organization has disabled|subscription access.*disabl|disabled claude subscription'
|
|
1393
|
+
r'|ask your admin to enable|not authorized to use claude code', re.I)
|
|
1394
|
+
|
|
1395
|
+
def mark_expired(d, slug, detail=''):
|
|
1396
|
+
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
1397
|
+
there is — a real inference call that came back 'not authenticated'."""
|
|
1398
|
+
mpath = os.path.join(d, '.expired')
|
|
1399
|
+
try:
|
|
1400
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
1401
|
+
f.write(f'{int(time.time())}\n')
|
|
1402
|
+
f.write(f"reason={slug} marked_at="
|
|
1403
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
1404
|
+
os.replace(mpath + '.tmp', mpath)
|
|
1405
|
+
except Exception:
|
|
1406
|
+
pass
|
|
1407
|
+
|
|
1012
1408
|
for acct in manifest.get('accounts', []):
|
|
1013
1409
|
aid = acct['id']
|
|
1014
1410
|
d = os.path.join(root, aid)
|
|
@@ -1024,8 +1420,11 @@ for acct in manifest.get('accounts', []):
|
|
|
1024
1420
|
try:
|
|
1025
1421
|
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
1026
1422
|
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
1027
|
-
if rexp and rexp < now:
|
|
1028
|
-
|
|
1423
|
+
if rexp and rexp < now and not has_token:
|
|
1424
|
+
mark_expired(d, 'refresh-token-expired',
|
|
1425
|
+
'the refresh token itself expired; only a re-login can fix it')
|
|
1426
|
+
print(f'{aid} {acct["email"]}: FAIL (refresh token expired — '
|
|
1427
|
+
f'run: claude-accounts relogin {aid})')
|
|
1029
1428
|
failures += 1
|
|
1030
1429
|
continue
|
|
1031
1430
|
except Exception as e:
|
|
@@ -1033,8 +1432,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1033
1432
|
failures += 1
|
|
1034
1433
|
continue
|
|
1035
1434
|
if quick:
|
|
1435
|
+
# Quick mode must agree with what the shim will actually do — presence of a
|
|
1436
|
+
# credential file is not proof it can authenticate.
|
|
1437
|
+
st = audit_account(root, acct, machine=machine)
|
|
1036
1438
|
kind = 'oauth' if has_creds else 'token'
|
|
1037
|
-
|
|
1439
|
+
if st['state'] == 'ok':
|
|
1440
|
+
print(f'{aid} {acct["email"]}: OK (quick, {kind} present)')
|
|
1441
|
+
else:
|
|
1442
|
+
print(f'{aid} {acct["email"]}: FAIL ({st["label"]} — {st["reason"]})'
|
|
1443
|
+
+ (f'; fix: {st["fix"]}' if st['fix'] else ''))
|
|
1444
|
+
failures += 1
|
|
1038
1445
|
continue
|
|
1039
1446
|
env = dict(os.environ)
|
|
1040
1447
|
env['CLAUDE_CONFIG_DIR'] = d
|
|
@@ -1042,7 +1449,10 @@ for acct in manifest.get('accounts', []):
|
|
|
1042
1449
|
env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
|
|
1043
1450
|
env.pop('CLAUDE_ACCOUNT', None)
|
|
1044
1451
|
env['CLAUDE_SHIM_ACTIVE'] = '1'
|
|
1045
|
-
|
|
1452
|
+
# Mirror the shim's acct_token(): a portable token is what actually authenticates
|
|
1453
|
+
# whenever there is no credential OR the credential beside it is dead. Testing such
|
|
1454
|
+
# an account with the dead credential would fail it — and park a healthy account.
|
|
1455
|
+
if has_token and (not has_creds or creds_state(cpath, now)[0] != 'ok'):
|
|
1046
1456
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
1047
1457
|
t0 = time.time()
|
|
1048
1458
|
try:
|
|
@@ -1056,10 +1466,28 @@ for acct in manifest.get('accounts', []):
|
|
|
1056
1466
|
dt = time.time() - t0
|
|
1057
1467
|
out = (r.stdout or '').strip()
|
|
1058
1468
|
if r.returncode == 0 and 'ok' in out.lower():
|
|
1469
|
+
# A real call succeeded: this account is definitively alive.
|
|
1470
|
+
try:
|
|
1471
|
+
os.remove(os.path.join(d, '.expired'))
|
|
1472
|
+
except OSError:
|
|
1473
|
+
pass
|
|
1059
1474
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
1060
1475
|
else:
|
|
1061
1476
|
err = (r.stderr or '').strip()[:200]
|
|
1062
|
-
|
|
1477
|
+
hint = ''
|
|
1478
|
+
if ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
1479
|
+
# Not an auth problem: the account authenticates fine, its organization
|
|
1480
|
+
# has simply turned Claude Code subscription access off. Park it — a
|
|
1481
|
+
# re-login changes nothing — and say what actually helps.
|
|
1482
|
+
mark_expired(d, 'org-blocked',
|
|
1483
|
+
"the account's organization has disabled Claude Code access")
|
|
1484
|
+
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
1485
|
+
f'try: claude-accounts relogin {aid}')
|
|
1486
|
+
elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
|
|
1487
|
+
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
1488
|
+
hint = f' — login is dead, run: claude-accounts relogin {aid}'
|
|
1489
|
+
print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
|
|
1490
|
+
f'out={out[:120]!r} err={err!r}{hint}')
|
|
1063
1491
|
failures += 1
|
|
1064
1492
|
|
|
1065
1493
|
print()
|
|
@@ -1118,8 +1546,12 @@ PYEOF
|
|
|
1118
1546
|
ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1119
1547
|
|| fail "mkdir $id failed"
|
|
1120
1548
|
if [ -s "$d/server.token" ]; then
|
|
1121
|
-
|
|
1549
|
+
# NB: no --chmod — macOS 26 ships openrsync, which rejects it (the push would
|
|
1550
|
+
# fail outright). The mode is fixed with an explicit remote chmod instead.
|
|
1551
|
+
rsync -az "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1122
1552
|
|| fail "token push for $id failed"
|
|
1553
|
+
ssh -o BatchMode=yes "$server" "chmod 600 '$sroot/$id/server.token'" \
|
|
1554
|
+
>>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id failed"
|
|
1123
1555
|
fi
|
|
1124
1556
|
local seed
|
|
1125
1557
|
for seed in .claude.json settings.json; do
|
|
@@ -1248,6 +1680,8 @@ case "${1:-help}" in
|
|
|
1248
1680
|
remove) shift; cmd_remove "$@" ;;
|
|
1249
1681
|
mint) shift; cmd_mint "$@" ;;
|
|
1250
1682
|
login) shift; cmd_login "$@" ;;
|
|
1683
|
+
expired) shift; cmd_expired "$@" ;;
|
|
1684
|
+
relogin|re-login) shift; cmd_relogin "$@" ;;
|
|
1251
1685
|
sync) shift; cmd_sync "$@" ;;
|
|
1252
1686
|
verify) shift; cmd_verify "$@" ;;
|
|
1253
1687
|
limits) shift; cmd_limits "$@" ;;
|