claude-multiacc 2.0.47 → 2.0.49

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 (43) hide show
  1. package/README.md +2 -2
  2. package/bin/claude +3 -3
  3. package/bin/codex +2 -2
  4. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  5. package/docs/AUTORESUME.md +9 -6
  6. package/docs/VERIFICATION.md +1 -1
  7. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_client_error.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/autoresume.py +4 -49
  21. package/lib/autoresume_probe.py +50 -0
  22. package/lib/claude_client_error.py +18 -0
  23. package/lib/report.py +4 -0
  24. package/package.json +1 -1
  25. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  39. package/tests/run-tests.sh +9 -3
  40. package/tests/test_autoresume_fallback.py +134 -0
  41. package/tests/test_autoresume_probe.py +119 -0
  42. package/tests/test_claude_client_error.py +28 -0
  43. package/tests/test_claude_org_report.py +68 -0
package/README.md CHANGED
@@ -446,8 +446,8 @@ Every other launch, including `-p` and `codex exec`, runs exactly as before.
446
446
  (`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
447
447
  rollout).
448
448
  - **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
449
- another account has headroom. If none does, it **holds** and touches nothing, and
450
- Claude's own "continuing automatically at …" carries on.
449
+ another account has headroom, including the 90–99% fallback for quota errors. If none
450
+ does, it **holds** and Claude's own "continuing automatically at …" carries on.
451
451
  - **Relaunch.** Otherwise it stops the TUI with SIGTERM and waits for the pane's shell
452
452
  prompt. It then types a one-line relaunch of the shim into that **shell**. It never
453
453
  sends a keystroke to the TUI, whose limit menu can add funds or spend the one-shot
package/bin/claude CHANGED
@@ -447,8 +447,8 @@ ar_relaunch_apply() {
447
447
  return 0
448
448
  }
449
449
 
450
- # AVOID: accounts this chain just left, each until its own epoch. Filters `eligible` only
451
- # — never `valid` — so a pool with nowhere else to go still reaches the all-limited
450
+ # AVOID: accounts this chain just left, each until its own epoch. Filters `eligible` and
451
+ # the soft fallback, never `valid`, so a pool with nowhere else to go reaches the hard
452
452
  # fallback exactly as before. Builtins only: this runs once per candidate.
453
453
  ar_avoided() { # $1 acct dir
454
454
  local rest e id until
@@ -2038,7 +2038,7 @@ else
2038
2038
  soft=()
2039
2039
  hard=()
2040
2040
  for d in "${valid[@]}"; do
2041
- if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
2041
+ if ar_avoided "$d" || limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
2042
2042
  done
2043
2043
  if [ "${#soft[@]}" -gt 0 ]; then
2044
2044
  assess_telemetry "${soft[@]}"
package/bin/codex CHANGED
@@ -1157,7 +1157,7 @@ for d in "$ACC_ROOT"/acct-*; do
1157
1157
  fi
1158
1158
  over_threshold "$d" && continue
1159
1159
  # An auto-resume relaunch (or its probe) skips the account the session just left until
1160
- # the reset it was handed. `eligible` only: the all-limited fallback still sees it.
1160
+ # the reset it was handed. The soft fallback also skips it; the hard fallback still sees it.
1161
1161
  ar_avoided "$d" && continue
1162
1162
  eligible+=("$d")
1163
1163
  done
@@ -1318,7 +1318,7 @@ else
1318
1318
  soft=()
1319
1319
  hard=()
1320
1320
  for d in "${valid[@]}"; do
1321
- if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
1321
+ if ar_avoided "$d" || limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
1322
1322
  done
1323
1323
  # A probe only ASKS: the fallback lines below would claim a pick nobody launched.
1324
1324
  if [ "${#soft[@]}" -gt 0 ]; then
@@ -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
@@ -83,9 +83,12 @@ shell: runs the shim again → token → marker on the old account → normal
83
83
  4. **It asks before it acts.** The watcher runs the shim in *probe mode*
84
84
  (`CLAUDE_MULTIACC_AR_PROBE=1`). This is the same candidate loop and the same argv the
85
85
  relaunch will use. It prints `pick=acct-NN tier=…` and never execs.
86
- - **Limit, login and refusal errors** probe with the current account excluded. They
87
- rotate only when the probe finds an **unlimited account other than the current
88
- one**.
86
+ - **Usage-limit errors** probe with the current account excluded. They prefer an
87
+ account below the pool's exclusion threshold (90% by default), then accept a
88
+ different `soft` fallback with remaining quota. An account at 90–99% can still
89
+ serve; an exhausted account or one with an active client rejection cannot.
90
+ - **Login, refusal and model-limit errors** still require a different account below
91
+ the exclusion threshold.
89
92
  - **Otherwise the watcher holds.** Nothing is stopped, and Claude's own "continuing
90
93
  automatically" keeps running. The probe repeats every 60 s for as long as the error
91
94
  stands.
@@ -296,9 +299,9 @@ CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_AR_AVOID=acct-07:1790000000 claude
296
299
  ```
297
300
 
298
301
  - **`tier`** is one of:
299
- - `eligible`: an unlimited account;
300
- - `soft`: only the all-limited fallback;
301
- - `hard`: only exhausted accounts are left;
302
+ - `eligible`: an account below the pool's exclusion threshold;
303
+ - `soft`: only the fallback with remaining quota, excluding accounts the chain avoids;
304
+ - `hard`: only exhausted accounts or accounts the chain avoids are left;
302
305
  - `none`: nothing is usable; exit 3.
303
306
  - **`resets_at`** (codex only): a `soft` or `hard` answer may end with
304
307
  ` resets_at=<epoch>`, when that tier's reset is known. For `hard` it is the soonest
@@ -5,7 +5,7 @@
5
5
  ```bash
6
6
  tests/run-tests.sh # sandboxed compatibility tests, no quota
7
7
  python3 tests/test_selector.py # unified selector contract
8
- python3 tests/test_autoresume.py # auto-resume classifier/relaunch unit tests (also run by run-tests.sh)
8
+ python3 -m unittest discover -s tests -p 'test_autoresume*.py' # watchers and fallback probes (also in run-tests.sh)
9
9
  npm run test:commands # packed npm commands; run npm install --ignore-scripts first
10
10
  claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
11
11
  claude-accounts verify --quick # auth presence/expiry only, no inference
package/lib/autoresume.py CHANGED
@@ -55,6 +55,10 @@ import tempfile
55
55
  import time
56
56
  import traceback
57
57
 
58
+ # -I omits the script directory; load only helpers from this installation.
59
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
60
+ from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_env
61
+
58
62
  # ---- vocabulary ---------------------------------------------------------------------
59
63
 
60
64
  PROVIDERS = ("claude", "codex")
@@ -79,10 +83,6 @@ REASONS = {
79
83
  "crash": "the previous process exited unexpectedly",
80
84
  }
81
85
 
82
- # Classes that MOVE the session to a different account (the probe must offer an eligible
83
- # one that is not the current account). transient/crash may land on the same account.
84
- ROTATE = ("quota", "auth", "blocked", "model")
85
-
86
86
  # How long the chain keeps away from the account it is leaving. quota uses the server's
87
87
  # own reset instead (QUOTA_FALLBACK when it named none that is still ahead).
88
88
  AVOID_SECONDS = {"auth": 3600, "blocked": 6 * 3600, "model": 5 * 3600}
@@ -110,7 +110,6 @@ CODEX_CANCEL = ("user_message", "task_started", "turn_aborted")
110
110
  SHELLS = ("zsh", "bash", "sh", "dash", "ksh")
111
111
 
112
112
  TOKEN_ALPHABET = string.ascii_letters + string.digits
113
- ACCT_RE = re.compile(r"^acct-[A-Za-z0-9._-]{1,64}$")
114
113
  PANE_RE = re.compile(r"^%[0-9]{1,9}$")
115
114
  CHAIN_RE = re.compile(r"^[A-Za-z0-9]{1,64}$")
116
115
  TOKEN_RE = re.compile(r"^[A-Za-z0-9]{8,64}$")
@@ -1100,50 +1099,6 @@ def notice_argv(sock, pane, provider, sid, sticky=True):
1100
1099
  "claude-multiacc: auto-resume failed — run: " + resume_hint(provider, sid))
1101
1100
 
1102
1101
 
1103
- # ---- probe --------------------------------------------------------------------------
1104
-
1105
- PROBE_DROP = {
1106
- "claude": ("CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
1107
- "CLAUDE_ACCOUNT", "CLAUDE_MULTIACC_AR"),
1108
- "codex": ("CODEX_HOME", "CODEX_SHIM_ACTIVE", "CODEX_ACCOUNT", "CODEX_MULTIACC_AR"),
1109
- }
1110
-
1111
-
1112
- def probe_env(environ, provider, avoid):
1113
- """The watcher inherited the picked account's exports; the probe must select from
1114
- scratch, so they go, and probe mode + the AVOID list come in."""
1115
- env = {k: v for k, v in environ.items() if k not in PROBE_DROP[provider]}
1116
- up = provider.upper()
1117
- env[up + "_MULTIACC_AR_PROBE"] = "1"
1118
- env[up + "_MULTIACC_AR_AVOID"] = avoid or ""
1119
- return env
1120
-
1121
-
1122
- def parse_probe(out):
1123
- """The probe's ``pick=<acct> tier=<eligible|soft|hard|none>`` -> (pick, tier).
1124
-
1125
- Trailing ``key=value`` fields (the codex shim's ``resets_at=<epoch>``) are accepted
1126
- and ignored here."""
1127
- pick, tier = "", ""
1128
- for line in (out or "").split("\n"):
1129
- m = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
1130
- if m:
1131
- pick, tier = m.group(1), m.group(2)
1132
- if pick and not ACCT_RE.match(pick):
1133
- return "", ""
1134
- if tier not in ("eligible", "soft", "hard", "none"):
1135
- return "", ""
1136
- return pick, tier
1137
-
1138
-
1139
- def probe_allows(cls, pick, tier, current):
1140
- """Rotate classes need an ELIGIBLE account that is not the one being left (holding
1141
- beats landing on a soft/hard fallback); transient/crash may retry anywhere usable."""
1142
- if cls in ROTATE:
1143
- return tier == "eligible" and bool(pick) and pick != current
1144
- return tier in ("eligible", "soft") and bool(pick)
1145
-
1146
-
1147
1102
  # ---- side effects -------------------------------------------------------------------
1148
1103
 
1149
1104
  def descendants(table, pid):
@@ -0,0 +1,50 @@
1
+ """Probe environment, wire response and restart eligibility shared by both watchers."""
2
+
3
+ import re
4
+
5
+
6
+ ACCT_RE = re.compile(r"^acct-[A-Za-z0-9._-]{1,64}$")
7
+ # These errors require a different account; transient/crash may retry the same one.
8
+ ROTATE = ("quota", "auth", "blocked", "model")
9
+ PROBE_DROP = {
10
+ "claude": ("CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
11
+ "CLAUDE_ACCOUNT", "CLAUDE_MULTIACC_AR"),
12
+ "codex": ("CODEX_HOME", "CODEX_SHIM_ACTIVE", "CODEX_ACCOUNT", "CODEX_MULTIACC_AR"),
13
+ }
14
+
15
+
16
+ def probe_env(environ, provider, avoid):
17
+ """Remove the watched account's exports so the shim selects from the whole pool."""
18
+ env = {k: v for k, v in environ.items() if k not in PROBE_DROP[provider]}
19
+ up = provider.upper()
20
+ env[up + "_MULTIACC_AR_PROBE"] = "1"
21
+ env[up + "_MULTIACC_AR_AVOID"] = avoid or ""
22
+ return env
23
+
24
+
25
+ def parse_probe(out):
26
+ """Read pick/tier, accepting trailing fields such as Codex's resets_at epoch."""
27
+ pick, tier = "", ""
28
+ for line in (out or "").split("\n"):
29
+ match = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
30
+ if match:
31
+ pick, tier = match.group(1), match.group(2)
32
+ if pick and not ACCT_RE.match(pick):
33
+ return "", ""
34
+ if tier not in ("eligible", "soft", "hard", "none"):
35
+ return "", ""
36
+ return pick, tier
37
+
38
+
39
+ def probe_allows(cls, pick, tier, current):
40
+ """Quota rotations may use remaining quota, as an ordinary shim launch already does.
41
+
42
+ The shim excludes chain-avoided accounts from soft fallback, and treats exhausted
43
+ accounts and observed client rejections as hard. Other rotation classes keep their
44
+ below-threshold requirement; transient/crash may retry anywhere still usable.
45
+ """
46
+ if cls == "quota":
47
+ return tier in ("eligible", "soft") and bool(pick) and pick != current
48
+ if cls in ROTATE:
49
+ return tier == "eligible" and bool(pick) and pick != current
50
+ return tier in ("eligible", "soft") and bool(pick)
@@ -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.49",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8773,8 +8773,8 @@ done
8773
8773
  out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
8774
8774
  CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) + 3600 ))" \
8775
8775
  claude --dangerously-skip-permissions </dev/null 2>&1)"
8776
- case "$out" in "pick=acct-0"[12]" tier=soft") t_ok "probe with every account avoided answers tier=soft" ;;
8777
- *) t_fail "probe tier=soft" "$out" ;; esac
8776
+ case "$out" in "pick=acct-0"[12]" tier=hard") t_ok "probe with every account avoided answers tier=hard" ;;
8777
+ *) t_fail "probe tier=hard" "$out" ;; esac
8778
8778
  ar_logged "$ARC" 'all-limited fallback' \
8779
8779
  && t_fail "a probe logs no fallback pick" "$(grep 'all-limited' "$ARC/selection.log")" \
8780
8780
  || t_ok "a probe logs no fallback pick"
@@ -9279,7 +9279,7 @@ else
9279
9279
  fi
9280
9280
  # ...and the auto-resume watcher, unit by unit: classifiers, argv sanitizer, relaunch
9281
9281
  # files, budgets, tmux commands and a fake-runner walk through every stop/relaunch branch.
9282
- if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume.py'; then
9282
+ if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume*.py'; then
9283
9283
  t_ok "auto-resume watcher unit suite"
9284
9284
  else
9285
9285
  t_fail "auto-resume watcher unit suite" "see unittest output above"
@@ -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"
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Quota fallback regressions using disposable pools and simulated processes only."""
3
+
4
+ import signal
5
+ import unittest
6
+
7
+ import test_autoresume as fixtures
8
+
9
+
10
+ class QuotaFallbackTests(unittest.TestCase):
11
+ def scenario(self, provider):
12
+ case_type = fixtures.PoolCase if provider == "claude" else fixtures.CodexWatcherTests
13
+ case = case_type()
14
+ case.setUp()
15
+ self.addCleanup(case.tearDown)
16
+ reset = int(case.t0) + 7200
17
+ previous = "acct-03:%d" % (reset + 100)
18
+ if provider == "claude":
19
+ state = case.write_state(["--dangerously-skip-permissions"], avoid=previous)
20
+ case.registry()
21
+ path = case.transcript([fixtures.cc_quota(case.t0, resets=reset)])
22
+ else:
23
+ state = case.write_state(["--yolo", "resume", fixtures.CX_SID],
24
+ provider=provider, avoid=previous)
25
+ path = case.rollout([
26
+ fixtures.cx_meta(case.launched, cwd=str(case.work)),
27
+ fixtures.cx_tokens(case.t0, primary={"used_percent": 100,
28
+ "window_minutes": 300, "resets_at": reset}),
29
+ fixtures.cx_error(case.t0, "usage_limit_exceeded")])
30
+ watcher, runner = case.watcher(state)
31
+ runner.probe_out = "pick=acct-02 tier=soft\n"
32
+ runner.at(case.t0 + 1500, lambda: runner.exit(case.PID))
33
+ return case, watcher, runner, path
34
+
35
+ def assert_rotation(self, case, watcher, runner, provider):
36
+ self.assertEqual(runner.signals, [(case.PID, signal.SIGTERM)])
37
+ case.assert_sequence(runner, provider)
38
+ fields, argv = case.relaunch_of(runner)
39
+ reset = int(case.t0) + 7200
40
+ avoid = "acct-01:%d,acct-03:%d" % (reset, reset + 100)
41
+ sid = fixtures.SID if provider == "claude" else fixtures.CX_SID
42
+ expected = ["--dangerously-skip-permissions", "--resume", sid] if provider == "claude" \
43
+ else ["resume", "--yolo", sid]
44
+ self.assertEqual(argv, expected + [fixtures.ar.resume_prompt("quota")])
45
+ self.assertEqual((fields["provider"], fields["class"], fields["acct"], fields["sid"]),
46
+ (provider, "quota", "acct-01", sid))
47
+ self.assertEqual((fields["cwd"], fields["reset"], fields["depth"], fields["avoid"]),
48
+ (str(case.work), str(reset), "1", avoid))
49
+ self.assertEqual(fields["rtype"], "five_hour" if provider == "claude" else "")
50
+ self.assertEqual(runner.probes[-1]["env"][provider.upper() + "_MULTIACC_AR_AVOID"], avoid)
51
+ self.assertIsNotNone(watcher.tracker.pending)
52
+ self.assertIn("probe=acct-02", case.log_lines()[-1])
53
+
54
+ def test_quota_uses_other_soft_account_for_both_providers(self):
55
+ for provider in ("claude", "codex"):
56
+ with self.subTest(provider=provider):
57
+ case, watcher, runner, _ = self.scenario(provider)
58
+ self.assertEqual(watcher.run(), 0)
59
+ self.assert_rotation(case, watcher, runner, provider)
60
+ self.assertEqual(len(runner.probes), 1)
61
+ self.assertEqual(case.events(), ["watch", "detect", "switch"])
62
+
63
+ def test_hard_same_account_and_missing_candidates_hold(self):
64
+ replies = ("pick=acct-02 tier=hard", "pick=acct-01 tier=soft",
65
+ "pick=acct-01 tier=eligible", "pick= tier=none", "pick= tier=soft")
66
+ for provider in ("claude", "codex"):
67
+ for reply in replies:
68
+ with self.subTest(provider=provider, reply=reply):
69
+ case, watcher, runner, _ = self.scenario(provider)
70
+ runner.probe_out = reply + "\n"
71
+ runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
72
+ self.assertEqual(watcher.run(), 0)
73
+ self.assertEqual(len(runner.probes), 1)
74
+ self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
75
+ self.assertEqual(case.events(), ["watch", "detect", "hold"])
76
+
77
+ def test_other_rotation_classes_keep_the_eligible_requirement(self):
78
+ for cls in ("auth", "blocked", "model"):
79
+ with self.subTest(error_class=cls):
80
+ self.assertFalse(fixtures.ar.probe_allows(cls, "acct-02", "soft", "acct-01"))
81
+ self.assertFalse(fixtures.ar.probe_allows(cls, "acct-02", "hard", "acct-01"))
82
+ self.assertTrue(fixtures.ar.probe_allows(cls, "acct-02", "eligible", "acct-01"))
83
+
84
+ def test_user_input_during_soft_probe_cancels_rotation(self):
85
+ for provider in ("claude", "codex"):
86
+ with self.subTest(provider=provider):
87
+ case, watcher, runner, path = self.scenario(provider)
88
+ record = fixtures.cc_user(case.t0 + 5, "stop, I will continue manually") \
89
+ if provider == "claude" else fixtures.cx_event(case.t0 + 5,
90
+ {"type": "user_message", "message": "stop, I will continue manually"})
91
+ original_run = runner.run
92
+
93
+ def run(argv, timeout, env=None, cwd=None):
94
+ if argv[0] == case.self_path and not runner.probes:
95
+ fixtures.append(path, [record])
96
+ return original_run(argv, timeout, env=env, cwd=cwd)
97
+
98
+ runner.run = run
99
+ registry = case.root / "acct-01" / "sessions" / ("%d.json" % case.PID)
100
+ if provider == "claude":
101
+ runner.at(case.t0 + 15, registry.unlink)
102
+ runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
103
+ self.assertEqual(watcher.run(), 0)
104
+ self.assertEqual(len(runner.probes), 1)
105
+ self.assertIsNone(watcher.tracker.pending)
106
+ self.assertEqual((runner.signals, runner.sends, case.relaunch_files()), ([], [], []))
107
+
108
+ def test_prolonged_hold_reprobes_and_recovers_when_soft_capacity_returns(self):
109
+ for provider in ("claude", "codex"):
110
+ with self.subTest(provider=provider):
111
+ case, watcher, runner, _ = self.scenario(provider)
112
+ runner.probe_out = "pick=acct-02 tier=hard\n"
113
+ runner.at(case.t0 + 600,
114
+ lambda: setattr(runner, "probe_out", "pick=acct-02 tier=soft\n"))
115
+ self.assertEqual(watcher.run(), 0)
116
+ self.assert_rotation(case, watcher, runner, provider)
117
+ self.assertEqual([round(probe["t"] - case.t0) for probe in runner.probes],
118
+ list(range(5, 606, 60)))
119
+ self.assertEqual(case.events(), ["watch", "detect", "hold", "switch"])
120
+
121
+ def test_soft_capacity_does_not_bypass_rotation_budget(self):
122
+ for provider in ("claude", "codex"):
123
+ with self.subTest(provider=provider):
124
+ case, watcher, runner, _ = self.scenario(provider)
125
+ watcher.st["hist"] = ",".join("quota:%d" % (case.t0 - offset) for offset in range(8))
126
+ runner.at(case.t0 + 15, lambda: runner.exit(case.PID))
127
+ self.assertEqual(watcher.run(), 0)
128
+ self.assertEqual((runner.probes, runner.signals, runner.sends), ([], [], []))
129
+ self.assertEqual(case.relaunch_files(), [])
130
+ self.assertIn("reason=rotate-budget", case.log_lines()[-1])
131
+
132
+
133
+ if __name__ == "__main__":
134
+ unittest.main()
@@ -0,0 +1,119 @@
1
+ """Real shim probes distinguish remaining quota from accounts this chain rejected.
2
+
3
+ Every account, credential and provider executable is synthetic and local to a
4
+ temporary HOME. Probe mode must never launch the provider or poll its network.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ import unittest
14
+
15
+
16
+ REPO = Path(__file__).resolve().parents[1]
17
+
18
+
19
+ class AutoresumeProbeTests(unittest.TestCase):
20
+ def setUp(self):
21
+ work = tempfile.TemporaryDirectory(prefix="multiacc-ar-probe-")
22
+ self.addCleanup(work.cleanup)
23
+ self.home = Path(work.name)
24
+ self.now = int(time.time())
25
+ self.fakebin = self.home / "bin"
26
+ self.fakebin.mkdir()
27
+ self.env = {k: v for k, v in os.environ.items()
28
+ if not k.startswith(("CLAUDE_", "CODEX_", "ANTHROPIC_", "OPENAI_"))
29
+ and k not in ("TMUX", "TMUX_PANE")}
30
+ self.env.update(HOME=str(self.home), PATH=f"{self.fakebin}:{os.environ['PATH']}",
31
+ PYTHONDONTWRITEBYTECODE="1")
32
+ for name in ("claude", "codex", "security", "curl"):
33
+ script = self.fakebin / name
34
+ script.write_text('#!/bin/sh\nprintf "%s\\n" "$0" >> "$HOME/unexpected-calls"\nexit 79\n')
35
+ script.chmod(0o755)
36
+ for provider in ("claude", "codex"):
37
+ root = self.home / provider
38
+ root.mkdir()
39
+ up = provider.upper()
40
+ self.env[up + "_ACCOUNTS_ROOT"] = str(root)
41
+ for key in ("KEYCHAIN", "MCP", "AUTORESUME", "AUTO_RESET", "CLIENT_LIMITS", "PATH_PROBE"):
42
+ self.env[up + "_MULTIACC_" + key] = "0"
43
+ self.env[up + "_MULTIACC_NO_SYNC"] = "1"
44
+ self.env[up + "_MULTIACC_NO_DISTRIBUTE"] = "1"
45
+ self.env[up + "_MULTIACC_HEADROOM_BAND"] = "0"
46
+ accounts = [{"id": f"acct-{i:02}", "email": f"fixture-{i}@example.invalid", "home": "mac"}
47
+ for i in range(1, 4)]
48
+ (root / "accounts.json").write_text(json.dumps({
49
+ "version": 1, "server": "none", "threshold": 90, "accounts": accounts}))
50
+ for account, used in zip(accounts, (90, 96, 99)):
51
+ self.seed_account(provider, account["id"], used)
52
+
53
+ def seed_account(self, provider, account, used, reason="limits"):
54
+ path = self.home / provider / account
55
+ path.mkdir(exist_ok=True)
56
+ if provider == "claude":
57
+ credential = {"claudeAiOauth": {"accessToken": "synthetic", "refreshToken": "synthetic",
58
+ "expiresAt": (self.now + 86400) * 1000}}
59
+ filename = ".credentials.json"
60
+ else:
61
+ credential = {"tokens": {"access_token": "synthetic", "refresh_token": "synthetic"}}
62
+ filename = "auth.json"
63
+ (path / filename).write_text(json.dumps(credential))
64
+ (path / "limits.json").write_text(json.dumps({
65
+ "fetched_at": self.now, "weekly_percent": used, "session_percent": 0,
66
+ "max_percent": used, "weekly_resets_epoch": self.now + 86400, "buckets": []}))
67
+ marked = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.now))
68
+ (path / ".limited").write_text(f"{self.now + 86400}\n"
69
+ f"bucket=weekly_all percent={used} marked_at={marked} reason={reason}\n")
70
+
71
+ def probe(self, provider, avoid=""):
72
+ up = provider.upper()
73
+ env = {**self.env, up + "_MULTIACC_AR_PROBE": "1", up + "_MULTIACC_AR_AVOID": avoid}
74
+ result = subprocess.run([str(REPO / "bin" / provider)], env=env, cwd=self.home,
75
+ capture_output=True, text=True, input="", timeout=30)
76
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
77
+ self.assertFalse((self.home / "unexpected-calls").exists(), result.stderr)
78
+ fields = dict(field.split("=", 1) for field in result.stdout.split())
79
+ return fields["pick"], fields["tier"]
80
+
81
+ def test_remaining_quota_is_soft_and_strictly_ranked(self):
82
+ for provider in ("claude", "codex"):
83
+ with self.subTest(provider=provider):
84
+ self.assertEqual(self.probe(provider), ("acct-01", "soft"))
85
+
86
+ def test_current_and_previous_chain_accounts_cannot_win_soft_fallback(self):
87
+ for provider in ("claude", "codex"):
88
+ with self.subTest(provider=provider):
89
+ avoid = f"acct-01:{self.now + 3600}"
90
+ self.assertEqual(self.probe(provider, avoid), ("acct-02", "soft"))
91
+ avoid += f",acct-02:{self.now + 3600}"
92
+ self.assertEqual(self.probe(provider, avoid), ("acct-03", "soft"))
93
+
94
+ def test_all_avoided_accounts_cannot_be_reported_as_usable(self):
95
+ avoid = ",".join(f"acct-{i:02}:{self.now + 3600}" for i in range(1, 4))
96
+ for provider in ("claude", "codex"):
97
+ with self.subTest(provider=provider):
98
+ self.assertEqual(self.probe(provider, avoid)[1], "hard")
99
+
100
+ def test_expired_avoidance_restores_remaining_quota(self):
101
+ for provider in ("claude", "codex"):
102
+ with self.subTest(provider=provider):
103
+ self.assertEqual(self.probe(provider, f"acct-01:{self.now - 1}"), ("acct-01", "soft"))
104
+
105
+ def test_rejected_and_exhausted_accounts_remain_hard(self):
106
+ for provider in ("claude", "codex"):
107
+ for reason, used in (("client-rate-limit", 90), ("error-cooldown", 90), ("limits", 100)):
108
+ with self.subTest(provider=provider, reason=reason):
109
+ self.seed_account(provider, "acct-01", used, reason)
110
+ self.assertEqual(self.probe(provider), ("acct-02", "soft"))
111
+ for account in ("acct-02", "acct-03"):
112
+ self.seed_account(provider, account, used, reason)
113
+ self.assertEqual(self.probe(provider)[1], "hard")
114
+ self.seed_account(provider, "acct-02", 96)
115
+ self.seed_account(provider, "acct-03", 99)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -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()