claude-multiacc 2.0.7 → 2.0.9
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 +40 -12
- 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 +59 -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,17 +1039,25 @@ 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"
|
|
1045
1047
|
return 1
|
|
1046
1048
|
fi
|
|
1047
1049
|
printf '%s\n' "$rows" | awk -F'\t' '
|
|
1048
|
-
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0 }
|
|
1050
|
+
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; unverified = 0 }
|
|
1049
1051
|
NF < 4 || $1 == "" { next } # never invent an account from a blank line
|
|
1050
1052
|
$4 == "ok" { ok++; next }
|
|
1051
1053
|
$4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
|
|
1054
|
+
$4 == "unverified" {
|
|
1055
|
+
bad++
|
|
1056
|
+
unverified++
|
|
1057
|
+
printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
|
|
1058
|
+
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
1059
|
+
next
|
|
1060
|
+
}
|
|
1052
1061
|
{
|
|
1053
1062
|
bad++
|
|
1054
1063
|
relogin++
|
|
@@ -1056,19 +1065,23 @@ cmd_expired() {
|
|
|
1056
1065
|
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
1057
1066
|
}
|
|
1058
1067
|
END {
|
|
1059
|
-
if (bad == 0) printf "All %d account(s) with
|
|
1060
|
-
else printf "\n%d account(s)
|
|
1068
|
+
if (bad == 0) printf "All %d account(s) with auth on this machine can authenticate (verified).\n", ok
|
|
1069
|
+
else printf "\n%d account(s) are dead or unverified, %d verified.\n", bad, ok
|
|
1061
1070
|
if (remote > 0) {
|
|
1062
1071
|
printf "\nNot logged in here on purpose (another machine owns the grant):\n"
|
|
1063
1072
|
printf "%s", rem
|
|
1064
1073
|
}
|
|
1074
|
+
if (unverified > 0) {
|
|
1075
|
+
printf "\nVerify portable tokens:\n"
|
|
1076
|
+
printf " claude-accounts verify\n"
|
|
1077
|
+
}
|
|
1065
1078
|
if (relogin > 0) {
|
|
1066
1079
|
printf "\nRe-authenticate them:\n"
|
|
1067
1080
|
printf " claude-accounts relogin # every account that needs it\n"
|
|
1068
1081
|
printf " claude-accounts relogin acct-NN # just one\n"
|
|
1069
1082
|
}
|
|
1070
1083
|
}'
|
|
1071
|
-
# Exit 1 when something needs
|
|
1084
|
+
# Exit 1 when something needs attention, so cron/health checks can alert on it.
|
|
1072
1085
|
[ -z "$bad" ]
|
|
1073
1086
|
}
|
|
1074
1087
|
|
|
@@ -2011,7 +2024,7 @@ cmd_verify() {
|
|
|
2011
2024
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
2012
2025
|
fi
|
|
2013
2026
|
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
2014
|
-
import json, os, re, subprocess, sys, time
|
|
2027
|
+
import hashlib, json, os, re, subprocess, sys, time
|
|
2015
2028
|
|
|
2016
2029
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
2017
2030
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
@@ -2118,10 +2131,11 @@ for acct in manifest.get('accounts', []):
|
|
|
2118
2131
|
if has_token and (not has_creds or cred_state != 'ok'):
|
|
2119
2132
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
2120
2133
|
t0 = time.time()
|
|
2134
|
+
uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
|
|
2121
2135
|
try:
|
|
2122
|
-
r = subprocess.run([real, '-p', '
|
|
2136
|
+
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
2123
2137
|
env=env, capture_output=True, text=True, timeout=240,
|
|
2124
|
-
|
|
2138
|
+
input='Reply with exactly: OK\n', cwd=root)
|
|
2125
2139
|
except subprocess.TimeoutExpired:
|
|
2126
2140
|
print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
|
|
2127
2141
|
failures += 1
|
|
@@ -2134,6 +2148,16 @@ for acct in manifest.get('accounts', []):
|
|
|
2134
2148
|
os.remove(os.path.join(d, '.expired'))
|
|
2135
2149
|
except OSError:
|
|
2136
2150
|
pass
|
|
2151
|
+
if uses_token:
|
|
2152
|
+
try:
|
|
2153
|
+
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
2154
|
+
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2155
|
+
with open(temp, 'w') as f:
|
|
2156
|
+
f.write(digest + '\n')
|
|
2157
|
+
os.chmod(temp, 0o600)
|
|
2158
|
+
os.replace(temp, os.path.join(d, '.server-token-verified'))
|
|
2159
|
+
except OSError:
|
|
2160
|
+
pass
|
|
2137
2161
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2138
2162
|
else:
|
|
2139
2163
|
err = (r.stderr or '').strip()[:200]
|
|
@@ -2147,6 +2171,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2147
2171
|
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
2148
2172
|
f'try: claude-accounts relogin {aid}')
|
|
2149
2173
|
elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
|
|
2174
|
+
try:
|
|
2175
|
+
os.remove(os.path.join(d, '.server-token-verified'))
|
|
2176
|
+
except OSError:
|
|
2177
|
+
pass
|
|
2150
2178
|
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
2151
2179
|
hint = f' — login is dead, run: claude-accounts relogin {aid}'
|
|
2152
2180
|
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,28 @@ 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"
|
|
2466
|
+
case "$out" in
|
|
2467
|
+
*"Re-authenticate them"*)
|
|
2468
|
+
t_fail "unverified setup-token does not demand re-login" "expired gave the wrong fix" ;;
|
|
2469
|
+
*) t_ok "unverified setup-token does not demand re-login" ;;
|
|
2470
|
+
esac
|
|
2420
2471
|
out="$(claude-accounts verify 2>&1)"
|
|
2421
2472
|
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
|
|
2422
2473
|
*) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
|
|
2423
2474
|
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
|
|
2424
2475
|
|| t_fail "verify token fallback" "healthy token-auth account was parked"
|
|
2476
|
+
[ -f "$ACC/acct-01/.server-token-verified" ] \
|
|
2477
|
+
&& t_ok "verify records proof for the exact setup-token" \
|
|
2478
|
+
|| t_fail "verify setup-token proof" ".server-token-verified missing"
|
|
2479
|
+
state="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" "$(uname -s | tr '[:upper:]' '[:lower:]')" \
|
|
2480
|
+
strict-tokens | awk -F'\t' '$1=="acct-01"{print $4}')"
|
|
2481
|
+
[ "$state" = "ok" ] && t_ok "verified setup-token is no longer false-unknown" \
|
|
2482
|
+
|| t_fail "verified setup-token audit" "expected ok, got: $state"
|
|
2425
2483
|
rm -f "$ACC/acct-01/server.token"
|
|
2426
2484
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
2427
2485
|
|