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
package/bin/claude-accounts
CHANGED
|
@@ -1846,12 +1846,6 @@ TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
|
|
|
1846
1846
|
'https://console.anthropic.com/v1/oauth/token')
|
|
1847
1847
|
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
|
|
1848
1848
|
'9d1c250a-e61b-44d9-88ed-5944d1962f5e')
|
|
1849
|
-
# Only refresh a token that has been expired for a while: a LIVE session refreshes
|
|
1850
|
-
# its own credential within moments of expiry, so a long-expired one proves no
|
|
1851
|
-
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
|
|
1852
|
-
REFRESH_MIN_EXPIRED = 300
|
|
1853
|
-
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
1854
|
-
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
1855
1849
|
# A usage fetch the server says will never succeed (x-should-retry: false — e.g. the
|
|
1856
1850
|
# 403 "does not meet scope requirement user:profile" that a setup token ALWAYS gets)
|
|
1857
1851
|
# is not a hiccup to retry every pass. Retrying it is what manufactured the 429s that
|
|
@@ -2008,39 +2002,12 @@ def park_dead_grant(aid, d, slug, detail):
|
|
|
2008
2002
|
say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')
|
|
2009
2003
|
|
|
2010
2004
|
|
|
2011
|
-
#
|
|
2012
|
-
#
|
|
2013
|
-
|
|
2014
|
-
# document back — and its write FAILS after about 7.5 s of retries on a held lock. So the
|
|
2015
|
-
# refresher never holds it across the token POST: a lock held when a refresh would start
|
|
2016
|
-
# skips that refresh (nothing has rotated yet; the next pass retries), and the
|
|
2017
|
-
# check-and-set and the write take it after the POST, waiting longer, because a rotated
|
|
2018
|
-
# grant must land: past the wait (or with no helper) it is written as before, without it.
|
|
2019
|
-
try:
|
|
2020
|
-
from mcp_oauth import CLIENT_LOCK, CLIENT_LOCK_STALE_S, ClientLock # noqa: E402
|
|
2021
|
-
except Exception: # noqa: BLE001 — fail open: refresh exactly as before, unlocked
|
|
2022
|
-
ClientLock = None
|
|
2023
|
-
CLIENT_LOCK_PROBE_S = 1.0 # a client's write takes milliseconds: held this long, it is busy
|
|
2005
|
+
# Legacy Keychain migration needs only the client's short storage-write lock.
|
|
2006
|
+
# Refresh request locking and credential CAS live in claude_oauth_refresh.py.
|
|
2007
|
+
from mcp_oauth import ClientLock # noqa: E402
|
|
2024
2008
|
CLIENT_LOCK_WAIT_S = 10.0
|
|
2025
2009
|
|
|
2026
2010
|
|
|
2027
|
-
def client_lock_busy(d):
|
|
2028
|
-
"""A live writer holds <d>'s client lock, and still does CLIENT_LOCK_PROBE_S later."""
|
|
2029
|
-
if ClientLock is None:
|
|
2030
|
-
return False
|
|
2031
|
-
path = os.path.join(d, CLIENT_LOCK)
|
|
2032
|
-
deadline = time.monotonic() + CLIENT_LOCK_PROBE_S
|
|
2033
|
-
while True:
|
|
2034
|
-
try:
|
|
2035
|
-
if time.time() - os.stat(path).st_mtime > CLIENT_LOCK_STALE_S:
|
|
2036
|
-
return False # its holder is gone; the write takes it over
|
|
2037
|
-
except OSError:
|
|
2038
|
-
return False # no lock: nobody is writing
|
|
2039
|
-
if time.monotonic() >= deadline:
|
|
2040
|
-
return True
|
|
2041
|
-
time.sleep(0.05)
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
2011
|
class FileStore:
|
|
2045
2012
|
"""The OAuth credential as `.credentials.json`: atomic 0600 writes. The client keeps
|
|
2046
2013
|
its MCP servers' OAuth state (mcpOAuth, mcpOAuthClientConfig) in this same file, so
|
|
@@ -2141,166 +2108,13 @@ def oauth_store(d):
|
|
|
2141
2108
|
|
|
2142
2109
|
|
|
2143
2110
|
def refresh_oauth(aid, d, store):
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
spath = os.path.join(d, '.oauth-refresh.json')
|
|
2151
|
-
try:
|
|
2152
|
-
doc = store.read()
|
|
2153
|
-
o = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
|
|
2154
|
-
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
2155
|
-
# write) must degrade THIS account only, like every other malformed input; and a
|
|
2156
|
-
# document with no login in it at all (MCP state alone) has nothing to refresh.
|
|
2157
|
-
if not isinstance(o, dict):
|
|
2158
|
-
return None
|
|
2159
|
-
except Exception:
|
|
2160
|
-
return None
|
|
2161
|
-
# NB: no "must have an accessToken" gate. A credential whose access token was
|
|
2162
|
-
# cleared but whose REFRESH token is alive is exactly the shape a grant is supposed
|
|
2163
|
-
# to recover from; requiring the dead half to be present meant such an account could
|
|
2164
|
-
# never come back, and (with a setup token beside it) went dark for telemetry
|
|
2165
|
-
# forever. The expiresAt gate below is what protects a live session's credential.
|
|
2166
|
-
if not o.get('refreshToken'):
|
|
2167
|
-
park_dead_grant(aid, d, 'no-refresh-token',
|
|
2168
|
-
'credential has no refresh token and its access token expired')
|
|
2169
|
-
return None
|
|
2170
|
-
if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
|
|
2171
|
-
return None # not expired long enough to prove no live session owns it
|
|
2172
|
-
if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
|
|
2173
|
-
# Nothing can revive this account: park it so the shim stops selecting it
|
|
2174
|
-
# (every run under it would fail with "OAuth session expired").
|
|
2175
|
-
park_dead_grant(aid, d, 'refresh-token-expired',
|
|
2176
|
-
'the refresh token itself expired; only a re-login can fix it')
|
|
2177
|
-
return None
|
|
2178
|
-
if not force:
|
|
2179
|
-
try:
|
|
2180
|
-
if json.load(open(spath)).get('retry_after', 0) > now:
|
|
2181
|
-
return None # earlier refresh failure still backing off
|
|
2182
|
-
except Exception:
|
|
2183
|
-
pass
|
|
2184
|
-
|
|
2185
|
-
def back_off(wait, why, denials=0):
|
|
2186
|
-
try:
|
|
2187
|
-
with open(spath + '.tmp', 'w') as f:
|
|
2188
|
-
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
2189
|
-
'denials': denials,
|
|
2190
|
-
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
2191
|
-
os.replace(spath + '.tmp', spath)
|
|
2192
|
-
except Exception:
|
|
2193
|
-
pass
|
|
2194
|
-
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
2111
|
+
# Import failures skip this account through the caller's guard; never rotate
|
|
2112
|
+
# without the native request lock. The helper owns the full refresh transaction.
|
|
2113
|
+
from claude_oauth_refresh import OAuthRefresher
|
|
2114
|
+
return OAuthRefresher(now=now, force=force, token_url=TOKEN_URL, client_id=CLIENT_ID,
|
|
2115
|
+
say=say, park_dead_grant=park_dead_grant,
|
|
2116
|
+
clear_expired=clear_expired).refresh(aid, d, store)
|
|
2195
2117
|
|
|
2196
|
-
if client_lock_busy(d):
|
|
2197
|
-
# The client is writing this credential right now (it may be its own refresh of
|
|
2198
|
-
# the same grant): no request is sent, so nothing rotates, and no backoff is noted.
|
|
2199
|
-
say(f'{aid}: oauth refresh skipped — the client holds its credential lock '
|
|
2200
|
-
f'({CLIENT_LOCK}); the next pass retries')
|
|
2201
|
-
return None
|
|
2202
|
-
started_with = o['refreshToken']
|
|
2203
|
-
body = json.dumps({'grant_type': 'refresh_token',
|
|
2204
|
-
'refresh_token': o['refreshToken'],
|
|
2205
|
-
'client_id': CLIENT_ID}).encode()
|
|
2206
|
-
req = urllib.request.Request(TOKEN_URL, data=body, headers={
|
|
2207
|
-
'Content-Type': 'application/json',
|
|
2208
|
-
'User-Agent': 'claude-multiacc/1.0',
|
|
2209
|
-
})
|
|
2210
|
-
try:
|
|
2211
|
-
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
2212
|
-
except urllib.error.HTTPError as e:
|
|
2213
|
-
if e.code in (400, 401, 403):
|
|
2214
|
-
# A 4xx from the TOKEN endpoint is only proof of a dead grant when the
|
|
2215
|
-
# server says so (OAuth's invalid_grant). Everything else 4xx — a bad
|
|
2216
|
-
# client_id, an endpoint change, a WAF page, a provider incident — would
|
|
2217
|
-
# hit EVERY account at once, so it must never park the whole pool on the
|
|
2218
|
-
# first try: back off, and only park after this account has been refused
|
|
2219
|
-
# repeatedly.
|
|
2220
|
-
body = ''
|
|
2221
|
-
try:
|
|
2222
|
-
body = e.read().decode('utf-8', 'replace')[:400]
|
|
2223
|
-
except Exception:
|
|
2224
|
-
pass
|
|
2225
|
-
denials = 1
|
|
2226
|
-
try:
|
|
2227
|
-
denials = int(json.load(open(spath)).get('denials', 0)) + 1
|
|
2228
|
-
except Exception:
|
|
2229
|
-
pass
|
|
2230
|
-
if 'invalid_grant' in body:
|
|
2231
|
-
park_dead_grant(aid, d, f'refresh-denied-http-{e.code}',
|
|
2232
|
-
'the refresh grant was refused as invalid_grant (revoked or rotated away)')
|
|
2233
|
-
elif denials >= 3:
|
|
2234
|
-
park_dead_grant(aid, d, f'refresh-denied-http-{e.code}',
|
|
2235
|
-
f'the refresh grant was refused {denials} times in a row')
|
|
2236
|
-
back_off(REFRESH_DENIED_BACKOFF,
|
|
2237
|
-
f'HTTP {e.code} — refresh token may be revoked; re-login needed',
|
|
2238
|
-
denials=denials)
|
|
2239
|
-
else:
|
|
2240
|
-
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
2241
|
-
return None
|
|
2242
|
-
except Exception as e:
|
|
2243
|
-
back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
|
|
2244
|
-
return None
|
|
2245
|
-
tok = data.get('access_token') if isinstance(data, dict) else None
|
|
2246
|
-
if not tok:
|
|
2247
|
-
back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
|
|
2248
|
-
return None
|
|
2249
|
-
o['accessToken'] = tok
|
|
2250
|
-
# The grant ROTATES the refresh token: persist it (and both expiries) or the
|
|
2251
|
-
# account is stranded — hence atomic write, and a loud message if it fails.
|
|
2252
|
-
if data.get('refresh_token'):
|
|
2253
|
-
o['refreshToken'] = data['refresh_token']
|
|
2254
|
-
if data.get('expires_in'):
|
|
2255
|
-
o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
|
|
2256
|
-
else:
|
|
2257
|
-
# No expires_in in the response: assume a conservative 1h. Leaving the old
|
|
2258
|
-
# (past) expiresAt would make every later pass re-run the grant in a loop.
|
|
2259
|
-
o['expiresAt'] = int((now + 3600) * 1000)
|
|
2260
|
-
if data.get('refresh_token_expires_in'):
|
|
2261
|
-
o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
|
|
2262
|
-
# CHECK-AND-SET. The grant rotates, and a live claude session refreshes the same
|
|
2263
|
-
# file. REFRESH_MIN_EXPIRED makes that unlikely, not impossible — and losing the
|
|
2264
|
-
# race by overwriting means the session's newer credential is destroyed. If the
|
|
2265
|
-
# on-disk refresh token is no longer the one this grant was issued against, the
|
|
2266
|
-
# other writer won: keep its result, discard ours. Both under the client's own lock,
|
|
2267
|
-
# so no client write lands between the check and the write.
|
|
2268
|
-
lock = ClientLock(d, CLIENT_LOCK_WAIT_S) if ClientLock is not None else None
|
|
2269
|
-
try:
|
|
2270
|
-
held = lock is not None and lock.acquire()
|
|
2271
|
-
except Exception: # noqa: BLE001 — the rotated grant lands either way
|
|
2272
|
-
held = False
|
|
2273
|
-
if lock is not None and not held and os.path.isdir(lock.path):
|
|
2274
|
-
say(f'{aid}: the client kept its credential lock ({CLIENT_LOCK}) past '
|
|
2275
|
-
f'{int(CLIENT_LOCK_WAIT_S)}s — writing the rotated login without it')
|
|
2276
|
-
try:
|
|
2277
|
-
try:
|
|
2278
|
-
disk = store.read().get('claudeAiOauth', {})
|
|
2279
|
-
if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
|
|
2280
|
-
say(f'{aid}: credential was refreshed by something else mid-flight — '
|
|
2281
|
-
f'keeping the newer one in the {store.kind}')
|
|
2282
|
-
return None
|
|
2283
|
-
except Exception:
|
|
2284
|
-
pass
|
|
2285
|
-
try:
|
|
2286
|
-
# Only the login is ours: both stores write it INTO the document as it is now,
|
|
2287
|
-
# so the MCP state the client saved meanwhile (mcpOAuth) is never rolled back.
|
|
2288
|
-
store.write({'claudeAiOauth': o})
|
|
2289
|
-
except Exception as e:
|
|
2290
|
-
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
2291
|
-
return None
|
|
2292
|
-
finally:
|
|
2293
|
-
if held:
|
|
2294
|
-
lock.release()
|
|
2295
|
-
try:
|
|
2296
|
-
os.remove(spath)
|
|
2297
|
-
except OSError:
|
|
2298
|
-
pass
|
|
2299
|
-
# The grant answered: whatever parked this account before, it authenticates now.
|
|
2300
|
-
if clear_expired(d):
|
|
2301
|
-
say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
|
|
2302
|
-
say(f'{aid}: oauth access token refreshed via refresh-token grant')
|
|
2303
|
-
return tok
|
|
2304
2118
|
|
|
2305
2119
|
for acct in manifest.get('accounts', []):
|
|
2306
2120
|
aid = acct['id']
|
|
@@ -2400,8 +2214,8 @@ for acct in manifest.get('accounts', []):
|
|
|
2400
2214
|
bearer, source = tok, 'oauth'
|
|
2401
2215
|
# A setup token is a LAST RESORT and only for an account that has no OAuth
|
|
2402
2216
|
# credential at all. Where one exists but could not be used this pass — its
|
|
2403
|
-
# access token expired moments ago (refresh_oauth
|
|
2404
|
-
#
|
|
2217
|
+
# access token expired moments ago (refresh_oauth gives the native
|
|
2218
|
+
# client a five-minute grace period), a refresh backoff, or a Keychain this session
|
|
2405
2219
|
# cannot open — spending the token is guaranteed to earn a 403 it can never not
|
|
2406
2220
|
# earn, and that 403 used to park the whole ACCOUNT for six hours. Telemetry then
|
|
2407
2221
|
# froze for an account whose OAuth would have worked on the very next pass, and
|
|
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,172 @@
|
|
|
1
|
+
"""Serialize OAuth token POSTs with Claude Code's native refresh locks.
|
|
2
|
+
|
|
3
|
+
Claude Code 2.1.310 Gnr/Vri use proper-lockfile directories: first
|
|
4
|
+
<config>/.oauth_refresh.lock, then <realpath(config)>.lock, with a 60-second
|
|
5
|
+
stale deadline and five-second mtime renewal. Ec changes callback scheduling,
|
|
6
|
+
not that on-disk protocol. The optional native owner sidecar only accelerates
|
|
7
|
+
proven-dead-holder recovery; without one, native clients retain the stale gate.
|
|
8
|
+
|
|
9
|
+
Hold this lock across credential reread, token exchange and rotated-token save.
|
|
10
|
+
The separate .storage-write.lock protects only short credential transactions.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
STALE_SECONDS = 60.0
|
|
21
|
+
UPDATE_SECONDS = 5.0
|
|
22
|
+
RETRY_SECONDS = 0.05
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _snapshot(path: Path) -> tuple[int, int, int] | None:
|
|
26
|
+
try:
|
|
27
|
+
value = path.lstat()
|
|
28
|
+
if stat.S_ISDIR(value.st_mode):
|
|
29
|
+
return value.st_dev, value.st_ino, value.st_mtime_ns
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RefreshLock:
|
|
36
|
+
"""Bounded, fail-closed lock; call release even after an exchange fails."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, directory: str | Path, timeout: float = 1.0):
|
|
39
|
+
directory = Path(directory).absolute()
|
|
40
|
+
self.paths = (directory / ".oauth_refresh.lock", Path(str(directory.resolve()) + ".lock"))
|
|
41
|
+
self.timeout = max(0.0, timeout)
|
|
42
|
+
self._held: list[tuple[Path, int, tuple[int, int, int]]] = []
|
|
43
|
+
self._mutex = threading.Lock()
|
|
44
|
+
self._stop = threading.Event()
|
|
45
|
+
self._thread: threading.Thread | None = None
|
|
46
|
+
self._compromised = False
|
|
47
|
+
|
|
48
|
+
def acquire(self) -> bool:
|
|
49
|
+
deadline = time.monotonic() + self.timeout
|
|
50
|
+
with self._mutex:
|
|
51
|
+
if self._held:
|
|
52
|
+
return not self._compromised and all(
|
|
53
|
+
_snapshot(path) == stamp and time.time_ns() - stamp[2] < STALE_SECONDS * 1e9
|
|
54
|
+
for path, _, stamp in self._held)
|
|
55
|
+
self._compromised = False
|
|
56
|
+
self._stop.clear()
|
|
57
|
+
while True:
|
|
58
|
+
for path in self.paths:
|
|
59
|
+
entry = self._claim(path)
|
|
60
|
+
if entry is None:
|
|
61
|
+
break
|
|
62
|
+
self._held.append(entry)
|
|
63
|
+
if len(self._held) == len(self.paths):
|
|
64
|
+
self._thread = threading.Thread(target=self._heartbeat, daemon=True)
|
|
65
|
+
try:
|
|
66
|
+
self._thread.start()
|
|
67
|
+
except RuntimeError:
|
|
68
|
+
self._release_paths()
|
|
69
|
+
self._thread = None
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
self._release_paths()
|
|
73
|
+
remaining = deadline - time.monotonic()
|
|
74
|
+
if remaining <= 0:
|
|
75
|
+
return False
|
|
76
|
+
time.sleep(min(RETRY_SECONDS, remaining))
|
|
77
|
+
|
|
78
|
+
def _claim(self, path: Path) -> tuple[Path, int, tuple[int, int, int]] | None:
|
|
79
|
+
try:
|
|
80
|
+
path.mkdir(mode=0o700)
|
|
81
|
+
except FileExistsError:
|
|
82
|
+
before = _snapshot(path)
|
|
83
|
+
if before is None or time.time_ns() - before[2] <= STALE_SECONDS * 1e9:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
if _snapshot(path) != before:
|
|
87
|
+
return None
|
|
88
|
+
path.rmdir()
|
|
89
|
+
path.mkdir(mode=0o700)
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
before = _snapshot(path)
|
|
95
|
+
descriptor = None
|
|
96
|
+
claimed = False
|
|
97
|
+
try:
|
|
98
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
99
|
+
value = os.fstat(descriptor)
|
|
100
|
+
if before is None or (value.st_dev, value.st_ino, value.st_mtime_ns) != before:
|
|
101
|
+
return None
|
|
102
|
+
now = time.time_ns()
|
|
103
|
+
os.utime(descriptor, ns=(now, now))
|
|
104
|
+
value = os.fstat(descriptor)
|
|
105
|
+
stamp = value.st_dev, value.st_ino, value.st_mtime_ns
|
|
106
|
+
before = stamp
|
|
107
|
+
if _snapshot(path) != stamp:
|
|
108
|
+
return None
|
|
109
|
+
claimed = True
|
|
110
|
+
result, descriptor = (path, descriptor, stamp), None
|
|
111
|
+
return result
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
finally:
|
|
115
|
+
if descriptor is not None:
|
|
116
|
+
os.close(descriptor)
|
|
117
|
+
if not claimed and before is not None and _snapshot(path) == before:
|
|
118
|
+
try:
|
|
119
|
+
path.rmdir()
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def _renew(self) -> bool:
|
|
124
|
+
for index, (path, descriptor, stamp) in enumerate(self._held):
|
|
125
|
+
if _snapshot(path) != stamp or time.time_ns() - stamp[2] >= STALE_SECONDS * 1e9:
|
|
126
|
+
return False
|
|
127
|
+
try:
|
|
128
|
+
now = time.time_ns()
|
|
129
|
+
os.utime(descriptor, ns=(now, now))
|
|
130
|
+
value = os.fstat(descriptor)
|
|
131
|
+
updated = value.st_dev, value.st_ino, value.st_mtime_ns
|
|
132
|
+
if _snapshot(path) != updated:
|
|
133
|
+
return False
|
|
134
|
+
self._held[index] = path, descriptor, updated
|
|
135
|
+
except OSError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
def _heartbeat(self) -> None:
|
|
140
|
+
while not self._stop.wait(UPDATE_SECONDS):
|
|
141
|
+
with self._mutex:
|
|
142
|
+
if not self._renew():
|
|
143
|
+
self._compromised = True
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
def _release_paths(self) -> None:
|
|
147
|
+
for path, descriptor, stamp in reversed(self._held):
|
|
148
|
+
try:
|
|
149
|
+
if _snapshot(path) == stamp:
|
|
150
|
+
path.rmdir()
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
finally:
|
|
154
|
+
os.close(descriptor)
|
|
155
|
+
self._held.clear()
|
|
156
|
+
|
|
157
|
+
def release(self) -> None:
|
|
158
|
+
self._stop.set()
|
|
159
|
+
thread = self._thread
|
|
160
|
+
if thread is not None:
|
|
161
|
+
thread.join(timeout=1.0)
|
|
162
|
+
with self._mutex:
|
|
163
|
+
self._release_paths()
|
|
164
|
+
self._thread = None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def compromised(self) -> bool:
|
|
168
|
+
with self._mutex:
|
|
169
|
+
if any(_snapshot(path) != stamp or time.time_ns() - stamp[2] >= STALE_SECONDS * 1e9
|
|
170
|
+
for path, _, stamp in self._held):
|
|
171
|
+
self._compromised = True
|
|
172
|
+
return self._compromised
|