claude-multiacc 2.0.35 → 2.0.36

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.
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+ """Sandboxed integration tests for automatic Claude limit-reset redemption.
3
+
4
+ The twin of tests/test_codex_reset*.py. A stateful loopback server plays the OAuth
5
+ usage endpoint (with the ``cedar_ember`` status block Claude Code 2.1.280 reads) and
6
+ ``POST /api/organizations/<org>/reset_rate_limits``, and ``claude-accounts limits``
7
+ runs against it with a file-held OAuth login — no network, no real accounts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import subprocess
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import unittest
22
+ import urllib.parse
23
+ import uuid
24
+
25
+ REPO = Path(__file__).resolve().parents[1]
26
+ ORG = "11111111-2222-4333-8444-555555555555"
27
+ USER = "99999999-8888-4777-8666-555555555555"
28
+ GRANT = "opus55-launch-promax-20260921"
29
+ CLI_UA = "claude-cli/2.1.280 (external, cli, client-app/claude-multiacc)"
30
+
31
+
32
+ def iso(offset: int) -> str:
33
+ return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(time.time() + offset))
34
+
35
+
36
+ def usage_doc(*, session=0, weekly=50, fable=50, exhausted=(), resets_left=1,
37
+ use_requires_limit=False, eligible=True, reason=None, blocking=(),
38
+ clears=("five_hour", "seven_day", "seven_day_overage_included"),
39
+ usable=True, week_resets_in=3 * 86400) -> dict:
40
+ """A usage response in the live 2026-09-22 shape, cedar_ember block included."""
41
+ grants = [{
42
+ "id": GRANT, "label": "Claude Opus 5.5 launch: one usage-limit reset for Pro and Max",
43
+ "resets_total": 1, "resets_left": resets_left,
44
+ "starts_at": iso(-3600), "ends_at": iso(30 * 86400),
45
+ "clears": list(clears), "paused": False, "usable_now": usable and resets_left > 0,
46
+ "use_requires_limit": use_requires_limit,
47
+ "percent_used": {"five_hour": session, "seven_day": weekly,
48
+ "seven_day_overage_included": fable},
49
+ "blocking": list(blocking), "arm": None}] if eligible else []
50
+ return {
51
+ "limits": [
52
+ {"kind": "session", "group": "session", "percent": session,
53
+ "resets_at": iso(3600) if session else None, "scope": None},
54
+ {"kind": "weekly_all", "group": "weekly", "percent": weekly,
55
+ "resets_at": iso(week_resets_in), "scope": None},
56
+ {"kind": "weekly_scoped", "group": "weekly", "percent": fable,
57
+ "resets_at": iso(week_resets_in),
58
+ "scope": {"model": {"id": None, "display_name": "Fable"}, "surface": None}}],
59
+ "five_hour": {"utilization": float(session), "resets_at": None},
60
+ "seven_day": {"utilization": float(weekly), "resets_at": iso(week_resets_in)},
61
+ "cedar_ember": {
62
+ "eligible": eligible, "ineligible_reason": reason,
63
+ "at_limit": bool(exhausted), "exhausted": list(exhausted),
64
+ "grants": grants, "next_grant_id": GRANT if grants else None,
65
+ "weekly_resets_at": iso(week_resets_in), "cooldown_until": None,
66
+ "event_props": None},
67
+ "juniper_tide": None,
68
+ }
69
+
70
+
71
+ class ResetHandler(BaseHTTPRequestHandler):
72
+ def _send(self, status: int, body: dict, headers: dict | None = None) -> None:
73
+ encoded = json.dumps(body).encode()
74
+ self.send_response(status)
75
+ for name, value in (headers or {}).items():
76
+ self.send_header(name, value)
77
+ self.send_header("Content-Type", "application/json")
78
+ self.send_header("Content-Length", str(len(encoded)))
79
+ self.end_headers()
80
+ self.wfile.write(encoded)
81
+
82
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
83
+ state = self.server.state
84
+ parsed = urllib.parse.urlsplit(self.path)
85
+ if parsed.path != "/api/oauth/usage":
86
+ self.send_error(404)
87
+ return
88
+ state["usage_gets"].append({"query": parsed.query,
89
+ "ua": self.headers.get("User-Agent"),
90
+ "beta": self.headers.get("anthropic-beta")})
91
+ body = copy.deepcopy(state["usage"])
92
+ if state.get("surface_gate") and "(external, cli" not in str(self.headers.get("User-Agent")):
93
+ # The live server's answer to a client it does not recognize as the CLI.
94
+ body["cedar_ember"] = {"eligible": False, "ineligible_reason": "surface",
95
+ "at_limit": False, "exhausted": [], "grants": [],
96
+ "next_grant_id": None, "weekly_resets_at": None,
97
+ "cooldown_until": None, "event_props": None}
98
+ self._send(200, body)
99
+
100
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
101
+ state = self.server.state
102
+ if self.path != f"/api/organizations/{ORG}/reset_rate_limits":
103
+ self.send_error(404)
104
+ return
105
+ length = int(self.headers.get("Content-Length") or 0)
106
+ state["posts"].append({"body": json.loads(self.rfile.read(length)),
107
+ "ua": self.headers.get("User-Agent"),
108
+ "auth": self.headers.get("Authorization"),
109
+ "beta": self.headers.get("anthropic-beta")})
110
+ status, body, applied, *extra = state["responses"].pop(0)
111
+ if applied:
112
+ # The reset landed server-side (whether or not the response makes it back).
113
+ block = state["usage"]["cedar_ember"]
114
+ for grant in block["grants"]:
115
+ grant["resets_left"] = max(0, grant["resets_left"] - 1)
116
+ grant["usable_now"] = grant["resets_left"] > 0
117
+ block["exhausted"], block["at_limit"] = [], False
118
+ for limit in state["usage"]["limits"]:
119
+ limit["percent"] = 0
120
+ self._send(status, body, extra[0] if extra else None)
121
+
122
+ def log_message(self, _format: str, *_args) -> None:
123
+ return
124
+
125
+
126
+ class ClaudePoolSandbox:
127
+ """A one-account claude pool whose OAuth login is a plain .credentials.json."""
128
+
129
+ def setUp(self) -> None:
130
+ self.temp = tempfile.TemporaryDirectory(prefix="multiacc-claude-reset-")
131
+ self.pool = Path(self.temp.name) / "pool"
132
+ self.account = self.pool / "acct-01"
133
+ self.account.mkdir(parents=True)
134
+ manifest = {"version": 1, "threshold": 90, "accounts": [
135
+ {"id": "acct-01", "email": "test@example.invalid", "home": "mac"}]}
136
+ (self.pool / "accounts.json").write_text(json.dumps(manifest), encoding="utf-8")
137
+ creds = {"claudeAiOauth": {
138
+ "accessToken": "sk-ant-oat01-test-access", "refreshToken": "test-refresh",
139
+ "expiresAt": int(time.time() + 86400) * 1000,
140
+ "refreshTokenExpiresAt": int(time.time() + 30 * 86400) * 1000,
141
+ "scopes": ["user:inference", "user:profile"], "subscriptionType": "max"}}
142
+ (self.account / ".credentials.json").write_text(json.dumps(creds), encoding="utf-8")
143
+ (self.account / ".claude.json").write_text(json.dumps({"oauthAccount": {
144
+ "organizationUuid": ORG, "accountUuid": USER}}), encoding="utf-8")
145
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), ResetHandler)
146
+ self.server.state = {"usage_gets": [], "posts": [], "responses": [],
147
+ "usage": usage_doc()}
148
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
149
+ self.thread.start()
150
+
151
+ def tearDown(self) -> None:
152
+ self.server.shutdown()
153
+ self.server.server_close()
154
+ self.thread.join(timeout=5)
155
+ self.temp.cleanup()
156
+
157
+ def env(self, **overrides) -> dict:
158
+ base = f"http://127.0.0.1:{self.server.server_port}"
159
+ env = os.environ.copy()
160
+ for key in ("CLAUDE_ACCOUNTS_ROOT", "CLAUDE_MULTIACC_RESET_URL",
161
+ "CLAUDE_MULTIACC_TOKEN_URL"):
162
+ env.pop(key, None)
163
+ env.update({
164
+ "CLAUDE_ACCOUNTS_DIR": str(self.pool), "CLAUDE_MULTIACC_NO_SYNC": "1",
165
+ "CLAUDE_MULTIACC_NO_DISTRIBUTE": "1", "CLAUDE_MULTIACC_MIN_FETCH": "0",
166
+ "CLAUDE_MULTIACC_KEYCHAIN": "0", "CLAUDE_MULTIACC_PATH_PROBE": "0",
167
+ "CLAUDE_MULTIACC_AUTO_RESET": "1", "CLAUDE_MULTIACC_CLI_VERSION": "2.1.280",
168
+ "PYTHONDONTWRITEBYTECODE": "1",
169
+ "CLAUDE_MULTIACC_USAGE_URL": f"{base}/api/oauth/usage?cedar_ember=1&skip_spend=1"})
170
+ env.update(overrides)
171
+ return env
172
+
173
+ def run_limits(self, *extra: str, **overrides) -> subprocess.CompletedProcess:
174
+ result = subprocess.run([REPO / "bin/claude-accounts", "limits", "--force", *extra],
175
+ capture_output=True, text=True, env=self.env(**overrides),
176
+ timeout=60, check=False)
177
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
178
+ return result
179
+
180
+ def limits_doc(self) -> dict:
181
+ return json.loads((self.account / "limits.json").read_text(encoding="utf-8"))
182
+
183
+ def reset_state(self) -> dict:
184
+ return json.loads((self.account / ".usage-reset.json").read_text(encoding="utf-8"))
185
+
186
+ def log(self) -> str:
187
+ path = self.pool / "limits.log"
188
+ return path.read_text(encoding="utf-8") if path.exists() else ""
189
+
190
+
191
+ class ClaudeResetIntegrationTest(ClaudePoolSandbox, unittest.TestCase):
192
+ def test_redeems_an_exhausted_limit_once(self) -> None:
193
+ self.server.state["usage"] = usage_doc(fable=100,
194
+ exhausted=["seven_day_overage_included"])
195
+ self.server.state["responses"] = [(200, {
196
+ "result": "reset", "resets_left": 0,
197
+ "cleared": ["five_hour", "seven_day", "seven_day_overage_included"],
198
+ "weekly_resets_at": "2026-09-26T00:00:00+00:00"}, True)]
199
+ self.run_limits()
200
+ posts = self.server.state["posts"]
201
+ self.assertEqual(len(posts), 1)
202
+ body = posts[0]["body"]
203
+ self.assertEqual(body["program"], "cedar_ember")
204
+ self.assertEqual(body["grant_id"], GRANT)
205
+ uuid.UUID(body["request_id"])
206
+ self.assertEqual(posts[0]["auth"], "Bearer sk-ant-oat01-test-access")
207
+ self.assertEqual(posts[0]["beta"], "oauth-2025-04-20")
208
+ self.assertEqual(posts[0]["ua"], CLI_UA)
209
+ # The status rides on the one usage call, asked for the way the client asks.
210
+ gets = self.server.state["usage_gets"]
211
+ self.assertEqual(len(gets), 1)
212
+ self.assertEqual(gets[0]["query"], "cedar_ember=1&skip_spend=1")
213
+ self.assertEqual(gets[0]["ua"], CLI_UA)
214
+ state = self.reset_state()
215
+ self.assertEqual(state["state"], "complete")
216
+ self.assertEqual(state["outcome"], "reset")
217
+ doc = self.limits_doc()
218
+ self.assertEqual(doc["fetched_at"], 0) # pre-reset numbers cannot exclude
219
+ self.assertNotIn("weekly_resets_epoch", doc) # ...nor rank as a lower bound
220
+ self.assertEqual(doc["auto_reset"]["status"], "redeemed")
221
+ self.assertEqual(doc["reset_credits_available"], 0) # the server's own count
222
+ self.assertFalse((self.account / ".limited").exists())
223
+ self.assertIn("limit reset redeemed automatically", self.log())
224
+ # The next pass sees a spent grant and a refilled account: nothing more to do.
225
+ self.run_limits()
226
+ self.assertEqual(len(self.server.state["posts"]), 1)
227
+ self.assertFalse((self.account / ".limited").exists())
228
+
229
+ def test_early_use_grant_redeems_at_five_percent_remaining(self) -> None:
230
+ self.server.state["usage"] = usage_doc(fable=95)
231
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0,
232
+ "cleared": ["seven_day_overage_included"]},
233
+ True)]
234
+ self.run_limits()
235
+ self.assertEqual(len(self.server.state["posts"]), 1)
236
+
237
+ def test_does_not_redeem_above_five_percent_remaining(self) -> None:
238
+ self.server.state["usage"] = usage_doc(session=94, weekly=80, fable=94)
239
+ self.run_limits()
240
+ self.assertEqual(self.server.state["posts"], [])
241
+ self.assertFalse((self.account / ".usage-reset.json").exists())
242
+ self.assertEqual(self.limits_doc()["reset_credits_available"], 1)
243
+
244
+ def test_a_full_five_hour_window_alone_never_spends_the_week(self) -> None:
245
+ # The grant refills the WEEK and exists once; a five-hour wall refills itself in
246
+ # hours while the pool picks another account.
247
+ self.server.state["usage"] = usage_doc(session=100, weekly=40, fable=40,
248
+ exhausted=["five_hour"])
249
+ self.run_limits()
250
+ self.assertEqual(self.server.state["posts"], [])
251
+ self.assertTrue((self.account / ".limited").exists()) # still parked for the 5h
252
+ self.assertEqual(self.limits_doc()["reset_credits_available"], 1)
253
+
254
+ def test_a_five_hour_wall_beside_a_spent_week_is_redeemed(self) -> None:
255
+ # The live acct-17 shape of 2026-09-22: session 100% AND Fable weekly at 95%.
256
+ self.server.state["usage"] = usage_doc(session=100, weekly=65, fable=95,
257
+ exhausted=["five_hour"])
258
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
259
+ self.run_limits()
260
+ self.assertEqual(len(self.server.state["posts"]), 1)
261
+ self.assertFalse((self.account / ".limited").exists())
262
+
263
+ def test_a_week_that_refills_tomorrow_keeps_its_reset(self) -> None:
264
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"],
265
+ week_resets_in=13 * 3600)
266
+ self.run_limits()
267
+ self.assertEqual(self.server.state["posts"], [])
268
+ self.assertIn("limit reset held", self.log())
269
+ self.assertFalse((self.account / ".usage-reset.json").exists())
270
+ # The horizon is the operator's to move.
271
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
272
+ self.run_limits(CLAUDE_MULTIACC_RESET_MIN_HORIZON="0")
273
+ self.assertEqual(len(self.server.state["posts"]), 1)
274
+
275
+ def test_a_grant_that_needs_the_limit_waits_for_it(self) -> None:
276
+ self.server.state["usage"] = usage_doc(fable=97, use_requires_limit=True)
277
+ self.run_limits()
278
+ self.assertEqual(self.server.state["posts"], [])
279
+ self.server.state["usage"] = usage_doc(fable=100, use_requires_limit=True,
280
+ exhausted=["seven_day_overage_included"])
281
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
282
+ self.run_limits()
283
+ self.assertEqual(len(self.server.state["posts"]), 1)
284
+
285
+ def test_a_limit_the_grant_does_not_refill_never_spends_it(self) -> None:
286
+ # Exhausted, but the grant refills only the five-hour window.
287
+ self.server.state["usage"] = usage_doc(fable=100, clears=("five_hour",),
288
+ exhausted=["seven_day_overage_included"])
289
+ self.run_limits()
290
+ self.assertEqual(self.server.state["posts"], [])
291
+ # ...and a grant whose benefit another exhausted limit would block is kept too.
292
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"],
293
+ blocking=["seven_day_opus"])
294
+ self.run_limits()
295
+ self.assertEqual(self.server.state["posts"], [])
296
+
297
+ def test_lost_response_is_retried_with_the_same_request_id(self) -> None:
298
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
299
+ self.server.state["responses"] = [
300
+ (500, {"error": "response lost"}, False),
301
+ (200, {"result": "reset", "resets_left": 0}, True)]
302
+ first = self.run_limits()
303
+ self.assertIn("retry is idempotent", self.log(), first.stdout)
304
+ self.assertEqual(self.reset_state()["state"], "pending")
305
+ self.run_limits()
306
+ posts = self.server.state["posts"]
307
+ self.assertEqual(len(posts), 2)
308
+ self.assertEqual(posts[0]["body"]["request_id"], posts[1]["body"]["request_id"])
309
+ self.assertEqual(self.reset_state()["state"], "complete")
310
+
311
+ def test_a_reset_that_landed_behind_a_lost_response_is_confirmed_not_reclaimed(self) -> None:
312
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
313
+ (self.account / ".limited").write_text(
314
+ f"{int(time.time()) + 3 * 86400}\nbucket=client:seven_day percent=100 "
315
+ f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(time.time() - 3600))} "
316
+ f"reason=client-rate-limit\n", encoding="utf-8")
317
+ # The server applies the reset, but the answer never arrives.
318
+ self.server.state["responses"] = [(502, {"error": "gateway"}, True)]
319
+ self.run_limits()
320
+ self.assertEqual(self.reset_state()["state"], "pending")
321
+ self.assertTrue((self.account / ".limited").exists())
322
+ self.run_limits()
323
+ self.assertEqual(len(self.server.state["posts"]), 1) # never a second claim
324
+ self.assertEqual(self.reset_state()["outcome"], "confirmed")
325
+ self.assertFalse((self.account / ".limited").exists())
326
+ self.assertIn("confirmed by the server's allowance", self.log())
327
+
328
+ def test_a_client_reported_weekly_limit_is_lifted_by_a_confirmed_reset(self) -> None:
329
+ # A weekly client marker outlives every telemetry reading until its reset —
330
+ # except a redemption, which is first-hand proof the window refilled.
331
+ marked = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 7200))
332
+ (self.account / ".limited").write_text(
333
+ f"{int(time.time()) + 3 * 86400}\nbucket=client:seven_day percent=100 "
334
+ f"marked_at={marked} reason=client-rate-limit\n", encoding="utf-8")
335
+ self.server.state["usage"] = usage_doc(fable=96)
336
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
337
+ self.run_limits()
338
+ self.assertFalse((self.account / ".limited").exists())
339
+ # The shim must not re-read the old rejection out of the transcript.
340
+ watermark = int((self.account / ".client-limit-cleared").read_text().strip())
341
+ self.assertGreaterEqual(watermark, int(time.time()) - 60)
342
+
343
+ def test_already_used_is_a_completed_reset(self) -> None:
344
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
345
+ self.server.state["responses"] = [(200, {"result": "already_used", "resets_left": 0},
346
+ False)]
347
+ self.run_limits()
348
+ self.assertEqual(self.reset_state()["outcome"], "already_used")
349
+ self.assertEqual(self.limits_doc()["auto_reset"]["status"], "redeemed")
350
+
351
+ def test_answers_that_spend_nothing_leave_no_claim_behind(self) -> None:
352
+ for answer in ("not_limited", "ineligible", "cooldown"):
353
+ with self.subTest(answer=answer):
354
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
355
+ self.server.state["responses"] = [(200, {"result": answer}, False)]
356
+ self.run_limits()
357
+ self.assertFalse((self.account / ".usage-reset.json").exists())
358
+ doc = self.limits_doc()
359
+ self.assertNotIn("auto_reset", doc)
360
+ self.assertNotEqual(doc["fetched_at"], 0)
361
+ self.assertTrue((self.account / ".limited").exists()) # still at the limit
362
+
363
+ def test_the_fleet_converges_on_one_request_id(self) -> None:
364
+ # Two machines polling one account (each with its own sign-in) ask identically,
365
+ # so the server can deduplicate the claim instead of spending twice.
366
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
367
+ self.server.state["responses"] = [(500, {}, False), (500, {}, False)]
368
+ self.run_limits()
369
+ (self.account / ".usage-reset.json").unlink() # the peer has no local receipt
370
+ self.run_limits()
371
+ posts = self.server.state["posts"]
372
+ self.assertEqual(posts[0]["body"]["request_id"], posts[1]["body"]["request_id"])
373
+
374
+ def test_disabled_automation_reports_without_spending(self) -> None:
375
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
376
+ self.run_limits(CLAUDE_MULTIACC_AUTO_RESET="0")
377
+ self.assertEqual(self.server.state["posts"], [])
378
+ self.assertEqual(self.limits_doc()["reset_credits_available"], 1)
379
+ self.assertTrue((self.account / ".limited").exists())
380
+
381
+ def test_without_the_organization_nothing_is_claimed(self) -> None:
382
+ (self.account / ".claude.json").unlink()
383
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
384
+ self.run_limits()
385
+ self.assertEqual(self.server.state["posts"], [])
386
+ self.assertIn("organization is unknown", self.log())
387
+
388
+ def test_an_unknown_cli_version_is_not_disguised(self) -> None:
389
+ # No invented version: without the real one the writer names itself plainly,
390
+ # the server answers "surface", and the allowance is unknown rather than zero.
391
+ self.server.state["surface_gate"] = True
392
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
393
+ self.run_limits(CLAUDE_MULTIACC_CLI_VERSION="not-a-version")
394
+ self.assertEqual(self.server.state["usage_gets"][0]["ua"], "claude-multiacc/1.0")
395
+ self.assertEqual(self.server.state["posts"], [])
396
+ self.assertNotIn("reset_credits_available", self.limits_doc())
397
+ # ...and it is not silent: on the one machine that redeems, this is the whole
398
+ # feature switched off (review 2026-09-22).
399
+ self.assertIn("automatic redemption is OFF on this host", self.log())
400
+
401
+
402
+ class ClaudeResetReviewRegressionTest(ClaudePoolSandbox, unittest.TestCase):
403
+ """Findings of the 2026-09-22 review, each pinned by the scenario that proved it."""
404
+
405
+ def weekly_park(self, offset: int = -3600) -> Path:
406
+ marker = self.account / ".limited"
407
+ marked = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + offset))
408
+ marker.write_text(f"{int(time.time()) + 3 * 86400}\nbucket=client:seven_day "
409
+ f"percent=100 marked_at={marked} reason=client-rate-limit\n",
410
+ encoding="utf-8")
411
+ return marker
412
+
413
+ def test_a_replayed_claim_that_finds_nothing_to_do_keeps_its_receipt(self) -> None:
414
+ # The first send lands but its answer is lost; the status read by the next pass
415
+ # still predates it; the replay is told `not_limited` — BECAUSE it landed.
416
+ marker = self.weekly_park()
417
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
418
+ self.server.state["responses"] = [(502, {}, False), (200, {"result": "not_limited"}, False)]
419
+ self.run_limits()
420
+ self.run_limits()
421
+ self.assertEqual(self.reset_state()["state"], "pending")
422
+ # The server's own count finally shows the spent reset: confirmed, recorded, lifted.
423
+ self.server.state["usage"] = usage_doc(fable=3, resets_left=0)
424
+ self.run_limits()
425
+ self.assertEqual(len(self.server.state["posts"]), 2)
426
+ self.assertEqual(self.reset_state()["outcome"], "confirmed")
427
+ self.assertIn("reset_redeemed_at", self.limits_doc())
428
+ self.assertFalse(marker.exists())
429
+
430
+ def test_a_rate_limited_claim_backs_off_and_keeps_the_measured_allowance(self) -> None:
431
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
432
+ self.server.state["responses"] = [(429, {"error": "slow down"}, False, {"Retry-After": "3600"})]
433
+ self.run_limits()
434
+ state = self.reset_state()
435
+ self.assertEqual(state["state"], "backoff")
436
+ self.assertGreaterEqual(state["retry_after"] - int(time.time()), 3500)
437
+ self.assertEqual(self.limits_doc()["reset_credits_available"], 1)
438
+ self.run_limits()
439
+ self.assertEqual(len(self.server.state["posts"]), 1) # not asked again
440
+ self.assertIn("not asking again", self.log())
441
+
442
+ def test_a_refused_claim_is_not_retried_every_pass(self) -> None:
443
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
444
+ self.server.state["responses"] = [(403, {"error": "forbidden"}, False)]
445
+ self.run_limits()
446
+ self.run_limits()
447
+ self.assertEqual(len(self.server.state["posts"]), 1)
448
+ self.assertGreaterEqual(self.reset_state()["backoff"], 3600)
449
+
450
+ def test_a_reset_claimed_elsewhere_is_observed_and_lifts_older_parks(self) -> None:
451
+ # A fleet peer (or a human's /limit-reset) spent the grant; this machine never
452
+ # claimed, but it saw the grant's own count drop.
453
+ self.server.state["usage"] = usage_doc(fable=96)
454
+ self.run_limits(CLAUDE_MULTIACC_AUTO_RESET="0")
455
+ self.assertEqual(self.limits_doc()["reset_grants_seen"], {GRANT: 1})
456
+ marker = self.weekly_park()
457
+ self.server.state["usage"] = usage_doc(fable=2, resets_left=0)
458
+ self.run_limits()
459
+ doc = self.limits_doc()
460
+ self.assertEqual(self.server.state["posts"], [])
461
+ self.assertIn("reset_redeemed_at", doc)
462
+ self.assertNotEqual(doc["fetched_at"], 0) # a post-reset reading stands
463
+ self.assertFalse(marker.exists())
464
+ self.assertIn("limit reset observed", self.log())
465
+
466
+ def test_an_expired_grant_is_never_read_as_a_reset(self) -> None:
467
+ self.server.state["usage"] = usage_doc(fable=96)
468
+ self.run_limits(CLAUDE_MULTIACC_AUTO_RESET="0")
469
+ marker = self.weekly_park()
470
+ expired = usage_doc(fable=96)
471
+ expired["cedar_ember"]["grants"][0]["ends_at"] = iso(-60)
472
+ self.server.state["usage"] = expired
473
+ self.run_limits()
474
+ self.assertNotIn("reset_redeemed_at", self.limits_doc())
475
+ self.assertTrue(marker.exists())
476
+
477
+ def test_our_own_claim_is_not_observed_again_next_pass(self) -> None:
478
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
479
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
480
+ self.run_limits()
481
+ stamp = self.limits_doc()["reset_redeemed_at"]
482
+ self.run_limits()
483
+ self.assertEqual(self.limits_doc()["reset_redeemed_at"], stamp)
484
+ self.assertNotIn("limit reset observed", self.log())
485
+
486
+
487
+ class ClaudeCliVersionTest(ClaudePoolSandbox, unittest.TestCase):
488
+ """The User-Agent names the INSTALLED Claude Code version, learned from the real binary."""
489
+
490
+ def run_with_fake(self, make) -> str:
491
+ fakebin = Path(self.temp.name) / "fakebin"
492
+ fakebin.mkdir()
493
+ make(fakebin)
494
+ home = Path(self.temp.name) / "home"
495
+ home.mkdir()
496
+ self.run_limits(CLAUDE_MULTIACC_CLI_VERSION="", HOME=str(home),
497
+ PATH=f"{REPO / 'bin'}:{fakebin}:{os.environ.get('PATH', '')}")
498
+ return self.server.state["usage_gets"][-1]["ua"]
499
+
500
+ def test_the_native_installers_version_symlink_answers(self) -> None:
501
+ def make(fakebin: Path) -> None:
502
+ versions = Path(self.temp.name) / "versions"
503
+ versions.mkdir()
504
+ binary = versions / "2.1.281"
505
+ binary.write_text("#!/usr/bin/env bash\nexit 3\n", encoding="utf-8")
506
+ binary.chmod(0o755)
507
+ (fakebin / "claude").symlink_to(binary)
508
+ self.assertEqual(self.run_with_fake(make),
509
+ "claude-cli/2.1.281 (external, cli, client-app/claude-multiacc)")
510
+
511
+ def test_an_npm_install_is_asked_for_its_version(self) -> None:
512
+ def make(fakebin: Path) -> None:
513
+ script = fakebin / "claude"
514
+ script.write_text('#!/usr/bin/env bash\n[ "$1" = --version ] && echo "2.1.282 (Claude Code)"\n',
515
+ encoding="utf-8")
516
+ script.chmod(0o755)
517
+ self.assertEqual(self.run_with_fake(make),
518
+ "claude-cli/2.1.282 (external, cli, client-app/claude-multiacc)")
519
+
520
+ def test_an_unreadable_version_is_never_invented(self) -> None:
521
+ def make(fakebin: Path) -> None:
522
+ script = fakebin / "claude"
523
+ script.write_text('#!/usr/bin/env bash\necho "CFG=none"\n', encoding="utf-8")
524
+ script.chmod(0o755)
525
+ self.assertEqual(self.run_with_fake(make), "claude-multiacc/1.0")
526
+
527
+
528
+ class ClaudeResetFleetRecordTest(ClaudePoolSandbox, unittest.TestCase):
529
+ """What the redeeming machine leaves behind for every OTHER reader of the account."""
530
+
531
+ def redeem(self) -> None:
532
+ self.server.state["usage"] = usage_doc(session=100, weekly=65, fable=96,
533
+ exhausted=["five_hour"])
534
+ self.server.state["responses"] = [(200, {
535
+ "result": "reset", "resets_left": 0,
536
+ "cleared": ["five_hour", "seven_day", "seven_day_overage_included"]}, True)]
537
+ self.run_limits()
538
+
539
+ def test_the_reset_is_recorded_and_the_pre_reset_numbers_withdrawn(self) -> None:
540
+ before = int(time.time())
541
+ self.redeem()
542
+ doc = self.limits_doc()
543
+ self.assertGreaterEqual(doc["reset_redeemed_at"], before)
544
+ self.assertEqual(doc["reset_cleared"], "five_hour,seven_day,seven_day_overage_included")
545
+ # Readers that ignore freshness (the shim's fallback-model check, the panel's
546
+ # window reads) must not find the 100% session or the 96% Fable week.
547
+ for key in ("max_percent", "weekly_percent", "session_percent",
548
+ "weekly_resets_epoch", "session_inferred"):
549
+ self.assertNotIn(key, doc)
550
+ self.assertEqual(doc["buckets"], [])
551
+
552
+ def test_the_record_rides_along_on_later_passes(self) -> None:
553
+ self.redeem()
554
+ stamp = self.limits_doc()["reset_redeemed_at"]
555
+ self.run_limits() # a normal post-reset pass
556
+ doc = self.limits_doc()
557
+ self.assertEqual(doc["reset_redeemed_at"], stamp)
558
+ self.assertNotEqual(doc["fetched_at"], 0)
559
+ self.assertIn("weekly_percent", doc) # server truth is back
560
+ # ...until nothing it could supersede can still exist (nine days on, for the
561
+ # document AND the machine's own receipt).
562
+ doc["reset_redeemed_at"] = stamp - 9 * 86400
563
+ (self.account / "limits.json").write_text(json.dumps(doc), encoding="utf-8")
564
+ receipt = self.reset_state()
565
+ receipt["redeemed_at"] = stamp - 9 * 86400
566
+ (self.account / ".usage-reset.json").write_text(json.dumps(receipt), encoding="utf-8")
567
+ self.run_limits()
568
+ self.assertNotIn("reset_redeemed_at", self.limits_doc())
569
+
570
+ def test_the_watermark_is_written_even_without_a_marker(self) -> None:
571
+ self.assertFalse((self.account / ".limited").exists())
572
+ (self.account / ".client-limit-cleared").write_text("5\n", encoding="utf-8")
573
+ self.server.state["usage"] = usage_doc(fable=96) # 96% is below no marker yet
574
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0}, True)]
575
+ self.run_limits()
576
+ mark = int((self.account / ".client-limit-cleared").read_text().split()[0])
577
+ self.assertEqual(mark, self.limits_doc()["reset_redeemed_at"])
578
+
579
+ def test_a_panel_park_older_than_the_reset_is_cleared_by_the_next_pass(self) -> None:
580
+ self.redeem()
581
+ stamp = self.limits_doc()["reset_redeemed_at"]
582
+ older = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stamp - 3600))
583
+ # What the panel's heartbeat writes back until app-robot honours the record.
584
+ (self.account / ".limited").write_text(
585
+ f"{stamp + 3 * 86400}\nbucket=weekly_all percent=95 marked_at={older} "
586
+ f"reason=client-rate-limit source=stream\n", encoding="utf-8")
587
+ self.run_limits()
588
+ self.assertFalse((self.account / ".limited").exists())
589
+ self.assertIn("written before a confirmed limit reset", self.log())
590
+
591
+
592
+ class ClaudeResetReportingTest(ClaudePoolSandbox, unittest.TestCase):
593
+ def report(self, **overrides) -> dict:
594
+ result = self.run_limits("--json", **overrides)
595
+ return json.loads(result.stdout)["accounts"][0]
596
+
597
+ def test_idle_account_reports_its_allowance(self) -> None:
598
+ account = self.report()
599
+ self.assertEqual(account["usage"]["reset_credits_available"], 1)
600
+ self.assertTrue(account["usage"]["reset_credits_fetched_at"].endswith("Z"))
601
+ self.assertEqual(self.server.state["posts"], [])
602
+
603
+ def test_an_account_without_grants_reports_a_measured_zero(self) -> None:
604
+ self.server.state["usage"] = usage_doc(eligible=False, reason="no_grant")
605
+ self.assertEqual(self.report()["usage"]["reset_credits_available"], 0)
606
+
607
+ def test_a_payload_without_the_block_reports_nothing(self) -> None:
608
+ del self.server.state["usage"]["cedar_ember"]
609
+ usage = self.report()["usage"]
610
+ self.assertNotIn("reset_credits_available", usage)
611
+ self.assertEqual(usage["weekly_percent"], 50)
612
+
613
+ def test_an_unconfirmed_claim_reports_no_allowance(self) -> None:
614
+ self.server.state["usage"] = usage_doc(fable=100, exhausted=["seven_day_overage_included"])
615
+ self.server.state["responses"] = [(200, {"result": "unavailable"}, False)]
616
+ usage = self.report()["usage"]
617
+ self.assertNotIn("reset_credits_available", usage)
618
+ self.assertEqual(self.reset_state()["state"], "pending")
619
+
620
+
621
+ if __name__ == "__main__":
622
+ unittest.main()