claude-multiacc 2.0.43 → 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 (38) hide show
  1. package/README.md +6 -1
  2. package/bin/claude +58 -15
  3. package/bin/claude-accounts +76 -11
  4. package/docs/claude-token-identity.md +147 -0
  5. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  16. package/lib/audit.py +10 -0
  17. package/lib/claude_token_identity.py +233 -0
  18. package/lib/common.sh +3 -1
  19. package/lib/credential.py +9 -2
  20. package/package.json +1 -1
  21. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  22. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  23. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  33. package/tests/run-tests.sh +11 -2
  34. package/tests/test_claude_token_identity.py +259 -0
  35. package/tests/test_token_identity_ceremony.py +167 -0
  36. package/tests/test_token_identity_integration.py +207 -0
  37. package/tests/test_token_identity_retry.py +95 -0
  38. package/tests/test_token_identity_sync.py +139 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ """Bind a portable Claude token to its slot using authenticated response headers.
3
+
4
+ An inference may be quota-rejected and still identify the token's organization.
5
+ This receipt proves organization identity only, never remaining quota or token validity.
6
+ No credentials or response bodies are written to the receipt or diagnostic output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import fcntl
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ import uuid
24
+
25
+ from claude_reset import cli_version, user_agent
26
+
27
+ RECEIPT = ".server-token-identity.json"
28
+ LOCK_FILE = ".server-token-identity.lock"
29
+ ENDPOINT = "https://api.anthropic.com/v1/messages?beta=true"
30
+ MODEL = "claude-haiku-4-5-20251001"
31
+ UNKNOWN_BACKOFF = 60
32
+ TIMEOUT = 10
33
+
34
+
35
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
36
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
37
+ return None
38
+
39
+
40
+ def _org(value):
41
+ try:
42
+ return str(uuid.UUID(value)) if isinstance(value, str) else ""
43
+ except ValueError:
44
+ return ""
45
+
46
+
47
+ def _load(path):
48
+ try:
49
+ with path.open(encoding="utf-8") as handle:
50
+ value = json.load(handle)
51
+ return value if isinstance(value, dict) else {}
52
+ except (OSError, ValueError):
53
+ return {}
54
+
55
+
56
+ def _context(account_dir, token, now, expected_email=None):
57
+ account = Path(account_dir)
58
+ result = {"status": "unknown", "reason": "token_unavailable", "checked_at": now}
59
+ try:
60
+ if token is None:
61
+ with (account / "server.token").open(encoding="utf-8") as handle:
62
+ token = handle.read(4097)
63
+ token = token.strip()
64
+ except (OSError, ValueError, AttributeError):
65
+ return result, ""
66
+ if not token.startswith("sk-ant-oat") or len(token) > 4096 or not all(33 <= ord(c) <= 126 for c in token):
67
+ return dict(result, reason="invalid_token"), ""
68
+ digest = hashlib.sha256(token.encode()).hexdigest()
69
+ config = _load(account / ".claude.json")
70
+ profile = config.get("oauthAccount")
71
+ profile = profile if isinstance(profile, dict) else {}
72
+ rows = _load(account.parent / "accounts.json").get("accounts", [])
73
+ rows = rows if isinstance(rows, list) else []
74
+ row = next((r for r in rows if isinstance(r, dict) and r.get("id") == account.name), {})
75
+ email = str(row.get("email") or "").strip().lower()
76
+ profile_email = str(profile.get("emailAddress") or profile.get("email") or "").strip().lower()
77
+ result.update(token_digest=digest, account_id=account.name, account_email=email, manifest_bound=bool(row))
78
+ if expected_email is not None and not (profile_email or email):
79
+ return dict(result, reason="missing_profile_email"), ""
80
+ if ((email and profile_email and email != profile_email)
81
+ or (expected_email is not None and str(expected_email).strip().lower() != (profile_email or email))):
82
+ return dict(result, status="mismatch", reason="profile_manifest_mismatch"), ""
83
+ receipt = _load(account / RECEIPT)
84
+ bound = all(receipt.get(key) == result[key] for key in
85
+ ("token_digest", "account_id", "account_email", "manifest_bound"))
86
+ expected = _org(profile.get("organizationUuid"))
87
+ if not expected and "oauthAccount" not in config and bound and row:
88
+ expected = _org(receipt.get("expected_org"))
89
+ if not expected:
90
+ return dict(result, reason="missing_expected_organization"), ""
91
+ result.update(expected_org=expected, reason="unchecked")
92
+ actual, status = _org(receipt.get("actual_org")), receipt.get("status")
93
+ checked = receipt.get("checked_at")
94
+ reusable = ((status == "match" and actual == expected)
95
+ or (status == "mismatch" and actual and actual != expected)
96
+ or (status == "unknown" and isinstance(checked, (int, float))
97
+ and 0 <= now - checked < UNKNOWN_BACKOFF))
98
+ if bound and receipt.get("expected_org") == expected and reusable:
99
+ return dict(receipt, cached=True), ""
100
+ return result, token
101
+
102
+
103
+ def _probe(token, endpoint, model, version):
104
+ try:
105
+ url = urllib.parse.urlsplit(endpoint)
106
+ except ValueError:
107
+ return {"status": "unknown", "actual_org": "", "reason": "invalid_endpoint"}
108
+ official = url.scheme == "https" and url.netloc == "api.anthropic.com"
109
+ local = url.scheme == "http" and url.hostname in {"127.0.0.1", "::1", "localhost"}
110
+ if not (official or local) or url.username or url.password or url.fragment:
111
+ return {"status": "unknown", "actual_org": "", "reason": "invalid_endpoint"}
112
+ betas = ["oauth-2025-04-20", "prompt-caching-scope-2026-01-05"]
113
+ if "haiku" not in model.lower():
114
+ betas.append("claude-code-20250219")
115
+ body = {"model": model, "max_tokens": 1, "messages": [{"role": "user", "content": "quota"}]}
116
+ request = urllib.request.Request(endpoint, data=json.dumps(body).encode(), headers={
117
+ "Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01",
118
+ "anthropic-beta": ",".join(betas), "Content-Type": "application/json",
119
+ "User-Agent": user_agent(version), "x-app": "cli"})
120
+ opener = urllib.request.build_opener(NoRedirect())
121
+ try:
122
+ try:
123
+ response = opener.open(request, timeout=TIMEOUT)
124
+ except urllib.error.HTTPError as error:
125
+ response = error
126
+ with response:
127
+ code = response.code
128
+ values = response.headers.get_all("anthropic-organization-id", [])
129
+ actual = _org(values[0]) if len(values) == 1 else ""
130
+ if 300 <= code < 400:
131
+ return {"status": "unknown", "actual_org": "", "reason": "redirect"}
132
+ reason = "organization_header" if actual else "missing_organization"
133
+ if not actual and code in {401, 403}:
134
+ reason = "auth_failed"
135
+ return {"actual_org": actual, "http_status": code, "reason": reason}
136
+ except (OSError, ValueError, urllib.error.URLError):
137
+ return {"status": "unknown", "actual_org": "", "reason": "request_failed"}
138
+
139
+
140
+ def _save(account, receipt):
141
+ path = None
142
+ try:
143
+ fd, path = tempfile.mkstemp(prefix=RECEIPT + ".", dir=account)
144
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
145
+ json.dump(receipt, handle, sort_keys=True)
146
+ handle.write("\n")
147
+ os.replace(path, account / RECEIPT)
148
+ except OSError:
149
+ pass
150
+ finally:
151
+ try:
152
+ if path:
153
+ os.unlink(path)
154
+ except OSError:
155
+ pass
156
+
157
+
158
+ def _result(receipt):
159
+ result = {"state": receipt.get("status", "unknown"), "cached": receipt.get("cached", False),
160
+ "token_digest": receipt.get("token_digest", ""), "reason": receipt.get("reason", "unchecked")}
161
+ if "http_status" in receipt:
162
+ result["http_status"] = receipt["http_status"]
163
+ return result
164
+
165
+
166
+ def cached_identity(account_dir, token=None, *, now=None):
167
+ """Return identity already proved for this token and slot, without HTTP."""
168
+ receipt, _ = _context(account_dir, token, time.time() if now is None else now)
169
+ return _result(receipt)
170
+
171
+
172
+ def check_identity(account_dir, token=None, *, endpoint=None, model=None, version=None, now=None,
173
+ persist=True, expected_email=None):
174
+ """Check this exact token, or only the requested account's server.token file.
175
+
176
+ Match/mismatch receipts survive indefinitely because a token cannot change its
177
+ organization. Changing either the token or configured organization invalidates
178
+ them; an inconclusive request backs off for one minute. Authentication preflight
179
+ remains independent, including for an identity-matched but later revoked token.
180
+ A persistent lock prevents a delayed unknown result from replacing proof written
181
+ by another launch. Read-only callers still receive independently proved mismatches.
182
+ """
183
+ lock = None
184
+ try:
185
+ lock = os.fdopen(os.open(Path(account_dir) / LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600), "w")
186
+ fcntl.flock(lock, fcntl.LOCK_EX)
187
+ except OSError:
188
+ if lock:
189
+ lock.close()
190
+ lock = None
191
+ try:
192
+ receipt, token = _context(account_dir, token, time.time() if now is None else now, expected_email)
193
+ if not token:
194
+ return _result(receipt)
195
+ endpoint = endpoint or os.environ.get("CLAUDE_MULTIACC_TOKEN_IDENTITY_URL") or ENDPOINT
196
+ receipt.update(_probe(token, endpoint, model or MODEL, version))
197
+ actual = receipt["actual_org"]
198
+ receipt["status"] = ("match" if actual == receipt["expected_org"] else "mismatch") if actual else "unknown"
199
+ if not lock and not actual:
200
+ current, _ = _context(account_dir, token, time.time() if now is None else now, expected_email)
201
+ if current["status"] in {"match", "mismatch"}:
202
+ return _result(current)
203
+ if persist and lock:
204
+ _save(Path(account_dir), receipt)
205
+ return _result(receipt)
206
+ finally:
207
+ if lock:
208
+ lock.close()
209
+
210
+
211
+ def main():
212
+ parser = argparse.ArgumentParser(description=__doc__)
213
+ parser.add_argument("command", choices=["check", "cached"])
214
+ parser.add_argument("account_dir")
215
+ parser.add_argument("--token-stdin", action="store_true", help="use the exact token read from stdin")
216
+ parser.add_argument("--real", help="installed Claude executable, used only to learn its version")
217
+ parser.add_argument("--model", default=MODEL)
218
+ parser.add_argument("--format", choices=["json", "state"], default="json")
219
+ parser.add_argument("--no-store", action="store_true", help="check a candidate without replacing the receipt")
220
+ parser.add_argument("--expected-email", help="named account email for a candidate not yet in the manifest")
221
+ args = parser.parse_args()
222
+ token = sys.stdin.read(4097) if args.token_stdin else None
223
+ if args.command == "cached":
224
+ result = cached_identity(args.account_dir, token)
225
+ else:
226
+ result = check_identity(args.account_dir, token, model=args.model,
227
+ version=cli_version(args.real), persist=not args.no_store,
228
+ expected_email=args.expected_email)
229
+ print(result["state"] if args.format == "state" else json.dumps(result, sort_keys=True))
230
+
231
+
232
+ if __name__ == "__main__":
233
+ main()
package/lib/common.sh CHANGED
@@ -441,7 +441,9 @@ EOF
441
441
  fi
442
442
  local d="$ACC_ROOT/$target_id"
443
443
  seed_account_dir "$d"
444
- "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" >/dev/null \
444
+ local identity_cli=""
445
+ [ "$MULTIACC_PROVIDER" != "claude" ] || identity_cli="$(find_real_claude "${_self:-$0}" 2>/dev/null)" || true
446
+ "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" "$identity_cli" >/dev/null \
445
447
  || fail_locked "could not install the credential into $d — nothing registered"
446
448
  manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
447
449
  || fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
package/lib/credential.py CHANGED
@@ -12,7 +12,7 @@ Run (all paths absolute; the blob never passes through argv):
12
12
  class, id, email, home, added_at, cred_type. The separator is deliberately NOT
13
13
  whitespace: bash collapses runs of IFS whitespace, which would silently shift
14
14
  an empty field (a blob with no `home`) into the next one.
15
- python3 lib/credential.py install <provider> <blob-file> <dest-dir>
15
+ python3 lib/credential.py install <provider> <blob-file> <dest-dir> [real-claude]
16
16
  writes the credential material into <dest-dir> (0600, atomic); no-op for an
17
17
  identity-only blob. Prints the file it wrote (or nothing).
18
18
 
@@ -315,7 +315,7 @@ def cmd_inspect(argv):
315
315
 
316
316
  def cmd_install(argv):
317
317
  if len(argv) < 3:
318
- die('usage: credential.py install <provider> <blob-file> <dest-dir>')
318
+ die('usage: credential.py install <provider> <blob-file> <dest-dir> [real-claude]')
319
319
  provider, path, dest = argv[0], argv[1], argv[2]
320
320
  blob, cclass, _aid, _email, _home, _added, _type = _validate(provider, path)
321
321
  if cclass == 'identity':
@@ -325,6 +325,13 @@ def cmd_install(argv):
325
325
  f'write a credential through it', 2)
326
326
  if not os.path.isdir(dest):
327
327
  die(f'destination is not a directory: {dest}')
328
+ from claude_reset import cli_version
329
+ from claude_token_identity import check_identity
330
+ identity = check_identity(dest, blob['credential']['value'].strip(), persist=False,
331
+ version=cli_version(argv[3] if len(argv) > 3 else None), expected_email=_email)
332
+ if identity['state'] == 'mismatch':
333
+ die('the token organization does not match the destination account — nothing installed; '
334
+ 'mint a token for the named account', 5)
328
335
  out = os.path.join(dest, 'server.token')
329
336
  _write_secure(out, blob['credential']['value'].strip(), nofollow_dir=True)
330
337
  print(out)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.43",
3
+ "version": "2.0.44",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -367,6 +367,9 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
367
367
  # (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
368
368
  # throttle keeps those background kicks from racing explicit limits runs.
369
369
  export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
370
+ # Organization checks must never send synthetic tokens to a real provider. The
371
+ # dedicated identity suite overrides this with its loopback HTTP contract server.
372
+ export CLAUDE_MULTIACC_TOKEN_IDENTITY_URL="file://$WORK/identity-endpoint-missing.json"
370
373
  : > "$ACC/.limits-kick"
371
374
  # Keychain lookups are OFF for the legacy sections (their pools are file-based and the
372
375
  # extra `security` process per account would only slow them down); section 17 turns
@@ -7672,12 +7675,12 @@ check "mint refuses an unregistered account dir" "unknown account: acct-77" "$ou
7672
7675
  || t_fail "mint orphan dir" "server.token was written to an unregistered slot"
7673
7676
  rm -rf "$ACC/acct-77"
7674
7677
 
7675
- # mint must NAME the account it is about to bind a token to (the only guard there is).
7678
+ # mint must NAME the account even when no saved organization is available to check.
7676
7679
  printf 'sk-ant-oat01-MINTNAMEDMINTNAMEDMINTNAMEDMINTNAMEDMINTNAMED\n' \
7677
7680
  | claude-accounts mint acct-01 --paste > "$WORK/mint-named.out" 2>&1
7678
7681
  out="$(cat "$WORK/mint-named.out")"
7679
7682
  check "mint --paste names the account in its prompt" "for acct-01" "$out"
7680
- check "mint warns that a setup token carries no identity" "carries no identity" "$out"
7683
+ check "mint reports an unavailable organization check" "organization could not be checked" "$out"
7681
7684
 
7682
7685
  # ---- 47. every known verb answers --help with exit 0 (app-robot probes with it) ----
7683
7686
  # app-robot's runner asks `<verb> --help` to decide whether a Mac's build has the verb;
@@ -9021,6 +9024,12 @@ else
9021
9024
  t_fail "auto-resume watcher unit suite" "see unittest output above"
9022
9025
  fi
9023
9026
 
9027
+ if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_*token_identity*.py'; then
9028
+ t_ok "claude: portable token organization identity suite"
9029
+ else
9030
+ t_fail "claude portable token identity suite" "see unittest output above"
9031
+ fi
9032
+
9024
9033
  # ---- summary ---------------------------------------------------------------------
9025
9034
  echo
9026
9035
  echo "passed: $PASS failed: $FAIL"
@@ -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()