claude-multiacc 2.0.46 → 2.0.47

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 (39) hide show
  1. package/README.md +29 -23
  2. package/bin/claude +48 -31
  3. package/bin/claude-accounts +30 -16
  4. package/docs/ACCOUNT_OPERATIONS.md +13 -9
  5. package/docs/AUTORESUME.md +8 -1
  6. package/docs/VERIFICATION.md +5 -2
  7. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/audit.py +8 -26
  20. package/lib/autoresume.py +7 -5
  21. package/lib/claude_client_error.py +81 -0
  22. package/lib/common.sh +11 -0
  23. package/package.json +1 -1
  24. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  36. package/tests/run-tests.sh +59 -38
  37. package/tests/test_autoresume.py +15 -2
  38. package/tests/test_claude_client_error.py +119 -0
  39. package/tests/test_claude_org_block.py +277 -0
@@ -0,0 +1,277 @@
1
+ """Organization refusals stay excluded until a real call proves access is enabled.
2
+
3
+ The actual shim and account commands run with synthetic OAuth files, a fake CLI,
4
+ and a shared transcript tree. No provider, Keychain, scheduler or real pool is used.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ import unittest
17
+
18
+
19
+ REPO = Path(__file__).resolve().parents[1]
20
+ SID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
21
+ ORG_TEXT = ("Your organization has disabled Claude subscription access for Claude Code · "
22
+ "Use an Anthropic API key instead, or ask your admin to enable access")
23
+ FAKE_CLI = r'''#!/usr/bin/env python3
24
+ import json, os, pathlib, sys
25
+ args = sys.argv[1:]
26
+ account = pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR', '/none')).name
27
+ if args == ['--version']:
28
+ print('2.1.285')
29
+ elif args[:2] == ['auth', 'status']:
30
+ print(json.dumps({'loggedIn': True, 'email': 'fixture@example.invalid'}))
31
+ elif args == ['-p', '--output-format', 'text', '--max-turns', '1']:
32
+ text = os.environ.get('ORG_TEST_VERIFY_TEXT') if account == 'acct-01' else None
33
+ print(text or 'OK')
34
+ sys.exit(1 if text else 0)
35
+ else:
36
+ print('CFG=' + account)
37
+ '''
38
+
39
+
40
+ class ClaudeOrganizationBlockIntegrationTests(unittest.TestCase):
41
+ def setUp(self):
42
+ self.work = tempfile.TemporaryDirectory(prefix="multiacc-org-block-")
43
+ self.addCleanup(self.work.cleanup)
44
+ self.home = Path(self.work.name)
45
+ self.pool, self.fakebin = self.home / "pool", self.home / "fakebin"
46
+ self.pool.mkdir()
47
+ self.fakebin.mkdir()
48
+ self.projects = self.home / "projects"
49
+ (self.projects / "-fixture").mkdir(parents=True)
50
+ self.accounts = [self.pool / "acct-01", self.pool / "acct-02"]
51
+ self.now = int(time.time())
52
+ creds = {"claudeAiOauth": {"accessToken": "sk-ant-oat01-synthetic", "refreshToken": "synthetic",
53
+ "expiresAt": (self.now + 86400) * 1000, "refreshTokenExpiresAt": (self.now + 864000) * 1000,
54
+ "scopes": ["user:inference", "user:profile"], "subscriptionType": "max"}}
55
+ for account, percent in zip(self.accounts, (0, 80)):
56
+ account.mkdir()
57
+ (account / "sessions").mkdir()
58
+ (account / "projects").symlink_to(self.projects, target_is_directory=True)
59
+ (account / ".credentials.json").write_text(json.dumps(creds))
60
+ (account / "limits.json").write_text(json.dumps({"fetched_at": self.now, "source": "oauth",
61
+ "weekly_percent": percent, "session_percent": 0, "max_percent": percent,
62
+ "weekly_resets_epoch": self.now + 86400, "buckets": []}))
63
+ (self.pool / "accounts.json").write_text(json.dumps({"version": 1, "threshold": 90, "server": "none",
64
+ "accounts": [{"id": account.name, "email": account.name + "@example.invalid", "home": "mac"}
65
+ for account in self.accounts]}))
66
+ for name, script in (("claude", FAKE_CLI), ("security", "#!/bin/sh\nexit 44\n")):
67
+ path = self.fakebin / name
68
+ path.write_text(script)
69
+ path.chmod(0o755)
70
+ self.env = {key: value for key, value in os.environ.items()
71
+ if not key.startswith(("CLAUDE_", "ANTHROPIC_")) and key not in ("TMUX", "TMUX_PANE")}
72
+ self.env.update(HOME=str(self.home), PATH=f"{self.fakebin}:{os.environ['PATH']}",
73
+ CLAUDE_ACCOUNTS_ROOT=str(self.pool), CODEX_ACCOUNTS_ROOT=str(self.home / "codex-pool"),
74
+ CLAUDE_MULTIACC_KEYCHAIN="0", CLAUDE_MULTIACC_PATH_PROBE="0", CLAUDE_MULTIACC_MCP="0",
75
+ CLAUDE_MULTIACC_AUTORESUME="0", CLAUDE_MULTIACC_AUTO_RESET="0", CLAUDE_MULTIACC_HEADROOM_BAND="0",
76
+ CLAUDE_MULTIACC_CLIENT_SCAN_TTL="0", CLAUDE_MULTIACC_NO_SYNC="1", CLAUDE_MULTIACC_NO_DISTRIBUTE="1",
77
+ CLAUDE_MULTIACC_USAGE_URL=f"file://{self.home}/missing-usage.json",
78
+ CLAUDE_MULTIACC_TOKEN_URL=f"file://{self.home}/missing-token.json",
79
+ CLAUDE_MULTIACC_TOKEN_IDENTITY_URL=f"file://{self.home}/missing-identity.json",
80
+ PYTHONDONTWRITEBYTECODE="1")
81
+
82
+ def run_cli(self, command="claude", *args, **overrides):
83
+ (self.pool / ".limits-kick").touch()
84
+ return subprocess.run([str(REPO / "bin" / command), *args], cwd=self.home,
85
+ env={**self.env, **overrides}, capture_output=True, text=True,
86
+ input="", timeout=30, check=False)
87
+
88
+ def transcript(self, *, epoch=None, claim=None, changes=None, reset=True):
89
+ account = self.accounts[0]
90
+ if reset:
91
+ for owner in self.accounts:
92
+ for name in (".expired", ".sessions-index", ".client-scan", ".client-org-cleared"):
93
+ (owner / name).unlink(missing_ok=True)
94
+ sessions = owner / "sessions"
95
+ if sessions.is_symlink():
96
+ sessions.unlink()
97
+ sessions.mkdir()
98
+ epoch = self.now - 30 if epoch is None else epoch
99
+ claim = self.now - 60 if claim is None else claim
100
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch))
101
+ record = {"type": "assistant", "isApiErrorMessage": True, "isSidechain": False,
102
+ "error": "oauth_org_not_allowed", "apiErrorStatus": 403, "timestamp": timestamp,
103
+ "sessionId": SID, "session_id": SID, "version": "2.1.285", "message": {
104
+ "role": "assistant", "type": "message", "model": "<synthetic>",
105
+ "content": [{"type": "text", "text": ORG_TEXT}]}}
106
+ record.update(changes or {})
107
+ path = self.projects / "-fixture" / f"{SID}.jsonl"
108
+ path.write_text(json.dumps(record) + "\n")
109
+ claim_text = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(claim))
110
+ (account / ".sessions-index").write_text(f"{SID} {claim_text}\n")
111
+ return path
112
+
113
+ def test_interactive_refusal_is_excluded_on_later_runs_and_all_limited_fallback(self):
114
+ path = self.transcript()
115
+ blocked, healthy = self.accounts
116
+ auth = json.loads(path.read_text())
117
+ auth.update(error="authentication_failed",
118
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.now - 10)))
119
+ auth["message"]["content"][0]["text"] = "Not logged in · Please run /login"
120
+ with path.open("a") as handle:
121
+ handle.write(json.dumps(auth) + "\n")
122
+ newer_sid = "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
123
+ auth.update(sessionId=newer_sid, session_id=newer_sid)
124
+ (path.parent / f"{newer_sid}.jsonl").write_text(json.dumps(auth) + "\n")
125
+ newer_claim = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.now - 20))
126
+ with (blocked / ".sessions-index").open("a") as handle:
127
+ handle.write(f"{newer_sid} {newer_claim}\n")
128
+ for arguments in ((), ("-p", "hello"), (), ("-p", "hello")):
129
+ result = self.run_cli("claude", *arguments)
130
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
131
+ self.assertEqual(result.stdout.strip(), "CFG=acct-02")
132
+ self.assertIn("reason=org-blocked", (blocked / ".expired").read_text())
133
+ for verb in ("status", "list"):
134
+ result = self.run_cli("claude-accounts", verb, "--json")
135
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
136
+ row = next(row for row in json.loads(result.stdout)["accounts"] if row["id"] == "acct-01")
137
+ self.assertEqual(row["status"], "blocked")
138
+ self.assertFalse(row["selectable"])
139
+ for account, duration in ((blocked, 120), (healthy, 3600)):
140
+ (account / ".limited").write_text(f"{self.now + duration}\n"
141
+ "bucket=client:five_hour percent=100 reason=client-rate-limit\n")
142
+ result = self.run_cli()
143
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
144
+ self.assertEqual(result.stdout.strip(), "CFG=acct-02")
145
+
146
+ def test_legacy_expired_soft_deadline_and_newer_credentials_do_not_lift_org_block(self):
147
+ blocked = self.accounts[0]
148
+ (blocked / ".expired").write_text(f"{self.now - 86400}\n"
149
+ f"reason=org-blocked soft_until={self.now - 60} marked_at=2020-01-01T00:00:00Z detail=fixture\n")
150
+ (blocked / "server.token").write_text("sk-ant-oat01-synthetic-newer")
151
+ for name in (".credentials.json", "server.token"):
152
+ os.utime(blocked / name, (self.now + 5, self.now + 5))
153
+ for verb in ("list", "status"):
154
+ result = self.run_cli("claude-accounts", verb, "--json")
155
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
156
+ row = json.loads(result.stdout)["accounts"][0]
157
+ self.assertEqual(row["status"], "blocked")
158
+ self.assertFalse(row["selectable"])
159
+ result = self.run_cli()
160
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
161
+ self.assertEqual(result.stdout.strip(), "CFG=acct-02")
162
+ self.assertTrue((blocked / ".expired").exists())
163
+
164
+ def test_verify_pass_or_quota_clears_org_and_does_not_reharvest_old_refusal(self):
165
+ blocked = self.accounts[0]
166
+ for refusal in ("", "You've hit your weekly limit · resets 1am (Europe/Istanbul)"):
167
+ with self.subTest(verification=refusal or "PASS"):
168
+ self.transcript()
169
+ parked = self.run_cli()
170
+ self.assertEqual(parked.stdout.strip(), "CFG=acct-02")
171
+ result = self.run_cli("claude-accounts", "verify", ORG_TEST_VERIFY_TEXT=refusal)
172
+ self.assertEqual(result.returncode, 1 if refusal else 0, result.stdout + result.stderr)
173
+ self.assertIn("usage-limited" if refusal else "PASS", result.stdout)
174
+ self.assertFalse((blocked / ".expired").exists())
175
+ watermark = float((blocked / ".client-org-cleared").read_text().strip())
176
+ self.assertGreaterEqual(watermark, self.now - 30)
177
+ result = self.run_cli()
178
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
179
+ self.assertEqual(result.stdout.strip(), "CFG=acct-01")
180
+ self.assertFalse((blocked / ".expired").exists())
181
+ time.sleep(max(0, int(watermark) + 1.05 - time.time()))
182
+ self.transcript(epoch=int(time.time()), reset=False)
183
+ result = self.run_cli()
184
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
185
+ self.assertEqual(result.stdout.strip(), "CFG=acct-02")
186
+ self.assertIn("reason=org-blocked", (blocked / ".expired").read_text())
187
+
188
+ def test_verify_org_refusal_parks_and_server_throttling_does_not_prove_recovery(self):
189
+ blocked = self.accounts[0]
190
+ result = self.run_cli("claude-accounts", "verify", ORG_TEST_VERIFY_TEXT=ORG_TEXT)
191
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
192
+ self.assertIn("ORG BLOCKED", result.stdout)
193
+ self.assertIn("reason=org-blocked", (blocked / ".expired").read_text())
194
+ result = self.run_cli("claude-accounts", "verify",
195
+ ORG_TEST_VERIFY_TEXT="Server is temporarily limiting requests (not your usage limit)")
196
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
197
+ self.assertTrue((blocked / ".expired").exists())
198
+ self.assertFalse((blocked / ".client-org-cleared").exists())
199
+ result = self.run_cli()
200
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
201
+ self.assertEqual(result.stdout.strip(), "CFG=acct-02")
202
+
203
+ def test_autoresume_org_verdict_parks_without_transcript_and_survives_stale_auth_verdict(self):
204
+ directory = self.pool / "tmp" / "autoresume"
205
+ directory.mkdir(parents=True)
206
+ blocked = self.accounts[0]
207
+ marker = blocked / ".expired"
208
+ org_marker = ""
209
+ cases = (("genericblocked01", "blocked", "", "acct-01"),
210
+ ("orgblocked00001", "blocked", "oauth_org_not_allowed", "acct-02"),
211
+ ("staleauth000001", "auth", "", "acct-02"))
212
+ for token, verdict, rtype, expected in cases:
213
+ with self.subTest(verdict=verdict, rtype=rtype):
214
+ fields = {"v": "1", "provider": "claude", "class": verdict, "acct": "acct-01",
215
+ "rtype": rtype, "cwd": str(self.home), "reset": "", "marked": "", "sid": "",
216
+ "depth": "0", "avoid": "", "hist": "", "chain": ""}
217
+ relaunch = directory / f"r-{token}.relaunch"
218
+ argv = directory / f"r-{token}.argv"
219
+ relaunch.write_text("".join(f"{key}={value}\n" for key, value in fields.items()))
220
+ argv.write_bytes(b"-p\0hello\0")
221
+ self.assertFalse((blocked / ".sessions-index").exists())
222
+ result = self.run_cli(CLAUDE_MULTIACC_AR=token)
223
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
224
+ self.assertEqual(result.stdout.strip(), f"CFG={expected}")
225
+ self.assertFalse(relaunch.exists())
226
+ self.assertFalse(argv.exists())
227
+ if rtype:
228
+ org_marker = marker.read_text()
229
+ self.assertIn("reason=org-blocked", org_marker)
230
+ elif verdict == "auth":
231
+ self.assertEqual(marker.read_text(), org_marker)
232
+ else:
233
+ self.assertFalse(marker.exists())
234
+
235
+ def test_ambiguous_or_unowned_session_records_do_not_park_any_account(self):
236
+ blocked, other = self.accounts
237
+ cases = ("later-claim", "shared-sessions", "shared-index", "unattributed", "missing-claim", "conflict")
238
+ for case in cases:
239
+ with self.subTest(ownership=case):
240
+ self.transcript(claim=self.now if case == "later-claim" else None)
241
+ index = blocked / ".sessions-index"
242
+ if case == "shared-sessions":
243
+ shutil.rmtree(blocked / "sessions")
244
+ (blocked / "sessions").symlink_to(other / "sessions", target_is_directory=True)
245
+ elif case == "shared-index":
246
+ shared = self.home / "shared-index"
247
+ shared.write_text(index.read_text())
248
+ index.unlink()
249
+ index.symlink_to(shared)
250
+ elif case == "unattributed":
251
+ index.unlink()
252
+ elif case == "missing-claim":
253
+ index.write_text(SID + "\n")
254
+ elif case == "conflict":
255
+ (other / ".sessions-index").write_text(index.read_text())
256
+ result = self.run_cli()
257
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
258
+ self.assertEqual(result.stdout.strip(), "CFG=acct-01")
259
+ for account in self.accounts:
260
+ self.assertFalse((account / ".expired").exists())
261
+
262
+ def test_user_text_ordinary_assistant_sidechains_and_unknown_errors_are_not_org_refusals(self):
263
+ cases = ({"type": "user", "message": {"role": "user", "content": ORG_TEXT}},
264
+ {"isApiErrorMessage": False}, {"isSidechain": True}, {"error": "brand_new_error"},
265
+ {"error": "model_not_found"}, {"error": "overloaded"})
266
+ for changes in cases:
267
+ with self.subTest(record=changes):
268
+ self.transcript(changes=changes)
269
+ result = self.run_cli()
270
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
271
+ self.assertEqual(result.stdout.strip(), "CFG=acct-01")
272
+ for account in self.accounts:
273
+ self.assertFalse((account / ".expired").exists())
274
+
275
+
276
+ if __name__ == "__main__":
277
+ unittest.main()