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.
- package/bin/claude-accounts +11 -197
- package/lib/__pycache__/audit.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
- package/lib/__pycache__/report.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
- package/lib/claude_oauth_lock.py +172 -0
- package/lib/claude_oauth_refresh.py +225 -0
- package/package.json +3 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
- package/tests/claude_oauth_fixture.py +100 -0
- package/tests/test_claude_oauth_lock.py +138 -0
- package/tests/test_claude_oauth_refresh.py +106 -0
- 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.
|
|
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'",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|