claude-multiacc 1.0.5 → 1.0.7
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 +89 -11
- package/bin/claude +165 -12
- package/bin/claude-accounts +345 -18
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +411 -0
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)
|
|
@@ -136,10 +148,13 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
|
136
148
|
|
|
137
149
|
cmd_list() {
|
|
138
150
|
require_manifest
|
|
139
|
-
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
151
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
140
152
|
import json, os, re, sys
|
|
141
153
|
doc = json.load(open(sys.argv[1]))
|
|
142
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]
|
|
143
158
|
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
|
|
144
159
|
accounts = [a for a in doc.get('accounts', [])
|
|
145
160
|
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
@@ -153,7 +168,24 @@ for a in accounts:
|
|
|
153
168
|
if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
|
|
154
169
|
auth.append('token')
|
|
155
170
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
156
|
-
|
|
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")
|
|
157
189
|
seen = {}
|
|
158
190
|
for a in accounts:
|
|
159
191
|
seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
@@ -167,10 +199,13 @@ PYEOF
|
|
|
167
199
|
|
|
168
200
|
cmd_status() {
|
|
169
201
|
require_manifest
|
|
170
|
-
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF'
|
|
202
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
171
203
|
import json, os, sys, time
|
|
172
204
|
doc = json.load(open(sys.argv[1]))
|
|
173
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]
|
|
174
209
|
now = time.time()
|
|
175
210
|
|
|
176
211
|
def last_pick(aid):
|
|
@@ -192,10 +227,20 @@ print(f"pool root : {root}")
|
|
|
192
227
|
print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
|
|
193
228
|
print(f"threshold : {doc.get('threshold', 90)}% (any bucket at/above => account excluded)")
|
|
194
229
|
print()
|
|
230
|
+
needs_login = []
|
|
195
231
|
for a in doc.get('accounts', []):
|
|
196
232
|
aid = a['id']
|
|
197
233
|
d = os.path.join(root, aid)
|
|
198
|
-
|
|
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']}")
|
|
199
244
|
cpath = os.path.join(d, '.credentials.json')
|
|
200
245
|
if os.path.isfile(cpath):
|
|
201
246
|
try:
|
|
@@ -241,6 +286,9 @@ for a in doc.get('accounts', []):
|
|
|
241
286
|
print(" marker : none (eligible)")
|
|
242
287
|
print(f" last picked : {last_pick(aid)}")
|
|
243
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")
|
|
244
292
|
PYEOF
|
|
245
293
|
}
|
|
246
294
|
|
|
@@ -331,6 +379,7 @@ cmd_add() {
|
|
|
331
379
|
warn "signed in as $got (you named $email) — registering the account that actually authenticated"
|
|
332
380
|
fi
|
|
333
381
|
manifest_add_account "$id" "$got" "$(machine_kind)"
|
|
382
|
+
clear_auth_markers "$d"
|
|
334
383
|
mutate_unlock
|
|
335
384
|
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
336
385
|
trap - EXIT INT TERM
|
|
@@ -640,7 +689,12 @@ TIP
|
|
|
640
689
|
else
|
|
641
690
|
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
|
|
642
691
|
fi
|
|
643
|
-
|
|
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"
|
|
644
698
|
}
|
|
645
699
|
|
|
646
700
|
cmd_mint() {
|
|
@@ -665,6 +719,7 @@ cmd_mint() {
|
|
|
665
719
|
|| die "that is not a subscription setup-token (sk-ant-oat...). API keys are not supported."
|
|
666
720
|
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
667
721
|
chmod 600 "$d/server.token"
|
|
722
|
+
clear_auth_markers "$d"
|
|
668
723
|
log_to ops.log "mint $id"
|
|
669
724
|
echo "Token saved to $d/server.token"
|
|
670
725
|
auto_sync
|
|
@@ -706,19 +761,159 @@ PYEOF
|
|
|
706
761
|
fi
|
|
707
762
|
( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
|
|
708
763
|
chmod 600 "$d/server.token"
|
|
764
|
+
clear_auth_markers "$d"
|
|
709
765
|
echo "$id token saved (portable — works on Mac and server)."
|
|
710
766
|
else
|
|
711
|
-
run_login_ceremony "$d" "$email"
|
|
767
|
+
run_login_ceremony "$d" "$email" \
|
|
768
|
+
|| die "login failed or aborted (no working credential landed) — nothing changed"
|
|
712
769
|
got="$(config_dir_email "$d")"
|
|
713
770
|
if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
|
|
714
771
|
die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
|
|
715
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"
|
|
716
777
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
717
778
|
fi
|
|
718
779
|
log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
|
|
719
780
|
auto_sync
|
|
720
781
|
}
|
|
721
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
|
+
|
|
722
917
|
cmd_limits() {
|
|
723
918
|
require_manifest
|
|
724
919
|
local quiet=0 force=0
|
|
@@ -791,6 +986,37 @@ def parse_iso(s):
|
|
|
791
986
|
except Exception:
|
|
792
987
|
return None
|
|
793
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
|
+
|
|
794
1020
|
try:
|
|
795
1021
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
796
1022
|
except Exception as e:
|
|
@@ -812,12 +1038,20 @@ def refresh_oauth(aid, d, cpath):
|
|
|
812
1038
|
return None
|
|
813
1039
|
except Exception:
|
|
814
1040
|
return None
|
|
815
|
-
if not o.get('
|
|
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')
|
|
816
1046
|
return None
|
|
817
1047
|
if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
|
|
818
1048
|
return None # not expired long enough to prove no live session owns it
|
|
819
1049
|
if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
|
|
820
|
-
|
|
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})')
|
|
821
1055
|
return None
|
|
822
1056
|
if not force:
|
|
823
1057
|
try:
|
|
@@ -826,10 +1060,11 @@ def refresh_oauth(aid, d, cpath):
|
|
|
826
1060
|
except Exception:
|
|
827
1061
|
pass
|
|
828
1062
|
|
|
829
|
-
def back_off(wait, why):
|
|
1063
|
+
def back_off(wait, why, denials=0):
|
|
830
1064
|
try:
|
|
831
1065
|
with open(spath + '.tmp', 'w') as f:
|
|
832
1066
|
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
1067
|
+
'denials': denials,
|
|
833
1068
|
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
834
1069
|
os.replace(spath + '.tmp', spath)
|
|
835
1070
|
except Exception:
|
|
@@ -847,8 +1082,31 @@ def refresh_oauth(aid, d, cpath):
|
|
|
847
1082
|
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
848
1083
|
except urllib.error.HTTPError as e:
|
|
849
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')
|
|
850
1107
|
back_off(REFRESH_DENIED_BACKOFF,
|
|
851
|
-
f'HTTP {e.code} — refresh token may be revoked; re-login needed'
|
|
1108
|
+
f'HTTP {e.code} — refresh token may be revoked; re-login needed',
|
|
1109
|
+
denials=denials)
|
|
852
1110
|
else:
|
|
853
1111
|
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
854
1112
|
return None
|
|
@@ -885,6 +1143,9 @@ def refresh_oauth(aid, d, cpath):
|
|
|
885
1143
|
os.remove(spath)
|
|
886
1144
|
except OSError:
|
|
887
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)')
|
|
888
1149
|
say(f'{aid}: oauth access token refreshed via refresh-token grant')
|
|
889
1150
|
return tok
|
|
890
1151
|
|
|
@@ -1065,6 +1326,9 @@ for acct in manifest.get('accounts', []):
|
|
|
1065
1326
|
with open(tmp, 'w') as f:
|
|
1066
1327
|
json.dump(out, f, indent=1)
|
|
1067
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)')
|
|
1068
1332
|
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
1069
1333
|
mpath = os.path.join(d, '.limited')
|
|
1070
1334
|
if offenders:
|
|
@@ -1107,15 +1371,40 @@ cmd_verify() {
|
|
|
1107
1371
|
if [ "$quick" = "0" ]; then
|
|
1108
1372
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
1109
1373
|
fi
|
|
1110
|
-
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" <<'PYEOF'
|
|
1111
|
-
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
|
|
1112
1376
|
|
|
1113
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]
|
|
1114
1381
|
now = time.time()
|
|
1115
1382
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
1116
1383
|
failures = 0
|
|
1117
1384
|
tested = 0
|
|
1118
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
|
+
|
|
1119
1408
|
for acct in manifest.get('accounts', []):
|
|
1120
1409
|
aid = acct['id']
|
|
1121
1410
|
d = os.path.join(root, aid)
|
|
@@ -1131,8 +1420,11 @@ for acct in manifest.get('accounts', []):
|
|
|
1131
1420
|
try:
|
|
1132
1421
|
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
1133
1422
|
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
1134
|
-
if rexp and rexp < now:
|
|
1135
|
-
|
|
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})')
|
|
1136
1428
|
failures += 1
|
|
1137
1429
|
continue
|
|
1138
1430
|
except Exception as e:
|
|
@@ -1140,8 +1432,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1140
1432
|
failures += 1
|
|
1141
1433
|
continue
|
|
1142
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)
|
|
1143
1438
|
kind = 'oauth' if has_creds else 'token'
|
|
1144
|
-
|
|
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
|
|
1145
1445
|
continue
|
|
1146
1446
|
env = dict(os.environ)
|
|
1147
1447
|
env['CLAUDE_CONFIG_DIR'] = d
|
|
@@ -1149,7 +1449,10 @@ for acct in manifest.get('accounts', []):
|
|
|
1149
1449
|
env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)
|
|
1150
1450
|
env.pop('CLAUDE_ACCOUNT', None)
|
|
1151
1451
|
env['CLAUDE_SHIM_ACTIVE'] = '1'
|
|
1152
|
-
|
|
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'):
|
|
1153
1456
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
1154
1457
|
t0 = time.time()
|
|
1155
1458
|
try:
|
|
@@ -1163,10 +1466,28 @@ for acct in manifest.get('accounts', []):
|
|
|
1163
1466
|
dt = time.time() - t0
|
|
1164
1467
|
out = (r.stdout or '').strip()
|
|
1165
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
|
|
1166
1474
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
1167
1475
|
else:
|
|
1168
1476
|
err = (r.stderr or '').strip()[:200]
|
|
1169
|
-
|
|
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}')
|
|
1170
1491
|
failures += 1
|
|
1171
1492
|
|
|
1172
1493
|
print()
|
|
@@ -1225,8 +1546,12 @@ PYEOF
|
|
|
1225
1546
|
ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1226
1547
|
|| fail "mkdir $id failed"
|
|
1227
1548
|
if [ -s "$d/server.token" ]; then
|
|
1228
|
-
|
|
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 \
|
|
1229
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"
|
|
1230
1555
|
fi
|
|
1231
1556
|
local seed
|
|
1232
1557
|
for seed in .claude.json settings.json; do
|
|
@@ -1355,6 +1680,8 @@ case "${1:-help}" in
|
|
|
1355
1680
|
remove) shift; cmd_remove "$@" ;;
|
|
1356
1681
|
mint) shift; cmd_mint "$@" ;;
|
|
1357
1682
|
login) shift; cmd_login "$@" ;;
|
|
1683
|
+
expired) shift; cmd_expired "$@" ;;
|
|
1684
|
+
relogin|re-login) shift; cmd_relogin "$@" ;;
|
|
1358
1685
|
sync) shift; cmd_sync "$@" ;;
|
|
1359
1686
|
verify) shift; cmd_verify "$@" ;;
|
|
1360
1687
|
limits) shift; cmd_limits "$@" ;;
|