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.
- package/README.md +29 -23
- package/bin/claude +48 -31
- package/bin/claude-accounts +30 -16
- package/docs/ACCOUNT_OPERATIONS.md +13 -9
- package/docs/AUTORESUME.md +8 -1
- package/docs/VERIFICATION.md +5 -2
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +8 -26
- package/lib/autoresume.py +7 -5
- package/lib/claude_client_error.py +81 -0
- package/lib/common.sh +11 -0
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +59 -38
- package/tests/test_autoresume.py +15 -2
- package/tests/test_claude_client_error.py +119 -0
- 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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -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",
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
[
|
|
1169
|
-
|| t_fail "org-block relogin" "
|
|
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
|
|
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.
|
|
3807
|
-
#
|
|
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
|
|
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
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
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
|
|
4455
|
-
#
|
|
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
|
-
|
|
4462
|
-
{ [
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
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
|
-
{ [
|
|
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
|
|
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
|
-
# ...
|
|
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=
|
|
4487
|
-
&& t_ok "
|
|
4488
|
-
|| t_fail "
|
|
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
|
|
4499
|
-
#
|
|
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}')" = "
|
|
4506
|
-
&& t_ok "
|
|
4507
|
-
|| t_fail "
|
|
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
|
|
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*)
|
|
4514
|
-
*)
|
|
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
|
|
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"
|
package/tests/test_autoresume.py
CHANGED
|
@@ -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.
|
|
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
|
|
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()
|