claude-multiacc 2.0.42 → 2.0.44
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 +17 -1
- package/bin/claude +78 -18
- package/bin/claude-accounts +112 -22
- package/docs/ACCOUNT_OPERATIONS.md +5 -0
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.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_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.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__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +10 -0
- package/lib/claude_token_identity.py +233 -0
- package/lib/common.sh +27 -2
- package/lib/credential.py +9 -2
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- 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_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_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_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_security.sh +90 -0
- package/tests/run-tests.sh +254 -66
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_keychain.py +323 -50
- package/tests/test_token_identity_ceremony.py +167 -0
- package/tests/test_token_identity_integration.py +207 -0
- package/tests/test_token_identity_retry.py +95 -0
- package/tests/test_token_identity_sync.py +139 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Organization binding uses fake tokens and a loopback Messages endpoint only."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from functools import partial
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import unittest
|
|
18
|
+
from unittest.mock import patch
|
|
19
|
+
|
|
20
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(REPO / "lib"))
|
|
22
|
+
import claude_token_identity as identity
|
|
23
|
+
|
|
24
|
+
ORG = "11111111-2222-4333-8444-555555555555"
|
|
25
|
+
OTHER = "99999999-8888-4777-8666-555555555555"
|
|
26
|
+
TOKEN = "sk-ant-oat01-sandbox-token-a"
|
|
27
|
+
SECOND = "sk-ant-oat01-sandbox-token-b"
|
|
28
|
+
EMAIL = "sandbox@example.invalid"
|
|
29
|
+
NOW = 1790292600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IdentityHandler(BaseHTTPRequestHandler):
|
|
33
|
+
def do_POST(self):
|
|
34
|
+
state = self.server.state
|
|
35
|
+
size = int(self.headers.get("Content-Length", 0))
|
|
36
|
+
state["requests"].append({"path": self.path, "body": self.rfile.read(size).decode(),
|
|
37
|
+
"headers": dict(self.headers), "method": self.command})
|
|
38
|
+
code, org = state["code"], state.get("org")
|
|
39
|
+
if state.get("serialize_test"):
|
|
40
|
+
if len(state["requests"]) == 1:
|
|
41
|
+
state["first_seen"].set()
|
|
42
|
+
state["second_seen"].wait(0.2)
|
|
43
|
+
code, org = 429, OTHER
|
|
44
|
+
else:
|
|
45
|
+
state["second_seen"].set()
|
|
46
|
+
state["allow_second"].wait(2)
|
|
47
|
+
code, org = 200, None
|
|
48
|
+
self.send_response(code)
|
|
49
|
+
if org is not None:
|
|
50
|
+
self.send_header("anthropic-organization-id", org)
|
|
51
|
+
if state.get("location"):
|
|
52
|
+
self.send_header("Location", state["location"])
|
|
53
|
+
self.send_header("Content-Length", "0")
|
|
54
|
+
self.end_headers()
|
|
55
|
+
|
|
56
|
+
do_GET = do_POST
|
|
57
|
+
|
|
58
|
+
def log_message(self, _format, *_args):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TokenIdentityTests(unittest.TestCase):
|
|
63
|
+
def setUp(self):
|
|
64
|
+
self.temp = tempfile.TemporaryDirectory(prefix="multiacc-token-identity-")
|
|
65
|
+
self.account = Path(self.temp.name) / "acct-01"
|
|
66
|
+
self.account.mkdir()
|
|
67
|
+
(self.account / "server.token").write_text(TOKEN)
|
|
68
|
+
self.manifest = self.account.parent / "accounts.json"
|
|
69
|
+
self.manifest.write_text(json.dumps({"accounts": [{"id": "acct-01", "email": EMAIL}]}))
|
|
70
|
+
self.config = self.account / ".claude.json"
|
|
71
|
+
self.config.write_text(json.dumps({"oauthAccount": {"organizationUuid": ORG, "emailAddress": EMAIL}}))
|
|
72
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), IdentityHandler)
|
|
73
|
+
self.server.state = {"code": 200, "org": ORG, "requests": []}
|
|
74
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
75
|
+
self.thread.start()
|
|
76
|
+
self.url = f"http://127.0.0.1:{self.server.server_port}/v1/messages?beta=true"
|
|
77
|
+
self.check = partial(identity.check_identity, self.account, endpoint=self.url, version="2.1.282")
|
|
78
|
+
self.env = patch.dict(os.environ, {"HOME": self.temp.name, "PATH": os.defpath}, clear=True)
|
|
79
|
+
self.env.start()
|
|
80
|
+
|
|
81
|
+
def tearDown(self):
|
|
82
|
+
self.env.stop()
|
|
83
|
+
self.server.shutdown()
|
|
84
|
+
self.server.server_close()
|
|
85
|
+
self.thread.join(timeout=5)
|
|
86
|
+
self.temp.cleanup()
|
|
87
|
+
|
|
88
|
+
def test_match_is_bound_and_legacy_auth_receipt_cannot_skip_probe(self):
|
|
89
|
+
(self.account / ".server-token-verified").write_text(hashlib.sha256(TOKEN.encode()).hexdigest())
|
|
90
|
+
result = self.check(now=NOW)
|
|
91
|
+
self.assertEqual(result["state"], "match")
|
|
92
|
+
self.assertFalse(result["cached"])
|
|
93
|
+
self.assertEqual(identity.cached_identity(self.account, now=NOW)["state"], "match")
|
|
94
|
+
self.assertTrue(self.check(now=NOW + 86400)["cached"])
|
|
95
|
+
requests = self.server.state["requests"]
|
|
96
|
+
self.assertEqual(len(requests), 1)
|
|
97
|
+
request = requests[0]
|
|
98
|
+
self.assertEqual(request["path"], "/v1/messages?beta=true")
|
|
99
|
+
self.assertEqual(json.loads(request["body"]), {
|
|
100
|
+
"model": identity.MODEL, "max_tokens": 1, "messages": [{"role": "user", "content": "quota"}]})
|
|
101
|
+
self.assertLess(len(request["body"]), 256)
|
|
102
|
+
headers = {k.lower(): v for k, v in request["headers"].items()}
|
|
103
|
+
self.assertEqual(headers["authorization"], "Bearer " + TOKEN)
|
|
104
|
+
self.assertIn("oauth-2025-04-20", headers["anthropic-beta"])
|
|
105
|
+
self.assertEqual(headers["user-agent"], "claude-cli/2.1.282 (external, cli, client-app/claude-multiacc)")
|
|
106
|
+
receipt = self.account / identity.RECEIPT
|
|
107
|
+
self.assertEqual(receipt.stat().st_mode & 0o777, 0o600)
|
|
108
|
+
self.assertNotIn(TOKEN, receipt.read_text())
|
|
109
|
+
self.assertNotIn(ORG, json.dumps(result))
|
|
110
|
+
self.assertEqual(json.loads(receipt.read_text())["expected_org"], ORG)
|
|
111
|
+
|
|
112
|
+
def test_quota_rejection_identifies_mismatch_and_persists(self):
|
|
113
|
+
self.config.write_text(json.dumps({"oauthAccount": {"organizationUuid": ORG, "emailAddress": EMAIL},
|
|
114
|
+
"projects": {"sandbox": {"history": "x" * 32768}}}))
|
|
115
|
+
self.assertGreater(self.config.stat().st_size, 16384)
|
|
116
|
+
self.server.state.update(code=429, org=OTHER)
|
|
117
|
+
with patch.object(identity.os, "replace", side_effect=PermissionError):
|
|
118
|
+
result = self.check(now=NOW, model="claude-opus-4-6")
|
|
119
|
+
self.assertEqual(result["state"], "mismatch")
|
|
120
|
+
self.assertFalse((self.account / identity.RECEIPT).exists())
|
|
121
|
+
headers = {k.lower(): v for k, v in self.server.state["requests"][0]["headers"].items()}
|
|
122
|
+
self.assertIn("claude-code-20250219", headers["anthropic-beta"])
|
|
123
|
+
with patch.object(identity.os, "open", side_effect=PermissionError):
|
|
124
|
+
self.assertEqual(self.check(now=NOW)["state"], "mismatch")
|
|
125
|
+
self.assertFalse((self.account / identity.RECEIPT).exists())
|
|
126
|
+
result = self.check(now=NOW)
|
|
127
|
+
self.assertEqual(result["state"], "mismatch")
|
|
128
|
+
self.assertEqual(result["http_status"], 429)
|
|
129
|
+
self.server.state.update(code=200, org=ORG)
|
|
130
|
+
self.assertEqual(self.check(now=NOW + 365 * 86400)["state"], "mismatch")
|
|
131
|
+
self.assertEqual(len(self.server.state["requests"]), 3)
|
|
132
|
+
stored = json.loads((self.account / identity.RECEIPT).read_text())
|
|
133
|
+
self.assertEqual(stored["actual_org"], OTHER)
|
|
134
|
+
self.assertNotIn(OTHER, json.dumps(result))
|
|
135
|
+
self.config.write_text("{}")
|
|
136
|
+
candidate = self.check(expected_email=EMAIL, persist=False, now=NOW)
|
|
137
|
+
self.assertEqual(candidate["state"], "mismatch")
|
|
138
|
+
self.assertTrue(candidate["cached"])
|
|
139
|
+
conflict = self.check(expected_email="different@example.invalid", persist=False, now=NOW)
|
|
140
|
+
self.assertEqual((conflict["state"], conflict["reason"]), ("mismatch", "profile_manifest_mismatch"))
|
|
141
|
+
self.assertEqual(len(self.server.state["requests"]), 3)
|
|
142
|
+
|
|
143
|
+
def test_token_and_expected_organization_changes_invalidate_cache(self):
|
|
144
|
+
self.assertEqual(self.check(now=NOW)["state"], "match")
|
|
145
|
+
receipt_before = (self.account / identity.RECEIPT).read_bytes()
|
|
146
|
+
self.server.state["org"] = OTHER
|
|
147
|
+
self.assertEqual(self.check(SECOND, now=NOW, persist=False)["state"], "mismatch")
|
|
148
|
+
self.assertEqual((self.account / identity.RECEIPT).read_bytes(), receipt_before)
|
|
149
|
+
self.assertEqual(identity.cached_identity(self.account, now=NOW)["state"], "match")
|
|
150
|
+
self.server.state["org"] = ORG
|
|
151
|
+
(self.account / "server.token").write_text(SECOND)
|
|
152
|
+
self.assertFalse(self.check(now=NOW + 1)["cached"])
|
|
153
|
+
self.config.write_text(json.dumps({"oauthAccount": {"organizationUuid": OTHER}}))
|
|
154
|
+
self.assertEqual(self.check(now=NOW + 2)["state"], "mismatch")
|
|
155
|
+
self.config.write_text(json.dumps({"oauthAccount": {"organizationUuid": ORG.upper()}}))
|
|
156
|
+
self.assertEqual(self.check(now=NOW + 3)["state"], "match")
|
|
157
|
+
self.assertEqual(len(self.server.state["requests"]), 5)
|
|
158
|
+
stored = json.loads((self.account / identity.RECEIPT).read_text())
|
|
159
|
+
self.assertEqual(stored["token_digest"], hashlib.sha256(SECOND.encode()).hexdigest())
|
|
160
|
+
self.config.write_text("{}")
|
|
161
|
+
self.assertEqual(identity.cached_identity(self.account, now=NOW + 4)["state"], "match")
|
|
162
|
+
self.assertTrue(self.check(now=NOW + 4)["cached"])
|
|
163
|
+
self.manifest.write_text(json.dumps({"accounts": [{"id": "acct-01", "email": "changed@example.invalid"}]}))
|
|
164
|
+
self.assertEqual(identity.cached_identity(self.account, now=NOW + 5)["state"], "unknown")
|
|
165
|
+
moved = self.account.parent / "acct-02"
|
|
166
|
+
self.account.rename(moved)
|
|
167
|
+
self.manifest.write_text(json.dumps({"accounts": [{"id": "acct-02", "email": EMAIL}]}))
|
|
168
|
+
self.assertEqual(identity.cached_identity(moved, now=NOW + 6)["state"], "unknown")
|
|
169
|
+
self.assertEqual(len(self.server.state["requests"]), 5)
|
|
170
|
+
|
|
171
|
+
def test_unknown_and_auth_failures_back_off_then_retry(self):
|
|
172
|
+
for code, org, reason in [(200, None, "missing_organization"), (401, None, "auth_failed"),
|
|
173
|
+
(403, None, "auth_failed"), (429, "bad-id", "missing_organization")]:
|
|
174
|
+
with self.subTest(code=code, org=org):
|
|
175
|
+
(self.account / identity.RECEIPT).unlink(missing_ok=True)
|
|
176
|
+
self.server.state.update(code=code, org=org)
|
|
177
|
+
result = self.check(now=NOW)
|
|
178
|
+
self.assertEqual((result["state"], result["reason"]), ("unknown", reason))
|
|
179
|
+
count = len(self.server.state["requests"])
|
|
180
|
+
self.server.state.update(code=200, org=ORG)
|
|
181
|
+
self.assertTrue(self.check(now=NOW + 59)["cached"])
|
|
182
|
+
self.assertEqual(len(self.server.state["requests"]), count)
|
|
183
|
+
self.assertEqual(self.check(now=NOW + 60)["state"], "match")
|
|
184
|
+
(self.account / identity.RECEIPT).unlink()
|
|
185
|
+
with patch.object(identity.urllib.request.OpenerDirector, "open", side_effect=TimeoutError) as opened:
|
|
186
|
+
result = self.check(now=NOW)
|
|
187
|
+
self.assertEqual((result["state"], result["reason"]), ("unknown", "request_failed"))
|
|
188
|
+
self.assertEqual(opened.call_count, 1)
|
|
189
|
+
self.assertEqual(opened.call_args.kwargs["timeout"], 10)
|
|
190
|
+
(self.account / identity.RECEIPT).unlink()
|
|
191
|
+
self.server.state.update(serialize_test=True, requests=[], first_seen=threading.Event(),
|
|
192
|
+
second_seen=threading.Event(), allow_second=threading.Event())
|
|
193
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
194
|
+
first = executor.submit(self.check, now=NOW)
|
|
195
|
+
self.assertTrue(self.server.state["first_seen"].wait(2))
|
|
196
|
+
second = executor.submit(self.check, now=NOW)
|
|
197
|
+
first_result = first.result(timeout=3)
|
|
198
|
+
self.server.state["allow_second"].set()
|
|
199
|
+
second_result = second.result(timeout=3)
|
|
200
|
+
self.assertEqual([first_result["state"], second_result["state"]], ["mismatch", "mismatch"])
|
|
201
|
+
self.assertEqual(len(self.server.state["requests"]), 1)
|
|
202
|
+
self.assertEqual(identity.cached_identity(self.account, now=NOW)["state"], "mismatch")
|
|
203
|
+
|
|
204
|
+
def test_redirects_never_forward_the_token(self):
|
|
205
|
+
for url in ("https://example.invalid/v1/messages", "http://[invalid"):
|
|
206
|
+
(self.account / identity.RECEIPT).unlink(missing_ok=True)
|
|
207
|
+
result = identity.check_identity(self.account, endpoint=url, now=NOW)
|
|
208
|
+
self.assertEqual((result["state"], result["reason"]), ("unknown", "invalid_endpoint"))
|
|
209
|
+
self.assertEqual(len(self.server.state["requests"]), 0)
|
|
210
|
+
for code in (301, 302, 303, 307, 308):
|
|
211
|
+
with self.subTest(code=code):
|
|
212
|
+
(self.account / identity.RECEIPT).unlink(missing_ok=True)
|
|
213
|
+
self.server.state.update(code=code, org=OTHER, location=self.url + "&redirected=1")
|
|
214
|
+
count = len(self.server.state["requests"])
|
|
215
|
+
result = self.check(now=NOW)
|
|
216
|
+
self.assertEqual((result["state"], result["reason"]), ("unknown", "redirect"))
|
|
217
|
+
self.assertEqual(len(self.server.state["requests"]), count + 1)
|
|
218
|
+
|
|
219
|
+
def test_cli_exact_stdin_and_missing_config_are_safe(self):
|
|
220
|
+
env = dict(os.environ, CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=self.url,
|
|
221
|
+
CLAUDE_MULTIACC_CLI_VERSION="2.1.282")
|
|
222
|
+
done = subprocess.run([sys.executable, str(REPO / "lib/claude_token_identity.py"),
|
|
223
|
+
"check", str(self.account), "--token-stdin"],
|
|
224
|
+
input=SECOND, text=True, capture_output=True, env=env, timeout=15)
|
|
225
|
+
self.assertEqual(done.returncode, 0, done.stderr)
|
|
226
|
+
result = json.loads(done.stdout)
|
|
227
|
+
self.assertEqual(result["state"], "match")
|
|
228
|
+
self.assertEqual(result["token_digest"], hashlib.sha256(SECOND.encode()).hexdigest())
|
|
229
|
+
self.assertNotIn(SECOND, done.stdout + done.stderr)
|
|
230
|
+
self.assertNotIn(ORG, done.stdout + done.stderr)
|
|
231
|
+
self.assertEqual(self.server.state["requests"][0]["headers"]["Authorization"], "Bearer " + SECOND)
|
|
232
|
+
cached = subprocess.run([sys.executable, str(REPO / "lib/claude_token_identity.py"),
|
|
233
|
+
"cached", str(self.account), "--token-stdin", "--format", "state"],
|
|
234
|
+
input=SECOND, text=True, capture_output=True, env=env, timeout=15)
|
|
235
|
+
self.assertEqual(cached.stdout, "match\n")
|
|
236
|
+
for document in ("not-json", "[]", "{}"):
|
|
237
|
+
self.config.write_text(document)
|
|
238
|
+
self.assertEqual(self.check(now=NOW)["reason"], "missing_expected_organization")
|
|
239
|
+
self.assertEqual(len(self.server.state["requests"]), 1)
|
|
240
|
+
self.config.write_text(json.dumps({"oauthAccount": {
|
|
241
|
+
"organizationUuid": ORG, "emailAddress": "different@example.invalid"}}))
|
|
242
|
+
conflict = identity.cached_identity(self.account)
|
|
243
|
+
self.assertEqual((conflict["state"], conflict["reason"]), ("mismatch", "profile_manifest_mismatch"))
|
|
244
|
+
self.assertEqual(self.check()["state"], "mismatch")
|
|
245
|
+
self.assertEqual(len(self.server.state["requests"]), 1)
|
|
246
|
+
self.manifest.write_text('{"accounts": []}')
|
|
247
|
+
candidate = subprocess.run([sys.executable, str(REPO / "lib/claude_token_identity.py"),
|
|
248
|
+
"check", str(self.account), "--token-stdin", "--no-store",
|
|
249
|
+
"--expected-email", EMAIL, "--format", "state"],
|
|
250
|
+
input=SECOND, text=True, capture_output=True, env=env, timeout=15)
|
|
251
|
+
self.assertEqual(candidate.stdout, "mismatch\n")
|
|
252
|
+
self.config.write_text(json.dumps({"oauthAccount": {"organizationUuid": ORG}}))
|
|
253
|
+
result = self.check(expected_email=EMAIL, persist=False)
|
|
254
|
+
self.assertEqual((result["state"], result["reason"]), ("unknown", "missing_profile_email"))
|
|
255
|
+
self.assertEqual(len(self.server.state["requests"]), 1)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__":
|
|
259
|
+
unittest.main()
|