claude-multiacc 2.0.43 → 2.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +20 -7
  2. package/bin/claude +66 -19
  3. package/bin/claude-accounts +186 -54
  4. package/bin/codex +4 -3
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/VERIFICATION.md +8 -0
  8. package/docs/claude-token-identity.md +147 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +42 -4
  21. package/lib/claude_token_identity.py +233 -0
  22. package/lib/codex_audit.py +8 -1
  23. package/lib/common.sh +3 -1
  24. package/lib/credential.py +9 -2
  25. package/package.json +1 -1
  26. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  38. package/tests/run-tests.sh +254 -3
  39. package/tests/test_autoresume.py +36 -5
  40. package/tests/test_claude_token_identity.py +259 -0
  41. package/tests/test_token_identity_ceremony.py +167 -0
  42. package/tests/test_token_identity_integration.py +207 -0
  43. package/tests/test_token_identity_retry.py +95 -0
  44. package/tests/test_token_identity_sync.py +139 -0
@@ -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()