claude-multiacc 2.0.7 → 2.0.8
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 +9 -3
- package/bin/claude +75 -6
- package/bin/claude-accounts +27 -10
- package/docs/ACCOUNT_OPERATIONS.md +6 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +33 -6
- package/lib/common.sh +2 -2
- package/package.json +1 -1
- package/tests/run-tests.sh +54 -1
package/README.md
CHANGED
|
@@ -70,8 +70,9 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
|
|
|
70
70
|
(Claude Code disabled for that account). Those can only fail, so they never enter
|
|
71
71
|
selection.
|
|
72
72
|
4. Among the accounts that remain valid **on this machine** (an OAuth login —
|
|
73
|
-
`.credentials.json`, or a macOS Keychain item this session can open — or
|
|
74
|
-
`server.token`
|
|
73
|
+
`.credentials.json`, or a macOS Keychain item this session can open — or a
|
|
74
|
+
`server.token` that passes its first inference preflight) and not limit-excluded,
|
|
75
|
+
pick the one with the **most remaining
|
|
75
76
|
headroom** (see below). On equal headroom the pool **rotates away from the account it
|
|
76
77
|
just handed out** and samples the rest at random — so quitting a session and starting
|
|
77
78
|
another one moves you along the pool, while a burst of parallel `claude -p` runs still
|
|
@@ -103,6 +104,10 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
|
|
|
103
104
|
- **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
|
|
104
105
|
came back *"OAuth session expired and could not be refreshed"*. Fixed by
|
|
105
106
|
`claude-accounts relogin`.
|
|
107
|
+
- **Rejected setup-token** (`EXPIRED`) — token presence and `claude auth status` do not
|
|
108
|
+
prove inference works. Before a portable token carries its first real command on a
|
|
109
|
+
machine, the shim privately verifies it. A 401 parks that exact account and reselects,
|
|
110
|
+
so direct, TUI, and `--resume` calls do not expose the rejected token's failure.
|
|
106
111
|
- **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
|
|
107
112
|
*"Your organization has disabled Claude subscription access for Claude Code"*. Handled
|
|
108
113
|
like any other dead login (`claude-accounts relogin` re-issues the grant and normally
|
|
@@ -112,7 +117,8 @@ this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
|
|
|
112
117
|
park deliberately survives credential refreshes (a new access token says nothing about
|
|
113
118
|
an org policy).
|
|
114
119
|
|
|
115
|
-
`claude-accounts expired` lists
|
|
120
|
+
`claude-accounts expired` lists what is excluded and also labels a token-only account
|
|
121
|
+
`UNVERIFIED` until `claude-accounts verify` or the shim's first-use preflight proves it.
|
|
116
122
|
Exclusion self-heals, but only against the evidence it was based on: a **credential**
|
|
117
123
|
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
|
|
118
124
|
by another process — or when a usage fetch authenticates; a **policy** park (org block)
|
package/bin/claude
CHANGED
|
@@ -559,14 +559,17 @@ client_auth_scan() { # $1 acct dir
|
|
|
559
559
|
return 1
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
|
|
563
|
-
local
|
|
562
|
+
mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail
|
|
563
|
+
local marked
|
|
564
564
|
marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
565
|
-
{ echo "$now"; echo "reason=auth-error
|
|
566
|
-
"detail=client session failed to authenticate"; } \
|
|
565
|
+
{ echo "$now"; echo "reason=auth-error marked_at=$marked detail=$2"; } \
|
|
567
566
|
2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
|
|
568
567
|
|| rm -f "$1/.expired.$$" 2>/dev/null || true
|
|
569
|
-
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
mark_client_auth_dead() { # $1 acct dir
|
|
571
|
+
mark_proven_auth_dead "$1" "client session failed to authenticate"
|
|
572
|
+
sel_log "$(basename "$1") parked (auth-error) — client-reported"
|
|
570
573
|
}
|
|
571
574
|
|
|
572
575
|
mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
|
|
@@ -717,6 +720,50 @@ acct_token() { # $1 = acct dir; prints token if the dir must authenticate by tok
|
|
|
717
720
|
fi
|
|
718
721
|
}
|
|
719
722
|
|
|
723
|
+
# A setup-token has no identity or readable expiry, and `claude auth status` only
|
|
724
|
+
# checks its shape. Test each token once on this machine with a real inference before
|
|
725
|
+
# allowing it to carry a user's command. This closes the direct/TUI/--resume gap where
|
|
726
|
+
# a revoked token exposed one 401 and was only parked on the following invocation.
|
|
727
|
+
token_digest() { # $1 token; prints a non-secret sha256 digest
|
|
728
|
+
local h=""
|
|
729
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
730
|
+
h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
|
|
731
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
732
|
+
h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"
|
|
733
|
+
fi
|
|
734
|
+
printf '%s' "$h" | cut -d ' ' -f1
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid auth
|
|
738
|
+
local d="$1" tok="$2" digest marker tmpd token_auth_pat rc=0
|
|
739
|
+
digest="$(token_digest "$tok")"
|
|
740
|
+
[ -n "$digest" ] || return 0
|
|
741
|
+
marker="$d/.server-token-verified"
|
|
742
|
+
[ -f "$marker" ] && grep -qx "$digest" "$marker" 2>/dev/null && return 0
|
|
743
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
744
|
+
tmpd="$(mktemp -d "$ACC_ROOT/tmp/token-check.XXXXXX" 2>/dev/null)" || return 0
|
|
745
|
+
printf 'Reply with exactly: OK\n' \
|
|
746
|
+
| CLAUDE_CONFIG_DIR="$d" CLAUDE_CODE_OAUTH_TOKEN="$tok" CLAUDE_SHIM_ACTIVE=1 \
|
|
747
|
+
"$REAL" -p --output-format text --max-turns 1 > "$tmpd/out" 2> "$tmpd/err" || rc=$?
|
|
748
|
+
token_auth_pat='failed to authenticate|oauth (access )?token is invalid|oauth session expired'
|
|
749
|
+
token_auth_pat="$token_auth_pat|please run /login|invalid bearer token|authentication_error"
|
|
750
|
+
if [ "$rc" -ne 0 ] \
|
|
751
|
+
&& grep -qiE "$token_auth_pat" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
752
|
+
rm -f "$marker" 2>/dev/null || true
|
|
753
|
+
mark_proven_auth_dead "$d" "portable OAuth token rejected by inference preflight"
|
|
754
|
+
sel_log "$(basename "$d") parked (auth-error) — setup-token preflight returned 401"
|
|
755
|
+
rm -rf "$tmpd"
|
|
756
|
+
return 1
|
|
757
|
+
fi
|
|
758
|
+
if [ "$rc" -eq 0 ] && LC_ALL=C grep -qx 'OK' "$tmpd/out" 2>/dev/null; then
|
|
759
|
+
{ umask 077; printf '%s\n' "$digest" > "$marker.$$"; } 2>/dev/null \
|
|
760
|
+
&& mv -f "$marker.$$" "$marker" 2>/dev/null \
|
|
761
|
+
|| rm -f "$marker.$$" 2>/dev/null || true
|
|
762
|
+
fi
|
|
763
|
+
rm -rf "$tmpd"
|
|
764
|
+
return 0
|
|
765
|
+
}
|
|
766
|
+
|
|
720
767
|
# Explicit pin wins over everything — markers, and even missing auth: the
|
|
721
768
|
# add/login ceremony pins to a dir that has no credentials yet, and the login
|
|
722
769
|
# must land exactly there, never in a randomly selected account's dir.
|
|
@@ -731,7 +778,14 @@ if [ -n "${CLAUDE_ACCOUNT:-}" ]; then
|
|
|
731
778
|
# pinned account with a stale login fail outright ("OAuth session expired") while
|
|
732
779
|
# the very same account worked unpinned.
|
|
733
780
|
tok="$(acct_token "$d")"
|
|
734
|
-
[ -n "$tok" ]
|
|
781
|
+
if [ -n "$tok" ]; then
|
|
782
|
+
if ! token_preflight "$d" "$tok"; then
|
|
783
|
+
printf 'claude-multiacc: pinned account %s has an invalid portable OAuth token (401)\n' \
|
|
784
|
+
"$CLAUDE_ACCOUNT" >&2
|
|
785
|
+
exit 1
|
|
786
|
+
fi
|
|
787
|
+
export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
|
788
|
+
fi
|
|
735
789
|
sel_capture_session "$d"
|
|
736
790
|
exec "$REAL" "$@"
|
|
737
791
|
fi
|
|
@@ -937,6 +991,21 @@ else
|
|
|
937
991
|
fi
|
|
938
992
|
fi
|
|
939
993
|
pick="$PICK_DIR"
|
|
994
|
+
|
|
995
|
+
# The preflight can prove the selected token dead without exposing its 401 to the
|
|
996
|
+
# caller. Re-enter selection so the new .expired marker is applied and another
|
|
997
|
+
# account serves the original command. The bounded depth also handles several revoked
|
|
998
|
+
# fleet tokens in a row without a loop when no usable account remains.
|
|
999
|
+
tok="$(acct_token "$pick")"
|
|
1000
|
+
if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
|
|
1001
|
+
preflight_depth="${CLAUDE_MULTIACC_PREFLIGHT_DEPTH:-0}"
|
|
1002
|
+
num_ok "$preflight_depth" || preflight_depth=0
|
|
1003
|
+
if [ "$preflight_depth" -lt 64 ]; then
|
|
1004
|
+
export CLAUDE_MULTIACC_PREFLIGHT_DEPTH=$((preflight_depth + 1))
|
|
1005
|
+
exec "$SELF" "$@"
|
|
1006
|
+
fi
|
|
1007
|
+
fi
|
|
1008
|
+
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
|
|
940
1009
|
# Remember the pick so the NEXT run does not hand back the same account. An explicit
|
|
941
1010
|
# CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
|
|
942
1011
|
# not a turn in the rotation.
|
package/bin/claude-accounts
CHANGED
|
@@ -1016,9 +1016,9 @@ cmd_login() {
|
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
1018
|
cmd_expired() {
|
|
1019
|
-
#
|
|
1020
|
-
#
|
|
1021
|
-
#
|
|
1019
|
+
# Accounts proven dead plus token-only accounts that have never passed a real call.
|
|
1020
|
+
# Presence/shape is not authentication: without strict token evidence, `expired`
|
|
1021
|
+
# falsely rendered a revoked setup-token as healthy.
|
|
1022
1022
|
require_manifest
|
|
1023
1023
|
local quiet=0
|
|
1024
1024
|
while [ $# -gt 0 ]; do
|
|
@@ -1028,7 +1028,8 @@ cmd_expired() {
|
|
|
1028
1028
|
esac
|
|
1029
1029
|
done
|
|
1030
1030
|
local rows bad
|
|
1031
|
-
rows="$(account_audit)"
|
|
1031
|
+
rows="$(account_audit strict-tokens)" \
|
|
1032
|
+
|| die "the account audit failed — cannot say which logins are dead"
|
|
1032
1033
|
if [ -z "$rows" ]; then
|
|
1033
1034
|
# No rows at all: either the pool is genuinely empty, or the manifest lost its
|
|
1034
1035
|
# accounts. Never render that as "all clear".
|
|
@@ -1038,7 +1039,8 @@ cmd_expired() {
|
|
|
1038
1039
|
fi
|
|
1039
1040
|
die "the manifest lists accounts but none could be audited — check $MANIFEST"
|
|
1040
1041
|
fi
|
|
1041
|
-
bad="$(printf '%s\n' "$rows"
|
|
1042
|
+
bad="$(printf '%s\n' "$rows" \
|
|
1043
|
+
| awk -F'\t' 'NF >= 4 && $1 != "" && $4 ~ /^(expired|blocked|missing|unverified)$/ { print $1 }')"
|
|
1042
1044
|
if [ "$quiet" = "1" ]; then
|
|
1043
1045
|
[ -n "$bad" ] || return 0
|
|
1044
1046
|
printf '%s\n' "$bad"
|
|
@@ -1056,8 +1058,8 @@ cmd_expired() {
|
|
|
1056
1058
|
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
1057
1059
|
}
|
|
1058
1060
|
END {
|
|
1059
|
-
if (bad == 0) printf "All %d account(s) with
|
|
1060
|
-
else printf "\n%d account(s)
|
|
1061
|
+
if (bad == 0) printf "All %d account(s) with auth on this machine can authenticate (verified).\n", ok
|
|
1062
|
+
else printf "\n%d account(s) are dead or unverified, %d verified.\n", bad, ok
|
|
1061
1063
|
if (remote > 0) {
|
|
1062
1064
|
printf "\nNot logged in here on purpose (another machine owns the grant):\n"
|
|
1063
1065
|
printf "%s", rem
|
|
@@ -2011,7 +2013,7 @@ cmd_verify() {
|
|
|
2011
2013
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
2012
2014
|
fi
|
|
2013
2015
|
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
2014
|
-
import json, os, re, subprocess, sys, time
|
|
2016
|
+
import hashlib, json, os, re, subprocess, sys, time
|
|
2015
2017
|
|
|
2016
2018
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
2017
2019
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
@@ -2118,10 +2120,11 @@ for acct in manifest.get('accounts', []):
|
|
|
2118
2120
|
if has_token and (not has_creds or cred_state != 'ok'):
|
|
2119
2121
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
2120
2122
|
t0 = time.time()
|
|
2123
|
+
uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
|
|
2121
2124
|
try:
|
|
2122
|
-
r = subprocess.run([real, '-p', '
|
|
2125
|
+
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
2123
2126
|
env=env, capture_output=True, text=True, timeout=240,
|
|
2124
|
-
|
|
2127
|
+
input='Reply with exactly: OK\n', cwd=root)
|
|
2125
2128
|
except subprocess.TimeoutExpired:
|
|
2126
2129
|
print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
|
|
2127
2130
|
failures += 1
|
|
@@ -2134,6 +2137,16 @@ for acct in manifest.get('accounts', []):
|
|
|
2134
2137
|
os.remove(os.path.join(d, '.expired'))
|
|
2135
2138
|
except OSError:
|
|
2136
2139
|
pass
|
|
2140
|
+
if uses_token:
|
|
2141
|
+
try:
|
|
2142
|
+
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
2143
|
+
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2144
|
+
with open(temp, 'w') as f:
|
|
2145
|
+
f.write(digest + '\n')
|
|
2146
|
+
os.chmod(temp, 0o600)
|
|
2147
|
+
os.replace(temp, os.path.join(d, '.server-token-verified'))
|
|
2148
|
+
except OSError:
|
|
2149
|
+
pass
|
|
2137
2150
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2138
2151
|
else:
|
|
2139
2152
|
err = (r.stderr or '').strip()[:200]
|
|
@@ -2147,6 +2160,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2147
2160
|
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
2148
2161
|
f'try: claude-accounts relogin {aid}')
|
|
2149
2162
|
elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
|
|
2163
|
+
try:
|
|
2164
|
+
os.remove(os.path.join(d, '.server-token-verified'))
|
|
2165
|
+
except OSError:
|
|
2166
|
+
pass
|
|
2150
2167
|
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
2151
2168
|
hint = f' — login is dead, run: claude-accounts relogin {aid}'
|
|
2152
2169
|
print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
|
|
@@ -373,6 +373,12 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
373
373
|
automatically, so this only surfaces when *every* account needs a re-login (the shim
|
|
374
374
|
then falls back to the machine's own `~/.claude` login) or when it was pinned with
|
|
375
375
|
`CLAUDE_ACCOUNT`.
|
|
376
|
+
- **"Please run /login · API Error: 401 OAuth access token is invalid"** — the portable
|
|
377
|
+
setup-token is rejected even though its presence and `claude auth status` look healthy.
|
|
378
|
+
The shim now runs a private first-use inference, parks a rejected token, and reselects
|
|
379
|
+
before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
|
|
380
|
+
check every token immediately; `claude-accounts expired` reports token-only accounts
|
|
381
|
+
as `UNVERIFIED` until that proof exists.
|
|
376
382
|
- **Everything marked limited** — the shim still runs (least-utilized fallback);
|
|
377
383
|
check `selection.log` for `all-limited` lines.
|
|
378
384
|
- **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/audit.py
CHANGED
|
@@ -12,6 +12,7 @@ States:
|
|
|
12
12
|
neither dead nor missing; just not usable from here
|
|
13
13
|
missing no auth on this machine, and this machine is supposed to own the grant
|
|
14
14
|
remote no auth here, but the manifest says another machine owns it (informational)
|
|
15
|
+
unverified a setup-token exists but has not passed inference on this machine
|
|
15
16
|
|
|
16
17
|
Only 'ok' accounts are selectable; everything else is excluded by the shim.
|
|
17
18
|
|
|
@@ -25,6 +26,7 @@ Run directly for a TSV dump: python3 lib/audit.py <acc-root> [mac|linux]
|
|
|
25
26
|
Columns: id, email, home, state, label, reason, fix
|
|
26
27
|
"""
|
|
27
28
|
|
|
29
|
+
import hashlib
|
|
28
30
|
import json
|
|
29
31
|
import os
|
|
30
32
|
import re
|
|
@@ -192,7 +194,7 @@ def oauth_login(d, now):
|
|
|
192
194
|
|
|
193
195
|
|
|
194
196
|
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED',
|
|
195
|
-
'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
|
|
197
|
+
'missing': 'NO LOGIN', 'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}
|
|
196
198
|
|
|
197
199
|
# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
|
|
198
200
|
# two vocabularies — normalize, or an un-authenticated account on the very machine that
|
|
@@ -227,10 +229,22 @@ def _fix_for(state, aid):
|
|
|
227
229
|
# stuck in an ssh session CAN do is mint the portable token from here — the
|
|
228
230
|
# setup-token ceremony is a fresh browser grant and never reads the keychain.
|
|
229
231
|
return f'claude-accounts mint {aid} (portable; or use it from the Mac\'s GUI session)'
|
|
232
|
+
if state == 'unverified':
|
|
233
|
+
return 'claude-accounts verify'
|
|
230
234
|
return ''
|
|
231
235
|
|
|
232
236
|
|
|
233
|
-
def
|
|
237
|
+
def _token_verified(d, tpath):
|
|
238
|
+
"""Whether this exact setup-token passed an inference on this machine."""
|
|
239
|
+
try:
|
|
240
|
+
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
241
|
+
saved = open(os.path.join(d, '.server-token-verified')).read().strip()
|
|
242
|
+
return bool(digest) and saved == digest
|
|
243
|
+
except OSError:
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def audit_account(root, acct, now=None, machine=None, require_verified_token=False):
|
|
234
248
|
now = time.time() if now is None else now
|
|
235
249
|
aid = acct.get('id', '')
|
|
236
250
|
d = os.path.join(root, aid)
|
|
@@ -263,14 +277,23 @@ def audit_account(root, acct, now=None, machine=None):
|
|
|
263
277
|
if state == 'ok' or has_token:
|
|
264
278
|
# A portable token authenticates on its own, so a dead credential beside it
|
|
265
279
|
# is not fatal — same rule the shim applies.
|
|
266
|
-
|
|
267
|
-
|
|
280
|
+
if state != 'ok' and require_verified_token and not _token_verified(d, tpath):
|
|
281
|
+
row['state'] = 'unverified'
|
|
282
|
+
row['reason'] = 'server.token has not passed an inference on this machine'
|
|
283
|
+
else:
|
|
284
|
+
row['state'] = 'ok'
|
|
285
|
+
row['reason'] = reason if state == 'ok' else f'{reason}; using server.token'
|
|
268
286
|
else:
|
|
269
287
|
row['state'], row['reason'] = 'expired', reason
|
|
270
288
|
return done(row)
|
|
271
289
|
if has_token:
|
|
272
290
|
age = int((now - _mtime(tpath)) / 86400)
|
|
273
|
-
|
|
291
|
+
if require_verified_token and not _token_verified(d, tpath):
|
|
292
|
+
row['state'] = 'unverified'
|
|
293
|
+
row['reason'] = (f'server.token present (minted ~{age}d ago) but has not passed '
|
|
294
|
+
'an inference on this machine')
|
|
295
|
+
else:
|
|
296
|
+
row['reason'] = f'server.token present (minted ~{age}d ago)'
|
|
274
297
|
return done(row)
|
|
275
298
|
if login['state'] == 'locked':
|
|
276
299
|
row['state'], row['reason'] = 'locked', login['reason']
|
|
@@ -299,7 +322,11 @@ def audit_all(root, machine=None, now=None):
|
|
|
299
322
|
if __name__ == '__main__':
|
|
300
323
|
root = sys.argv[1]
|
|
301
324
|
machine = sys.argv[2] if len(sys.argv) > 2 else None
|
|
302
|
-
|
|
325
|
+
strict_tokens = len(sys.argv) > 3 and sys.argv[3] == 'strict-tokens'
|
|
326
|
+
for a in json.load(open(os.path.join(root, 'accounts.json'))).get('accounts', []):
|
|
327
|
+
if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
|
|
328
|
+
continue
|
|
329
|
+
r = audit_account(root, a, machine=machine, require_verified_token=strict_tokens)
|
|
303
330
|
# Tabs are the field separator, so no field may contain one.
|
|
304
331
|
print('\t'.join(str(r[k]).replace('\t', ' ')
|
|
305
332
|
for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
|
package/lib/common.sh
CHANGED
|
@@ -499,11 +499,11 @@ clear_auth_markers() { # $1 = acct dir
|
|
|
499
499
|
# Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
|
|
500
500
|
# silently empty audit reads exactly like a perfectly healthy pool.
|
|
501
501
|
account_audit() {
|
|
502
|
-
local out rc err="$ACC_ROOT/tmp/audit.$$.err"
|
|
502
|
+
local mode="${1:-}" out rc err="$ACC_ROOT/tmp/audit.$$.err"
|
|
503
503
|
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
504
504
|
# stderr goes to a file, never into the rows — a stray warning must not become a
|
|
505
505
|
# bogus account line.
|
|
506
|
-
out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
|
|
506
|
+
out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" "$mode" 2>"$err")"
|
|
507
507
|
rc=$?
|
|
508
508
|
if [ "$rc" -ne 0 ]; then
|
|
509
509
|
printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -84,6 +84,14 @@ if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}"
|
|
|
84
84
|
fi
|
|
85
85
|
ctl="${FAKE_CTL:-/nonexistent}"
|
|
86
86
|
acct="$(basename "${CLAUDE_CONFIG_DIR:-none}")"
|
|
87
|
+
case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
88
|
+
*REVOKED*)
|
|
89
|
+
echo "Please run /login · API Error: 401 OAuth access token is invalid." >&2
|
|
90
|
+
exit 1 ;;
|
|
91
|
+
esac
|
|
92
|
+
case " $* " in
|
|
93
|
+
*" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
|
|
94
|
+
esac
|
|
87
95
|
if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
|
|
88
96
|
echo "API Error: 429 rate limit exceeded" >&2
|
|
89
97
|
exit 1
|
|
@@ -492,6 +500,40 @@ out="$(claude 2>&1)"
|
|
|
492
500
|
check "dead creds + portable token still authenticate" "TOK=sk-ant-oat01-rescue-token" "$out"
|
|
493
501
|
rm -f "$ACC/acct-01/server.token"
|
|
494
502
|
|
|
503
|
+
# A revoked setup-token must be rejected BEFORE it carries the user's real command.
|
|
504
|
+
# The 401 stays inside the preflight, the account gets a durable auth marker, and the
|
|
505
|
+
# untouched --resume invocation continues on the next healthy account.
|
|
506
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
|
|
507
|
+
printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
|
|
508
|
+
printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
|
|
509
|
+
printf '{"fetched_at":%s,"max_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
510
|
+
printf '{"fetched_at":%s,"max_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
511
|
+
rm -f "$ACC/.last-pick" "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
|
|
512
|
+
out="$(claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
|
|
513
|
+
check "revoked setup-token fails over before --resume" "CFG=acct-02" "$out"
|
|
514
|
+
case "$out" in
|
|
515
|
+
*"API Error: 401"*) t_fail "revoked setup-token hides preflight 401" "401 reached caller" ;;
|
|
516
|
+
*) t_ok "revoked setup-token hides preflight 401" ;;
|
|
517
|
+
esac
|
|
518
|
+
grep -q 'reason=auth-error' "$ACC/acct-01/.expired" 2>/dev/null \
|
|
519
|
+
&& t_ok "revoked setup-token writes .expired" \
|
|
520
|
+
|| t_fail "revoked setup-token marker" ".expired missing"
|
|
521
|
+
if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
|
|
522
|
+
t_fail "proven token rejection is durable" "marker has a soft expiry"
|
|
523
|
+
else
|
|
524
|
+
t_ok "proven token rejection is durable"
|
|
525
|
+
fi
|
|
526
|
+
out="$(CLAUDE_ACCOUNT=acct-01 claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
|
|
527
|
+
rc=$?
|
|
528
|
+
check "pinned revoked setup-token is classified before --resume" \
|
|
529
|
+
"invalid portable OAuth token (401)" "$out"
|
|
530
|
+
[ "$rc" -ne 0 ] && t_ok "pinned revoked setup-token exits nonzero" \
|
|
531
|
+
|| t_fail "pinned revoked setup-token rc" "rc=$rc"
|
|
532
|
+
rm -f "$ACC/acct-01/server.token" "$ACC/acct-01/.expired" \
|
|
533
|
+
"$ACC/acct-01/.server-token-verified" "$ACC"/acct-*/limits.json
|
|
534
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
|
|
535
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-02/.credentials.json"
|
|
536
|
+
|
|
495
537
|
# every account dead => stock passthrough. The reason lands in selection.log; the
|
|
496
538
|
# stderr hint is terminal-only, so a service-spawned `claude -p` stays byte-clean.
|
|
497
539
|
out="$(claude 2>&1)"
|
|
@@ -2416,12 +2458,23 @@ check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
|
2416
2458
|
# account and park it.
|
|
2417
2459
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
2418
2460
|
printf 'sk-ant-oat01-token-for-01' > "$ACC/acct-01/server.token"
|
|
2419
|
-
rm -f "$ACC/acct-01/.expired"
|
|
2461
|
+
rm -f "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
|
|
2462
|
+
out="$(claude-accounts expired 2>&1)"
|
|
2463
|
+
check "expired does not call an unverified setup-token healthy" "acct-01" "$out"
|
|
2464
|
+
check "expired labels an unverified setup-token" "UNVERIFIED" "$out"
|
|
2465
|
+
check "expired gives the setup-token verification fix" "claude-accounts verify" "$out"
|
|
2420
2466
|
out="$(claude-accounts verify 2>&1)"
|
|
2421
2467
|
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
|
|
2422
2468
|
*) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
|
|
2423
2469
|
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
|
|
2424
2470
|
|| t_fail "verify token fallback" "healthy token-auth account was parked"
|
|
2471
|
+
[ -f "$ACC/acct-01/.server-token-verified" ] \
|
|
2472
|
+
&& t_ok "verify records proof for the exact setup-token" \
|
|
2473
|
+
|| t_fail "verify setup-token proof" ".server-token-verified missing"
|
|
2474
|
+
state="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" "$(uname -s | tr '[:upper:]' '[:lower:]')" \
|
|
2475
|
+
strict-tokens | awk -F'\t' '$1=="acct-01"{print $4}')"
|
|
2476
|
+
[ "$state" = "ok" ] && t_ok "verified setup-token is no longer false-unknown" \
|
|
2477
|
+
|| t_fail "verified setup-token audit" "expected ok, got: $state"
|
|
2425
2478
|
rm -f "$ACC/acct-01/server.token"
|
|
2426
2479
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
2427
2480
|
|