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,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()