claude-multiacc 2.0.65 → 2.0.67

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 (118) hide show
  1. package/bin/claude-accounts +11 -197
  2. package/docs/INSTALLATION.md +4 -3
  3. package/docs/RELEASING.md +75 -0
  4. package/lib/claude_oauth_lock.py +172 -0
  5. package/lib/claude_oauth_refresh.py +225 -0
  6. package/package.json +3 -2
  7. package/tests/autoresume_cross_tmux_environment.py +4 -1
  8. package/tests/claude_oauth_fixture.py +100 -0
  9. package/tests/run-tests.sh +3 -1
  10. package/tests/test_claude_oauth_lock.py +138 -0
  11. package/tests/test_claude_oauth_refresh.py +106 -0
  12. package/tests/test_claude_oauth_refresh_failures.py +112 -0
  13. package/tests/test_release_validation.py +826 -0
  14. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  40. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  41. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  42. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  43. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  44. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  45. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  46. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  47. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  48. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  49. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  50. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  51. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  52. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  53. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  54. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  55. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  56. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  57. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  111. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  112. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  114. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  115. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  116. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
@@ -0,0 +1,225 @@
1
+ """Refresh machine-local Claude logins without racing the native rotating grant.
2
+
3
+ Claude Code 2.1.310 holds both refresh locks across the token request. The storage
4
+ lock protects only the final read/compare/write, never the network round trip.
5
+ An invalid_grant response can leave an empty-token tombstone; that is not a newer
6
+ login and must not displace a successfully rotated grant. A removed login is logout.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from claude_oauth_lock import RefreshLock
17
+ from mcp_oauth import CLIENT_LOCK, CLIENT_LOCK_STALE_S, ClientLock
18
+
19
+ REFRESH_MIN_EXPIRED = 300
20
+ REFRESH_FAIL_BACKOFF = 600
21
+ REFRESH_DENIED_BACKOFF = 21600
22
+ CLIENT_LOCK_PROBE_S = 1.0
23
+ CLIENT_LOCK_WAIT_S = 10.0
24
+
25
+
26
+ def read_login(store):
27
+ """Unreadable, missing, or non-object logins cannot authorize a token write."""
28
+ try:
29
+ doc = store.read()
30
+ login = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
31
+ return dict(login) if isinstance(login, dict) else None
32
+ except Exception:
33
+ return None
34
+
35
+
36
+ def client_lock_busy(directory):
37
+ deadline = time.monotonic() + CLIENT_LOCK_PROBE_S
38
+ while True:
39
+ try:
40
+ if time.time() - os.stat(os.path.join(directory, CLIENT_LOCK)).st_mtime > CLIENT_LOCK_STALE_S:
41
+ return False
42
+ except OSError:
43
+ return False
44
+ if time.monotonic() >= deadline:
45
+ return True
46
+ time.sleep(0.05)
47
+
48
+
49
+ def rotated_login(login, data, now):
50
+ result = dict(login, accessToken=data['access_token'])
51
+ if data.get('refresh_token'):
52
+ result['refreshToken'] = data['refresh_token']
53
+ result['expiresAt'] = int((now + float(data.get('expires_in') or 3600)) * 1000)
54
+ if data.get('refresh_token_expires_in'):
55
+ result['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
56
+ return result
57
+
58
+
59
+ class OAuthRefresher:
60
+ def __init__(self, *, now, force, token_url, client_id, say, park_dead_grant, clear_expired):
61
+ self.now = now
62
+ self.force = force
63
+ self.token_url = token_url
64
+ self.client_id = client_id
65
+ self.say = say
66
+ self.park = park_dead_grant
67
+ self.clear_expired = clear_expired
68
+
69
+ def refresh(self, aid, directory, store):
70
+ # This lock must fail closed: a grace period after access expiry does not
71
+ # prove that another process has stopped refreshing the same rotating grant.
72
+ lock = RefreshLock(directory)
73
+ if not lock.acquire():
74
+ self.say(f'{aid}: oauth refresh skipped — the client holds its refresh lock; the next pass retries')
75
+ return None
76
+ try:
77
+ # Reread AFTER ownership: the client may have refreshed while we waited.
78
+ login = read_login(store)
79
+ if login is None:
80
+ return None
81
+ if login.get('accessToken') and login.get('expiresAt', 0) / 1000 > self.now + 60:
82
+ return login['accessToken']
83
+ if not self.eligible(aid, directory, login):
84
+ return None
85
+ if client_lock_busy(directory) or lock.compromised:
86
+ self.say(f'{aid}: oauth refresh skipped — the client holds its credential lock ({CLIENT_LOCK})')
87
+ return None
88
+ data = self.post(aid, directory, login['refreshToken'], store)
89
+ if data is None:
90
+ return None
91
+ rotated = rotated_login(login, data, self.now)
92
+ if not self.save(aid, directory, store, login['refreshToken'], rotated):
93
+ return None
94
+ try:
95
+ os.remove(os.path.join(directory, '.oauth-refresh.json'))
96
+ except OSError:
97
+ pass
98
+ if self.clear_expired(directory):
99
+ self.say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
100
+ self.say(f'{aid}: oauth access token refreshed via refresh-token grant')
101
+ return rotated['accessToken']
102
+ finally:
103
+ lock.release()
104
+
105
+ def eligible(self, aid, directory, login):
106
+ if not login.get('refreshToken'):
107
+ self.park(aid, directory, 'no-refresh-token',
108
+ 'credential has no refresh token and its access token expired')
109
+ return False
110
+ if login.get('expiresAt', 0) / 1000 > self.now - REFRESH_MIN_EXPIRED:
111
+ return False
112
+ refresh_expiry = login.get('refreshTokenExpiresAt')
113
+ if refresh_expiry and refresh_expiry / 1000 <= self.now:
114
+ self.park(aid, directory, 'refresh-token-expired',
115
+ 'the refresh token itself expired; only a re-login can fix it')
116
+ return False
117
+ if not self.force:
118
+ try:
119
+ with open(os.path.join(directory, '.oauth-refresh.json')) as stream:
120
+ if json.load(stream).get('retry_after', 0) > self.now:
121
+ return False
122
+ except (OSError, ValueError, AttributeError):
123
+ pass
124
+ return True
125
+
126
+ def back_off(self, aid, directory, wait, why, denials=0):
127
+ path = os.path.join(directory, '.oauth-refresh.json')
128
+ try:
129
+ with open(path + '.tmp', 'w') as stream:
130
+ json.dump({'retry_after': int(self.now + wait), 'error': why, 'denials': denials,
131
+ 'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, stream)
132
+ os.replace(path + '.tmp', path)
133
+ except OSError:
134
+ pass
135
+ self.say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
136
+
137
+ def post(self, aid, directory, refresh_token, store):
138
+ body = json.dumps({'grant_type': 'refresh_token', 'refresh_token': refresh_token,
139
+ 'client_id': self.client_id}).encode()
140
+ request = urllib.request.Request(self.token_url, data=body, headers={
141
+ 'Content-Type': 'application/json', 'User-Agent': 'claude-multiacc/1.0',
142
+ })
143
+ try:
144
+ data = json.loads(urllib.request.urlopen(request, timeout=30).read().decode())
145
+ except Exception as error:
146
+ # A sign-in/logout can replace this grant without the refresh lock.
147
+ # A failure of the old request must never park the new login.
148
+ disk = read_login(store)
149
+ if disk is None or disk.get('refreshToken') != refresh_token:
150
+ self.say(f'{aid}: oauth login changed during a failed refresh; leaving its state unchanged')
151
+ return None
152
+ if isinstance(error, urllib.error.HTTPError) and error.code in (400, 401, 403):
153
+ self.denied(aid, directory, error, store, refresh_token)
154
+ else:
155
+ why = f'HTTP {error.code}' if isinstance(error, urllib.error.HTTPError) else type(error).__name__
156
+ self.back_off(aid, directory, REFRESH_FAIL_BACKOFF, why)
157
+ return None
158
+ if not isinstance(data, dict) or not data.get('access_token'):
159
+ disk = read_login(store)
160
+ if disk is not None and disk.get('refreshToken') == refresh_token:
161
+ self.back_off(aid, directory, REFRESH_DENIED_BACKOFF, 'no access_token in response')
162
+ return None
163
+ return data
164
+
165
+ def denied(self, aid, directory, error, store, refresh_token):
166
+ body = ''
167
+ try:
168
+ body = error.read(400).decode('utf-8', 'replace')
169
+ except Exception:
170
+ pass
171
+ denials = 1
172
+ try:
173
+ with open(os.path.join(directory, '.oauth-refresh.json')) as stream:
174
+ denials += int(json.load(stream).get('denials', 0))
175
+ except (OSError, ValueError, TypeError, AttributeError):
176
+ pass
177
+ lock = ClientLock(directory, CLIENT_LOCK_WAIT_S)
178
+ if not lock.acquire():
179
+ return # no rotation occurred; retry without marking an uncertain login
180
+ try:
181
+ disk = read_login(store)
182
+ if disk is None or disk.get('refreshToken') != refresh_token:
183
+ return
184
+ if 'invalid_grant' in body:
185
+ self.park(aid, directory, f'refresh-denied-http-{error.code}',
186
+ 'the refresh grant was refused as invalid_grant (revoked or rotated away)')
187
+ elif denials >= 3:
188
+ self.park(aid, directory, f'refresh-denied-http-{error.code}',
189
+ f'the refresh grant was refused {denials} times in a row')
190
+ self.back_off(aid, directory, REFRESH_DENIED_BACKOFF,
191
+ f'HTTP {error.code} — refresh token may be revoked; re-login needed', denials)
192
+ finally:
193
+ lock.release()
194
+
195
+ def save(self, aid, directory, store, started_with, login):
196
+ lock = ClientLock(directory, CLIENT_LOCK_WAIT_S)
197
+ held = False
198
+ try:
199
+ held = lock.acquire()
200
+ except Exception:
201
+ pass
202
+ if not held and os.path.isdir(lock.path):
203
+ self.say(f'{aid}: the client kept its credential lock ({CLIENT_LOCK}) past '
204
+ f'{int(CLIENT_LOCK_WAIT_S)}s — writing the rotated login without it')
205
+ try:
206
+ disk = read_login(store)
207
+ # Native invalid_grant clears these three fields but leaves the login
208
+ # object. A missing document/key is logout, never a tombstone to restore.
209
+ tombstone = (disk is not None and disk.get('refreshToken') == ''
210
+ and disk.get('accessToken') == '' and disk.get('expiresAt') == 0)
211
+ if disk is None or (disk.get('refreshToken') != started_with and not tombstone):
212
+ self.say(f'{aid}: credential was refreshed by something else mid-flight — '
213
+ f'keeping the newer one in the {store.kind} (or its removal)')
214
+ return False
215
+ try:
216
+ # Store implementations merge only this key, preserving current MCP state.
217
+ store.write({'claudeAiOauth': login})
218
+ except Exception as error:
219
+ self.say(f'{aid}: token refreshed but credentials NOT persisted '
220
+ f'({type(error).__name__}) — re-login may be needed')
221
+ return False
222
+ return True
223
+ finally:
224
+ if held:
225
+ lock.release()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.65",
3
+ "version": "2.0.67",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,7 +56,8 @@
56
56
  "scripts": {
57
57
  "postinstall": "node scripts/postinstall.mjs",
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
- "test:core": "bash tests/run-tests.sh && npm run test:selector && npm run test:shim",
59
+ "test:core": "bash tests/run-tests.sh && npm run test:claude-oauth && npm run test:selector && npm run test:shim",
60
+ "test:claude-oauth": "python3 -m unittest discover -s tests -p 'test_claude_oauth*.py'",
60
61
  "test:shim": "python3 tests/test_shim_path.py && npm run test:codex-stdin",
61
62
  "test:codex-stdin": "python3 tests/test_codex_exec_stdin.py && python3 tests/test_codex_retry_process.py",
62
63
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
@@ -53,7 +53,10 @@ def test_environment(home, pools, overrides):
53
53
  "USAGE_URL": "file:///nonexistent", "TOKEN_URL": "file:///nonexistent",
54
54
  "AR_POLL": "0.05", "AR_GRACE": "0.4", "AR_HOLD_REPROBE": "0.3",
55
55
  "AR_TERM_GRACE": "0.3", "AR_PANE_WAIT": "4", "AR_RELAUNCH_WAIT": "5",
56
- "AR_PROMPT_WAIT": "2", "AR_TRANSIENT_LADDER": "0.4"}.items():
56
+ # A real target process can take over two seconds to persist
57
+ # its first prompt. Match the relaunch allowance; production
58
+ # allows 30s, and missing/stale receipts are still rejected.
59
+ "AR_PROMPT_WAIT": "5", "AR_TRANSIENT_LADDER": "0.4"}.items():
57
60
  env[prefix + "MULTIACC_" + name] = value
58
61
  env.update(overrides)
59
62
  return env
@@ -0,0 +1,100 @@
1
+ """Temporary Claude OAuth stores and a stub provider; never load a real account."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import io
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from unittest import mock
14
+ import urllib.request
15
+
16
+ REPO = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(REPO / "lib"))
18
+ import claude_oauth_refresh # noqa: E402
19
+
20
+
21
+ def file_store_class():
22
+ """Load the real file store only; never execute limits or access the real pool."""
23
+ source = (REPO / "bin/claude-accounts").read_text(encoding="utf-8")
24
+ source = source.split("cmd_limits() {", 1)[1].split("<<'PYEOF'", 1)[1]
25
+ source = source.split("\n", 1)[1].split("\nPYEOF", 1)[0]
26
+ nodes = [node for node in ast.parse(source).body
27
+ if isinstance(node, ast.ClassDef) and node.name == "FileStore"]
28
+ namespace = {"json": json, "os": os}
29
+ exec(compile(ast.Module(body=nodes, type_ignores=[]), "limits-test-definitions", "exec"), namespace)
30
+ return namespace["FileStore"]
31
+
32
+
33
+ class AcquireCallbackLock:
34
+ """Make a competing login finish exactly when our lock is acquired."""
35
+
36
+ def __init__(self, callback):
37
+ self.callback = callback
38
+ self.compromised = False
39
+ self.released = False
40
+
41
+ def acquire(self):
42
+ self.callback()
43
+ return True
44
+
45
+ def release(self):
46
+ self.released = True
47
+
48
+
49
+ class OAuthRefreshFixture:
50
+ def setUp(self):
51
+ self.temp = tempfile.TemporaryDirectory(prefix="multiacc-oauth-refresh-")
52
+ self.addCleanup(self.temp.cleanup)
53
+ self.account = Path(self.temp.name) / "acct-test"
54
+ self.account.mkdir()
55
+ self.path = self.account / ".credentials.json"
56
+ self.now = time.time()
57
+ self.original = {"claudeAiOauth": {
58
+ "accessToken": "fixture-old-access", "refreshToken": "fixture-old-refresh",
59
+ "expiresAt": int((self.now - 3600) * 1000),
60
+ "refreshTokenExpiresAt": int((self.now + 86400) * 1000),
61
+ "scopes": ["user:inference", "user:profile"]},
62
+ "mcpOAuth": {"fixture-server": {"accessToken": "fixture-mcp-original"}}}
63
+ self.write(self.original)
64
+ self.store = file_store_class()(str(self.path))
65
+ self.posts = []
66
+ self.messages = []
67
+ self.park = mock.Mock()
68
+ self.on_post = lambda: None
69
+ self.lock = None
70
+ self.error = None
71
+ self.reply = {"access_token": "fixture-new-access", "refresh_token": "fixture-new-refresh",
72
+ "expires_in": 3600, "refresh_token_expires_in": 2592000}
73
+ patcher = mock.patch.object(urllib.request, "urlopen", side_effect=self.provider)
74
+ patcher.start()
75
+ self.addCleanup(patcher.stop)
76
+
77
+ def write(self, document):
78
+ self.path.write_text(json.dumps(document), encoding="utf-8")
79
+
80
+ def read(self):
81
+ return json.loads(self.path.read_text(encoding="utf-8"))
82
+
83
+ def provider(self, request, **_kwargs):
84
+ self.assertEqual(request.full_url, "https://provider.invalid/token")
85
+ self.posts.append(json.loads(request.data))
86
+ self.on_post()
87
+ if self.error is not None:
88
+ raise self.error
89
+ return io.BytesIO(json.dumps(self.reply).encode())
90
+
91
+ def refresh(self):
92
+ options = {"now": self.now, "force": True, "token_url": "https://provider.invalid/token",
93
+ "client_id": "fixture-client", "say": self.messages.append,
94
+ "park_dead_grant": self.park, "clear_expired": lambda *_args: False}
95
+ if self.lock is not None:
96
+ patcher = mock.patch.object(claude_oauth_refresh, "RefreshLock", return_value=self.lock)
97
+ patcher.start()
98
+ self.addCleanup(patcher.stop)
99
+ refresher = claude_oauth_refresh.OAuthRefresher(**options)
100
+ return refresher.refresh("acct-test", str(self.account), self.store)
@@ -1867,7 +1867,9 @@ claude >/dev/null 2>&1
1867
1867
  [ ! -f "$ACC/acct-01/.limited" ] \
1868
1868
  && t_ok "an old transcript cannot recreate a telemetry-disproved marker" \
1869
1869
  || t_fail "client recovery watermark" "old rejection recreated the marker"
1870
- rm -f "$ACC/acct-01/.limited"
1870
+ # Later fixtures create independent rejections, possibly in this same second. The
1871
+ # preceding recovery watermark must not suppress their expiry/opt-out/index checks.
1872
+ rm -f "$ACC/acct-01/.limited" "$ACC/acct-01/.client-limit-cleared"
1871
1873
 
1872
1874
  # an ALREADY-ELAPSED rejection is history, not an exclusion
1873
1875
  mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) - 60 ))"
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Native-shaped OAuth locks in temporary directories; no credentials or network."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ import unittest
12
+ from unittest import mock
13
+
14
+ sys.dont_write_bytecode = True
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
16
+ import claude_oauth_lock as locks
17
+
18
+
19
+ class RefreshLockTests(unittest.TestCase):
20
+ def setUp(self):
21
+ temporary = tempfile.TemporaryDirectory(prefix='claude-oauth-lock-test-')
22
+ self.addCleanup(temporary.cleanup)
23
+ self.root = Path(temporary.name)
24
+ self.account = self.root / 'acct-02'
25
+ self.account.mkdir()
26
+ self.lock = locks.RefreshLock(self.account, timeout=0.1)
27
+ self.addCleanup(self.lock.release)
28
+
29
+ def test_holds_native_and_legacy_without_storage_lock(self):
30
+ self.assertTrue(self.lock.acquire())
31
+ self.assertEqual(self.lock.paths, (self.account / '.oauth_refresh.lock',
32
+ Path(str(self.account.resolve()) + '.lock')))
33
+ self.assertTrue(all(path.is_dir() for path in self.lock.paths))
34
+ self.assertFalse((self.account / '.storage-write.lock').exists())
35
+ self.assertFalse(self.lock.compromised)
36
+ self.lock.release()
37
+ self.assertTrue(all(not path.exists() for path in self.lock.paths))
38
+
39
+ def test_either_native_lock_blocks_and_partial_acquisition_is_released(self):
40
+ for blocked in self.lock.paths:
41
+ with self.subTest(path=blocked.name):
42
+ blocked.mkdir()
43
+ identity = blocked.stat().st_ino
44
+ self.assertFalse(self.lock.acquire())
45
+ self.assertEqual(blocked.stat().st_ino, identity)
46
+ self.assertFalse(next(path for path in self.lock.paths if path != blocked).exists())
47
+ blocked.rmdir()
48
+
49
+ def test_stale_empty_native_locks_are_recovered(self):
50
+ for path in self.lock.paths:
51
+ path.mkdir()
52
+ old = time.time() - locks.STALE_SECONDS - 10
53
+ os.utime(path, (old, old))
54
+ self.lock.timeout = 0
55
+ self.assertTrue(self.lock.acquire())
56
+ self.assertFalse(self.lock.compromised)
57
+
58
+ def test_symlinks_and_nonempty_stale_locks_are_preserved(self):
59
+ target = self.root / 'unrelated'
60
+ target.mkdir()
61
+ for path in self.lock.paths:
62
+ with self.subTest(path=path.name):
63
+ path.symlink_to(target, target_is_directory=True)
64
+ self.assertFalse(self.lock.acquire())
65
+ self.assertTrue(path.is_symlink())
66
+ path.unlink()
67
+ path = self.lock.paths[0]
68
+ path.mkdir()
69
+ (path / 'foreign-file').write_text('preserve')
70
+ old = time.time() - locks.STALE_SECONDS - 10
71
+ os.utime(path, (old, old))
72
+ self.assertFalse(self.lock.acquire())
73
+ self.assertEqual((path / 'foreign-file').read_text(), 'preserve')
74
+
75
+ def test_replaced_directory_is_not_released(self):
76
+ self.assertTrue(self.lock.acquire())
77
+ current = self.lock.paths[0]
78
+ previous = self.account / 'previous-lock'
79
+ current.rename(previous)
80
+ current.mkdir()
81
+ identity = current.stat().st_ino
82
+ self.assertTrue(self.lock.compromised)
83
+ self.lock.release()
84
+ self.assertEqual(current.stat().st_ino, identity)
85
+ self.assertTrue(previous.is_dir())
86
+ self.assertFalse(self.lock.paths[1].exists())
87
+
88
+ def test_changed_timestamp_is_not_released_or_renewed(self):
89
+ self.assertTrue(self.lock.acquire())
90
+ current = self.lock.paths[0]
91
+ changed = current.stat().st_mtime_ns + 1_000_000
92
+ os.utime(current, ns=(changed, changed))
93
+ self.assertTrue(self.lock.compromised)
94
+ self.assertFalse(self.lock._renew())
95
+ self.lock.release()
96
+ self.assertEqual(current.stat().st_mtime_ns, changed)
97
+
98
+ def test_heartbeat_updates_both_locks_and_stops_on_release(self):
99
+ renewed = threading.Event()
100
+ original = self.lock._renew
101
+ with mock.patch.object(locks, 'UPDATE_SECONDS', 0.01), mock.patch.object(
102
+ self.lock, '_renew', side_effect=lambda: (original(), renewed.set())[0]
103
+ ):
104
+ self.assertTrue(self.lock.acquire())
105
+ before = [path.stat().st_mtime_ns for path in self.lock.paths]
106
+ self.assertTrue(renewed.wait(1))
107
+ self.assertTrue(all(path.stat().st_mtime_ns > stamp
108
+ for path, stamp in zip(self.lock.paths, before)))
109
+ self.assertFalse(self.lock.compromised)
110
+ thread = self.lock._thread
111
+ self.lock.release()
112
+ self.assertFalse(thread.is_alive())
113
+
114
+ def test_symlinked_account_uses_resolved_legacy_lock(self):
115
+ alias = self.root / 'alias'
116
+ alias.symlink_to(self.account, target_is_directory=True)
117
+ peer = locks.RefreshLock(alias, timeout=0)
118
+ self.addCleanup(peer.release)
119
+ self.assertEqual(peer.paths[1], self.lock.paths[1])
120
+ self.assertTrue(self.lock.acquire())
121
+ self.assertFalse(peer.acquire())
122
+ self.assertTrue(alias.is_symlink())
123
+
124
+ def test_second_helper_can_acquire_only_after_release(self):
125
+ peer = locks.RefreshLock(self.account, timeout=0)
126
+ self.addCleanup(peer.release)
127
+ self.assertTrue(self.lock.acquire())
128
+ self.assertFalse(peer.acquire())
129
+ self.lock.release()
130
+ self.assertTrue(peer.acquire())
131
+ peer.release()
132
+ with mock.patch.object(locks.os, 'open', side_effect=PermissionError):
133
+ self.assertFalse(peer.acquire())
134
+ self.assertTrue(all(not path.exists() for path in peer.paths))
135
+
136
+
137
+ if __name__ == '__main__':
138
+ unittest.main()
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic OAuth refresh races: temporary credentials and a stub provider only."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ from pathlib import Path
8
+ import unittest
9
+
10
+ from claude_oauth_fixture import AcquireCallbackLock, OAuthRefreshFixture
11
+
12
+
13
+ class ClaudeOAuthRefreshTest(OAuthRefreshFixture, unittest.TestCase):
14
+
15
+ def test_native_refresh_lock_blocks_token_post(self):
16
+ for path in (self.account / ".oauth_refresh.lock", Path(str(self.account.resolve()) + ".lock")):
17
+ with self.subTest(lock=path.name):
18
+ self.write(self.original)
19
+ self.posts.clear()
20
+ path.mkdir()
21
+ try:
22
+ self.refresh()
23
+ self.assertEqual(self.posts, [], "a native refresh lock must prevent rotating the grant")
24
+ self.assertEqual(self.read(), self.original)
25
+ finally:
26
+ path.rmdir()
27
+
28
+ def test_rereads_login_after_acquiring_refresh_lock(self):
29
+ newer = copy.deepcopy(self.original)
30
+ newer["claudeAiOauth"].update(accessToken="fixture-native-access",
31
+ refreshToken="fixture-native-refresh",
32
+ expiresAt=int((self.now + 3600) * 1000))
33
+ self.lock = AcquireCallbackLock(lambda: self.write(newer))
34
+ result = self.refresh()
35
+ self.assertEqual(self.posts, [], "a refresh completed during lock acquisition must be adopted")
36
+ self.assertEqual(result, "fixture-native-access")
37
+ self.assertEqual(self.read(), newer)
38
+ self.assertTrue(self.lock.released)
39
+
40
+ def test_success_survives_native_invalid_grant_tombstone(self):
41
+ tombstone = copy.deepcopy(self.original)
42
+ tombstone["claudeAiOauth"].update(accessToken="", refreshToken="", expiresAt=0)
43
+ self.on_post = lambda: self.write(tombstone)
44
+ self.assertEqual(self.refresh(), "fixture-new-access")
45
+ self.assertEqual(len(self.posts), 1)
46
+ self.assertEqual(self.read()["claudeAiOauth"]["refreshToken"], "fixture-new-refresh")
47
+
48
+ def test_nonempty_newer_grant_is_preserved(self):
49
+ newer = copy.deepcopy(self.original)
50
+ newer["claudeAiOauth"].update(accessToken="fixture-other-access",
51
+ refreshToken="fixture-other-refresh")
52
+ self.on_post = lambda: self.write(newer)
53
+ self.refresh()
54
+ self.assertEqual(len(self.posts), 1)
55
+ self.assertEqual(self.read(), newer)
56
+
57
+ def test_partial_clearing_is_not_mistaken_for_native_tombstone(self):
58
+ for fields in ({"accessToken": "fixture-other-access", "refreshToken": "", "expiresAt": 0},
59
+ {"accessToken": "", "refreshToken": "", "expiresAt": 1000},
60
+ {"refreshToken": ""}):
61
+ with self.subTest(fields=fields):
62
+ self.write(self.original)
63
+ changed = {"claudeAiOauth": fields}
64
+ self.on_post = lambda document=changed: self.write(document)
65
+ self.assertIsNone(self.refresh())
66
+ self.assertEqual(self.read(), changed)
67
+
68
+ def test_deleted_document_is_not_resurrected_after_logout(self):
69
+ self.on_post = self.path.unlink
70
+ self.assertIsNone(self.refresh())
71
+ self.assertEqual(len(self.posts), 1)
72
+ self.assertFalse(self.path.exists(), "an in-flight refresh must not undo logout")
73
+
74
+ def test_removed_login_is_not_restored_into_mcp_document(self):
75
+ remaining = {"mcpOAuth": {"fixture-server": {"accessToken": "fixture-mcp-only"}}}
76
+ self.on_post = lambda: self.write(remaining)
77
+ self.assertIsNone(self.refresh())
78
+ self.assertEqual(self.read(), remaining)
79
+
80
+ def test_concurrent_mcp_changes_survive_successful_refresh(self):
81
+ concurrent = copy.deepcopy(self.original)
82
+ concurrent["mcpOAuth"]["fixture-server"]["accessToken"] = "fixture-mcp-updated"
83
+ concurrent["mcpOAuthClientConfig"] = {"fixture-server": {"clientId": "fixture-client-id"}}
84
+ self.on_post = lambda: self.write(concurrent)
85
+ self.assertEqual(self.refresh(), "fixture-new-access")
86
+ saved = self.read()
87
+ self.assertEqual(saved["mcpOAuth"], concurrent["mcpOAuth"])
88
+ self.assertEqual(saved["mcpOAuthClientConfig"], concurrent["mcpOAuthClientConfig"])
89
+ self.assertEqual(saved["claudeAiOauth"]["refreshToken"], "fixture-new-refresh")
90
+
91
+ def test_unknown_refresh_expiry_still_attempts_refresh(self):
92
+ document = copy.deepcopy(self.original)
93
+ del document["claudeAiOauth"]["refreshTokenExpiresAt"]
94
+ self.write(document)
95
+ self.assertEqual(self.refresh(), "fixture-new-access")
96
+ self.assertEqual(len(self.posts), 1)
97
+
98
+ def test_omitted_refresh_expiry_preserves_native_expiry_semantics(self):
99
+ del self.reply["refresh_token_expires_in"]
100
+ self.assertEqual(self.refresh(), "fixture-new-access")
101
+ self.assertEqual(self.read()["claudeAiOauth"]["refreshTokenExpiresAt"],
102
+ self.original["claudeAiOauth"]["refreshTokenExpiresAt"])
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()