claude-multiacc 2.0.43 → 2.0.45

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 (44) hide show
  1. package/README.md +20 -7
  2. package/bin/claude +66 -19
  3. package/bin/claude-accounts +186 -54
  4. package/bin/codex +4 -3
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/VERIFICATION.md +8 -0
  8. package/docs/claude-token-identity.md +147 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +42 -4
  21. package/lib/claude_token_identity.py +233 -0
  22. package/lib/codex_audit.py +8 -1
  23. package/lib/common.sh +3 -1
  24. package/lib/credential.py +9 -2
  25. package/package.json +1 -1
  26. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  38. package/tests/run-tests.sh +254 -3
  39. package/tests/test_autoresume.py +36 -5
  40. package/tests/test_claude_token_identity.py +259 -0
  41. package/tests/test_token_identity_ceremony.py +167 -0
  42. package/tests/test_token_identity_integration.py +207 -0
  43. package/tests/test_token_identity_retry.py +95 -0
  44. 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()
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise real account commands against an isolated CLI and organization server."""
3
+
4
+ import hashlib
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import unittest
13
+
14
+ REPO = Path(__file__).resolve().parents[1]
15
+ EXPECTED = "00000000-0000-4000-8000-000000000001"
16
+ OTHER = "00000000-0000-4000-8000-000000000002"
17
+ TOKEN = "sk-ant-oat01-" + "C" * 96
18
+ OLD_TOKEN = "sk-ant-oat01-" + "O" * 96
19
+ FAKE_CLI = r'''#!/usr/bin/env bash
20
+ case "${1:-}" in
21
+ --version) echo '2.1.282 (Claude Code)' ;;
22
+ auth) echo '{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}' ;;
23
+ setup-token)
24
+ if [ -n "${TEST_CEREMONY_PROFILE:-}" ]; then
25
+ printf '%s' "$TEST_CEREMONY_PROFILE" > "$CLAUDE_CONFIG_DIR/.claude.json"
26
+ fi
27
+ echo 'Long-lived authentication token created successfully!'
28
+ echo 'Your OAuth token (valid for 1 year):'
29
+ printf '%s\n' "$TEST_CANDIDATE_TOKEN"
30
+ echo 'Store this token securely.' ;;
31
+ -p) echo inference >> "$TEST_INFERENCE_LOG"; echo OK ;;
32
+ *) exit 2 ;;
33
+ esac
34
+ '''
35
+
36
+
37
+ class IdentityHandler(BaseHTTPRequestHandler):
38
+ def do_POST(self):
39
+ self.rfile.read(int(self.headers.get("Content-Length", "0")))
40
+ self.server.calls += 1
41
+ self.send_response(self.server.code)
42
+ if self.server.organization:
43
+ self.send_header("anthropic-organization-id", self.server.organization)
44
+ self.end_headers()
45
+ self.wfile.write(b'{}')
46
+
47
+ def log_message(self, *_args):
48
+ pass
49
+
50
+
51
+ class TokenIdentityCeremonyTests(unittest.TestCase):
52
+ def setUp(self):
53
+ self.tmp = tempfile.TemporaryDirectory(prefix="multiacc-identity-ceremony-")
54
+ self.work = Path(self.tmp.name)
55
+ self.root = self.work / "accounts"
56
+ self.account = self.root / "acct-01"
57
+ self.account.mkdir(parents=True)
58
+ self.manifest = {"accounts": [{"id": "acct-01", "email": "one@test", "home": "mac"}]}
59
+ (self.root / "accounts.json").write_text(json.dumps(self.manifest))
60
+ (self.account / ".claude.json").write_text(json.dumps({"oauthAccount": {
61
+ "organizationUuid": EXPECTED, "emailAddress": "one@test"}}))
62
+ (self.account / "server.token").write_text(OLD_TOKEN)
63
+ self.receipt = self.account / ".server-token-identity.json"
64
+ self.receipt.write_text(json.dumps({"preserve": "existing credential receipt"}))
65
+ self.receipt_before = self.receipt.read_bytes()
66
+ fakebin = self.work / "bin"
67
+ fakebin.mkdir()
68
+ cli = fakebin / "claude"
69
+ cli.write_text(FAKE_CLI)
70
+ cli.chmod(0o755)
71
+ self.inference_log = self.work / "inference.log"
72
+ self.server = HTTPServer(("127.0.0.1", 0), IdentityHandler)
73
+ self.server.code, self.server.organization, self.server.calls = 429, OTHER, 0
74
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
75
+ self.thread.start()
76
+ self.env = {k: v for k, v in os.environ.items() if not k.startswith(("CLAUDE_", "CODEX_", "ANTHROPIC_"))}
77
+ self.env.update(HOME=str(self.work), PATH=f"{fakebin}:{os.environ['PATH']}",
78
+ CLAUDE_ACCOUNTS_ROOT=str(self.root), CLAUDE_MULTIACC_NO_SYNC="1",
79
+ CLAUDE_MULTIACC_KEYCHAIN="0", CLAUDE_MULTIACC_PATH_PROBE="0",
80
+ CLAUDE_MULTIACC_FORCE_TTY="1", TEST_CANDIDATE_TOKEN=TOKEN,
81
+ TEST_INFERENCE_LOG=str(self.inference_log),
82
+ CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=f"http://127.0.0.1:{self.server.server_port}/v1/messages")
83
+
84
+ def tearDown(self):
85
+ self.server.shutdown()
86
+ self.thread.join(timeout=5)
87
+ self.server.server_close()
88
+ self.tmp.cleanup()
89
+
90
+ def run_cli(self, args, stdin=None):
91
+ result = subprocess.run([str(REPO / "bin/claude-accounts"), *args], input=stdin,
92
+ capture_output=True, text=True, env=self.env, cwd=self.work, timeout=25)
93
+ self.assertNotIn(TOKEN, result.stdout + result.stderr)
94
+ self.assertNotIn(OLD_TOKEN, result.stdout + result.stderr)
95
+ return result
96
+
97
+ def test_mismatch_refused_before_save_or_inference(self):
98
+ cases = [(["mint", "acct-01", "--paste"], TOKEN + "\n"),
99
+ (["login", "acct-01", "--token", "--force"], None)]
100
+ for args, payload in cases:
101
+ with self.subTest(command=args[0]):
102
+ result = self.run_cli(args, payload)
103
+ self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
104
+ self.assertIn("organization does not match", result.stderr)
105
+ self.assertEqual((self.account / "server.token").read_text(), OLD_TOKEN)
106
+ self.assertEqual(self.receipt.read_bytes(), self.receipt_before)
107
+ self.assertFalse(self.inference_log.exists())
108
+ self.assertEqual(self.server.calls, 2)
109
+
110
+ def test_matching_organization_still_proves_auth(self):
111
+ self.server.organization = EXPECTED
112
+ result = self.run_cli(["mint", "acct-01", "--paste"], TOKEN + "\n")
113
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
114
+ self.assertIn("organization matches", result.stdout)
115
+ self.assertEqual((self.account / "server.token").read_text(), TOKEN)
116
+ self.assertEqual((self.account / ".server-token-verified").read_text().strip(),
117
+ hashlib.sha256(TOKEN.encode()).hexdigest())
118
+ self.assertEqual(self.receipt.read_bytes(), self.receipt_before)
119
+ self.assertEqual(self.inference_log.read_text().splitlines(), ["inference"])
120
+
121
+ def test_unavailable_organization_preserves_existing_acceptance(self):
122
+ self.server.code, self.server.organization = 503, None
123
+ result = self.run_cli(["mint", "acct-01", "--paste"], TOKEN + "\n")
124
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
125
+ self.assertIn("organization could not be checked", result.stderr)
126
+ self.assertNotIn("organization matches", result.stdout)
127
+ self.assertEqual((self.account / "server.token").read_text(), TOKEN)
128
+ self.assertEqual(self.receipt.read_bytes(), self.receipt_before)
129
+
130
+ def test_new_token_only_account_needs_no_unavailable_profile(self):
131
+ self.env["TEST_CEREMONY_PROFILE"] = json.dumps({"oauthAccount": {
132
+ "organizationUuid": OTHER, "emailAddress": "wrong@test"}})
133
+ result = self.run_cli(["add", "new@test", "--token"])
134
+ self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
135
+ self.assertFalse((self.root / "acct-02").exists())
136
+ self.assertFalse(self.inference_log.exists())
137
+ self.assertEqual(json.loads((self.root / "accounts.json").read_text()), self.manifest)
138
+ del self.env["TEST_CEREMONY_PROFILE"]
139
+ result = self.run_cli(["add", "new@test", "--token"])
140
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
141
+ self.assertIn("identity unverifiable", result.stdout)
142
+ accounts = json.loads((self.root / "accounts.json").read_text())["accounts"]
143
+ added = next(row for row in accounts if row["email"] == "new@test")
144
+ self.assertEqual((self.root / added["id"] / "server.token").read_text(), TOKEN)
145
+ self.assertEqual(self.server.calls, 0)
146
+
147
+ def test_direct_imports_refuse_wrong_organization_without_mutation(self):
148
+ blob = {"format": "claude-multiacc/credential", "version": 1, "provider": "claude",
149
+ "class": "portable", "account": self.manifest["accounts"][0],
150
+ "credential": {"type": "setup-token", "value": TOKEN}}
151
+ cases = [(["import", "one@test", "--id", "acct-01", "--token-file", "-", "--no-sync"], TOKEN),
152
+ (["import-credential", "acct-01", "--no-sync"], json.dumps(blob))]
153
+ before = (self.root / "accounts.json").read_bytes()
154
+ for args, payload in cases:
155
+ with self.subTest(command=args[0]):
156
+ result = self.run_cli(args, payload)
157
+ self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
158
+ self.assertIn("organization does not match", result.stderr)
159
+ self.assertEqual((self.account / "server.token").read_text(), OLD_TOKEN)
160
+ self.assertEqual(self.receipt.read_bytes(), self.receipt_before)
161
+ self.assertEqual((self.root / "accounts.json").read_bytes(), before)
162
+ self.assertEqual(self.server.calls, 2)
163
+ self.assertFalse(self.inference_log.exists())
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()
@@ -0,0 +1,207 @@
1
+ """Exercise token organization checks through the actual shim, reports and writer.
2
+
3
+ Only synthetic credentials and a loopback Messages endpoint are used. The fake
4
+ Claude executable would accept every token, including one from the wrong account.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import unittest
17
+ from unittest.mock import patch
18
+
19
+ from test_claude_reset import ClaudePoolSandbox, ORG, REPO, ResetHandler, usage_doc
20
+
21
+ sys.path.insert(0, str(REPO / "lib"))
22
+
23
+ WRONG_ORG = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
24
+ WRONG_TOKEN = "sk-ant-oat01-synthetic-wrong-organization"
25
+ RIGHT_TOKEN = "sk-ant-oat01-synthetic-right-organization"
26
+ FAKE_CLI = r'''#!/usr/bin/env python3
27
+ import json, os, pathlib, sys
28
+ args = sys.argv[1:]
29
+ if args == ['--version']:
30
+ print('2.1.280')
31
+ elif args[:2] == ['auth', 'status']:
32
+ print(json.dumps({'loggedIn': True, 'email': 'test@example.invalid'}))
33
+ else:
34
+ row = {'account': pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR', '/default')).name,
35
+ 'token': bool(os.environ.get('CLAUDE_CODE_OAUTH_TOKEN')), 'args': args}
36
+ with open(os.environ['IDENTITY_TEST_COMMANDS'], 'a') as handle:
37
+ handle.write(json.dumps(row) + '\n')
38
+ print('OK' if '-p' in args else json.dumps(row))
39
+ '''
40
+
41
+
42
+ class IdentityHandler(ResetHandler):
43
+ def do_POST(self): # noqa: N802 - HTTP handler contract
44
+ if self.path.startswith("/api/organizations/"):
45
+ return super().do_POST()
46
+ length = int(self.headers.get("Content-Length") or 0)
47
+ self.rfile.read(length)
48
+ state = self.server.state
49
+ state["identity_calls"] += 1
50
+ authorization = self.headers.get("Authorization", "")
51
+ token = authorization[7:] if authorization.startswith("Bearer ") else authorization
52
+ org = ORG if token == RIGHT_TOKEN else state["identity_org"]
53
+ headers = {"anthropic-organization-id": org} if org else {}
54
+ self._send(state["identity_status"], {"content": [{"type": "text", "text": "OK"}]}, headers)
55
+
56
+
57
+ class TokenIdentityIntegrationTests(ClaudePoolSandbox, unittest.TestCase):
58
+ def setUp(self):
59
+ super().setUp()
60
+ self.server.RequestHandlerClass = IdentityHandler
61
+ self.server.state.update(identity_calls=0, identity_org=WRONG_ORG, identity_status=200)
62
+ self.home = Path(self.temp.name) / "home"
63
+ self.fakebin = Path(self.temp.name) / "fakebin"
64
+ self.home.mkdir()
65
+ self.fakebin.mkdir()
66
+ executable = self.fakebin / "claude"
67
+ executable.write_text(FAKE_CLI)
68
+ executable.chmod(0o755)
69
+ self.commands = self.home / "commands.jsonl"
70
+ self.oauth = (self.account / ".credentials.json").read_text()
71
+ (self.account / ".credentials.json").unlink()
72
+ (self.account / "server.token").write_text(WRONG_TOKEN)
73
+ (self.account / ".server-token-verified").write_text(hashlib.sha256(WRONG_TOKEN.encode()).hexdigest())
74
+ self.profile = json.loads((self.account / ".claude.json").read_text())
75
+ self.profile["oauthAccount"]["emailAddress"] = "test@example.invalid"
76
+ (self.account / ".claude.json").write_text(json.dumps(self.profile))
77
+ control = self.pool / "acct-02"
78
+ control.mkdir()
79
+ (control / ".credentials.json").write_text(self.oauth)
80
+ manifest = json.loads((self.pool / "accounts.json").read_text())
81
+ manifest.update(server="none", peers=[])
82
+ manifest["accounts"].append({"id": "acct-02", "email": "control@example.invalid", "home": "mac"})
83
+ (self.pool / "accounts.json").write_text(json.dumps(manifest))
84
+ for account, percent in ((self.account, 0), (control, 80)):
85
+ (account / "limits.json").write_text(json.dumps({"fetched_at": int(time.time()), "source": "oauth",
86
+ "weekly_percent": percent, "session_percent": 0, "max_percent": percent,
87
+ "weekly_resets_epoch": int(time.time()) + 86400, "buckets": []}))
88
+ (self.pool / ".limits-kick").touch()
89
+ (self.pool / "autoresume.off").touch()
90
+ self.environ = self.env(HOME=str(self.home), PATH=f"{self.fakebin}:{os.environ['PATH']}",
91
+ CLAUDE_ACCOUNTS_ROOT=str(self.pool), CLAUDE_MULTIACC_MCP="0", CLAUDE_MULTIACC_CLIENT_LIMITS="0",
92
+ CLAUDE_MULTIACC_AUTO_RESET="0", CLAUDE_SHIM_RETRY="0", IDENTITY_TEST_COMMANDS=str(self.commands),
93
+ CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=f"http://127.0.0.1:{self.server.server_port}/v1/messages")
94
+ for name in ("CLAUDE_ACCOUNT", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
95
+ "ANTHROPIC_API_KEY", "TMUX", "TMUX_PANE", "CLAUDE_MULTIACC_AR_PROBE"):
96
+ self.environ.pop(name, None)
97
+
98
+ def run_cli(self, command="claude", *args, **overrides):
99
+ arguments = list(args) or ["--identity-test"]
100
+ return subprocess.run([str(REPO / "bin" / command), *arguments], cwd=self.home,
101
+ env={**self.environ, **overrides}, capture_output=True, text=True,
102
+ input="", timeout=30, check=False)
103
+
104
+ def identity(self, *, refresh=False):
105
+ from claude_token_identity import cached_identity, check_identity
106
+ with patch.dict(os.environ, self.environ, clear=True):
107
+ return check_identity(str(self.account)) if refresh else cached_identity(str(self.account))
108
+
109
+ def test_old_auth_proof_and_touched_token_cannot_bypass_a_mismatch(self):
110
+ self.server.state["identity_status"] = 429
111
+ first = self.run_cli()
112
+ self.assertEqual(first.returncode, 0, first.stderr)
113
+ self.assertEqual(json.loads(first.stdout)["account"], "acct-02")
114
+ self.assertEqual(self.identity()["state"], "mismatch")
115
+ calls = self.server.state["identity_calls"]
116
+ stamp = time.time() + 5
117
+ os.utime(self.account / "server.token", (stamp, stamp))
118
+ again = self.run_cli()
119
+ self.assertEqual(json.loads(again.stdout)["account"], "acct-02")
120
+ self.assertEqual(self.server.state["identity_calls"], calls)
121
+ report = self.run_cli("claude-accounts", "status", "--json")
122
+ row = next(row for row in json.loads(report.stdout)["accounts"] if row["id"] == "acct-01")
123
+ self.assertFalse(row["selectable"])
124
+ self.assertIn("organization", row["reason"].lower())
125
+ from claude_token_identity import RECEIPT
126
+ receipt_path = self.account / RECEIPT
127
+ receipt_path.unlink()
128
+ receipt_path.mkdir() # Atomic receipt replacement must fail even when tests run as root.
129
+ unsaved = self.run_cli()
130
+ self.assertEqual(unsaved.returncode, 0, unsaved.stderr)
131
+ self.assertEqual(json.loads(unsaved.stdout)["account"], "acct-02")
132
+ self.assertGreater(self.server.state["identity_calls"], calls)
133
+ commands_before = self.commands.read_bytes()
134
+ exhausted = self.run_cli(CLAUDE_MULTIACC_PREFLIGHT_DEPTH="64")
135
+ self.assertNotEqual(exhausted.returncode, 0)
136
+ self.assertEqual(self.commands.read_bytes(), commands_before)
137
+
138
+ def test_changed_token_and_expected_organization_trigger_a_new_check(self):
139
+ self.assertEqual(self.identity(refresh=True)["state"], "mismatch")
140
+ (self.account / "server.token").write_text(RIGHT_TOKEN)
141
+ selected = self.run_cli()
142
+ self.assertEqual(json.loads(selected.stdout)["account"], "acct-01")
143
+ self.assertEqual(self.identity()["state"], "match")
144
+ (self.account / "server.token").write_text(WRONG_TOKEN)
145
+ self.assertEqual(self.identity(refresh=True)["state"], "mismatch")
146
+ self.profile["oauthAccount"]["organizationUuid"] = WRONG_ORG
147
+ (self.account / ".claude.json").write_text(json.dumps(self.profile))
148
+ selected = self.run_cli()
149
+ self.assertEqual(json.loads(selected.stdout)["account"], "acct-01")
150
+ self.assertEqual(self.identity()["state"], "match")
151
+
152
+ def test_working_oauth_and_its_usage_reset_preserve_the_token_receipt(self):
153
+ self.assertEqual(self.identity(refresh=True)["state"], "mismatch")
154
+ receipt = self.identity()
155
+ (self.account / ".credentials.json").write_text(self.oauth)
156
+ selected = self.run_cli()
157
+ self.assertEqual(json.loads(selected.stdout),
158
+ {"account": "acct-01", "token": False, "args": ["--identity-test"]})
159
+ report = self.run_cli("claude-accounts", "status", "--json")
160
+ row = next(row for row in json.loads(report.stdout)["accounts"] if row["id"] == "acct-01")
161
+ self.assertTrue(row["selectable"])
162
+ verified = self.run_cli("claude-accounts", "verify")
163
+ self.assertEqual(verified.returncode, 0, verified.stdout + verified.stderr)
164
+ self.assertEqual(self.identity(), receipt)
165
+ self.server.state["usage"] = usage_doc(session=95, weekly=95, exhausted=["seven_day"])
166
+ self.server.state["responses"] = [(200, {"result": "reset", "resets_left": 0,
167
+ "cleared": ["five_hour", "seven_day", "seven_day_overage_included"]}, True)]
168
+ refreshed = self.run_cli("claude-accounts", "limits", "--force", "--quiet",
169
+ CLAUDE_MULTIACC_AUTO_RESET="1")
170
+ self.assertEqual(refreshed.returncode, 0, refreshed.stderr)
171
+ self.assertEqual(len(self.server.state["posts"]), 1)
172
+ self.assertEqual(self.identity(), receipt)
173
+ (self.account / ".credentials.json").unlink()
174
+ again = self.run_cli()
175
+ self.assertEqual(json.loads(again.stdout)["account"], "acct-02")
176
+
177
+ def test_explicit_pin_refuses_known_wrong_identity_before_user_command(self):
178
+ self.assertEqual(self.identity(refresh=True)["state"], "mismatch")
179
+ pinned = self.run_cli(CLAUDE_ACCOUNT="acct-01")
180
+ self.assertNotEqual(pinned.returncode, 0)
181
+ self.assertIn("organization", (pinned.stdout + pinned.stderr).lower())
182
+ self.assertFalse(self.commands.exists())
183
+
184
+ def test_verify_fails_wrong_organization_even_when_inference_would_answer_ok(self):
185
+ verified = self.run_cli("claude-accounts", "verify")
186
+ self.assertNotEqual(verified.returncode, 0, verified.stdout)
187
+ lines = [line for line in verified.stdout.splitlines() if "acct-01 " in line]
188
+ self.assertTrue(lines, verified.stdout)
189
+ self.assertNotIn(": PASS", "\n".join(lines))
190
+ self.assertIn("organization", "\n".join(lines).lower())
191
+ self.assertEqual(self.identity()["state"], "mismatch")
192
+
193
+ def test_missing_identity_or_endpoint_evidence_remains_fail_open(self):
194
+ for mode in ("profile", "header", "unavailable"):
195
+ with self.subTest(mode=mode):
196
+ profile = {} if mode == "profile" else self.profile
197
+ (self.account / ".claude.json").write_text(json.dumps(profile))
198
+ self.server.state["identity_org"] = None
199
+ self.server.state["identity_status"] = 503 if mode == "unavailable" else 200
200
+ selected = self.run_cli()
201
+ self.assertEqual(selected.returncode, 0, selected.stderr)
202
+ self.assertEqual(json.loads(selected.stdout)["account"], "acct-01")
203
+ self.assertNotEqual(self.identity().get("state"), "mismatch")
204
+
205
+
206
+ if __name__ == "__main__":
207
+ unittest.main()