claude-multiacc 2.0.50 → 2.0.52

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.
Files changed (74) hide show
  1. package/bin/codex +55 -11
  2. package/bin/codex-accounts +34 -40
  3. package/bin/multiacc-select +6 -1
  4. package/docs/CODEX.md +118 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  28. package/lib/autoresume.py +7 -2
  29. package/lib/autoresume_probe.py +3 -3
  30. package/lib/codex_credit_controls.py +27 -0
  31. package/lib/codex_credit_local_scope.py +35 -0
  32. package/lib/codex_credit_selection.py +172 -0
  33. package/lib/codex_credits.py +158 -0
  34. package/lib/codex_limits_lock.py +56 -0
  35. package/lib/codex_usage_fetch.py +85 -0
  36. package/lib/report.py +6 -0
  37. package/lib/selector_credit_scope.py +85 -0
  38. package/lib/selector_credits.py +105 -0
  39. package/lib/selector_funding.py +113 -0
  40. package/lib/selector_policy.py +20 -2
  41. package/lib/selector_primitives.py +20 -2
  42. package/package.json +1 -1
  43. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  67. package/tests/test_codex_reset_credits.py +154 -0
  68. package/tests/test_codex_reset_credits_polling.py +151 -0
  69. package/tests/test_codex_reset_credits_report.py +85 -0
  70. package/tests/test_codex_reset_fetch.py +123 -0
  71. package/tests/test_selector_cached_credits.py +135 -0
  72. package/tests/test_selector_credit_scope.py +118 -0
  73. package/tests/test_selector_credit_shim.py +139 -0
  74. package/tests/test_selector_credits.py +140 -0
@@ -0,0 +1,151 @@
1
+ """Existing Codex usage polling carries paid balances without extra provider requests."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ import os
6
+ import subprocess
7
+ import time
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import test_codex_reset as fixtures
12
+
13
+
14
+ class CreditPollingTest(fixtures.CodexPoolSandbox, unittest.TestCase):
15
+ def setUp(self):
16
+ super().setUp()
17
+ now = int(time.time())
18
+ self.server.state["usage"] = {
19
+ "account_id": "account-test", "plan_type": "pro", "rate_limit": {
20
+ "allowed": False, "limit_reached": True, "primary_window": {
21
+ "used_percent": 100, "limit_window_seconds": 604800, "reset_at": now + 86400}},
22
+ "credits": {"has_credits": True, "unlimited": False, "balance": "61812.4520750000"},
23
+ "rate_limit_reached_type": {"type": "rate_limit_reached"}, "spend_control": {"reached": False},
24
+ }
25
+ self.server.state["credits"] = {"available_count": 2, "credits": []}
26
+
27
+ def command(self, *args, auto_reset="0"):
28
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
29
+ env = dict(os.environ, CODEX_ACCOUNTS_ROOT=str(self.pool), CODEX_MULTIACC_NO_SYNC="1",
30
+ CODEX_MULTIACC_AUTO_RESET=auto_reset, CODEX_MULTIACC_USAGE_URL=f"{base}/usage",
31
+ CLAUDE_MULTIACC_PATH_PROBE="0", PYTHONDONTWRITEBYTECODE="1")
32
+ for key in ("CODEX_MULTIACC_RESET_CREDITS_URL", "CODEX_MULTIACC_RESET_CONSUME_URL"):
33
+ env.pop(key, None)
34
+ result = subprocess.run([fixtures.REPO / "bin/codex-accounts", *args], env=env,
35
+ capture_output=True, text=True, timeout=20, check=False)
36
+ self.assertEqual(result.returncode, 0, result.stderr)
37
+ return result
38
+
39
+ def test_existing_usage_read_reports_both_credit_types_without_more_calls(self):
40
+ answer = json.loads(self.command("limits", "--force", "--json").stdout)
41
+ usage = answer["accounts"][0]["usage"]
42
+ self.assertEqual(usage["credits"]["balance"], "61812.452075")
43
+ self.assertEqual(usage["credits"]["account_id"], "account-test")
44
+ self.assertEqual(usage["credits"]["limit_reached_type"], "rate_limit_reached")
45
+ self.assertIs(usage["credits"]["ordinary_usage_allowed"], False)
46
+ self.assertEqual(usage["reset_credits_available"], 2)
47
+ self.assertEqual(self.server.state["usage_gets"], 1)
48
+ self.assertEqual(self.server.state["credit_gets"], 1)
49
+ self.assertEqual(self.server.state["posts"], [])
50
+ self.assertTrue((self.pool / "acct-01/.limited").exists())
51
+ listed = json.loads(self.command("list", "--json").stdout)
52
+ self.assertEqual(listed["accounts"][0]["usage"]["credits"]["balance"], "61812.452075")
53
+ self.assertEqual(self.server.state["usage_gets"], 1)
54
+
55
+ def test_missing_credit_read_preserves_previous_amount_and_reports_staleness(self):
56
+ self.command("limits", "--force", "--quiet")
57
+ path = self.pool / "acct-01/limits.json"
58
+ before = json.loads(path.read_text())["credits"]
59
+ self.server.state["usage"].pop("credits")
60
+ self.command("limits", "--force", "--quiet")
61
+ after = json.loads(path.read_text())["credits"]
62
+ self.assertEqual(after["balance"], before["balance"])
63
+ self.assertEqual(after["fetched_at"], before["fetched_at"])
64
+ self.assertEqual(after["last_error"], "missing_credits")
65
+ self.assertTrue(after["stale"])
66
+
67
+ def test_bad_usage_does_not_silently_keep_paid_admission_fresh(self):
68
+ self.command("limits", "--force", "--quiet")
69
+ self.server.state["usage"] = []
70
+ self.command("limits", "--force", "--quiet")
71
+ stored = json.loads((self.pool / "acct-01/limits.json").read_text())
72
+ self.assertEqual(stored["credits"]["last_error"], "invalid_usage")
73
+ self.assertTrue(stored["credits"]["stale"])
74
+ self.assertEqual(stored["weekly_percent"], 100)
75
+
76
+ def test_credit_snapshot_survives_unknown_quota_without_inventing_usage(self):
77
+ self.server.state["usage"].pop("rate_limit")
78
+ self.command("limits", "--force", "--quiet")
79
+ stored = json.loads((self.pool / "acct-01/limits.json").read_text())
80
+ self.assertTrue(stored["no_data"])
81
+ self.assertNotIn("weekly_percent", stored)
82
+ self.assertEqual(stored["credits"]["balance"], "61812.452075")
83
+ self.assertIsNone(stored["credits"]["ordinary_usage_allowed"])
84
+
85
+ def test_success_timestamp_is_after_response_instead_of_pass_start(self):
86
+ original = fixtures.ResetHandler.do_GET
87
+ observed = []
88
+
89
+ def delayed(handler):
90
+ if handler.path == "/wham/usage":
91
+ time.sleep(0.05)
92
+ observed.append(time.time())
93
+ original(handler)
94
+
95
+ with patch.object(fixtures.ResetHandler, "do_GET", delayed):
96
+ self.command("limits", "--force", "--quiet")
97
+ stored = json.loads((self.pool / "acct-01/limits.json").read_text())
98
+ fetched = datetime.fromisoformat(stored["credits"]["fetched_at"].replace("Z", "+00:00")).timestamp()
99
+ self.assertGreaterEqual(fetched, observed[0])
100
+ self.assertEqual(stored["fetched_at"], int(fetched))
101
+
102
+ def test_failed_http_read_keeps_balance_and_next_pass_honors_backoff(self):
103
+ self.command("limits", "--force", "--quiet")
104
+ path = self.pool / "acct-01/limits.json"
105
+ previous = json.loads(path.read_text())
106
+ original = fixtures.ResetHandler.do_GET
107
+ calls = []
108
+
109
+ def unavailable(handler):
110
+ if handler.path == "/wham/usage":
111
+ calls.append(time.time())
112
+ handler.send_error(503)
113
+ else:
114
+ original(handler)
115
+
116
+ with patch.object(fixtures.ResetHandler, "do_GET", unavailable):
117
+ self.command("limits", "--force", "--quiet")
118
+ stored = json.loads(path.read_text())
119
+ self.assertEqual(stored["credits"]["balance"], previous["credits"]["balance"])
120
+ self.assertEqual(stored["credits"]["fetched_at"], previous["credits"]["fetched_at"])
121
+ self.assertEqual(stored["credits"]["last_error"], "http_error")
122
+ self.assertGreaterEqual(stored["last_error_at"], int(calls[-1]))
123
+ stored["fetched_at"] = 0
124
+ path.write_text(json.dumps(stored))
125
+ self.command("limits", "--quiet")
126
+ self.assertEqual(len(calls), 2)
127
+
128
+ def test_legacy_429_backoff_keeps_its_existing_diagnostic(self):
129
+ path = self.pool / "acct-01/limits.json"
130
+ for error in (None, "http_429", "network_unreachable"):
131
+ previous = {"fetched_at": 0, "retry_after": int(time.time()) + 300}
132
+ if error is not None:
133
+ previous["last_error"] = error
134
+ path.write_text(json.dumps(previous))
135
+ result = self.command("limits")
136
+ reason = "usage read failure" if error == "network_unreachable" else "429"
137
+ self.assertIn(f"acct-01: backing off after {reason}", result.stdout)
138
+ self.assertEqual(self.server.state["usage_gets"], 0)
139
+
140
+ def test_earned_reset_requires_new_usage_before_authorizing_paid_fallback(self):
141
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 1})]
142
+ self.command("limits", "--force", "--quiet", auto_reset="1")
143
+ stored = json.loads((self.pool / "acct-01/limits.json").read_text())
144
+ self.assertEqual(stored["fetched_at"], 0)
145
+ self.assertEqual(stored["credits"]["balance"], "61812.452075")
146
+ self.assertEqual(stored["credits"]["last_error"], "usage_reset_pending")
147
+ self.assertFalse((self.pool / "acct-01/.limited").exists())
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()
@@ -0,0 +1,85 @@
1
+ """Credit report freshness, failure persistence and safe output use isolated files only."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
11
+ import report
12
+ from codex_credits import record_read_failure, snapshot_from_usage
13
+ from test_codex_reset_credits import NOW, payload
14
+
15
+
16
+ class CreditReportTest(unittest.TestCase):
17
+ def setUp(self):
18
+ scratch = tempfile.TemporaryDirectory(prefix="multiacc-credit-report-")
19
+ self.addCleanup(scratch.cleanup)
20
+ self.account = Path(scratch.name)
21
+ self.path = self.account / "limits.json"
22
+ self.credits = snapshot_from_usage(payload(), "account-test", NOW)
23
+
24
+ def test_report_keeps_earned_resets_and_spendable_credits_separate(self):
25
+ self.path.write_text(json.dumps({"fetched_at": NOW, "credits": self.credits,
26
+ "reset_credits_available": 2, "reset_credits_fetched_at": NOW, "weekly_percent": 100}))
27
+ with patch("report.time.time", return_value=NOW):
28
+ usage = report._usage(str(self.account), 900)
29
+ self.assertEqual(usage["reset_credits_available"], 2)
30
+ self.assertEqual(usage["credits"]["balance"], "1234.5")
31
+ self.assertFalse(usage["credits"]["stale"])
32
+ self.assertEqual(usage["weekly_percent"], 100)
33
+
34
+ def test_fresh_windows_do_not_refresh_an_old_wallet(self):
35
+ self.path.write_text(json.dumps({"fetched_at": NOW + 901, "credits": self.credits}))
36
+ with patch("report.time.time", return_value=NOW + 901):
37
+ usage = report._usage(str(self.account), 900)
38
+ self.assertFalse(usage["stale"])
39
+ self.assertTrue(usage["credits"]["stale"])
40
+ self.assertEqual(usage["credits"]["age_seconds"], 901)
41
+
42
+ def test_failed_read_keeps_windows_and_backoff_without_refreshing_balance(self):
43
+ previous = {"fetched_at": NOW, "credits": self.credits, "weekly_percent": 100,
44
+ "retry_after": NOW + 300, "reset_credits_available": 2}
45
+ record_read_failure(str(self.path), previous, "http_429", NOW + 10)
46
+ stored = json.loads(self.path.read_text())
47
+ self.assertEqual(stored["fetched_at"], NOW)
48
+ self.assertEqual(stored["retry_after"], NOW + 300)
49
+ self.assertEqual(stored["credits"]["fetched_at"], self.credits["fetched_at"])
50
+ with patch("report.time.time", return_value=NOW + 10):
51
+ usage = report._usage(str(self.account), 900)
52
+ self.assertEqual(usage["credits"]["last_error"], "http_429")
53
+ self.assertTrue(usage["credits"]["stale"])
54
+
55
+ def test_first_failed_read_records_unknown_and_never_zero(self):
56
+ record_read_failure(str(self.path), {}, "unauthenticated", NOW)
57
+ usage = report._usage(str(self.account), 900)
58
+ self.assertNotIn("credits", usage)
59
+ self.assertEqual(usage["last_error"], "unauthenticated")
60
+
61
+ def test_report_never_passes_arbitrary_wallet_fields_or_error_details(self):
62
+ credits = {**self.credits, "access_token": "must-not-leak", "last_error": "secret-detail"}
63
+ self.path.write_text(json.dumps({"fetched_at": NOW, "credits": credits}))
64
+ usage = report._usage(str(self.account), 900)
65
+ rendered = json.dumps(usage)
66
+ self.assertNotIn("must-not-leak", rendered)
67
+ self.assertNotIn("secret-detail", rendered)
68
+ self.assertEqual(usage["credits"]["last_error"], "usage_read_failed")
69
+
70
+ def test_refusal_evidence_belongs_to_the_observed_wallet_identity(self):
71
+ self.path.write_text(json.dumps({"fetched_at": NOW, "credits": self.credits}))
72
+ receipt = self.account / ".credit-refused.json"
73
+ refused = {"credit_blocked_at": self.credits["fetched_at"], "credit_blocked_balance": "1234.5",
74
+ "limited_reason": "credits", "account_id": "account-test"}
75
+ receipt.write_text(json.dumps(refused))
76
+ usage = report._usage(str(self.account), 900)
77
+ self.assertEqual(usage["credit_blocked_balance"], "1234.5")
78
+ self.assertEqual(usage["limited_reason"], "credits")
79
+ receipt.write_text(json.dumps({**refused, "account_id": "another-account"}))
80
+ usage = report._usage(str(self.account), 900)
81
+ self.assertNotIn("credit_blocked_at", usage)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()
@@ -0,0 +1,123 @@
1
+ """Network failures retain usage evidence, retry safely, and never steal a live poller's lock."""
2
+
3
+ from contextlib import contextmanager
4
+ from email.message import Message
5
+ import errno
6
+ import io
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import socket
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ import unittest
15
+ from unittest.mock import patch
16
+ import urllib.error
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib"))
19
+ from codex_limits_lock import change_lock
20
+ from codex_usage_fetch import failure_kind, fetch_usage, retry_delay
21
+
22
+
23
+ def http_error(code, **headers):
24
+ message = Message()
25
+ for key, value in headers.items():
26
+ message[key] = value
27
+ result = urllib.error.HTTPError("https://example.invalid/usage", code, "failure", message, io.BytesIO())
28
+ result.close()
29
+ return result
30
+
31
+
32
+ @contextmanager
33
+ def response(value):
34
+ yield io.BytesIO(json.dumps(value).encode())
35
+
36
+
37
+ class UsageFetchTest(unittest.TestCase):
38
+ def test_transient_route_failure_retries_once_and_returns_response(self):
39
+ failed = urllib.error.URLError(OSError(errno.EHOSTUNREACH, "No route to host"))
40
+ with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=[failed, response({"credits": {}})]) as read:
41
+ self.assertEqual(fetch_usage("request"), {"credits": {}})
42
+ self.assertEqual(read.call_count, 2)
43
+
44
+ def test_dns_failure_is_bounded_to_two_requests(self):
45
+ failed = urllib.error.URLError(socket.gaierror(socket.EAI_AGAIN, "unavailable"))
46
+ with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=failed) as read:
47
+ with self.assertRaises(urllib.error.URLError):
48
+ fetch_usage("request")
49
+ self.assertEqual(read.call_count, 2)
50
+ self.assertEqual(failure_kind(failed), "dns_unavailable")
51
+
52
+ def test_elapsed_budget_prevents_retry_after_slow_failure(self):
53
+ with patch("codex_usage_fetch.time.monotonic", side_effect=[0, 0, 16]), \
54
+ patch("codex_usage_fetch.urllib.request.urlopen", side_effect=TimeoutError()) as read:
55
+ with self.assertRaises(TimeoutError):
56
+ fetch_usage("request")
57
+ self.assertEqual(read.call_count, 1)
58
+ self.assertEqual(read.call_args.kwargs["timeout"], 10)
59
+
60
+ def test_http_auth_and_rate_limits_never_retry_immediately(self):
61
+ for code in (401, 403, 429):
62
+ with self.subTest(code=code), \
63
+ patch("codex_usage_fetch.urllib.request.urlopen", side_effect=http_error(code)) as read:
64
+ with self.assertRaises(urllib.error.HTTPError):
65
+ fetch_usage("request")
66
+ self.assertEqual(read.call_count, 1)
67
+
68
+ def test_transient_provider_error_retries_unless_retry_after_present(self):
69
+ for error, count in ((http_error(503), 2), (http_error(503, **{"Retry-After": "60"}), 1)):
70
+ with patch("codex_usage_fetch.urllib.request.urlopen", side_effect=error) as read:
71
+ with self.assertRaises(urllib.error.HTTPError):
72
+ fetch_usage("request")
73
+ self.assertEqual(read.call_count, count)
74
+
75
+ def test_retry_after_seconds_and_date_are_honored(self):
76
+ self.assertEqual(retry_delay(http_error(429, **{"Retry-After": "2100"}), {}, 0), 2100)
77
+ self.assertEqual(retry_delay(http_error(429, **{"Retry-After": "Thu, 01 Jan 1970 00:02:00 GMT"}), {}, 10), 110)
78
+ self.assertEqual(retry_delay(TimeoutError(), {"backoff": 600}, 0), 900)
79
+ self.assertEqual(retry_delay(http_error(429), {"backoff": 1200}, 0), 1800)
80
+
81
+ def test_precise_safe_failures_and_edge_challenge(self):
82
+ self.assertEqual(failure_kind(urllib.error.URLError(OSError(errno.EHOSTUNREACH, "secret"))),
83
+ "network_unreachable")
84
+ self.assertEqual(failure_kind(TimeoutError("secret")), "usage_timeout")
85
+ self.assertEqual(failure_kind(http_error(403, **{"cf-mitigated": "challenge"})), "edge_challenge")
86
+ with patch("codex_usage_fetch.urllib.request.urlopen", return_value=response([])) as read:
87
+ with self.assertRaises(ValueError) as result:
88
+ fetch_usage("request")
89
+ self.assertEqual(failure_kind(result.exception), "invalid_usage")
90
+ self.assertEqual(read.call_count, 1)
91
+
92
+
93
+ class UsageLockTest(unittest.TestCase):
94
+ def test_old_live_collector_is_not_replaced_and_wrong_owner_cannot_release(self):
95
+ with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
96
+ path = Path(directory) / "limits"
97
+ with patch("codex_limits_lock.process_birth", side_effect=lambda pid: str(pid)):
98
+ self.assertTrue(change_lock(path, 11))
99
+ os.utime(path, (time.time() - 600, time.time() - 600))
100
+ self.assertFalse(change_lock(path, 12))
101
+ self.assertFalse(change_lock(path, 12, release=True))
102
+ self.assertTrue(change_lock(path, 11, release=True))
103
+
104
+ def test_dead_owner_is_reclaimed_without_waiting_for_age(self):
105
+ with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
106
+ path = Path(directory) / "limits"
107
+ path.mkdir()
108
+ (path / "owner.json").write_text(json.dumps({"pid": 11, "birth": "old"}))
109
+ with patch("codex_limits_lock.process_birth", side_effect=lambda pid: "new" if pid == 12 else ""):
110
+ self.assertTrue(change_lock(path, 12))
111
+ self.assertTrue(change_lock(path, 12, release=True))
112
+
113
+ def test_reused_pid_cannot_keep_abandoned_lock(self):
114
+ with tempfile.TemporaryDirectory(prefix="codex-lock-test-") as directory:
115
+ path = Path(directory) / "limits"
116
+ path.mkdir()
117
+ (path / "owner.json").write_text(json.dumps({"pid": 11, "birth": "old"}))
118
+ with patch("codex_limits_lock.process_birth", return_value="new"):
119
+ self.assertTrue(change_lock(path, 12))
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()
@@ -0,0 +1,135 @@
1
+ """Cached confirmed funds remain usable through telemetry outages, without an age cap."""
2
+
3
+ import json
4
+ import unittest
5
+
6
+ from test_selector import NOW, _request
7
+ from test_selector_credits import _paid
8
+ import test_selector_credit_shim as shim_fixtures
9
+ from selector_policy import select
10
+
11
+ SOFT_ERRORS = (None, "usage_read_failed", "http_401", "http_403", "http_429", "invalid_usage",
12
+ "network_unreachable", "dns_unavailable", "usage_timeout", "tls_error", "connection_reset")
13
+
14
+
15
+ class CachedCreditTests(unittest.TestCase):
16
+ def test_cached_positive_survives_every_soft_error_without_fallback_age_limit(self):
17
+ for error in SOFT_ERRORS:
18
+ with self.subTest(error=error):
19
+ row = _paid()
20
+ row["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True, last_error=error,
21
+ last_error_at=NOW)
22
+ response = select(_request([row], credit_fallback=True, credit_max_age_seconds=30))
23
+ self.assertTrue(response["ok"], response)
24
+ self.assertEqual(response["funding_tier"], "credits")
25
+ self.assertEqual(response["score_basis"]["credit_balance"], "61812.452075")
26
+
27
+ def test_cached_unlimited_remains_distinct_from_finite_zero(self):
28
+ row = _paid()
29
+ row["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True,
30
+ last_error="http_429", unlimited=True, balance="0", has_credits=False)
31
+ self.assertTrue(select(_request([row], credit_fallback=True))["ok"])
32
+ row["credits"]["unlimited"] = False
33
+ self.assertFalse(select(_request([row], credit_fallback=True))["ok"])
34
+
35
+ def test_newer_confirmed_zero_wins_over_old_positive_with_later_refresh_failure(self):
36
+ old, zero = _paid(account_id="old"), _paid(account_id="zero")
37
+ old["credits"].update(fetched_at="2020-01-01T00:00:00Z", last_error="network_unreachable",
38
+ last_error_at="2026-08-25T09:00:00Z", stale=True)
39
+ zero["credits"].update(balance="0", fetched_at="2026-08-25T08:00:00Z", stale=True)
40
+ self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
41
+ for error in ("unauthenticated", "account_mismatch", "refusal_read_failed"):
42
+ zero["credits"]["last_error"] = error
43
+ self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
44
+ zero["credits"].pop("last_error")
45
+ zero["credits"].update(balance="80", has_credits=False)
46
+ self.assertFalse(select(_request([old, zero], credit_fallback=True))["ok"])
47
+ zero["credits"]["has_credits"] = True
48
+ result = select(_request([old, zero], credit_fallback=True))
49
+ self.assertTrue(result["ok"])
50
+ self.assertEqual(result["score_basis"]["credit_balance"], "80")
51
+
52
+ def test_new_unknown_read_does_not_replace_confirmed_wallet_money(self):
53
+ old, unknown = _paid(account_id="old"), _paid(account_id="unknown")
54
+ old["credits"].update(fetched_at="2020-01-01T00:00:00Z", stale=True,
55
+ last_error="network_unreachable")
56
+ unknown["credits"].update(fetched_at=NOW, balance=None)
57
+ response = select(_request([old, unknown], credit_fallback=True))
58
+ self.assertTrue(response["ok"])
59
+ self.assertEqual(response["score_basis"]["credit_balance"], "61812.452075")
60
+ unknown["credits"]["has_credits"] = False
61
+ self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
62
+ unknown["credits"].update(has_credits=True, balance="0")
63
+ self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
64
+ unknown["credits"]["balance"] = None
65
+ old["credits"]["balance"] = None
66
+ self.assertFalse(select(_request([old, unknown], credit_fallback=True))["ok"])
67
+
68
+ def test_cached_read_cannot_erase_actual_paid_refusal_even_after_cooldown(self):
69
+ row = _paid()
70
+ row.update(limited_reason="credits", credit_blocked_at="2026-08-25T08:00:00Z",
71
+ credit_blocked_balance="1")
72
+ for patch in ({"fetched_at": "2026-08-25T08:01:00Z"},
73
+ {"fetched_at": NOW, "last_error": "http_429"},
74
+ {"fetched_at": NOW, "stale": True}):
75
+ with self.subTest(patch=patch):
76
+ candidate = dict(row, credits=dict(row["credits"], **patch))
77
+ self.assertFalse(select(_request([candidate], credit_fallback=True))["ok"])
78
+ self.assertTrue(select(_request([row], credit_fallback=True))["ok"])
79
+
80
+ def test_newer_actor_restriction_wins_over_later_failed_refresh_of_old_allowance(self):
81
+ old, denied = _paid(account_id="old"), _paid(account_id="denied")
82
+ for row in (old, denied):
83
+ row["credit_actor_email"] = "same@example.com"
84
+ old["credits"].update(fetched_at="2020-01-01T00:00:00Z", last_error="http_429", last_error_at=NOW)
85
+ denied["credits"].update(fetched_at="2026-08-25T08:00:00Z", spend_control_reached=True)
86
+ self.assertFalse(select(_request([old, denied], credit_fallback=True))["ok"])
87
+ old["credits"]["fetched_at"] = denied["credits"]["fetched_at"]
88
+ self.assertFalse(select(_request([old, denied], credit_fallback=True))["ok"])
89
+ denied["credit_actor_email"] = "different@example.com"
90
+ self.assertEqual(select(_request([old, denied], credit_fallback=True))["account_id"], "old")
91
+
92
+ def test_failed_partial_read_restriction_is_not_erased_by_peer_monetary_timestamp(self):
93
+ blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
94
+ for row in (blocked, peer):
95
+ row["credit_actor_email"] = "same@example.com"
96
+ blocked["credits"].update(fetched_at="2020-01-01T00:00:00Z", spend_control_reached=True,
97
+ last_error="invalid_credits", last_error_at=NOW)
98
+ self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
99
+ blocked["credits"].update(spend_control_reached=False, limit_reached_type="unknown")
100
+ self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
101
+ peer["credit_actor_email"] = "other@example.com"
102
+ self.assertEqual(select(_request([blocked, peer], credit_fallback=True))["account_id"], "peer")
103
+ peer["credit_actor_email"] = "same@example.com"
104
+ blocked["credits"].update(last_error=None, fetched_at=NOW, limit_reached_type="rate_limit_reached")
105
+ self.assertTrue(select(_request([blocked, peer], credit_fallback=True))["ok"])
106
+
107
+ def test_refusal_still_propagates_across_same_actor_copies_when_refresh_failed(self):
108
+ blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
109
+ for row in (blocked, peer):
110
+ row["credit_actor_email"] = "same@example.com"
111
+ blocked.update(limited_reason="credits", credit_blocked_at="2026-08-25T08:59:59Z",
112
+ credit_blocked_balance="61812.452075")
113
+ blocked["credits"].update(last_error="network_unreachable", stale=True)
114
+ self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
115
+
116
+ def test_standalone_shim_accepts_cached_funds_and_keeps_durable_refusal(self):
117
+ case = shim_fixtures.CreditShimTests()
118
+ case.setUp()
119
+ self.addCleanup(case.doCleanups)
120
+ directory = case.account("acct-01", fetched_at="2020-01-01T00:00:00Z", stale=True,
121
+ last_error="network_unreachable")
122
+ self.assertIn("tier=credits", case.run_shim().stdout)
123
+ result = case.run_shim(False, FAKE_ERROR="You've hit your usage limit")
124
+ self.assertEqual(result.returncode, 1)
125
+ self.assertIn("tier=hard", case.run_shim().stdout)
126
+ receipt = json.loads((directory / ".credit-refused.json").read_text())
127
+ receipt["credit_blocked_at"] = "2019-01-01T00:00:00Z"
128
+ (directory / ".credit-refused.json").write_text(json.dumps(receipt))
129
+ self.assertIn("tier=hard", case.run_shim().stdout)
130
+ case.account("acct-01")
131
+ self.assertIn("tier=credits", case.run_shim().stdout)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -0,0 +1,118 @@
1
+ """Shared wallets never erase actor permissions, authentication or paid refusals."""
2
+
3
+ import ast
4
+ import json
5
+ import os
6
+ import time
7
+ import unittest
8
+
9
+ from test_selector import ROOT, _request
10
+ from test_selector_credits import _paid
11
+ from selector_policy import select
12
+ import test_selector_credit_shim as shim_fixtures
13
+ from codex_credit_selection import record_launch, record_refusal
14
+
15
+
16
+ class CreditScopeTests(unittest.TestCase):
17
+ def test_refusal_follows_same_actor_across_slots_but_not_other_members(self):
18
+ blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
19
+ blocked.update(credit_actor_email=" Person@Example.COM ", limited_reason="credits",
20
+ credit_blocked_at="2026-08-25T08:59:59Z", credit_blocked_balance="61812.452075")
21
+ peer["credit_actor_email"] = "person@example.com"
22
+ self.assertFalse(select(_request([blocked, peer], credit_fallback=True))["ok"])
23
+ peer["credit_actor_email"] = "other@example.com"
24
+ response = select(_request([blocked, peer], credit_fallback=True))
25
+ self.assertEqual(response["account_id"], "peer")
26
+ self.assertEqual(response["score_basis"]["credit_actor_email"], "other@example.com")
27
+
28
+ def test_unknown_actor_refusal_remains_with_its_runner_and_slot(self):
29
+ blocked, peer = _paid(account_id="blocked"), _paid(account_id="peer")
30
+ blocked.update(limited_reason="credits", credit_blocked_at="2026-08-25T08:59:59Z",
31
+ credit_blocked_balance="61812.452075")
32
+ self.assertEqual(select(_request([blocked, peer], credit_fallback=True))["account_id"], "peer")
33
+ peer.update(runner_id=4, account_id="blocked")
34
+ self.assertEqual(select(_request([blocked, peer], credit_fallback=True))["runner_id"], 4)
35
+
36
+ def test_peer_wallet_cannot_erase_local_auth_identity_or_refusal_read_failure(self):
37
+ failed, busy = _paid(account_id="failed"), _paid(account_id="busy")
38
+ busy["reservation_history"]["active_expires_at"] = "2026-08-25T09:05:00Z"
39
+ for error in ("account_mismatch", "unauthenticated", "refusal_read_failed"):
40
+ with self.subTest(error=error):
41
+ failed["credits"].update(last_error=error, fetched_at="2026-08-25T08:59:00Z")
42
+ self.assertFalse(select(_request([failed, busy], credit_fallback=True))["ok"])
43
+
44
+ def test_peer_wallet_cannot_erase_member_spend_controls_or_share_them(self):
45
+ blocked, healthy = _paid(account_id="blocked"), _paid(account_id="healthy")
46
+ blocked["credits"]["spend_control_reached"] = True
47
+ blocked["credit_actor_email"], healthy["credit_actor_email"] = "a@example.com", "b@example.com"
48
+ response = select(_request([blocked, healthy], credit_fallback=True))
49
+ self.assertEqual(response["account_id"], "healthy")
50
+ healthy["reservation_history"]["active_expires_at"] = "2026-08-25T09:05:00Z"
51
+ self.assertFalse(select(_request([blocked, healthy], credit_fallback=True))["ok"])
52
+
53
+ def test_peer_observation_cannot_end_actor_cooldown_without_new_actor_read(self):
54
+ blocked, busy = _paid(account_id="blocked"), _paid(account_id="busy")
55
+ blocked.update(credit_actor_email="a@example.com", limited_reason="credits",
56
+ credit_blocked_at="2026-08-25T08:49:00Z", credit_blocked_balance="61812.452075")
57
+ blocked["credits"]["fetched_at"] = "2026-08-25T08:48:00Z"
58
+ busy["credit_actor_email"] = "b@example.com"
59
+ busy["reservation_history"]["active_expires_at"] = "2026-08-25T09:05:00Z"
60
+ self.assertFalse(select(_request([blocked, busy], credit_fallback=True))["ok"])
61
+
62
+ def test_failed_refresh_does_not_replace_latest_confirmed_monetary_observation(self):
63
+ old, failed = _paid(account_id="old"), _paid(account_id="failed")
64
+ old["credits"]["fetched_at"] = "2026-08-25T08:59:00Z"
65
+ failed["credits"].update(fetched_at="2026-08-25T08:58:00Z", last_error="usage_read_failed",
66
+ last_error_at="2026-08-25T09:00:00Z")
67
+ self.assertTrue(select(_request([old, failed], credit_fallback=True))["ok"])
68
+ old["credits"]["balance"] = "0"
69
+ self.assertFalse(select(_request([old, failed], credit_fallback=True))["ok"])
70
+
71
+ def test_standalone_refusal_is_shared_only_for_same_manifest_actor(self):
72
+ case = shim_fixtures.CreditShimTests()
73
+ case.setUp()
74
+ self.addCleanup(case.doCleanups)
75
+ first = case.account("acct-01", account_id="workspace-a")
76
+ case.account("acct-02", account_id="workspace-a")
77
+ accounts = [{"id": name, "email": "person@example.com"} for name in ("acct-01", "acct-02")]
78
+ (case.pool / "accounts.json").write_text(json.dumps({"accounts": accounts}))
79
+ record_launch(first, os.getpid(), time.time())
80
+ record_refusal(first, os.getpid(), time.time())
81
+ self.assertIn("tier=hard", case.run_shim().stdout)
82
+ accounts[1]["email"] = "other@example.com"
83
+ (case.pool / "accounts.json").write_text(json.dumps({"accounts": accounts}))
84
+ self.assertIn("pick=acct-02 tier=credits", case.run_shim().stdout)
85
+ (case.pool / "accounts.json").write_text('{"accounts":[]}')
86
+ record_launch(first, os.getpid(), time.time())
87
+ record_refusal(first, os.getpid(), time.time())
88
+ self.assertIn("pick=acct-02 tier=credits", case.run_shim().stdout)
89
+
90
+ def test_ordinary_gate_log_retains_legacy_fields_and_paid_only_appends_funding(self):
91
+ case = shim_fixtures.CreditShimTests()
92
+ case.setUp()
93
+ self.addCleanup(case.doCleanups)
94
+ case.account("acct-01", weekly=20)
95
+ result = case.run_shim(False)
96
+ self.assertEqual(result.returncode, 0, result.stderr)
97
+ line = (case.pool / "selection.log").read_text().splitlines()[-1].split(" ", 1)[1]
98
+ self.assertEqual(line, "acct-01 weekly=20% session=10% band=30 band-count=1 "
99
+ f"session-gate=50 session-ok=1 pwd={os.getcwd()}")
100
+ case.account("acct-01")
101
+ result = case.run_shim(False)
102
+ self.assertEqual(result.returncode, 0, result.stderr)
103
+ line = (case.pool / "selection.log").read_text().splitlines()[-1].split(" ", 1)[1]
104
+ self.assertEqual(line, "acct-01 weekly=100% session=10% band=30 band-count=0 "
105
+ f"session-gate=50 session-ok=0 pwd={os.getcwd()} funding=credits")
106
+
107
+ def test_new_modules_parse_as_python39_and_defer_union_annotations(self):
108
+ for name in ("selector_credits", "selector_funding", "selector_credit_scope", "codex_credit_selection",
109
+ "codex_credit_local_scope"):
110
+ source = (ROOT / "lib" / f"{name}.py").read_text()
111
+ tree = ast.parse(source, feature_version=(3, 9))
112
+ self.assertTrue(any(isinstance(node, ast.ImportFrom) and node.module == "__future__"
113
+ and any(alias.name == "annotations" for alias in node.names)
114
+ for node in tree.body), name)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ unittest.main()