claude-multiacc 2.0.47 → 2.0.48

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 (31) hide show
  1. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  2. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  3. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  4. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  14. package/lib/claude_client_error.py +18 -0
  15. package/lib/report.py +4 -0
  16. package/package.json +1 -1
  17. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  18. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  19. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  20. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  21. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  22. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  23. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  29. package/tests/run-tests.sh +6 -0
  30. package/tests/test_claude_client_error.py +28 -0
  31. package/tests/test_claude_org_report.py +68 -0
@@ -279,7 +279,7 @@ providers (`lib/report.py`), so a consumer writes one parser:
279
279
  "portable": true,
280
280
  "credentials": {"oauth": true, "token": true, "oauth_expires_at": "…Z",
281
281
  "oauth_refresh_expires_at": "…Z", "token_minted_at": "…Z",
282
- "token_age_days": 39},
282
+ "token_age_days": 39, "org_access_verified_at": 1790765000.125},
283
283
  "limited": false, "limit_reset_at": null, "limit_detail": null,
284
284
  "usage": {"fetched_at": "…Z", "age_seconds": 41, "source": "oauth",
285
285
  // a null percent = that signal had no informative bucket; all three
@@ -306,6 +306,12 @@ A consumer that only distinguishes *usable / parked / needs-a-human* can read
306
306
  without the limit overlay. `limited` follows the shim's marker rule exactly — a marker
307
307
  whose reset time has passed does not count, an unreadable one does.
308
308
 
309
+ Claude's optional `credentials.org_access_verified_at` is a Unix timestamp from the
310
+ last real verification that passed the organisation access gate (a successful inference
311
+ or a genuine usage-limit refusal). Missing or invalid proof is omitted. Consumers can
312
+ use newer proof to lift an older organisation hold; a later refusal still keeps the
313
+ account blocked. Usage telemetry, login and token verification alone are not recovery proof.
314
+
309
315
  A percent field is **null** when that signal had no informative bucket to aggregate. The
310
316
  writers keep the signals separate: `weekly_percent` comes from informative weekly buckets,
311
317
  `session_percent` from informative session ones, and each is omitted on its own when its
@@ -11,11 +11,29 @@ import argparse
11
11
  from datetime import datetime
12
12
  import json
13
13
  import math
14
+ from pathlib import Path
14
15
  import sys
16
+ import time
15
17
 
16
18
  ERRORS = {"authentication_failed": "auth", "oauth_org_not_allowed": "org-blocked"}
17
19
 
18
20
 
21
+ def org_access_verified_at(account_dir, now=None):
22
+ """Read a bounded proof that this account's organization gate allowed inference."""
23
+ try:
24
+ with (Path(account_dir) / ".client-org-cleared").open("rb") as handle:
25
+ raw = handle.read(65)
26
+ if len(raw) > 64:
27
+ return None
28
+ stamp = float(raw.strip())
29
+ reference = time.time() if now is None else float(now)
30
+ except (OSError, ValueError, TypeError, OverflowError):
31
+ return None
32
+ if not math.isfinite(stamp) or not math.isfinite(reference) or not 0 < stamp <= reference + 300:
33
+ return None
34
+ return stamp
35
+
36
+
19
37
  def iso_epoch(value):
20
38
  """Return an aware ISO timestamp's epoch, or None when it cannot prove timing."""
21
39
  if not isinstance(value, str) or "T" not in value or any(c.isspace() for c in value):
package/lib/report.py CHANGED
@@ -47,6 +47,7 @@ import time
47
47
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
48
  import keychain # noqa: E402
49
49
  from audit import creds_doc_state, token_verified # noqa: E402
50
+ from claude_client_error import org_access_verified_at # noqa: E402
50
51
  from claude_reset import marker_superseded, record_from # noqa: E402
51
52
 
52
53
  SCHEMA = 'claude-multiacc/pool.v1'
@@ -178,6 +179,9 @@ def _claude_credentials(d):
178
179
  # True once THIS exact token passed a real inference on this machine
179
180
  # (.server-token-verified); False = present but never proven here.
180
181
  'token_verified': token_verified(d, tpath) if has_token else None}
182
+ proof = org_access_verified_at(d)
183
+ if proof is not None:
184
+ detail['org_access_verified_at'] = proof
181
185
  o = None
182
186
  if has_oauth:
183
187
  detail['oauth_store'] = 'file'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.47",
3
+ "version": "2.0.48",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9303,6 +9303,12 @@ else
9303
9303
  t_fail "claude persistent organization-block suite" "see unittest output above"
9304
9304
  fi
9305
9305
 
9306
+ if python3 "$REPO_DIR/tests/test_claude_org_report.py"; then
9307
+ t_ok "claude: organization recovery report contract suite"
9308
+ else
9309
+ t_fail "claude organization recovery report suite" "see unittest output above"
9310
+ fi
9311
+
9306
9312
  # ---- summary ---------------------------------------------------------------------
9307
9313
  echo
9308
9314
  echo "passed: $PASS failed: $FAIL"
@@ -7,6 +7,7 @@ import json
7
7
  from pathlib import Path
8
8
  import subprocess
9
9
  import sys
10
+ import tempfile
10
11
  import unittest
11
12
 
12
13
  REPO = Path(__file__).resolve().parents[1]
@@ -114,6 +115,33 @@ class ClaudeClientErrorTests(unittest.TestCase):
114
115
  result = subprocess.run(command + args, input=json.dumps(record), text=True, capture_output=True)
115
116
  self.assertEqual((result.returncode, result.stdout, result.stderr), (1, "", ""))
116
117
 
118
+ def test_org_access_proof_returns_exact_stamp_with_clock_slack(self):
119
+ now = 1800000000.25
120
+ with tempfile.TemporaryDirectory(prefix="multiacc-client-org-proof-") as directory:
121
+ marker = Path(directory) / ".client-org-cleared"
122
+ for stamp in (now - 123.125, now, now + 0.125, now + 300):
123
+ with self.subTest(stamp=stamp):
124
+ marker.write_text(f" {stamp}\n")
125
+ self.assertEqual(errors.org_access_verified_at(directory, now), stamp)
126
+ marker.write_text(str(now).ljust(64))
127
+ self.assertEqual(errors.org_access_verified_at(directory, now), now)
128
+ stamp = errors.time.time() - 10
129
+ marker.write_text(repr(stamp))
130
+ self.assertEqual(errors.org_access_verified_at(directory), stamp)
131
+
132
+ def test_org_access_proof_ignores_missing_invalid_or_oversized_values(self):
133
+ now = 1800000000.25
134
+ with tempfile.TemporaryDirectory(prefix="multiacc-client-org-proof-") as directory:
135
+ marker = Path(directory) / ".client-org-cleared"
136
+ self.assertIsNone(errors.org_access_verified_at(directory, now))
137
+ invalid = [b"", b" ", b"NaN", b"Infinity", b"-Infinity", b"-10", b"0", b"true", b"false",
138
+ b"True", b"null", b"{}", b"[]", b'"1800000000"', b"\xff", b"1" + b" " * 64,
139
+ str(now + 300.001).encode()]
140
+ for raw in invalid:
141
+ with self.subTest(raw=raw):
142
+ marker.write_bytes(raw)
143
+ self.assertIsNone(errors.org_access_verified_at(directory, now))
144
+
117
145
 
118
146
  if __name__ == "__main__":
119
147
  unittest.main()
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ """The report publishes recovery proof without overriding a newer refusal."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ import unittest
12
+
13
+ REPO = Path(__file__).resolve().parents[1]
14
+
15
+
16
+ class OrgReportTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.work = tempfile.TemporaryDirectory(prefix="multiacc-org-report-")
19
+ self.addCleanup(self.work.cleanup)
20
+ self.root = Path(self.work.name)
21
+ self.account = self.root / "acct-01"
22
+ self.account.mkdir()
23
+ self.now = time.time()
24
+ (self.root / "accounts.json").write_text(json.dumps({"version": 1, "server": "none",
25
+ "accounts": [{"id": "acct-01", "email": "fixture@example.invalid", "home": "mac"}]}))
26
+ (self.account / ".credentials.json").write_text(json.dumps({"claudeAiOauth": {
27
+ "accessToken": "synthetic", "refreshToken": "synthetic",
28
+ "expiresAt": (self.now + 86400) * 1000,
29
+ "refreshTokenExpiresAt": (self.now + 864000) * 1000}}))
30
+
31
+ def row(self, kind, provider="claude"):
32
+ result = subprocess.run([sys.executable, str(REPO / "lib/report.py"),
33
+ str(self.root), provider, "mac", kind], capture_output=True, text=True,
34
+ env={**os.environ, "CLAUDE_MULTIACC_KEYCHAIN": "0", "PYTHONDONTWRITEBYTECODE": "1"},
35
+ timeout=10, check=True)
36
+ self.assertEqual(result.stderr, "")
37
+ document = json.loads(result.stdout)
38
+ self.assertEqual(document["schema"], "claude-multiacc/pool.v1")
39
+ return document["accounts"][0]
40
+
41
+ def test_all_report_verbs_publish_proof_and_preserve_later_org_block(self):
42
+ stamp = self.now - 5.125
43
+ (self.account / ".client-org-cleared").write_text(repr(stamp))
44
+ for kind in ("list", "status", "limits"):
45
+ with self.subTest(kind=kind):
46
+ row = self.row(kind)
47
+ self.assertEqual(row["credentials"]["org_access_verified_at"], stamp)
48
+ self.assertEqual(row["status"], "active")
49
+ (self.account / ".expired").write_text(f"{int(self.now)}\nreason=org-blocked\n")
50
+ row = self.row(kind)
51
+ self.assertEqual(row["credentials"]["org_access_verified_at"], stamp)
52
+ self.assertEqual(row["status"], "blocked")
53
+ self.assertFalse(row["selectable"])
54
+ (self.account / ".expired").unlink()
55
+
56
+ def test_invalid_missing_and_other_provider_proofs_are_omitted(self):
57
+ marker = self.account / ".client-org-cleared"
58
+ self.assertNotIn("org_access_verified_at", self.row("list")["credentials"])
59
+ for text in ("NaN", "-1", str(self.now + 3600), "1" * 65):
60
+ with self.subTest(text=text):
61
+ marker.write_text(text)
62
+ self.assertNotIn("org_access_verified_at", self.row("status")["credentials"])
63
+ marker.write_text(repr(self.now))
64
+ self.assertNotIn("org_access_verified_at", self.row("list", "codex")["credentials"])
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()