claude-multiacc 2.0.57 → 2.0.59
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/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/lib/mcp_oauth.py
ADDED
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP OAuth logins, shared by every pooled Claude account on this machine.
|
|
3
|
+
|
|
4
|
+
WHY (2026-10-19). Claude Code keeps an MCP server's OAuth login in the config dir's
|
|
5
|
+
credential store, next to its own login: ONE JSON document holding `claudeAiOauth` (the
|
|
6
|
+
Claude login) and `mcpOAuth` (MCP servers' OAuth state, keyed
|
|
7
|
+
`<serverName>|<sha256(JSON.stringify({type,url,headers}))[:16]>`). Under the pool every
|
|
8
|
+
launch runs in another config dir, so a server authenticated in one account said "needs
|
|
9
|
+
authentication" under every other account the shim picked: five separate higgsfield
|
|
10
|
+
grants sat in five accounts, and the rest held discovery-only stubs (`accessToken: ""`).
|
|
11
|
+
|
|
12
|
+
The client's store (Claude Code 2.1.303/2.1.304, read out of the binary):
|
|
13
|
+
* macOS: the login Keychain item `Claude Code-credentials-<sha256(dir)[:8]>` under
|
|
14
|
+
`$USER` (lib/keychain.py), with `<dir>/.credentials.json` (0600) as the fallback.
|
|
15
|
+
read() is the Keychain document when there is one, else the file. A session that
|
|
16
|
+
cannot open the keychain — ssh, tmux, background, `security` exit 36 — reads and
|
|
17
|
+
writes the FILE, and every session on the operator's Mac is one: there the file holds
|
|
18
|
+
`mcpOAuth` alone (the Claude login is the shim's setup token). Elsewhere: the file.
|
|
19
|
+
* every write takes a proper-lockfile lock — the DIRECTORY `<dir>/.storage-write.lock`,
|
|
20
|
+
stale after 15 s, its mtime refreshed by the holder — then re-reads, applies its one
|
|
21
|
+
change and writes the whole document back.
|
|
22
|
+
* an MCP entry: serverName, serverUrl, accessToken, refreshToken, expiresAt (epoch ms,
|
|
23
|
+
now + expires_in on every save — and NO expiresAt when the answer had no
|
|
24
|
+
expires_in), scope, issuer, clientId, clientSecret?, redirectUri, discoveryState
|
|
25
|
+
{authorizationServerUrl, authServerMetadataUrl, …}, stepUpScope?. tokens() re-reads
|
|
26
|
+
the store on every call and refreshes once the access token has 300 s or less left,
|
|
27
|
+
under a lock of ITS OWN config dir (`mcp-refresh-<key>.lock`); a refresh answered
|
|
28
|
+
without a refresh token keeps the old one.
|
|
29
|
+
* a refresh refused with invalid_grant blanks the entry (tokens gone, expiresAt 0) —
|
|
30
|
+
only while the store still holds the tokens that client was using. /mcp "Clear
|
|
31
|
+
authentication" and `claude mcp login` REVOKE the stored tokens at the authorization
|
|
32
|
+
server (RFC 7009, when its metadata names a revocation_endpoint), then delete or
|
|
33
|
+
blank the entry; a re-authentication revokes the tokens it replaced.
|
|
34
|
+
|
|
35
|
+
So the pool keeps one MACHINE-LOCAL store, ROOT/mcp-oauth.json (0600, NEVER synced or
|
|
36
|
+
fanned out: a grant belongs to this machine, and two machines refreshing one rotating
|
|
37
|
+
grant strand one of them — the codex auth.json rule), and four verbs work on it:
|
|
38
|
+
|
|
39
|
+
harvest every account's `mcpOAuth`, from its file and from its Keychain item when
|
|
40
|
+
this session can read it. An entry is a LOGIN when its access or refresh
|
|
41
|
+
token is a non-empty string. Per key the winner is the largest expiresAt
|
|
42
|
+
(the latest save) — a login saved without one ranks, and is kept, as if it
|
|
43
|
+
lived ASSUMED_LIFETIME_S from the harvest — ties going to one with a
|
|
44
|
+
refresh token, then to the store's own copy; a login the store marked
|
|
45
|
+
needs_reauth loses to any OTHER grant. Stubs never overwrite anything, and
|
|
46
|
+
tokens the pool's own refreshes replaced, or that `forget` or a clear
|
|
47
|
+
retired, are ignored wherever they turn up.
|
|
48
|
+
distribute the winner, into each account store that exists and lacks the key, holds a
|
|
49
|
+
stub, or holds a provably older login (an earlier expiresAt, or tokens the
|
|
50
|
+
pool replaced) — a login of another grant whose age cannot be compared
|
|
51
|
+
stays. Under the client's own lock, re-reading first, touching that one key
|
|
52
|
+
and nothing else — `claudeAiOauth` above all. A .credentials.json is
|
|
53
|
+
CREATED, holding `mcpOAuth` alone, only for an account that runs on its
|
|
54
|
+
portable token (a non-empty server.token, no `.expired` park, not a
|
|
55
|
+
symlinked dir) and has no file or link of that name: every reader counts a
|
|
56
|
+
document as a login only when it carries `claudeAiOauth` (bin/claude
|
|
57
|
+
file_login, lib/audit.py file_has_login), so that file costs the token
|
|
58
|
+
nothing — it is the very file the client writes itself once it meets an
|
|
59
|
+
OAuth server from a session that cannot open the Keychain — and it is all
|
|
60
|
+
such a client has to read MCP logins from. Every other account has a store
|
|
61
|
+
of its own already, or nothing to run on. A Keychain item is written only
|
|
62
|
+
when it EXISTS and this session can read it, then read back; an absent item
|
|
63
|
+
is never created (the client reads the file then, and a new item would hide
|
|
64
|
+
the file from every session that can open the keychain). A merged file
|
|
65
|
+
keeps its mtime: bin/claude and lib/audit.py read a .credentials.json that
|
|
66
|
+
carries a login and is newer than an `.expired` park as a fresh login and
|
|
67
|
+
lift the park, and an MCP merge is not one — so a parked account gets no
|
|
68
|
+
Keychain write (and no created file) either until it is back in the pool.
|
|
69
|
+
A login is shared only while the pool is its ONE refresher, ahead of every
|
|
70
|
+
client: clients refresh under their own config dir's lock, so accounts
|
|
71
|
+
holding one rotating grant would otherwise refresh it at once and strand
|
|
72
|
+
one (a server that detects reuse revokes the whole grant). So nothing is
|
|
73
|
+
shared with refresh off, without a refreshing `sync` in the last hour,
|
|
74
|
+
or for a login within the clients' own 5-minute window, refused by its
|
|
75
|
+
server, or failing to refresh so often that the next try comes too late.
|
|
76
|
+
refresh (sync only — one writer, under the pool lock) a winner with a refresh token
|
|
77
|
+
whose access token expires within its window — REFRESH_AHEAD (2 h), or half
|
|
78
|
+
its lifetime when that is shorter (never under 10 min, never twice in one
|
|
79
|
+
half-lifetime) — is refreshed at its authorization server, long before any
|
|
80
|
+
client's own 5-minute window. The rotated login is saved before anything
|
|
81
|
+
else, the tokens it replaced are remembered (digests) as superseded, and it
|
|
82
|
+
is distributed everywhere. A login with a copy in a Keychain item this run
|
|
83
|
+
cannot open is left to a run that can, until it is close to expiry.
|
|
84
|
+
forget a deliberate logout: the server's login leaves the store and every account
|
|
85
|
+
store, and the retired grants are remembered (digests — never tokens) so a
|
|
86
|
+
copy in a store this run could not reach is ignored and cleared later.
|
|
87
|
+
A store that held the pool's login and now shows the key gone or blanked
|
|
88
|
+
was cleared by its client — a revocation or a refusal at the server — so
|
|
89
|
+
that login is retired the same way, never merged back.
|
|
90
|
+
|
|
91
|
+
The shim (bin/claude mcp_oauth_ensure) runs `ensure` — harvest the files plus the picked
|
|
92
|
+
account's own Keychain item, distribute to that one account, no network — only when some
|
|
93
|
+
acct-*/.credentials.json is newer than ROOT/.mcp-oauth-seen (the harvest watermark) and
|
|
94
|
+
either the store exists or a file carries `mcpOAuth`, or when the store is newer than the
|
|
95
|
+
account's <dir>/.mcp-oauth-applied stamp. The store's mtime moves only when what the
|
|
96
|
+
accounts are given does (the logins and the retired grants — never bookkeeping), a whole
|
|
97
|
+
second at a time, and stamps copy it; the watermark is the newest file mtime the last
|
|
98
|
+
harvest saw, set a second early when that is the current second — so a write right behind
|
|
99
|
+
a harvest is never hidden from a one-second `test -nt` (bash 3.2).
|
|
100
|
+
|
|
101
|
+
Kill switches: CLAUDE_MULTIACC_MCP_OAUTH=0 (ensure and sync do nothing) and
|
|
102
|
+
CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0 (sync never refreshes — and so nothing is shared:
|
|
103
|
+
one shared login needs one refresher). status and forget are explicit operator commands
|
|
104
|
+
and always run. Nothing here prints or logs a token.
|
|
105
|
+
|
|
106
|
+
Run: mcp_oauth.py ensure --root R --account-dir D [--quiet] | sync --root R [--quiet]
|
|
107
|
+
[--force] | status --root R [--json] | forget --root R NAME [--quiet]
|
|
108
|
+
"""
|
|
109
|
+
from __future__ import annotations
|
|
110
|
+
|
|
111
|
+
import argparse
|
|
112
|
+
import base64
|
|
113
|
+
import calendar
|
|
114
|
+
import contextlib
|
|
115
|
+
import copy
|
|
116
|
+
import errno
|
|
117
|
+
import fcntl
|
|
118
|
+
import glob
|
|
119
|
+
import hashlib
|
|
120
|
+
import http.client
|
|
121
|
+
import json
|
|
122
|
+
import os
|
|
123
|
+
import re
|
|
124
|
+
import signal
|
|
125
|
+
import sys
|
|
126
|
+
import tempfile
|
|
127
|
+
import threading
|
|
128
|
+
import time
|
|
129
|
+
import urllib.error
|
|
130
|
+
import urllib.parse
|
|
131
|
+
import urllib.request
|
|
132
|
+
|
|
133
|
+
# The install tree may be root-owned, read-only or an npm prefix: no __pycache__ in it.
|
|
134
|
+
sys.dont_write_bytecode = True
|
|
135
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
136
|
+
import keychain # noqa: E402 (the one place that knows the Keychain layout)
|
|
137
|
+
|
|
138
|
+
STORE_FILE = 'mcp-oauth.json'
|
|
139
|
+
SEEN_FILE = '.mcp-oauth-seen'
|
|
140
|
+
STAMP_FILE = '.mcp-oauth-applied'
|
|
141
|
+
POOL_LOCK = os.path.join('.locks', 'mcp-oauth.lock')
|
|
142
|
+
LIMITS_LOCK = os.path.join('.locks', 'limits')
|
|
143
|
+
CREDENTIALS_FILE = '.credentials.json'
|
|
144
|
+
CLIENT_LOCK = '.storage-write.lock'
|
|
145
|
+
CLIENT_LOCK_STALE_S = 15 # proper-lockfile's `stale`, as the client sets it
|
|
146
|
+
LIMITS_LOCK_FRESH_S = 120 # cmd_limits breaks an older one
|
|
147
|
+
REFRESH_AHEAD_S = 7200
|
|
148
|
+
MIN_WINDOW_S = 600 # a short-lived login is still refreshed this far ahead
|
|
149
|
+
CLIENT_REFRESH_S = 300 # the client refreshes on its own inside this window
|
|
150
|
+
SYNC_PERIOD_S = 900 # the scheduled claude limits pass that runs `sync`
|
|
151
|
+
URGENT_S = CLIENT_REFRESH_S + SYNC_PERIOD_S # the last sync before the clients' window
|
|
152
|
+
REFRESHER_FRESH_S = 3600 # ensure shares only behind a sync this recent
|
|
153
|
+
ASSUMED_LIFETIME_S = 3600 # a token answer without expires_in (as refresh_oauth)
|
|
154
|
+
SUPERSEDED_KEEP = 16
|
|
155
|
+
BACKOFF_FIRST_S = 900
|
|
156
|
+
BACKOFF_MAX_S = 21600
|
|
157
|
+
HTTP_TIMEOUT_S = 10
|
|
158
|
+
KEYCHAIN_HOLD_S = 21600
|
|
159
|
+
FORGOTTEN_KEEP_S = 90 * 86400
|
|
160
|
+
MAX_DOC_BYTES = 4 << 20
|
|
161
|
+
MAX_BODY_BYTES = 1 << 20
|
|
162
|
+
ENSURE_BUDGET_S = 5 # the shim's hot path: a backstop, never the normal case
|
|
163
|
+
ENSURE_SECURITY_TIMEOUT_S = 2
|
|
164
|
+
ENSURE_LOCK_WAIT_S = 0.5 # a busy lock costs this launch the merge, never a second
|
|
165
|
+
SYNC_BUDGET_S = 240
|
|
166
|
+
REFRESH_MIN_LEFT_S = 60 # never start a refresh the budget could cut in half
|
|
167
|
+
ACCT_ID = re.compile(r'^acct-\d{2,4}$')
|
|
168
|
+
OAUTH_ERROR = re.compile(r'^[a-z_]{1,64}$')
|
|
169
|
+
#: outcomes that leave an account to be retried at its next launch (no stamp)
|
|
170
|
+
TRANSIENT = frozenset({'busy', 'deferred', 'failed', 'error', 'parked'})
|
|
171
|
+
#: outcomes `sync` and `forget` report as "not reached"
|
|
172
|
+
UNREACHED = ('busy', 'deferred', 'failed', 'error', 'mismatch', 'held', 'corrupt',
|
|
173
|
+
'unreadable', 'parked', 'locked')
|
|
174
|
+
USER_AGENT = 'claude-multiacc-mcp-oauth'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Budget(BaseException):
|
|
178
|
+
"""The run's time budget ran out (a BaseException, so no `except Exception` eats it)."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _flag(name):
|
|
182
|
+
return os.environ.get(name, '1').strip().lower() not in ('0', 'false', 'no', 'off')
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def enabled():
|
|
186
|
+
return _flag('CLAUDE_MULTIACC_MCP_OAUTH')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def refresh_enabled():
|
|
190
|
+
return _flag('CLAUDE_MULTIACC_MCP_OAUTH_REFRESH')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def refresh_ahead():
|
|
194
|
+
try:
|
|
195
|
+
value = int(os.environ.get('CLAUDE_MULTIACC_MCP_OAUTH_REFRESH_AHEAD', REFRESH_AHEAD_S))
|
|
196
|
+
except ValueError:
|
|
197
|
+
return REFRESH_AHEAD_S
|
|
198
|
+
return max(0, min(value, 7 * 86400))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------- io
|
|
202
|
+
|
|
203
|
+
def _now_iso(t=None):
|
|
204
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(time.time() if t is None else t))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _epoch(iso):
|
|
208
|
+
"""The epoch an _now_iso() stamp names, or None."""
|
|
209
|
+
try:
|
|
210
|
+
return calendar.timegm(time.strptime(iso, '%Y-%m-%dT%H:%M:%SZ'))
|
|
211
|
+
except (TypeError, ValueError, OverflowError):
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _log(root, message):
|
|
216
|
+
try:
|
|
217
|
+
with open(os.path.join(root, 'ops.log'), 'a', encoding='utf-8') as handle:
|
|
218
|
+
handle.write(f'{_now_iso()} mcp-oauth {message}\n')
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _scratch(directory, name, data, mode=0o600):
|
|
224
|
+
"""A fsynced temp file beside ``name`` holding ``data`` (bytes): its path."""
|
|
225
|
+
handle, path = tempfile.mkstemp(prefix=f".{name.lstrip('.')}.", suffix='.tmp', dir=directory)
|
|
226
|
+
try:
|
|
227
|
+
with os.fdopen(handle, 'wb') as stream:
|
|
228
|
+
os.fchmod(stream.fileno(), mode)
|
|
229
|
+
stream.write(data)
|
|
230
|
+
stream.flush()
|
|
231
|
+
os.fsync(stream.fileno())
|
|
232
|
+
except BaseException:
|
|
233
|
+
with contextlib.suppress(OSError):
|
|
234
|
+
os.unlink(path)
|
|
235
|
+
raise
|
|
236
|
+
return path
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _write_atomic(path, text, mode=0o600):
|
|
240
|
+
scratch = _scratch(os.path.dirname(path) or '.', os.path.basename(path),
|
|
241
|
+
text.encode('utf-8'), mode)
|
|
242
|
+
try:
|
|
243
|
+
os.replace(scratch, path)
|
|
244
|
+
except BaseException:
|
|
245
|
+
with contextlib.suppress(OSError):
|
|
246
|
+
os.unlink(scratch)
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _refuse_constant(name):
|
|
251
|
+
raise ValueError(f'{name} is not JSON')
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse_doc(raw):
|
|
255
|
+
"""The credential document in ``raw`` (bytes), or None when it is not a JSON object.
|
|
256
|
+
NaN/Infinity are refused: the client's JSON.parse refuses them too."""
|
|
257
|
+
if raw is None or len(raw) > MAX_DOC_BYTES:
|
|
258
|
+
return None
|
|
259
|
+
try:
|
|
260
|
+
doc = json.loads(raw.decode('utf-8'), parse_constant=_refuse_constant)
|
|
261
|
+
except ValueError:
|
|
262
|
+
return None
|
|
263
|
+
return doc if isinstance(doc, dict) else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _mcp_map(doc):
|
|
267
|
+
"""The document's `mcpOAuth` map ({} when absent), or None when it holds something
|
|
268
|
+
else — a store this module does not understand is never written."""
|
|
269
|
+
value = doc.get('mcpOAuth', {})
|
|
270
|
+
return value if isinstance(value, dict) else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_bytes(path):
|
|
274
|
+
"""('ok', bytes) / ('absent', None) / ('error', None)."""
|
|
275
|
+
try:
|
|
276
|
+
with open(path, 'rb') as handle:
|
|
277
|
+
return 'ok', handle.read(MAX_DOC_BYTES + 1)
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return 'absent', None
|
|
280
|
+
except OSError:
|
|
281
|
+
return 'error', None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------- entries
|
|
285
|
+
|
|
286
|
+
def is_login(entry):
|
|
287
|
+
"""An MCP entry that can authenticate: an access or a refresh token is present."""
|
|
288
|
+
return isinstance(entry, dict) and any(
|
|
289
|
+
isinstance(entry.get(name), str) and entry.get(name)
|
|
290
|
+
for name in ('accessToken', 'refreshToken'))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _number(value):
|
|
294
|
+
if isinstance(value, bool):
|
|
295
|
+
return None
|
|
296
|
+
if isinstance(value, str):
|
|
297
|
+
try:
|
|
298
|
+
value = float(value)
|
|
299
|
+
except ValueError:
|
|
300
|
+
return None
|
|
301
|
+
if isinstance(value, (int, float)) and value == value and abs(value) != float('inf'):
|
|
302
|
+
return value
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _expires_ms(entry):
|
|
307
|
+
return _number(entry.get('expiresAt')) if isinstance(entry, dict) else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _has_refresh(entry):
|
|
311
|
+
token = entry.get('refreshToken') if isinstance(entry, dict) else None
|
|
312
|
+
return isinstance(token, str) and bool(token)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _rank(entry):
|
|
316
|
+
expires = _expires_ms(entry)
|
|
317
|
+
return (expires if expires is not None else -1, _has_refresh(entry))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _token(entry, name):
|
|
321
|
+
value = entry.get(name) if isinstance(entry, dict) else None
|
|
322
|
+
return value if isinstance(value, str) and value else None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _same_tokens(a, b):
|
|
326
|
+
"""Both logins carry exactly the same tokens: one save, wherever it sits (the store's
|
|
327
|
+
copy may add the expiry the client's lacked)."""
|
|
328
|
+
return is_login(a) and is_login(b) and all(
|
|
329
|
+
_token(a, name) == _token(b, name) for name in ('accessToken', 'refreshToken'))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _same_grant(a, b):
|
|
333
|
+
if not isinstance(a, dict) or not isinstance(b, dict):
|
|
334
|
+
return False
|
|
335
|
+
if _has_refresh(a) and _has_refresh(b):
|
|
336
|
+
return a['refreshToken'] == b['refreshToken']
|
|
337
|
+
token = a.get('accessToken')
|
|
338
|
+
return isinstance(token, str) and bool(token) and token == b.get('accessToken')
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _digests(entry):
|
|
342
|
+
"""Short sha256 digests of an entry's tokens: how the store remembers a token it
|
|
343
|
+
retired or replaced without keeping it."""
|
|
344
|
+
out = set()
|
|
345
|
+
for name in ('accessToken', 'refreshToken'):
|
|
346
|
+
value = entry.get(name) if isinstance(entry, dict) else None
|
|
347
|
+
if isinstance(value, str) and value:
|
|
348
|
+
out.add(hashlib.sha256(value.encode('utf-8')).hexdigest()[:24])
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _superseded(rec, entry):
|
|
353
|
+
"""``entry`` carries a token one of the pool's own refreshes replaced: an older copy
|
|
354
|
+
of ``rec``'s login, whatever its expiry says."""
|
|
355
|
+
return bool(rec) and bool(_digests(entry) & set(rec.get('superseded') or ()))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _provably_older(have, entry):
|
|
359
|
+
"""``have`` is a login older than ``entry`` by their expiries — never when one of
|
|
360
|
+
them has none: a login saved without an expiry may well be the newest."""
|
|
361
|
+
return _expires_ms(have) is not None and _expires_ms(entry) is not None \
|
|
362
|
+
and _rank(entry) > _rank(have)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _server_name(key, entry):
|
|
366
|
+
name = entry.get('serverName') if isinstance(entry, dict) else None
|
|
367
|
+
return name if isinstance(name, str) and name else key.split('|', 1)[0]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _sharing_off(rec, now):
|
|
371
|
+
"""Why the pool must not share this login now, or None. A login shared into many
|
|
372
|
+
accounts needs ONE refresher — the pool, ahead of every client — or the clients,
|
|
373
|
+
each under its own config dir's lock, refresh one rotating grant at once."""
|
|
374
|
+
if rec.get('needs_reauth'):
|
|
375
|
+
return 'its authorization server refused the refresh token'
|
|
376
|
+
expires = _expires_ms(rec['entry'])
|
|
377
|
+
if expires is None:
|
|
378
|
+
return 'no expiry to refresh it ahead of'
|
|
379
|
+
if expires / 1000.0 - now <= CLIENT_REFRESH_S:
|
|
380
|
+
return 'expiring: the clients refresh it themselves now'
|
|
381
|
+
backoff = rec.get('refresh') or {}
|
|
382
|
+
if backoff.get('failures') and (_number(backoff.get('next_at')) or 0) + SYNC_PERIOD_S \
|
|
383
|
+
>= expires / 1000.0 - CLIENT_REFRESH_S:
|
|
384
|
+
return 'its refresh keeps failing and the next try comes too late'
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------------ store
|
|
389
|
+
|
|
390
|
+
class Store:
|
|
391
|
+
"""ROOT/mcp-oauth.json: {"version": 1, "entries": {key: {"entry": <the client's
|
|
392
|
+
entry, its expiresAt stamped when it had none>, "source": acct, "store":
|
|
393
|
+
file|keychain, "harvested_at", "refreshed_at"?, "lifetime_s"?, "expiry_assumed"?,
|
|
394
|
+
"refresh"?: {failures, next_at, last_error, last_attempt_at}, "deferred"?,
|
|
395
|
+
"needs_reauth"?, "holders"?: ["acct-NN:file"|"acct-NN:keychain", …] (the stores
|
|
396
|
+
known to hold this very login), "superseded"?: [token digests its refreshes
|
|
397
|
+
replaced]}}, "forgotten": {key: {"at", "digests", "reason"}}, "holds": {acct:
|
|
398
|
+
{"until", "reason", "at"}}, "refresher": {"at", "refresh"} (the last sync's beat)}.
|
|
399
|
+
Its mtime is the shim's "the store changed" clock (see :meth:`save`)."""
|
|
400
|
+
|
|
401
|
+
def __init__(self, root):
|
|
402
|
+
self.root = root
|
|
403
|
+
self.path = os.path.join(root, STORE_FILE)
|
|
404
|
+
self.doc = {'version': 1, 'entries': {}, 'forgotten': {}, 'holds': {}}
|
|
405
|
+
self.exists = False
|
|
406
|
+
self.dirty = False
|
|
407
|
+
self.clock = 0
|
|
408
|
+
self.signature = None
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def entries(self):
|
|
412
|
+
return self.doc['entries']
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def forgotten(self):
|
|
416
|
+
return self.doc['forgotten']
|
|
417
|
+
|
|
418
|
+
def load(self, readonly=False):
|
|
419
|
+
try:
|
|
420
|
+
st = os.stat(self.path)
|
|
421
|
+
with open(self.path, encoding='utf-8') as handle:
|
|
422
|
+
doc = json.load(handle)
|
|
423
|
+
except FileNotFoundError:
|
|
424
|
+
return self
|
|
425
|
+
except ValueError:
|
|
426
|
+
if not readonly:
|
|
427
|
+
# A store this module cannot read is a cache it can rebuild from the
|
|
428
|
+
# accounts: keep the bad copy for a human and start over.
|
|
429
|
+
with contextlib.suppress(OSError):
|
|
430
|
+
os.replace(self.path, self.path + '.corrupt')
|
|
431
|
+
_log(self.root, f'{STORE_FILE} was not JSON; moved aside to {STORE_FILE}.corrupt')
|
|
432
|
+
self.dirty = True
|
|
433
|
+
return self
|
|
434
|
+
self.exists, self.clock = True, int(st.st_mtime)
|
|
435
|
+
if not isinstance(doc, dict):
|
|
436
|
+
self.dirty = True
|
|
437
|
+
return self
|
|
438
|
+
entries = doc.get('entries')
|
|
439
|
+
for key, rec in (entries.items() if isinstance(entries, dict) else ()):
|
|
440
|
+
if isinstance(key, str) and isinstance(rec, dict) and is_login(rec.get('entry')):
|
|
441
|
+
self.entries[key] = rec
|
|
442
|
+
else:
|
|
443
|
+
self.dirty = True
|
|
444
|
+
for name in ('forgotten', 'holds'):
|
|
445
|
+
value = doc.get(name)
|
|
446
|
+
if isinstance(value, dict):
|
|
447
|
+
self.doc[name] = {k: v for k, v in value.items()
|
|
448
|
+
if isinstance(k, str) and isinstance(v, dict)}
|
|
449
|
+
if isinstance(doc.get('refresher'), dict):
|
|
450
|
+
self.doc['refresher'] = doc['refresher']
|
|
451
|
+
self.signature = self._signature()
|
|
452
|
+
return self
|
|
453
|
+
|
|
454
|
+
def _signature(self):
|
|
455
|
+
"""What the accounts are given: the logins and the retired grants. Only its change
|
|
456
|
+
moves the store's clock — holders, backoff, holds and the refresher's beat are
|
|
457
|
+
bookkeeping no account has to act on."""
|
|
458
|
+
doc = {'entries': {k: r.get('entry') for k, r in self.entries.items()},
|
|
459
|
+
'forgotten': {k: sorted(v.get('digests') or ()) for k, v in self.forgotten.items()}}
|
|
460
|
+
return hashlib.sha256(json.dumps(doc, sort_keys=True).encode('utf-8')).hexdigest()
|
|
461
|
+
|
|
462
|
+
def forgotten_match(self, key, entry):
|
|
463
|
+
tomb = self.forgotten.get(key)
|
|
464
|
+
return bool(tomb) and bool(_digests(entry) & set(tomb.get('digests') or ()))
|
|
465
|
+
|
|
466
|
+
def held(self, account, now):
|
|
467
|
+
hold = self.doc['holds'].get(account)
|
|
468
|
+
return bool(hold) and (_number(hold.get('until')) or 0) > now
|
|
469
|
+
|
|
470
|
+
def hold(self, account, reason, now):
|
|
471
|
+
self.doc['holds'][account] = {'until': int(now + KEYCHAIN_HOLD_S), 'reason': reason,
|
|
472
|
+
'at': _now_iso(now)}
|
|
473
|
+
self.dirty = True
|
|
474
|
+
|
|
475
|
+
def beat(self, now):
|
|
476
|
+
"""`sync` is running: the refresher record that `ensure` shares behind."""
|
|
477
|
+
self.doc['refresher'] = {'at': int(now), 'refresh': refresh_enabled()}
|
|
478
|
+
self.dirty = True
|
|
479
|
+
|
|
480
|
+
def refresher_ok(self, now):
|
|
481
|
+
"""A sync with refresh on ran within REFRESHER_FRESH_S: every due login will be
|
|
482
|
+
refreshed ahead of the clients, so `ensure` may share."""
|
|
483
|
+
beat = self.doc.get('refresher') or {}
|
|
484
|
+
at = _number(beat.get('at'))
|
|
485
|
+
return beat.get('refresh') is True and at is not None \
|
|
486
|
+
and -CLIENT_REFRESH_S <= now - at < REFRESHER_FRESH_S
|
|
487
|
+
|
|
488
|
+
def retire(self, key, reason, now, digests=()):
|
|
489
|
+
"""Drop ``key``'s login and remember its tokens — and the older ones its
|
|
490
|
+
refreshes replaced — so no copy of it is ever shared again, and every copy is
|
|
491
|
+
cleared wherever a later run reaches it."""
|
|
492
|
+
rec = self.entries.pop(key, None)
|
|
493
|
+
digests = set(digests)
|
|
494
|
+
if rec:
|
|
495
|
+
digests |= _digests(rec['entry']) | set(rec.get('superseded') or ())
|
|
496
|
+
old = self.forgotten.get(key) or {}
|
|
497
|
+
self.forgotten[key] = {'at': _now_iso(now), 'reason': reason,
|
|
498
|
+
'digests': sorted(digests | set(old.get('digests') or ()))}
|
|
499
|
+
self.dirty = True
|
|
500
|
+
|
|
501
|
+
def save(self):
|
|
502
|
+
"""Write the store (0600, atomic). Its mtime is the shim's "the store changed"
|
|
503
|
+
clock: it moves — at least one whole second past the previous one, so a
|
|
504
|
+
one-second `test -nt` against the accounts' stamps still sees every change — only
|
|
505
|
+
when what the accounts are given changed; a bookkeeping save keeps it."""
|
|
506
|
+
now = time.time()
|
|
507
|
+
self.doc['holds'] = {k: v for k, v in self.doc['holds'].items()
|
|
508
|
+
if (_number(v.get('until')) or 0) > now}
|
|
509
|
+
self.doc['forgotten'] = {k: v for k, v in self.forgotten.items()
|
|
510
|
+
if (now - (_epoch(v.get('at')) or now)) < FORGOTTEN_KEEP_S}
|
|
511
|
+
self.doc['version'] = 1
|
|
512
|
+
self.doc['updated_at'] = _now_iso(now)
|
|
513
|
+
signature = self._signature()
|
|
514
|
+
_write_atomic(self.path, json.dumps(self.doc, indent=2) + '\n')
|
|
515
|
+
if self.exists and self.clock and signature == self.signature:
|
|
516
|
+
clock = self.clock
|
|
517
|
+
else:
|
|
518
|
+
clock = max(int(now), self.clock + 1)
|
|
519
|
+
os.utime(self.path, (clock, clock))
|
|
520
|
+
self.exists, self.dirty, self.clock, self.signature = True, False, clock, signature
|
|
521
|
+
return clock
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class PoolLock:
|
|
525
|
+
"""flock on ROOT/.locks/mcp-oauth.lock: one writer of the store at a time — and with
|
|
526
|
+
it ONE refresher. Released when the process exits, however it exits."""
|
|
527
|
+
|
|
528
|
+
def __init__(self, root, wait):
|
|
529
|
+
self.path = os.path.join(root, POOL_LOCK)
|
|
530
|
+
self.wait = wait
|
|
531
|
+
self.fd = None
|
|
532
|
+
self.held = False
|
|
533
|
+
|
|
534
|
+
def __enter__(self):
|
|
535
|
+
try:
|
|
536
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
537
|
+
self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
538
|
+
except OSError:
|
|
539
|
+
return self
|
|
540
|
+
deadline = time.monotonic() + self.wait
|
|
541
|
+
while True:
|
|
542
|
+
try:
|
|
543
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
544
|
+
self.held = True
|
|
545
|
+
return self
|
|
546
|
+
except OSError as exc:
|
|
547
|
+
if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES) \
|
|
548
|
+
or time.monotonic() >= deadline:
|
|
549
|
+
return self
|
|
550
|
+
time.sleep(0.05)
|
|
551
|
+
|
|
552
|
+
def __exit__(self, *_exc):
|
|
553
|
+
if self.fd is not None:
|
|
554
|
+
with contextlib.suppress(OSError):
|
|
555
|
+
os.close(self.fd)
|
|
556
|
+
self.fd = None
|
|
557
|
+
self.held = False
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class ClientLock:
|
|
562
|
+
"""The client's own write lock, in proper-lockfile's layout: the DIRECTORY
|
|
563
|
+
<dir>/.storage-write.lock, stale once its mtime is CLIENT_LOCK_STALE_S old (a live
|
|
564
|
+
holder refreshes it well inside that). A held lock is waited on for ``wait`` seconds,
|
|
565
|
+
then the account is left for the next run; a stale one is taken over."""
|
|
566
|
+
|
|
567
|
+
def __init__(self, account_dir, wait):
|
|
568
|
+
self.path = os.path.join(account_dir, CLIENT_LOCK)
|
|
569
|
+
self.wait = wait
|
|
570
|
+
self.held = False
|
|
571
|
+
|
|
572
|
+
def acquire(self):
|
|
573
|
+
deadline = time.monotonic() + self.wait
|
|
574
|
+
while True:
|
|
575
|
+
try:
|
|
576
|
+
os.mkdir(self.path)
|
|
577
|
+
self.held = True
|
|
578
|
+
return True
|
|
579
|
+
except FileExistsError:
|
|
580
|
+
pass
|
|
581
|
+
except OSError:
|
|
582
|
+
return False
|
|
583
|
+
try:
|
|
584
|
+
stale = time.time() - os.stat(self.path).st_mtime > CLIENT_LOCK_STALE_S
|
|
585
|
+
except FileNotFoundError:
|
|
586
|
+
continue # released just now: take it
|
|
587
|
+
except OSError:
|
|
588
|
+
return False
|
|
589
|
+
if stale:
|
|
590
|
+
try:
|
|
591
|
+
os.rmdir(self.path) # its holder is gone
|
|
592
|
+
continue
|
|
593
|
+
except FileNotFoundError:
|
|
594
|
+
continue
|
|
595
|
+
except OSError:
|
|
596
|
+
pass # cannot clear it: wait like for any held lock
|
|
597
|
+
if time.monotonic() >= deadline:
|
|
598
|
+
return False
|
|
599
|
+
time.sleep(0.05)
|
|
600
|
+
|
|
601
|
+
def touch(self):
|
|
602
|
+
if self.held:
|
|
603
|
+
with contextlib.suppress(OSError):
|
|
604
|
+
os.utime(self.path)
|
|
605
|
+
|
|
606
|
+
def release(self):
|
|
607
|
+
if self.held:
|
|
608
|
+
with contextlib.suppress(OSError):
|
|
609
|
+
os.rmdir(self.path)
|
|
610
|
+
self.held = False
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@contextlib.contextmanager
|
|
614
|
+
def _budget(seconds):
|
|
615
|
+
"""Bound the whole run (the shim waits for `ensure`): past ``seconds`` it unwinds —
|
|
616
|
+
locks released by their own `finally` — and the caller exits 0."""
|
|
617
|
+
usable = seconds and hasattr(signal, 'setitimer') \
|
|
618
|
+
and threading.current_thread() is threading.main_thread()
|
|
619
|
+
if not usable:
|
|
620
|
+
yield
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
def on_alarm(_signum, _frame):
|
|
624
|
+
raise _Budget()
|
|
625
|
+
|
|
626
|
+
previous = signal.signal(signal.SIGALRM, on_alarm)
|
|
627
|
+
signal.setitimer(signal.ITIMER_REAL, seconds)
|
|
628
|
+
try:
|
|
629
|
+
yield
|
|
630
|
+
finally:
|
|
631
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
632
|
+
signal.signal(signal.SIGALRM, previous)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _limits_running(root):
|
|
636
|
+
"""A `claude-accounts limits` pass holds its lock: it may be rotating a login right
|
|
637
|
+
now, and it writes without the client's lock."""
|
|
638
|
+
try:
|
|
639
|
+
return time.time() - os.stat(os.path.join(root, LIMITS_LOCK)).st_mtime \
|
|
640
|
+
< LIMITS_LOCK_FRESH_S
|
|
641
|
+
except OSError:
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ---------------------------------------------------------------------- accounts
|
|
646
|
+
|
|
647
|
+
def account_dirs(root):
|
|
648
|
+
"""[(id, dir)] of every manifest account whose dir exists, in id order — the accounts
|
|
649
|
+
the shim can pick. An unreadable manifest is no accounts (fail-open)."""
|
|
650
|
+
try:
|
|
651
|
+
with open(os.path.join(root, 'accounts.json'), encoding='utf-8') as handle:
|
|
652
|
+
doc = json.load(handle)
|
|
653
|
+
except (OSError, ValueError):
|
|
654
|
+
return []
|
|
655
|
+
listed = doc.get('accounts') if isinstance(doc, dict) else None
|
|
656
|
+
out = set()
|
|
657
|
+
for account in listed if isinstance(listed, list) else ():
|
|
658
|
+
aid = account.get('id') if isinstance(account, dict) else None
|
|
659
|
+
if isinstance(aid, str) and ACCT_ID.match(aid) and os.path.isdir(os.path.join(root, aid)):
|
|
660
|
+
out.add((aid, os.path.join(root, aid)))
|
|
661
|
+
return sorted(out)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _read_file_store(account_dir):
|
|
665
|
+
status, raw = _read_bytes(os.path.join(account_dir, CREDENTIALS_FILE))
|
|
666
|
+
if status == 'absent':
|
|
667
|
+
return {'state': 'absent', 'doc': None, 'map': {}}
|
|
668
|
+
if status != 'ok':
|
|
669
|
+
return {'state': 'unreadable', 'doc': None, 'map': {}}
|
|
670
|
+
doc = _parse_doc(raw)
|
|
671
|
+
mapping = _mcp_map(doc) if doc is not None else None
|
|
672
|
+
if mapping is None:
|
|
673
|
+
# `mentions`: what the shim's grep sees in a file no JSON parser can read
|
|
674
|
+
return {'state': 'corrupt', 'doc': None, 'map': {}, 'mentions': b'"mcpOAuth"' in raw}
|
|
675
|
+
return {'state': 'ok', 'doc': doc, 'map': mapping}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _read_keychain_store(keychain_dir, timeout):
|
|
679
|
+
state, doc = keychain.read_doc(keychain_dir, timeout=timeout)
|
|
680
|
+
if state != 'present':
|
|
681
|
+
return {'state': state, 'doc': None, 'map': {}}
|
|
682
|
+
mapping = _mcp_map(doc)
|
|
683
|
+
if mapping is None:
|
|
684
|
+
return {'state': 'corrupt', 'doc': None, 'map': {}}
|
|
685
|
+
return {'state': 'ok', 'doc': doc, 'map': mapping}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _harvest(root, accounts, keychain_dirs, timeout):
|
|
689
|
+
"""({id: {'file': seen, 'keychain': seen or None}}, {path: mtime_ns}). The mtimes are
|
|
690
|
+
of EVERY acct-*/.credentials.json (the shim's glob, retired dirs included) and are
|
|
691
|
+
taken BEFORE anything is read: a write that lands after the stat can only make the
|
|
692
|
+
next gate fire again, never hide."""
|
|
693
|
+
stats = {}
|
|
694
|
+
for path in glob.glob(os.path.join(glob.escape(root), 'acct-*', CREDENTIALS_FILE)):
|
|
695
|
+
with contextlib.suppress(OSError):
|
|
696
|
+
stats[path] = os.stat(path).st_mtime_ns
|
|
697
|
+
seen = {}
|
|
698
|
+
for aid, account_dir in accounts:
|
|
699
|
+
kdir = keychain_dirs.get(aid)
|
|
700
|
+
seen[aid] = {'file': _read_file_store(account_dir),
|
|
701
|
+
'keychain': _read_keychain_store(kdir, timeout) if kdir else None}
|
|
702
|
+
return seen, stats
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _in_play(seen):
|
|
706
|
+
"""Some account's client has written MCP OAuth state: the store is worth keeping."""
|
|
707
|
+
return any(s and s['state'] == 'ok' and 'mcpOAuth' in s['doc']
|
|
708
|
+
for stores in seen.values() for s in stores.values())
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _choose(store, seen, now):
|
|
712
|
+
"""Fold the harvested logins into the store. Returns the keys whose winner changed."""
|
|
713
|
+
candidates = {}
|
|
714
|
+
for aid in sorted(seen):
|
|
715
|
+
for kind in ('file', 'keychain'):
|
|
716
|
+
s = seen[aid].get(kind)
|
|
717
|
+
if not s or s['state'] != 'ok':
|
|
718
|
+
continue
|
|
719
|
+
for key, entry in s['map'].items():
|
|
720
|
+
if isinstance(key, str) and key and is_login(entry) \
|
|
721
|
+
and not store.forgotten_match(key, entry) \
|
|
722
|
+
and not _superseded(store.entries.get(key), entry):
|
|
723
|
+
candidates.setdefault(key, []).append((entry, aid, kind))
|
|
724
|
+
# A login saved without an expiry (a token answer with no expires_in) is ranked — and
|
|
725
|
+
# kept — as if it lived ASSUMED_LIFETIME_S from now: never as older than every copy
|
|
726
|
+
# that has one, which put the old grant back over a rotated one.
|
|
727
|
+
stamp = int(now * 1000 + ASSUMED_LIFETIME_S * 1000)
|
|
728
|
+
changed = []
|
|
729
|
+
for key in sorted(candidates):
|
|
730
|
+
rec = store.entries.get(key)
|
|
731
|
+
current = rec['entry'] if rec else None
|
|
732
|
+
known_bad = bool(rec and rec.get('needs_reauth'))
|
|
733
|
+
|
|
734
|
+
def order(item, current=current, known_bad=known_bad):
|
|
735
|
+
entry, _aid, _kind, is_current, _assumed = item
|
|
736
|
+
refused = known_bad and _same_grant(entry, current)
|
|
737
|
+
return (not refused, _rank(entry), is_current)
|
|
738
|
+
|
|
739
|
+
items = []
|
|
740
|
+
for entry, aid, kind in candidates[key]:
|
|
741
|
+
if _same_tokens(entry, current):
|
|
742
|
+
continue # the store's own login, wherever it sits
|
|
743
|
+
assumed = _expires_ms(entry) is None
|
|
744
|
+
items.append((dict(entry, expiresAt=stamp) if assumed else entry,
|
|
745
|
+
aid, kind, False, assumed))
|
|
746
|
+
if current is not None:
|
|
747
|
+
items.append((current, None, None, True, False))
|
|
748
|
+
best = max(items, key=order) # the first of equals: lowest account id
|
|
749
|
+
if best[3]:
|
|
750
|
+
continue
|
|
751
|
+
new = {'entry': copy.deepcopy(best[0]), 'source': best[1], 'store': best[2],
|
|
752
|
+
'harvested_at': _now_iso(now)}
|
|
753
|
+
if best[4]:
|
|
754
|
+
new['expiry_assumed'] = True
|
|
755
|
+
if rec and rec.get('superseded'):
|
|
756
|
+
new['superseded'] = rec['superseded'] # those tokens stay dead
|
|
757
|
+
store.entries[key] = new
|
|
758
|
+
store.dirty = True
|
|
759
|
+
changed.append(key)
|
|
760
|
+
return changed
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _note_holders(store, seen, now):
|
|
764
|
+
"""Bookkeeping after a harvest: the stores holding each record's very login. A store
|
|
765
|
+
the record knew as a holder that now holds no login for the key — gone from a map it
|
|
766
|
+
still has, or blanked — was cleared by its own client: a refused refresh, /mcp "Clear
|
|
767
|
+
authentication", `claude mcp login`, each revoking or refused at the server first. So
|
|
768
|
+
that login is retired everywhere, as `forget` does — merging it back would only hand
|
|
769
|
+
every account a dead grant. Returns the retired keys."""
|
|
770
|
+
retired = []
|
|
771
|
+
for key in sorted(store.entries):
|
|
772
|
+
rec = store.entries[key]
|
|
773
|
+
before = set(rec.get('holders') or ())
|
|
774
|
+
holders = {h for h in before if h.split(':', 1)[0] in seen}
|
|
775
|
+
dropped = None
|
|
776
|
+
for aid in sorted(seen):
|
|
777
|
+
for kind in ('file', 'keychain'):
|
|
778
|
+
s = seen[aid].get(kind)
|
|
779
|
+
name = f'{aid}:{kind}'
|
|
780
|
+
if s is None or s['state'] not in ('ok', 'absent'):
|
|
781
|
+
continue # cannot tell from here: keep what we knew
|
|
782
|
+
have = s['map'].get(key)
|
|
783
|
+
if _same_tokens(have, rec['entry']):
|
|
784
|
+
holders.add(name)
|
|
785
|
+
elif name in holders:
|
|
786
|
+
holders.discard(name)
|
|
787
|
+
# a store that is gone, or rewritten without any MCP state, says
|
|
788
|
+
# nothing about the login
|
|
789
|
+
if s['state'] == 'ok' and 'mcpOAuth' in s['doc'] and not is_login(have):
|
|
790
|
+
dropped = dropped or name
|
|
791
|
+
if dropped:
|
|
792
|
+
store.retire(key, f'cleared in {dropped.replace(":", " ")}', now)
|
|
793
|
+
_log(store.root, f'{key}: the login was cleared in {dropped.replace(":", " ")} '
|
|
794
|
+
'(revoked or refused there) — retired in every account')
|
|
795
|
+
retired.append(key)
|
|
796
|
+
elif holders != before:
|
|
797
|
+
rec['holders'] = sorted(holders)
|
|
798
|
+
store.dirty = True
|
|
799
|
+
return retired
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _hold(store, aid, res):
|
|
803
|
+
"""After a merge: the stores now holding a record's login."""
|
|
804
|
+
for kind, keys in res['held'].items():
|
|
805
|
+
for key in keys:
|
|
806
|
+
rec = store.entries.get(key)
|
|
807
|
+
if rec is not None:
|
|
808
|
+
holders = set(rec.get('holders') or ()) | {f'{aid}:{kind}'}
|
|
809
|
+
if holders != set(rec.get('holders') or ()):
|
|
810
|
+
rec['holders'] = sorted(holders)
|
|
811
|
+
store.dirty = True
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _plan(store, now, sharing):
|
|
815
|
+
"""account map -> {key: entry to write, or None to drop}: what this account store
|
|
816
|
+
needs from the store. Retired logins are always dropped; logins are shared only with
|
|
817
|
+
``sharing`` (this process may: the refresher runs) and while :func:`_sharing_off`
|
|
818
|
+
has no objection."""
|
|
819
|
+
def plan(mapping):
|
|
820
|
+
out = {}
|
|
821
|
+
for key, rec in store.entries.items():
|
|
822
|
+
entry = rec['entry']
|
|
823
|
+
have = mapping.get(key)
|
|
824
|
+
if not sharing or _sharing_off(rec, now) or _same_tokens(have, entry):
|
|
825
|
+
continue
|
|
826
|
+
if not is_login(have) or store.forgotten_match(key, have) \
|
|
827
|
+
or _superseded(rec, have) or _provably_older(have, entry):
|
|
828
|
+
out[key] = entry
|
|
829
|
+
for key in store.forgotten:
|
|
830
|
+
have = mapping.get(key)
|
|
831
|
+
if key not in out and is_login(have) and store.forgotten_match(key, have):
|
|
832
|
+
out[key] = None
|
|
833
|
+
return out
|
|
834
|
+
return plan
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _apply(doc, mapping, changes):
|
|
838
|
+
"""``doc`` with ``changes`` merged into its `mcpOAuth` map; every other key — the
|
|
839
|
+
Claude login above all — exactly as it was, in its place."""
|
|
840
|
+
merged = dict(mapping)
|
|
841
|
+
for key, entry in changes.items():
|
|
842
|
+
if entry is None:
|
|
843
|
+
merged.pop(key, None)
|
|
844
|
+
else:
|
|
845
|
+
merged[key] = copy.deepcopy(entry)
|
|
846
|
+
out = dict(doc)
|
|
847
|
+
out['mcpOAuth'] = merged
|
|
848
|
+
return out
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _landed(mapping, changes):
|
|
852
|
+
return all((key not in mapping) if entry is None else (mapping.get(key) == entry)
|
|
853
|
+
for key, entry in changes.items())
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _may_create(account_dir):
|
|
857
|
+
"""Whether a .credentials.json holding MCP state alone may be CREATED here: only for an
|
|
858
|
+
account that runs on its portable token (a non-empty server.token) — that client has
|
|
859
|
+
no other store to read MCP logins from in a session that cannot open the Keychain,
|
|
860
|
+
and writes this very file itself once it meets an OAuth server — never one parked
|
|
861
|
+
(`.expired`), an adopted dir (a symlink: ~/.claude itself), or a name that is taken,
|
|
862
|
+
a dangling link included. Every reader counts a document as a login only when it
|
|
863
|
+
carries `claudeAiOauth` (bin/claude file_login, lib/audit.py file_has_login), so the
|
|
864
|
+
file costs the token nothing; an account with a login has a store already, and one
|
|
865
|
+
with no credential at all gains nothing (`adopt` refuses a dir holding the file)."""
|
|
866
|
+
path = os.path.join(account_dir, CREDENTIALS_FILE)
|
|
867
|
+
try:
|
|
868
|
+
if os.path.islink(account_dir) or os.path.lexists(path) \
|
|
869
|
+
or os.path.lexists(os.path.join(account_dir, '.expired')):
|
|
870
|
+
return False
|
|
871
|
+
return os.path.getsize(os.path.join(account_dir, 'server.token')) > 0
|
|
872
|
+
except OSError:
|
|
873
|
+
return False
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _create_file(target, plan):
|
|
877
|
+
"""The file a token account's client would write (MCP state alone), 0600, appearing
|
|
878
|
+
whole and only if the name is still free: (outcome, changes) — ('taken', {}) when a
|
|
879
|
+
client wrote one meanwhile (the caller merges into that instead)."""
|
|
880
|
+
changes = {k: v for k, v in plan({}).items() if v is not None}
|
|
881
|
+
if not changes:
|
|
882
|
+
return 'absent', {}
|
|
883
|
+
text = json.dumps(_apply({}, {}, changes), ensure_ascii=False, separators=(',', ':'))
|
|
884
|
+
scratch = _scratch(os.path.dirname(target), os.path.basename(target), text.encode('utf-8'))
|
|
885
|
+
try:
|
|
886
|
+
os.link(scratch, target)
|
|
887
|
+
except FileExistsError:
|
|
888
|
+
return 'taken', {}
|
|
889
|
+
except OSError:
|
|
890
|
+
return 'error', {}
|
|
891
|
+
finally:
|
|
892
|
+
with contextlib.suppress(OSError):
|
|
893
|
+
os.unlink(scratch)
|
|
894
|
+
return 'created', changes
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _merge_file(root, account_dir, plan, create=False):
|
|
898
|
+
"""Under the client lock: (outcome, changes written). Into a file that exists; or,
|
|
899
|
+
with ``create`` (_may_create), a new one holding MCP state alone. Re-reads, merges,
|
|
900
|
+
writes a temp beside the file, checks the file did not change under it, replaces it,
|
|
901
|
+
and puts the old mtime back — the merge is not a login. A file holding a login is left
|
|
902
|
+
alone while a limits pass runs (defence in depth: its refresher writes under the
|
|
903
|
+
client's lock and re-reads first)."""
|
|
904
|
+
path = os.path.join(account_dir, CREDENTIALS_FILE)
|
|
905
|
+
target = os.path.realpath(path) if os.path.islink(path) else path
|
|
906
|
+
|
|
907
|
+
def snapshot():
|
|
908
|
+
status, raw = _read_bytes(target)
|
|
909
|
+
try:
|
|
910
|
+
st = os.stat(target) if status == 'ok' else None
|
|
911
|
+
except OSError:
|
|
912
|
+
return 'error', None, None
|
|
913
|
+
return status, raw, st
|
|
914
|
+
|
|
915
|
+
for _attempt in range(3):
|
|
916
|
+
status, raw, before = snapshot()
|
|
917
|
+
if status == 'absent':
|
|
918
|
+
if not create:
|
|
919
|
+
return 'absent', {}
|
|
920
|
+
outcome, changes = _create_file(target, plan)
|
|
921
|
+
if outcome == 'taken':
|
|
922
|
+
continue # the client wrote one meanwhile: merge into it
|
|
923
|
+
return outcome, changes
|
|
924
|
+
if status == 'error':
|
|
925
|
+
return 'unreadable', {}
|
|
926
|
+
doc = _parse_doc(raw)
|
|
927
|
+
mapping = _mcp_map(doc) if doc is not None else None
|
|
928
|
+
if mapping is None:
|
|
929
|
+
return 'corrupt', {}
|
|
930
|
+
changes = plan(mapping)
|
|
931
|
+
if not changes:
|
|
932
|
+
return 'current', {}
|
|
933
|
+
if 'claudeAiOauth' in doc and _limits_running(root):
|
|
934
|
+
return 'deferred', {}
|
|
935
|
+
text = json.dumps(_apply(doc, mapping, changes), ensure_ascii=False,
|
|
936
|
+
separators=(',', ':'))
|
|
937
|
+
scratch = _scratch(os.path.dirname(target), os.path.basename(target),
|
|
938
|
+
text.encode('utf-8'))
|
|
939
|
+
try:
|
|
940
|
+
now_status, now_raw, now_st = snapshot()
|
|
941
|
+
if (now_status, now_raw) != (status, raw) or now_st is None \
|
|
942
|
+
or now_st.st_mtime_ns != before.st_mtime_ns:
|
|
943
|
+
continue # changed under us: merge again from the new text
|
|
944
|
+
os.replace(scratch, target)
|
|
945
|
+
scratch = None
|
|
946
|
+
finally:
|
|
947
|
+
if scratch:
|
|
948
|
+
with contextlib.suppress(OSError):
|
|
949
|
+
os.unlink(scratch)
|
|
950
|
+
with contextlib.suppress(OSError):
|
|
951
|
+
os.utime(target, ns=(before.st_atime_ns, before.st_mtime_ns))
|
|
952
|
+
return 'written', changes
|
|
953
|
+
return 'busy', {}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _merge_keychain(root, account_dir, plan, parked, timeout):
|
|
957
|
+
"""Under the client lock: (outcome, changes written). Only an item that exists and
|
|
958
|
+
this session can read; written the client's way and read back — the Claude login must
|
|
959
|
+
be exactly what it was and every change must have landed, or it is a 'mismatch'."""
|
|
960
|
+
if parked:
|
|
961
|
+
return 'parked', {}
|
|
962
|
+
state, doc = keychain.read_doc(account_dir, timeout=timeout)
|
|
963
|
+
if state != 'present':
|
|
964
|
+
return state, {} # absent: never created; locked: not ours to open
|
|
965
|
+
mapping = _mcp_map(doc)
|
|
966
|
+
if mapping is None:
|
|
967
|
+
return 'corrupt', {}
|
|
968
|
+
changes = plan(mapping)
|
|
969
|
+
if not changes:
|
|
970
|
+
return 'current', {}
|
|
971
|
+
if 'claudeAiOauth' in doc and _limits_running(root):
|
|
972
|
+
return 'deferred', {}
|
|
973
|
+
if not keychain.write_doc(account_dir, _apply(doc, mapping, changes), timeout=timeout):
|
|
974
|
+
return 'failed', {}
|
|
975
|
+
state, back = keychain.read_doc(account_dir, timeout=timeout)
|
|
976
|
+
back_map = _mcp_map(back) if state == 'present' else None
|
|
977
|
+
if back_map is None or back.get('claudeAiOauth') != doc.get('claudeAiOauth') \
|
|
978
|
+
or not _landed(back_map, changes):
|
|
979
|
+
return 'mismatch', {}
|
|
980
|
+
return 'written', changes
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _distribute(root, store, aid, account_dir, seen, keychain_dir, plan, now,
|
|
984
|
+
wait, timeout):
|
|
985
|
+
"""Merge into one account's stores: {'file', 'keychain', 'keys', 'held'} — the
|
|
986
|
+
outcome per store, the keys written to it and the ones it now holds a login for."""
|
|
987
|
+
out = {'file': 'current', 'keychain': None, 'keys': {}, 'held': {}}
|
|
988
|
+
# A Keychain write moves the item's stamp, and bin/claude lifts an `.expired` park on
|
|
989
|
+
# a Keychain login written after it: a parked account gets none.
|
|
990
|
+
parked = os.path.exists(os.path.join(account_dir, '.expired'))
|
|
991
|
+
fs, ks = seen['file'], seen.get('keychain')
|
|
992
|
+
file_todo = create = False
|
|
993
|
+
if fs['state'] == 'ok':
|
|
994
|
+
file_todo = bool(plan(fs['map']))
|
|
995
|
+
else:
|
|
996
|
+
out['file'] = fs['state']
|
|
997
|
+
# absent: created for a token account alone, and only with a login to share
|
|
998
|
+
if fs['state'] == 'absent' and _may_create(account_dir):
|
|
999
|
+
create = file_todo = any(v is not None for v in plan({}).values())
|
|
1000
|
+
kc_todo = False
|
|
1001
|
+
if ks is not None:
|
|
1002
|
+
out['keychain'] = 'current' if ks['state'] == 'ok' else ks['state']
|
|
1003
|
+
if ks['state'] == 'ok' and plan(ks['map']):
|
|
1004
|
+
if store.held(aid, now):
|
|
1005
|
+
out['keychain'] = 'held'
|
|
1006
|
+
else:
|
|
1007
|
+
kc_todo = True
|
|
1008
|
+
if not file_todo and not kc_todo:
|
|
1009
|
+
return out
|
|
1010
|
+
lock = ClientLock(account_dir, wait)
|
|
1011
|
+
if not lock.acquire():
|
|
1012
|
+
if file_todo:
|
|
1013
|
+
out['file'] = 'busy'
|
|
1014
|
+
if kc_todo:
|
|
1015
|
+
out['keychain'] = 'busy'
|
|
1016
|
+
return out
|
|
1017
|
+
written = {}
|
|
1018
|
+
try:
|
|
1019
|
+
if file_todo:
|
|
1020
|
+
out['file'], written['file'] = _merge_file(root, account_dir, plan, create)
|
|
1021
|
+
lock.touch()
|
|
1022
|
+
if kc_todo:
|
|
1023
|
+
out['keychain'], written['keychain'] = _merge_keychain(root, keychain_dir, plan,
|
|
1024
|
+
parked, timeout)
|
|
1025
|
+
except OSError:
|
|
1026
|
+
if file_todo and out['file'] in ('current', 'absent'):
|
|
1027
|
+
out['file'] = 'error'
|
|
1028
|
+
finally:
|
|
1029
|
+
lock.release()
|
|
1030
|
+
for kind, changes in written.items():
|
|
1031
|
+
if changes:
|
|
1032
|
+
out['keys'][kind] = sorted(changes)
|
|
1033
|
+
out['held'][kind] = sorted(k for k, v in changes.items() if v is not None)
|
|
1034
|
+
if out['keychain'] == 'mismatch':
|
|
1035
|
+
# Stop touching that item: whatever answered is not what was written.
|
|
1036
|
+
store.hold(aid, 'read-back mismatch', now)
|
|
1037
|
+
_log(root, f'{aid} keychain: read-back mismatch — left alone for {KEYCHAIN_HOLD_S // 3600}h')
|
|
1038
|
+
for kind in ('file', 'keychain'):
|
|
1039
|
+
if out['keys'].get(kind):
|
|
1040
|
+
verb = 'created with' if out[kind] == 'created' else 'merged'
|
|
1041
|
+
_log(root, f"{aid} {kind}: {verb} {','.join(out['keys'][kind])}")
|
|
1042
|
+
elif out[kind] in ('busy', 'deferred', 'failed', 'error'):
|
|
1043
|
+
_log(root, f'{aid} {kind}: not merged ({out[kind]}); the next run retries')
|
|
1044
|
+
return out
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _stamp(account_dir, clock):
|
|
1048
|
+
path = os.path.join(account_dir, STAMP_FILE)
|
|
1049
|
+
try:
|
|
1050
|
+
if int(os.stat(path).st_mtime) == clock:
|
|
1051
|
+
return # already says so: no write every pass
|
|
1052
|
+
except OSError:
|
|
1053
|
+
pass
|
|
1054
|
+
try:
|
|
1055
|
+
with open(path, 'w', encoding='utf-8') as handle:
|
|
1056
|
+
handle.write(f'{clock}\n')
|
|
1057
|
+
os.utime(path, (clock, clock))
|
|
1058
|
+
except OSError:
|
|
1059
|
+
pass
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _set_watermark(root, stats):
|
|
1063
|
+
"""ROOT/.mcp-oauth-seen gets the newest .credentials.json mtime this run saw (merges
|
|
1064
|
+
keep a file's mtime) — a second early when that is the current second, so the next
|
|
1065
|
+
write cannot hide behind it."""
|
|
1066
|
+
ns = max(stats.values()) if stats else time.time_ns()
|
|
1067
|
+
if ns // 10**9 >= int(time.time()):
|
|
1068
|
+
ns = (ns // 10**9) * 10**9 - 10**6
|
|
1069
|
+
path = os.path.join(root, SEEN_FILE)
|
|
1070
|
+
try:
|
|
1071
|
+
with open(path, 'a', encoding='utf-8'):
|
|
1072
|
+
pass
|
|
1073
|
+
os.utime(path, ns=(ns, ns))
|
|
1074
|
+
except OSError:
|
|
1075
|
+
pass
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ----------------------------------------------------------------------- refresh
|
|
1079
|
+
|
|
1080
|
+
def _url_ok(url):
|
|
1081
|
+
"""https, or http to this machine (the test suite's loopback stub): a refresh token
|
|
1082
|
+
never travels in clear text across a network."""
|
|
1083
|
+
try:
|
|
1084
|
+
parts = urllib.parse.urlsplit(url)
|
|
1085
|
+
except ValueError:
|
|
1086
|
+
return False
|
|
1087
|
+
if parts.scheme == 'https' and parts.netloc:
|
|
1088
|
+
return True
|
|
1089
|
+
return parts.scheme == 'http' and (parts.hostname or '') in ('localhost', '127.0.0.1', '::1')
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _metadata_urls(as_url, explicit=None):
|
|
1093
|
+
"""RFC 8414 metadata (path inserted after the well-known prefix), then OpenID
|
|
1094
|
+
discovery — the order the MCP SDK tries them in; the discovered URL first."""
|
|
1095
|
+
urls = [explicit] if isinstance(explicit, str) and _url_ok(explicit) else []
|
|
1096
|
+
parts = urllib.parse.urlsplit(as_url)
|
|
1097
|
+
base = f'{parts.scheme}://{parts.netloc}'
|
|
1098
|
+
path = parts.path.rstrip('/')
|
|
1099
|
+
if path:
|
|
1100
|
+
urls += [f'{base}/.well-known/oauth-authorization-server{path}',
|
|
1101
|
+
f'{base}/.well-known/openid-configuration{path}',
|
|
1102
|
+
f'{base}{path}/.well-known/openid-configuration']
|
|
1103
|
+
else:
|
|
1104
|
+
urls += [f'{base}/.well-known/oauth-authorization-server',
|
|
1105
|
+
f'{base}/.well-known/openid-configuration']
|
|
1106
|
+
return list(dict.fromkeys(urls))
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
1110
|
+
def redirect_request(self, *_args, **_kwargs):
|
|
1111
|
+
return None # a refresh token is never re-sent anywhere
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _http(url, data=None, headers=None, follow=True):
|
|
1115
|
+
request = urllib.request.Request(url, data=data, method='POST' if data else 'GET',
|
|
1116
|
+
headers=dict({'Accept': 'application/json',
|
|
1117
|
+
'User-Agent': USER_AGENT}, **(headers or {})))
|
|
1118
|
+
opener = urllib.request.build_opener() if follow else urllib.request.build_opener(_NoRedirect)
|
|
1119
|
+
try:
|
|
1120
|
+
with opener.open(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
1121
|
+
return response.status, response.read(MAX_BODY_BYTES)
|
|
1122
|
+
except urllib.error.HTTPError as exc:
|
|
1123
|
+
try:
|
|
1124
|
+
body = exc.read(MAX_BODY_BYTES)
|
|
1125
|
+
except Exception: # noqa: BLE001 — an error body is a nicety
|
|
1126
|
+
body = b''
|
|
1127
|
+
return exc.code, body
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _json_body(body):
|
|
1131
|
+
try:
|
|
1132
|
+
doc = json.loads((body or b'').decode('utf-8'))
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
return None
|
|
1135
|
+
return doc if isinstance(doc, dict) else None
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _as_metadata(as_url, explicit):
|
|
1139
|
+
"""(metadata, None) or (None, error word)."""
|
|
1140
|
+
error = 'no authorization server metadata'
|
|
1141
|
+
for url in _metadata_urls(as_url, explicit):
|
|
1142
|
+
try:
|
|
1143
|
+
status, body = _http(url)
|
|
1144
|
+
except (OSError, ValueError, http.client.HTTPException) as exc:
|
|
1145
|
+
error = f'network ({type(exc).__name__})'
|
|
1146
|
+
continue
|
|
1147
|
+
doc = _json_body(body) if status == 200 else None
|
|
1148
|
+
if doc is not None and isinstance(doc.get('token_endpoint'), str):
|
|
1149
|
+
return doc, None
|
|
1150
|
+
return None, error
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _refresh_entry(entry):
|
|
1154
|
+
"""POST the refresh grant for ``entry``: (new entry, lifetime_s, None, False) on
|
|
1155
|
+
success, else (None, None, error, refused) — ``refused`` when the server rejected the
|
|
1156
|
+
grant itself (invalid_grant). The error is a short phrase, never a token. An answer
|
|
1157
|
+
without expires_in is given ASSUMED_LIFETIME_S (lifetime_s None): with no expiry the
|
|
1158
|
+
pool could never refresh it ahead of the clients, and would rank it below the very
|
|
1159
|
+
copies it replaced."""
|
|
1160
|
+
ds = entry.get('discoveryState') if isinstance(entry.get('discoveryState'), dict) else {}
|
|
1161
|
+
as_url = ds.get('authorizationServerUrl') or entry.get('issuer')
|
|
1162
|
+
if not isinstance(as_url, str) or not _url_ok(as_url):
|
|
1163
|
+
return None, None, 'no usable authorization server URL', False
|
|
1164
|
+
client_id = entry.get('clientId')
|
|
1165
|
+
if not isinstance(client_id, str) or not client_id:
|
|
1166
|
+
return None, None, 'no client id', False
|
|
1167
|
+
meta, error = _as_metadata(as_url, ds.get('authServerMetadataUrl'))
|
|
1168
|
+
if meta is None:
|
|
1169
|
+
return None, None, error, False
|
|
1170
|
+
token_url = meta['token_endpoint']
|
|
1171
|
+
if not _url_ok(token_url):
|
|
1172
|
+
return None, None, 'token endpoint is not https', False
|
|
1173
|
+
form = {'grant_type': 'refresh_token', 'refresh_token': entry['refreshToken']}
|
|
1174
|
+
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
|
1175
|
+
secret = entry.get('clientSecret')
|
|
1176
|
+
methods = meta.get('token_endpoint_auth_methods_supported')
|
|
1177
|
+
methods = [m for m in methods if isinstance(m, str)] if isinstance(methods, list) else []
|
|
1178
|
+
if isinstance(secret, str) and secret and 'client_secret_basic' in methods \
|
|
1179
|
+
and 'client_secret_post' not in methods:
|
|
1180
|
+
pair = f"{urllib.parse.quote(client_id, safe='')}:{urllib.parse.quote(secret, safe='')}"
|
|
1181
|
+
headers['Authorization'] = 'Basic ' + base64.b64encode(pair.encode('utf-8')).decode('ascii')
|
|
1182
|
+
else:
|
|
1183
|
+
form['client_id'] = client_id
|
|
1184
|
+
if isinstance(secret, str) and secret:
|
|
1185
|
+
form['client_secret'] = secret
|
|
1186
|
+
resource = entry.get('serverUrl')
|
|
1187
|
+
if isinstance(resource, str) and resource:
|
|
1188
|
+
form['resource'] = resource # RFC 8707, as the MCP SDK sends it
|
|
1189
|
+
try:
|
|
1190
|
+
status, body = _http(token_url, urllib.parse.urlencode(form).encode('ascii'), headers,
|
|
1191
|
+
follow=False)
|
|
1192
|
+
except (OSError, ValueError, http.client.HTTPException) as exc:
|
|
1193
|
+
return None, None, f'network ({type(exc).__name__})', False
|
|
1194
|
+
doc = _json_body(body)
|
|
1195
|
+
if status != 200:
|
|
1196
|
+
code = doc.get('error') if doc else None
|
|
1197
|
+
code = code if isinstance(code, str) and OAUTH_ERROR.match(code) else ''
|
|
1198
|
+
return None, None, f'http {status}' + (f' {code}' if code else ''), code == 'invalid_grant'
|
|
1199
|
+
token = doc.get('access_token') if doc else None
|
|
1200
|
+
if not isinstance(token, str) or not token:
|
|
1201
|
+
return None, None, 'no access token in the answer', False
|
|
1202
|
+
new = copy.deepcopy(entry)
|
|
1203
|
+
new['accessToken'] = token
|
|
1204
|
+
rotated = doc.get('refresh_token')
|
|
1205
|
+
if isinstance(rotated, str) and rotated:
|
|
1206
|
+
new['refreshToken'] = rotated
|
|
1207
|
+
lifetime = _number(doc.get('expires_in'))
|
|
1208
|
+
reported = lifetime if lifetime is not None and lifetime > 0 else None
|
|
1209
|
+
new['expiresAt'] = int(time.time() * 1000 + (reported or ASSUMED_LIFETIME_S) * 1000)
|
|
1210
|
+
if isinstance(doc.get('scope'), str):
|
|
1211
|
+
new['scope'] = doc['scope']
|
|
1212
|
+
return new, reported, None, False
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _refresh_window(rec, ahead):
|
|
1216
|
+
"""How far ahead of its expiry a login is refreshed: ``ahead``, or half the
|
|
1217
|
+
lifetime its last refresh reported when that is shorter (never under MIN_WINDOW_S) —
|
|
1218
|
+
a 2 h window over a 1 h token refreshed it on every pass."""
|
|
1219
|
+
lifetime = _number(rec.get('lifetime_s'))
|
|
1220
|
+
if lifetime is None or lifetime <= 0:
|
|
1221
|
+
return ahead
|
|
1222
|
+
return min(ahead, max(MIN_WINDOW_S, lifetime / 2.0))
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _refresh_due(rec, now, ahead, force):
|
|
1226
|
+
entry = rec['entry']
|
|
1227
|
+
if rec.get('needs_reauth') or not _has_refresh(entry):
|
|
1228
|
+
return False
|
|
1229
|
+
expires = _expires_ms(entry)
|
|
1230
|
+
if expires is None or expires / 1000.0 - now > _refresh_window(rec, ahead):
|
|
1231
|
+
return False
|
|
1232
|
+
lifetime = _number(rec.get('lifetime_s'))
|
|
1233
|
+
refreshed = _epoch(rec.get('refreshed_at'))
|
|
1234
|
+
if lifetime and refreshed is not None and 0 <= now - refreshed < lifetime / 2.0:
|
|
1235
|
+
return False # refreshed this half-lifetime already
|
|
1236
|
+
return force or (_number((rec.get('refresh') or {}).get('next_at')) or 0) <= now
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _blind_holders(rec, seen):
|
|
1240
|
+
"""The Keychain items known to hold ``rec``'s login that this run cannot open."""
|
|
1241
|
+
out = []
|
|
1242
|
+
for holder in rec.get('holders') or ():
|
|
1243
|
+
aid, _, kind = holder.partition(':')
|
|
1244
|
+
s = (seen.get(aid) or {}).get(kind) if kind == 'keychain' else None
|
|
1245
|
+
if kind == 'keychain' and (s is None or s['state'] != 'ok'):
|
|
1246
|
+
out.append(aid)
|
|
1247
|
+
return out
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _refresh_all(root, store, seen, force, deadline):
|
|
1251
|
+
"""Refresh every due login, saving the store after EACH answer: a rotated refresh
|
|
1252
|
+
token exists nowhere else until it is saved. {key: 'refreshed' | error | deferral}."""
|
|
1253
|
+
out = {}
|
|
1254
|
+
ahead = refresh_ahead()
|
|
1255
|
+
for key in sorted(store.entries):
|
|
1256
|
+
rec = store.entries[key]
|
|
1257
|
+
if not _refresh_due(rec, time.time(), ahead, force):
|
|
1258
|
+
continue
|
|
1259
|
+
# A copy in a Keychain item this run cannot open keeps the old refresh token, and
|
|
1260
|
+
# its client (a GUI session) would present it once rotated away: invalid_grant —
|
|
1261
|
+
# or, on a server that detects reuse, the whole grant revoked. A run that can
|
|
1262
|
+
# open the item (the scheduled pass) refreshes it and updates the item, until the
|
|
1263
|
+
# login is close enough to expiry that every client would refresh on its own.
|
|
1264
|
+
blind = _blind_holders(rec, seen)
|
|
1265
|
+
left = _expires_ms(rec['entry']) / 1000.0 - time.time()
|
|
1266
|
+
if blind and left > URGENT_S:
|
|
1267
|
+
why = f"a copy sits in {', '.join(blind)} keychain, which this run cannot open"
|
|
1268
|
+
if rec.get('deferred') != why:
|
|
1269
|
+
rec['deferred'] = why
|
|
1270
|
+
store.dirty = True
|
|
1271
|
+
_log(root, f'refresh {key}: deferred — {why}')
|
|
1272
|
+
out[key] = 'deferred'
|
|
1273
|
+
continue
|
|
1274
|
+
if deadline - time.monotonic() < REFRESH_MIN_LEFT_S:
|
|
1275
|
+
break
|
|
1276
|
+
old = rec['entry']
|
|
1277
|
+
new, lifetime, error, refused = _refresh_entry(old)
|
|
1278
|
+
now = time.time()
|
|
1279
|
+
rec.pop('deferred', None)
|
|
1280
|
+
if new is not None:
|
|
1281
|
+
replaced = sorted(_digests(old) - _digests(new))
|
|
1282
|
+
rec['superseded'] = (replaced + [d for d in rec.get('superseded') or ()
|
|
1283
|
+
if d not in replaced])[:SUPERSEDED_KEEP]
|
|
1284
|
+
rec['entry'] = new
|
|
1285
|
+
rec['refreshed_at'] = _now_iso(now)
|
|
1286
|
+
rec['lifetime_s'] = int(lifetime or ASSUMED_LIFETIME_S)
|
|
1287
|
+
if lifetime is None:
|
|
1288
|
+
rec['expiry_assumed'] = True
|
|
1289
|
+
else:
|
|
1290
|
+
rec.pop('expiry_assumed', None)
|
|
1291
|
+
rec['holders'] = [] # nothing holds the new login until it is shared
|
|
1292
|
+
rec.pop('refresh', None)
|
|
1293
|
+
rec.pop('needs_reauth', None)
|
|
1294
|
+
out[key] = 'refreshed'
|
|
1295
|
+
_log(root, f'refresh {key}: ok' + (' (no expires_in: assumed 1h)' if lifetime is None else ''))
|
|
1296
|
+
else:
|
|
1297
|
+
state = rec.setdefault('refresh', {})
|
|
1298
|
+
failures = int(_number(state.get('failures')) or 0) + 1
|
|
1299
|
+
state.update(failures=failures, last_error=error, last_attempt_at=_now_iso(now),
|
|
1300
|
+
next_at=int(now + min(BACKOFF_FIRST_S * 2 ** (failures - 1),
|
|
1301
|
+
BACKOFF_MAX_S)))
|
|
1302
|
+
if refused:
|
|
1303
|
+
rec['needs_reauth'] = True
|
|
1304
|
+
_log(root, f'refresh {key}: {error} — needs a new sign-in')
|
|
1305
|
+
else:
|
|
1306
|
+
_log(root, f"refresh {key}: {error}; retry after {_now_iso(state['next_at'])}")
|
|
1307
|
+
out[key] = error
|
|
1308
|
+
store.dirty = True
|
|
1309
|
+
store.save()
|
|
1310
|
+
return out
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
# -------------------------------------------------------------------------- report
|
|
1314
|
+
|
|
1315
|
+
def _fmt_ms(ms):
|
|
1316
|
+
return time.strftime('%Y-%m-%d %H:%MZ', time.gmtime(ms / 1000.0))
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _fmt_span(seconds):
|
|
1320
|
+
seconds = int(abs(seconds))
|
|
1321
|
+
days, rest = divmod(seconds, 86400)
|
|
1322
|
+
hours, rest = divmod(rest, 3600)
|
|
1323
|
+
minutes = rest // 60
|
|
1324
|
+
if days:
|
|
1325
|
+
return f'{days}d {hours}h'
|
|
1326
|
+
return f'{hours}h {minutes}m' if hours else f'{minutes}m'
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _validity(entry, now):
|
|
1330
|
+
expires = _expires_ms(entry)
|
|
1331
|
+
if expires is None:
|
|
1332
|
+
return 'expiry unknown'
|
|
1333
|
+
left = expires / 1000.0 - now
|
|
1334
|
+
if left >= 0:
|
|
1335
|
+
return f'valid until {_fmt_ms(expires)} (in {_fmt_span(left)})'
|
|
1336
|
+
return f'access token EXPIRED {_fmt_span(left)} ago'
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _account_state(store, key, s, kind):
|
|
1340
|
+
"""One account store's view of one key, for status: missing / stub / fresh / stale /
|
|
1341
|
+
own / forgotten — or, for a store that is not there or could not be read, its own
|
|
1342
|
+
state ('absent': nothing to share into — a file is created for a setup-token account
|
|
1343
|
+
alone, a Keychain item never)."""
|
|
1344
|
+
if s['state'] != 'ok':
|
|
1345
|
+
return s['state']
|
|
1346
|
+
have = s['map'].get(key)
|
|
1347
|
+
if have is None:
|
|
1348
|
+
return 'missing'
|
|
1349
|
+
if not is_login(have):
|
|
1350
|
+
return 'stub'
|
|
1351
|
+
if store.forgotten_match(key, have):
|
|
1352
|
+
return 'forgotten'
|
|
1353
|
+
rec = store.entries.get(key)
|
|
1354
|
+
if rec is None or _same_tokens(have, rec['entry']):
|
|
1355
|
+
return 'fresh'
|
|
1356
|
+
if _superseded(rec, have) or _provably_older(have, rec['entry']):
|
|
1357
|
+
return 'stale'
|
|
1358
|
+
# another grant whose age the expiries cannot settle (or a newer one the next
|
|
1359
|
+
# harvest takes): the pool leaves it where it is
|
|
1360
|
+
return 'fresh' if _provably_older(rec['entry'], have) else 'own'
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _status_doc(root, store, seen, now, paused):
|
|
1364
|
+
"""``paused``: why no launch on this machine shares any login right now, or None."""
|
|
1365
|
+
keys = set(store.entries) | set(store.forgotten)
|
|
1366
|
+
for stores in seen.values():
|
|
1367
|
+
for s in stores.values():
|
|
1368
|
+
if s and s['state'] == 'ok':
|
|
1369
|
+
keys.update(k for k in s['map'] if isinstance(k, str) and k)
|
|
1370
|
+
rows = []
|
|
1371
|
+
for key in sorted(keys):
|
|
1372
|
+
rec = store.entries.get(key)
|
|
1373
|
+
entry = rec['entry'] if rec else None
|
|
1374
|
+
if entry is None:
|
|
1375
|
+
entry = next((s['map'][key] for stores in seen.values() for s in stores.values()
|
|
1376
|
+
if s and s['state'] == 'ok' and isinstance(s['map'].get(key), dict)),
|
|
1377
|
+
{})
|
|
1378
|
+
backoff = (rec or {}).get('refresh') or {}
|
|
1379
|
+
expires = _expires_ms(entry) if rec else None
|
|
1380
|
+
accounts = {}
|
|
1381
|
+
for aid in sorted(seen):
|
|
1382
|
+
states = {}
|
|
1383
|
+
for kind in ('file', 'keychain'):
|
|
1384
|
+
s = seen[aid].get(kind)
|
|
1385
|
+
if s is not None:
|
|
1386
|
+
states[kind] = _account_state(store, key, s, kind)
|
|
1387
|
+
accounts[aid] = states
|
|
1388
|
+
due_at = None
|
|
1389
|
+
if rec and _has_refresh(entry) and expires is not None and not rec.get('needs_reauth'):
|
|
1390
|
+
due_at = int(max(expires / 1000.0 - _refresh_window(rec, refresh_ahead()),
|
|
1391
|
+
_number(backoff.get('next_at')) or 0))
|
|
1392
|
+
tomb = store.forgotten.get(key) or {}
|
|
1393
|
+
rows.append({
|
|
1394
|
+
'key': key, 'server_name': _server_name(key, entry),
|
|
1395
|
+
'server_url': entry.get('serverUrl') if isinstance(entry.get('serverUrl'), str) else None,
|
|
1396
|
+
'login': rec is not None,
|
|
1397
|
+
'expires_at': int(expires) if expires is not None else None,
|
|
1398
|
+
'expires_in_s': int(expires / 1000.0 - now) if expires is not None else None,
|
|
1399
|
+
'expiry_assumed': bool((rec or {}).get('expiry_assumed')),
|
|
1400
|
+
'lifetime_s': (rec or {}).get('lifetime_s'),
|
|
1401
|
+
'has_refresh_token': bool(rec) and _has_refresh(entry),
|
|
1402
|
+
'source': (rec or {}).get('source'), 'source_store': (rec or {}).get('store'),
|
|
1403
|
+
'harvested_at': (rec or {}).get('harvested_at'),
|
|
1404
|
+
'refreshed_at': (rec or {}).get('refreshed_at'),
|
|
1405
|
+
'needs_reauth': bool((rec or {}).get('needs_reauth')),
|
|
1406
|
+
'refresh_due_at': due_at,
|
|
1407
|
+
'refresh_deferred': (rec or {}).get('deferred'),
|
|
1408
|
+
'shared': bool(rec) and not paused and _sharing_off(rec, now) is None,
|
|
1409
|
+
'not_shared_because': (paused or _sharing_off(rec, now)) if rec else None,
|
|
1410
|
+
'retired': ({'at': tomb.get('at'), 'reason': tomb.get('reason')}
|
|
1411
|
+
if tomb and rec is None else None),
|
|
1412
|
+
'backoff': {'failures': int(_number(backoff.get('failures')) or 0),
|
|
1413
|
+
'next_at': _number(backoff.get('next_at')),
|
|
1414
|
+
'last_error': backoff.get('last_error')},
|
|
1415
|
+
'accounts': accounts})
|
|
1416
|
+
beat = store.doc.get('refresher') or {}
|
|
1417
|
+
return {'store': store.path, 'store_exists': store.exists, 'enabled': enabled(),
|
|
1418
|
+
'refresh_enabled': refresh_enabled(), 'refresh_ahead_s': refresh_ahead(),
|
|
1419
|
+
'keychain': keychain.enabled(), 'sharing_paused': paused,
|
|
1420
|
+
'refresher': {'at': _number(beat.get('at')), 'refresh': beat.get('refresh')},
|
|
1421
|
+
'keys': rows,
|
|
1422
|
+
'forgotten': [{'key': k, 'at': v.get('at'), 'reason': v.get('reason')}
|
|
1423
|
+
for k, v in sorted(store.forgotten.items())],
|
|
1424
|
+
'holds': [{'account': k, 'until': v.get('until'), 'reason': v.get('reason')}
|
|
1425
|
+
for k, v in sorted(store.doc['holds'].items())]}
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _group(accounts, kind):
|
|
1429
|
+
groups = {}
|
|
1430
|
+
for aid, states in accounts.items():
|
|
1431
|
+
state = states.get(kind)
|
|
1432
|
+
if state is not None:
|
|
1433
|
+
groups.setdefault(state, []).append(aid)
|
|
1434
|
+
order = ('fresh', 'stale', 'own', 'stub', 'missing', 'forgotten', 'locked', 'absent')
|
|
1435
|
+
return ' · '.join(f"{state} {' '.join(ids)}" for state, ids in
|
|
1436
|
+
sorted(groups.items(), key=lambda kv: (order.index(kv[0])
|
|
1437
|
+
if kv[0] in order else 99, kv[0])))
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _print_status(doc, now):
|
|
1441
|
+
print(f"MCP OAuth logins shared on this machine — {doc['store']}"
|
|
1442
|
+
+ ('' if doc['enabled'] else ' [sharing OFF: CLAUDE_MULTIACC_MCP_OAUTH=0]')
|
|
1443
|
+
+ ('' if doc['refresh_enabled'] else
|
|
1444
|
+
' [refresh OFF: CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0 — nothing is shared]'))
|
|
1445
|
+
if doc['sharing_paused'] and doc['enabled'] and doc['refresh_enabled'] and doc['keys']:
|
|
1446
|
+
print(f" sharing paused: {doc['sharing_paused']} — every claude limits pass runs "
|
|
1447
|
+
'one; claude-accounts mcp auth-sync runs one now')
|
|
1448
|
+
if not doc['keys']:
|
|
1449
|
+
print(' no MCP OAuth state in any account yet')
|
|
1450
|
+
for row in doc['keys']:
|
|
1451
|
+
print(f"{row['server_name']} {row['server_url'] or ''} [{row['key']}]".rstrip())
|
|
1452
|
+
if not row['login']:
|
|
1453
|
+
if row['retired']:
|
|
1454
|
+
print(f" login: none — retired {row['retired']['at']} ({row['retired']['reason']}); "
|
|
1455
|
+
'sign in once in any account (/mcp) and every account gets it')
|
|
1456
|
+
else:
|
|
1457
|
+
print(' login: none yet — authenticate it once in any account (/mcp) and every '
|
|
1458
|
+
'account gets it')
|
|
1459
|
+
else:
|
|
1460
|
+
entry = {'expiresAt': row['expires_at']}
|
|
1461
|
+
source = f"{row['source'] or '?'} ({row['source_store'] or '?'})"
|
|
1462
|
+
print(f" login: {_validity(entry, now)}"
|
|
1463
|
+
+ (' [expiry assumed: the server sent none]' if row['expiry_assumed'] else '')
|
|
1464
|
+
+ f" · refresh token: {'yes' if row['has_refresh_token'] else 'no'} · from {source} "
|
|
1465
|
+
f"{row['harvested_at'] or ''}".rstrip()
|
|
1466
|
+
+ (f" · refreshed {row['refreshed_at']}" if row['refreshed_at'] else ''))
|
|
1467
|
+
back = row['backoff']
|
|
1468
|
+
if row['needs_reauth']:
|
|
1469
|
+
print(f" refresh: NEEDS RE-AUTH ({back['last_error'] or 'refused'}) — sign in "
|
|
1470
|
+
'once in any account (/mcp)')
|
|
1471
|
+
elif back['failures']:
|
|
1472
|
+
print(f" refresh: {back['failures']} failure(s), last {back['last_error']}; "
|
|
1473
|
+
f"retry after {_now_iso(back['next_at'] or now)}")
|
|
1474
|
+
elif row['refresh_due_at'] is not None:
|
|
1475
|
+
print(f" refresh: due {_now_iso(row['refresh_due_at'])}"
|
|
1476
|
+
+ (f" — deferred: {row['refresh_deferred']}" if row['refresh_deferred'] else ''))
|
|
1477
|
+
if row['not_shared_because']:
|
|
1478
|
+
print(f" NOT shared: {row['not_shared_because']} (each account keeps what it holds)")
|
|
1479
|
+
for kind, label in (('file', 'files'), ('keychain', 'keychain')):
|
|
1480
|
+
line = _group(row['accounts'], kind)
|
|
1481
|
+
if line:
|
|
1482
|
+
print(f' {label}: {line}')
|
|
1483
|
+
for hold in doc['holds']:
|
|
1484
|
+
print(f"hold: {hold['account']} keychain left alone until {_now_iso(hold['until'] or now)} "
|
|
1485
|
+
f"({hold['reason']})")
|
|
1486
|
+
live = {row['key'] for row in doc['keys'] if row['login']}
|
|
1487
|
+
for tomb in doc['forgotten']:
|
|
1488
|
+
if tomb['key'] in live: # an older grant retired, a new login since
|
|
1489
|
+
print(f"forgotten: an older login of {tomb['key']} at {tomb['at']}"
|
|
1490
|
+
+ (f" ({tomb['reason']})" if tomb.get('reason') else ''))
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# -------------------------------------------------------------------------- verbs
|
|
1494
|
+
|
|
1495
|
+
def cmd_ensure(args):
|
|
1496
|
+
if not enabled():
|
|
1497
|
+
return 0
|
|
1498
|
+
account_dir = args.account_dir
|
|
1499
|
+
accounts = account_dirs(args.root)
|
|
1500
|
+
match = [aid for aid, d in accounts if os.path.normpath(d) == os.path.normpath(account_dir)]
|
|
1501
|
+
if not match:
|
|
1502
|
+
return 0
|
|
1503
|
+
aid = match[0]
|
|
1504
|
+
with _budget(ENSURE_BUDGET_S), PoolLock(args.root, wait=ENSURE_LOCK_WAIT_S) as lock:
|
|
1505
|
+
if not lock.held:
|
|
1506
|
+
return 0
|
|
1507
|
+
store = Store(args.root).load()
|
|
1508
|
+
# The Keychain item under the dir string the shim hands the client: its hash is
|
|
1509
|
+
# the item's name.
|
|
1510
|
+
kdirs = {aid: account_dir} if keychain.enabled() else {}
|
|
1511
|
+
seen, stats = _harvest(args.root, accounts, kdirs, ENSURE_SECURITY_TIMEOUT_S)
|
|
1512
|
+
if not store.exists and not _in_play(seen):
|
|
1513
|
+
# The shim's grep matched text no document holds (a torn file): note the look,
|
|
1514
|
+
# or every launch would start python again until some file changes.
|
|
1515
|
+
if any(stores['file'].get('mentions') for stores in seen.values()):
|
|
1516
|
+
_set_watermark(args.root, stats)
|
|
1517
|
+
return 0
|
|
1518
|
+
now = time.time()
|
|
1519
|
+
_choose(store, seen, now)
|
|
1520
|
+
_note_holders(store, seen, now)
|
|
1521
|
+
if store.dirty or not store.exists:
|
|
1522
|
+
store.save()
|
|
1523
|
+
# Shared only behind a refreshing sync: this process never refreshes.
|
|
1524
|
+
sharing = refresh_enabled() and store.refresher_ok(now)
|
|
1525
|
+
res = _distribute(args.root, store, aid, account_dir, seen[aid], kdirs.get(aid),
|
|
1526
|
+
_plan(store, now, sharing), now, wait=ENSURE_LOCK_WAIT_S,
|
|
1527
|
+
timeout=ENSURE_SECURITY_TIMEOUT_S)
|
|
1528
|
+
_hold(store, aid, res)
|
|
1529
|
+
if store.dirty:
|
|
1530
|
+
store.save()
|
|
1531
|
+
if res['file'] not in TRANSIENT and res['keychain'] not in TRANSIENT:
|
|
1532
|
+
_stamp(account_dir, store.clock)
|
|
1533
|
+
_set_watermark(args.root, stats)
|
|
1534
|
+
return 0
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def cmd_sync(args):
|
|
1538
|
+
quiet = args.quiet
|
|
1539
|
+
if not enabled():
|
|
1540
|
+
if not quiet:
|
|
1541
|
+
print('MCP OAuth sharing is off (CLAUDE_MULTIACC_MCP_OAUTH=0)')
|
|
1542
|
+
return 0
|
|
1543
|
+
deadline = time.monotonic() + SYNC_BUDGET_S
|
|
1544
|
+
with _budget(SYNC_BUDGET_S), PoolLock(args.root, wait=30.0) as lock:
|
|
1545
|
+
if not lock.held:
|
|
1546
|
+
if not quiet:
|
|
1547
|
+
print('another MCP OAuth sync holds the pool lock — skipped')
|
|
1548
|
+
return 0
|
|
1549
|
+
store = Store(args.root).load()
|
|
1550
|
+
accounts = account_dirs(args.root)
|
|
1551
|
+
kdirs = dict(accounts) if keychain.enabled() else {}
|
|
1552
|
+
seen, stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
|
|
1553
|
+
if not store.exists and not _in_play(seen):
|
|
1554
|
+
if not quiet:
|
|
1555
|
+
print('no MCP OAuth state in any account yet — nothing to share')
|
|
1556
|
+
return 0
|
|
1557
|
+
now = time.time()
|
|
1558
|
+
store.beat(now)
|
|
1559
|
+
for key in _choose(store, seen, now):
|
|
1560
|
+
rec = store.entries[key]
|
|
1561
|
+
_log(args.root, f"{key}: login from {rec['source']} ({rec['store']})")
|
|
1562
|
+
retired = _note_holders(store, seen, now)
|
|
1563
|
+
if store.dirty or not store.exists:
|
|
1564
|
+
store.save()
|
|
1565
|
+
refreshed = {}
|
|
1566
|
+
if refresh_enabled():
|
|
1567
|
+
refreshed = _refresh_all(args.root, store, seen, args.force, deadline)
|
|
1568
|
+
now = time.time()
|
|
1569
|
+
plan = _plan(store, now, refresh_enabled())
|
|
1570
|
+
results = {}
|
|
1571
|
+
for aid, account_dir in accounts:
|
|
1572
|
+
results[aid] = _distribute(args.root, store, aid, account_dir, seen[aid],
|
|
1573
|
+
kdirs.get(aid), plan, now, wait=3.0,
|
|
1574
|
+
timeout=keychain.TIMEOUT_S)
|
|
1575
|
+
_hold(store, aid, results[aid])
|
|
1576
|
+
if store.dirty:
|
|
1577
|
+
store.save()
|
|
1578
|
+
for aid, account_dir in accounts:
|
|
1579
|
+
res = results[aid]
|
|
1580
|
+
if res['file'] not in TRANSIENT and res['keychain'] not in TRANSIENT:
|
|
1581
|
+
_stamp(account_dir, store.clock)
|
|
1582
|
+
_set_watermark(args.root, stats)
|
|
1583
|
+
if not quiet:
|
|
1584
|
+
_print_sync(store, results, refreshed, retired, now)
|
|
1585
|
+
return 0
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def _print_sync(store, results, refreshed, retired, now):
|
|
1589
|
+
if not store.entries and not retired:
|
|
1590
|
+
print('no MCP OAuth login in any account yet — authenticate a server once in any '
|
|
1591
|
+
'account (/mcp) and every account gets it')
|
|
1592
|
+
for key in sorted(retired):
|
|
1593
|
+
tomb = store.forgotten.get(key) or {}
|
|
1594
|
+
print(f"{key.split('|', 1)[0]} [{key}]: login retired ({tomb.get('reason')}) — "
|
|
1595
|
+
'sign in once in any account (/mcp) and every account gets the new one')
|
|
1596
|
+
for key in sorted(store.entries):
|
|
1597
|
+
rec = store.entries[key]
|
|
1598
|
+
written = sum(1 for r in results.values() for keys in r['held'].values() if key in keys)
|
|
1599
|
+
words = [f"login from {rec.get('source') or '?'} ({rec.get('store') or '?'}), "
|
|
1600
|
+
f"{_validity(rec['entry'], now)}"]
|
|
1601
|
+
outcome = refreshed.get(key)
|
|
1602
|
+
if outcome == 'refreshed':
|
|
1603
|
+
words.append('refreshed now')
|
|
1604
|
+
elif outcome == 'deferred':
|
|
1605
|
+
words.append(f"refresh deferred ({rec.get('deferred')})")
|
|
1606
|
+
elif outcome:
|
|
1607
|
+
words.append(f'refresh failed ({outcome})')
|
|
1608
|
+
if rec.get('needs_reauth'):
|
|
1609
|
+
words.append('NEEDS RE-AUTH — sign in once in any account (/mcp)')
|
|
1610
|
+
why = _sharing_off(rec, now) or (None if refresh_enabled() else
|
|
1611
|
+
'refresh is off (CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0)')
|
|
1612
|
+
words.append(f'NOT shared: {why}' if why else f'{written} account store(s) updated')
|
|
1613
|
+
print(f"{_server_name(key, rec['entry'])} [{key}]: {'; '.join(words)}")
|
|
1614
|
+
missed = []
|
|
1615
|
+
for aid in sorted(results):
|
|
1616
|
+
for kind in ('file', 'keychain'):
|
|
1617
|
+
state = results[aid][kind]
|
|
1618
|
+
if state in UNREACHED:
|
|
1619
|
+
missed.append(f'{aid} {kind} ({state})')
|
|
1620
|
+
if missed:
|
|
1621
|
+
print(f"not reached: {', '.join(missed)}")
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def cmd_status(args):
|
|
1625
|
+
store = Store(args.root).load(readonly=True)
|
|
1626
|
+
accounts = account_dirs(args.root)
|
|
1627
|
+
kdirs = dict(accounts) if keychain.enabled() else {}
|
|
1628
|
+
seen, _stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
|
|
1629
|
+
now = time.time()
|
|
1630
|
+
# What a launch would do now: share only behind a refreshing sync.
|
|
1631
|
+
beat = store.doc.get('refresher') or {}
|
|
1632
|
+
if not enabled():
|
|
1633
|
+
paused = 'sharing is off (CLAUDE_MULTIACC_MCP_OAUTH=0)'
|
|
1634
|
+
elif not refresh_enabled():
|
|
1635
|
+
paused = 'refresh is off (CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0)'
|
|
1636
|
+
elif not store.refresher_ok(now):
|
|
1637
|
+
last = _number(beat.get('at'))
|
|
1638
|
+
paused = ('no refreshing sync in the last hour ('
|
|
1639
|
+
+ (f"last {_now_iso(last)}" + ('' if beat.get('refresh') else ', refresh off')
|
|
1640
|
+
if last else 'none yet') + ')')
|
|
1641
|
+
else:
|
|
1642
|
+
paused = None
|
|
1643
|
+
doc = _status_doc(args.root, store, seen, now, paused)
|
|
1644
|
+
if args.json:
|
|
1645
|
+
print(json.dumps(doc, indent=2))
|
|
1646
|
+
else:
|
|
1647
|
+
_print_status(doc, now)
|
|
1648
|
+
return 0
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def cmd_forget(args):
|
|
1652
|
+
name = args.name
|
|
1653
|
+
with PoolLock(args.root, wait=30.0) as lock:
|
|
1654
|
+
if not lock.held:
|
|
1655
|
+
print('another MCP OAuth sync holds the pool lock — try again', file=sys.stderr)
|
|
1656
|
+
return 1
|
|
1657
|
+
store = Store(args.root).load()
|
|
1658
|
+
accounts = account_dirs(args.root)
|
|
1659
|
+
kdirs = dict(accounts) if keychain.enabled() else {}
|
|
1660
|
+
seen, _stats = _harvest(args.root, accounts, kdirs, keychain.TIMEOUT_S)
|
|
1661
|
+
keys = {k for k, rec in store.entries.items() if _server_name(k, rec['entry']) == name}
|
|
1662
|
+
for stores in seen.values():
|
|
1663
|
+
for s in stores.values():
|
|
1664
|
+
if s and s['state'] == 'ok':
|
|
1665
|
+
keys.update(k for k, e in s['map'].items()
|
|
1666
|
+
if isinstance(k, str) and _server_name(k, e) == name)
|
|
1667
|
+
if not keys:
|
|
1668
|
+
print(f'no MCP OAuth state for {name} on this machine', file=sys.stderr)
|
|
1669
|
+
return 1
|
|
1670
|
+
now = time.time()
|
|
1671
|
+
for key in sorted(keys):
|
|
1672
|
+
digests = set()
|
|
1673
|
+
for stores in seen.values():
|
|
1674
|
+
for s in stores.values():
|
|
1675
|
+
if s and s['state'] == 'ok':
|
|
1676
|
+
digests |= _digests(s['map'].get(key))
|
|
1677
|
+
store.retire(key, 'auth-forget', now, digests)
|
|
1678
|
+
store.save()
|
|
1679
|
+
_log(args.root, f"forget {name}: {','.join(sorted(keys))}")
|
|
1680
|
+
|
|
1681
|
+
def plan(mapping):
|
|
1682
|
+
return {key: None for key in keys if key in mapping}
|
|
1683
|
+
|
|
1684
|
+
cleared, missed = 0, []
|
|
1685
|
+
for aid, account_dir in accounts:
|
|
1686
|
+
res = _distribute(args.root, store, aid, account_dir, seen[aid], kdirs.get(aid),
|
|
1687
|
+
plan, now, wait=3.0, timeout=keychain.TIMEOUT_S)
|
|
1688
|
+
cleared += len(res['keys'])
|
|
1689
|
+
for kind in ('file', 'keychain'):
|
|
1690
|
+
if res[kind] in UNREACHED:
|
|
1691
|
+
missed.append(f'{aid} {kind} ({res[kind]})')
|
|
1692
|
+
if store.dirty:
|
|
1693
|
+
store.save()
|
|
1694
|
+
if not args.quiet:
|
|
1695
|
+
print(f"forgot {name}: {len(keys)} key(s); cleared from {cleared} account store(s)")
|
|
1696
|
+
if missed:
|
|
1697
|
+
print(f"not reached: {', '.join(missed)} — the retired login is ignored there "
|
|
1698
|
+
'and cleared by a later sync that can reach it')
|
|
1699
|
+
return 0
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def build_parser():
|
|
1703
|
+
parser = argparse.ArgumentParser(prog='mcp_oauth.py', description=__doc__.split('\n\n')[0])
|
|
1704
|
+
modes = parser.add_subparsers(dest='mode')
|
|
1705
|
+
p = modes.add_parser('ensure', help='shim: share into one account before exec')
|
|
1706
|
+
p.add_argument('--root', required=True)
|
|
1707
|
+
p.add_argument('--account-dir', required=True)
|
|
1708
|
+
p.add_argument('--quiet', action='store_true')
|
|
1709
|
+
p = modes.add_parser('sync', help='harvest, refresh what is due, share into every account')
|
|
1710
|
+
p.add_argument('--root', required=True)
|
|
1711
|
+
p.add_argument('--quiet', action='store_true')
|
|
1712
|
+
p.add_argument('--force', action='store_true', help='retry a refresh that is backing off')
|
|
1713
|
+
p = modes.add_parser('status', help='what the pool holds (no secrets)')
|
|
1714
|
+
p.add_argument('--root', required=True)
|
|
1715
|
+
p.add_argument('--json', action='store_true')
|
|
1716
|
+
p = modes.add_parser('forget', help="log a server out of every account on this machine")
|
|
1717
|
+
p.add_argument('--root', required=True)
|
|
1718
|
+
p.add_argument('name')
|
|
1719
|
+
p.add_argument('--quiet', action='store_true')
|
|
1720
|
+
return parser
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def main(argv=None):
|
|
1724
|
+
parser = build_parser()
|
|
1725
|
+
args = parser.parse_args(argv)
|
|
1726
|
+
if not args.mode:
|
|
1727
|
+
parser.error('a mode is required (ensure, sync, status, forget)')
|
|
1728
|
+
if not os.path.isdir(args.root):
|
|
1729
|
+
print(f'mcp-oauth: pool root {args.root} does not exist', file=sys.stderr)
|
|
1730
|
+
return 1
|
|
1731
|
+
verb = {'ensure': cmd_ensure, 'sync': cmd_sync, 'status': cmd_status,
|
|
1732
|
+
'forget': cmd_forget}[args.mode]
|
|
1733
|
+
try:
|
|
1734
|
+
return verb(args)
|
|
1735
|
+
except _Budget:
|
|
1736
|
+
_log(args.root, f'{args.mode}: time budget ran out — the next run carries on')
|
|
1737
|
+
return 0
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
if __name__ == '__main__':
|
|
1741
|
+
fail_open = len(sys.argv) > 1 and sys.argv[1] in ('ensure', 'sync')
|
|
1742
|
+
try:
|
|
1743
|
+
sys.exit(main())
|
|
1744
|
+
except Exception as error: # noqa: BLE001 — a traceback is never the shim's problem
|
|
1745
|
+
# The type only: a message could quote a path or a server answer, never a token,
|
|
1746
|
+
# but nothing here is worth that risk.
|
|
1747
|
+
print(f'mcp-oauth: {type(error).__name__}', file=sys.stderr)
|
|
1748
|
+
sys.exit(0 if fail_open else 1)
|