claude-multiacc 2.0.6 → 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 +74 -18
- 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 +119 -8
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
|
|
@@ -1273,6 +1275,7 @@ cmd_limits() {
|
|
|
1273
1275
|
import hashlib, json, os, sys, time, urllib.request
|
|
1274
1276
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1275
1277
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1278
|
+
from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
|
|
1276
1279
|
|
|
1277
1280
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
1278
1281
|
now = time.time()
|
|
@@ -1655,6 +1658,18 @@ for acct in manifest.get('accounts', []):
|
|
|
1655
1658
|
locked = store == 'locked'
|
|
1656
1659
|
if locked:
|
|
1657
1660
|
store = None
|
|
1661
|
+
# "This account has an OAuth login that could still work." A Keychain-held one
|
|
1662
|
+
# only the Mac's own launchd probe can open counts: it is unusable HERE, not
|
|
1663
|
+
# broken. A provably DEAD grant (no refresh token, or a refresh token that has
|
|
1664
|
+
# itself expired) does not count — for that account the setup token really is
|
|
1665
|
+
# the only bearer left, and it must still get its one try.
|
|
1666
|
+
oauth_alive = locked
|
|
1667
|
+
if store is not None:
|
|
1668
|
+
try:
|
|
1669
|
+
oauth_alive = creds_doc_state(store.read(), now)[0] == 'ok'
|
|
1670
|
+
except Exception:
|
|
1671
|
+
oauth_alive = True # unreadable is not proof of death: do not spend the token
|
|
1672
|
+
has_oauth = oauth_alive
|
|
1658
1673
|
if store is not None:
|
|
1659
1674
|
try:
|
|
1660
1675
|
c = store.read().get('claudeAiOauth', {})
|
|
@@ -1672,7 +1687,19 @@ for acct in manifest.get('accounts', []):
|
|
|
1672
1687
|
tok = None
|
|
1673
1688
|
if tok:
|
|
1674
1689
|
bearer, source = tok, 'oauth'
|
|
1675
|
-
|
|
1690
|
+
# A setup token is a LAST RESORT and only for an account that has no OAuth
|
|
1691
|
+
# credential at all. Where one exists but could not be used this pass — its
|
|
1692
|
+
# access token expired moments ago (refresh_oauth waits REFRESH_MIN_EXPIRED to
|
|
1693
|
+
# prove no live session owns it), a refresh backoff, or a Keychain this session
|
|
1694
|
+
# cannot open — spending the token is guaranteed to earn a 403 it can never not
|
|
1695
|
+
# earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
|
|
1696
|
+
# froze for an account whose OAuth would have worked on the very next pass, and
|
|
1697
|
+
# the shim ranked the pool on hour-old readings. Waiting for the next pass costs
|
|
1698
|
+
# minutes; the token costs six hours and answers nothing.
|
|
1699
|
+
if not bearer and has_oauth:
|
|
1700
|
+
say(f'{aid}: oauth credential not usable this pass; NOT spending the setup '
|
|
1701
|
+
f'token on a usage endpoint that always refuses it — retrying next pass')
|
|
1702
|
+
elif not bearer and os.path.isfile(tpath):
|
|
1676
1703
|
# Once this endpoint has refused THIS token file for lacking a scope, asking
|
|
1677
1704
|
# again is guaranteed to fail and only spends the account's hourly budget —
|
|
1678
1705
|
# which is how a permanent authorization problem disguised itself as a rate
|
|
@@ -1717,6 +1744,14 @@ for acct in manifest.get('accounts', []):
|
|
|
1717
1744
|
except Exception:
|
|
1718
1745
|
pass
|
|
1719
1746
|
|
|
1747
|
+
def save_prev():
|
|
1748
|
+
"""Persist the account's telemetry state as-is (no backoff, no invented
|
|
1749
|
+
freshness) — used when what failed says nothing about the ACCOUNT."""
|
|
1750
|
+
tmp = lpath + '.tmp'
|
|
1751
|
+
with open(tmp, 'w') as f:
|
|
1752
|
+
json.dump(prev, f, indent=1)
|
|
1753
|
+
os.replace(tmp, lpath)
|
|
1754
|
+
|
|
1720
1755
|
def park(wait, note):
|
|
1721
1756
|
"""Record a failed fetch WITHOUT inventing freshness: fetched_at is left
|
|
1722
1757
|
exactly as it was, so a parked account still reads as stale everywhere."""
|
|
@@ -1724,10 +1759,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1724
1759
|
prev['backoff'] = wait
|
|
1725
1760
|
prev['last_error'] = note
|
|
1726
1761
|
prev['last_error_at'] = int(now)
|
|
1727
|
-
|
|
1728
|
-
with open(tmp, 'w') as f:
|
|
1729
|
-
json.dump(prev, f, indent=1)
|
|
1730
|
-
os.replace(tmp, lpath)
|
|
1762
|
+
save_prev()
|
|
1731
1763
|
|
|
1732
1764
|
if e.code == 429:
|
|
1733
1765
|
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
@@ -1765,8 +1797,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1765
1797
|
tdigest = token_digest(tpath)
|
|
1766
1798
|
if tdigest:
|
|
1767
1799
|
prev['token_scope_denied'] = tdigest
|
|
1768
|
-
|
|
1769
|
-
|
|
1800
|
+
if scope_denied and source == 'token':
|
|
1801
|
+
# A refusal of the TOKEN is a fact about that credential, not about
|
|
1802
|
+
# the account: parking the account here also blocked the OAuth path,
|
|
1803
|
+
# so an account that merely missed one refresh went dark for six
|
|
1804
|
+
# hours. The digest above already stops this token being spent again;
|
|
1805
|
+
# leave the account free to try its OAuth login next pass.
|
|
1806
|
+
save_prev()
|
|
1807
|
+
else:
|
|
1808
|
+
park(wait, f'HTTP {e.code} (source={source})'
|
|
1809
|
+
+ (' — permanent, server said do not retry' if denied else ''))
|
|
1770
1810
|
if scope_denied and source == 'token':
|
|
1771
1811
|
# The exact shape of this outage: a setup token is minted WITHOUT the
|
|
1772
1812
|
# user:profile scope the usage endpoint requires, so an account whose
|
|
@@ -1775,7 +1815,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1775
1815
|
say(f'{aid}: usage endpoint refuses the setup token (HTTP {e.code} — a '
|
|
1776
1816
|
f'setup token has no user:profile scope). Telemetry is DEAD for this '
|
|
1777
1817
|
f'account until it has an OAuth login here: claude-accounts login {aid}. '
|
|
1778
|
-
f'
|
|
1818
|
+
f'This token will not be offered again; the ACCOUNT is not parked, so '
|
|
1819
|
+
f'an OAuth login works the moment it lands.')
|
|
1779
1820
|
elif denied:
|
|
1780
1821
|
say(f'{aid}: usage fetch refused for good (HTTP {e.code}, source={source}); '
|
|
1781
1822
|
f'backing off {wait}s — re-login needed: claude-accounts login {aid}')
|
|
@@ -1972,7 +2013,7 @@ cmd_verify() {
|
|
|
1972
2013
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
1973
2014
|
fi
|
|
1974
2015
|
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
1975
|
-
import json, os, re, subprocess, sys, time
|
|
2016
|
+
import hashlib, json, os, re, subprocess, sys, time
|
|
1976
2017
|
|
|
1977
2018
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
1978
2019
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
@@ -2079,10 +2120,11 @@ for acct in manifest.get('accounts', []):
|
|
|
2079
2120
|
if has_token and (not has_creds or cred_state != 'ok'):
|
|
2080
2121
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
2081
2122
|
t0 = time.time()
|
|
2123
|
+
uses_token = 'CLAUDE_CODE_OAUTH_TOKEN' in env
|
|
2082
2124
|
try:
|
|
2083
|
-
r = subprocess.run([real, '-p', '
|
|
2125
|
+
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
2084
2126
|
env=env, capture_output=True, text=True, timeout=240,
|
|
2085
|
-
|
|
2127
|
+
input='Reply with exactly: OK\n', cwd=root)
|
|
2086
2128
|
except subprocess.TimeoutExpired:
|
|
2087
2129
|
print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
|
|
2088
2130
|
failures += 1
|
|
@@ -2095,6 +2137,16 @@ for acct in manifest.get('accounts', []):
|
|
|
2095
2137
|
os.remove(os.path.join(d, '.expired'))
|
|
2096
2138
|
except OSError:
|
|
2097
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
|
|
2098
2150
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2099
2151
|
else:
|
|
2100
2152
|
err = (r.stderr or '').strip()[:200]
|
|
@@ -2108,6 +2160,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2108
2160
|
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
2109
2161
|
f'try: claude-accounts relogin {aid}')
|
|
2110
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
|
|
2111
2167
|
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
2112
2168
|
hint = f' — login is dead, run: claude-accounts relogin {aid}'
|
|
2113
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)"
|
|
@@ -1791,17 +1833,21 @@ EOF
|
|
|
1791
1833
|
claude-accounts limits --force 2>&1)"
|
|
1792
1834
|
check "a scope-denied 403 names the missing scope" "user:profile" "$out"
|
|
1793
1835
|
check "a scope-denied 403 names the ceremony that fixes it" "claude-accounts login acct-01" "$out"
|
|
1794
|
-
|
|
1836
|
+
# The CREDENTIAL is refused for good (the digest below stops it being offered
|
|
1837
|
+
# again); the ACCOUNT is deliberately NOT parked, because parking it also blocked
|
|
1838
|
+
# the OAuth path and froze telemetry for hours on accounts whose login was fine.
|
|
1839
|
+
check "a scope-denied 403 retires the token, not the account" \
|
|
1840
|
+
"This token will not be offered again" "$out"
|
|
1795
1841
|
python3 - "$SD/acct-01/limits.json" "$now" <<'EOF'
|
|
1796
1842
|
import json, sys
|
|
1797
1843
|
lim = json.load(open(sys.argv[1]))
|
|
1798
1844
|
now = int(sys.argv[2])
|
|
1799
|
-
assert lim
|
|
1845
|
+
assert lim.get('token_scope_denied'), lim # THIS token is retired
|
|
1846
|
+
assert not lim.get('retry_after'), lim # ...but the account is not parked
|
|
1800
1847
|
assert lim['fetched_at'] == now - 950000, lim # a FAILURE never invents freshness
|
|
1801
|
-
assert 'HTTP 403' in lim['last_error'], lim
|
|
1802
1848
|
EOF
|
|
1803
|
-
[ $? -eq 0 ] && t_ok "a refused
|
|
1804
|
-
|| t_fail "403
|
|
1849
|
+
[ $? -eq 0 ] && t_ok "a refused token is retired without parking the account, keeping its stale fetched_at" \
|
|
1850
|
+
|| t_fail "403 credential-scoped state" "see $SD/acct-01/limits.json"
|
|
1805
1851
|
|
|
1806
1852
|
# The whole point: the next scheduled pass must NOT spend another request. Before the
|
|
1807
1853
|
# fix this retried every five minutes, from every machine, forever.
|
|
@@ -1809,9 +1855,12 @@ EOF
|
|
|
1809
1855
|
out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
1810
1856
|
claude-accounts limits 2>&1)"
|
|
1811
1857
|
after="$(wc -l < "$uhits")"
|
|
1812
|
-
[ "$before" = "$after" ] && t_ok "a
|
|
1858
|
+
[ "$before" = "$after" ] && t_ok "a retired token is not re-offered on the next pass" \
|
|
1813
1859
|
|| t_fail "403 retry storm" "endpoint hit again ($before -> $after requests)"
|
|
1814
|
-
|
|
1860
|
+
# It is the CREDENTIAL that is spent, so the message names the ceremony that
|
|
1861
|
+
# replaces it rather than a clock the operator would otherwise sit and watch.
|
|
1862
|
+
check "and says what would fix it, not how long to wait" \
|
|
1863
|
+
"telemetry stays dark until: claude-accounts login acct-01" "$out"
|
|
1815
1864
|
|
|
1816
1865
|
# Any other non-2xx backs off too — a 5xx retried every pass is the same storm.
|
|
1817
1866
|
rm -f "$SD/acct-01/limits.json"
|
|
@@ -1990,6 +2039,57 @@ EOF
|
|
|
1990
2039
|
[ "$before" = "$after" ] && t_ok "a token already refused for scope is not offered again" \
|
|
1991
2040
|
|| t_fail "token re-offered" "endpoint hit again ($before -> $after)"
|
|
1992
2041
|
check "and the message says what would fix it" "claude-accounts login acct-01" "$out"
|
|
2042
|
+
# ---- an OAuth account NEVER spends its setup token here, and a token refusal
|
|
2043
|
+
# ---- never parks the ACCOUNT ------------------------------------------------
|
|
2044
|
+
# Live symptom (my-mini, 2026-08-28): acct-02/acct-05 sat 1-5 HOURS stale with
|
|
2045
|
+
# "backing off after HTTP 403 (source=token) — permanent" while their OAuth
|
|
2046
|
+
# credentials were fine. The chain: the access token had expired minutes ago, so
|
|
2047
|
+
# refresh_oauth declined (REFRESH_MIN_EXPIRED proves no live session owns it),
|
|
2048
|
+
# the probe fell through to the setup token, the endpoint refused it for scope,
|
|
2049
|
+
# and that parked the whole ACCOUNT for six hours — blocking the OAuth path that
|
|
2050
|
+
# would have worked on the very next pass. The shim then ranked the pool on
|
|
2051
|
+
# hour-old readings and said so ("usage telemetry is 1h old").
|
|
2052
|
+
SD2="$WORK/token-poison"
|
|
2053
|
+
mkdir -p "$SD2/acct-01" "$SD2/tmp"
|
|
2054
|
+
: > "$SD2/.limits-kick"
|
|
2055
|
+
cat > "$SD2/accounts.json" <<EOF
|
|
2056
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
2057
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
2058
|
+
"accounts": [ {"id": "acct-01", "email": "poison@test", "home": "mac",
|
|
2059
|
+
"added_at": "2026-08-28T00:00:00Z"} ] }
|
|
2060
|
+
EOF
|
|
2061
|
+
# An OAuth credential whose ACCESS token expired a moment ago: too recent for
|
|
2062
|
+
# refresh_oauth to touch, so this pass has no bearer it may use.
|
|
2063
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-justexpired","refreshToken":"r","expiresAt":%s000,"refreshTokenExpiresAt":9999999999999}}' \
|
|
2064
|
+
"$((now - 30))" > "$SD2/acct-01/.credentials.json"
|
|
2065
|
+
printf 'sk-ant-oat01-PORTABLE0001\n' > "$SD2/acct-01/server.token"
|
|
2066
|
+
before="$(wc -l < "$uhits")"
|
|
2067
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2068
|
+
claude-accounts limits 2>&1)"
|
|
2069
|
+
after="$(wc -l < "$uhits")"
|
|
2070
|
+
[ "$before" = "$after" ] \
|
|
2071
|
+
&& t_ok "an OAuth account never spends its setup token on the usage endpoint" \
|
|
2072
|
+
|| t_fail "token spent" "the endpoint was called ($before -> $after) with a token that can only 403"
|
|
2073
|
+
check "and it says it will simply retry" "retrying next pass" "$out"
|
|
2074
|
+
[ ! -f "$SD2/acct-01/limits.json" ] \
|
|
2075
|
+
&& t_ok "no six-hour park is written for an account that merely missed a refresh" \
|
|
2076
|
+
|| t_fail "account parked" "$(cat "$SD2/acct-01/limits.json")"
|
|
2077
|
+
|
|
2078
|
+
# A token-only account still tries once (that is the only way to learn), but the
|
|
2079
|
+
# refusal must park the CREDENTIAL, not the account: the digest is remembered and
|
|
2080
|
+
# no retry_after is written, so a later OAuth login is free to work immediately.
|
|
2081
|
+
rm -f "$SD2/acct-01/.credentials.json" "$SD2/acct-01/limits.json"
|
|
2082
|
+
CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2083
|
+
claude-accounts limits >/dev/null 2>&1
|
|
2084
|
+
python3 - "$SD2/acct-01/limits.json" <<'EOF'
|
|
2085
|
+
import json, sys
|
|
2086
|
+
d = json.load(open(sys.argv[1]))
|
|
2087
|
+
assert d.get('token_scope_denied'), d
|
|
2088
|
+
assert not d.get('retry_after'), f"the account was parked by a credential refusal: {d}"
|
|
2089
|
+
EOF
|
|
2090
|
+
[ $? -eq 0 ] && t_ok "a token scope refusal parks the credential, never the account" \
|
|
2091
|
+
|| t_fail "token refusal parked the account" "see $SD2/acct-01/limits.json"
|
|
2092
|
+
|
|
1993
2093
|
# Re-minting the token is a new credential, so it earns a fresh try.
|
|
1994
2094
|
printf 'sk-ant-oat01-REMINTED01\n' > "$SD/acct-01/server.token"
|
|
1995
2095
|
before="$(wc -l < "$uhits")"
|
|
@@ -2358,12 +2458,23 @@ check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
|
2358
2458
|
# account and park it.
|
|
2359
2459
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
2360
2460
|
printf 'sk-ant-oat01-token-for-01' > "$ACC/acct-01/server.token"
|
|
2361
|
-
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"
|
|
2362
2466
|
out="$(claude-accounts verify 2>&1)"
|
|
2363
2467
|
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
|
|
2364
2468
|
*) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
|
|
2365
2469
|
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
|
|
2366
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"
|
|
2367
2478
|
rm -f "$ACC/acct-01/server.token"
|
|
2368
2479
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
2369
2480
|
|