claude-multiacc 2.0.43 → 2.0.45
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 +20 -7
- package/bin/claude +66 -19
- package/bin/claude-accounts +186 -54
- package/bin/codex +4 -3
- package/bin/codex-accounts +18 -8
- package/docs/ACCOUNT_OPERATIONS.md +5 -1
- package/docs/VERIFICATION.md +8 -0
- package/docs/claude-token-identity.md +147 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.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 +42 -4
- package/lib/claude_token_identity.py +233 -0
- package/lib/codex_audit.py +8 -1
- package/lib/common.sh +3 -1
- package/lib/credential.py +9 -2
- 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 +254 -3
- package/tests/test_autoresume.py +36 -5
- package/tests/test_claude_token_identity.py +259 -0
- package/tests/test_token_identity_ceremony.py +167 -0
- package/tests/test_token_identity_integration.py +207 -0
- package/tests/test_token_identity_retry.py +95 -0
- package/tests/test_token_identity_sync.py +139 -0
package/tests/run-tests.sh
CHANGED
|
@@ -272,8 +272,21 @@ if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
|
|
|
272
272
|
exit 1
|
|
273
273
|
fi
|
|
274
274
|
case " $* " in
|
|
275
|
-
*" -p --output-format text --max-turns 1 "*)
|
|
275
|
+
*" -p --output-format text --max-turns 1 "*)
|
|
276
|
+
# verify's real call, refused: `verifysay:<acct>` answers FAKE_VERIFY_TEXT the way
|
|
277
|
+
# the real client prints a refusal in text mode — on STDOUT, exit 1 (health.log
|
|
278
|
+
# 2026-09-28: out="You've hit your weekly limit · resets 1am (Europe/Istanbul)" err='').
|
|
279
|
+
if [ -f "$ctl" ] && grep -qx "verifysay:$acct" "$ctl" 2>/dev/null; then
|
|
280
|
+
printf '%s\n' "${FAKE_VERIFY_TEXT:-}"
|
|
281
|
+
exit 1
|
|
282
|
+
fi
|
|
283
|
+
echo "OK"; exit 0 ;;
|
|
276
284
|
esac
|
|
285
|
+
if [ -f "$ctl" ] && grep -qx "model404:$acct" "$ctl" 2>/dev/null; then
|
|
286
|
+
# 2.1.283's 404 model_not_found text: about a MODEL, never the organization.
|
|
287
|
+
echo "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."
|
|
288
|
+
exit 1
|
|
289
|
+
fi
|
|
277
290
|
if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
|
|
278
291
|
echo "API Error: 429 rate limit exceeded" >&2
|
|
279
292
|
exit 1
|
|
@@ -367,6 +380,9 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
|
367
380
|
# (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
|
|
368
381
|
# throttle keeps those background kicks from racing explicit limits runs.
|
|
369
382
|
export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
383
|
+
# Organization checks must never send synthetic tokens to a real provider. The
|
|
384
|
+
# dedicated identity suite overrides this with its loopback HTTP contract server.
|
|
385
|
+
export CLAUDE_MULTIACC_TOKEN_IDENTITY_URL="file://$WORK/identity-endpoint-missing.json"
|
|
370
386
|
: > "$ACC/.limits-kick"
|
|
371
387
|
# Keychain lookups are OFF for the legacy sections (their pools are file-based and the
|
|
372
388
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
@@ -1152,6 +1168,17 @@ check "relogin targets org-blocked accounts" "acct-01 login saved" "$out"
|
|
|
1152
1168
|
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login clears an org block" \
|
|
1153
1169
|
|| t_fail "org-block relogin" "marker survived the re-login"
|
|
1154
1170
|
rm -f "$ACC/acct-01/.expired"
|
|
1171
|
+
# The client's 404 model_not_found ends "…or ask your admin to enable this model". The org
|
|
1172
|
+
# vocabulary's bare "ask your admin to enable" matched it, parking a healthy account as
|
|
1173
|
+
# org-blocked over a model name; only "…to enable access" is the org refusal.
|
|
1174
|
+
printf '{"fetched_at":%s,"max_percent":1,"weekly_percent":1,"session_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
1175
|
+
printf '{"fetched_at":%s,"max_percent":50,"weekly_percent":50,"session_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
1176
|
+
echo "model404:acct-01" > "$FAKE_CTL"
|
|
1177
|
+
out="$(claude -p hello < /dev/null 2>&1)"
|
|
1178
|
+
check "a model 404 reaches the caller as it is" "not available on your firstParty deployment" "$out"
|
|
1179
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a model 404 ('ask your admin to enable this model') parks nothing" \
|
|
1180
|
+
|| t_fail "model 404 park" "$(tail -1 "$ACC/acct-01/.expired")"
|
|
1181
|
+
rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
|
|
1155
1182
|
|
|
1156
1183
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
1157
1184
|
mkdir -p "$ACC/acct-03"
|
|
@@ -3210,6 +3237,14 @@ rm -f "$ACC/acct-05/limits.json"
|
|
|
3210
3237
|
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
3211
3238
|
[ -f "$ACC/acct-05/.expired" ] && t_ok "a usage fetch does not unpark an org-blocked account" \
|
|
3212
3239
|
|| 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)
|
|
3241
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' "$now" "$(( $(date +%s) + 21600 ))" \
|
|
3242
|
+
> "$ACC/acct-05/.expired"
|
|
3243
|
+
rm -f "$ACC/acct-05/limits.json"
|
|
3244
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
3245
|
+
grep -q 'reason=org-blocked soft_until=' "$ACC/acct-05/.expired" 2>/dev/null \
|
|
3246
|
+
&& t_ok "a usage fetch does not unpark a soft-stamped org block" \
|
|
3247
|
+
|| t_fail "soft org-block unpark" "telemetry cleared a soft-stamped org block"
|
|
3213
3248
|
rm -f "$ACC/acct-05/.expired" "$ACC/acct-05/server.token"
|
|
3214
3249
|
|
|
3215
3250
|
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
@@ -3767,6 +3802,34 @@ RACER
|
|
|
3767
3802
|
grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null \
|
|
3768
3803
|
&& t_ok "a dead refresh grant never overwrites an org-blocked marker" \
|
|
3769
3804
|
|| t_fail "org block downgraded" "marker is now: $(cat "$SD/acct-01/.expired" 2>/dev/null | tail -1)"
|
|
3805
|
+
# 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.
|
|
3809
|
+
mv "$SD/acct-01/server.token" "$SD/acct-01/server.token.aside"
|
|
3810
|
+
for soft in none "$(( $(date +%s) + 21600 ))" "$(( $(date +%s) - 10 ))"; do
|
|
3811
|
+
if [ "$soft" = none ]; then
|
|
3812
|
+
printf '%s\nreason=org-blocked marked_at=now detail=test\n' "$now" > "$SD/acct-01/.expired"
|
|
3813
|
+
else
|
|
3814
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=now detail=test\n' \
|
|
3815
|
+
"$((now - 30000))" "$soft" > "$SD/acct-01/.expired"
|
|
3816
|
+
fi
|
|
3817
|
+
rm -f "$SD/acct-01/limits.json"
|
|
3818
|
+
CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
|
|
3819
|
+
claude-accounts limits --force >/dev/null 2>&1
|
|
3820
|
+
grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null && echo kept || echo "$(tail -1 "$SD/acct-01/.expired" 2>/dev/null)"
|
|
3821
|
+
done > "$WORK/lapsed-org.txt"
|
|
3822
|
+
mv "$SD/acct-01/server.token.aside" "$SD/acct-01/server.token"
|
|
3823
|
+
[ "$(sed -n 1p "$WORK/lapsed-org.txt")" = kept ] \
|
|
3824
|
+
&& t_ok "a dead refresh grant (no token beside it) never overwrites an org block" \
|
|
3825
|
+
|| t_fail "org block downgraded" "$(sed -n 1p "$WORK/lapsed-org.txt")"
|
|
3826
|
+
[ "$(sed -n 2p "$WORK/lapsed-org.txt")" = kept ] \
|
|
3827
|
+
&& t_ok "a dead refresh grant never overwrites an org block inside its soft window" \
|
|
3828
|
+
|| 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
|
|
3770
3833
|
rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json"
|
|
3771
3834
|
|
|
3772
3835
|
# ---- the >=90% cutoff keeps the TIGHT window --------------------------------
|
|
@@ -4290,6 +4353,171 @@ case "$out" in
|
|
|
4290
4353
|
esac
|
|
4291
4354
|
rm -f "$WORK/ctl-brokencli"
|
|
4292
4355
|
|
|
4356
|
+
# ---- 17a3. a usage-limit refusal proves the org lets the account through --------------
|
|
4357
|
+
# acct-15, 2026-09-27: at weekly_all 100% a real call came back with the ORG refusal, and
|
|
4358
|
+
# once the lapsed subscription was paid again the same call answered "You've hit your
|
|
4359
|
+
# weekly limit" — the provider checks the org gate before the usage limiter. verify
|
|
4360
|
+
# failed that answer with no hint and kept the org-blocked park, so a paid account read
|
|
4361
|
+
# BLOCKED on that Mac through every re-check. A limit refusal now lifts a park by the PASS
|
|
4362
|
+
# rule, and the line stays FAIL (the runner counts it).
|
|
4363
|
+
VP="$WORK/verifylimit"
|
|
4364
|
+
vnow="$(date +%s)" # the suite's $now is minutes old by here; soft windows need a fresh one
|
|
4365
|
+
VP_HOME=mac; VP_MACHINE=mac; [ "$(uname -s)" = "Darwin" ] || { VP_HOME=server; VP_MACHINE=linux; }
|
|
4366
|
+
mkdir -p "$VP/tmp" "$VP/acct-01" "$VP/acct-02" "$VP/acct-03"
|
|
4367
|
+
: > "$VP/.limits-kick"
|
|
4368
|
+
cat > "$VP/accounts.json" <<EOF2
|
|
4369
|
+
{ "version": 1, "threshold": 90,
|
|
4370
|
+
"accounts": [
|
|
4371
|
+
{"id": "acct-01", "email": "vl1@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
|
|
4372
|
+
{"id": "acct-02", "email": "vl2@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"},
|
|
4373
|
+
{"id": "acct-03", "email": "vl3@test", "home": "$VP_HOME", "added_at": "2026-09-28T00:00:00Z"}
|
|
4374
|
+
] }
|
|
4375
|
+
EOF2
|
|
4376
|
+
VP_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-vl","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4377
|
+
# acct-01: OAuth login only. acct-02: a live login beside a portable token (verify runs
|
|
4378
|
+
# the LOGIN). acct-03: the portable token only (verify runs the TOKEN).
|
|
4379
|
+
printf '%s' "$VP_LIVE" > "$VP/acct-01/.credentials.json"
|
|
4380
|
+
printf '%s' "$VP_LIVE" > "$VP/acct-02/.credentials.json"
|
|
4381
|
+
printf 'sk-ant-oat01-vl-token-02' > "$VP/acct-02/server.token"
|
|
4382
|
+
printf 'sk-ant-oat01-vl-token-03' > "$VP/acct-03/server.token"
|
|
4383
|
+
printf 'verifysay:acct-01\nverifysay:acct-02\nverifysay:acct-03\n' > "$WORK/ctl-verifysay"
|
|
4384
|
+
vp_verify() { # $1 = what the refused call prints
|
|
4385
|
+
FAKE_CTL="$WORK/ctl-verifysay" FAKE_VERIFY_TEXT="$1" CLAUDE_ACCOUNTS_DIR="$VP" \
|
|
4386
|
+
claude-accounts verify 2>&1
|
|
4387
|
+
}
|
|
4388
|
+
vp_line() { printf '%s\n' "$out" | grep "^$1 " | head -1; }
|
|
4389
|
+
vp_park() { # $1 acct, $2 marker line 2 (reason=… fields)
|
|
4390
|
+
printf '%s\n%s\n' "$((vnow - 3600))" "$2" > "$VP/$1/.expired"
|
|
4391
|
+
}
|
|
4392
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=the account's organization has disabled Claude Code access"
|
|
4393
|
+
vp_park acct-02 "reason=setup-token-invalid marked_at=2026-09-27T20:42:42Z detail=portable setup-token failed a real inference"
|
|
4394
|
+
vp_park acct-03 "reason=org-blocked soft_until=$((vnow + 20000)) marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4395
|
+
rm -f "$VP/acct-03/.server-token-verified"
|
|
4396
|
+
out="$(vp_verify "You've hit your weekly limit · resets 1am (Europe/Istanbul)")"
|
|
4397
|
+
rc=$?
|
|
4398
|
+
check "a limit refusal still reads FAIL, in the shape the runner parses" \
|
|
4399
|
+
"acct-01 vl1@test: FAIL rc=1 out=\"You've hit your weekly limit" "$out"
|
|
4400
|
+
check "a limit refusal is called what it is" "usage-limited, not a login or org problem" "$(vp_line acct-01)"
|
|
4401
|
+
check "a limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-01)"
|
|
4402
|
+
[ ! -f "$VP/acct-01/.expired" ] && t_ok "a limit refusal lifts an org-blocked park (the org gate answers first)" \
|
|
4403
|
+
|| t_fail "limit lifts org park" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4404
|
+
[ "$rc" = 1 ] && check "limit refusals are still counted as failures" "verified 3 account(s), 3 failure(s)" "$out" \
|
|
4405
|
+
|| t_fail "limit refusal exit" "rc=$rc"
|
|
4406
|
+
n="$(printf '%s\n' "$out" | grep -cE '^(acct-[0-9]+)[[:space:]]+([^[:space:]]+):[[:space:]]+(PASS|FAIL|OK|SKIP)([^[:alnum:]_].*)?$')"
|
|
4407
|
+
[ "$n" = 3 ] && t_ok "every account line still matches app-robot's verdict pattern" \
|
|
4408
|
+
|| t_fail "verdict pattern" "matched $n of 3: $out"
|
|
4409
|
+
# The LOGIN answered for acct-02: that proves nothing about the portable token a real
|
|
4410
|
+
# call rejected, so its token park stays — the PASS rule, exactly.
|
|
4411
|
+
check "a limit refusal on the login is still called usage-limited" "usage-limited" "$(vp_line acct-02)"
|
|
4412
|
+
case "$(vp_line acct-02)" in
|
|
4413
|
+
*lifted*) t_fail "login limit must not claim a lift" "$(vp_line acct-02)" ;;
|
|
4414
|
+
*) t_ok "a limit refusal that lifted nothing does not claim a lift" ;;
|
|
4415
|
+
esac
|
|
4416
|
+
grep -q 'reason=setup-token-invalid' "$VP/acct-02/.expired" 2>/dev/null \
|
|
4417
|
+
&& t_ok "a limit refusal on the OAuth login keeps a setup-token-invalid park" \
|
|
4418
|
+
|| t_fail "login limit vs token park" "the token park was lifted by the login's answer"
|
|
4419
|
+
[ ! -f "$VP/acct-02/.server-token-verified" ] && t_ok "the login's limit answer records no token proof" \
|
|
4420
|
+
|| t_fail "login limit token proof" ".server-token-verified written for a token that never answered"
|
|
4421
|
+
# The TOKEN answered for acct-03: its park goes and the token is proven, as on a PASS.
|
|
4422
|
+
check "a token limit refusal names the park it lifted" "lifted its org-blocked park" "$(vp_line acct-03)"
|
|
4423
|
+
[ ! -f "$VP/acct-03/.expired" ] && t_ok "a limit refusal on the token lifts the park" \
|
|
4424
|
+
|| t_fail "token limit lift" "$(tail -1 "$VP/acct-03/.expired")"
|
|
4425
|
+
want="$(printf 'sk-ant-oat01-vl-token-03' | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d' ' -f1)"
|
|
4426
|
+
[ "$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)" = "$want" ] \
|
|
4427
|
+
&& t_ok "a limit refusal on the token records .server-token-verified for that token" \
|
|
4428
|
+
|| t_fail "token limit proof" "got '$(cat "$VP/acct-03/.server-token-verified" 2>/dev/null)'"
|
|
4429
|
+
st="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list --json 2>/dev/null \
|
|
4430
|
+
| python3 -c 'import json,sys; print({a["id"]: a["status"] for a in json.load(sys.stdin)["accounts"]}.get("acct-01"))')"
|
|
4431
|
+
[ "$st" != "blocked" ] && t_ok "the lifted account no longer lists as blocked ($st)" \
|
|
4432
|
+
|| t_fail "lifted account status" "list --json still says blocked"
|
|
4433
|
+
# A model-scoped limit is the same limiter speaking.
|
|
4434
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4435
|
+
out="$(vp_verify "You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cl to continue.")"
|
|
4436
|
+
[ ! -f "$VP/acct-01/.expired" ] && check "a model-scoped limit lifts the org park too" "lifted its org-blocked park" "$(vp_line acct-01)" \
|
|
4437
|
+
|| t_fail "model limit lift" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4438
|
+
# ...but not the throttle the client itself calls "not your usage limit", nor an overload:
|
|
4439
|
+
# neither proves the org gate was passed, and both contain limit words.
|
|
4440
|
+
for said in "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited" \
|
|
4441
|
+
"API Error: 529 Overloaded. Rate limit exceeded, try again" \
|
|
4442
|
+
"Opus is experiencing high load, please use /model to switch to Sonnet"; do
|
|
4443
|
+
vp_park acct-01 "reason=org-blocked marked_at=2026-09-27T20:42:42Z detail=x"
|
|
4444
|
+
out="$(vp_verify "$said")"
|
|
4445
|
+
if grep -q 'reason=org-blocked' "$VP/acct-01/.expired" 2>/dev/null \
|
|
4446
|
+
&& case "$(vp_line acct-01)" in *usage-limited*) false ;; *) true ;; esac; then
|
|
4447
|
+
t_ok "no lift on a transient answer: ${said:0:48}"
|
|
4448
|
+
else
|
|
4449
|
+
t_fail "transient answer lifted a park" "${said:0:48} -> $(vp_line acct-01)"
|
|
4450
|
+
fi
|
|
4451
|
+
done
|
|
4452
|
+
rm -f "$VP"/acct-*/.expired
|
|
4453
|
+
|
|
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).
|
|
4456
|
+
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
|
+
t0="$(date +%s)"
|
|
4458
|
+
out="$(vp_verify "$ORG_TEXT")"
|
|
4459
|
+
t1="$(date +%s)"
|
|
4460
|
+
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
|
+
&& t_ok "verify writes the shim's field order" || t_fail "org park shape" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4467
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
4468
|
+
"$((vnow - 21000))" "$((vnow + 60))" > "$VP/acct-01/.expired"
|
|
4469
|
+
t0="$(date +%s)"
|
|
4470
|
+
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
|
+
epoch="$(head -1 "$VP/acct-01/.expired" 2>/dev/null)"
|
|
4473
|
+
{ [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] && [ "$epoch" -ge "$t0" ] \
|
|
4474
|
+
&& ! 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)" \
|
|
4476
|
+
|| t_fail "org park refresh" "$(cat "$VP/acct-01/.expired" 2>/dev/null | tr '\n' ' ')"
|
|
4477
|
+
# A weaker reason still never overwrites an org block that binds...
|
|
4478
|
+
out="$(vp_verify "Failed to authenticate. API Error: 401 OAuth access token is invalid.")"
|
|
4479
|
+
grep -q 'reason=org-blocked' "$VP/acct-01/.expired" \
|
|
4480
|
+
&& t_ok "an auth failure never overwrites an org block in force" \
|
|
4481
|
+
|| 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.
|
|
4483
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
4484
|
+
"$((vnow - 30000))" "$((vnow - 10))" > "$VP/acct-01/.expired"
|
|
4485
|
+
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")"
|
|
4489
|
+
rm -f "$VP"/acct-*/.expired
|
|
4490
|
+
# The 404 model_not_found text is about a model, not the org: no park in verify either.
|
|
4491
|
+
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.")"
|
|
4492
|
+
[ ! -f "$VP/acct-01/.expired" ] && t_ok "verify does not park on 'ask your admin to enable this model'" \
|
|
4493
|
+
|| t_fail "verify model 404" "$(tail -1 "$VP/acct-01/.expired")"
|
|
4494
|
+
case "$(vp_line acct-01)" in
|
|
4495
|
+
*"ORG BLOCKED"*|*usage-limited*) t_fail "verify model 404 wording" "$(vp_line acct-01)" ;;
|
|
4496
|
+
*) t_ok "a model 404 is neither an org block nor a usage limit" ;;
|
|
4497
|
+
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.
|
|
4500
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$((vnow - 30000))" "$((vnow - 10))" \
|
|
4501
|
+
> "$VP/acct-01/.expired"
|
|
4502
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=x detail=x\n' "$vnow" "$((vnow + 21600))" \
|
|
4503
|
+
> "$VP/acct-02/.expired"
|
|
4504
|
+
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)"
|
|
4508
|
+
[ "$(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" \
|
|
4510
|
+
|| t_fail "org park audit" "$(printf '%s\n' "$rows" | grep acct-02)"
|
|
4511
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$VP" claude-accounts list 2>&1)"
|
|
4512
|
+
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" ;;
|
|
4515
|
+
esac
|
|
4516
|
+
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"
|
|
4518
|
+
rm -f "$WORK/ctl-verifysay"
|
|
4519
|
+
unset -f vp_verify vp_line vp_park
|
|
4520
|
+
|
|
4293
4521
|
# ---- 16e. MCP servers for every account ---------------------------------------------
|
|
4294
4522
|
# Claude Code keeps MCP servers in the CONFIG DIR, so under the pool a stock `claude mcp
|
|
4295
4523
|
# add` used to land in ONE random account — appinspire was connected in some sessions
|
|
@@ -6883,6 +7111,23 @@ out="$(codex-accounts verify 2>&1)"
|
|
|
6883
7111
|
check "codex: verify calls out the workspace block" "ORG BLOCKED" "$out"
|
|
6884
7112
|
grep -q "reason=org-blocked" "$CX/acct-02/.expired" 2>/dev/null \
|
|
6885
7113
|
&& t_ok "codex: verify parks the org-blocked account" || t_fail "codex verify org park" "no marker"
|
|
7114
|
+
# ...with bin/codex's 6 h soft_until, in the shim's field order, refreshed when seen again
|
|
7115
|
+
t0="$(date +%s)"
|
|
7116
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7117
|
+
{ [ -n "$soft" ] && [ "$soft" -gt $((t0 + 21000)) ] && [ "$soft" -le $((t0 + 21600)) ] \
|
|
7118
|
+
&& grep -q '^reason=org-blocked soft_until=[0-9]* marked_at=' "$CX/acct-02/.expired"; } \
|
|
7119
|
+
&& t_ok "codex: verify's org park carries soft_until = now + 6 h" \
|
|
7120
|
+
|| t_fail "codex verify org soft_until" "$(tail -1 "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7121
|
+
printf '%s\nreason=org-blocked soft_until=%s marked_at=2026-09-27T20:42:42Z detail=x\n' \
|
|
7122
|
+
"$((t0 - 21000))" "$((t0 + 60))" > "$CX/acct-02/.expired"
|
|
7123
|
+
out="$(codex-accounts verify 2>&1)"
|
|
7124
|
+
soft="$(sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$CX/acct-02/.expired" 2>/dev/null)"
|
|
7125
|
+
{ [ -n "$soft" ] && [ "$soft" -ge $((t0 + 21600)) ] \
|
|
7126
|
+
&& ! grep -q 'marked_at=2026-09-27T20:42:42Z' "$CX/acct-02/.expired"; } \
|
|
7127
|
+
&& t_ok "codex: a re-observed workspace block refreshes the park" \
|
|
7128
|
+
|| t_fail "codex org park refresh" "$(cat "$CX/acct-02/.expired" 2>/dev/null | tr '\n' ' ')"
|
|
7129
|
+
out="$(codex-accounts expired 2>&1)"
|
|
7130
|
+
check "codex: expired says when the pool re-tries a blocked account" "re-tried by the pool after" "$out"
|
|
6886
7131
|
rm -f "$FAKE_CTL2" "$CX/acct-02/.expired"
|
|
6887
7132
|
|
|
6888
7133
|
# ---- C16. audit vocabulary + empty pool ---------------------------------------------
|
|
@@ -7672,12 +7917,12 @@ check "mint refuses an unregistered account dir" "unknown account: acct-77" "$ou
|
|
|
7672
7917
|
|| t_fail "mint orphan dir" "server.token was written to an unregistered slot"
|
|
7673
7918
|
rm -rf "$ACC/acct-77"
|
|
7674
7919
|
|
|
7675
|
-
# mint must NAME the account
|
|
7920
|
+
# mint must NAME the account even when no saved organization is available to check.
|
|
7676
7921
|
printf 'sk-ant-oat01-MINTNAMEDMINTNAMEDMINTNAMEDMINTNAMEDMINTNAMED\n' \
|
|
7677
7922
|
| claude-accounts mint acct-01 --paste > "$WORK/mint-named.out" 2>&1
|
|
7678
7923
|
out="$(cat "$WORK/mint-named.out")"
|
|
7679
7924
|
check "mint --paste names the account in its prompt" "for acct-01" "$out"
|
|
7680
|
-
check "mint
|
|
7925
|
+
check "mint reports an unavailable organization check" "organization could not be checked" "$out"
|
|
7681
7926
|
|
|
7682
7927
|
# ---- 47. every known verb answers --help with exit 0 (app-robot probes with it) ----
|
|
7683
7928
|
# app-robot's runner asks `<verb> --help` to decide whether a Mac's build has the verb;
|
|
@@ -9021,6 +9266,12 @@ else
|
|
|
9021
9266
|
t_fail "auto-resume watcher unit suite" "see unittest output above"
|
|
9022
9267
|
fi
|
|
9023
9268
|
|
|
9269
|
+
if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_*token_identity*.py'; then
|
|
9270
|
+
t_ok "claude: portable token organization identity suite"
|
|
9271
|
+
else
|
|
9272
|
+
t_fail "claude portable token identity suite" "see unittest output above"
|
|
9273
|
+
fi
|
|
9274
|
+
|
|
9024
9275
|
# ---- summary ---------------------------------------------------------------------
|
|
9025
9276
|
echo
|
|
9026
9277
|
echo "passed: $PASS failed: $FAIL"
|
package/tests/test_autoresume.py
CHANGED
|
@@ -11,6 +11,7 @@ PATH and a fake probe script. Runs under python3.9+.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import calendar
|
|
14
15
|
import contextlib
|
|
15
16
|
import importlib.util
|
|
16
17
|
import json
|
|
@@ -128,6 +129,27 @@ def cx_error(ts, info, message="boom", completed_at=None):
|
|
|
128
129
|
|
|
129
130
|
LIMIT_MSG = ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
|
|
130
131
|
"to purchase more credits or try again at Sep 26th, 2026 12:39 AM.")
|
|
132
|
+
_MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
|
|
133
|
+
"Dec")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def limit_msg(epoch):
|
|
137
|
+
"""LIMIT_MSG with its "try again at" date set to ``epoch``, rendered in UTC the way
|
|
138
|
+
the codex CLI prints it (read it back with TZ=UTC). A walk on the real clock needs this:
|
|
139
|
+
the classifier ignores a "try again at" that is already past, and a fixed date goes stale
|
|
140
|
+
(LIMIT_MSG's Sep 26th, 2026 did on that day, which failed the Publish run)."""
|
|
141
|
+
t = time.gmtime(epoch)
|
|
142
|
+
day = t.tm_mday
|
|
143
|
+
nth = "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
144
|
+
return ("You’ve hit your usage limit. Visit https://chatgpt.com/codex/settings/usage "
|
|
145
|
+
"to purchase more credits or try again at %s %d%s, %d %d:%02d %s."
|
|
146
|
+
% (_MONTH_ABBR[t.tm_mon - 1], day, nth, t.tm_year, t.tm_hour % 12 or 12,
|
|
147
|
+
t.tm_min, "AM" if t.tm_hour < 12 else "PM"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def future_reset(now):
|
|
151
|
+
"""A minute-aligned "try again at" three days after ``now``."""
|
|
152
|
+
return (int(now) // 60 + 3 * 24 * 60) * 60
|
|
131
153
|
|
|
132
154
|
|
|
133
155
|
def cx_tokens(ts, primary=None, secondary=None, limit_id="codex", info=None):
|
|
@@ -434,6 +456,12 @@ class CodexClassifierTests(unittest.TestCase):
|
|
|
434
456
|
1793631600)
|
|
435
457
|
self.assertEqual(ar.parse_try_again("try again at December 3rd, 2026 9:15 AM"),
|
|
436
458
|
1796289300)
|
|
459
|
+
self.assertEqual(limit_msg(1790383140), LIMIT_MSG) # the helper renders it
|
|
460
|
+
for stamp in ((2026, 10, 1, 12, 5), (2026, 11, 2, 15, 0), (2026, 12, 3, 9, 15),
|
|
461
|
+
(2026, 9, 11, 0, 0), (2026, 9, 12, 23, 59), (2026, 9, 13, 12, 0),
|
|
462
|
+
(2026, 9, 21, 1, 1), (2026, 9, 22, 13, 30), (2026, 9, 23, 11, 59)):
|
|
463
|
+
when = calendar.timegm(stamp + (0,))
|
|
464
|
+
self.assertEqual(ar.parse_try_again(limit_msg(when)), when, limit_msg(when))
|
|
437
465
|
if os.path.exists("/usr/share/zoneinfo/America/New_York"):
|
|
438
466
|
with local_tz("America/New_York"): # EDT, UTC-4
|
|
439
467
|
self.assertEqual(ar.parse_try_again(LIMIT_MSG), 1790383140 + 4 * 3600)
|
|
@@ -1973,16 +2001,17 @@ class CodexWatcherTests(PoolCase):
|
|
|
1973
2001
|
return rc, w, r, path
|
|
1974
2002
|
|
|
1975
2003
|
def test_quota_rotates_with_resume_and_the_message_date(self):
|
|
2004
|
+
reset = future_reset(self.t0) # the message's date, read in the local (UTC) zone
|
|
2005
|
+
msg = limit_msg(reset)
|
|
1976
2006
|
with local_tz("UTC"):
|
|
1977
2007
|
rc, w, r, _ = self.run_codex(
|
|
1978
2008
|
["--yolo", "resume", CX_SID], [
|
|
1979
2009
|
cx_meta(self.launched - 7200, cwd=str(self.work)),
|
|
1980
|
-
cx_error(self.launched - 7000, "usage_limit_exceeded",
|
|
2010
|
+
cx_error(self.launched - 7000, "usage_limit_exceeded", msg),
|
|
1981
2011
|
cx_event(self.t0 - 2, {"type": "task_started"}),
|
|
1982
2012
|
cx_tokens(self.t0 - 1, limit_id="premium"),
|
|
1983
|
-
cx_error(self.t0 - 1, "usage_limit_exceeded",
|
|
2013
|
+
cx_error(self.t0 - 1, "usage_limit_exceeded", msg)])
|
|
1984
2014
|
self.assertEqual(rc, 0)
|
|
1985
|
-
reset = 1790383140 # Sep 26th, 2026 12:39 AM, read in the local (UTC) zone
|
|
1986
2015
|
prompt = ar.resume_prompt("quota")
|
|
1987
2016
|
probe = r.probes[0]
|
|
1988
2017
|
self.assertEqual(probe["argv"], [self.self_path, "resume", "--yolo", CX_SID, prompt])
|
|
@@ -2234,13 +2263,15 @@ class CliTests(PoolCase):
|
|
|
2234
2263
|
self.assertEqual(doc["events"][0]["kind"], "verdict") # no --since: everything counts
|
|
2235
2264
|
|
|
2236
2265
|
def test_classify_codex(self):
|
|
2266
|
+
reset = future_reset(self.t0)
|
|
2237
2267
|
path = self.tmp / "r.jsonl"
|
|
2238
2268
|
path.write_text(jl([cx_meta(self.t0), cx_tokens(self.t0, limit_id="premium"),
|
|
2239
|
-
cx_error(self.t0, {"usage_limit_exceeded": None},
|
|
2269
|
+
cx_error(self.t0, {"usage_limit_exceeded": None},
|
|
2270
|
+
limit_msg(reset))]))
|
|
2240
2271
|
doc = json.loads(self.cli("classify", "--provider", "codex", str(path),
|
|
2241
2272
|
env={"TZ": "UTC"}).stdout)
|
|
2242
2273
|
self.assertEqual(doc["pending"]["class"], "quota")
|
|
2243
|
-
self.assertEqual(doc["pending"]["reset"],
|
|
2274
|
+
self.assertEqual(doc["pending"]["reset"], reset)
|
|
2244
2275
|
self.assertNotIn("_message", doc["pending"])
|
|
2245
2276
|
self.assertNotIn("usage limit", json.dumps(doc))
|
|
2246
2277
|
|