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,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()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Identity checks must cover retry accounts and the token handed to the command."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
import test_token_identity_integration as integration
|
|
10
|
+
from test_token_identity_integration import IdentityHandler, RIGHT_TOKEN, WRONG_TOKEN
|
|
11
|
+
|
|
12
|
+
RETRY_CLI = r'''#!/usr/bin/env python3
|
|
13
|
+
import json, os, pathlib, sys
|
|
14
|
+
args = sys.argv[1:]
|
|
15
|
+
if args == ['--version']:
|
|
16
|
+
print('2.1.280')
|
|
17
|
+
elif 'USER_IDENTITY_SENTINEL' not in args:
|
|
18
|
+
print('OK')
|
|
19
|
+
else:
|
|
20
|
+
account = pathlib.Path(os.environ['CLAUDE_CONFIG_DIR']).name
|
|
21
|
+
wrong = os.environ.get('CLAUDE_CODE_OAUTH_TOKEN') == 'sk-ant-oat01-synthetic-wrong-organization'
|
|
22
|
+
with open(os.environ['IDENTITY_TEST_COMMANDS'], 'a') as handle:
|
|
23
|
+
handle.write(json.dumps({'account': account, 'wrong_token': wrong}) + '\n')
|
|
24
|
+
if account == os.environ.get('IDENTITY_TEST_FAIL_ACCOUNT'):
|
|
25
|
+
print('rate limit exceeded (429)', file=sys.stderr)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
print('served:' + account)
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SwappingIdentityHandler(IdentityHandler):
|
|
32
|
+
def do_POST(self): # noqa: N802 - HTTP handler contract
|
|
33
|
+
if self.headers.get("Authorization") == f"Bearer {RIGHT_TOKEN}":
|
|
34
|
+
self.server.swap_token.write_text(WRONG_TOKEN)
|
|
35
|
+
return super().do_POST()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TokenIdentityRetryTests(unittest.TestCase):
|
|
39
|
+
def fixture(self):
|
|
40
|
+
pool = integration.TokenIdentityIntegrationTests()
|
|
41
|
+
pool.setUp()
|
|
42
|
+
self.addCleanup(pool.tearDown)
|
|
43
|
+
(pool.fakebin / "claude").write_text(RETRY_CLI)
|
|
44
|
+
startup = Path(pool.temp.name) / "fixed-random.sh"
|
|
45
|
+
startup.write_text("unset RANDOM\nRANDOM=1\n")
|
|
46
|
+
pool.environ.update(CLAUDE_SHIM_RETRY="1", CLAUDE_MULTIACC_HEADROOM_BAND="0", BASH_ENV=str(startup))
|
|
47
|
+
return pool
|
|
48
|
+
|
|
49
|
+
def run_user(self, pool, **overrides):
|
|
50
|
+
return subprocess.run([str(integration.REPO / "bin/claude"), "-p", "USER_IDENTITY_SENTINEL"],
|
|
51
|
+
cwd=pool.home, env={**pool.environ, **overrides}, stdin=subprocess.DEVNULL,
|
|
52
|
+
capture_output=True, text=True, timeout=30, check=False)
|
|
53
|
+
|
|
54
|
+
def test_retry_skips_an_unchecked_wrong_token_before_sending_the_user_command(self):
|
|
55
|
+
pool = self.fixture()
|
|
56
|
+
(pool.account / ".credentials.json").write_text(pool.oauth)
|
|
57
|
+
wrong = pool.pool / "acct-02"
|
|
58
|
+
(wrong / ".credentials.json").unlink()
|
|
59
|
+
(wrong / "server.token").write_text(WRONG_TOKEN)
|
|
60
|
+
(wrong / ".server-token-verified").write_text(hashlib.sha256(WRONG_TOKEN.encode()).hexdigest())
|
|
61
|
+
profile = json.loads(json.dumps(pool.profile))
|
|
62
|
+
profile["oauthAccount"]["emailAddress"] = "control@example.invalid"
|
|
63
|
+
(wrong / ".claude.json").write_text(json.dumps(profile))
|
|
64
|
+
control = pool.pool / "acct-03"
|
|
65
|
+
control.mkdir()
|
|
66
|
+
(control / ".credentials.json").write_text(pool.oauth)
|
|
67
|
+
(control / "limits.json").write_bytes((wrong / "limits.json").read_bytes())
|
|
68
|
+
manifest = json.loads((pool.pool / "accounts.json").read_text())
|
|
69
|
+
manifest["accounts"].append({"id": "acct-03", "email": "third@example.invalid", "home": "mac"})
|
|
70
|
+
(pool.pool / "accounts.json").write_text(json.dumps(manifest))
|
|
71
|
+
result = self.run_user(pool, IDENTITY_TEST_FAIL_ACCOUNT="acct-01")
|
|
72
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
73
|
+
commands = [json.loads(line) for line in pool.commands.read_text().splitlines()]
|
|
74
|
+
self.assertEqual(commands, [{"account": "acct-01", "wrong_token": False},
|
|
75
|
+
{"account": "acct-03", "wrong_token": False}])
|
|
76
|
+
self.assertEqual(result.stdout.strip(), "served:acct-03")
|
|
77
|
+
self.assertGreater(pool.server.state["identity_calls"], 0)
|
|
78
|
+
|
|
79
|
+
def test_a_token_replaced_during_identity_check_is_never_sent_without_verification(self):
|
|
80
|
+
pool = self.fixture()
|
|
81
|
+
(pool.account / "server.token").write_text(RIGHT_TOKEN)
|
|
82
|
+
(pool.account / ".server-token-verified").write_text(hashlib.sha256(RIGHT_TOKEN.encode()).hexdigest())
|
|
83
|
+
pool.server.RequestHandlerClass = SwappingIdentityHandler
|
|
84
|
+
pool.server.swap_token = pool.account / "server.token"
|
|
85
|
+
result = self.run_user(pool)
|
|
86
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
87
|
+
self.assertEqual((pool.account / "server.token").read_text(), WRONG_TOKEN)
|
|
88
|
+
commands = [json.loads(line) for line in pool.commands.read_text().splitlines()]
|
|
89
|
+
self.assertEqual(len(commands), 1)
|
|
90
|
+
self.assertFalse(commands[0]["wrong_token"], commands)
|
|
91
|
+
self.assertIn(commands[0]["account"], {"acct-01", "acct-02"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
unittest.main()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Bootstrap token identity on the source and export only proven receipts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import test_token_identity_integration as integration
|
|
7
|
+
from test_token_identity_integration import IdentityHandler, RIGHT_TOKEN
|
|
8
|
+
|
|
9
|
+
UNKNOWN_TOKEN = "sk-ant-oat01-synthetic-no-identity-header"
|
|
10
|
+
RSYNC_RECORDER = r'''#!/usr/bin/env python3
|
|
11
|
+
import json, os, pathlib, sys
|
|
12
|
+
files = []
|
|
13
|
+
for argument in sys.argv[1:]:
|
|
14
|
+
if argument.startswith('--files-from='):
|
|
15
|
+
files.extend(pathlib.Path(argument.split('=', 1)[1]).read_text().splitlines())
|
|
16
|
+
elif not argument.startswith('-') and pathlib.Path(argument).is_file():
|
|
17
|
+
path = pathlib.Path(argument)
|
|
18
|
+
files.append(path.parent.name + '/' + path.name)
|
|
19
|
+
with open(os.environ['IDENTITY_TEST_TRANSFERS'], 'a') as handle:
|
|
20
|
+
handle.write(json.dumps(files) + '\n')
|
|
21
|
+
'''
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OptionalHeaderHandler(IdentityHandler):
|
|
25
|
+
def do_POST(self): # noqa: N802 - HTTP handler contract
|
|
26
|
+
if self.headers.get("Authorization") == f"Bearer {UNKNOWN_TOKEN}":
|
|
27
|
+
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
|
28
|
+
self.server.state["identity_calls"] += 1
|
|
29
|
+
return self._send(200, {"content": [{"type": "text", "text": "OK"}]})
|
|
30
|
+
return super().do_POST()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenIdentitySyncTests(unittest.TestCase):
|
|
34
|
+
def fixture(self):
|
|
35
|
+
pool = integration.TokenIdentityIntegrationTests()
|
|
36
|
+
pool.setUp()
|
|
37
|
+
self.addCleanup(pool.tearDown)
|
|
38
|
+
(pool.account / ".credentials.json").write_text(pool.oauth)
|
|
39
|
+
pool.server.RequestHandlerClass = OptionalHeaderHandler
|
|
40
|
+
pool.transfers = pool.home / "transfers.jsonl"
|
|
41
|
+
pool.environ["IDENTITY_TEST_TRANSFERS"] = str(pool.transfers)
|
|
42
|
+
for name, body in (("rsync", RSYNC_RECORDER), ("ssh", "#!/bin/sh\nexit 0\n")):
|
|
43
|
+
path = pool.fakebin / name
|
|
44
|
+
path.write_text(body)
|
|
45
|
+
path.chmod(0o755)
|
|
46
|
+
return pool
|
|
47
|
+
|
|
48
|
+
def export_candidates(self, pool):
|
|
49
|
+
# Only the remote-sync command is Mac-gated; shim tests need the native OS's stat flags.
|
|
50
|
+
uname = pool.fakebin / "uname"
|
|
51
|
+
uname.write_text("#!/bin/sh\necho Darwin\n")
|
|
52
|
+
uname.chmod(0o755)
|
|
53
|
+
manifest = json.loads((pool.pool / "accounts.json").read_text())
|
|
54
|
+
manifest.update(server="fixture@peer.invalid", server_root="/fixture/pool", server_repo="/fixture/repo")
|
|
55
|
+
for aid, token, email in (("acct-02", UNKNOWN_TOKEN, "control@example.invalid"),
|
|
56
|
+
("acct-03", RIGHT_TOKEN, "matching@example.invalid")):
|
|
57
|
+
account = pool.pool / aid
|
|
58
|
+
account.mkdir(exist_ok=True)
|
|
59
|
+
(account / "server.token").write_text(token)
|
|
60
|
+
(account / ".credentials.json").write_text(pool.oauth)
|
|
61
|
+
profile = json.loads(json.dumps(pool.profile))
|
|
62
|
+
profile["oauthAccount"]["emailAddress"] = email
|
|
63
|
+
(account / ".claude.json").write_text(json.dumps(profile))
|
|
64
|
+
(account / "limits.json").write_bytes((pool.account / "limits.json").read_bytes())
|
|
65
|
+
if aid == "acct-03":
|
|
66
|
+
manifest["accounts"].append({"id": aid, "email": email, "home": "mac"})
|
|
67
|
+
(pool.pool / "accounts.json").write_text(json.dumps(manifest))
|
|
68
|
+
|
|
69
|
+
def exported_receipts(self, pool):
|
|
70
|
+
entries = [entry for line in pool.transfers.read_text().splitlines() for entry in json.loads(line)]
|
|
71
|
+
self.assertFalse(any(entry.endswith("/.credentials.json") for entry in entries), entries)
|
|
72
|
+
return {entry for entry in entries if entry.endswith("/.server-token-identity.json")}
|
|
73
|
+
|
|
74
|
+
def test_local_source_sync_checks_tokens_even_with_a_working_oauth_login(self):
|
|
75
|
+
pool = self.fixture()
|
|
76
|
+
self.assertEqual(pool.identity()["state"], "unknown")
|
|
77
|
+
synced = pool.run_cli("claude-accounts", "sync", "--no-server")
|
|
78
|
+
self.assertEqual(synced.returncode, 0, synced.stdout + synced.stderr)
|
|
79
|
+
self.assertEqual(pool.identity()["state"], "mismatch")
|
|
80
|
+
self.assertEqual(pool.server.state["identity_calls"], 1)
|
|
81
|
+
self.assertFalse(pool.transfers.exists())
|
|
82
|
+
selected = pool.run_cli()
|
|
83
|
+
self.assertEqual(selected.returncode, 0, selected.stdout + selected.stderr)
|
|
84
|
+
self.assertEqual(json.loads(selected.stdout)["account"], "acct-01")
|
|
85
|
+
self.assertFalse(json.loads(selected.stdout)["token"])
|
|
86
|
+
|
|
87
|
+
def test_verify_bootstraps_token_identity_and_reports_the_healthy_oauth_pass(self):
|
|
88
|
+
pool = self.fixture()
|
|
89
|
+
self.assertEqual(pool.identity()["state"], "unknown")
|
|
90
|
+
verified = pool.run_cli("claude-accounts", "verify")
|
|
91
|
+
self.assertEqual(verified.returncode, 0, verified.stdout + verified.stderr)
|
|
92
|
+
self.assertIn("acct-01 test@example.invalid: PASS", verified.stdout)
|
|
93
|
+
self.assertIn("portable setup-token belongs to a different organization", verified.stdout)
|
|
94
|
+
self.assertEqual(pool.identity()["state"], "mismatch")
|
|
95
|
+
commands = [json.loads(line) for line in pool.commands.read_text().splitlines()]
|
|
96
|
+
self.assertTrue(commands)
|
|
97
|
+
self.assertFalse(any(command["token"] for command in commands))
|
|
98
|
+
|
|
99
|
+
def test_both_distribution_paths_export_match_and_mismatch_but_never_unknown(self):
|
|
100
|
+
from claude_token_identity import cached_identity
|
|
101
|
+
pool = self.fixture()
|
|
102
|
+
self.export_candidates(pool)
|
|
103
|
+
expected = {"acct-01/.server-token-identity.json", "acct-03/.server-token-identity.json"}
|
|
104
|
+
for command in ("limits-distribute-now", "sync"):
|
|
105
|
+
with self.subTest(command=command):
|
|
106
|
+
pool.transfers.write_text("")
|
|
107
|
+
sent = pool.run_cli("claude-accounts", command)
|
|
108
|
+
self.assertEqual(sent.returncode, 0, sent.stdout + sent.stderr)
|
|
109
|
+
self.assertEqual(self.exported_receipts(pool), expected)
|
|
110
|
+
self.assertEqual(pool.identity()["state"], "mismatch")
|
|
111
|
+
self.assertEqual(cached_identity(str(pool.pool / "acct-02"))["state"], "unknown")
|
|
112
|
+
self.assertEqual(cached_identity(str(pool.pool / "acct-03"))["state"], "match")
|
|
113
|
+
|
|
114
|
+
def test_profile_conflict_does_not_export_the_underlying_unknown_receipt(self):
|
|
115
|
+
from claude_token_identity import RECEIPT, cached_identity, check_identity
|
|
116
|
+
pool = self.fixture()
|
|
117
|
+
self.export_candidates(pool)
|
|
118
|
+
account = pool.pool / "acct-02"
|
|
119
|
+
unknown = check_identity(str(account), endpoint=pool.environ["CLAUDE_MULTIACC_TOKEN_IDENTITY_URL"])
|
|
120
|
+
self.assertEqual(unknown["state"], "unknown")
|
|
121
|
+
receipt = (account / RECEIPT).read_bytes()
|
|
122
|
+
profile = json.loads((account / ".claude.json").read_text())
|
|
123
|
+
profile["oauthAccount"]["emailAddress"] = "different-profile@example.invalid"
|
|
124
|
+
(account / ".claude.json").write_text(json.dumps(profile))
|
|
125
|
+
computed = cached_identity(str(account))
|
|
126
|
+
self.assertEqual(computed["state"], "mismatch")
|
|
127
|
+
self.assertFalse(computed["cached"])
|
|
128
|
+
expected = {"acct-01/.server-token-identity.json", "acct-03/.server-token-identity.json"}
|
|
129
|
+
for command in ("limits-distribute-now", "sync"):
|
|
130
|
+
with self.subTest(command=command):
|
|
131
|
+
pool.transfers.write_text("")
|
|
132
|
+
sent = pool.run_cli("claude-accounts", command)
|
|
133
|
+
self.assertEqual(sent.returncode, 0, sent.stdout + sent.stderr)
|
|
134
|
+
self.assertEqual(self.exported_receipts(pool), expected)
|
|
135
|
+
self.assertEqual((account / RECEIPT).read_bytes(), receipt)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
unittest.main()
|