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
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lib/mcp_oauth.py: an MCP server authenticated once in ANY pooled Claude account on this
|
|
3
|
+
machine works in EVERY pooled Claude account on it.
|
|
4
|
+
|
|
5
|
+
Runs everywhere: a fake `security` on PATH (tests/fake_security.sh, with
|
|
6
|
+
CLAUDE_MULTIACC_KEYCHAIN=1) serves Keychain items from a temp dir, and a loopback
|
|
7
|
+
http.server plays the authorization server. Nothing here can touch a real keychain, a
|
|
8
|
+
real pool or the network: an entry that is due for a refresh names either the loopback
|
|
9
|
+
server or a plain-http host the module refuses to send a token to. Every token is a fake
|
|
10
|
+
value, and no output may contain one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import contextlib
|
|
17
|
+
import http.server
|
|
18
|
+
import importlib.util
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import unittest
|
|
29
|
+
import urllib.parse
|
|
30
|
+
|
|
31
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
32
|
+
LIB = REPO / 'lib'
|
|
33
|
+
SHIM = REPO / 'bin' / 'claude'
|
|
34
|
+
FAKE_SECURITY = (REPO / 'tests' / 'fake_security.sh').read_text()
|
|
35
|
+
sys.path.insert(0, str(LIB))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load():
|
|
39
|
+
spec = importlib.util.spec_from_file_location('mcp_oauth', LIB / 'mcp_oauth.py')
|
|
40
|
+
module = importlib.util.module_from_spec(spec)
|
|
41
|
+
spec.loader.exec_module(module)
|
|
42
|
+
return module
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
M = _load()
|
|
46
|
+
KEY = 'higgsfield|0a1b2c3d4e5f6a7b'
|
|
47
|
+
OTHER_KEY = 'linear|1122334455667788'
|
|
48
|
+
CLIENT_ID = 'https://claude.ai/oauth/claude-code-client-metadata'
|
|
49
|
+
SERVER_URL = 'https://mcp.higgsfield.example/mcp'
|
|
50
|
+
#: an authorization server the module refuses outright (plain http to another host): a
|
|
51
|
+
#: due login naming it fails its refresh without a single packet leaving the machine
|
|
52
|
+
NO_AS = 'http://auth.example.invalid'
|
|
53
|
+
HOUR_MS = 3600 * 1000
|
|
54
|
+
#: every environment knob this module or the fake reads — saved, then set per test (an
|
|
55
|
+
#: ambient proxy would carry the loopback authorization server's traffic elsewhere)
|
|
56
|
+
ENV_KEYS = ('PATH', 'USER', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR', 'FAKE_KEYCHAIN_LOCKED',
|
|
57
|
+
'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG', 'CLAUDE_MULTIACC_MCP_OAUTH',
|
|
58
|
+
'CLAUDE_MULTIACC_MCP_OAUTH_REFRESH', 'CLAUDE_MULTIACC_MCP_OAUTH_REFRESH_AHEAD',
|
|
59
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthServer:
|
|
63
|
+
"""A loopback OAuth authorization server: RFC 8414 metadata at ``meta_path`` and a
|
|
64
|
+
token endpoint whose answers a test scripts (default: rotate both tokens, 24 h).
|
|
65
|
+
Every token request is recorded — form fields and whether it carried Basic auth."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, base_path='', meta_path=None, methods=None):
|
|
68
|
+
self.base_path = base_path
|
|
69
|
+
self.meta_path = meta_path or f'/.well-known/oauth-authorization-server{base_path}'
|
|
70
|
+
self.methods = methods
|
|
71
|
+
self.answers = []
|
|
72
|
+
self.requests = []
|
|
73
|
+
self.metadata_hits = []
|
|
74
|
+
self.issued = 0
|
|
75
|
+
self.lifetime = 86400
|
|
76
|
+
server = self
|
|
77
|
+
|
|
78
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
79
|
+
def log_message(self, *_args):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def _send(self, status, doc):
|
|
83
|
+
body = json.dumps(doc).encode()
|
|
84
|
+
self.send_response(status)
|
|
85
|
+
self.send_header('Content-Type', 'application/json')
|
|
86
|
+
self.send_header('Content-Length', str(len(body)))
|
|
87
|
+
self.end_headers()
|
|
88
|
+
self.wfile.write(body)
|
|
89
|
+
|
|
90
|
+
def do_GET(self): # noqa: N802 — http.server's naming
|
|
91
|
+
server.metadata_hits.append(self.path)
|
|
92
|
+
if self.path != server.meta_path:
|
|
93
|
+
return self._send(404, {'error': 'not_found'})
|
|
94
|
+
doc = {'issuer': server.url, 'token_endpoint': f'{server.origin}/token',
|
|
95
|
+
'authorization_endpoint': f'{server.origin}/authorize'}
|
|
96
|
+
if server.methods is not None:
|
|
97
|
+
doc['token_endpoint_auth_methods_supported'] = server.methods
|
|
98
|
+
return self._send(200, doc)
|
|
99
|
+
|
|
100
|
+
def do_POST(self): # noqa: N802
|
|
101
|
+
length = int(self.headers.get('Content-Length') or 0)
|
|
102
|
+
form = dict(urllib.parse.parse_qsl(self.rfile.read(length).decode()))
|
|
103
|
+
server.requests.append({'path': self.path, 'form': form,
|
|
104
|
+
'authorization': self.headers.get('Authorization')})
|
|
105
|
+
if server.answers:
|
|
106
|
+
status, doc = server.answers.pop(0)
|
|
107
|
+
else:
|
|
108
|
+
server.issued += 1
|
|
109
|
+
status, doc = 200, {'access_token': f'fake-mcp-access-rotated-{server.issued}',
|
|
110
|
+
'refresh_token': f'fake-mcp-refresh-rotated-{server.issued}',
|
|
111
|
+
'expires_in': server.lifetime, 'token_type': 'Bearer'}
|
|
112
|
+
return self._send(status, doc)
|
|
113
|
+
|
|
114
|
+
self.httpd = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
115
|
+
self.origin = f'http://127.0.0.1:{self.httpd.server_address[1]}'
|
|
116
|
+
self.url = f'{self.origin}{base_path}'
|
|
117
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
118
|
+
self.thread.start()
|
|
119
|
+
|
|
120
|
+
def stop(self):
|
|
121
|
+
self.httpd.shutdown()
|
|
122
|
+
self.httpd.server_close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Case(unittest.TestCase):
|
|
126
|
+
ACCOUNTS = ('acct-01', 'acct-02', 'acct-03')
|
|
127
|
+
|
|
128
|
+
def setUp(self):
|
|
129
|
+
self.work = tempfile.TemporaryDirectory()
|
|
130
|
+
self.addCleanup(self.work.cleanup)
|
|
131
|
+
work = Path(self.work.name)
|
|
132
|
+
self.fakebin = work / 'bin'
|
|
133
|
+
self.fakebin.mkdir()
|
|
134
|
+
tool = self.fakebin / 'security'
|
|
135
|
+
tool.write_text(FAKE_SECURITY)
|
|
136
|
+
tool.chmod(0o755)
|
|
137
|
+
self.kcdir = work / 'keychain'
|
|
138
|
+
self.calllog = work / 'security.log'
|
|
139
|
+
self.root = work / 'pool'
|
|
140
|
+
self.root.mkdir()
|
|
141
|
+
(self.root / 'accounts.json').write_text(json.dumps(
|
|
142
|
+
{'version': 1, 'accounts': [{'id': a} for a in self.ACCOUNTS]}))
|
|
143
|
+
for account in self.ACCOUNTS:
|
|
144
|
+
(self.root / account).mkdir()
|
|
145
|
+
saved = {k: os.environ.get(k) for k in ENV_KEYS}
|
|
146
|
+
|
|
147
|
+
def restore():
|
|
148
|
+
for k, v in saved.items():
|
|
149
|
+
if v is None:
|
|
150
|
+
os.environ.pop(k, None)
|
|
151
|
+
else:
|
|
152
|
+
os.environ[k] = v
|
|
153
|
+
self.addCleanup(restore)
|
|
154
|
+
for k in ENV_KEYS:
|
|
155
|
+
os.environ.pop(k, None)
|
|
156
|
+
os.environ.update({'PATH': f"{self.fakebin}:{saved['PATH'] or '/usr/bin:/bin'}",
|
|
157
|
+
'USER': 'gas', 'CLAUDE_MULTIACC_KEYCHAIN': '1',
|
|
158
|
+
'FAKE_KEYCHAIN_DIR': str(self.kcdir)})
|
|
159
|
+
self.now_ms = int(time.time() * 1000)
|
|
160
|
+
self.secrets = set()
|
|
161
|
+
self.server = None
|
|
162
|
+
|
|
163
|
+
def tearDown(self):
|
|
164
|
+
if self.server is not None:
|
|
165
|
+
self.server.stop()
|
|
166
|
+
|
|
167
|
+
# -- fixtures ---------------------------------------------------------------------
|
|
168
|
+
def d(self, account):
|
|
169
|
+
return str(self.root / account)
|
|
170
|
+
|
|
171
|
+
def entry(self, tag, hours=20.0, refresh=True, as_url='https://clerk.higgsfield.example',
|
|
172
|
+
**extra):
|
|
173
|
+
"""An MCP entry shaped like the client's own (2.1.303). ``hours=None``: saved
|
|
174
|
+
without an expiry, as the client does when the token answer had no expires_in."""
|
|
175
|
+
e = {'serverName': 'higgsfield', 'serverUrl': SERVER_URL,
|
|
176
|
+
'accessToken': f'fake-mcp-access-{tag}', 'scope': 'openid offline_access',
|
|
177
|
+
'issuer': as_url, 'clientId': CLIENT_ID,
|
|
178
|
+
'redirectUri': 'http://localhost:53682/callback',
|
|
179
|
+
'discoveryState': {'authorizationServerUrl': as_url, 'oauthMetadataFound': True,
|
|
180
|
+
'resourceMetadataUrl': f'{SERVER_URL}/.well-known/oauth-protected-resource'}}
|
|
181
|
+
if hours is not None:
|
|
182
|
+
e['expiresAt'] = self.now_ms + int(hours * HOUR_MS)
|
|
183
|
+
if refresh:
|
|
184
|
+
e['refreshToken'] = f'fake-mcp-refresh-{tag}'
|
|
185
|
+
e.update(extra)
|
|
186
|
+
self.secrets.update(v for k, v in e.items()
|
|
187
|
+
if k in ('accessToken', 'refreshToken', 'clientSecret') and v)
|
|
188
|
+
return e
|
|
189
|
+
|
|
190
|
+
def stub(self, hours=None):
|
|
191
|
+
"""What a discovery-only client leaves behind (2.1.304 saveDiscoveryState): no
|
|
192
|
+
token, and no expiry unless an earlier entry had one."""
|
|
193
|
+
e = {'serverName': 'higgsfield', 'serverUrl': SERVER_URL, 'accessToken': '',
|
|
194
|
+
'discoveryState': {'authorizationServerUrl': 'https://clerk.higgsfield.example'}}
|
|
195
|
+
if hours is not None:
|
|
196
|
+
e['expiresAt'] = self.now_ms + int(hours * HOUR_MS)
|
|
197
|
+
return e
|
|
198
|
+
|
|
199
|
+
def blanked(self, entry):
|
|
200
|
+
"""What the client leaves after a refused refresh (invalidateCredentials 'tokens')."""
|
|
201
|
+
return dict({k: v for k, v in entry.items() if k != 'refreshToken'},
|
|
202
|
+
accessToken='', expiresAt=0)
|
|
203
|
+
|
|
204
|
+
def login(self, n):
|
|
205
|
+
doc = {'accessToken': f'fake-claude-access-{n}', 'refreshToken': f'fake-claude-refresh-{n}',
|
|
206
|
+
'expiresAt': self.now_ms + 8 * HOUR_MS, 'scopes': ['user:inference', 'user:profile'],
|
|
207
|
+
'subscriptionType': 'max', 'rateLimitTier': 'default_claude_max_20x'}
|
|
208
|
+
self.secrets.update((doc['accessToken'], doc['refreshToken']))
|
|
209
|
+
return doc
|
|
210
|
+
|
|
211
|
+
def put_file(self, account, doc, mtime=None):
|
|
212
|
+
path = self.root / account / '.credentials.json'
|
|
213
|
+
path.write_text(json.dumps(doc, separators=(',', ':')))
|
|
214
|
+
path.chmod(0o600)
|
|
215
|
+
if mtime is not None:
|
|
216
|
+
os.utime(path, (mtime, mtime))
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
def stub_files(self, *accounts):
|
|
220
|
+
"""The file a tmux client writes once it meets the OAuth server (MCP state alone)."""
|
|
221
|
+
for account in accounts:
|
|
222
|
+
self.put_file(account, {'mcpOAuth': {KEY: self.stub()}})
|
|
223
|
+
|
|
224
|
+
def has_file(self, account):
|
|
225
|
+
return (self.root / account / '.credentials.json').exists()
|
|
226
|
+
|
|
227
|
+
def file_doc(self, account):
|
|
228
|
+
return json.loads((self.root / account / '.credentials.json').read_text())
|
|
229
|
+
|
|
230
|
+
def kc_service(self, account):
|
|
231
|
+
return M.keychain.service_name(self.d(account))
|
|
232
|
+
|
|
233
|
+
def kc_put(self, account, doc, name='gas'):
|
|
234
|
+
subprocess.run(['security', 'add-generic-password', '-U', '-a', name, '-s',
|
|
235
|
+
self.kc_service(account), '-X', json.dumps(doc).encode().hex()],
|
|
236
|
+
check=True)
|
|
237
|
+
|
|
238
|
+
def kc_raw(self, account, name='gas'):
|
|
239
|
+
path = self.kcdir / self.kc_service(account) / name / 'secret'
|
|
240
|
+
return path.read_bytes() if path.exists() else None
|
|
241
|
+
|
|
242
|
+
def kc_doc(self, account, name='gas'):
|
|
243
|
+
raw = self.kc_raw(account, name)
|
|
244
|
+
return json.loads(raw) if raw is not None else None
|
|
245
|
+
|
|
246
|
+
def store(self):
|
|
247
|
+
return json.loads((self.root / 'mcp-oauth.json').read_text())
|
|
248
|
+
|
|
249
|
+
def edit_store(self, fn):
|
|
250
|
+
"""Change the store the way time would (an expiry coming due, an old beat)."""
|
|
251
|
+
path = self.root / 'mcp-oauth.json'
|
|
252
|
+
doc = json.loads(path.read_text()) if path.exists() else \
|
|
253
|
+
{'version': 1, 'entries': {}, 'forgotten': {}, 'holds': {}}
|
|
254
|
+
fn(doc)
|
|
255
|
+
path.write_text(json.dumps(doc))
|
|
256
|
+
path.chmod(0o600)
|
|
257
|
+
|
|
258
|
+
def beat(self, ago=0, refresh=True):
|
|
259
|
+
"""The refresher's beat `ensure` shares behind, as a sync leaves it."""
|
|
260
|
+
def set_beat(doc):
|
|
261
|
+
doc['refresher'] = {'at': int(time.time() - ago), 'refresh': refresh}
|
|
262
|
+
self.edit_store(set_beat)
|
|
263
|
+
|
|
264
|
+
def run_cli(self, *argv):
|
|
265
|
+
out, err = io.StringIO(), io.StringIO()
|
|
266
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
267
|
+
rc = M.main([argv[0], '--root', str(self.root), *argv[1:]])
|
|
268
|
+
return rc, out.getvalue(), err.getvalue()
|
|
269
|
+
|
|
270
|
+
def sync(self, *extra):
|
|
271
|
+
rc, out, err = self.run_cli('sync', *extra)
|
|
272
|
+
self.assertEqual(rc, 0, err)
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
def ensure(self, account):
|
|
276
|
+
rc, out, err = self.run_cli('ensure', '--account-dir', self.d(account), '--quiet')
|
|
277
|
+
self.assertEqual(rc, 0, err)
|
|
278
|
+
return out + err
|
|
279
|
+
|
|
280
|
+
def status_json(self):
|
|
281
|
+
rc, out, err = self.run_cli('status', '--json')
|
|
282
|
+
self.assertEqual(rc, 0, err)
|
|
283
|
+
return json.loads(out)
|
|
284
|
+
|
|
285
|
+
def security_calls(self, fn, *args):
|
|
286
|
+
self.calllog.write_text('')
|
|
287
|
+
os.environ['FAKE_SECURITY_LOG'] = str(self.calllog)
|
|
288
|
+
try:
|
|
289
|
+
result = fn(*args)
|
|
290
|
+
finally:
|
|
291
|
+
os.environ.pop('FAKE_SECURITY_LOG', None)
|
|
292
|
+
return result, self.calllog.read_text().splitlines()
|
|
293
|
+
|
|
294
|
+
def auth_server(self, **kwargs):
|
|
295
|
+
self.server = AuthServer(**kwargs)
|
|
296
|
+
return self.server
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --------------------------------------------------------------------------- harvest
|
|
300
|
+
|
|
301
|
+
class HarvestTests(Case):
|
|
302
|
+
def test_the_largest_expiry_wins_and_reaches_every_account(self):
|
|
303
|
+
old, new = self.entry('old', hours=2), self.entry('new', hours=20)
|
|
304
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {KEY: old}})
|
|
305
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
306
|
+
self.stub_files('acct-03')
|
|
307
|
+
out = self.sync()
|
|
308
|
+
rec = self.store()['entries'][KEY]
|
|
309
|
+
self.assertEqual(rec['entry'], new)
|
|
310
|
+
self.assertEqual((rec['source'], rec['store']), ('acct-02', 'file'))
|
|
311
|
+
self.assertIn('harvested_at', rec)
|
|
312
|
+
self.assertEqual(rec['holders'], ['acct-01:file', 'acct-02:file', 'acct-03:file'])
|
|
313
|
+
for account in self.ACCOUNTS:
|
|
314
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], new, account)
|
|
315
|
+
self.assertEqual(self.file_doc('acct-01')['claudeAiOauth'], self.login(1))
|
|
316
|
+
self.assertIn('login from acct-02 (file)', out)
|
|
317
|
+
self.assertEqual(os.stat(self.root / 'mcp-oauth.json').st_mode & 0o777, 0o600)
|
|
318
|
+
|
|
319
|
+
def test_stubs_never_win_and_never_overwrite(self):
|
|
320
|
+
real = self.entry('real', hours=5)
|
|
321
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.stub(hours=500)}})
|
|
322
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: real}})
|
|
323
|
+
self.sync()
|
|
324
|
+
self.assertEqual(self.store()['entries'][KEY]['entry'], real)
|
|
325
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], real) # the stub is replaced
|
|
326
|
+
# a key nobody holds a login for stays out of the store, and nothing is written
|
|
327
|
+
self.put_file('acct-03', {'mcpOAuth': {OTHER_KEY: self.stub()}})
|
|
328
|
+
before = (self.root / 'acct-01' / '.credentials.json').read_bytes()
|
|
329
|
+
self.sync()
|
|
330
|
+
self.assertNotIn(OTHER_KEY, self.store()['entries'])
|
|
331
|
+
self.assertEqual((self.root / 'acct-01' / '.credentials.json').read_bytes(), before)
|
|
332
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][OTHER_KEY], self.stub())
|
|
333
|
+
|
|
334
|
+
def test_ties_prefer_a_refresh_token_then_the_stores_own_copy(self):
|
|
335
|
+
with_rt = self.entry('rt', hours=10)
|
|
336
|
+
without = self.entry('nort', hours=10, refresh=False)
|
|
337
|
+
without['expiresAt'] = with_rt['expiresAt']
|
|
338
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: without}})
|
|
339
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: with_rt}})
|
|
340
|
+
self.sync()
|
|
341
|
+
self.assertEqual(self.store()['entries'][KEY]['entry'], with_rt)
|
|
342
|
+
# an equally ranked OTHER copy never displaces the store's: no flip-flopping
|
|
343
|
+
twin = dict(with_rt, accessToken='fake-mcp-access-twin')
|
|
344
|
+
self.secrets.add('fake-mcp-access-twin')
|
|
345
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: twin}})
|
|
346
|
+
stamp = self.store()['entries'][KEY]['harvested_at']
|
|
347
|
+
self.sync()
|
|
348
|
+
rec = self.store()['entries'][KEY]
|
|
349
|
+
self.assertEqual((rec['entry'], rec['harvested_at']), (with_rt, stamp))
|
|
350
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], twin) # equal rank: left alone
|
|
351
|
+
|
|
352
|
+
def test_a_refused_login_loses_to_any_other_grant(self):
|
|
353
|
+
newest, older = self.entry('newest', hours=20), self.entry('older', hours=3)
|
|
354
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: newest}})
|
|
355
|
+
self.sync()
|
|
356
|
+
self.edit_store(lambda doc: doc['entries'][KEY].update(needs_reauth=True))
|
|
357
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: older}})
|
|
358
|
+
self.sync()
|
|
359
|
+
rec = self.store()['entries'][KEY]
|
|
360
|
+
self.assertEqual(rec['entry'], older)
|
|
361
|
+
self.assertNotIn('needs_reauth', rec)
|
|
362
|
+
|
|
363
|
+
def test_keychain_items_are_harvested_only_when_this_session_can_read_them(self):
|
|
364
|
+
held = self.entry('keychain', hours=30)
|
|
365
|
+
self.kc_put('acct-02', {'claudeAiOauth': self.login(2), 'mcpOAuth': {KEY: held}})
|
|
366
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.entry('file', hours=5)}})
|
|
367
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
368
|
+
self.sync()
|
|
369
|
+
self.assertEqual(self.store()['entries'][KEY]['source'], 'acct-01')
|
|
370
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
371
|
+
self.sync()
|
|
372
|
+
rec = self.store()['entries'][KEY]
|
|
373
|
+
self.assertEqual((rec['entry'], rec['source'], rec['store']), (held, 'acct-02', 'keychain'))
|
|
374
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], held)
|
|
375
|
+
self.assertEqual(self.kc_doc('acct-02')['claudeAiOauth'], self.login(2))
|
|
376
|
+
|
|
377
|
+
def test_a_login_saved_without_an_expiry_is_never_older_than_every_copy(self):
|
|
378
|
+
# The client's saveTokens drops expiresAt when the token answer has no expires_in.
|
|
379
|
+
# Ranked as "no expiry = oldest", one sync put the other accounts' older copy back
|
|
380
|
+
# over the rotated grant — in the very account that held it.
|
|
381
|
+
old = self.entry('old', hours=0.5, as_url=NO_AS)
|
|
382
|
+
for account in self.ACCOUNTS:
|
|
383
|
+
self.put_file(account, {'mcpOAuth': {KEY: old}})
|
|
384
|
+
self.sync()
|
|
385
|
+
rotated = self.entry('rotated', hours=None, as_url=NO_AS)
|
|
386
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: rotated}})
|
|
387
|
+
self.sync()
|
|
388
|
+
rec = self.store()['entries'][KEY]
|
|
389
|
+
self.assertEqual(rec['entry']['refreshToken'], rotated['refreshToken'])
|
|
390
|
+
self.assertTrue(rec['expiry_assumed'])
|
|
391
|
+
self.assertAlmostEqual(rec['entry']['expiresAt'] / 1000, time.time() + 3600, delta=60)
|
|
392
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], rotated) # untouched
|
|
393
|
+
for account in ('acct-02', 'acct-03'):
|
|
394
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], rec['entry'], account)
|
|
395
|
+
# ...and a login of ANOTHER grant whose age the expiries cannot settle stays put
|
|
396
|
+
other = self.entry('other', hours=None, as_url=NO_AS)
|
|
397
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: other}})
|
|
398
|
+
self.sync()
|
|
399
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], other)
|
|
400
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], rotated)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ------------------------------------------------------------------------ distribute
|
|
404
|
+
|
|
405
|
+
class DistributeTests(Case):
|
|
406
|
+
def test_a_merge_touches_one_key_and_keeps_everything_else_exactly(self):
|
|
407
|
+
mine = self.entry('mine', hours=1)
|
|
408
|
+
theirs = self.entry('theirs', hours=20)
|
|
409
|
+
unrelated = {'serverName': 'linear', 'accessToken': 'fake-mcp-access-linear',
|
|
410
|
+
'expiresAt': self.now_ms + HOUR_MS}
|
|
411
|
+
self.secrets.add('fake-mcp-access-linear')
|
|
412
|
+
doc = {'claudeAiOauth': self.login(1), 'mcpOAuth': {OTHER_KEY: unrelated, KEY: mine},
|
|
413
|
+
'mcpOAuthClientConfig': {'higgsfield': {'clientId': CLIENT_ID}},
|
|
414
|
+
'somethingNew': [1, 2.5, None, {'ünïcode': '✓'}]}
|
|
415
|
+
path = self.put_file('acct-01', doc, mtime=time.time() - 3600)
|
|
416
|
+
before = os.stat(path)
|
|
417
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: theirs}})
|
|
418
|
+
self.sync()
|
|
419
|
+
after = self.file_doc('acct-01')
|
|
420
|
+
self.assertEqual(list(after), list(doc)) # same keys, same order
|
|
421
|
+
self.assertEqual(after['claudeAiOauth'], doc['claudeAiOauth'])
|
|
422
|
+
self.assertEqual(after['mcpOAuthClientConfig'], doc['mcpOAuthClientConfig'])
|
|
423
|
+
self.assertEqual(after['somethingNew'], doc['somethingNew'])
|
|
424
|
+
self.assertEqual(list(after['mcpOAuth']), [OTHER_KEY, KEY])
|
|
425
|
+
self.assertEqual(after['mcpOAuth'], {OTHER_KEY: unrelated, KEY: theirs})
|
|
426
|
+
st = os.stat(path)
|
|
427
|
+
self.assertEqual(st.st_mode & 0o777, 0o600)
|
|
428
|
+
# an MCP merge is not a login: bin/claude and lib/audit.py read a newer file as one
|
|
429
|
+
self.assertEqual(st.st_mtime_ns, before.st_mtime_ns)
|
|
430
|
+
self.assertEqual(sorted(os.listdir(self.root / 'acct-01')), ['.credentials.json',
|
|
431
|
+
'.mcp-oauth-applied'])
|
|
432
|
+
|
|
433
|
+
def test_a_credentials_file_is_created_for_a_setup_token_account_only(self):
|
|
434
|
+
# Every reader counts a document as a login only when it carries claudeAiOauth
|
|
435
|
+
# (bin/claude file_login, lib/audit.py file_has_login): MCP state alone beside a
|
|
436
|
+
# setup token costs it nothing, and is the very file that account's client writes
|
|
437
|
+
# itself once it meets an OAuth server from a session that cannot open the
|
|
438
|
+
# Keychain. An account with a Keychain login has a store of its own already.
|
|
439
|
+
new = self.entry('n')
|
|
440
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
441
|
+
self.kc_put('acct-03', {'claudeAiOauth': self.login(3)})
|
|
442
|
+
(self.root / 'acct-01' / 'server.token').write_text('fake-setup-token-01')
|
|
443
|
+
self.secrets.add('fake-setup-token-01')
|
|
444
|
+
self.beat()
|
|
445
|
+
self.ensure('acct-01')
|
|
446
|
+
path = self.root / 'acct-01' / '.credentials.json'
|
|
447
|
+
self.assertEqual(self.file_doc('acct-01'), {'mcpOAuth': {KEY: new}})
|
|
448
|
+
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
|
|
449
|
+
self.assertTrue((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
450
|
+
self.assertEqual([n for n in os.listdir(self.root / 'acct-01') if n.endswith('.tmp')], [])
|
|
451
|
+
self.assertIn(f'acct-01 file: created with {KEY}', (self.root / 'ops.log').read_text())
|
|
452
|
+
self.ensure('acct-03')
|
|
453
|
+
out = self.sync()
|
|
454
|
+
self.assertFalse(self.has_file('acct-03'))
|
|
455
|
+
self.assertTrue((self.root / 'acct-03' / '.mcp-oauth-applied').exists())
|
|
456
|
+
self.assertEqual(self.kc_doc('acct-03')['mcpOAuth'][KEY], new)
|
|
457
|
+
self.assertEqual(self.kc_doc('acct-03')['claudeAiOauth'], self.login(3))
|
|
458
|
+
self.assertNotIn('acct-03 file', out) # absent is not "not reached"
|
|
459
|
+
row = {r['key']: r for r in self.status_json()['keys']}[KEY]
|
|
460
|
+
self.assertEqual(row['accounts']['acct-01'], {'file': 'fresh', 'keychain': 'absent'})
|
|
461
|
+
self.assertEqual(row['accounts']['acct-03'], {'file': 'absent', 'keychain': 'fresh'})
|
|
462
|
+
self.assertIn('acct-01:file', self.store()['entries'][KEY]['holders'])
|
|
463
|
+
# a discovery stub the client wrote first is merged into, like any file
|
|
464
|
+
self.stub_files('acct-03')
|
|
465
|
+
self.ensure('acct-03')
|
|
466
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], new)
|
|
467
|
+
|
|
468
|
+
def test_no_file_is_created_where_it_could_cost_anything(self):
|
|
469
|
+
new = self.entry('n')
|
|
470
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
471
|
+
for account in ('acct-01', 'acct-03'):
|
|
472
|
+
(self.root / account / 'server.token').write_text('fake-setup-token-x')
|
|
473
|
+
self.secrets.add('fake-setup-token-x')
|
|
474
|
+
# sharing paused (no refreshing sync behind this launch): nothing to share, no file
|
|
475
|
+
self.ensure('acct-01')
|
|
476
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
477
|
+
(self.root / 'acct-01' / '.expired').write_text('reason=auth-error\n') # parked
|
|
478
|
+
link = self.root / 'acct-03' / '.credentials.json'
|
|
479
|
+
link.symlink_to(Path(self.work.name) / 'gone.json') # a name taken
|
|
480
|
+
self.sync()
|
|
481
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
482
|
+
self.assertTrue(link.is_symlink() and not link.exists())
|
|
483
|
+
# an empty token is no token; an adopted dir is ~/.claude itself
|
|
484
|
+
(self.root / 'acct-01' / '.expired').unlink()
|
|
485
|
+
(self.root / 'acct-01' / 'server.token').write_text('')
|
|
486
|
+
home = Path(self.work.name) / 'home-claude'
|
|
487
|
+
home.mkdir()
|
|
488
|
+
(home / 'server.token').write_text('fake-setup-token-x')
|
|
489
|
+
(self.root / 'acct-04').symlink_to(home)
|
|
490
|
+
(self.root / 'accounts.json').write_text(json.dumps(
|
|
491
|
+
{'version': 1, 'accounts': [{'id': a} for a in self.ACCOUNTS + ('acct-04',)]}))
|
|
492
|
+
self.sync()
|
|
493
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
494
|
+
self.assertFalse((home / '.credentials.json').exists())
|
|
495
|
+
# once back in the pool with a token, the account gets its file
|
|
496
|
+
(self.root / 'acct-01' / 'server.token').write_text('fake-setup-token-x')
|
|
497
|
+
self.sync()
|
|
498
|
+
self.assertEqual(self.file_doc('acct-01'), {'mcpOAuth': {KEY: new}})
|
|
499
|
+
|
|
500
|
+
def test_a_file_the_client_writes_first_is_merged_into(self):
|
|
501
|
+
# The client takes the same lock, but a name that is free when the merge looks may
|
|
502
|
+
# not be free when the file lands: the new file appears only where none is, and a
|
|
503
|
+
# file the client wrote meanwhile is merged into instead, its own state kept.
|
|
504
|
+
new = self.entry('n')
|
|
505
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
506
|
+
(self.root / 'acct-01' / 'server.token').write_text('fake-setup-token-01')
|
|
507
|
+
self.secrets.add('fake-setup-token-01')
|
|
508
|
+
theirs = {'serverName': 'linear', 'accessToken': 'fake-mcp-access-linear',
|
|
509
|
+
'expiresAt': self.now_ms + HOUR_MS}
|
|
510
|
+
self.secrets.add('fake-mcp-access-linear')
|
|
511
|
+
real_scratch = M._scratch
|
|
512
|
+
calls = []
|
|
513
|
+
|
|
514
|
+
def racing_scratch(directory, name, data, mode=0o600):
|
|
515
|
+
scratch = real_scratch(directory, name, data, mode)
|
|
516
|
+
if directory == str(self.root / 'acct-01') and not calls:
|
|
517
|
+
calls.append(1)
|
|
518
|
+
self.put_file('acct-01', {'mcpOAuth': {OTHER_KEY: theirs}})
|
|
519
|
+
return scratch
|
|
520
|
+
|
|
521
|
+
M._scratch = racing_scratch
|
|
522
|
+
self.addCleanup(setattr, M, '_scratch', real_scratch)
|
|
523
|
+
self.beat()
|
|
524
|
+
self.ensure('acct-01')
|
|
525
|
+
self.assertEqual(calls, [1])
|
|
526
|
+
self.assertEqual(self.file_doc('acct-01'), {'mcpOAuth': {OTHER_KEY: theirs, KEY: new}})
|
|
527
|
+
self.assertEqual([n for n in os.listdir(self.root / 'acct-01') if n.endswith('.tmp')], [])
|
|
528
|
+
|
|
529
|
+
def test_a_document_this_module_does_not_understand_is_never_rewritten(self):
|
|
530
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('x')}})
|
|
531
|
+
(self.root / 'acct-01' / '.credentials.json').write_text('{"claudeAiOauth": {"accessT')
|
|
532
|
+
self.put_file('acct-03', {'claudeAiOauth': self.login(3), 'mcpOAuth': ['not', 'a', 'map']})
|
|
533
|
+
raw1 = (self.root / 'acct-01' / '.credentials.json').read_bytes()
|
|
534
|
+
raw3 = (self.root / 'acct-03' / '.credentials.json').read_bytes()
|
|
535
|
+
out = self.sync()
|
|
536
|
+
self.assertEqual((self.root / 'acct-01' / '.credentials.json').read_bytes(), raw1)
|
|
537
|
+
self.assertEqual((self.root / 'acct-03' / '.credentials.json').read_bytes(), raw3)
|
|
538
|
+
self.assertIn('acct-01 file (corrupt)', out)
|
|
539
|
+
|
|
540
|
+
def test_a_symlinked_credentials_file_keeps_its_link(self):
|
|
541
|
+
real = Path(self.work.name) / 'elsewhere.json'
|
|
542
|
+
real.write_text(json.dumps({'claudeAiOauth': self.login(1)}))
|
|
543
|
+
link = self.root / 'acct-01' / '.credentials.json'
|
|
544
|
+
link.symlink_to(real)
|
|
545
|
+
new = self.entry('n')
|
|
546
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
547
|
+
self.sync()
|
|
548
|
+
self.assertTrue(link.is_symlink())
|
|
549
|
+
self.assertEqual(json.loads(real.read_text())['mcpOAuth'][KEY], new)
|
|
550
|
+
self.assertEqual(json.loads(real.read_text())['claudeAiOauth'], self.login(1))
|
|
551
|
+
|
|
552
|
+
def test_a_parked_account_gets_no_keychain_write_until_it_is_back(self):
|
|
553
|
+
new = self.entry('n')
|
|
554
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
555
|
+
(self.root / 'acct-01' / '.expired').write_text('reason=auth-error\n')
|
|
556
|
+
(self.root / 'acct-03' / '.expired').write_text('reason=auth-error\n')
|
|
557
|
+
path = self.put_file('acct-03', {'mcpOAuth': {}}, mtime=time.time() - 600)
|
|
558
|
+
before = os.stat(path).st_mtime_ns
|
|
559
|
+
self.kc_put('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {}})
|
|
560
|
+
raw_kc = self.kc_raw('acct-01')
|
|
561
|
+
out = self.sync()
|
|
562
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
563
|
+
self.assertEqual(self.kc_raw('acct-01'), raw_kc)
|
|
564
|
+
# an existing file is merged — and keeps its mtime, so the park stays in force
|
|
565
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], new)
|
|
566
|
+
self.assertEqual(os.stat(path).st_mtime_ns, before)
|
|
567
|
+
self.assertFalse((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
568
|
+
self.assertIn('acct-01 keychain (parked)', out)
|
|
569
|
+
(self.root / 'acct-01' / '.expired').unlink()
|
|
570
|
+
self.sync()
|
|
571
|
+
self.assertEqual(self.kc_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
572
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
573
|
+
|
|
574
|
+
def test_a_held_client_lock_is_waited_on(self):
|
|
575
|
+
new = self.entry('n')
|
|
576
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
577
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1)})
|
|
578
|
+
lock = self.root / 'acct-01' / '.storage-write.lock'
|
|
579
|
+
lock.mkdir()
|
|
580
|
+
timer = threading.Timer(0.4, lock.rmdir)
|
|
581
|
+
timer.start()
|
|
582
|
+
self.addCleanup(timer.cancel)
|
|
583
|
+
self.sync()
|
|
584
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
585
|
+
self.assertFalse(lock.exists())
|
|
586
|
+
|
|
587
|
+
def test_a_lock_held_past_the_wait_leaves_the_account_for_the_next_run(self):
|
|
588
|
+
new = self.entry('n')
|
|
589
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
590
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1)})
|
|
591
|
+
self.beat()
|
|
592
|
+
raw = (self.root / 'acct-01' / '.credentials.json').read_bytes()
|
|
593
|
+
lock = self.root / 'acct-01' / '.storage-write.lock'
|
|
594
|
+
lock.mkdir()
|
|
595
|
+
rc, _out, err = self.run_cli('ensure', '--account-dir', self.d('acct-01'))
|
|
596
|
+
self.assertEqual(rc, 0, err)
|
|
597
|
+
self.assertEqual((self.root / 'acct-01' / '.credentials.json').read_bytes(), raw)
|
|
598
|
+
self.assertTrue(lock.is_dir()) # never ours to remove
|
|
599
|
+
self.assertFalse((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
600
|
+
lock.rmdir()
|
|
601
|
+
self.ensure('acct-01')
|
|
602
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
603
|
+
self.assertTrue((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
604
|
+
|
|
605
|
+
def test_a_stale_client_lock_is_taken_over(self):
|
|
606
|
+
new = self.entry('n')
|
|
607
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
608
|
+
self.stub_files('acct-01')
|
|
609
|
+
self.beat()
|
|
610
|
+
lock = self.root / 'acct-01' / '.storage-write.lock'
|
|
611
|
+
lock.mkdir()
|
|
612
|
+
old = time.time() - 60
|
|
613
|
+
os.utime(lock, (old, old))
|
|
614
|
+
self.ensure('acct-01')
|
|
615
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
616
|
+
self.assertFalse(lock.exists())
|
|
617
|
+
|
|
618
|
+
def test_a_file_rewritten_under_the_merge_is_merged_again(self):
|
|
619
|
+
# The limits pass rotates a login WITHOUT the client's lock: a merge that read the
|
|
620
|
+
# file before that write must not put the old login back.
|
|
621
|
+
new = self.entry('n')
|
|
622
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
623
|
+
path = self.put_file('acct-01', {'claudeAiOauth': self.login(1)})
|
|
624
|
+
rotated = self.login(11)
|
|
625
|
+
real_scratch = M._scratch
|
|
626
|
+
calls = []
|
|
627
|
+
|
|
628
|
+
def racing_scratch(directory, name, data, mode=0o600):
|
|
629
|
+
scratch = real_scratch(directory, name, data, mode)
|
|
630
|
+
if directory == str(self.root / 'acct-01') and not calls:
|
|
631
|
+
calls.append(1)
|
|
632
|
+
path.write_text(json.dumps({'claudeAiOauth': rotated}))
|
|
633
|
+
return scratch
|
|
634
|
+
|
|
635
|
+
M._scratch = racing_scratch
|
|
636
|
+
self.addCleanup(setattr, M, '_scratch', real_scratch)
|
|
637
|
+
self.sync()
|
|
638
|
+
after = self.file_doc('acct-01')
|
|
639
|
+
self.assertEqual(after['claudeAiOauth'], rotated)
|
|
640
|
+
self.assertEqual(after['mcpOAuth'][KEY], new)
|
|
641
|
+
self.assertEqual(calls, [1])
|
|
642
|
+
leftovers = [n for n in os.listdir(self.root / 'acct-01') if n.endswith('.tmp')]
|
|
643
|
+
self.assertEqual(leftovers, [])
|
|
644
|
+
|
|
645
|
+
def test_a_file_holding_a_login_waits_out_a_running_limits_pass(self):
|
|
646
|
+
# ...and a merge must not land between that pass's read and its write either.
|
|
647
|
+
new = self.entry('n')
|
|
648
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
649
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1)})
|
|
650
|
+
self.stub_files('acct-03')
|
|
651
|
+
(self.root / '.locks' / 'limits').mkdir(parents=True)
|
|
652
|
+
raw = (self.root / 'acct-01' / '.credentials.json').read_bytes()
|
|
653
|
+
out = self.sync()
|
|
654
|
+
self.assertEqual((self.root / 'acct-01' / '.credentials.json').read_bytes(), raw)
|
|
655
|
+
self.assertIn('acct-01 file (deferred)', out)
|
|
656
|
+
self.assertFalse((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
657
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], new) # MCP state alone
|
|
658
|
+
(self.root / '.locks' / 'limits').rmdir()
|
|
659
|
+
self.sync()
|
|
660
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# -------------------------------------------------------------------------- keychain
|
|
664
|
+
|
|
665
|
+
class KeychainTests(Case):
|
|
666
|
+
def test_an_item_is_merged_through_stdin_and_read_back(self):
|
|
667
|
+
new = self.entry('n')
|
|
668
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
669
|
+
doc = {'claudeAiOauth': self.login(1), 'mcpOAuth': {KEY: self.stub()}}
|
|
670
|
+
self.kc_put('acct-01', doc)
|
|
671
|
+
_, calls = self.security_calls(self.sync)
|
|
672
|
+
back = self.kc_doc('acct-01')
|
|
673
|
+
self.assertEqual(back['claudeAiOauth'], doc['claudeAiOauth'])
|
|
674
|
+
self.assertEqual(back['mcpOAuth'][KEY], new)
|
|
675
|
+
writes = [c for c in calls if c.startswith('add-generic-password')]
|
|
676
|
+
self.assertEqual(writes, ['add-generic-password a=gas w=0 via=stdin'])
|
|
677
|
+
# ...and NO file beside it: the limits pass and the audit would take a file for
|
|
678
|
+
# the login, and the Keychain login would go dark (KeychainStore: never copied out)
|
|
679
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
680
|
+
|
|
681
|
+
def test_a_secret_printed_as_hex_still_reads_as_the_document(self):
|
|
682
|
+
# `find-generic-password -w` prints a secret it cannot show as text in hex.
|
|
683
|
+
doc = {'claudeAiOauth': {'accessToken': 'fake-claude-access-ü'}, 'mcpOAuth': {}}
|
|
684
|
+
self.secrets.add('fake-claude-access-ü')
|
|
685
|
+
text = json.dumps(doc, ensure_ascii=False).encode('utf-8').hex()
|
|
686
|
+
self.assertEqual(M.keychain._json_object(text + '\n'), doc)
|
|
687
|
+
self.assertIsNone(M.keychain._json_object('7b22')) # hex, but not JSON
|
|
688
|
+
self.assertIsNone(M.keychain._json_object('[1, 2]')) # JSON, not an object
|
|
689
|
+
|
|
690
|
+
def test_a_document_too_long_for_the_stdin_buffer_goes_on_argv(self):
|
|
691
|
+
big = self.entry('big', padding='x' * 3000)
|
|
692
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: big}})
|
|
693
|
+
self.kc_put('acct-01', {'claudeAiOauth': self.login(1)})
|
|
694
|
+
_, calls = self.security_calls(self.sync)
|
|
695
|
+
self.assertIn('add-generic-password a=gas w=0', calls)
|
|
696
|
+
self.assertNotIn('add-generic-password a=gas w=0 via=stdin', calls)
|
|
697
|
+
self.assertEqual(self.kc_doc('acct-01')['mcpOAuth'][KEY], big)
|
|
698
|
+
|
|
699
|
+
def test_a_locked_keychain_means_file_only(self):
|
|
700
|
+
new = self.entry('n')
|
|
701
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
702
|
+
self.kc_put('acct-01', {'claudeAiOauth': self.login(1)})
|
|
703
|
+
self.stub_files('acct-01') # what the client writes when it cannot open it
|
|
704
|
+
raw = self.kc_raw('acct-01')
|
|
705
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
706
|
+
_, calls = self.security_calls(self.sync)
|
|
707
|
+
self.assertEqual(self.kc_raw('acct-01'), raw)
|
|
708
|
+
self.assertFalse([c for c in calls if c.startswith('add-generic-password')])
|
|
709
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
710
|
+
|
|
711
|
+
def test_an_absent_item_is_never_created(self):
|
|
712
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
713
|
+
_, calls = self.security_calls(self.sync)
|
|
714
|
+
self.assertFalse([c for c in calls if c.startswith('add-generic-password')])
|
|
715
|
+
self.assertFalse(self.kcdir.exists() and any(self.kcdir.iterdir()))
|
|
716
|
+
self.assertFalse(self.has_file('acct-01') or self.has_file('acct-03'))
|
|
717
|
+
|
|
718
|
+
def test_a_read_back_mismatch_holds_the_item(self):
|
|
719
|
+
# A `security` that answers 0 to a write and stores nothing.
|
|
720
|
+
tool = self.fakebin / 'security'
|
|
721
|
+
tool.rename(self.fakebin / 'security-real')
|
|
722
|
+
tool.write_text('#!/usr/bin/env bash\n'
|
|
723
|
+
'[ "$1" = "-i" ] && { cat >/dev/null; exit 0; }\n'
|
|
724
|
+
f'exec "{self.fakebin}/security-real" "$@"\n')
|
|
725
|
+
tool.chmod(0o755)
|
|
726
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
727
|
+
self.kc_put('acct-01', {'claudeAiOauth': self.login(1)})
|
|
728
|
+
out = self.sync()
|
|
729
|
+
holds = self.store()['holds']
|
|
730
|
+
self.assertEqual(list(holds), ['acct-01'])
|
|
731
|
+
self.assertIn('acct-01 keychain (mismatch)', out)
|
|
732
|
+
_, calls = self.security_calls(self.sync)
|
|
733
|
+
self.assertFalse([c for c in calls if c.startswith('add-generic-password')])
|
|
734
|
+
rc, status, _ = self.run_cli('status')
|
|
735
|
+
self.assertIn('hold: acct-01 keychain', status)
|
|
736
|
+
|
|
737
|
+
def test_a_running_limits_pass_defers_an_item_holding_a_login(self):
|
|
738
|
+
new = self.entry('n')
|
|
739
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
740
|
+
self.kc_put('acct-01', {'claudeAiOauth': self.login(1)})
|
|
741
|
+
(self.root / '.locks' / 'limits').mkdir(parents=True)
|
|
742
|
+
raw = self.kc_raw('acct-01')
|
|
743
|
+
out = self.sync()
|
|
744
|
+
self.assertEqual(self.kc_raw('acct-01'), raw)
|
|
745
|
+
self.assertIn('acct-01 keychain (deferred)', out)
|
|
746
|
+
self.assertFalse(self.has_file('acct-01'))
|
|
747
|
+
self.assertFalse((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
748
|
+
(self.root / '.locks' / 'limits').rmdir()
|
|
749
|
+
self.sync()
|
|
750
|
+
self.assertEqual(self.kc_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# --------------------------------------------------------------------------- refresh
|
|
754
|
+
|
|
755
|
+
class RefreshTests(Case):
|
|
756
|
+
def setUp(self):
|
|
757
|
+
super().setUp()
|
|
758
|
+
self.as_ = self.auth_server()
|
|
759
|
+
|
|
760
|
+
def due(self, tag='due', **extra):
|
|
761
|
+
return self.entry(tag, hours=1, as_url=self.as_.url, **extra)
|
|
762
|
+
|
|
763
|
+
def test_a_due_login_is_refreshed_and_reaches_every_account(self):
|
|
764
|
+
old = self.due()
|
|
765
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {KEY: old}})
|
|
766
|
+
self.stub_files('acct-02', 'acct-03')
|
|
767
|
+
out = self.sync()
|
|
768
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
769
|
+
form = self.as_.requests[0]['form']
|
|
770
|
+
self.assertEqual(form, {'grant_type': 'refresh_token', 'refresh_token': old['refreshToken'],
|
|
771
|
+
'client_id': CLIENT_ID, 'resource': SERVER_URL})
|
|
772
|
+
self.assertIsNone(self.as_.requests[0]['authorization'])
|
|
773
|
+
rec = self.store()['entries'][KEY]
|
|
774
|
+
self.secrets.update(('fake-mcp-access-rotated-1', 'fake-mcp-refresh-rotated-1'))
|
|
775
|
+
self.assertEqual(rec['entry']['accessToken'], 'fake-mcp-access-rotated-1')
|
|
776
|
+
self.assertEqual(rec['entry']['refreshToken'], 'fake-mcp-refresh-rotated-1')
|
|
777
|
+
self.assertAlmostEqual(rec['entry']['expiresAt'] / 1000, time.time() + 86400, delta=60)
|
|
778
|
+
self.assertEqual(rec['lifetime_s'], 86400)
|
|
779
|
+
self.assertIn('refreshed_at', rec)
|
|
780
|
+
self.assertEqual(rec['source'], 'acct-01')
|
|
781
|
+
self.assertEqual(len(rec['superseded']), 2) # both old tokens, as digests
|
|
782
|
+
self.assertNotIn('fake-mcp', json.dumps(rec['superseded']))
|
|
783
|
+
for account in self.ACCOUNTS:
|
|
784
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], rec['entry'], account)
|
|
785
|
+
self.assertEqual(rec['holders'], ['acct-01:file', 'acct-02:file', 'acct-03:file'])
|
|
786
|
+
self.assertEqual(self.file_doc('acct-01')['claudeAiOauth'], self.login(1))
|
|
787
|
+
self.assertIn('refreshed now', out)
|
|
788
|
+
self.sync()
|
|
789
|
+
self.assertEqual(len(self.as_.requests), 1) # fresh for 24 h: nothing due
|
|
790
|
+
|
|
791
|
+
def test_a_refresh_without_a_new_refresh_token_keeps_the_old_one(self):
|
|
792
|
+
old = self.due()
|
|
793
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: old}})
|
|
794
|
+
self.as_.answers.append((200, {'access_token': 'fake-mcp-access-only', 'scope': 'openid'}))
|
|
795
|
+
self.secrets.add('fake-mcp-access-only')
|
|
796
|
+
self.sync()
|
|
797
|
+
rec = self.store()['entries'][KEY]
|
|
798
|
+
entry = rec['entry']
|
|
799
|
+
self.assertEqual(entry['accessToken'], 'fake-mcp-access-only')
|
|
800
|
+
self.assertEqual(entry['refreshToken'], old['refreshToken'])
|
|
801
|
+
self.assertEqual(entry['scope'], 'openid')
|
|
802
|
+
# no expires_in: an assumed hour, as refresh_oauth does — never no expiry at all
|
|
803
|
+
self.assertAlmostEqual(entry['expiresAt'] / 1000, time.time() + 3600, delta=60)
|
|
804
|
+
self.assertTrue(rec['expiry_assumed'])
|
|
805
|
+
self.assertEqual(len(rec['superseded']), 1) # the access token alone
|
|
806
|
+
|
|
807
|
+
def test_a_rotated_login_without_expires_in_stays_the_winner_everywhere(self):
|
|
808
|
+
# Probe A: dropping the expiry ranked the rotated grant below every old copy — it
|
|
809
|
+
# was shared nowhere, then replaced from the accounts, and the old (rotated-away)
|
|
810
|
+
# refresh token was posted again.
|
|
811
|
+
old = self.due()
|
|
812
|
+
for account in self.ACCOUNTS:
|
|
813
|
+
self.put_file(account, {'mcpOAuth': {KEY: old}})
|
|
814
|
+
self.as_.answers.append((200, {'access_token': 'fake-mcp-access-r2',
|
|
815
|
+
'refresh_token': 'fake-mcp-refresh-r2'}))
|
|
816
|
+
self.secrets.update(('fake-mcp-access-r2', 'fake-mcp-refresh-r2'))
|
|
817
|
+
self.sync()
|
|
818
|
+
rec = self.store()['entries'][KEY]
|
|
819
|
+
self.assertEqual(rec['entry']['refreshToken'], 'fake-mcp-refresh-r2')
|
|
820
|
+
for account in self.ACCOUNTS:
|
|
821
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], rec['entry'], account)
|
|
822
|
+
# an old copy surfacing from a store the run could not reach changes nothing
|
|
823
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: old}})
|
|
824
|
+
for _ in range(3):
|
|
825
|
+
self.sync()
|
|
826
|
+
self.assertEqual(self.store()['entries'][KEY]['entry'], rec['entry'])
|
|
827
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], rec['entry'])
|
|
828
|
+
posted = [r['form']['refresh_token'] for r in self.as_.requests]
|
|
829
|
+
self.assertEqual(posted, [old['refreshToken']])
|
|
830
|
+
|
|
831
|
+
def test_a_short_lived_login_is_refreshed_at_half_life_not_on_every_pass(self):
|
|
832
|
+
self.as_.lifetime = 3600
|
|
833
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.due()}})
|
|
834
|
+
self.sync()
|
|
835
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
836
|
+
clock = os.stat(self.root / 'mcp-oauth.json').st_mtime
|
|
837
|
+
for _ in range(3):
|
|
838
|
+
self.sync()
|
|
839
|
+
self.assertEqual(len(self.as_.requests), 1) # 1 h left of 1 h: not due yet
|
|
840
|
+
self.assertEqual(os.stat(self.root / 'mcp-oauth.json').st_mtime, clock)
|
|
841
|
+
|
|
842
|
+
def age(minutes_left, refreshed_ago):
|
|
843
|
+
def edit(doc):
|
|
844
|
+
rec = doc['entries'][KEY]
|
|
845
|
+
rec['entry']['expiresAt'] = int((time.time() + minutes_left * 60) * 1000)
|
|
846
|
+
rec['refreshed_at'] = M._now_iso(time.time() - refreshed_ago * 60)
|
|
847
|
+
self.edit_store(edit)
|
|
848
|
+
age(20, 10) # due, but refreshed 10 min ago
|
|
849
|
+
self.sync()
|
|
850
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
851
|
+
age(25, 35) # inside half its lifetime: due
|
|
852
|
+
self.sync()
|
|
853
|
+
self.assertEqual(len(self.as_.requests), 2)
|
|
854
|
+
|
|
855
|
+
def test_a_login_that_is_not_due_sends_nothing(self):
|
|
856
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.entry('fresh', hours=20,
|
|
857
|
+
as_url=self.as_.url)}})
|
|
858
|
+
self.sync()
|
|
859
|
+
self.assertEqual((self.as_.requests, self.as_.metadata_hits), ([], []))
|
|
860
|
+
|
|
861
|
+
def test_a_failed_refresh_backs_off_and_changes_nothing(self):
|
|
862
|
+
old = self.due()
|
|
863
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: old}})
|
|
864
|
+
self.stub_files('acct-02', 'acct-03')
|
|
865
|
+
self.as_.answers.append((503, {'error': 'temporarily_unavailable'}))
|
|
866
|
+
out = self.sync()
|
|
867
|
+
rec = self.store()['entries'][KEY]
|
|
868
|
+
self.assertEqual(rec['entry'], old)
|
|
869
|
+
for account in self.ACCOUNTS:
|
|
870
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], old) # still in time
|
|
871
|
+
self.assertEqual(rec['refresh']['failures'], 1)
|
|
872
|
+
self.assertEqual(rec['refresh']['last_error'], 'http 503 temporarily_unavailable')
|
|
873
|
+
self.assertAlmostEqual(rec['refresh']['next_at'], time.time() + 900, delta=30)
|
|
874
|
+
self.assertIn('refresh failed (http 503', out)
|
|
875
|
+
self.sync() # still backing off
|
|
876
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
877
|
+
self.as_.answers.append((502, {}))
|
|
878
|
+
self.sync('--force')
|
|
879
|
+
rec = self.store()['entries'][KEY]
|
|
880
|
+
self.assertEqual((len(self.as_.requests), rec['refresh']['failures']), (2, 2))
|
|
881
|
+
self.assertAlmostEqual(rec['refresh']['next_at'], time.time() + 1800, delta=30)
|
|
882
|
+
|
|
883
|
+
def test_a_refresh_failing_too_close_to_expiry_stops_sharing(self):
|
|
884
|
+
# The next try would come after the clients' own 5-minute window: they refresh it
|
|
885
|
+
# themselves then, each under its own config dir's lock — one grant, many refreshers.
|
|
886
|
+
old = self.entry('late', hours=25 / 60, as_url=self.as_.url)
|
|
887
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: old}})
|
|
888
|
+
self.stub_files('acct-02')
|
|
889
|
+
self.as_.answers.append((503, {'error': 'temporarily_unavailable'}))
|
|
890
|
+
out = self.sync()
|
|
891
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], self.stub())
|
|
892
|
+
self.assertIn('NOT shared: its refresh keeps failing', out)
|
|
893
|
+
row = {r['key']: r for r in self.status_json()['keys']}[KEY]
|
|
894
|
+
self.assertFalse(row['shared'])
|
|
895
|
+
self.sync('--force') # a refresh that lands: shared
|
|
896
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY],
|
|
897
|
+
self.store()['entries'][KEY]['entry'])
|
|
898
|
+
self.secrets.update(('fake-mcp-access-rotated-1', 'fake-mcp-refresh-rotated-1'))
|
|
899
|
+
|
|
900
|
+
def test_invalid_grant_marks_the_login_needs_reauth(self):
|
|
901
|
+
old = self.due()
|
|
902
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: old}})
|
|
903
|
+
self.stub_files('acct-02')
|
|
904
|
+
self.as_.answers.append((400, {'error': 'invalid_grant', 'error_description': 'revoked'}))
|
|
905
|
+
out = self.sync()
|
|
906
|
+
rec = self.store()['entries'][KEY]
|
|
907
|
+
self.assertTrue(rec['needs_reauth'])
|
|
908
|
+
self.assertEqual(rec['entry'], old) # left in place...
|
|
909
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], self.stub()) # ...unshared
|
|
910
|
+
self.assertIn('NEEDS RE-AUTH', out)
|
|
911
|
+
self.sync('--force')
|
|
912
|
+
self.assertEqual(len(self.as_.requests), 1) # never retried with a dead grant
|
|
913
|
+
rc, status, _ = self.run_cli('status')
|
|
914
|
+
self.assertIn('NEEDS RE-AUTH', status)
|
|
915
|
+
# a NEW sign-in anywhere replaces it and clears the verdict
|
|
916
|
+
fresh = self.entry('relogin', hours=20, as_url=self.as_.url)
|
|
917
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: fresh}})
|
|
918
|
+
self.sync()
|
|
919
|
+
rec = self.store()['entries'][KEY]
|
|
920
|
+
self.assertEqual(rec['entry'], fresh)
|
|
921
|
+
self.assertNotIn('needs_reauth', rec)
|
|
922
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], fresh)
|
|
923
|
+
|
|
924
|
+
def test_a_copy_in_a_keychain_item_this_run_cannot_open_defers_the_refresh(self):
|
|
925
|
+
# Rotating it would leave that item's client — a GUI session — with a dead refresh
|
|
926
|
+
# token. A run that can open the item refreshes it, until expiry is close.
|
|
927
|
+
grant = self.entry('kc', hours=3, as_url=self.as_.url)
|
|
928
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: grant}})
|
|
929
|
+
self.kc_put('acct-02', {'claudeAiOauth': self.login(2), 'mcpOAuth': {}})
|
|
930
|
+
self.sync()
|
|
931
|
+
self.assertEqual(self.store()['entries'][KEY]['holders'], ['acct-01:file', 'acct-02:keychain'])
|
|
932
|
+
|
|
933
|
+
def left(minutes):
|
|
934
|
+
self.edit_store(lambda doc: doc['entries'][KEY]['entry'].update(
|
|
935
|
+
expiresAt=int((time.time() + minutes * 60) * 1000)))
|
|
936
|
+
left(60)
|
|
937
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
938
|
+
out = self.sync()
|
|
939
|
+
self.assertEqual(self.as_.requests, [])
|
|
940
|
+
self.assertIn('refresh deferred (a copy sits in acct-02 keychain', out)
|
|
941
|
+
rc, status, _ = self.run_cli('status')
|
|
942
|
+
self.assertIn('deferred: a copy sits in acct-02 keychain', status)
|
|
943
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
944
|
+
self.sync() # this run can open it
|
|
945
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
946
|
+
rec = self.store()['entries'][KEY]
|
|
947
|
+
self.assertEqual(self.kc_doc('acct-02')['mcpOAuth'][KEY], rec['entry'])
|
|
948
|
+
self.assertNotIn('deferred', rec)
|
|
949
|
+
self.secrets.update(('fake-mcp-access-rotated-1', 'fake-mcp-refresh-rotated-1'))
|
|
950
|
+
# close to expiry, a run that cannot open it refreshes anyway: every client would
|
|
951
|
+
# otherwise refresh the one grant on its own
|
|
952
|
+
left(10)
|
|
953
|
+
self.edit_store(lambda doc: doc['entries'][KEY].update(
|
|
954
|
+
refreshed_at=M._now_iso(time.time() - 86400 + 600)))
|
|
955
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
956
|
+
self.sync()
|
|
957
|
+
self.assertEqual(len(self.as_.requests), 2)
|
|
958
|
+
self.secrets.update(('fake-mcp-access-rotated-2', 'fake-mcp-refresh-rotated-2'))
|
|
959
|
+
|
|
960
|
+
def test_a_client_secret_goes_in_the_body_unless_the_server_takes_basic_only(self):
|
|
961
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.due(clientSecret='fake-client-secret')}})
|
|
962
|
+
self.sync()
|
|
963
|
+
form = self.as_.requests[0]['form']
|
|
964
|
+
self.assertEqual((form['client_id'], form['client_secret']), (CLIENT_ID, 'fake-client-secret'))
|
|
965
|
+
self.as_.stop()
|
|
966
|
+
self.server = self.as_ = AuthServer(methods=['client_secret_basic'])
|
|
967
|
+
self.put_file('acct-02', {'mcpOAuth': {OTHER_KEY: self.entry(
|
|
968
|
+
'basic', hours=1, as_url=self.as_.url, clientSecret='fake-client-secret')}})
|
|
969
|
+
self.sync()
|
|
970
|
+
request = self.as_.requests[0]
|
|
971
|
+
self.assertNotIn('client_secret', request['form'])
|
|
972
|
+
self.assertNotIn('client_id', request['form'])
|
|
973
|
+
pair = base64.b64decode(request['authorization'].split(' ', 1)[1]).decode()
|
|
974
|
+
self.assertEqual(pair, f"{urllib.parse.quote(CLIENT_ID, safe='')}:fake-client-secret")
|
|
975
|
+
|
|
976
|
+
def test_metadata_is_found_by_path_insertion_or_at_the_discovered_url(self):
|
|
977
|
+
self.as_.stop()
|
|
978
|
+
self.server = self.as_ = AuthServer(base_path='/tenant-a')
|
|
979
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.due()}})
|
|
980
|
+
self.sync()
|
|
981
|
+
self.assertEqual(self.as_.metadata_hits, ['/.well-known/oauth-authorization-server/tenant-a'])
|
|
982
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
983
|
+
self.as_.stop()
|
|
984
|
+
self.server = self.as_ = AuthServer(meta_path='/custom/metadata')
|
|
985
|
+
entry = self.due('custom')
|
|
986
|
+
entry['discoveryState']['authServerMetadataUrl'] = f'{self.as_.origin}/custom/metadata'
|
|
987
|
+
self.put_file('acct-02', {'mcpOAuth': {OTHER_KEY: entry}})
|
|
988
|
+
self.sync()
|
|
989
|
+
self.assertEqual(self.as_.metadata_hits, ['/custom/metadata'])
|
|
990
|
+
self.assertEqual(len(self.as_.requests), 1)
|
|
991
|
+
|
|
992
|
+
def test_a_refresh_token_never_travels_over_plain_http_to_another_host(self):
|
|
993
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.entry('remote', hours=1, as_url=NO_AS)}})
|
|
994
|
+
self.sync()
|
|
995
|
+
rec = self.store()['entries'][KEY]
|
|
996
|
+
self.assertEqual(rec['refresh']['last_error'], 'no usable authorization server URL')
|
|
997
|
+
self.assertEqual(self.as_.requests, [])
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
# ---------------------------------------------------------------- cleared in an account
|
|
1001
|
+
|
|
1002
|
+
class ClearTests(Case):
|
|
1003
|
+
"""/mcp "Clear authentication", `claude mcp login` and a refused refresh revoke or
|
|
1004
|
+
refuse the login AT THE SERVER before the client drops it — and under the pool that
|
|
1005
|
+
login is every account's. It is retired everywhere, never merged back."""
|
|
1006
|
+
|
|
1007
|
+
def shared(self):
|
|
1008
|
+
grant = self.entry('g')
|
|
1009
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {KEY: grant}})
|
|
1010
|
+
self.stub_files('acct-02', 'acct-03')
|
|
1011
|
+
self.sync()
|
|
1012
|
+
self.assertEqual(self.store()['entries'][KEY]['holders'],
|
|
1013
|
+
['acct-01:file', 'acct-02:file', 'acct-03:file'])
|
|
1014
|
+
return grant
|
|
1015
|
+
|
|
1016
|
+
def test_a_login_cleared_in_one_account_is_retired_in_every_account(self):
|
|
1017
|
+
self.shared()
|
|
1018
|
+
self.put_file('acct-02', {'mcpOAuth': {}}) # Clear authentication: key gone
|
|
1019
|
+
out = self.sync()
|
|
1020
|
+
store = self.store()
|
|
1021
|
+
self.assertNotIn(KEY, store['entries'])
|
|
1022
|
+
self.assertEqual(store['forgotten'][KEY]['reason'], 'cleared in acct-02 file')
|
|
1023
|
+
self.assertIn('login retired (cleared in acct-02 file)', out)
|
|
1024
|
+
for account in self.ACCOUNTS:
|
|
1025
|
+
self.assertNotIn(KEY, self.file_doc(account)['mcpOAuth'], account)
|
|
1026
|
+
self.assertEqual(self.file_doc('acct-01')['claudeAiOauth'], self.login(1))
|
|
1027
|
+
rc, status, _ = self.run_cli('status')
|
|
1028
|
+
self.assertIn('login: none — retired', status)
|
|
1029
|
+
self.assertNotIn('valid until', status)
|
|
1030
|
+
self.assertIn('the login was cleared in acct-02 file', (self.root / 'ops.log').read_text())
|
|
1031
|
+
# one new sign-in anywhere is shared again
|
|
1032
|
+
fresh = self.entry('fresh')
|
|
1033
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: fresh}})
|
|
1034
|
+
self.sync()
|
|
1035
|
+
for account in self.ACCOUNTS:
|
|
1036
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'].get(KEY), fresh, account)
|
|
1037
|
+
|
|
1038
|
+
def test_a_blanked_login_is_retired_at_the_next_launch(self):
|
|
1039
|
+
grant = self.shared()
|
|
1040
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: self.blanked(grant)}}) # refused refresh
|
|
1041
|
+
self.ensure('acct-02') # no network, no sync
|
|
1042
|
+
self.assertNotIn(KEY, self.store()['entries'])
|
|
1043
|
+
self.assertNotIn(KEY, self.file_doc('acct-02')['mcpOAuth'])
|
|
1044
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], self.blanked(grant))
|
|
1045
|
+
|
|
1046
|
+
def test_a_stub_that_never_held_the_login_is_filled_not_retired(self):
|
|
1047
|
+
grant = self.entry('g')
|
|
1048
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: grant}})
|
|
1049
|
+
self.sync() # acct-02 had no file yet
|
|
1050
|
+
self.stub_files('acct-02') # its client meets the server
|
|
1051
|
+
self.sync()
|
|
1052
|
+
self.assertEqual(self.store()['entries'][KEY]['entry'], grant)
|
|
1053
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], grant)
|
|
1054
|
+
|
|
1055
|
+
def test_a_store_gone_or_rewritten_without_mcp_state_says_nothing(self):
|
|
1056
|
+
grant = self.shared()
|
|
1057
|
+
(self.root / 'acct-02' / '.credentials.json').unlink()
|
|
1058
|
+
self.put_file('acct-03', {'claudeAiOauth': self.login(3)}) # a re-login, say
|
|
1059
|
+
self.sync()
|
|
1060
|
+
rec = self.store()['entries'][KEY]
|
|
1061
|
+
self.assertEqual(rec['entry'], grant)
|
|
1062
|
+
self.assertEqual(rec['holders'], ['acct-01:file', 'acct-03:file'])
|
|
1063
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], grant)
|
|
1064
|
+
self.assertFalse(self.has_file('acct-02'))
|
|
1065
|
+
|
|
1066
|
+
def test_a_holder_whose_client_rotated_the_login_is_no_clear(self):
|
|
1067
|
+
self.shared()
|
|
1068
|
+
newer = self.entry('newer', hours=24)
|
|
1069
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: newer}})
|
|
1070
|
+
self.sync()
|
|
1071
|
+
self.assertEqual(self.store()['entries'][KEY]['entry'], newer)
|
|
1072
|
+
for account in self.ACCOUNTS:
|
|
1073
|
+
self.assertEqual(self.file_doc(account)['mcpOAuth'][KEY], newer, account)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
# ---------------------------------------------------------------------- kill switches
|
|
1077
|
+
|
|
1078
|
+
class SwitchTests(Case):
|
|
1079
|
+
def test_the_kill_switch_stops_ensure_and_sync(self):
|
|
1080
|
+
os.environ['CLAUDE_MULTIACC_MCP_OAUTH'] = '0'
|
|
1081
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
1082
|
+
self.ensure('acct-01')
|
|
1083
|
+
out = self.sync()
|
|
1084
|
+
self.assertIn('off (CLAUDE_MULTIACC_MCP_OAUTH=0)', out)
|
|
1085
|
+
self.assertFalse((self.root / 'mcp-oauth.json').exists())
|
|
1086
|
+
self.assertFalse((self.root / 'acct-01' / '.credentials.json').exists())
|
|
1087
|
+
rc, status, _ = self.run_cli('status')
|
|
1088
|
+
self.assertEqual(rc, 0)
|
|
1089
|
+
self.assertIn('sharing OFF', status)
|
|
1090
|
+
|
|
1091
|
+
def test_the_refresh_switch_never_refreshes_and_so_never_shares(self):
|
|
1092
|
+
# One shared login needs ONE refresher: without the pool's, every account holding
|
|
1093
|
+
# it would refresh it on its own.
|
|
1094
|
+
as_ = self.auth_server()
|
|
1095
|
+
os.environ['CLAUDE_MULTIACC_MCP_OAUTH_REFRESH'] = '0'
|
|
1096
|
+
due = self.entry('due', hours=1, as_url=as_.url)
|
|
1097
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: due}})
|
|
1098
|
+
self.stub_files('acct-01')
|
|
1099
|
+
out = self.sync()
|
|
1100
|
+
self.assertEqual(as_.requests, [])
|
|
1101
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], self.stub())
|
|
1102
|
+
self.assertIn('NOT shared: refresh is off', out)
|
|
1103
|
+
rc, status, _ = self.run_cli('status')
|
|
1104
|
+
self.assertIn('refresh OFF', status)
|
|
1105
|
+
# a launch with refresh on still shares nothing behind a sync that had it off
|
|
1106
|
+
os.environ.pop('CLAUDE_MULTIACC_MCP_OAUTH_REFRESH')
|
|
1107
|
+
self.ensure('acct-01')
|
|
1108
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], self.stub())
|
|
1109
|
+
|
|
1110
|
+
def test_ensure_shares_only_behind_a_recent_refreshing_sync(self):
|
|
1111
|
+
new = self.entry('n')
|
|
1112
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
1113
|
+
self.stub_files('acct-01', 'acct-03')
|
|
1114
|
+
self.ensure('acct-01') # no sync ever: nothing shared
|
|
1115
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], self.stub())
|
|
1116
|
+
self.assertTrue((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
1117
|
+
rc, status, _ = self.run_cli('status')
|
|
1118
|
+
self.assertIn('sharing paused: no refreshing sync in the last hour (none yet)', status)
|
|
1119
|
+
self.beat(ago=2 * 3600) # one long ago: still nothing
|
|
1120
|
+
self.ensure('acct-03')
|
|
1121
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], self.stub())
|
|
1122
|
+
self.beat()
|
|
1123
|
+
self.ensure('acct-03')
|
|
1124
|
+
self.assertEqual(self.file_doc('acct-03')['mcpOAuth'][KEY], new)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
# ---------------------------------------------------------------------------- ensure
|
|
1128
|
+
|
|
1129
|
+
class EnsureTests(Case):
|
|
1130
|
+
def test_ensure_shares_into_its_own_account_only_and_stamps_it(self):
|
|
1131
|
+
new = self.entry('n')
|
|
1132
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}})
|
|
1133
|
+
self.stub_files('acct-01')
|
|
1134
|
+
self.put_file('acct-03', {'claudeAiOauth': self.login(3)})
|
|
1135
|
+
self.beat()
|
|
1136
|
+
self.ensure('acct-01')
|
|
1137
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
1138
|
+
self.assertNotIn('mcpOAuth', self.file_doc('acct-03'))
|
|
1139
|
+
store = os.stat(self.root / 'mcp-oauth.json')
|
|
1140
|
+
stamp = os.stat(self.root / 'acct-01' / '.mcp-oauth-applied')
|
|
1141
|
+
self.assertEqual(int(stamp.st_mtime), int(store.st_mtime))
|
|
1142
|
+
self.assertTrue((self.root / '.mcp-oauth-seen').exists())
|
|
1143
|
+
self.assertFalse((self.root / 'acct-03' / '.mcp-oauth-applied').exists())
|
|
1144
|
+
|
|
1145
|
+
def test_ensure_reads_only_its_own_keychain_item(self):
|
|
1146
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
1147
|
+
_, calls = self.security_calls(self.ensure, 'acct-01')
|
|
1148
|
+
self.assertEqual(calls, ['find-generic-password a=gas w=1'])
|
|
1149
|
+
|
|
1150
|
+
def test_a_pool_without_mcp_oauth_state_gets_nothing_written(self):
|
|
1151
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1)})
|
|
1152
|
+
raw = (self.root / 'acct-01' / '.credentials.json').read_bytes()
|
|
1153
|
+
self.ensure('acct-01')
|
|
1154
|
+
out = self.sync()
|
|
1155
|
+
self.assertIn('no MCP OAuth state in any account yet', out)
|
|
1156
|
+
for name in ('mcp-oauth.json', '.mcp-oauth-seen', 'ops.log'):
|
|
1157
|
+
self.assertFalse((self.root / name).exists(), name)
|
|
1158
|
+
self.assertFalse((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
1159
|
+
self.assertEqual((self.root / 'acct-01' / '.credentials.json').read_bytes(), raw)
|
|
1160
|
+
|
|
1161
|
+
def test_an_account_outside_the_manifest_is_left_alone(self):
|
|
1162
|
+
stray = self.root / 'acct-77'
|
|
1163
|
+
stray.mkdir()
|
|
1164
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
1165
|
+
rc, _out, err = self.run_cli('ensure', '--account-dir', str(stray))
|
|
1166
|
+
self.assertEqual(rc, 0, err)
|
|
1167
|
+
self.assertFalse((stray / '.credentials.json').exists())
|
|
1168
|
+
|
|
1169
|
+
def test_the_store_clock_moves_a_whole_second_per_change(self):
|
|
1170
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('a', hours=5)}})
|
|
1171
|
+
self.sync()
|
|
1172
|
+
first = os.stat(self.root / 'mcp-oauth.json').st_mtime
|
|
1173
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: self.entry('b', hours=9)}})
|
|
1174
|
+
self.sync()
|
|
1175
|
+
second = os.stat(self.root / 'mcp-oauth.json').st_mtime
|
|
1176
|
+
self.assertGreaterEqual(second, first + 1)
|
|
1177
|
+
self.assertEqual(second, int(second))
|
|
1178
|
+
# bookkeeping is no change for the accounts: the beat, the holders a merge adds
|
|
1179
|
+
self.stub_files('acct-01')
|
|
1180
|
+
self.sync()
|
|
1181
|
+
self.assertEqual(self.store()['entries'][KEY]['holders'],
|
|
1182
|
+
['acct-01:file', 'acct-02:file', 'acct-03:file'])
|
|
1183
|
+
self.sync()
|
|
1184
|
+
self.assertEqual(os.stat(self.root / 'mcp-oauth.json').st_mtime, second)
|
|
1185
|
+
|
|
1186
|
+
def test_the_watermark_never_hides_a_write_in_the_current_second(self):
|
|
1187
|
+
# A newest mtime in the current second (or later — a clock that ran ahead) is set
|
|
1188
|
+
# a second early. In the future here, so the case cannot straddle a second.
|
|
1189
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('a')}}, mtime=time.time() + 30)
|
|
1190
|
+
self.stub_files('acct-01')
|
|
1191
|
+
self.sync()
|
|
1192
|
+
seen = os.stat(self.root / '.mcp-oauth-seen').st_mtime
|
|
1193
|
+
newest = os.stat(self.root / 'acct-02' / '.credentials.json').st_mtime
|
|
1194
|
+
self.assertLess(int(seen), int(newest)) # one second early: `test -nt` still fires
|
|
1195
|
+
old = time.time() - 120
|
|
1196
|
+
for account in ('acct-01', 'acct-02'):
|
|
1197
|
+
os.utime(self.root / account / '.credentials.json', (old, old))
|
|
1198
|
+
self.sync()
|
|
1199
|
+
self.assertEqual(int(os.stat(self.root / '.mcp-oauth-seen').st_mtime), int(old))
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
class ShimGateTests(Case):
|
|
1203
|
+
"""The bash gate in bin/claude: python runs only when something changed."""
|
|
1204
|
+
|
|
1205
|
+
def setUp(self):
|
|
1206
|
+
super().setUp()
|
|
1207
|
+
work = Path(self.work.name)
|
|
1208
|
+
self.realbin = work / 'realbin'
|
|
1209
|
+
self.realbin.mkdir()
|
|
1210
|
+
fake = self.realbin / 'claude'
|
|
1211
|
+
fake.write_text('#!/bin/sh\necho "CFG=${CLAUDE_CONFIG_DIR##*/} ARGS=$*"\n')
|
|
1212
|
+
fake.chmod(0o755)
|
|
1213
|
+
self.pylog = work / 'py.log'
|
|
1214
|
+
wrapper = work / 'pylog.sh'
|
|
1215
|
+
wrapper.write_text(f'#!/bin/sh\nprintf "PY %s\\n" "$*" >> "{self.pylog}"\n'
|
|
1216
|
+
f'exec "{sys.executable}" "$@"\n')
|
|
1217
|
+
wrapper.chmod(0o755)
|
|
1218
|
+
self.wrapper = wrapper
|
|
1219
|
+
self.home = work / 'home'
|
|
1220
|
+
self.home.mkdir()
|
|
1221
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1)}, mtime=time.time() - 300)
|
|
1222
|
+
|
|
1223
|
+
def launch(self, **extra):
|
|
1224
|
+
# A scrubbed env, built from nothing: this suite may itself run inside a pooled
|
|
1225
|
+
# session, whose CLAUDE_CONFIG_DIR / CLAUDE_SHIM_ACTIVE would turn the launch into
|
|
1226
|
+
# a passthrough — and whose pool roots are the REAL ones.
|
|
1227
|
+
env = {'PATH': f'{self.realbin}:/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(self.home),
|
|
1228
|
+
'USER': 'gas', 'LC_ALL': 'C', 'CLAUDE_ACCOUNTS_ROOT': str(self.root),
|
|
1229
|
+
'CLAUDE_ACCOUNT': 'acct-01', 'CLAUDE_MULTIACC_PYTHON': str(self.wrapper),
|
|
1230
|
+
'CLAUDE_MULTIACC_KEYCHAIN': '0', 'CLAUDE_MULTIACC_AUTORESUME': '0',
|
|
1231
|
+
'CLAUDE_MULTIACC_CLIENT_LIMITS': '0', 'CLAUDE_MULTIACC_PATH_PROBE': '0',
|
|
1232
|
+
'CLAUDE_MULTIACC_MODEL_CATALOG': '0'}
|
|
1233
|
+
env.update(extra)
|
|
1234
|
+
if self.pylog.exists():
|
|
1235
|
+
self.pylog.unlink()
|
|
1236
|
+
p = subprocess.run([str(SHIM), '--version'], env=env, capture_output=True, text=True,
|
|
1237
|
+
timeout=60, stdin=subprocess.DEVNULL)
|
|
1238
|
+
ran = self.pylog.read_text() if self.pylog.exists() else ''
|
|
1239
|
+
return p, ran
|
|
1240
|
+
|
|
1241
|
+
def test_python_runs_only_when_something_changed(self):
|
|
1242
|
+
new = self.entry('n')
|
|
1243
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}}, mtime=time.time() - 200)
|
|
1244
|
+
self.beat()
|
|
1245
|
+
p, ran = self.launch()
|
|
1246
|
+
self.assertEqual((p.returncode, p.stdout.strip()), (0, 'CFG=acct-01 ARGS=--version'))
|
|
1247
|
+
self.assertIn('mcp_oauth.py ensure', ran)
|
|
1248
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], new)
|
|
1249
|
+
self.assertEqual(self.file_doc('acct-01')['claudeAiOauth'], self.login(1))
|
|
1250
|
+
self.assertTrue((self.root / 'acct-01' / '.mcp-oauth-applied').exists())
|
|
1251
|
+
# nothing changed since: no python start-up at all
|
|
1252
|
+
p, ran = self.launch()
|
|
1253
|
+
self.assertEqual((p.returncode, ran), (0, ''))
|
|
1254
|
+
# a client in another account saves a newer login: the next launch picks it up
|
|
1255
|
+
newer = self.entry('newer', hours=30)
|
|
1256
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: newer}})
|
|
1257
|
+
p, ran = self.launch()
|
|
1258
|
+
self.assertIn('mcp_oauth.py ensure', ran)
|
|
1259
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], newer)
|
|
1260
|
+
# the store changed since acct-02 last got it: its first launch runs once
|
|
1261
|
+
p, ran = self.launch(CLAUDE_ACCOUNT='acct-02')
|
|
1262
|
+
self.assertIn('mcp_oauth.py ensure', ran)
|
|
1263
|
+
self.assertEqual(self.file_doc('acct-02')['mcpOAuth'][KEY], newer)
|
|
1264
|
+
|
|
1265
|
+
def test_a_launch_creates_no_credentials_file_for_an_account_without_a_token(self):
|
|
1266
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}}, mtime=time.time() - 200)
|
|
1267
|
+
self.beat()
|
|
1268
|
+
for _ in range(2):
|
|
1269
|
+
p, _ran = self.launch(CLAUDE_ACCOUNT='acct-03')
|
|
1270
|
+
self.assertEqual((p.returncode, p.stdout.strip()), (0, 'CFG=acct-03 ARGS=--version'))
|
|
1271
|
+
self.assertFalse(self.has_file('acct-03'))
|
|
1272
|
+
|
|
1273
|
+
def test_a_setup_token_accounts_launch_gets_its_file_and_the_gate_settles(self):
|
|
1274
|
+
new = self.entry('n')
|
|
1275
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: new}}, mtime=time.time() - 200)
|
|
1276
|
+
(self.root / 'acct-03' / 'server.token').write_text('fake-setup-token-03')
|
|
1277
|
+
self.secrets.add('fake-setup-token-03')
|
|
1278
|
+
self.beat()
|
|
1279
|
+
p, ran = self.launch(CLAUDE_ACCOUNT='acct-03')
|
|
1280
|
+
self.assertEqual((p.returncode, p.stdout.strip()), (0, 'CFG=acct-03 ARGS=--version'))
|
|
1281
|
+
self.assertIn('mcp_oauth.py ensure', ran)
|
|
1282
|
+
self.assertEqual(self.file_doc('acct-03'), {'mcpOAuth': {KEY: new}}) # before exec
|
|
1283
|
+
time.sleep(1.1) # out of the second the file was created in
|
|
1284
|
+
self.launch(CLAUDE_ACCOUNT='acct-03') # the harvest sees the new file once...
|
|
1285
|
+
p, ran = self.launch(CLAUDE_ACCOUNT='acct-03')
|
|
1286
|
+
self.assertEqual(p.returncode, 0)
|
|
1287
|
+
self.assertNotIn('mcp_oauth.py', ran) # ...and then the gate starts it no more
|
|
1288
|
+
# (a pinned token account's own token checks are the shim's, not this gate's)
|
|
1289
|
+
|
|
1290
|
+
def test_a_pool_without_mcp_oauth_state_never_starts_python(self):
|
|
1291
|
+
p, ran = self.launch()
|
|
1292
|
+
self.assertEqual((p.returncode, ran), (0, ''))
|
|
1293
|
+
self.assertFalse((self.root / 'mcp-oauth.json').exists())
|
|
1294
|
+
|
|
1295
|
+
def test_a_torn_file_that_mentions_mcp_oauth_costs_one_python_start_only(self):
|
|
1296
|
+
torn = self.root / 'acct-02' / '.credentials.json'
|
|
1297
|
+
torn.write_text('{"mcpOAuth": {"higgsfield|0a1b2c3d4e5f6a7b": {"accessTo')
|
|
1298
|
+
old = time.time() - 100
|
|
1299
|
+
os.utime(torn, (old, old))
|
|
1300
|
+
p, ran = self.launch()
|
|
1301
|
+
self.assertEqual(p.returncode, 0)
|
|
1302
|
+
self.assertIn('mcp_oauth.py ensure', ran)
|
|
1303
|
+
self.assertFalse((self.root / 'mcp-oauth.json').exists())
|
|
1304
|
+
p, ran = self.launch()
|
|
1305
|
+
self.assertEqual((p.returncode, ran), (0, ''))
|
|
1306
|
+
|
|
1307
|
+
def test_the_kill_switch_and_a_missing_python_never_break_a_launch(self):
|
|
1308
|
+
self.put_file('acct-02', {'mcpOAuth': {KEY: self.entry('n')}})
|
|
1309
|
+
p, ran = self.launch(CLAUDE_MULTIACC_MCP_OAUTH='0')
|
|
1310
|
+
self.assertEqual((p.returncode, p.stdout.strip(), ran), (0, 'CFG=acct-01 ARGS=--version', ''))
|
|
1311
|
+
self.assertNotIn('mcpOAuth', self.file_doc('acct-01'))
|
|
1312
|
+
p, _ran = self.launch(CLAUDE_MULTIACC_PYTHON='/nonexistent/python3')
|
|
1313
|
+
self.assertEqual((p.returncode, p.stdout.strip(), p.stderr), (0, 'CFG=acct-01 ARGS=--version', ''))
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
# ---------------------------------------------------------------------------- forget
|
|
1317
|
+
|
|
1318
|
+
class ForgetTests(Case):
|
|
1319
|
+
def test_forget_clears_every_store_and_a_straggler_stays_forgotten(self):
|
|
1320
|
+
grant = self.entry('g')
|
|
1321
|
+
keep = {'serverName': 'linear', 'accessToken': 'fake-mcp-access-linear'}
|
|
1322
|
+
self.secrets.add('fake-mcp-access-linear')
|
|
1323
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {KEY: grant, OTHER_KEY: keep}})
|
|
1324
|
+
self.kc_put('acct-02', {'claudeAiOauth': self.login(2), 'mcpOAuth': {KEY: grant}})
|
|
1325
|
+
self.stub_files('acct-03')
|
|
1326
|
+
self.sync()
|
|
1327
|
+
rc, out, err = self.run_cli('forget', 'higgsfield')
|
|
1328
|
+
self.assertEqual(rc, 0, err)
|
|
1329
|
+
self.assertIn('forgot higgsfield: 1 key(s)', out)
|
|
1330
|
+
store = self.store()
|
|
1331
|
+
self.assertNotIn(KEY, store['entries'])
|
|
1332
|
+
self.assertEqual(store['forgotten'][KEY]['reason'], 'auth-forget')
|
|
1333
|
+
for account in ('acct-01', 'acct-03'):
|
|
1334
|
+
self.assertNotIn(KEY, self.file_doc(account)['mcpOAuth'], account)
|
|
1335
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][OTHER_KEY], keep)
|
|
1336
|
+
self.assertEqual(self.file_doc('acct-01')['claudeAiOauth'], self.login(1))
|
|
1337
|
+
self.assertNotIn(KEY, self.kc_doc('acct-02')['mcpOAuth'])
|
|
1338
|
+
self.assertEqual(self.kc_doc('acct-02')['claudeAiOauth'], self.login(2))
|
|
1339
|
+
self.assertFalse(self.has_file('acct-02'))
|
|
1340
|
+
# a copy a forget could not reach (a locked item, say) never comes back...
|
|
1341
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: grant}})
|
|
1342
|
+
self.sync()
|
|
1343
|
+
self.assertNotIn(KEY, self.store()['entries'])
|
|
1344
|
+
self.assertNotIn(KEY, self.file_doc('acct-03')['mcpOAuth']) # ...and is cleared
|
|
1345
|
+
# a NEW sign-in is a new login, shared as usual
|
|
1346
|
+
fresh = self.entry('fresh')
|
|
1347
|
+
self.put_file('acct-03', {'mcpOAuth': {KEY: fresh}})
|
|
1348
|
+
self.sync()
|
|
1349
|
+
self.assertEqual(self.file_doc('acct-01')['mcpOAuth'][KEY], fresh)
|
|
1350
|
+
self.assertNotIn('fake-mcp', json.dumps(self.store()['forgotten']))
|
|
1351
|
+
|
|
1352
|
+
def test_forgetting_an_unknown_server_says_so(self):
|
|
1353
|
+
rc, _out, err = self.run_cli('forget', 'nothing-here')
|
|
1354
|
+
self.assertEqual(rc, 1)
|
|
1355
|
+
self.assertIn('no MCP OAuth state for nothing-here', err)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
# ---------------------------------------------------------------------------- output
|
|
1359
|
+
|
|
1360
|
+
class OutputTests(Case):
|
|
1361
|
+
def test_no_output_and_no_log_line_ever_contains_a_token(self):
|
|
1362
|
+
as_ = self.auth_server()
|
|
1363
|
+
self.put_file('acct-01', {'claudeAiOauth': self.login(1), 'mcpOAuth': {
|
|
1364
|
+
KEY: self.entry('due', hours=1, as_url=as_.url, clientSecret='fake-client-secret')}})
|
|
1365
|
+
self.put_file('acct-02', {'mcpOAuth': {OTHER_KEY: self.entry('other', hours=1, as_url=NO_AS)}})
|
|
1366
|
+
self.kc_put('acct-03', {'claudeAiOauth': self.login(3)})
|
|
1367
|
+
self.secrets.update(('fake-mcp-access-rotated-1', 'fake-mcp-refresh-rotated-1'))
|
|
1368
|
+
outputs = []
|
|
1369
|
+
for argv in (('sync',), ('status',), ('status', '--json'),
|
|
1370
|
+
('ensure', '--account-dir', self.d('acct-02')), ('forget', 'linear'),
|
|
1371
|
+
('status',)):
|
|
1372
|
+
rc, out, err = self.run_cli(*argv)
|
|
1373
|
+
outputs.append(out + err)
|
|
1374
|
+
self.put_file('acct-02', {'mcpOAuth': {}}) # a clear: the login is retired
|
|
1375
|
+
for argv in (('sync',), ('status',), ('status', '--json')):
|
|
1376
|
+
rc, out, err = self.run_cli(*argv)
|
|
1377
|
+
outputs.append(out + err)
|
|
1378
|
+
log = (self.root / 'ops.log').read_text()
|
|
1379
|
+
self.assertIn('refresh', log)
|
|
1380
|
+
self.assertIn('retired in every account', log)
|
|
1381
|
+
for secret in self.secrets:
|
|
1382
|
+
for text in outputs + [log]:
|
|
1383
|
+
self.assertNotIn(secret, text)
|
|
1384
|
+
|
|
1385
|
+
def test_status_json_names_every_key_and_every_account_store(self):
|
|
1386
|
+
self.put_file('acct-01', {'mcpOAuth': {KEY: self.entry('a', hours=5)}})
|
|
1387
|
+
self.put_file('acct-02', {'mcpOAuth': {OTHER_KEY: self.stub()}})
|
|
1388
|
+
self.kc_put('acct-03', {'claudeAiOauth': self.login(3)})
|
|
1389
|
+
self.sync()
|
|
1390
|
+
doc = self.status_json()
|
|
1391
|
+
rows = {row['key']: row for row in doc['keys']}
|
|
1392
|
+
self.assertEqual(sorted(rows), sorted([KEY, OTHER_KEY]))
|
|
1393
|
+
row = rows[KEY]
|
|
1394
|
+
self.assertTrue(row['login'])
|
|
1395
|
+
self.assertTrue(row['shared'])
|
|
1396
|
+
self.assertIsNone(doc['sharing_paused'])
|
|
1397
|
+
self.assertEqual((row['server_name'], row['server_url'], row['source']),
|
|
1398
|
+
('higgsfield', SERVER_URL, 'acct-01'))
|
|
1399
|
+
self.assertEqual(row['accounts']['acct-02'], {'file': 'fresh', 'keychain': 'absent'})
|
|
1400
|
+
self.assertEqual(row['accounts']['acct-03'], {'file': 'absent', 'keychain': 'fresh'})
|
|
1401
|
+
self.assertFalse(rows[OTHER_KEY]['login'])
|
|
1402
|
+
self.assertEqual(rows[OTHER_KEY]['accounts']['acct-02']['file'], 'stub')
|
|
1403
|
+
|
|
1404
|
+
def test_the_store_is_never_pushed_anywhere(self):
|
|
1405
|
+
# sync and the limits fan-out push explicit file lists; none may name the store.
|
|
1406
|
+
for name in ('bin/claude-accounts', 'bin/codex-accounts', 'lib/common.sh'):
|
|
1407
|
+
for line in (REPO / name).read_text().splitlines():
|
|
1408
|
+
code = line.split('#', 1)[0]
|
|
1409
|
+
if 'rsync' in code or "printf '" in code:
|
|
1410
|
+
self.assertNotIn('mcp-oauth', code, f'{name}: {line.strip()}')
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
if __name__ == '__main__':
|
|
1414
|
+
unittest.main()
|