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.
- package/bin/claude-accounts +11 -197
- package/docs/INSTALLATION.md +4 -3
- package/docs/RELEASING.md +75 -0
- package/lib/claude_oauth_lock.py +172 -0
- package/lib/claude_oauth_refresh.py +225 -0
- package/package.json +3 -2
- package/tests/autoresume_cross_tmux_environment.py +4 -1
- package/tests/claude_oauth_fixture.py +100 -0
- package/tests/run-tests.sh +3 -1
- 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
- package/tests/test_release_validation.py +826 -0
- 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/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_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
|
@@ -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.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
|
-
|
|
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)
|
package/tests/run-tests.sh
CHANGED
|
@@ -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
|
-
|
|
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()
|