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
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
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -56,9 +56,10 @@ version is on npm.
|
|
|
56
56
|
(Note: newer npm versions gate install scripts; if postinstall is blocked, just run
|
|
57
57
|
`claude-multiacc install` once.)
|
|
58
58
|
|
|
59
|
-
New versions publish to npm automatically from `main` via GitHub Actions
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
New versions publish to npm automatically from `main` via GitHub Actions using OIDC
|
|
60
|
+
Trusted Publishing. Prepare the version with `scripts/auto-version.mjs` before
|
|
61
|
+
committing, then complete the [local release validation](RELEASING.md). The workflow
|
|
62
|
+
verifies that evidence and publishes the exact validated version and package bytes.
|
|
62
63
|
|
|
63
64
|
### From a git checkout
|
|
64
65
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Releasing within two minutes of GitHub Actions starting
|
|
2
|
+
|
|
3
|
+
Every release runs the full validation locally before GitHub Actions. The workflow
|
|
4
|
+
then verifies authenticated evidence, repacks and compares the exact package, runs
|
|
5
|
+
short hosted compatibility checks, publishes through npm OIDC, confirms registry
|
|
6
|
+
integrity, and tags the same source. Measure the complete successful Actions run
|
|
7
|
+
from its creation through completion, including setup and publication: the budget
|
|
8
|
+
is 120 seconds. A timeout alone does not establish compliance.
|
|
9
|
+
|
|
10
|
+
The full local checks still take minutes. They have not been removed, weakened or
|
|
11
|
+
replaced by a smoke test. `scripts/release_validation.py` defines their canonical
|
|
12
|
+
roster: the complete shell suite, selector, piped Codex retries, Keychain, shell PATH
|
|
13
|
+
probe, Claude OAuth refresh coordination, autoresume, account seeding/settings/MCP,
|
|
14
|
+
packed commands, and release policy tests. Seeding runs in a private Linux PID namespace; tmux and zsh must be present.
|
|
15
|
+
|
|
16
|
+
## Prepare and validate an immutable candidate
|
|
17
|
+
|
|
18
|
+
1. Resolve the next npm version before the final candidate commit:
|
|
19
|
+
`node scripts/auto-version.mjs .`. Commit all source, tests, release scripts and
|
|
20
|
+
documentation. Validation requires a clean checkout, including untracked files.
|
|
21
|
+
2. Run `scripts/validate-release.sh --receipt /absolute/path/outside/repo/receipt.json`.
|
|
22
|
+
The helper prepares a Linux environment, runs with a private process namespace
|
|
23
|
+
and HOME, disables networking during tests, and mounts the candidate read-only.
|
|
24
|
+
npm 12.2.1 is used locally and in Actions so the packed bytes match.
|
|
25
|
+
3. Keep the receipt, complete command logs and packed artifact together outside the
|
|
26
|
+
checkout. Any failing command or source change prevents successful attestation.
|
|
27
|
+
4. Push that exact candidate and authenticate the result:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python3 scripts/release_validation.py publish --repository gowalk-public/claude-multiacc --receipt /absolute/path/outside/repo/receipt.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
5. Mark the draft PR ready after publishing the statuses, or rerun its workflow if
|
|
34
|
+
it was already ready. Same-repository ready PRs verify the exact head and run the
|
|
35
|
+
short hosted checks; they cannot publish a package. Draft and fork PRs skip this job.
|
|
36
|
+
6. Merge only when the branch tree is exactly the tested tree. If main changed such
|
|
37
|
+
that the merge would change it, update the branch and repeat validation. A squash
|
|
38
|
+
merge may change the commit ID; its tree must still equal the validated PR head.
|
|
39
|
+
|
|
40
|
+
The repository variable `LOCAL_RELEASE_VALIDATION_ACTOR` names the trusted GitHub
|
|
41
|
+
account allowed to attest local execution. GitHub authenticates that actor, not the
|
|
42
|
+
local machine; this does not claim hardware or machine attestation. A checked-in
|
|
43
|
+
JSON file alone never admits a release. Raw local receipts remain available for
|
|
44
|
+
review and bind each command's exact arguments, return code and log digest.
|
|
45
|
+
|
|
46
|
+
The latest `local-release-validation` status binds the Git tree and complete suite
|
|
47
|
+
policy. The latest `local-release-artifact` status binds version and tarball SHA-256.
|
|
48
|
+
Both must be successful, created by the trusted actor, and less than 24 hours old.
|
|
49
|
+
A newer failed, pending or unauthorized status blocks an older success. Changes to
|
|
50
|
+
source bytes, modes, symlink targets, policy, version or package bytes require fresh
|
|
51
|
+
evidence. No CI version bump is permitted after validation.
|
|
52
|
+
|
|
53
|
+
## Publication and confirmation
|
|
54
|
+
|
|
55
|
+
The workflow retains real hosted PATH-probe and atomic-lock checks because hosted
|
|
56
|
+
shell integration and core utilities can differ from a local Linux image. It runs
|
|
57
|
+
the release-policy rejection tests as well. These short checks supplement the full
|
|
58
|
+
local proof.
|
|
59
|
+
|
|
60
|
+
After publishing, require both a green workflow and matching npm integrity/version
|
|
61
|
+
and Git tag. Record the measured run duration. Queue or registry outages can prevent
|
|
62
|
+
a release; they must never cause stale proof to be accepted or required checks to
|
|
63
|
+
be skipped. The workflow refuses to start publication when it has already consumed
|
|
64
|
+
the time reserved for publication and tagging.
|
|
65
|
+
|
|
66
|
+
npm can accept a package and keep it unavailable while processing it. That is
|
|
67
|
+
reported as pending registry confirmation, never as a completed deployment. A rerun
|
|
68
|
+
checks the exact existing version's integrity, skips republishing matching bytes,
|
|
69
|
+
and completes the matching tag. Conflicting bytes or an existing tag for another
|
|
70
|
+
commit stop before any new publication. Registry confirmation uses the remaining
|
|
71
|
+
run budget while reserving time for tagging and Actions cleanup; it never extends
|
|
72
|
+
the 120-second budget. Recovery uses the new attempt's start time;
|
|
73
|
+
reports retain the original run creation time so waiting does not disappear from
|
|
74
|
+
the end-to-end record. Registry processing time is external to Actions and must be
|
|
75
|
+
reported separately if it prevents the requested complete deployment budget.
|
|
@@ -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
|