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.
Files changed (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. 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()