claude-multiacc 2.0.6 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claude-accounts +47 -8
- 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/package.json +1 -1
- package/tests/run-tests.sh +65 -7
package/bin/claude-accounts
CHANGED
|
@@ -1273,6 +1273,7 @@ cmd_limits() {
|
|
|
1273
1273
|
import hashlib, json, os, sys, time, urllib.request
|
|
1274
1274
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1275
1275
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1276
|
+
from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
|
|
1276
1277
|
|
|
1277
1278
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
1278
1279
|
now = time.time()
|
|
@@ -1655,6 +1656,18 @@ for acct in manifest.get('accounts', []):
|
|
|
1655
1656
|
locked = store == 'locked'
|
|
1656
1657
|
if locked:
|
|
1657
1658
|
store = None
|
|
1659
|
+
# "This account has an OAuth login that could still work." A Keychain-held one
|
|
1660
|
+
# only the Mac's own launchd probe can open counts: it is unusable HERE, not
|
|
1661
|
+
# broken. A provably DEAD grant (no refresh token, or a refresh token that has
|
|
1662
|
+
# itself expired) does not count — for that account the setup token really is
|
|
1663
|
+
# the only bearer left, and it must still get its one try.
|
|
1664
|
+
oauth_alive = locked
|
|
1665
|
+
if store is not None:
|
|
1666
|
+
try:
|
|
1667
|
+
oauth_alive = creds_doc_state(store.read(), now)[0] == 'ok'
|
|
1668
|
+
except Exception:
|
|
1669
|
+
oauth_alive = True # unreadable is not proof of death: do not spend the token
|
|
1670
|
+
has_oauth = oauth_alive
|
|
1658
1671
|
if store is not None:
|
|
1659
1672
|
try:
|
|
1660
1673
|
c = store.read().get('claudeAiOauth', {})
|
|
@@ -1672,7 +1685,19 @@ for acct in manifest.get('accounts', []):
|
|
|
1672
1685
|
tok = None
|
|
1673
1686
|
if tok:
|
|
1674
1687
|
bearer, source = tok, 'oauth'
|
|
1675
|
-
|
|
1688
|
+
# A setup token is a LAST RESORT and only for an account that has no OAuth
|
|
1689
|
+
# credential at all. Where one exists but could not be used this pass — its
|
|
1690
|
+
# access token expired moments ago (refresh_oauth waits REFRESH_MIN_EXPIRED to
|
|
1691
|
+
# prove no live session owns it), a refresh backoff, or a Keychain this session
|
|
1692
|
+
# cannot open — spending the token is guaranteed to earn a 403 it can never not
|
|
1693
|
+
# earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
|
|
1694
|
+
# froze for an account whose OAuth would have worked on the very next pass, and
|
|
1695
|
+
# the shim ranked the pool on hour-old readings. Waiting for the next pass costs
|
|
1696
|
+
# minutes; the token costs six hours and answers nothing.
|
|
1697
|
+
if not bearer and has_oauth:
|
|
1698
|
+
say(f'{aid}: oauth credential not usable this pass; NOT spending the setup '
|
|
1699
|
+
f'token on a usage endpoint that always refuses it — retrying next pass')
|
|
1700
|
+
elif not bearer and os.path.isfile(tpath):
|
|
1676
1701
|
# Once this endpoint has refused THIS token file for lacking a scope, asking
|
|
1677
1702
|
# again is guaranteed to fail and only spends the account's hourly budget —
|
|
1678
1703
|
# which is how a permanent authorization problem disguised itself as a rate
|
|
@@ -1717,6 +1742,14 @@ for acct in manifest.get('accounts', []):
|
|
|
1717
1742
|
except Exception:
|
|
1718
1743
|
pass
|
|
1719
1744
|
|
|
1745
|
+
def save_prev():
|
|
1746
|
+
"""Persist the account's telemetry state as-is (no backoff, no invented
|
|
1747
|
+
freshness) — used when what failed says nothing about the ACCOUNT."""
|
|
1748
|
+
tmp = lpath + '.tmp'
|
|
1749
|
+
with open(tmp, 'w') as f:
|
|
1750
|
+
json.dump(prev, f, indent=1)
|
|
1751
|
+
os.replace(tmp, lpath)
|
|
1752
|
+
|
|
1720
1753
|
def park(wait, note):
|
|
1721
1754
|
"""Record a failed fetch WITHOUT inventing freshness: fetched_at is left
|
|
1722
1755
|
exactly as it was, so a parked account still reads as stale everywhere."""
|
|
@@ -1724,10 +1757,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1724
1757
|
prev['backoff'] = wait
|
|
1725
1758
|
prev['last_error'] = note
|
|
1726
1759
|
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)
|
|
1760
|
+
save_prev()
|
|
1731
1761
|
|
|
1732
1762
|
if e.code == 429:
|
|
1733
1763
|
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
@@ -1765,8 +1795,16 @@ for acct in manifest.get('accounts', []):
|
|
|
1765
1795
|
tdigest = token_digest(tpath)
|
|
1766
1796
|
if tdigest:
|
|
1767
1797
|
prev['token_scope_denied'] = tdigest
|
|
1768
|
-
|
|
1769
|
-
|
|
1798
|
+
if scope_denied and source == 'token':
|
|
1799
|
+
# A refusal of the TOKEN is a fact about that credential, not about
|
|
1800
|
+
# the account: parking the account here also blocked the OAuth path,
|
|
1801
|
+
# so an account that merely missed one refresh went dark for six
|
|
1802
|
+
# hours. The digest above already stops this token being spent again;
|
|
1803
|
+
# leave the account free to try its OAuth login next pass.
|
|
1804
|
+
save_prev()
|
|
1805
|
+
else:
|
|
1806
|
+
park(wait, f'HTTP {e.code} (source={source})'
|
|
1807
|
+
+ (' — permanent, server said do not retry' if denied else ''))
|
|
1770
1808
|
if scope_denied and source == 'token':
|
|
1771
1809
|
# The exact shape of this outage: a setup token is minted WITHOUT the
|
|
1772
1810
|
# user:profile scope the usage endpoint requires, so an account whose
|
|
@@ -1775,7 +1813,8 @@ for acct in manifest.get('accounts', []):
|
|
|
1775
1813
|
say(f'{aid}: usage endpoint refuses the setup token (HTTP {e.code} — a '
|
|
1776
1814
|
f'setup token has no user:profile scope). Telemetry is DEAD for this '
|
|
1777
1815
|
f'account until it has an OAuth login here: claude-accounts login {aid}. '
|
|
1778
|
-
f'
|
|
1816
|
+
f'This token will not be offered again; the ACCOUNT is not parked, so '
|
|
1817
|
+
f'an OAuth login works the moment it lands.')
|
|
1779
1818
|
elif denied:
|
|
1780
1819
|
say(f'{aid}: usage fetch refused for good (HTTP {e.code}, source={source}); '
|
|
1781
1820
|
f'backing off {wait}s — re-login needed: claude-accounts login {aid}')
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -1791,17 +1791,21 @@ EOF
|
|
|
1791
1791
|
claude-accounts limits --force 2>&1)"
|
|
1792
1792
|
check "a scope-denied 403 names the missing scope" "user:profile" "$out"
|
|
1793
1793
|
check "a scope-denied 403 names the ceremony that fixes it" "claude-accounts login acct-01" "$out"
|
|
1794
|
-
|
|
1794
|
+
# The CREDENTIAL is refused for good (the digest below stops it being offered
|
|
1795
|
+
# again); the ACCOUNT is deliberately NOT parked, because parking it also blocked
|
|
1796
|
+
# the OAuth path and froze telemetry for hours on accounts whose login was fine.
|
|
1797
|
+
check "a scope-denied 403 retires the token, not the account" \
|
|
1798
|
+
"This token will not be offered again" "$out"
|
|
1795
1799
|
python3 - "$SD/acct-01/limits.json" "$now" <<'EOF'
|
|
1796
1800
|
import json, sys
|
|
1797
1801
|
lim = json.load(open(sys.argv[1]))
|
|
1798
1802
|
now = int(sys.argv[2])
|
|
1799
|
-
assert lim
|
|
1803
|
+
assert lim.get('token_scope_denied'), lim # THIS token is retired
|
|
1804
|
+
assert not lim.get('retry_after'), lim # ...but the account is not parked
|
|
1800
1805
|
assert lim['fetched_at'] == now - 950000, lim # a FAILURE never invents freshness
|
|
1801
|
-
assert 'HTTP 403' in lim['last_error'], lim
|
|
1802
1806
|
EOF
|
|
1803
|
-
[ $? -eq 0 ] && t_ok "a refused
|
|
1804
|
-
|| t_fail "403
|
|
1807
|
+
[ $? -eq 0 ] && t_ok "a refused token is retired without parking the account, keeping its stale fetched_at" \
|
|
1808
|
+
|| t_fail "403 credential-scoped state" "see $SD/acct-01/limits.json"
|
|
1805
1809
|
|
|
1806
1810
|
# The whole point: the next scheduled pass must NOT spend another request. Before the
|
|
1807
1811
|
# fix this retried every five minutes, from every machine, forever.
|
|
@@ -1809,9 +1813,12 @@ EOF
|
|
|
1809
1813
|
out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
1810
1814
|
claude-accounts limits 2>&1)"
|
|
1811
1815
|
after="$(wc -l < "$uhits")"
|
|
1812
|
-
[ "$before" = "$after" ] && t_ok "a
|
|
1816
|
+
[ "$before" = "$after" ] && t_ok "a retired token is not re-offered on the next pass" \
|
|
1813
1817
|
|| t_fail "403 retry storm" "endpoint hit again ($before -> $after requests)"
|
|
1814
|
-
|
|
1818
|
+
# It is the CREDENTIAL that is spent, so the message names the ceremony that
|
|
1819
|
+
# replaces it rather than a clock the operator would otherwise sit and watch.
|
|
1820
|
+
check "and says what would fix it, not how long to wait" \
|
|
1821
|
+
"telemetry stays dark until: claude-accounts login acct-01" "$out"
|
|
1815
1822
|
|
|
1816
1823
|
# Any other non-2xx backs off too — a 5xx retried every pass is the same storm.
|
|
1817
1824
|
rm -f "$SD/acct-01/limits.json"
|
|
@@ -1990,6 +1997,57 @@ EOF
|
|
|
1990
1997
|
[ "$before" = "$after" ] && t_ok "a token already refused for scope is not offered again" \
|
|
1991
1998
|
|| t_fail "token re-offered" "endpoint hit again ($before -> $after)"
|
|
1992
1999
|
check "and the message says what would fix it" "claude-accounts login acct-01" "$out"
|
|
2000
|
+
# ---- an OAuth account NEVER spends its setup token here, and a token refusal
|
|
2001
|
+
# ---- never parks the ACCOUNT ------------------------------------------------
|
|
2002
|
+
# Live symptom (my-mini, 2026-08-28): acct-02/acct-05 sat 1-5 HOURS stale with
|
|
2003
|
+
# "backing off after HTTP 403 (source=token) — permanent" while their OAuth
|
|
2004
|
+
# credentials were fine. The chain: the access token had expired minutes ago, so
|
|
2005
|
+
# refresh_oauth declined (REFRESH_MIN_EXPIRED proves no live session owns it),
|
|
2006
|
+
# the probe fell through to the setup token, the endpoint refused it for scope,
|
|
2007
|
+
# and that parked the whole ACCOUNT for six hours — blocking the OAuth path that
|
|
2008
|
+
# would have worked on the very next pass. The shim then ranked the pool on
|
|
2009
|
+
# hour-old readings and said so ("usage telemetry is 1h old").
|
|
2010
|
+
SD2="$WORK/token-poison"
|
|
2011
|
+
mkdir -p "$SD2/acct-01" "$SD2/tmp"
|
|
2012
|
+
: > "$SD2/.limits-kick"
|
|
2013
|
+
cat > "$SD2/accounts.json" <<EOF
|
|
2014
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
2015
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
2016
|
+
"accounts": [ {"id": "acct-01", "email": "poison@test", "home": "mac",
|
|
2017
|
+
"added_at": "2026-08-28T00:00:00Z"} ] }
|
|
2018
|
+
EOF
|
|
2019
|
+
# An OAuth credential whose ACCESS token expired a moment ago: too recent for
|
|
2020
|
+
# refresh_oauth to touch, so this pass has no bearer it may use.
|
|
2021
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-justexpired","refreshToken":"r","expiresAt":%s000,"refreshTokenExpiresAt":9999999999999}}' \
|
|
2022
|
+
"$((now - 30))" > "$SD2/acct-01/.credentials.json"
|
|
2023
|
+
printf 'sk-ant-oat01-PORTABLE0001\n' > "$SD2/acct-01/server.token"
|
|
2024
|
+
before="$(wc -l < "$uhits")"
|
|
2025
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2026
|
+
claude-accounts limits 2>&1)"
|
|
2027
|
+
after="$(wc -l < "$uhits")"
|
|
2028
|
+
[ "$before" = "$after" ] \
|
|
2029
|
+
&& t_ok "an OAuth account never spends its setup token on the usage endpoint" \
|
|
2030
|
+
|| t_fail "token spent" "the endpoint was called ($before -> $after) with a token that can only 403"
|
|
2031
|
+
check "and it says it will simply retry" "retrying next pass" "$out"
|
|
2032
|
+
[ ! -f "$SD2/acct-01/limits.json" ] \
|
|
2033
|
+
&& t_ok "no six-hour park is written for an account that merely missed a refresh" \
|
|
2034
|
+
|| t_fail "account parked" "$(cat "$SD2/acct-01/limits.json")"
|
|
2035
|
+
|
|
2036
|
+
# A token-only account still tries once (that is the only way to learn), but the
|
|
2037
|
+
# refusal must park the CREDENTIAL, not the account: the digest is remembered and
|
|
2038
|
+
# no retry_after is written, so a later OAuth login is free to work immediately.
|
|
2039
|
+
rm -f "$SD2/acct-01/.credentials.json" "$SD2/acct-01/limits.json"
|
|
2040
|
+
CLAUDE_ACCOUNTS_ROOT="$SD2" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
|
|
2041
|
+
claude-accounts limits >/dev/null 2>&1
|
|
2042
|
+
python3 - "$SD2/acct-01/limits.json" <<'EOF'
|
|
2043
|
+
import json, sys
|
|
2044
|
+
d = json.load(open(sys.argv[1]))
|
|
2045
|
+
assert d.get('token_scope_denied'), d
|
|
2046
|
+
assert not d.get('retry_after'), f"the account was parked by a credential refusal: {d}"
|
|
2047
|
+
EOF
|
|
2048
|
+
[ $? -eq 0 ] && t_ok "a token scope refusal parks the credential, never the account" \
|
|
2049
|
+
|| t_fail "token refusal parked the account" "see $SD2/acct-01/limits.json"
|
|
2050
|
+
|
|
1993
2051
|
# Re-minting the token is a new credential, so it earns a fresh try.
|
|
1994
2052
|
printf 'sk-ant-oat01-REMINTED01\n' > "$SD/acct-01/server.token"
|
|
1995
2053
|
before="$(wc -l < "$uhits")"
|