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.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/bin/claude +78 -18
  3. package/bin/claude-accounts +112 -22
  4. package/docs/ACCOUNT_OPERATIONS.md +5 -0
  5. package/docs/claude-token-identity.md +147 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  17. package/lib/audit.py +10 -0
  18. package/lib/claude_token_identity.py +233 -0
  19. package/lib/common.sh +27 -2
  20. package/lib/credential.py +9 -2
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  35. package/tests/fake_security.sh +90 -0
  36. package/tests/run-tests.sh +254 -66
  37. package/tests/test_claude_token_identity.py +259 -0
  38. package/tests/test_keychain.py +323 -50
  39. package/tests/test_token_identity_ceremony.py +167 -0
  40. package/tests/test_token_identity_integration.py +207 -0
  41. package/tests/test_token_identity_retry.py +95 -0
  42. 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()