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.
- package/README.md +6 -1
- package/bin/claude +58 -15
- package/bin/claude-accounts +76 -11
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +10 -0
- package/lib/claude_token_identity.py +233 -0
- package/lib/common.sh +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 +11 -2
- 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,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
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -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
|
|
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
|
|
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()
|