claude-multiacc 2.0.2 → 2.0.4

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.
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """Sandboxed integration tests for automatic Codex usage-reset redemption."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import unittest
16
+ import uuid
17
+
18
+ REPO = Path(__file__).resolve().parents[1]
19
+
20
+
21
+ def jwt(claims: dict) -> str:
22
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
23
+ return f"header.{payload}.signature"
24
+
25
+
26
+ class ResetHandler(BaseHTTPRequestHandler):
27
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
28
+ state = self.server.state
29
+ if self.path == "/wham/usage":
30
+ state["usage_gets"] += 1
31
+ body = state["usage"]
32
+ elif self.path == "/wham/rate-limit-reset-credits":
33
+ state["credit_gets"] += 1
34
+ body = state["credits"]
35
+ else:
36
+ self.send_error(404)
37
+ return
38
+ encoded = json.dumps(body).encode()
39
+ self.send_response(200)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.send_header("Content-Length", str(len(encoded)))
42
+ self.end_headers()
43
+ self.wfile.write(encoded)
44
+
45
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
46
+ state = self.server.state
47
+ if self.path != "/wham/rate-limit-reset-credits/consume":
48
+ self.send_error(404)
49
+ return
50
+ length = int(self.headers.get("Content-Length") or 0)
51
+ state["posts"].append(json.loads(self.rfile.read(length)))
52
+ status, body = state["responses"].pop(0)
53
+ encoded = json.dumps(body).encode()
54
+ self.send_response(status)
55
+ self.send_header("Content-Type", "application/json")
56
+ self.send_header("Content-Length", str(len(encoded)))
57
+ self.end_headers()
58
+ self.wfile.write(encoded)
59
+
60
+ def log_message(self, _format: str, *_args) -> None:
61
+ return
62
+
63
+
64
+ class CodexResetIntegrationTest(unittest.TestCase):
65
+ def setUp(self) -> None:
66
+ self.temp = tempfile.TemporaryDirectory(prefix="multiacc-reset-")
67
+ self.pool = Path(self.temp.name) / "pool"
68
+ account = self.pool / "acct-01"
69
+ account.mkdir(parents=True)
70
+ manifest = {"version": 1, "threshold": 90, "accounts": [
71
+ {"id": "acct-01", "email": "test@example.invalid", "home": "mac"}]}
72
+ (self.pool / "accounts.json").write_text(json.dumps(manifest), encoding="utf-8")
73
+ claims = {"exp": int(time.time()) + 86400,
74
+ "https://api.openai.com/auth": {"chatgpt_account_id": "account-test"}}
75
+ auth = {"auth_mode": "chatgpt", "tokens": {
76
+ "access_token": jwt(claims), "refresh_token": "fake-refresh",
77
+ "account_id": "account-test"}}
78
+ (account / "auth.json").write_text(json.dumps(auth), encoding="utf-8")
79
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), ResetHandler)
80
+ self.server.state = {"usage_gets": 0, "credit_gets": 0, "posts": [],
81
+ "usage": {}, "credits": {}, "responses": []}
82
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
83
+ self.thread.start()
84
+
85
+ def tearDown(self) -> None:
86
+ self.server.shutdown()
87
+ self.server.server_close()
88
+ self.thread.join(timeout=5)
89
+ self.temp.cleanup()
90
+
91
+ def run_limits(self) -> subprocess.CompletedProcess:
92
+ base = f"http://127.0.0.1:{self.server.server_port}/wham"
93
+ env = os.environ.copy()
94
+ env.update({"CODEX_ACCOUNTS_ROOT": str(self.pool),
95
+ "CODEX_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_MIN_FETCH": "0",
96
+ "CODEX_MULTIACC_AUTO_RESET": "1", "PYTHONDONTWRITEBYTECODE": "1",
97
+ "CODEX_MULTIACC_USAGE_URL": f"{base}/usage"})
98
+ env.pop("CODEX_MULTIACC_RESET_CREDITS_URL", None)
99
+ env.pop("CODEX_MULTIACC_RESET_CONSUME_URL", None)
100
+ return subprocess.run([REPO / "bin/codex-accounts", "limits", "--force"],
101
+ capture_output=True, text=True, env=env, timeout=20, check=False)
102
+
103
+ def test_redeems_at_five_percent_remaining_once(self) -> None:
104
+ now = int(time.time())
105
+ self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
106
+ "allowed": True, "primary_window": {"used_percent": 95,
107
+ "limit_window_seconds": 18000, "reset_at": now + 3600}},
108
+ "rate_limit_reset_credits": {"available_count": 2}}
109
+ self.server.state["credits"] = {"available_count": 2, "credits": [
110
+ {"id": "later", "status": "available", "expires_at": "2026-09-02T00:00:00Z"},
111
+ {"id": "sooner", "status": "available", "expires_at": "2026-09-01T00:00:00Z"}]}
112
+ self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 2})]
113
+ result = self.run_limits()
114
+ self.assertEqual(result.returncode, 0, result.stderr)
115
+ self.assertIn("usage reset redeemed automatically at 95% used", result.stdout)
116
+ self.assertEqual(len(self.server.state["posts"]), 1)
117
+ request = self.server.state["posts"][0]
118
+ self.assertEqual(request["credit_id"], "sooner")
119
+ uuid.UUID(request["redeem_request_id"])
120
+ state = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
121
+ self.assertEqual(state["state"], "complete")
122
+ marker = self.pool / "acct-01/.limited"
123
+ self.assertFalse(marker.exists())
124
+ marker.write_text(f"{now + 3600}\nreason=client-rate-limit\n", encoding="utf-8")
125
+ (self.pool / "acct-01/.usage-reset.json").unlink()
126
+ self.server.state["responses"] = [(200, {"code": "already_redeemed"})]
127
+ result = self.run_limits()
128
+ self.assertEqual(result.returncode, 0, result.stderr)
129
+ self.assertFalse(marker.exists())
130
+ self.assertEqual(self.server.state["posts"][0]["redeem_request_id"],
131
+ self.server.state["posts"][1]["redeem_request_id"])
132
+ self.run_limits()
133
+ self.assertEqual(len(self.server.state["posts"]), 2)
134
+
135
+ def test_does_not_redeem_above_five_percent_remaining(self) -> None:
136
+ now = int(time.time())
137
+ self.server.state["usage"] = {"rate_limit": {"allowed": True,
138
+ "primary_window": {"used_percent": 94, "limit_window_seconds": 18000,
139
+ "reset_at": now + 3600}},
140
+ "rate_limit_reset_credits": {"available_count": 1}}
141
+ result = self.run_limits()
142
+ self.assertEqual(result.returncode, 0, result.stderr)
143
+ self.assertEqual(self.server.state["credit_gets"], 0)
144
+ self.assertEqual(self.server.state["posts"], [])
145
+ self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
146
+
147
+ def test_retries_a_lost_response_with_the_same_idempotency_key(self) -> None:
148
+ now = int(time.time())
149
+ self.server.state["usage"] = {"rate_limit": {"allowed": False,
150
+ "limit_reached": True, "primary_window": {"used_percent": 100,
151
+ "limit_window_seconds": 604800, "reset_at": now + 86400}},
152
+ "rate_limit_reset_credits": {"available_count": 1}}
153
+ self.server.state["credits"] = {"available_count": 1, "credits": []}
154
+ self.server.state["responses"] = [
155
+ (500, {"error": "response lost"}),
156
+ (200, {"code": "already_redeemed", "windows_reset": 2})]
157
+ first = self.run_limits()
158
+ self.assertIn("retry is idempotent", first.stdout)
159
+ self.assertEqual(json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
160
+ ["state"], "pending")
161
+ second = self.run_limits()
162
+ self.assertEqual(second.returncode, 0, second.stderr)
163
+ self.assertEqual(len(self.server.state["posts"]), 2)
164
+ keys = [request["redeem_request_id"] for request in self.server.state["posts"]]
165
+ self.assertEqual(keys[0], keys[1])
166
+ self.assertEqual(self.server.state["credit_gets"], 1)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for lib/keychain.py and its audit/report integration.
3
+
4
+ Runs everywhere: a fake `security` on PATH serves generic-password items from a
5
+ temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS. Nothing
6
+ here can ever touch a real keychain — the fake shadows /usr/bin/security.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import unittest
18
+
19
+ REPO = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(REPO / 'lib'))
21
+
22
+ FAKE_SECURITY = r'''#!/usr/bin/env bash
23
+ KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
24
+ cmd="${1:-}"; shift || true
25
+ svc=""; acct=""; want_pw=0; hexdata=""
26
+ while [ $# -gt 0 ]; do
27
+ case "$1" in
28
+ -s) svc="$2"; shift 2 ;;
29
+ -a) acct="$2"; shift 2 ;;
30
+ -w) want_pw=1; shift ;;
31
+ -X) hexdata="$2"; shift 2 ;;
32
+ *) shift ;;
33
+ esac
34
+ done
35
+ case "$cmd" in
36
+ find-generic-password)
37
+ [ -f "$KC/$svc" ] || exit 44
38
+ if [ "$want_pw" = "1" ]; then
39
+ [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
40
+ cat "$KC/$svc"; exit 0
41
+ fi
42
+ a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
43
+ printf 'attributes:\n "acct"<blob>="%s"\n' "$a"
44
+ printf ' "mdat"<timedate>=0x00 "20260828043137Z\\000"\n'
45
+ printf ' "svce"<blob>="%s"\n' "$svc"
46
+ exit 0 ;;
47
+ add-generic-password)
48
+ [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
49
+ mkdir -p "$KC"
50
+ printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
51
+ printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
52
+ exit 0 ;;
53
+ delete-generic-password)
54
+ [ -f "$KC/$svc" ] || exit 44
55
+ rm -f "$KC/$svc" "$KC/$svc.acct"; exit 0 ;;
56
+ *) exit 1 ;;
57
+ esac
58
+ '''
59
+
60
+ DOC = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-x', 'refreshToken': 'r',
61
+ 'expiresAt': 9999999999999,
62
+ 'refreshTokenExpiresAt': 9999999999999}}
63
+
64
+
65
+ class KeychainTests(unittest.TestCase):
66
+ def setUp(self):
67
+ self.work = tempfile.TemporaryDirectory()
68
+ work = Path(self.work.name)
69
+ fakebin = work / 'bin'
70
+ fakebin.mkdir()
71
+ tool = fakebin / 'security'
72
+ tool.write_text(FAKE_SECURITY)
73
+ tool.chmod(0o755)
74
+ self.kcdir = work / 'keychain'
75
+ self.saved = {k: os.environ.get(k) for k in
76
+ ('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
77
+ 'FAKE_KEYCHAIN_LOCKED')}
78
+ os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
79
+ os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
80
+ os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
81
+ os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
82
+ # (re)load with the fake in place
83
+ import importlib
84
+ import keychain
85
+ self.keychain = importlib.reload(keychain)
86
+ self.root = work / 'accounts'
87
+ (self.root / 'acct-01').mkdir(parents=True)
88
+ (self.root / 'acct-01' / 'tmp').mkdir(exist_ok=True)
89
+
90
+ def tearDown(self):
91
+ for key, value in self.saved.items():
92
+ if value is None:
93
+ os.environ.pop(key, None)
94
+ else:
95
+ os.environ[key] = value
96
+ self.work.cleanup()
97
+
98
+ # -- the layout facts this whole feature stands on ------------------------------
99
+ def test_service_name_matches_the_client(self):
100
+ # Observed live 2026-08-28 (Claude Code 2.1.250, my-mini): this config dir's
101
+ # login sat under exactly this service. The hash input is the PATH STRING as
102
+ # the client received it — no realpath, no trailing slash.
103
+ self.assertEqual(
104
+ self.keychain.service_name('/Users/gas/.claude-accounts/acct-16'),
105
+ 'Claude Code-credentials-4219e2b1')
106
+
107
+ def test_absent_then_present_roundtrip(self):
108
+ d = str(self.root / 'acct-01')
109
+ self.assertEqual(self.keychain.probe(d)['state'], 'absent')
110
+ self.assertTrue(self.keychain.write(d, DOC, account='gas'))
111
+ res = self.keychain.probe(d)
112
+ self.assertEqual(res['state'], 'present')
113
+ self.assertEqual(res['doc'], DOC)
114
+ # -U update path keeps a single item and the account name it was made with
115
+ doc2 = {'claudeAiOauth': dict(DOC['claudeAiOauth'], accessToken='sk-ant-oat01-y')}
116
+ self.assertTrue(self.keychain.write(d, doc2))
117
+ again = self.keychain.probe(d)
118
+ self.assertEqual(again['doc']['claudeAiOauth']['accessToken'], 'sk-ant-oat01-y')
119
+ self.assertTrue(self.keychain.delete(d))
120
+ self.assertEqual(self.keychain.probe(d)['state'], 'absent')
121
+ self.assertTrue(self.keychain.delete(d)) # deleting a missing item is fine
122
+
123
+ def test_locked_is_distinguished_from_absent(self):
124
+ d = str(self.root / 'acct-01')
125
+ self.keychain.write(d, DOC, account='tester')
126
+ os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
127
+ res = self.keychain.probe(d)
128
+ self.assertEqual(res['state'], 'locked')
129
+ self.assertIsNone(res['doc'])
130
+ self.assertEqual(res['account'], 'tester')
131
+ self.assertGreater(self.keychain.item_mtime(d), 0)
132
+ # an id with NO item is still absent, not locked
133
+ self.assertEqual(self.keychain.probe(d + '-other')['state'], 'absent')
134
+
135
+ def test_corrupt_item(self):
136
+ d = str(self.root / 'acct-01')
137
+ svc = self.keychain.service_name(d)
138
+ self.kcdir.mkdir(exist_ok=True)
139
+ (self.kcdir / svc).write_text('not json at all')
140
+ self.assertEqual(self.keychain.probe(d)['state'], 'corrupt')
141
+
142
+ def test_kill_switch(self):
143
+ d = str(self.root / 'acct-01')
144
+ self.keychain.write(d, DOC)
145
+ os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '0'
146
+ self.assertFalse(self.keychain.enabled())
147
+ self.assertEqual(self.keychain.probe(d)['state'], 'absent')
148
+ os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
149
+
150
+ # -- audit + report integration --------------------------------------------------
151
+ def _manifest(self):
152
+ (self.root / 'accounts.json').write_text(json.dumps({
153
+ 'version': 1, 'server': 'root@203.0.113.1',
154
+ 'server_root': '/root/.claude-accounts',
155
+ 'server_repo': '/root/claude-multiacc', 'threshold': 90,
156
+ 'accounts': [{'id': 'acct-01', 'email': 'kc@test', 'home': 'mac',
157
+ 'added_at': '2026-08-28T00:00:00Z'}]}))
158
+
159
+ def test_audit_sees_a_keychain_login(self):
160
+ import audit
161
+ self._manifest()
162
+ d = str(self.root / 'acct-01')
163
+ self.assertEqual(
164
+ audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
165
+ 'home': 'mac'}, machine='mac')['state'],
166
+ 'missing')
167
+ self.keychain.write(d, DOC)
168
+ row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
169
+ 'home': 'mac'}, machine='mac')
170
+ self.assertEqual((row['state'], row['store']), ('ok', 'keychain'))
171
+ os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
172
+ row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
173
+ 'home': 'mac'}, machine='mac')
174
+ self.assertEqual(row['state'], 'locked')
175
+ self.assertEqual(row['label'], 'KEYCHAIN LOCKED')
176
+ os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
177
+
178
+ def test_report_class_and_status(self):
179
+ self._manifest()
180
+ d = str(self.root / 'acct-01')
181
+ self.keychain.write(d, DOC)
182
+ out = subprocess.run(
183
+ [sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
184
+ 'claude', 'mac', 'list'],
185
+ capture_output=True, text=True, check=True, env=os.environ.copy())
186
+ row = json.loads(out.stdout)['accounts'][0]
187
+ self.assertEqual(row['status'], 'active')
188
+ self.assertEqual(row['credential_class'], 'machine-local')
189
+ self.assertEqual(row['credentials']['oauth_store'], 'keychain')
190
+ self.assertEqual(row['credentials']['keychain'], 'readable')
191
+ env = dict(os.environ, FAKE_KEYCHAIN_LOCKED='1')
192
+ out = subprocess.run(
193
+ [sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
194
+ 'claude', 'mac', 'list'],
195
+ capture_output=True, text=True, check=True, env=env)
196
+ row = json.loads(out.stdout)['accounts'][0]
197
+ self.assertEqual(row['status'], 'locked')
198
+ self.assertEqual(row['credential_class'], 'machine-local')
199
+ self.assertFalse(row['selectable'])
200
+ self.assertFalse(row['needs_login'])
201
+
202
+
203
+ if __name__ == '__main__':
204
+ unittest.main()