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,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.46",
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
 
@@ -9282,6 +9291,18 @@ else
9282
9291
  t_fail "claude portable token identity suite" "see unittest output above"
9283
9292
  fi
9284
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
+
9285
9306
  # ---- summary ---------------------------------------------------------------------
9286
9307
  echo
9287
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 é"]
@@ -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()