claude-multiacc 2.0.65 → 2.0.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/claude-accounts +11 -197
  2. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  3. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  4. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  5. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  13. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  14. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  40. package/lib/claude_oauth_lock.py +172 -0
  41. package/lib/claude_oauth_refresh.py +225 -0
  42. package/package.json +3 -2
  43. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  47. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  48. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
  111. package/tests/claude_oauth_fixture.py +100 -0
  112. package/tests/test_claude_oauth_lock.py +138 -0
  113. package/tests/test_claude_oauth_refresh.py +106 -0
  114. package/tests/test_claude_oauth_refresh_failures.py +112 -0
@@ -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
- # The client's own write lock: proper-lockfile's DIRECTORY <dir>/.storage-write.lock, stale
2012
- # after 15 s (lib/mcp_oauth.py ClientLock). Claude Code takes it around every write of its
2013
- # credential document, file or Keychain item alike — re-read, one change, the whole
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
- """Refresh a long-expired OAuth access token via the refresh-token grant and
2145
- persist the ROTATED credential atomically (0600 file, or the Keychain item it
2146
- came from). Returns the new bearer, or None (fail open: the stored credential
2147
- is never touched on failure). Failures back off via <dir>/.oauth-refresh.json —
2148
- a side file, NOT limits.json, because telemetry state must only ever reflect
2149
- real usage fetches."""
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 waits REFRESH_MIN_EXPIRED to
2404
- # prove no live session owns it), a refresh backoff, or a Keychain this session
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
@@ -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