claude-multiacc 2.0.44 → 2.0.45
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 +14 -6
- package/bin/claude +8 -4
- package/bin/claude-accounts +111 -44
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +32 -4
- package/lib/codex_audit.py +8 -1
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +243 -1
- package/tests/test_autoresume.py +36 -5
package/README.md
CHANGED
|
@@ -147,7 +147,13 @@ this one is *down*, and handing work to it guarantees a hard failure. Reported s
|
|
|
147
147
|
Code for that account. Telemetry cannot see this — those accounts' usage endpoint
|
|
148
148
|
answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
|
|
149
149
|
park deliberately survives credential refreshes (a new access token says nothing about
|
|
150
|
-
an org policy).
|
|
150
|
+
an org policy). Every org park carries a 6-hour `soft_until` (a re-observed block
|
|
151
|
+
renews it), and a `verify` that the **usage limiter** refuses ("You've hit your weekly
|
|
152
|
+
limit") lifts it at once: the provider checks the org's Claude Code gate before the
|
|
153
|
+
limiter, so a limit answer proves the org lets the account through (acct-15,
|
|
154
|
+
2026-09-27: a lapsed subscription paid again stayed BLOCKED until this rule existed).
|
|
155
|
+
The line still reads `FAIL` — the account cannot work until the limit resets — with a
|
|
156
|
+
`usage-limited` note saying which park it lifted.
|
|
151
157
|
|
|
152
158
|
`claude-accounts expired` lists what is excluded and also labels a token-only account
|
|
153
159
|
`UNVERIFIED` until `claude-accounts verify` or the shim's first-use preflight proves it.
|
|
@@ -155,11 +161,13 @@ Exclusion self-heals, but only against the evidence it was based on: a **credent
|
|
|
155
161
|
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
|
|
156
162
|
by another process — or when a usage fetch authenticates; a **policy** park (org block)
|
|
157
163
|
ignores both, because a fresh access token says nothing about whether the org re-enabled
|
|
158
|
-
Claude Code
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
Claude Code: only a real call that got past the org gate (a passing `verify`, or one
|
|
165
|
+
refused only by a usage limit), a re-login, or its own 6-hour `soft_until` lifts it.
|
|
166
|
+
Parks the **shim** writes from a single failed run additionally carry a `soft_until`
|
|
167
|
+
stamp and expire on their own — the shim's guess never outlives the evidence for it —
|
|
168
|
+
while parks `claude-accounts` writes from proof of a dead credential (an `invalid_grant`
|
|
169
|
+
refresh response, a real call that failed to authenticate) stay until the account works
|
|
170
|
+
again. If *every* account is unusable the shim still
|
|
163
171
|
fails open into stock `claude`, and logs why to `selection.log`.
|
|
164
172
|
|
|
165
173
|
The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
|
package/bin/claude
CHANGED
|
@@ -1581,8 +1581,10 @@ creds_dead() { # $1 = acct dir
|
|
|
1581
1581
|
# A marker written by the SHIM (a guess from one failed run) also carries
|
|
1582
1582
|
# `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
|
|
1583
1583
|
# a misread never costs an account permanently. Markers written by claude-accounts —
|
|
1584
|
-
# a refresh grant that answered "revoked", a real verify call
|
|
1585
|
-
# stay until the account provably works again.
|
|
1584
|
+
# a refresh grant that answered "revoked", a real verify call that failed to
|
|
1585
|
+
# authenticate — carry no soft_until and stay until the account provably works again.
|
|
1586
|
+
# An org block is the exception: verify's carries the shim's 6 h soft_until too, and a
|
|
1587
|
+
# verify the usage limiter refused lifts it (the limiter answers only past the org gate).
|
|
1586
1588
|
expired_marked() { # $1 = acct dir
|
|
1587
1589
|
local m="$1/.expired" mt f soft reason
|
|
1588
1590
|
[ -f "$m" ] || return 1
|
|
@@ -2276,11 +2278,13 @@ fi
|
|
|
2276
2278
|
# PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
|
|
2277
2279
|
# is parked until a re-login / a fetch that authenticates clears it.
|
|
2278
2280
|
# PARK_ORG — the account's organization turned Claude Code access off; no re-login
|
|
2279
|
-
# fixes that.
|
|
2281
|
+
# fixes that. "ask your admin to enable ACCESS", never the bare prefix:
|
|
2282
|
+
# the same client ends a 404 model_not_found with "…or ask your admin to
|
|
2283
|
+
# enable this model", which says nothing about the organization.
|
|
2280
2284
|
# Both shim-written parks carry a soft_until stamp, so even a false positive returns to
|
|
2281
2285
|
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
2282
2286
|
PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
|
|
2283
|
-
PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
|
|
2287
|
+
PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable access|not authorized to use claude code'
|
|
2284
2288
|
# A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
|
|
2285
2289
|
# Switch to another model, or manage usage credits..." — the account still has
|
|
2286
2290
|
# capacity for every other model, and the endpoint says so in the message itself.
|
package/bin/claude-accounts
CHANGED
|
@@ -1715,7 +1715,7 @@ cmd_limits() {
|
|
|
1715
1715
|
import hashlib, json, os, sys, time, urllib.request
|
|
1716
1716
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1717
1717
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1718
|
-
from audit import creds_doc_state # noqa: E402 (one rule for "can this credential work")
|
|
1718
|
+
from audit import creds_doc_state, org_block_in_force # noqa: E402 (one rule for "can this credential work")
|
|
1719
1719
|
import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
|
|
1720
1720
|
|
|
1721
1721
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
@@ -1812,11 +1812,12 @@ def weekly_marker(txt):
|
|
|
1812
1812
|
def mark_expired(d, slug, detail=''):
|
|
1813
1813
|
mpath = os.path.join(d, '.expired')
|
|
1814
1814
|
# An org-blocked marker is the strongest statement there is about an account and
|
|
1815
|
-
#
|
|
1816
|
-
#
|
|
1817
|
-
#
|
|
1815
|
+
# telemetry can never lift it (see clear_expired). Overwriting its REASON with a
|
|
1816
|
+
# weaker one is how it gets lifted by accident: the next successful fetch sees a
|
|
1817
|
+
# reason clear_expired is willing to drop, and the block vanishes. One whose
|
|
1818
|
+
# soft_until has run out binds nothing any more and outranks nothing either.
|
|
1818
1819
|
try:
|
|
1819
|
-
if
|
|
1820
|
+
if org_block_in_force(open(mpath, errors='replace').read(), now):
|
|
1820
1821
|
return
|
|
1821
1822
|
except OSError:
|
|
1822
1823
|
pass
|
|
@@ -1840,7 +1841,9 @@ def clear_expired(d):
|
|
|
1840
1841
|
un-parked every dead-token account minutes after the shim parked it, and every
|
|
1841
1842
|
fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
|
|
1842
1843
|
token (the shim's newer-file rule) or a real call that used the token lifts it.
|
|
1843
|
-
Only a
|
|
1844
|
+
Only a real call lifts the excepted ones — a passing `verify`, or one the usage
|
|
1845
|
+
limiter refused (the limiter answers only past the credential and the org gate) —
|
|
1846
|
+
or a re-login; an org block also lapses at its soft_until (6 h)."""
|
|
1844
1847
|
mpath = os.path.join(d, '.expired')
|
|
1845
1848
|
try:
|
|
1846
1849
|
body = open(mpath, errors='replace').read()
|
|
@@ -2788,7 +2791,8 @@ import hashlib, json, os, re, subprocess, sys, time
|
|
|
2788
2791
|
|
|
2789
2792
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
2790
2793
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
2791
|
-
from audit import audit_account, creds_state, creds_doc_state
|
|
2794
|
+
from audit import (audit_account, creds_state, creds_doc_state, # noqa: E402 (shared with the shim's rule)
|
|
2795
|
+
marker_slug, org_block_in_force)
|
|
2792
2796
|
import keychain # noqa: E402
|
|
2793
2797
|
from claude_token_identity import check_identity # noqa: E402
|
|
2794
2798
|
from claude_reset import cli_version # noqa: E402
|
|
@@ -2803,14 +2807,43 @@ AUTH_ERR = re.compile(
|
|
|
2803
2807
|
r'401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)'
|
|
2804
2808
|
r'|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)|session expired'
|
|
2805
2809
|
r'|could not be refreshed|please (run|sign in|log ?in)|re-?authenticate', re.I)
|
|
2810
|
+
# `ask your admin to enable ACCESS`, never the bare prefix: the same client ends a 404
|
|
2811
|
+
# model_not_found with "…or ask your admin to enable this model", which is about the
|
|
2812
|
+
# model and never about the organization (the real refusal still matches three ways).
|
|
2806
2813
|
ORG_ERR = re.compile(
|
|
2807
2814
|
r'organization has disabled|subscription access.*disabl|disabled claude subscription'
|
|
2808
|
-
r'|ask your admin to enable|not authorized to use claude code', re.I)
|
|
2815
|
+
r'|ask your admin to enable access|not authorized to use claude code', re.I)
|
|
2816
|
+
# A USAGE-LIMIT refusal, tested after BROKEN_CLI and BEFORE ORG_ERR/AUTH_ERR — bin/claude's
|
|
2817
|
+
# rule "a rate limit wins the classification". It is also EXCULPATORY: the provider checks
|
|
2818
|
+
# the credential, then the organization's Claude Code gate, and only then the usage
|
|
2819
|
+
# limiter. acct-15 (2026-09-27) proved the order: already at weekly_all 100%, a real call
|
|
2820
|
+
# came back with the ORG refusal, and once the subscription was paid again the same
|
|
2821
|
+
# call came back "You've hit your weekly limit". So a limit refusal proves the credential
|
|
2822
|
+
# that answered authenticated AND its organization lets Claude Code through: verify lifts
|
|
2823
|
+
# a park exactly as a PASS would. Before this, that answer failed with no hint, the
|
|
2824
|
+
# org-blocked park stayed, and every re-check on that Mac kept a paid account BLOCKED.
|
|
2825
|
+
#
|
|
2826
|
+
# The shim's LIMITPAT/MODEL_LIMITPAT, minus what proves nothing about the account: the
|
|
2827
|
+
# shim only picks a cooldown over a park and can afford "overloaded"/529 there; here a
|
|
2828
|
+
# match LIFTS a park. The client's usage-limiter texts ("You've hit your weekly limit ·
|
|
2829
|
+
# resets …", "You've reached your Fable limit. Switch to another model…", "You're out of
|
|
2830
|
+
# usage credits") are covered; a bare 429 is not — a headerless 429 is the throttle the
|
|
2831
|
+
# client itself prints as "Server is temporarily limiting requests (not your usage
|
|
2832
|
+
# limit)", which can answer before any credential is looked at. NOT_A_LIMIT vetoes those
|
|
2833
|
+
# (that text contains "usage limit") and the overload family outright.
|
|
2834
|
+
LIMIT_ERR = re.compile(
|
|
2835
|
+
r"(hit|reached) your [^.\n]{0,40}(limit|budget)|out of usage|usage limit"
|
|
2836
|
+
r"|limit (reached|exceeded)|rate[ _-]?limit|switch to another model", re.I)
|
|
2837
|
+
NOT_A_LIMIT = re.compile(
|
|
2838
|
+
r'not your usage limit|temporarily limiting|overloaded|\b529\b|high load', re.I)
|
|
2839
|
+
# bin/claude PARK_SOFT_ORG: an org policy will not change in minutes, but a verdict that
|
|
2840
|
+
# never expires kept a paid-again account out of the pool until a person noticed.
|
|
2841
|
+
PARK_SOFT_ORG = 21600
|
|
2809
2842
|
# EXCULPATORY, tested FIRST — the twin of BROKEN_CLI in bin/codex-accounts, and
|
|
2810
2843
|
# it matters MORE here: AUTH_ERR above matches a bare `401` or `403`, so any
|
|
2811
2844
|
# machine fault whose output happens to contain those three digits parks a live
|
|
2812
|
-
# login. `mark_expired` writes no `soft_until
|
|
2813
|
-
# until a person clears it. These patterns say the CLI never reached the API,
|
|
2845
|
+
# login. `mark_expired` writes no `soft_until` for an auth park, so that verdict is
|
|
2846
|
+
# permanent until a person clears it. These patterns say the CLI never reached the API,
|
|
2814
2847
|
# which is a fact about the machine and never about the login.
|
|
2815
2848
|
# See health.log 2026-08-24: `env: node: No such file or directory` failed
|
|
2816
2849
|
# every account in one pass.
|
|
@@ -2826,23 +2859,60 @@ def mark_expired(d, slug, detail=''):
|
|
|
2826
2859
|
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
2827
2860
|
there is — a real inference call that came back 'not authenticated'."""
|
|
2828
2861
|
mpath = os.path.join(d, '.expired')
|
|
2829
|
-
|
|
2830
|
-
#
|
|
2831
|
-
# one is how it gets lifted by accident
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2862
|
+
t = time.time()
|
|
2863
|
+
# Same rule as the limits path: an org block outranks every other reason while it
|
|
2864
|
+
# binds. Rewriting its reason with a weaker one is how it gets lifted by accident
|
|
2865
|
+
# later. Only the org refusal itself may rewrite it — and does: a re-observed block
|
|
2866
|
+
# is a fresh park with a fresh soft_until, not a stale one left to run out.
|
|
2867
|
+
if slug != 'org-blocked':
|
|
2868
|
+
try:
|
|
2869
|
+
if org_block_in_force(open(mpath, errors='replace').read(), t):
|
|
2870
|
+
return
|
|
2871
|
+
except OSError:
|
|
2872
|
+
pass
|
|
2873
|
+
# The shim's field order (bin/claude park_line), so every reader parses one shape.
|
|
2874
|
+
soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
|
|
2837
2875
|
try:
|
|
2838
2876
|
with open(mpath + '.tmp', 'w') as f:
|
|
2839
|
-
f.write(f'{int(
|
|
2840
|
-
f.write(f"reason={slug} marked_at="
|
|
2841
|
-
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
2877
|
+
f.write(f'{int(t)}\n')
|
|
2878
|
+
f.write(f"reason={slug} {soft}marked_at="
|
|
2879
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
|
|
2842
2880
|
os.replace(mpath + '.tmp', mpath)
|
|
2843
2881
|
except Exception:
|
|
2844
2882
|
pass
|
|
2845
2883
|
|
|
2884
|
+
def lift_park(d, uses_token):
|
|
2885
|
+
"""A real call reached the account: drop its park, and return the reason it had
|
|
2886
|
+
('' when nothing was removed). The PASS rule, shared with a usage-limit refusal.
|
|
2887
|
+
WHICH credential answered matters: an answer on the OAuth login says nothing about
|
|
2888
|
+
a portable token a real call rejected; clearing that park re-opened the 401 loop
|
|
2889
|
+
the marker exists to stop (the shim re-picked the account, exported the dead
|
|
2890
|
+
token, parked it again — 2026-08-29)."""
|
|
2891
|
+
mpath = os.path.join(d, '.expired')
|
|
2892
|
+
try:
|
|
2893
|
+
marker = open(mpath, errors='replace').read()
|
|
2894
|
+
except OSError:
|
|
2895
|
+
marker = ''
|
|
2896
|
+
if not uses_token and 'reason=setup-token-invalid' in marker:
|
|
2897
|
+
return ''
|
|
2898
|
+
try:
|
|
2899
|
+
os.remove(mpath)
|
|
2900
|
+
except OSError:
|
|
2901
|
+
return ''
|
|
2902
|
+
return marker_slug(marker) or 'unlabelled'
|
|
2903
|
+
|
|
2904
|
+
def record_token_verified(d, token):
|
|
2905
|
+
"""Proof that a real call authenticated with exactly this portable token."""
|
|
2906
|
+
try:
|
|
2907
|
+
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
2908
|
+
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2909
|
+
with open(temp, 'w') as f:
|
|
2910
|
+
f.write(digest + '\n')
|
|
2911
|
+
os.chmod(temp, 0o600)
|
|
2912
|
+
os.replace(temp, os.path.join(d, '.server-token-verified'))
|
|
2913
|
+
except OSError:
|
|
2914
|
+
pass
|
|
2915
|
+
|
|
2846
2916
|
for acct in manifest.get('accounts', []):
|
|
2847
2917
|
aid = acct['id']
|
|
2848
2918
|
d = os.path.join(root, aid)
|
|
@@ -2933,29 +3003,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2933
3003
|
out = (r.stdout or '').strip()
|
|
2934
3004
|
if r.returncode == 0 and 'ok' in out.lower():
|
|
2935
3005
|
# A real call succeeded: this account is definitively alive — but WHICH
|
|
2936
|
-
# credential answered matters.
|
|
2937
|
-
|
|
2938
|
-
# 401 loop the marker exists to stop (the shim re-picked the account, exported
|
|
2939
|
-
# the dead token, parked it again — 2026-08-29).
|
|
2940
|
-
try:
|
|
2941
|
-
marker = open(os.path.join(d, '.expired'), errors='replace').read()
|
|
2942
|
-
except OSError:
|
|
2943
|
-
marker = ''
|
|
2944
|
-
if uses_token or 'reason=setup-token-invalid' not in marker:
|
|
2945
|
-
try:
|
|
2946
|
-
os.remove(os.path.join(d, '.expired'))
|
|
2947
|
-
except OSError:
|
|
2948
|
-
pass
|
|
3006
|
+
# credential answered matters (lift_park).
|
|
3007
|
+
lift_park(d, uses_token)
|
|
2949
3008
|
if uses_token:
|
|
2950
|
-
|
|
2951
|
-
digest = hashlib.sha256(env['CLAUDE_CODE_OAUTH_TOKEN'].encode()).hexdigest()
|
|
2952
|
-
temp = os.path.join(d, '.server-token-verified.tmp')
|
|
2953
|
-
with open(temp, 'w') as f:
|
|
2954
|
-
f.write(digest + '\n')
|
|
2955
|
-
os.chmod(temp, 0o600)
|
|
2956
|
-
os.replace(temp, os.path.join(d, '.server-token-verified'))
|
|
2957
|
-
except OSError:
|
|
2958
|
-
pass
|
|
3009
|
+
record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
|
|
2959
3010
|
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {out[:60]!r}')
|
|
2960
3011
|
if not uses_token and identity['state'] == 'mismatch':
|
|
2961
3012
|
print(f' portable setup-token belongs to a different organization; OAuth login passed; '
|
|
@@ -2964,16 +3015,32 @@ for acct in manifest.get('accounts', []):
|
|
|
2964
3015
|
raw = (r.stderr or '').strip()
|
|
2965
3016
|
err = raw[:200]
|
|
2966
3017
|
hint = ''
|
|
3018
|
+
# The limiter's refusal is printed on STDOUT (health.log 2026-09-28: out="You've
|
|
3019
|
+
# hit your weekly limit · resets 1am (Europe/Istanbul)" err=''), so it is read
|
|
3020
|
+
# from both streams, whole.
|
|
3021
|
+
said = f'{out}\n{raw}'
|
|
2967
3022
|
# STDERR only, and the WHOLE of it: stdout is the model's own answer,
|
|
2968
3023
|
# and a session that merely discusses "Cannot find module" must not be
|
|
2969
3024
|
# able to excuse a real dead login.
|
|
2970
3025
|
if BROKEN_CLI.search(raw):
|
|
2971
3026
|
hint = (' — the Claude CLI cannot run on this machine; not a login '
|
|
2972
3027
|
'problem, nothing was parked')
|
|
3028
|
+
elif LIMIT_ERR.search(said) and not NOT_A_LIMIT.search(said):
|
|
3029
|
+
# Still a FAIL — the account cannot serve work right now, and the runner
|
|
3030
|
+
# counts it — but the usage limiter only answers a credential that
|
|
3031
|
+
# authenticated for an organization that lets Claude Code through (see
|
|
3032
|
+
# LIMIT_ERR), so this is the PASS path's lift, word for word.
|
|
3033
|
+
lifted = lift_park(d, uses_token)
|
|
3034
|
+
if uses_token:
|
|
3035
|
+
record_token_verified(d, env['CLAUDE_CODE_OAUTH_TOKEN'])
|
|
3036
|
+
hint = ' — usage-limited, not a login or org problem'
|
|
3037
|
+
if lifted:
|
|
3038
|
+
hint += f'; lifted its {lifted} park'
|
|
2973
3039
|
elif ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
2974
3040
|
# Not an auth problem: the account authenticates fine, its organization
|
|
2975
3041
|
# has simply turned Claude Code subscription access off. Park it — a
|
|
2976
|
-
# re-login changes nothing — and say what actually helps.
|
|
3042
|
+
# re-login changes nothing — and say what actually helps. The park carries
|
|
3043
|
+
# the shim's 6 h soft_until, and a re-observed block refreshes it.
|
|
2977
3044
|
mark_expired(d, 'org-blocked',
|
|
2978
3045
|
"the account's organization has disabled Claude Code access")
|
|
2979
3046
|
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
package/bin/codex
CHANGED
|
@@ -700,7 +700,8 @@ has_auth() {
|
|
|
700
700
|
# `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
|
|
701
701
|
# a misread never costs an account permanently. Markers written by codex-accounts —
|
|
702
702
|
# a refresh grant that answered invalid_grant, a failed real call — carry no soft_until
|
|
703
|
-
# and stay until the account provably works again
|
|
703
|
+
# and stay until the account provably works again; an org block is the exception and
|
|
704
|
+
# carries the shim's 6 h soft_until wherever it was written.
|
|
704
705
|
expired_marked() { # $1 = acct dir
|
|
705
706
|
local m="$1/.expired" mt soft reason
|
|
706
707
|
[ -f "$m" ] || return 1
|
|
@@ -708,8 +709,8 @@ expired_marked() { # $1 = acct dir
|
|
|
708
709
|
# CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
|
|
709
710
|
# evidence they were about. A POLICY park (org-blocked: a workspace admin turned
|
|
710
711
|
# Codex off for the account) is about the account, not the credential: refreshing
|
|
711
|
-
# its token does not re-enable Codex, so only a passing real call
|
|
712
|
-
# lifts it.
|
|
712
|
+
# its token does not re-enable Codex, so only a passing real call, a re-login or
|
|
713
|
+
# its own soft_until lifts it.
|
|
713
714
|
if [ "$reason" != "org-blocked" ]; then
|
|
714
715
|
mt="$(file_mtime "$m")"
|
|
715
716
|
if [ -f "$1/auth.json" ] && [ "$(file_mtime "$1/auth.json")" -gt "$mt" ]; then
|
package/bin/codex-accounts
CHANGED
|
@@ -1245,7 +1245,8 @@ def clear_expired(d):
|
|
|
1245
1245
|
"""A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
|
|
1246
1246
|
EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
|
|
1247
1247
|
they are just barred from Codex inference, so telemetry says nothing about them.
|
|
1248
|
-
Only a passing `verify` (a real call) or a re-login lifts that
|
|
1248
|
+
Only a passing `verify` (a real call) or a re-login lifts that, or its own
|
|
1249
|
+
soft_until (6 h) runs out."""
|
|
1249
1250
|
mpath = os.path.join(d, '.expired')
|
|
1250
1251
|
try:
|
|
1251
1252
|
if 'reason=org-blocked' in open(mpath, errors='replace').read():
|
|
@@ -1886,9 +1887,9 @@ ORG_ERR = re.compile(
|
|
|
1886
1887
|
# API at all — a missing native package, a missing node, an unrunnable binary —
|
|
1887
1888
|
# so they are facts about this MACHINE, not about the login. Parking on one
|
|
1888
1889
|
# converts an indeterminate probe into "this login is dead", and that verdict
|
|
1889
|
-
# does not self-heal: `mark_expired` here writes no `soft_until
|
|
1890
|
-
# reads it as proven dead and keeps it, while the panel
|
|
1891
|
-
# the token until its fingerprint changes.
|
|
1890
|
+
# does not self-heal: `mark_expired` here writes no `soft_until` for an auth
|
|
1891
|
+
# park, so the shim reads it as proven dead and keeps it, while the panel
|
|
1892
|
+
# refuses to re-import the token until its fingerprint changes.
|
|
1892
1893
|
#
|
|
1893
1894
|
# Not hypothetical. health.log 2026-08-24T06:37:02Z: all four accounts
|
|
1894
1895
|
# `FAIL rc=127 err='env: node: No such file or directory'` — one broken PATH
|
|
@@ -1907,15 +1908,24 @@ BROKEN_CLI = re.compile(
|
|
|
1907
1908
|
r'|cannot execute binary file|bad CPU type in executable|exec format error',
|
|
1908
1909
|
re.I | re.M)
|
|
1909
1910
|
|
|
1911
|
+
# bin/codex PARK_SOFT_ORG: a workspace policy will not change in minutes, but a verdict
|
|
1912
|
+
# that never expires keeps a re-enabled account out of the pool until a person notices
|
|
1913
|
+
# (claude side, acct-15, 2026-09-27: an org block outlived the org's own repair).
|
|
1914
|
+
PARK_SOFT_ORG = 21600
|
|
1915
|
+
|
|
1910
1916
|
def mark_expired(d, slug, detail=''):
|
|
1911
1917
|
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
1912
|
-
there is — a real inference call that came back 'not authenticated'.
|
|
1918
|
+
there is — a real inference call that came back 'not authenticated'. An org block
|
|
1919
|
+
carries the shim's soft_until, and a re-observed one is rewritten with a fresh one."""
|
|
1913
1920
|
mpath = os.path.join(d, '.expired')
|
|
1921
|
+
t = time.time()
|
|
1922
|
+
# The shim's field order (bin/codex park line), so every reader parses one shape.
|
|
1923
|
+
soft = f'soft_until={int(t) + PARK_SOFT_ORG} ' if slug == 'org-blocked' else ''
|
|
1914
1924
|
try:
|
|
1915
1925
|
with open(mpath + '.tmp', 'w') as f:
|
|
1916
|
-
f.write(f'{int(
|
|
1917
|
-
f.write(f"reason={slug} marked_at="
|
|
1918
|
-
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
1926
|
+
f.write(f'{int(t)}\n')
|
|
1927
|
+
f.write(f"reason={slug} {soft}marked_at="
|
|
1928
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))} detail={detail}\n")
|
|
1919
1929
|
os.replace(mpath + '.tmp', mpath)
|
|
1920
1930
|
except Exception:
|
|
1921
1931
|
pass
|
|
@@ -33,7 +33,11 @@ claude-accounts relogin acct-01 --token # portable token instead (Mac + server
|
|
|
33
33
|
|
|
34
34
|
`relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
|
|
35
35
|
clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
|
|
36
|
-
one at any time; a PASS clears the park automatically
|
|
36
|
+
one at any time; a PASS clears the park automatically, and so does a call the usage
|
|
37
|
+
limiter refuses (`FAIL … — usage-limited, not a login or org problem; lifted its
|
|
38
|
+
org-blocked park`): the org's Claude Code gate answers before the limiter, so a limit
|
|
39
|
+
answer proves the org lets the account through. A `BLOCKED` park also lapses on its own
|
|
40
|
+
after 6 hours (`soft_until`), and the pool tries the account again.
|
|
37
41
|
|
|
38
42
|
`relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
|
|
39
43
|
the wrong email), one account at a time, and syncs to the server once at the end.
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -50,6 +50,14 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
50
50
|
before direct, TUI, or `--resume` work sees the 401. Run `claude-accounts verify` to
|
|
51
51
|
check every token immediately; `claude-accounts expired` reports token-only accounts
|
|
52
52
|
as `UNVERIFIED` until that proof exists.
|
|
53
|
+
- **An account reads `BLOCKED` although its subscription is active again** — the park came
|
|
54
|
+
from a real call the org refused ("Your organization has disabled Claude subscription
|
|
55
|
+
access for Claude Code"), e.g. while a lapsed subscription was unpaid. Run
|
|
56
|
+
`claude-accounts verify`: a PASS lifts it, and so does a refusal from the usage limiter
|
|
57
|
+
(`FAIL … — usage-limited, not a login or org problem; lifted its org-blocked park`),
|
|
58
|
+
because the provider answers the org gate before the limiter. Without either, the park
|
|
59
|
+
lapses after its 6-hour `soft_until` and the pool tries the account again. The 404
|
|
60
|
+
"…or ask your admin to enable this model" is about a model, never an org block.
|
|
53
61
|
- **A session restarted itself, or did not** — that is [auto-resume](AUTORESUME.md).
|
|
54
62
|
`grep autoresume ~/.claude-accounts/selection.log` shows each step: `hold` means no
|
|
55
63
|
other account had headroom, and `giveup` gives the reason nothing was relaunched. A
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/audit.py
CHANGED
|
@@ -7,7 +7,9 @@ States:
|
|
|
7
7
|
expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
|
|
8
8
|
token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
|
|
9
9
|
blocked authenticates, but its organization has disabled Claude Code subscription
|
|
10
|
-
access — a re-login cannot fix it, so it needs an admin (or removal)
|
|
10
|
+
access — a re-login cannot fix it, so it needs an admin (or removal); a
|
|
11
|
+
park with a soft_until (every one written now: 6 h) lapses on its own
|
|
12
|
+
and the pool re-tries the account
|
|
11
13
|
locked the OAuth login is in the macOS Keychain and THIS session cannot open it
|
|
12
14
|
(ssh/tmux/background) — it works from the Mac's own GUI session, so it is
|
|
13
15
|
neither dead nor missing; just not usable from here
|
|
@@ -73,12 +75,31 @@ def marker_slug(detail):
|
|
|
73
75
|
return m.group(1) if m else ''
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
def marker_soft_until(detail):
|
|
79
|
+
"""The soft_until=<epoch> field of a marker line (None when absent)."""
|
|
80
|
+
m = re.search(r'soft_until=(\d+)', detail or '')
|
|
81
|
+
return int(m.group(1)) if m else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def org_block_in_force(body, now=None):
|
|
85
|
+
"""True when a marker is an org-blocked park that still binds — no soft_until, or
|
|
86
|
+
one still ahead (expired_marked below and bin/claude's reading of the same line).
|
|
87
|
+
The writers use it to keep a weaker reason from overwriting a live org block; a
|
|
88
|
+
block whose soft window has run out no longer outranks anything."""
|
|
89
|
+
now = time.time() if now is None else now
|
|
90
|
+
if marker_slug(body) != 'org-blocked':
|
|
91
|
+
return False
|
|
92
|
+
soft = marker_soft_until(body)
|
|
93
|
+
return soft is None or soft > now
|
|
94
|
+
|
|
95
|
+
|
|
76
96
|
def expired_marked(d, now=None):
|
|
77
97
|
"""True when <d>/.expired is still in force. Mirrors the shim exactly:
|
|
78
98
|
a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
|
|
79
99
|
refresh by another process); a POLICY park (org-blocked) does not, because a token
|
|
80
100
|
refresh says nothing about whether the org re-enabled Claude Code. Either kind also
|
|
81
|
-
expires at its own soft_until when
|
|
101
|
+
expires at its own soft_until when it carries one — every shim park, and every
|
|
102
|
+
org-blocked park (the shim's and verify's alike carry six hours)."""
|
|
82
103
|
now = time.time() if now is None else now
|
|
83
104
|
mpath = os.path.join(d, '.expired')
|
|
84
105
|
if not os.path.isfile(mpath):
|
|
@@ -105,8 +126,8 @@ def expired_marked(d, now=None):
|
|
|
105
126
|
if not os.path.isfile(os.path.join(d, '.credentials.json')) \
|
|
106
127
|
and keychain.item_mtime(d) > mt:
|
|
107
128
|
return False
|
|
108
|
-
|
|
109
|
-
if
|
|
129
|
+
soft = marker_soft_until(detail)
|
|
130
|
+
if soft is not None and soft <= now:
|
|
110
131
|
return False
|
|
111
132
|
return True
|
|
112
133
|
|
|
@@ -288,6 +309,13 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
288
309
|
row['state'] = 'blocked'
|
|
289
310
|
row['reason'] = ('this account\'s organization has disabled Claude Code '
|
|
290
311
|
'subscription access')
|
|
312
|
+
soft = marker_soft_until(detail)
|
|
313
|
+
if soft is not None:
|
|
314
|
+
# When the pool gives it another real call on its own (the park's
|
|
315
|
+
# soft_until). A passing verify, one refused only by a usage limit, or
|
|
316
|
+
# a re-login lifts it sooner.
|
|
317
|
+
row['reason'] += time.strftime(
|
|
318
|
+
'; re-tried by the pool after %Y-%m-%d %H:%M UTC', time.gmtime(soft))
|
|
291
319
|
elif slug == 'setup-token-invalid':
|
|
292
320
|
login = oauth_login(d, now)
|
|
293
321
|
if login['state'] == 'ok':
|
package/lib/codex_audit.py
CHANGED
|
@@ -69,7 +69,8 @@ def expired_marked(d, now=None):
|
|
|
69
69
|
a CREDENTIAL-scoped park clears as soon as a newer auth.json lands (re-login, or a
|
|
70
70
|
refresh by another process); a POLICY park (org-blocked) does not, because a token
|
|
71
71
|
refresh says nothing about whether the workspace re-enabled Codex. Either kind also
|
|
72
|
-
expires at its own soft_until when
|
|
72
|
+
expires at its own soft_until when it carries one — every shim park, and every
|
|
73
|
+
org-blocked park (the shim's and verify's alike carry six hours)."""
|
|
73
74
|
now = time.time() if now is None else now
|
|
74
75
|
mpath = os.path.join(d, '.expired')
|
|
75
76
|
if not os.path.isfile(mpath):
|
|
@@ -199,6 +200,12 @@ def audit_account(root, acct, now=None, machine=None):
|
|
|
199
200
|
row['state'] = 'blocked'
|
|
200
201
|
row['reason'] = ('Codex access is disabled for this account by a '
|
|
201
202
|
'workspace admin')
|
|
203
|
+
soft = re.search(r'soft_until=(\d+)', detail)
|
|
204
|
+
if soft:
|
|
205
|
+
# When the pool gives it another real call on its own (lib/audit.py).
|
|
206
|
+
row['reason'] += time.strftime(
|
|
207
|
+
'; re-tried by the pool after %Y-%m-%d %H:%M UTC',
|
|
208
|
+
time.gmtime(int(soft.group(1))))
|
|
202
209
|
else:
|
|
203
210
|
row['state'] = 'expired'
|
|
204
211
|
row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -272,8 +272,21 @@ if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
|
|
|
272
272
|
exit 1
|
|
273
273
|
fi
|
|
274
274
|
case " $* " in
|
|
275
|
-
*" -p --output-format text --max-turns 1 "*)
|
|
275
|
+
*" -p --output-format text --max-turns 1 "*)
|
|
276
|
+
# verify's real call, refused: `verifysay:<acct>` answers FAKE_VERIFY_TEXT the way
|
|
277
|
+
# the real client prints a refusal in text mode — on STDOUT, exit 1 (health.log
|
|
278
|
+
# 2026-09-28: out="You've hit your weekly limit · resets 1am (Europe/Istanbul)" err='').
|
|
279
|
+
if [ -f "$ctl" ] && grep -qx "verifysay:$acct" "$ctl" 2>/dev/null; then
|
|
280
|
+
printf '%s\n' "${FAKE_VERIFY_TEXT:-}"
|
|
281
|
+
exit 1
|
|
282
|
+
fi
|
|
283
|
+
echo "OK"; exit 0 ;;
|
|
276
284
|
esac
|
|
285
|
+
if [ -f "$ctl" ] && grep -qx "model404:$acct" "$ctl" 2>/dev/null; then
|
|
286
|
+
# 2.1.283's 404 model_not_found text: about a MODEL, never the organization.
|
|
287
|
+
echo "The model claude-nope is not available on your firstParty deployment. Try switching to claude-opus-5, or ask your admin to enable this model."
|
|
288
|
+
exit 1
|
|
289
|
+
fi
|
|
277
290
|
if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
|
|
278
291
|
echo "API Error: 429 rate limit exceeded" >&2
|
|
279
292
|
exit 1
|
|
@@ -1155,6 +1168,17 @@ check "relogin targets org-blocked accounts" "acct-01 login saved" "$out"
|
|
|
1155
1168
|
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login clears an org block" \
|
|
1156
1169
|
|| t_fail "org-block relogin" "marker survived the re-login"
|
|
1157
1170
|
rm -f "$ACC/acct-01/.expired"
|
|
1171
|
+
# The client's 404 model_not_found ends "…or ask your admin to enable this model". The org
|
|
1172
|
+
# vocabulary's bare "ask your admin to enable" matched it, parking a healthy account as
|
|
1173
|
+
# org-blocked over a model name; only "…to enable access" is the org refusal.
|
|
1174
|
+
printf '{"fetched_at":%s,"max_percent":1,"weekly_percent":1,"session_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
1175
|
+
printf '{"fetched_at":%s,"max_percent":50,"weekly_percent":50,"session_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
1176
|
+
echo "model404:acct-01" > "$FAKE_CTL"
|
|
1177
|
+
out="$(claude -p hello < /dev/null 2>&1)"
|
|
1178
|
+
check "a model 404 reaches the caller as it is" "not available on your firstParty deployment" "$out"
|
|
1179
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a model 404 ('ask your admin to enable this model') parks nothing" \
|
|
1180
|
+
|| t_fail "model 404 park" "$(tail -1 "$ACC/acct-01/.expired")"
|
|
1181
|
+
rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
|
|
1158
1182
|
|
|
1159
1183
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
1160
1184
|
mkdir -p "$ACC/acct-03"
|
|
@@ -3213,6 +3237,14 @@ rm -f "$ACC/acct-05/limits.json"
|
|
|
3213
3237
|
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
3214
3238
|
[ -f "$ACC/acct-05/.expired" ] && t_ok "a usage fetch does not unpark an org-blocked account" \
|
|
3215
3239
|
|| t_fail "org-block unpark" "telemetry cleared an org block it cannot observe"
|
|
3240
|
+
# ...nor one verify wrote with its soft_until (every org park carries one now)
|
|
3241
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' "$now" "$(( $(date +%s) + 21600 ))" \
|
|
3242
|
+
> "$ACC/acct-05/.expired"
|
|
3243
|
+
rm -f "$ACC/acct-05/limits.json"
|
|
3244
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
3245
|
+
grep -q 'reason=org-blocked soft_until=' "$ACC/acct-05/.expired" 2>/dev/null \
|
|
3246
|
+
&& t_ok "a usage fetch does not unpark a soft-stamped org block" \
|
|
3247
|
+
|| t_fail "soft org-block unpark" "telemetry cleared a soft-stamped org block"
|
|
3216
3248
|
rm -f "$ACC/acct-05/.expired" "$ACC/acct-05/server.token"
|
|
3217
3249
|
|
|
3218
3250
|
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
@@ -3770,6 +3802,34 @@ RACER
|
|
|
3770
3802
|
grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null \
|
|
3771
3803
|
&& t_ok "a dead refresh grant never overwrites an org-blocked marker" \
|
|
3772
3804
|
|| t_fail "org block downgraded" "marker is now: $(cat "$SD/acct-01/.expired" 2>/dev/null | tail -1)"
|
|
3805
|
+
# The same without the portable token beside it — park_dead_grant never parks an
|
|
3806
|
+
# account its token still serves, so the check above cannot see the guard. A live org
|
|
3807
|
+
# block (no soft_until, or one still ahead) is never overwritten; one whose soft_until
|
|
3808
|
+
# has run out binds nothing and outranks nothing, so the dead grant gets its own park.
|
|
3809
|
+
mv "$SD/acct-01/server.token" "$SD/acct-01/server.token.aside"
|
|
3810
|
+
for soft in none "$(( $(date +%s) + 21600 ))" "$(( $(date +%s) - 10 ))"; do
|
|
3811
|
+
if [ "$soft" = none ]; then
|
|
3812
|
+
printf '%s\nreason=org-blocked marked_at=now detail=test\n' "$now" > "$SD/acct-01/.expired"
|
|
3813
|
+
else
|
|
3814
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' \
|
|
3815
|
+
"$((now - 30000))" "$soft" > "$SD/acct-01/.expired"
|
|
3816
|
+
fi
|
|
3817
|
+
rm -f "$SD/acct-01/limits.json"
|
|
3818
|
+
CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
|
|
3819
|
+
claude-accounts limits --force >/dev/null 2>&1
|
|
3820
|
+
grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null && echo kept || echo "$(tail -1 "$SD/acct-01/.expired" 2>/dev/null)"
|
|
3821
|
+
done > "$WORK/lapsed-org.txt"
|
|
3822
|
+
mv "$SD/acct-01/server.token.aside" "$SD/acct-01/server.token"
|
|
3823
|
+
[ "$(sed -n 1p "$WORK/lapsed-org.txt")" = kept ] \
|
|
3824
|
+
&& t_ok "a dead refresh grant (no token beside it) never overwrites an org block" \
|
|
3825
|
+
|| t_fail "org block downgraded" "$(sed -n 1p "$WORK/lapsed-org.txt")"
|
|
3826
|
+
[ "$(sed -n 2p "$WORK/lapsed-org.txt")" = kept ] \
|
|
3827
|
+
&& t_ok "a dead refresh grant never overwrites an org block inside its soft window" \
|
|
3828
|
+
|| t_fail "soft org block downgraded" "$(sed -n 2p "$WORK/lapsed-org.txt")"
|
|
3829
|
+
case "$(sed -n 3p "$WORK/lapsed-org.txt")" in
|
|
3830
|
+
reason=refresh-token-expired*) t_ok "a lapsed org block does not hide a dead refresh grant from its park" ;;
|
|
3831
|
+
*) t_fail "lapsed org block shields a dead grant" "$(sed -n 3p "$WORK/lapsed-org.txt")" ;;
|
|
3832
|
+
esac
|
|
3773
3833
|
rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json"
|
|
3774
3834
|
|
|
3775
3835
|
# ---- the >=90% cutoff keeps the TIGHT window --------------------------------
|
|
@@ -4293,6 +4353,171 @@ case "$out" in
|
|
|
4293
4353
|
esac
|
|
4294
4354
|
rm -f "$WORK/ctl-brokencli"
|
|
4295
4355
|
|
|
4356
|
+
# ---- 17a3. a usage-limit refusal proves the org lets the account through --------------
|
|
4357
|
+
# acct-15, 2026-09-27: at weekly_all 100% a real call came back with the ORG refusal, and
|
|
4358
|
+
# once the lapsed subscription was paid again the same call answered "You've hit your
|
|
4359
|
+
# weekly limit" — the provider checks the org gate before the usage limiter. verify
|
|
4360
|
+
# failed that answer with no hint and kept the org-blocked park, so a paid account read
|
|
4361
|
+
# BLOCKED on that Mac through every re-check. A limit refusal now lifts a park by the PASS
|
|
4362
|
+
# rule, and the line stays FAIL (the runner counts it).
|
|
4363
|
+
VP="$WORK/verifylimit"
|
|
4364
|
+
vnow="$(date +%s)" # the suite's $now is minutes old by here; soft windows need a fresh one
|
|
4365
|
+
VP_HOME=mac; VP_MACHINE=mac; [ "$(uname -s)" = "Darwin" ] || { VP_HOME=server; VP_MACHINE=linux; }
|
|
4366
|
+
mkdir -p "$VP/tmp" "$VP/acct-01" "$VP/acct-02" "$VP/acct-03"
|
|
4367
|
+
: > "$VP/.limits-kick"
|
|
4368
|
+
cat > "$VP/accounts.json" <<EOF2
|
|
4369
|
+
{ "version": 1, "threshold": 90,
|
|
4370
|
+
"accounts": [
|
|
4371
|
+
{"id": "acct-01", "email": "vl1@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
|
|
4372
|
+
{"id": "acct-02", "email": "vl2@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
|
|
4373
|
+
{"id": "acct-03", "email": "vl3@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"}
|
|
4374
|
+
] }
|
|
4375
|
+
EOF2
|
|
4376
|
+
VP_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-vl","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4377
|
+
# acct-01: OAuth login only. acct-02: a live login beside a portable token (verify runs
|
|
4378
|
+
# the LOGIN). acct-03: the portable token only (verify runs the TOKEN).
|
|
4379
|
+
printf '%s' "$VP_LIVE" > "$VP/acct-01/.credentials.json"
|
|
4380
|
+
printf '%s' "$VP_LIVE" > "$VP/acct-02/.credentials.json"
|
|
4381
|
+
printf 'sk-ant-oat01-vl-token-02' > "$VP/acct-02/server.token"
|
|
4382
|
+
printf 'sk-ant-oat01-vl-token-03' > "$VP/acct-03/server.token"
|
|
4383
|
+
printf 'verifysay:acct-01\nverifysay:acct-02\nverifysay:acct-03\n' > "$WORK/ctl-verifysay"
|
|
4384
|
+
vp_verify() { # $1 = what the refused call prints
|
|
4385
|
+
FAKE_CTL="$WORK/ctl-verifysay" FAKE_VERIFY_TEXT="$1" CLAUDE_ACCOUNTS_DIR="$VP" \
|
|
4386
|
+
claude-accounts verify 2>&1
|
|
4387
|
+
}
|
|
4388
|
+
vp_line() { printf '%s\n' "$out" | grep "^$1 " | head -1; }
|
|
4389
|
+
vp_park() { # $1 acct, $2 marker line 2 (reason=… fields)
|
|
4390
|
+
printf '%s\n%s\n' "$((vnow - 3600))" "$2" > "$VP/$1/.expired"
|
|
4391
|
+
}
|
|
4392
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=the account's organization has disabled Claude Code access"
|
|
4393
|
+
vp_park acct-02 "reason=setup-token-invalid marked_at=2026-09-27T20:42:42Z detail=portable setup-token failed a real inference"
|
|
4394
|
+
vp_park acct-03 "reason=org-blocked soft_until=$((vnow + 20000)) marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4395
|
+
rm -f "$VP/acct-03/.server-token-verified"
|
|
4396
|
+
out="$(vp_verify "You've hit your weekly limit · resets 1am (Europe/Istanbul)")"
|
|
4397
|
+
rc=$?
|
|
4398
|
+
check "a limit refusal still reads FAIL, in the shape the runner parses" \
|
|
4399
|
+
"acct-01 vl1@test: FAIL rc=1 out=\"You've hit your weekly limit" "$out"
|
|
4400
|
+
check "a limit refusal is called what it is" "usage-limited, not a login or org problem" "$(vp_line acct-01)"
|
|
4401
|
+
check "a limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-01)"
|
|
4402
|
+
[ ! -f "$VP/acct-01/.expired" ] && t_ok "a limit refusal lifts an org-blocked park (the org gate answers first)" \
|
|
4403
|
+
|| t_fail "limit lifts org park" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4404
|
+
[ "$rc" = 1 ] && check "limit refusals are still counted as failures" "verified 3 account(s), 3 failure(s)" "$out" \
|
|
4405
|
+
|| t_fail "limit refusal exit" "rc=$rc"
|
|
4406
|
+
n="$(printf '%s\n' "$out" | grep -cE '^(acct-[0-9]+)[[:space:]]+([^[:space:]]+):[[:space:]]+(PASS|FAIL|OK|SKIP)([^[:alnum:]_].*)?$')"
|
|
4407
|
+
[ "$n" = 3 ] && t_ok "every account line still matches app-robot's verdict pattern" \
|
|
4408
|
+
|| t_fail "verdict pattern" "matched $n of 3: $out"
|
|
4409
|
+
# The LOGIN answered for acct-02: that proves nothing about the portable token a real
|
|
4410
|
+
# call rejected, so its token park stays — the PASS rule, exactly.
|
|
4411
|
+
check "a limit refusal on the login is still called usage-limited" "usage-limited" "$(vp_line acct-02)"
|
|
4412
|
+
case "$(vp_line acct-02)" in
|
|
4413
|
+
*lifted*) t_fail "login limit must not claim a lift" "$(vp_line acct-02)" ;;
|
|
4414
|
+
*) t_ok "a limit refusal that lifted nothing does not claim a lift" ;;
|
|
4415
|
+
esac
|
|
4416
|
+
grep -q 'reason=setup-token-invalid' "$VP/acct-02/.expired" 2>/dev/null \
|
|
4417
|
+
&& t_ok "a limit refusal on the OAuth login keeps a setup-token-invalid park" \
|
|
4418
|
+
|| t_fail "login limit vs token park" "the token park was lifted by the login's answer"
|
|
4419
|
+
[ ! -f "$VP/acct-02/.server-token-verified" ] && t_ok "the login's limit answer records no token proof" \
|
|
4420
|
+
|| t_fail "login limit token proof" ".server-token-verified written for a token that never answered"
|
|
4421
|
+
# The TOKEN answered for acct-03: its park goes and the token is proven, as on a PASS.
|
|
4422
|
+
check "a token limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-03)"
|
|
4423
|
+
[ ! -f "$VP/acct-03/.expired" ] && t_ok "a limit refusal on the token lifts the park" \
|
|
4424
|
+
|| t_fail "token limit lift" "$(tail -1 "$VP/acct-03/.expired")"
|
|
4425
|
+
want="$(printf 'sk-ant-oat01-vl-token-03' | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d' ' -f1)"
|
|
4426
|
+
[ "$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)" = "$want" ] \
|
|
4427
|
+
&& t_ok "a limit refusal on the token records .server-token-verified for that token" \
|
|
4428
|
+
|| t_fail "token limit proof" "got '$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)'"
|
|
4429
|
+
st="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list --json 2>/dev/null \
|
|
4430
|
+
| python3 -c 'import json,sys; print({a["id"]: a["status"] for a in json.load(sys.stdin)["accounts"]}.get("acct-01"))')"
|
|
4431
|
+
[ "$st" != "blocked" ] && t_ok "the lifted account no longer lists as blocked ($st)" \
|
|
4432
|
+
|| t_fail "lifted account status" "list --json still says blocked"
|
|
4433
|
+
# A model-scoped limit is the same limiter speaking.
|
|
4434
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4435
|
+
out="$(vp_verify "You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cl to continue.")"
|
|
4436
|
+
[ ! -f "$VP/acct-01/.expired" ] && check "a model-scoped limit lifts the org park too" "lifted its org-blocked park" "$(vp_line acct-01)" \
|
|
4437
|
+
|| t_fail "model limit lift" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4438
|
+
# ...but not the throttle the client itself calls "not your usage limit", nor an overload:
|
|
4439
|
+
# neither proves the org gate was passed, and both contain limit words.
|
|
4440
|
+
for said in "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited" \
|
|
4441
|
+
"API Error: 529 Overloaded. Rate limit exceeded, try again" \
|
|
4442
|
+
"Opus is experiencing high load, please use /model to switch to Sonnet"; do
|
|
4443
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4444
|
+
out="$(vp_verify "$said")"
|
|
4445
|
+
if grep -q 'reason=org-blocked' "$VP/acct-01/.expired" 2>/dev/null \
|
|
4446
|
+
&& case "$(vp_line acct-01)" in *usage-limited*) false ;; *) true ;; esac; then
|
|
4447
|
+
t_ok "no lift on a transient answer: ${said:0:48}"
|
|
4448
|
+
else
|
|
4449
|
+
t_fail "transient answer lifted a park" "${said:0:48} -> $(vp_line acct-01)"
|
|
4450
|
+
fi
|
|
4451
|
+
done
|
|
4452
|
+
rm -f "$VP"/acct-*/.expired
|
|
4453
|
+
|
|
4454
|
+
# The ORG refusal: verify's park carries the shim's 6 h soft_until, and seeing the refusal
|
|
4455
|
+
# again refreshes it (a stale stamp would release a still-blocked account early).
|
|
4456
|
+
ORG_TEXT="Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
|
|
4457
|
+
t0="$(date +%s)"
|
|
4458
|
+
out="$(vp_verify "$ORG_TEXT")"
|
|
4459
|
+
t1="$(date +%s)"
|
|
4460
|
+
check "verify calls out the org block" "ORG BLOCKED" "$(vp_line acct-01)"
|
|
4461
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
|
|
4462
|
+
{ [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$soft" -le $((t1 + 21600)) ]; } \
|
|
4463
|
+
&& t_ok "verify's org park carries soft_until = now + 6 h" \
|
|
4464
|
+
|| t_fail "verify org soft_until" "$(tail -1 "$VP/acct-01/.expired" 2>/dev/null)"
|
|
4465
|
+
grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$VP/acct-01/.expired" \
|
|
4466
|
+
&& t_ok "verify writes the shim's field order" || t_fail "org park shape" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4467
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
4468
|
+
"$((vnow - 21000))" "$((vnow + 60))" > "$VP/acct-01/.expired"
|
|
4469
|
+
t0="$(date +%s)"
|
|
4470
|
+
out="$(vp_verify "$ORG_TEXT")"
|
|
4471
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
|
|
4472
|
+
epoch="$(head -1 "$VP/acct-01/.expired" 2>/dev/null)"
|
|
4473
|
+
{ [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$epoch" -ge "$t0" ] \
|
|
4474
|
+
&& ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$VP/acct-01/.expired"; } \
|
|
4475
|
+
&& t_ok "a re-observed org refusal refreshes the park (epoch, marked_at, soft_until)" \
|
|
4476
|
+
|| t_fail "org park refresh" "$(cat "$VP/acct-01/.expired" 2>/dev/null | tr '\n' ' ')"
|
|
4477
|
+
# A weaker reason still never overwrites an org block that binds...
|
|
4478
|
+
out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
|
|
4479
|
+
grep -q 'reason=org-blocked' "$VP/acct-01/.expired" \
|
|
4480
|
+
&& t_ok "an auth failure never overwrites an org block in force" \
|
|
4481
|
+
|| t_fail "org block outranked" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4482
|
+
# ...while one whose soft window has run out binds nothing and outranks nothing.
|
|
4483
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
4484
|
+
"$((vnow - 30000))" "$((vnow - 10))" > "$VP/acct-01/.expired"
|
|
4485
|
+
out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
|
|
4486
|
+
grep -q 'reason=auth-error' "$VP/acct-01/.expired" \
|
|
4487
|
+
&& t_ok "a lapsed org block no longer shields the account from an auth park" \
|
|
4488
|
+
|| t_fail "lapsed org block" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4489
|
+
rm -f "$VP"/acct-*/.expired
|
|
4490
|
+
# The 404 model_not_found text is about a model, not the org: no park in verify either.
|
|
4491
|
+
out="$(vp_verify "The model claude-nope is not available on your firstParty deployment. Try switching to claude-opus-5, or ask your admin to enable this model.")"
|
|
4492
|
+
[ ! -f "$VP/acct-01/.expired" ] && t_ok "verify does not park on 'ask your admin to enable this model'" \
|
|
4493
|
+
|| t_fail "verify model 404" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4494
|
+
case "$(vp_line acct-01)" in
|
|
4495
|
+
*"ORG BLOCKED"*|*usage-limited*) t_fail "verify model 404 wording" "$(vp_line acct-01)" ;;
|
|
4496
|
+
*) t_ok "a model 404 is neither an org block nor a usage limit" ;;
|
|
4497
|
+
esac
|
|
4498
|
+
# An org park whose soft_until has passed no longer blocks the account anywhere: the
|
|
4499
|
+
# audit (list/expired/status/--json) reads it the way the shim does.
|
|
4500
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$((vnow - 30000))" "$((vnow - 10))" \
|
|
4501
|
+
> "$VP/acct-01/.expired"
|
|
4502
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$vnow" "$((vnow + 21600))" \
|
|
4503
|
+
> "$VP/acct-02/.expired"
|
|
4504
|
+
rows="$(python3 "$REPO_DIR/lib/audit.py" "$VP" "$VP_MACHINE")"
|
|
4505
|
+
[ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-01"{print $4}')" = "ok" ] \
|
|
4506
|
+
&& t_ok "a lapsed org park no longer reads blocked in the audit" \
|
|
4507
|
+
|| t_fail "lapsed org park audit" "$(printf '%s\n' "$rows" | grep acct-01)"
|
|
4508
|
+
[ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-02"{print $4}')" = "blocked" ] \
|
|
4509
|
+
&& t_ok "an org park inside its soft window still reads blocked" \
|
|
4510
|
+
|| t_fail "org park audit" "$(printf '%s\n' "$rows" | grep acct-02)"
|
|
4511
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list 2>&1)"
|
|
4512
|
+
case "$(printf '%s\n' "$out" | grep '^acct-01 ')" in
|
|
4513
|
+
*ORG-BLOCKED*) t_fail "lapsed org park list" "$(printf '%s\n' "$out" | grep '^acct-01 ')" ;;
|
|
4514
|
+
*) t_ok "list does not flag a lapsed org park" ;;
|
|
4515
|
+
esac
|
|
4516
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts expired 2>&1)"
|
|
4517
|
+
check "expired says when the pool re-tries an org-blocked account" "re-tried by the pool after" "$out"
|
|
4518
|
+
rm -f "$WORK/ctl-verifysay"
|
|
4519
|
+
unset -f vp_verify vp_line vp_park
|
|
4520
|
+
|
|
4296
4521
|
# ---- 16e. MCP servers for every account ---------------------------------------------
|
|
4297
4522
|
# Claude Code keeps MCP servers in the CONFIG DIR, so under the pool a stock `claude mcp
|
|
4298
4523
|
# add` used to land in ONE random account — appinspire was connected in some sessions
|
|
@@ -6886,6 +7111,23 @@ out="$(codex-accounts verify 2>&1)"
|
|
|
6886
7111
|
check "codex: verify calls out the workspace block" "ORG BLOCKED" "$out"
|
|
6887
7112
|
grep -q "reason=org-blocked" "$CX/acct-02/.expired" 2>/dev/null \
|
|
6888
7113
|
&& t_ok "codex: verify parks the org-blocked account" || t_fail "codex verify org park" "no marker"
|
|
7114
|
+
# ...with bin/codex's 6 h soft_until, in the shim's field order, refreshed when seen again
|
|
7115
|
+
t0="$(date +%s)"
|
|
7116
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7117
|
+
{ [ -n "$soft" ] && [ "$soft" -gt $((t0 + 21000)) ] && [ "$soft" -le $((t0 + 21600)) ] \
|
|
7118
|
+
&& grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$CX/acct-02/.expired"; } \
|
|
7119
|
+
&& t_ok "codex: verify's org park carries soft_until = now + 6 h" \
|
|
7120
|
+
|| t_fail "codex verify org soft_until" "$(tail -1 "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7121
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
7122
|
+
"$((t0 - 21000))" "$((t0 + 60))" > "$CX/acct-02/.expired"
|
|
7123
|
+
out="$(codex-accounts verify 2>&1)"
|
|
7124
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7125
|
+
{ [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] \
|
|
7126
|
+
&& ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$CX/acct-02/.expired"; } \
|
|
7127
|
+
&& t_ok "codex: a re-observed workspace block refreshes the park" \
|
|
7128
|
+
|| t_fail "codex org park refresh" "$(cat "$CX/acct-02/.expired" 2>/dev/null | tr '\n' ' ')"
|
|
7129
|
+
out="$(codex-accounts expired 2>&1)"
|
|
7130
|
+
check "codex: expired says when the pool re-tries a blocked account" "re-tried by the pool after" "$out"
|
|
6889
7131
|
rm -f "$FAKE_CTL2" "$CX/acct-02/.expired"
|
|
6890
7132
|
|
|
6891
7133
|
# ---- C16. audit vocabulary + empty pool ---------------------------------------------
|
package/tests/test_autoresume.py
CHANGED
|
@@ -11,6 +11,7 @@ PATH and a fake probe script. Runs under python3.9+.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import calendar
|
|
14
15
|
import contextlib
|
|
15
16
|
import importlib.util
|
|
16
17
|
import json
|
|
@@ -128,6 +129,27 @@ def cx_error(ts, info, message="boom", completed_at=None):
|
|
|
128
129
|
|
|
129
130
|
LIMIT_MSG = ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
|
|
130
131
|
"to purchase more credits or try again at Sep 26th, 2026 12:39 AM.")
|
|
132
|
+
_MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
|
|
133
|
+
"Dec")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def limit_msg(epoch):
|
|
137
|
+
"""LIMIT_MSG with its "try again at" date set to ``epoch``, rendered in UTC the way
|
|
138
|
+
the codex CLI prints it (read it back with TZ=UTC). A walk on the real clock needs this:
|
|
139
|
+
the classifier ignores a "try again at" that is already past, and a fixed date goes stale
|
|
140
|
+
(LIMIT_MSG's Sep 26th, 2026 did on that day, which failed the Publish run)."""
|
|
141
|
+
t = time.gmtime(epoch)
|
|
142
|
+
day = t.tm_mday
|
|
143
|
+
nth = "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
144
|
+
return ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
|
|
145
|
+
"to purchase more credits or try again at %s %d%s, %d %d:%02d %s."
|
|
146
|
+
% (_MONTH_ABBR[t.tm_mon - 1], day, nth, t.tm_year, t.tm_hour % 12 or 12,
|
|
147
|
+
t.tm_min, "AM" if t.tm_hour < 12 else "PM"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def future_reset(now):
|
|
151
|
+
"""A minute-aligned "try again at" three days after ``now``."""
|
|
152
|
+
return (int(now) // 60 + 3 * 24 * 60) * 60
|
|
131
153
|
|
|
132
154
|
|
|
133
155
|
def cx_tokens(ts, primary=None, secondary=None, limit_id="codex", info=None):
|
|
@@ -434,6 +456,12 @@ class CodexClassifierTests(unittest.TestCase):
|
|
|
434
456
|
1793631600)
|
|
435
457
|
self.assertEqual(ar.parse_try_again("try again at December 3rd, 2026 9:15 AM"),
|
|
436
458
|
1796289300)
|
|
459
|
+
self.assertEqual(limit_msg(1790383140), LIMIT_MSG) # the helper renders it
|
|
460
|
+
for stamp in ((2026, 10, 1, 12, 5), (2026, 11, 2, 15, 0), (2026, 12, 3, 9, 15),
|
|
461
|
+
(2026, 9, 11, 0, 0), (2026, 9, 12, 23, 59), (2026, 9, 13, 12, 0),
|
|
462
|
+
(2026, 9, 21, 1, 1), (2026, 9, 22, 13, 30), (2026, 9, 23, 11, 59)):
|
|
463
|
+
when = calendar.timegm(stamp + (0,))
|
|
464
|
+
self.assertEqual(ar.parse_try_again(limit_msg(when)), when, limit_msg(when))
|
|
437
465
|
if os.path.exists("/usr/share/zoneinfo/America/New_York"):
|
|
438
466
|
with local_tz("America/New_York"): # EDT, UTC-4
|
|
439
467
|
self.assertEqual(ar.parse_try_again(LIMIT_MSG), 1790383140 + 4 * 3600)
|
|
@@ -1973,16 +2001,17 @@ class CodexWatcherTests(PoolCase):
|
|
|
1973
2001
|
return rc, w, r, path
|
|
1974
2002
|
|
|
1975
2003
|
def test_quota_rotates_with_resume_and_the_message_date(self):
|
|
2004
|
+
reset = future_reset(self.t0) # the message's date, read in the local (UTC) zone
|
|
2005
|
+
msg = limit_msg(reset)
|
|
1976
2006
|
with local_tz("UTC"):
|
|
1977
2007
|
rc, w, r, _ = self.run_codex(
|
|
1978
2008
|
["--yolo", "resume", CX_SID], [
|
|
1979
2009
|
cx_meta(self.launched - 7200, cwd=str(self.work)),
|
|
1980
|
-
cx_error(self.launched - 7000, "usage_limit_exceeded",
|
|
2010
|
+
cx_error(self.launched - 7000, "usage_limit_exceeded", msg),
|
|
1981
2011
|
cx_event(self.t0 - 2, {"type": "task_started"}),
|
|
1982
2012
|
cx_tokens(self.t0 - 1, limit_id="premium"),
|
|
1983
|
-
cx_error(self.t0 - 1, "usage_limit_exceeded",
|
|
2013
|
+
cx_error(self.t0 - 1, "usage_limit_exceeded", msg)])
|
|
1984
2014
|
self.assertEqual(rc, 0)
|
|
1985
|
-
reset = 1790383140 # Sep 26th, 2026 12:39 AM, read in the local (UTC) zone
|
|
1986
2015
|
prompt = ar.resume_prompt("quota")
|
|
1987
2016
|
probe = r.probes[0]
|
|
1988
2017
|
self.assertEqual(probe["argv"], [self.self_path, "resume", "--yolo", CX_SID, prompt])
|
|
@@ -2234,13 +2263,15 @@ class CliTests(PoolCase):
|
|
|
2234
2263
|
self.assertEqual(doc["events"][0]["kind"], "verdict") # no --since: everything counts
|
|
2235
2264
|
|
|
2236
2265
|
def test_classify_codex(self):
|
|
2266
|
+
reset = future_reset(self.t0)
|
|
2237
2267
|
path = self.tmp / "r.jsonl"
|
|
2238
2268
|
path.write_text(jl([cx_meta(self.t0), cx_tokens(self.t0, limit_id="premium"),
|
|
2239
|
-
cx_error(self.t0, {"usage_limit_exceeded": None},
|
|
2269
|
+
cx_error(self.t0, {"usage_limit_exceeded": None},
|
|
2270
|
+
limit_msg(reset))]))
|
|
2240
2271
|
doc = json.loads(self.cli("classify", "--provider", "codex", str(path),
|
|
2241
2272
|
env={"TZ": "UTC"}).stdout)
|
|
2242
2273
|
self.assertEqual(doc["pending"]["class"], "quota")
|
|
2243
|
-
self.assertEqual(doc["pending"]["reset"],
|
|
2274
|
+
self.assertEqual(doc["pending"]["reset"], reset)
|
|
2244
2275
|
self.assertNotIn("_message", doc["pending"])
|
|
2245
2276
|
self.assertNotIn("usage limit", json.dumps(doc))
|
|
2246
2277
|
|