claude-multiacc 1.0.4 → 1.0.5
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 +10 -3
- package/bin/claude-accounts +146 -39
- package/package.json +1 -1
- package/tests/run-tests.sh +97 -2
package/README.md
CHANGED
|
@@ -74,9 +74,16 @@ code cannot read at all degrades that one account (fail open), never the run.
|
|
|
74
74
|
Telemetry failures never block work: no fresh data ⇒ account treated as available. The
|
|
75
75
|
endpoint rate-limits per account, so the refresher skips accounts fetched in the last 45s
|
|
76
76
|
and backs off exponentially (honoring `Retry-After`) on a 429 — `limits --force` overrides
|
|
77
|
-
both. If an account's OAuth access token has expired
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
both. If an account's OAuth access token has been expired for a while (idle account,
|
|
78
|
+
nothing ran claude under it for hours), the refresher renews it directly via the OAuth
|
|
79
|
+
**refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
|
|
80
|
+
and atomically persists the rotated credential (0600) back to that account's
|
|
81
|
+
`.credentials.json`. This is what keeps idle accounts' telemetry fresh so they win
|
|
82
|
+
selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
|
|
83
|
+
account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
|
|
84
|
+
`<acct>/.oauth-refresh.json` (10 min transient, 6 h when the grant looks revoked — the log
|
|
85
|
+
then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
|
|
86
|
+
`CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
|
|
80
87
|
|
|
81
88
|
**Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
|
|
82
89
|
auth/rate-limit-looking failure the shim marks the account with a 10-minute error
|
package/bin/claude-accounts
CHANGED
|
@@ -47,8 +47,11 @@ USAGE
|
|
|
47
47
|
claude-accounts sync push manifest+tokens to the server (Mac only)
|
|
48
48
|
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
49
49
|
claude-accounts limits [--quiet] [--force]
|
|
50
|
-
refresh usage buckets, apply >=90% markers.
|
|
51
|
-
|
|
50
|
+
refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
|
|
51
|
+
OAuth access tokens via the refresh-token grant (rotated credential is
|
|
52
|
+
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
53
|
+
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
54
|
+
--force ignores all three.
|
|
52
55
|
claude-accounts health limits + full verify; logs to health.log
|
|
53
56
|
claude-accounts self-update update the addon (npm i -g @latest, or git
|
|
54
57
|
pull + reinstall); logs to update.log
|
|
@@ -740,24 +743,9 @@ cmd_limits() {
|
|
|
740
743
|
# shellcheck disable=SC2064
|
|
741
744
|
trap "rm -rf '$lock'" EXIT
|
|
742
745
|
rotate_log limits.log
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
#
|
|
746
|
-
local real="" d
|
|
747
|
-
real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
|
|
748
|
-
if [ -n "$real" ]; then
|
|
749
|
-
for d in "$ACC_ROOT"/acct-*; do
|
|
750
|
-
[ -d "$d" ] || continue
|
|
751
|
-
[ -f "$d/.credentials.json" ] || continue
|
|
752
|
-
[ -s "$d/server.token" ] && continue
|
|
753
|
-
if "$PYBIN" -c '
|
|
754
|
-
import json, sys, time
|
|
755
|
-
c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
|
|
756
|
-
sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
|
|
757
|
-
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
|
|
758
|
-
fi
|
|
759
|
-
done
|
|
760
|
-
fi
|
|
746
|
+
# NB: expired OAuth access tokens are refreshed inside the Python below via the
|
|
747
|
+
# refresh-token grant. (`claude auth status` was tried for this and does NOT
|
|
748
|
+
# refresh credentials — it only reports the on-disk state.)
|
|
761
749
|
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
762
750
|
# never loosen it, or an account could sit at 95% and still be selected.
|
|
763
751
|
local threshold
|
|
@@ -773,6 +761,21 @@ now = time.time()
|
|
|
773
761
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
774
762
|
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
|
|
775
763
|
|
|
764
|
+
# OAuth refresh-token grant — the same endpoint + public client id Claude Code
|
|
765
|
+
# itself uses to keep .credentials.json alive. An account that sits idle past its
|
|
766
|
+
# access-token TTL would otherwise drop out of telemetry forever (stale data ranks
|
|
767
|
+
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
|
|
768
|
+
TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
|
|
769
|
+
'https://console.anthropic.com/v1/oauth/token')
|
|
770
|
+
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
|
|
771
|
+
'9d1c250a-e61b-44d9-88ed-5944d1962f5e')
|
|
772
|
+
# Only refresh a token that has been expired for a while: a LIVE session refreshes
|
|
773
|
+
# its own credential within moments of expiry, so a long-expired one proves no
|
|
774
|
+
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
|
|
775
|
+
REFRESH_MIN_EXPIRED = 300
|
|
776
|
+
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
777
|
+
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
778
|
+
|
|
776
779
|
def say(msg):
|
|
777
780
|
if not quiet:
|
|
778
781
|
print(msg)
|
|
@@ -793,34 +796,108 @@ try:
|
|
|
793
796
|
except Exception as e:
|
|
794
797
|
sys.exit(f'cannot read manifest: {e}')
|
|
795
798
|
|
|
799
|
+
def refresh_oauth(aid, d, cpath):
|
|
800
|
+
"""Refresh a long-expired OAuth access token via the refresh-token grant and
|
|
801
|
+
persist the ROTATED credential atomically (0600). Returns the new bearer, or
|
|
802
|
+
None (fail open: the on-disk credential is never touched on failure).
|
|
803
|
+
Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
|
|
804
|
+
because telemetry state must only ever reflect real usage fetches."""
|
|
805
|
+
spath = os.path.join(d, '.oauth-refresh.json')
|
|
806
|
+
try:
|
|
807
|
+
doc = json.load(open(cpath))
|
|
808
|
+
o = doc.get('claudeAiOauth', {})
|
|
809
|
+
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
810
|
+
# write) must degrade THIS account only, like every other malformed input.
|
|
811
|
+
if not isinstance(doc, dict) or not isinstance(o, dict):
|
|
812
|
+
return None
|
|
813
|
+
except Exception:
|
|
814
|
+
return None
|
|
815
|
+
if not o.get('refreshToken') or not o.get('accessToken'):
|
|
816
|
+
return None
|
|
817
|
+
if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
|
|
818
|
+
return None # not expired long enough to prove no live session owns it
|
|
819
|
+
if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
|
|
820
|
+
say(f'{aid}: refresh token expired — re-login needed (claude-accounts login {aid})')
|
|
821
|
+
return None
|
|
822
|
+
if not force:
|
|
823
|
+
try:
|
|
824
|
+
if json.load(open(spath)).get('retry_after', 0) > now:
|
|
825
|
+
return None # earlier refresh failure still backing off
|
|
826
|
+
except Exception:
|
|
827
|
+
pass
|
|
828
|
+
|
|
829
|
+
def back_off(wait, why):
|
|
830
|
+
try:
|
|
831
|
+
with open(spath + '.tmp', 'w') as f:
|
|
832
|
+
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
833
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
834
|
+
os.replace(spath + '.tmp', spath)
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
838
|
+
|
|
839
|
+
body = json.dumps({'grant_type': 'refresh_token',
|
|
840
|
+
'refresh_token': o['refreshToken'],
|
|
841
|
+
'client_id': CLIENT_ID}).encode()
|
|
842
|
+
req = urllib.request.Request(TOKEN_URL, data=body, headers={
|
|
843
|
+
'Content-Type': 'application/json',
|
|
844
|
+
'User-Agent': 'claude-multiacc/1.0',
|
|
845
|
+
})
|
|
846
|
+
try:
|
|
847
|
+
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
848
|
+
except urllib.error.HTTPError as e:
|
|
849
|
+
if e.code in (400, 401, 403):
|
|
850
|
+
back_off(REFRESH_DENIED_BACKOFF,
|
|
851
|
+
f'HTTP {e.code} — refresh token may be revoked; re-login needed')
|
|
852
|
+
else:
|
|
853
|
+
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
854
|
+
return None
|
|
855
|
+
except Exception as e:
|
|
856
|
+
back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
|
|
857
|
+
return None
|
|
858
|
+
tok = data.get('access_token') if isinstance(data, dict) else None
|
|
859
|
+
if not tok:
|
|
860
|
+
back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
|
|
861
|
+
return None
|
|
862
|
+
o['accessToken'] = tok
|
|
863
|
+
# The grant ROTATES the refresh token: persist it (and both expiries) or the
|
|
864
|
+
# account is stranded — hence atomic write, and a loud message if it fails.
|
|
865
|
+
if data.get('refresh_token'):
|
|
866
|
+
o['refreshToken'] = data['refresh_token']
|
|
867
|
+
if data.get('expires_in'):
|
|
868
|
+
o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
|
|
869
|
+
else:
|
|
870
|
+
# No expires_in in the response: assume a conservative 1h. Leaving the old
|
|
871
|
+
# (past) expiresAt would make every later pass re-run the grant in a loop.
|
|
872
|
+
o['expiresAt'] = int((now + 3600) * 1000)
|
|
873
|
+
if data.get('refresh_token_expires_in'):
|
|
874
|
+
o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
|
|
875
|
+
doc['claudeAiOauth'] = o
|
|
876
|
+
try:
|
|
877
|
+
fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
878
|
+
with os.fdopen(fd, 'w') as f:
|
|
879
|
+
json.dump(doc, f)
|
|
880
|
+
os.replace(cpath + '.tmp', cpath)
|
|
881
|
+
except Exception as e:
|
|
882
|
+
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
883
|
+
return None
|
|
884
|
+
try:
|
|
885
|
+
os.remove(spath)
|
|
886
|
+
except OSError:
|
|
887
|
+
pass
|
|
888
|
+
say(f'{aid}: oauth access token refreshed via refresh-token grant')
|
|
889
|
+
return tok
|
|
890
|
+
|
|
796
891
|
for acct in manifest.get('accounts', []):
|
|
797
892
|
aid = acct['id']
|
|
798
893
|
d = os.path.join(root, aid)
|
|
799
894
|
if not os.path.isdir(d):
|
|
800
895
|
continue
|
|
801
|
-
bearer = None
|
|
802
|
-
source = None
|
|
803
|
-
cpath = os.path.join(d, '.credentials.json')
|
|
804
|
-
if os.path.isfile(cpath):
|
|
805
|
-
try:
|
|
806
|
-
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
807
|
-
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
808
|
-
bearer, source = c['accessToken'], 'oauth'
|
|
809
|
-
except Exception:
|
|
810
|
-
pass
|
|
811
|
-
tpath = os.path.join(d, 'server.token')
|
|
812
|
-
if not bearer and os.path.isfile(tpath):
|
|
813
|
-
t = open(tpath).read().strip()
|
|
814
|
-
if t:
|
|
815
|
-
bearer, source = t, 'token'
|
|
816
|
-
if not bearer:
|
|
817
|
-
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
818
|
-
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
819
|
-
continue
|
|
820
896
|
|
|
821
897
|
# The usage endpoint rate-limits per account. Several callers can fire at once
|
|
822
898
|
# (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
|
|
823
899
|
# this account's data is already fresh, and honor any backoff a 429 set earlier.
|
|
900
|
+
# Checked FIRST so a skipped account never burns an oauth refresh for nothing.
|
|
824
901
|
lpath = os.path.join(d, 'limits.json')
|
|
825
902
|
prev = {}
|
|
826
903
|
if os.path.isfile(lpath):
|
|
@@ -837,6 +914,36 @@ for acct in manifest.get('accounts', []):
|
|
|
837
914
|
say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
|
|
838
915
|
continue
|
|
839
916
|
|
|
917
|
+
bearer = None
|
|
918
|
+
source = None
|
|
919
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
920
|
+
if os.path.isfile(cpath):
|
|
921
|
+
try:
|
|
922
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
923
|
+
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
924
|
+
bearer, source = c['accessToken'], 'oauth'
|
|
925
|
+
except Exception:
|
|
926
|
+
pass
|
|
927
|
+
tpath = os.path.join(d, 'server.token')
|
|
928
|
+
if not bearer and os.path.isfile(tpath):
|
|
929
|
+
t = open(tpath).read().strip()
|
|
930
|
+
if t:
|
|
931
|
+
bearer, source = t, 'token'
|
|
932
|
+
if not bearer and os.path.isfile(cpath):
|
|
933
|
+
# Hard fail-open guard: NOTHING a single account's refresh does may abort
|
|
934
|
+
# the loop — every account after it would silently starve of telemetry.
|
|
935
|
+
try:
|
|
936
|
+
tok = refresh_oauth(aid, d, cpath)
|
|
937
|
+
except Exception as e:
|
|
938
|
+
say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
|
|
939
|
+
tok = None
|
|
940
|
+
if tok:
|
|
941
|
+
bearer, source = tok, 'oauth'
|
|
942
|
+
if not bearer:
|
|
943
|
+
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
944
|
+
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
945
|
+
continue
|
|
946
|
+
|
|
840
947
|
req = urllib.request.Request(url, headers={
|
|
841
948
|
'Authorization': 'Bearer ' + bearer,
|
|
842
949
|
'anthropic-beta': 'oauth-2025-04-20',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -75,6 +75,9 @@ export CLAUDE_MULTIACC_NO_SYNC=1
|
|
|
75
75
|
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
76
76
|
# is off by default here; the throttle test re-enables it explicitly.
|
|
77
77
|
export CLAUDE_MULTIACC_MIN_FETCH=0
|
|
78
|
+
# The oauth token endpoint must NEVER be hit for real from tests: default to a missing
|
|
79
|
+
# file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
|
|
80
|
+
export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
78
81
|
|
|
79
82
|
now="$(date +%s)"
|
|
80
83
|
|
|
@@ -618,15 +621,107 @@ d = json.load(open('$ACC/acct-01/limits.json'))
|
|
|
618
621
|
sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
|
|
619
622
|
&& t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
|
|
620
623
|
|
|
621
|
-
# ---- 16b. expired-bearer account
|
|
624
|
+
# ---- 16b. expired-bearer account: oauth refresh attempted; fail-open when it fails ----
|
|
622
625
|
mkdir -p "$ACC/acct-05"
|
|
623
|
-
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"
|
|
626
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-oldrefresh","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
624
627
|
claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
|
|
625
628
|
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
626
629
|
rc=$?
|
|
630
|
+
check "failed oauth refresh logged with backoff" "acct-05: oauth refresh failed" "$out"
|
|
627
631
|
check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
|
|
628
632
|
[ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
|
|
629
633
|
[ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
|
|
634
|
+
[ -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "refresh failure recorded in .oauth-refresh.json" || t_fail "refresh backoff file" "missing"
|
|
635
|
+
|
|
636
|
+
# ---- 16b2. refresh backoff honored: even a now-working endpoint is not retried early --
|
|
637
|
+
cat > "$WORK/token-ok.json" <<'EOF'
|
|
638
|
+
{"access_token":"sk-ant-oat01-refreshednew","refresh_token":"sk-ant-ort01-rotatednew","expires_in":28800,"refresh_token_expires_in":2592000}
|
|
639
|
+
EOF
|
|
640
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
641
|
+
check "refresh backoff honored (no early retry)" "acct-05: no fresh bearer" "$out"
|
|
642
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
643
|
+
&& t_fail "backoff prevented refresh" "credentials rewritten inside the backoff window" \
|
|
644
|
+
|| t_ok "no refresh inside the backoff window"
|
|
645
|
+
|
|
646
|
+
# ---- 16b3. --force bypasses refresh backoff: rotated credential persisted + fetch ok --
|
|
647
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
648
|
+
check "--force refreshes the expired oauth token" "acct-05: oauth access token refreshed" "$out"
|
|
649
|
+
check "refreshed account fetches telemetry" "acct-05: ok" "$out"
|
|
650
|
+
python3 - "$ACC/acct-05/.credentials.json" <<'EOF'
|
|
651
|
+
import json, os, stat, sys, time
|
|
652
|
+
p = sys.argv[1]
|
|
653
|
+
o = json.load(open(p))['claudeAiOauth']
|
|
654
|
+
assert o['accessToken'] == 'sk-ant-oat01-refreshednew', o['accessToken']
|
|
655
|
+
assert o['refreshToken'] == 'sk-ant-ort01-rotatednew', 'refresh token was not rotated'
|
|
656
|
+
assert o['expiresAt'] / 1000.0 > time.time() + 3600, 'expiresAt not advanced'
|
|
657
|
+
assert o['refreshTokenExpiresAt'] / 1000.0 > time.time() + 86400, 'refreshTokenExpiresAt not advanced'
|
|
658
|
+
mode = stat.S_IMODE(os.stat(p).st_mode)
|
|
659
|
+
assert mode == 0o600, oct(mode)
|
|
660
|
+
EOF
|
|
661
|
+
[ $? -eq 0 ] && t_ok "rotated credential persisted with 0600" || t_fail "credential rotation" "see assertions above"
|
|
662
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "successful refresh clears the backoff file" || t_fail "refresh backoff clear" "file still present"
|
|
663
|
+
[ -f "$ACC/acct-05/limits.json" ] && t_ok "telemetry written right after refresh" || t_fail "limits.json after refresh" "missing"
|
|
664
|
+
grep -qE "sk-ant-ort01|sk-ant-oat01-refreshednew" "$ACC/limits.log" \
|
|
665
|
+
&& t_fail "limits.log leaks no tokens" "a token leaked into limits.log" \
|
|
666
|
+
|| t_ok "limits.log leaks no tokens"
|
|
667
|
+
|
|
668
|
+
# ---- 16b4. steady state: fresh data means no refresh and no fetch (quiet skip) --------
|
|
669
|
+
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
670
|
+
printf '%s' "$out" | grep -q "acct-05" \
|
|
671
|
+
&& t_fail "fresh account skipped silently" "unexpected acct-05 output: $out" \
|
|
672
|
+
|| t_ok "fresh account skipped silently (no refresh, no fetch)"
|
|
673
|
+
|
|
674
|
+
# ---- 16b5. an EXPIRED refresh token is never sent: clear re-login message -------------
|
|
675
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-dead","expiresAt":1000,"refreshTokenExpiresAt":1000}}' > "$ACC/acct-05/.credentials.json"
|
|
676
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
677
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
678
|
+
check "expired refresh token => re-login message" "re-login needed" "$out"
|
|
679
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
680
|
+
&& t_fail "dead refresh token never used" "credentials rewritten from a dead refresh token" \
|
|
681
|
+
|| t_ok "dead refresh token never used"
|
|
682
|
+
|
|
683
|
+
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
684
|
+
# The 5-min REFRESH_MIN_EXPIRED gate is the rotation-safety core: a token that expired
|
|
685
|
+
# moments ago may be mid-refresh by a live claude session; grants must not race it.
|
|
686
|
+
# Not even --force may bypass this.
|
|
687
|
+
recent_ms="$(python3 -c 'import time; print(int((time.time()-100)*1000))')"
|
|
688
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-recent","refreshToken":"sk-ant-ort01-live","expiresAt":%s,"refreshTokenExpiresAt":9999999999999}}' "$recent_ms" > "$ACC/acct-05/.credentials.json"
|
|
689
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
690
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
691
|
+
check "recently-expired token is not refreshed (even --force)" "acct-05: no fresh bearer" "$out"
|
|
692
|
+
grep -q "sk-ant-oat01-recent" "$ACC/acct-05/.credentials.json" \
|
|
693
|
+
&& t_ok "recently-expired credential left untouched" \
|
|
694
|
+
|| t_fail "REFRESH_MIN_EXPIRED gate" "credential was rewritten within the 5-min grace window"
|
|
695
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "no backoff recorded for a gated (skipped) refresh" \
|
|
696
|
+
|| t_fail "gated refresh backoff" ".oauth-refresh.json written despite the gate"
|
|
697
|
+
|
|
698
|
+
# ---- 16b7. server.token accounts are NEVER oauth-refreshed (token bearer wins) ---------
|
|
699
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
700
|
+
printf 'sk-ant-oat01-portable-token-05' > "$ACC/acct-05/server.token"
|
|
701
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
702
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
703
|
+
check "token-bearer account fetches without refresh" "acct-05: ok" "$out"
|
|
704
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
705
|
+
&& t_fail "server.token exempts oauth refresh" "oauth creds were rotated despite a portable token" \
|
|
706
|
+
|| t_ok "server.token account never oauth-refreshed (grant not run)"
|
|
707
|
+
python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='token' else 1)" \
|
|
708
|
+
&& t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
|
|
709
|
+
rm -f "$ACC/acct-05/server.token"
|
|
710
|
+
|
|
711
|
+
# ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
|
|
712
|
+
# {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
|
|
713
|
+
# must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
|
|
714
|
+
# acct-01 is first in the manifest, so corrupting it exercises the loop guarantee.
|
|
715
|
+
cp "$ACC/acct-01/.credentials.json" "$WORK/acct01-creds.bak"
|
|
716
|
+
printf '{"claudeAiOauth": null}' > "$ACC/acct-01/.credentials.json"
|
|
717
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.oauth-refresh.json" "$ACC/acct-02/limits.json"
|
|
718
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
719
|
+
rc=$?
|
|
720
|
+
[ "$rc" = "0" ] && t_ok "null claudeAiOauth exits 0 (fail open)" || t_fail "null claudeAiOauth rc" "rc=$rc: $out"
|
|
721
|
+
check "null claudeAiOauth degrades only that account" "acct-01: no fresh bearer" "$out"
|
|
722
|
+
[ -f "$ACC/acct-02/limits.json" ] && t_ok "accounts after a malformed one still refresh" \
|
|
723
|
+
|| t_fail "fail-open loop guarantee" "acct-02 was starved by acct-01's malformed creds"
|
|
724
|
+
cp "$WORK/acct01-creds.bak" "$ACC/acct-01/.credentials.json"
|
|
630
725
|
claude-accounts remove acct-05 --yes >/dev/null 2>&1
|
|
631
726
|
|
|
632
727
|
# ---- 16c. codex-review: security hardening -----------------------------------------
|