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.
- package/README.md +20 -7
- package/bin/claude +66 -19
- package/bin/claude-accounts +186 -54
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -0
- 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 +42 -4
- package/lib/claude_token_identity.py +233 -0
- package/lib/codex_audit.py +8 -1
- 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 +254 -3
- package/tests/test_autoresume.py +36 -5
- 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,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()
|