claude-multiacc 2.0.65 → 2.0.66

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 (114) hide show
  1. package/bin/claude-accounts +11 -197
  2. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  3. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  4. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  5. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  13. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  14. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  40. package/lib/claude_oauth_lock.py +172 -0
  41. package/lib/claude_oauth_refresh.py +225 -0
  42. package/package.json +3 -2
  43. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  47. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  48. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
  111. package/tests/claude_oauth_fixture.py +100 -0
  112. package/tests/test_claude_oauth_lock.py +138 -0
  113. package/tests/test_claude_oauth_refresh.py +106 -0
  114. package/tests/test_claude_oauth_refresh_failures.py +112 -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.66",
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'",
@@ -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)