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.
- package/README.md +20 -7
- package/bin/claude +66 -19
- package/bin/claude-accounts +186 -54
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -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 +42 -4
- package/lib/claude_token_identity.py +233 -0
- package/lib/codex_audit.py +8 -1
- package/lib/common.sh +3 -1
- package/lib/credential.py +9 -2
- 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/run-tests.sh +254 -3
- package/tests/test_autoresume.py +36 -5
- package/tests/test_claude_token_identity.py +259 -0
- 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()
|
|
@@ -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()
|