claude-multiacc 2.0.45 → 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 (40) hide show
  1. package/README.md +29 -23
  2. package/bin/claude +48 -31
  3. package/bin/claude-accounts +30 -16
  4. package/bin/codex +14 -1
  5. package/docs/ACCOUNT_OPERATIONS.md +13 -9
  6. package/docs/AUTORESUME.md +14 -1
  7. package/docs/VERIFICATION.md +5 -2
  8. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume.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/audit.py +8 -26
  21. package/lib/autoresume.py +12 -7
  22. package/lib/claude_client_error.py +81 -0
  23. package/lib/common.sh +11 -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_claude_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  37. package/tests/run-tests.sh +69 -38
  38. package/tests/test_autoresume.py +22 -3
  39. package/tests/test_claude_client_error.py +119 -0
  40. package/tests/test_claude_org_block.py +277 -0
package/lib/autoresume.py CHANGED
@@ -440,7 +440,8 @@ def classify_claude(rec, prefixes=(PROMPT_PREFIX,)):
440
440
  if code == "authentication_failed":
441
441
  return ("error", _verdict("auth", code, at))
442
442
  if code in CLAUDE_BLOCKED:
443
- return ("error", _verdict("blocked", code, at))
443
+ rtype = code if code == "oauth_org_not_allowed" else ""
444
+ return ("error", _verdict("blocked", code, at, rtype=rtype))
444
445
  # invalid_request, max_output_tokens, model_not_found, ...: another account would
445
446
  # fail the same way, so it is logged once and left alone.
446
447
  return ("never", code or "?")
@@ -873,15 +874,16 @@ def remove_quietly(*paths):
873
874
 
874
875
 
875
876
  def relaunch_fields(provider, verdict, acct, sid, cwd, depth, avoid, hist, chain):
876
- """The relaunch file's fields for one verdict. reset/rtype/marked only mean something
877
- to the shim for a quota verdict (claude marks ``client:<rtype>`` until ``reset``;
878
- codex writes its fixed cooldown)."""
877
+ """The relaunch fields for a verdict. Claude quota carries its bucket and reset;
878
+ its organization refusal carries only the known error code in rtype. Codex quota
879
+ uses its fixed cooldown."""
879
880
  cls = verdict["class"]
880
881
  quota = cls == "quota"
882
+ org_block = cls == "blocked" and verdict.get("rtype") == "oauth_org_not_allowed"
881
883
  return {
882
884
  "v": "1", "provider": provider, "class": cls, "acct": acct,
883
885
  "reset": str(int(verdict["reset"])) if quota and verdict.get("reset") else "",
884
- "rtype": verdict.get("rtype", "") if quota and provider == "claude" else "",
886
+ "rtype": verdict.get("rtype", "") if (quota or org_block) and provider == "claude" else "",
885
887
  "marked": verdict.get("marked") or "",
886
888
  "sid": sid, "cwd": cwd, "depth": str(int(depth) + 1), "avoid": avoid,
887
889
  "hist": hist, "chain": chain,
@@ -1118,10 +1120,13 @@ def probe_env(environ, provider, avoid):
1118
1120
 
1119
1121
 
1120
1122
  def parse_probe(out):
1121
- """The probe's ``pick=<acct> tier=<eligible|soft|hard|none>`` -> (pick, tier)."""
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."""
1122
1127
  pick, tier = "", ""
1123
1128
  for line in (out or "").split("\n"):
1124
- m = re.match(r"^pick=(\S*) tier=(\S+)\s*$", line.strip())
1129
+ m = re.match(r"^pick=(\S*) tier=(\S+)(?:\s+\w+=\S*)*\s*$", line.strip())
1125
1130
  if m:
1126
1131
  pick, tier = m.group(1), m.group(2)
1127
1132
  if pick and not ACCT_RE.match(pick):
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """Read attributable Claude client authentication and organization errors from JSONL.
3
+
4
+ The caller supplies a bounded transcript tail and the session's account claim time.
5
+ Only structured main-thread API error records count; message wording never does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from datetime import datetime
12
+ import json
13
+ import math
14
+ import sys
15
+
16
+ ERRORS = {"authentication_failed": "auth", "oauth_org_not_allowed": "org-blocked"}
17
+
18
+
19
+ def iso_epoch(value):
20
+ """Return an aware ISO timestamp's epoch, or None when it cannot prove timing."""
21
+ if not isinstance(value, str) or "T" not in value or any(c.isspace() for c in value):
22
+ return None
23
+ try:
24
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
25
+ if parsed.tzinfo is None:
26
+ return None
27
+ return parsed.timestamp()
28
+ except (ValueError, OverflowError, OSError):
29
+ return None
30
+
31
+
32
+ def newest_error(lines, claim, org_cleared=0):
33
+ """Return the newest org refusal, otherwise auth error, as (slug, ISO timestamp)."""
34
+ claimed_at = iso_epoch(claim)
35
+ if claimed_at is None:
36
+ return None
37
+ try:
38
+ cleared_at = float(org_cleared)
39
+ except (TypeError, ValueError, OverflowError):
40
+ cleared_at = 0
41
+ if not math.isfinite(cleared_at):
42
+ cleared_at = 0
43
+ latest = {}
44
+ for line in lines:
45
+ try:
46
+ record = json.loads(line)
47
+ except (ValueError, TypeError, RecursionError):
48
+ continue
49
+ if not isinstance(record, dict) or record.get("isSidechain") is True:
50
+ continue
51
+ if record.get("type") != "assistant" or record.get("isApiErrorMessage") is not True:
52
+ continue
53
+ code = record.get("error")
54
+ slug = ERRORS.get(code) if isinstance(code, str) else None
55
+ at = iso_epoch(record.get("timestamp"))
56
+ if not slug or at is None or at < claimed_at:
57
+ continue
58
+ if slug == "org-blocked" and at <= cleared_at:
59
+ continue
60
+ if at >= latest.get(slug, (float("-inf"), ""))[0]:
61
+ latest[slug] = (at, record["timestamp"])
62
+ for slug in ("org-blocked", "auth"):
63
+ if slug in latest:
64
+ return slug, latest[slug][1]
65
+ return None
66
+
67
+
68
+ def main(argv=None):
69
+ parser = argparse.ArgumentParser(description=__doc__)
70
+ parser.add_argument("--claim", required=True)
71
+ parser.add_argument("--org-cleared", type=float, default=0)
72
+ args = parser.parse_args(argv)
73
+ result = newest_error(sys.stdin, args.claim, args.org_cleared)
74
+ if result is None:
75
+ return 1
76
+ print(*result)
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())
package/lib/common.sh CHANGED
@@ -507,6 +507,10 @@ keychain_prune() { # $1 = acct dir: drop the non-canonical items once the canoni
507
507
  mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
508
508
  local d="$1" slug="$2" detail="${3:-}"
509
509
  [ -d "$d" ] || return 0
510
+ if [ "$MULTIACC_PROVIDER" = "claude" ] && [ "$slug" != "org-blocked" ] \
511
+ && grep -q 'reason=org-blocked' "$d/.expired" 2>/dev/null; then
512
+ return 0
513
+ fi
510
514
  {
511
515
  epoch_now
512
516
  printf 'reason=%s marked_at=%s detail=%s\n' "$slug" "$(ts_utc)" "$detail"
@@ -519,6 +523,13 @@ mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
519
523
  # account is provably alive again, so both the dead-auth marker and any refresh
520
524
  # backoff must go, or it would stay parked until the next re-login.
521
525
  clear_auth_markers() { # $1 = acct dir, $2 = "keep-token-park" after a LOGIN-only ceremony
526
+ # A new credential says nothing about organization policy. Only inference verification
527
+ # can restore a Claude account whose organization disabled Claude Code access.
528
+ if [ "$MULTIACC_PROVIDER" = "claude" ] \
529
+ && grep -q 'reason=org-blocked' "$1/.expired" 2>/dev/null; then
530
+ rm -f "$1/.oauth-refresh.json" 2>/dev/null || true
531
+ return 0
532
+ fi
522
533
  # A fresh login proves the login; it says nothing about a portable token a real
523
534
  # call rejected, so that park outlives it — only a new token (mint / login --token)
524
535
  # or a real call through the token lifts it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.45",
3
+ "version": "2.0.47",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1032,7 +1032,8 @@ printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
1032
1032
  auth_sid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
1033
1033
  mkdir -p "$ACC/acct-01/projects/auth-regression"
1034
1034
  printf '%s %s\n' "$auth_sid" '2020-01-01T00:00:00Z' > "$ACC/acct-01/.sessions-index"
1035
- auth_line='{"timestamp":"2026-08-22T20:27:29.985Z","error":"authentication_failed",'
1035
+ auth_line='{"type":"assistant","isApiErrorMessage":true,"timestamp":"2026-08-22T20:27:29.985Z",'
1036
+ auth_line="$auth_line\"error\":\"authentication_failed\","
1036
1037
  auth_line="$auth_line\"session_id\":\"$auth_sid\"}"
1037
1038
  printf '%s\n' "$auth_line" \
1038
1039
  > "$ACC/acct-01/projects/auth-regression/$auth_sid.jsonl"
@@ -1124,6 +1125,11 @@ rc=$?
1124
1125
  grep -q "reason=org-blocked" "$ACC/acct-01/.expired" 2>/dev/null \
1125
1126
  && t_ok "org-disabled account is parked as org-blocked" \
1126
1127
  || t_fail "org-block marker" "no reason=org-blocked marker"
1128
+ if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
1129
+ t_fail "org-block park is persistent" "marker has a soft expiry"
1130
+ else
1131
+ t_ok "org-block park is persistent until access is verified"
1132
+ fi
1127
1133
  rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json
1128
1134
  # it stays out of the pool...
1129
1135
  all2=1
@@ -1155,18 +1161,19 @@ claude >/dev/null 2>&1
1155
1161
  [ ! -f "$ACC/acct-01/.expired" ] && t_ok "a credential park still clears on a newer credential" \
1156
1162
  || t_fail "credential park" "marker survived a fresh credential"
1157
1163
  printf '%s\nreason=org-blocked detail=test\n' "$((now-100))" > "$ACC/acct-01/.expired"
1158
- # `expired` explains it and points at the same fix as any other dead login
1164
+ # `expired` explains the organization policy and the inference check that recovers it.
1159
1165
  out="$(claude-accounts expired 2>&1)"
1160
1166
  check "expired labels an org-blocked account" "BLOCKED" "$out"
1161
1167
  check "expired explains the org block" "organization has disabled" "$out"
1162
- check "expired points org blocks at relogin" "relogin acct-01" "$out"
1168
+ check "expired points org blocks at an admin" "admin" "$out"
1169
+ check "expired points org blocks at verify after access is enabled" "claude-accounts verify" "$out"
1163
1170
  out="$(claude-accounts list 2>&1)"
1164
1171
  check "list flags org-blocked accounts" "ORG-BLOCKED" "$out"
1165
- # relogin targets them like any other dead login
1172
+ # Re-login may refresh its credential, but cannot prove the organization enabled access.
1166
1173
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts relogin --yes 2>&1)"
1167
1174
  check "relogin targets org-blocked accounts" "acct-01 login saved" "$out"
1168
- [ ! -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login clears an org block" \
1169
- || t_fail "org-block relogin" "marker survived the re-login"
1175
+ [ -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login preserves an org block" \
1176
+ || t_fail "org-block relogin" "credential-only recovery removed the org policy marker"
1170
1177
  rm -f "$ACC/acct-01/.expired"
1171
1178
  # The client's 404 model_not_found ends "…or ask your admin to enable this model". The org
1172
1179
  # vocabulary's bare "ask your admin to enable" matched it, parking a healthy account as
@@ -3237,7 +3244,7 @@ rm -f "$ACC/acct-05/limits.json"
3237
3244
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
3238
3245
  [ -f "$ACC/acct-05/.expired" ] && t_ok "a usage fetch does not unpark an org-blocked account" \
3239
3246
  || t_fail "org-block unpark" "telemetry cleared an org block it cannot observe"
3240
- # ...nor one verify wrote with its soft_until (every org park carries one now)
3247
+ # ...nor a legacy org marker carrying the old soft_until field.
3241
3248
  printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' "$now" "$(( $(date +%s) + 21600 ))" \
3242
3249
  > "$ACC/acct-05/.expired"
3243
3250
  rm -f "$ACC/acct-05/limits.json"
@@ -3803,9 +3810,8 @@ RACER
3803
3810
  && t_ok "a dead refresh grant never overwrites an org-blocked marker" \
3804
3811
  || t_fail "org block downgraded" "marker is now: $(cat "$SD/acct-01/.expired" 2>/dev/null | tail -1)"
3805
3812
  # The same without the portable token beside it — park_dead_grant never parks an
3806
- # account its token still serves, so the check above cannot see the guard. A live org
3807
- # block (no soft_until, or one still ahead) is never overwritten; one whose soft_until
3808
- # has run out binds nothing and outranks nothing, so the dead grant gets its own park.
3813
+ # account its token still serves, so the check above cannot see the guard. Organization
3814
+ # blocks persist, including old markers whose six-hour soft_until has already passed.
3809
3815
  mv "$SD/acct-01/server.token" "$SD/acct-01/server.token.aside"
3810
3816
  for soft in none "$(( $(date +%s) + 21600 ))" "$(( $(date +%s) - 10 ))"; do
3811
3817
  if [ "$soft" = none ]; then
@@ -3824,12 +3830,11 @@ RACER
3824
3830
  && t_ok "a dead refresh grant (no token beside it) never overwrites an org block" \
3825
3831
  || t_fail "org block downgraded" "$(sed -n 1p "$WORK/lapsed-org.txt")"
3826
3832
  [ "$(sed -n 2p "$WORK/lapsed-org.txt")" = kept ] \
3827
- && t_ok "a dead refresh grant never overwrites an org block inside its soft window" \
3833
+ && t_ok "a dead refresh grant never overwrites an org block with an old soft window" \
3828
3834
  || t_fail "soft org block downgraded" "$(sed -n 2p "$WORK/lapsed-org.txt")"
3829
- case "$(sed -n 3p "$WORK/lapsed-org.txt")" in
3830
- reason=refresh-token-expired*) t_ok "a lapsed org block does not hide a dead refresh grant from its park" ;;
3831
- *) t_fail "lapsed org block shields a dead grant" "$(sed -n 3p "$WORK/lapsed-org.txt")" ;;
3832
- esac
3835
+ [ "$(sed -n 3p "$WORK/lapsed-org.txt")" = kept ] \
3836
+ && t_ok "a dead refresh grant never overwrites an org block after its old soft expiry" \
3837
+ || t_fail "old org block downgraded" "$(sed -n 3p "$WORK/lapsed-org.txt")"
3833
3838
  rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json"
3834
3839
 
3835
3840
  # ---- the >=90% cutoff keeps the TIGHT window --------------------------------
@@ -4451,41 +4456,41 @@ for said in "API Error: Server is temporarily limiting requests (not your usage
4451
4456
  done
4452
4457
  rm -f "$VP"/acct-*/.expired
4453
4458
 
4454
- # The ORG refusal: verify's park carries the shim's 6 h soft_until, and seeing the refusal
4455
- # again refreshes it (a stale stamp would release a still-blocked account early).
4459
+ # The ORG refusal persists until a real inference proves access. Seeing it again
4460
+ # refreshes the observation and replaces any legacy soft expiry with a persistent park.
4456
4461
  ORG_TEXT="Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
4457
4462
  t0="$(date +%s)"
4458
4463
  out="$(vp_verify "$ORG_TEXT")"
4459
4464
  t1="$(date +%s)"
4460
4465
  check "verify calls out the org block" "ORG BLOCKED" "$(vp_line acct-01)"
4461
- soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
4462
- { [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$soft" -le $((t1 + 21600)) ]; } \
4463
- && t_ok "verify's org park carries soft_until = now + 6 h" \
4464
- || t_fail "verify org soft_until" "$(tail -1 "$VP/acct-01/.expired" 2>/dev/null)"
4465
- grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$VP/acct-01/.expired" \
4466
+ epoch="$(head -1 "$VP/acct-01/.expired" 2>/dev/null)"
4467
+ { [ "$epoch" -ge "$t0" ] && [ "$epoch" -le "$t1" ] \
4468
+ && ! grep -q 'soft_until=' "$VP/acct-01/.expired"; } \
4469
+ && t_ok "verify's org park has no automatic expiry" \
4470
+ || t_fail "verify org persistence" "$(tail -1 "$VP/acct-01/.expired" 2>/dev/null)"
4471
+ grep -q '^reason=org-blocked marked_at=' "$VP/acct-01/.expired" \
4466
4472
  && t_ok "verify writes the shim's field order" || t_fail "org park shape" "$(tail -1 "$VP/acct-01/.expired")"
4467
4473
  printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
4468
4474
  "$((vnow - 21000))" "$((vnow + 60))" > "$VP/acct-01/.expired"
4469
4475
  t0="$(date +%s)"
4470
4476
  out="$(vp_verify "$ORG_TEXT")"
4471
- soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$VP/acct-01/.expired" 2>/dev/null)"
4472
4477
  epoch="$(head -1 "$VP/acct-01/.expired" 2>/dev/null)"
4473
- { [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$epoch" -ge "$t0" ] \
4478
+ { [ "$epoch" -ge "$t0" ] && ! grep -q 'soft_until=' "$VP/acct-01/.expired" \
4474
4479
  && ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$VP/acct-01/.expired"; } \
4475
- && t_ok "a re-observed org refusal refreshes the park (epoch, marked_at, soft_until)" \
4480
+ && t_ok "a re-observed org refusal refreshes the persistent park (epoch, marked_at)" \
4476
4481
  || t_fail "org park refresh" "$(cat "$VP/acct-01/.expired" 2>/dev/null | tr '\n' ' ')"
4477
4482
  # A weaker reason still never overwrites an org block that binds...
4478
4483
  out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
4479
4484
  grep -q 'reason=org-blocked' "$VP/acct-01/.expired" \
4480
4485
  && t_ok "an auth failure never overwrites an org block in force" \
4481
4486
  || t_fail "org block outranked" "$(tail -1 "$VP/acct-01/.expired")"
4482
- # ...while one whose soft window has run out binds nothing and outranks nothing.
4487
+ # ...including an old marker whose soft window already ran out.
4483
4488
  printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
4484
4489
  "$((vnow - 30000))" "$((vnow - 10))" > "$VP/acct-01/.expired"
4485
4490
  out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
4486
- grep -q 'reason=auth-error' "$VP/acct-01/.expired" \
4487
- && t_ok "a lapsed org block no longer shields the account from an auth park" \
4488
- || t_fail "lapsed org block" "$(tail -1 "$VP/acct-01/.expired")"
4491
+ grep -q 'reason=org-blocked' "$VP/acct-01/.expired" \
4492
+ && t_ok "an org block past its old soft expiry is never replaced by an auth park" \
4493
+ || t_fail "old org block downgraded" "$(tail -1 "$VP/acct-01/.expired")"
4489
4494
  rm -f "$VP"/acct-*/.expired
4490
4495
  # The 404 model_not_found text is about a model, not the org: no park in verify either.
4491
4496
  out="$(vp_verify "The model claude-nope is not available on your firstParty deployment. Try switching to claude-opus-5, or ask your admin to enable this model.")"
@@ -4495,26 +4500,30 @@ case "$(vp_line acct-01)" in
4495
4500
  *"ORG BLOCKED"*|*usage-limited*) t_fail "verify model 404 wording" "$(vp_line acct-01)" ;;
4496
4501
  *) t_ok "a model 404 is neither an org block nor a usage limit" ;;
4497
4502
  esac
4498
- # An org park whose soft_until has passed no longer blocks the account anywhere: the
4499
- # audit (list/expired/status/--json) reads it the way the shim does.
4503
+ # An old org park remains blocked after soft_until: the audit (list/expired/status/JSON)
4504
+ # agrees with the shim that elapsed time alone never proves access was enabled.
4500
4505
  printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$((vnow - 30000))" "$((vnow - 10))" \
4501
4506
  > "$VP/acct-01/.expired"
4502
4507
  printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$vnow" "$((vnow + 21600))" \
4503
4508
  > "$VP/acct-02/.expired"
4504
4509
  rows="$(python3 "$REPO_DIR/lib/audit.py" "$VP" "$VP_MACHINE")"
4505
- [ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-01"{print $4}')" = "ok" ] \
4506
- && t_ok "a lapsed org park no longer reads blocked in the audit" \
4507
- || t_fail "lapsed org park audit" "$(printf '%s\n' "$rows" | grep acct-01)"
4510
+ [ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-01"{print $4}')" = "blocked" ] \
4511
+ && t_ok "an org park past its old soft expiry still reads blocked in the audit" \
4512
+ || t_fail "old org park audit" "$(printf '%s\n' "$rows" | grep acct-01)"
4508
4513
  [ "$(printf '%s\n' "$rows" | awk -F'\t' '$1=="acct-02"{print $4}')" = "blocked" ] \
4509
- && t_ok "an org park inside its soft window still reads blocked" \
4514
+ && t_ok "an org park with a future old soft expiry still reads blocked" \
4510
4515
  || t_fail "org park audit" "$(printf '%s\n' "$rows" | grep acct-02)"
4511
4516
  out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list 2>&1)"
4512
4517
  case "$(printf '%s\n' "$out" | grep '^acct-01 ')" in
4513
- *ORG-BLOCKED*) t_fail "lapsed org park list" "$(printf '%s\n' "$out" | grep '^acct-01 ')" ;;
4514
- *) t_ok "list does not flag a lapsed org park" ;;
4518
+ *ORG-BLOCKED*) t_ok "list flags an org park after its old soft expiry" ;;
4519
+ *) t_fail "old org park list" "$(printf '%s\n' "$out" | grep '^acct-01 ')" ;;
4515
4520
  esac
4516
4521
  out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts expired 2>&1)"
4517
- check "expired says when the pool re-tries an org-blocked account" "re-tried by the pool after" "$out"
4522
+ check "expired explains the inference recovery for an org-blocked account" "claude-accounts verify" "$out"
4523
+ case "$out" in
4524
+ *"re-tried by the pool after"*) t_fail "org block has no scheduled release" "an automatic retry is advertised" ;;
4525
+ *) t_ok "expired does not advertise an automatic org-block release" ;;
4526
+ esac
4518
4527
  rm -f "$WORK/ctl-verifysay"
4519
4528
  unset -f vp_verify vp_line vp_park
4520
4529
 
@@ -5695,6 +5704,10 @@ cxlj 99 10 99 > "$CX/acct-01/limits.json"
5695
5704
  cxlj 7 100 100 > "$CX/acct-02/limits.json"
5696
5705
  out="$(codex 2>&1)"
5697
5706
  check "codex: a still-serving limited account beats an exhausted one with more headroom" "CFG=acct-01" "$out"
5707
+ out="$(CODEX_MULTIACC_AR_PROBE=1 codex 2>&1)"; rc=$?
5708
+ [ "$rc:$out" = "0:pick=acct-01 tier=soft resets_at=$((now+3600))" ] \
5709
+ && t_ok "codex probe: a soft pick carries its own marker's resets_at" \
5710
+ || t_fail "codex probe soft resets_at" "rc=$rc out=$out"
5698
5711
  # The codex-review case (2026-09-04): the exhausted account is the only one INSIDE the
5699
5712
  # session gate; the gate must not resurrect it over a still-serving account outside it.
5700
5713
  printf '%s\nbucket=7d percent=100 marked_at=x reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
@@ -5713,6 +5726,12 @@ printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+7200))" >
5713
5726
  printf '%s\nbucket=5h percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$CX/acct-02/.limited"
5714
5727
  out="$(codex 2>&1)"
5715
5728
  check "codex: all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
5729
+ # The probe is the machine channel for the tty-only notice: it names the soonest reset,
5730
+ # and its stderr stays empty.
5731
+ out="$(CODEX_MULTIACC_AR_PROBE=1 codex 2>"$WORK/cx-probe.err")"; rc=$?
5732
+ [ "$rc:$out" = "0:pick=acct-02 tier=hard resets_at=$((now+600))" ] && [ ! -s "$WORK/cx-probe.err" ] \
5733
+ && t_ok "codex probe: all exhausted answers tier=hard resets_at=<soonest reset>, stderr clean" \
5734
+ || t_fail "codex probe hard resets_at" "rc=$rc out=$out err=$(cat "$WORK/cx-probe.err")"
5716
5735
  grep -q "all-exhausted resets_in=" "$CX/selection.log" \
5717
5736
  && t_ok "codex: the all-exhausted pick is logged with its reset" || t_fail "codex all-exhausted log" "no line"
5718
5737
  rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
@@ -9272,6 +9291,18 @@ else
9272
9291
  t_fail "claude portable token identity suite" "see unittest output above"
9273
9292
  fi
9274
9293
 
9294
+ if python3 "$REPO_DIR/tests/test_claude_client_error.py"; then
9295
+ t_ok "claude: interactive client error classification suite"
9296
+ else
9297
+ t_fail "claude interactive client error suite" "see unittest output above"
9298
+ fi
9299
+
9300
+ if python3 "$REPO_DIR/tests/test_claude_org_block.py"; then
9301
+ t_ok "claude: persistent organization-block integration suite"
9302
+ else
9303
+ t_fail "claude persistent organization-block suite" "see unittest output above"
9304
+ fi
9305
+
9275
9306
  # ---- summary ---------------------------------------------------------------------
9276
9307
  echo
9277
9308
  echo "passed: $PASS failed: $FAIL"
@@ -242,7 +242,9 @@ class ClaudeClassifierTests(unittest.TestCase):
242
242
  "auth")
243
243
  for code in ("oauth_org_not_allowed", "account_on_hold", "billing_error",
244
244
  "verification_required"):
245
- self.assertEqual(self.verdict(cc_api_error(self.T, code))["class"], "blocked", code)
245
+ verdict = self.verdict(cc_api_error(self.T, code))
246
+ self.assertEqual(verdict["class"], "blocked", code)
247
+ self.assertEqual(verdict["rtype"], code if code == "oauth_org_not_allowed" else "", code)
246
248
 
247
249
  def test_everything_else_is_never(self):
248
250
  for code in ("invalid_request", "max_output_tokens", "model_not_found",
@@ -597,7 +599,7 @@ class RelaunchFileTests(unittest.TestCase):
597
599
  "depth=3", "avoid=acct-01:1790123817", "hist=quota:1790100000,quota:17901",
598
600
  "chain=Ab3dEf9h", ""])
599
601
 
600
- def test_reset_and_rtype_belong_to_a_claude_quota_only(self):
602
+ def test_quota_fields_stay_empty_for_auth_and_codex_rtype(self):
601
603
  kv = ar.parse_kv(ar.format_relaunch(self.fields(verdict={"class": "auth"})),
602
604
  ar.RELAUNCH_KEYS)
603
605
  self.assertEqual((kv["class"], kv["reset"], kv["rtype"]), ("auth", "", ""))
@@ -605,6 +607,17 @@ class RelaunchFileTests(unittest.TestCase):
605
607
  self.assertEqual((kv["provider"], kv["reset"], kv["rtype"]),
606
608
  ("codex", "1790123817", ""))
607
609
 
610
+ def test_only_claude_organization_refusal_survives_in_relaunch_rtype(self):
611
+ for provider in ("claude", "codex"):
612
+ for code in ("oauth_org_not_allowed", "account_on_hold", "billing_error", "verification_required"):
613
+ with self.subTest(provider=provider, code=code), tempfile.TemporaryDirectory() as tmp:
614
+ _, verdict = ar.classify_claude(cc_api_error(1790113018, code))
615
+ fields = self.fields(provider=provider, verdict=verdict)
616
+ rel, _ = ar.write_relaunch(tmp, "Tok3nTok3nTok3n0", fields, ["--resume", SID])
617
+ kv = ar.parse_kv(Path(rel).read_text(), ar.RELAUNCH_KEYS)
618
+ rtype = code if provider == "claude" and code == "oauth_org_not_allowed" else ""
619
+ self.assertEqual((kv["class"], kv["reset"], kv["rtype"]), ("blocked", "", rtype))
620
+
608
621
  def test_write_is_atomic_private_and_bash_readable(self):
609
622
  argv = ["--dangerously-skip-permissions", "--resume", SID,
610
623
  "multi\nline prompt with 'quotes' and é"]
@@ -697,7 +710,13 @@ class StateTests(unittest.TestCase):
697
710
  self.assertEqual(ar.parse_probe("noise\npick=acct-02 tier=eligible\n"),
698
711
  ("acct-02", "eligible"))
699
712
  self.assertEqual(ar.parse_probe("pick= tier=none\n"), ("", "none"))
700
- for bad in ("", "pick=acct-02", "pick=../x tier=eligible", "pick=acct-02 tier=great"):
713
+ # The codex shim appends resets_at=<epoch> to a limited tier; old lines still parse.
714
+ self.assertEqual(ar.parse_probe("pick=acct-03 tier=hard resets_at=1790000000\n"),
715
+ ("acct-03", "hard"))
716
+ self.assertEqual(ar.parse_probe("pick=acct-03 tier=soft resets_at=1790000000"),
717
+ ("acct-03", "soft"))
718
+ for bad in ("", "pick=acct-02", "pick=../x tier=eligible", "pick=acct-02 tier=great",
719
+ "pick=acct-02 tier=hard resets_at", "pick=acct-02 tier=hard x y"):
701
720
  self.assertEqual(ar.parse_probe(bad), ("", ""), bad)
702
721
  self.assertTrue(ar.probe_allows("quota", "acct-02", "eligible", "acct-01"))
703
722
  self.assertFalse(ar.probe_allows("quota", "acct-01", "eligible", "acct-01"))
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Structured Claude client error classification, without credentials or inference."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import unittest
11
+
12
+ REPO = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(REPO / "lib"))
14
+ import claude_client_error as errors
15
+
16
+ CLAIM = "2026-09-30T10:30:00.000Z"
17
+ LATER = "2026-09-30T10:30:01.322Z"
18
+ ORG_TEXT = ("Your organization has disabled Claude subscription access for Claude Code · "
19
+ "Use an Anthropic API key instead, or ask your admin to enable access")
20
+
21
+
22
+ def api_record(code="oauth_org_not_allowed", timestamp=LATER, **extra):
23
+ record = {"parentUuid": "06c84ec0-a9db-4387-b695-a6e0bdf91685", "isSidechain": False,
24
+ "userType": "external", "cwd": "/sandbox", "sessionId": "sandbox-session",
25
+ "version": "2.1.285", "gitBranch": "HEAD", "type": "assistant",
26
+ "uuid": "3a416522-6c97-44cf-adfe-00b44d2dcad5", "timestamp": timestamp,
27
+ "message": {"id": "sandbox-message", "type": "message", "role": "assistant",
28
+ "model": "<synthetic>", "content": [{"type": "text", "text": ORG_TEXT}],
29
+ "stop_reason": "stop_sequence", "usage": {"input_tokens": 0, "output_tokens": 0}},
30
+ "isApiErrorMessage": True, "error": code, "apiErrorStatus": 403}
31
+ record.update(extra)
32
+ return record
33
+
34
+
35
+ class ClaudeClientErrorTests(unittest.TestCase):
36
+ def test_authentic_organization_and_authentication_records(self):
37
+ for code, slug in errors.ERRORS.items():
38
+ with self.subTest(code=code):
39
+ self.assertEqual(errors.newest_error([json.dumps(api_record(code))], CLAIM), (slug, LATER))
40
+ self.assertEqual(errors.newest_error([json.dumps(api_record(timestamp=CLAIM))], CLAIM),
41
+ ("org-blocked", CLAIM))
42
+
43
+ def test_claim_and_record_timing_must_be_provable(self):
44
+ invalid = [None, False, 1790764200, "", "invalid", "2026-09-30T10:30:00", "2026-09-30"]
45
+ for timestamp in invalid + ["2026-09-30T10:29:59.999Z"]:
46
+ with self.subTest(timestamp=timestamp):
47
+ self.assertIsNone(errors.newest_error([json.dumps(api_record(timestamp=timestamp))], CLAIM))
48
+ missing = api_record()
49
+ del missing["timestamp"]
50
+ self.assertIsNone(errors.newest_error([json.dumps(missing)], CLAIM))
51
+ for claim in invalid:
52
+ with self.subTest(claim=claim):
53
+ self.assertIsNone(errors.newest_error([json.dumps(api_record())], claim))
54
+
55
+ def test_message_text_and_other_api_errors_never_exclude(self):
56
+ message = {"content": [{"type": "text", "text": ORG_TEXT + " authentication_failed oauth_org_not_allowed"}]}
57
+ variants = [{"type": "user"}, {"type": "system"}, {"isApiErrorMessage": False},
58
+ {"isApiErrorMessage": "true"}, {"isApiErrorMessage": 1}, {"error": None},
59
+ {"error": ["oauth_org_not_allowed"]}, {"error": "billing_error"},
60
+ {"error": "model_not_found", "apiErrorStatus": 404,
61
+ "message": {"content": [{"type": "text", "text": "Ask your admin to enable this model"}]}}]
62
+ for extra in variants:
63
+ with self.subTest(extra=extra):
64
+ record = api_record(message=message)
65
+ record.update(extra)
66
+ self.assertIsNone(errors.newest_error([json.dumps(record)], CLAIM))
67
+ ordinary = api_record(message=message)
68
+ del ordinary["isApiErrorMessage"]
69
+ self.assertIsNone(errors.newest_error([json.dumps(ordinary)], CLAIM))
70
+
71
+ def test_sidechains_and_malformed_records_are_ignored(self):
72
+ lines = ["not-json", "{", "[]", "null", "1", '"oauth_org_not_allowed"',
73
+ json.dumps(api_record(isSidechain=True))]
74
+ self.assertIsNone(errors.newest_error(lines, CLAIM))
75
+ self.assertEqual(errors.newest_error(lines + [json.dumps(api_record())], CLAIM),
76
+ ("org-blocked", LATER))
77
+ without_sidechain = api_record()
78
+ del without_sidechain["isSidechain"]
79
+ self.assertEqual(errors.newest_error([json.dumps(without_sidechain)], CLAIM), ("org-blocked", LATER))
80
+
81
+ def test_organization_recovery_watermark_does_not_clear_authentication(self):
82
+ at = errors.iso_epoch(LATER)
83
+ org = json.dumps(api_record())
84
+ self.assertIsNone(errors.newest_error([org], CLAIM, at))
85
+ self.assertIsNone(errors.newest_error([org], CLAIM, at + 1))
86
+ self.assertEqual(errors.newest_error([org], CLAIM, at - 0.001), ("org-blocked", LATER))
87
+ auth = json.dumps(api_record("authentication_failed"))
88
+ self.assertEqual(errors.newest_error([auth], CLAIM, at + 100), ("auth", LATER))
89
+
90
+ def test_latest_in_class_wins_and_auth_cannot_downgrade_org_refusal(self):
91
+ latest = "2026-09-30T13:30:02.000+03:00"
92
+ for code, slug in errors.ERRORS.items():
93
+ lines = [json.dumps(api_record(code, latest)), json.dumps(api_record(code)),
94
+ json.dumps(api_record(code, None))]
95
+ with self.subTest(code=code):
96
+ self.assertEqual(errors.newest_error(lines, CLAIM), (slug, latest))
97
+ lines.reverse()
98
+ self.assertEqual(errors.newest_error(lines, CLAIM), (slug, latest))
99
+ lines = [json.dumps(api_record()), json.dumps(api_record("authentication_failed", latest))]
100
+ self.assertEqual(errors.newest_error(lines, CLAIM), ("org-blocked", LATER))
101
+ lines.reverse()
102
+ self.assertEqual(errors.newest_error(lines, CLAIM), ("org-blocked", LATER))
103
+ self.assertEqual(errors.newest_error(lines, CLAIM, errors.iso_epoch(LATER)), ("auth", latest))
104
+ self.assertEqual(errors.newest_error([json.dumps(api_record("authentication_failed", LATER)),
105
+ json.dumps(api_record())], CLAIM), ("org-blocked", LATER))
106
+
107
+ def test_cli_returns_one_safe_result_or_silent_failure(self):
108
+ command = [sys.executable, str(REPO / "lib" / "claude_client_error.py"), "--claim", CLAIM]
109
+ result = subprocess.run(command, input=json.dumps(api_record()) + "\n", text=True, capture_output=True)
110
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, f"org-blocked {LATER}\n", ""))
111
+ for args, record in [(["--org-cleared", str(errors.iso_epoch(LATER))], api_record()),
112
+ ([], api_record(isSidechain=True)), (["--claim", "invalid"], api_record())]:
113
+ with self.subTest(args=args):
114
+ result = subprocess.run(command + args, input=json.dumps(record), text=True, capture_output=True)
115
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (1, "", ""))
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()