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.
@@ -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
- if not bearer and os.path.isfile(tpath):
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
- tmp = lpath + '.tmp'
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
- park(wait, f'HTTP {e.code} (source={source})'
1769
- + (' permanent, server said do not retry' if denied else ''))
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'Backing off {wait}s.')
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}')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- check "a scope-denied 403 backs off instead of retrying" "Backing off" "$out"
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['retry_after'] > now + 3600, lim # parked for hours, not minutes
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 fetch records a long backoff and keeps its stale fetched_at" \
1804
- || t_fail "403 backoff state" "see $SD/acct-01/limits.json"
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 parked account is not re-fetched on the next pass" \
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
- check "the parked account says why it is waiting" "backing off after HTTP 403" "$out"
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")"