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.
Files changed (44) hide show
  1. package/README.md +20 -7
  2. package/bin/claude +66 -19
  3. package/bin/claude-accounts +186 -54
  4. package/bin/codex +4 -3
  5. package/bin/codex-accounts +18 -8
  6. package/docs/ACCOUNT_OPERATIONS.md +5 -1
  7. package/docs/VERIFICATION.md +8 -0
  8. package/docs/claude-token-identity.md +147 -0
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  20. package/lib/audit.py +42 -4
  21. package/lib/claude_token_identity.py +233 -0
  22. package/lib/codex_audit.py +8 -1
  23. package/lib/common.sh +3 -1
  24. package/lib/credential.py +9 -2
  25. package/package.json +1 -1
  26. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  38. package/tests/run-tests.sh +254 -3
  39. package/tests/test_autoresume.py +36 -5
  40. package/tests/test_claude_token_identity.py +259 -0
  41. package/tests/test_token_identity_ceremony.py +167 -0
  42. package/tests/test_token_identity_integration.py +207 -0
  43. package/tests/test_token_identity_retry.py +95 -0
  44. package/tests/test_token_identity_sync.py +139 -0
@@ -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 "*) echo "OK"; exit 0 ;;
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 it is about to bind a token to (the only guard there is).
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 warns that a setup token carries no identity" "carries no identity" "$out"
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"
@@ -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", LIMIT_MSG),
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", LIMIT_MSG)])
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}, LIMIT_MSG)]))
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"], 1790383140)
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