claude-multiacc 2.0.12 → 2.0.14
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/bin/claude +107 -10
- package/bin/claude-accounts +83 -15
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.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/audit.py +21 -1
- package/lib/common.sh +9 -1
- package/package.json +1 -1
- package/tests/run-tests.sh +163 -1
package/bin/claude
CHANGED
|
@@ -280,6 +280,48 @@ sel_score_of() { # $1 = acct dir
|
|
|
280
280
|
printf '%s\n' $((w * 1000 + s))
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
# The .limited marker's own fields (line 1: reset epoch; line 2: "bucket=…
|
|
284
|
+
# percent=… … reason=…"). All tolerant: an unreadable or bare marker answers
|
|
285
|
+
# "unknown", never an error — these feed the all-limited fallback only.
|
|
286
|
+
limited_reset_of() { # $1 acct dir -> the marker's reset epoch, or 0 (unknown)
|
|
287
|
+
local m="$1/.limited" r=""
|
|
288
|
+
[ -f "$m" ] && { IFS= read -r r < "$m" 2>/dev/null || r=""; }
|
|
289
|
+
if num_ok "$r"; then printf '%s\n' "$r"; else printf '0\n'; fi
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
limited_percent_of() { # $1 acct dir -> the marked bucket's percent, or -1 (unknown)
|
|
293
|
+
local m="$1/.limited" line=""
|
|
294
|
+
[ -f "$m" ] && line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
295
|
+
case "$line" in
|
|
296
|
+
*percent=*) line="${line#*percent=}"; line="${line%% *}" ;;
|
|
297
|
+
*) line="" ;;
|
|
298
|
+
esac
|
|
299
|
+
if num_ok "$line"; then printf '%s\n' "$line"; else printf '%s\n' "-1"; fi
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
# Rejected RIGHT NOW, not merely near the threshold: the marked bucket is exhausted
|
|
303
|
+
# (100%), or the marker records a real client rejection (a 429 the server sent).
|
|
304
|
+
# A bucket at 90-99% still answers requests — the difference the all-limited
|
|
305
|
+
# fallback lives on, because "degraded service beats a hard failure" only holds
|
|
306
|
+
# for an account that can actually serve.
|
|
307
|
+
limited_hard_blocked() { # $1 acct dir
|
|
308
|
+
local m="$1/.limited" line="" p
|
|
309
|
+
if [ -f "$m" ]; then
|
|
310
|
+
line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
311
|
+
case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
|
|
312
|
+
p="$(limited_percent_of "$1")"
|
|
313
|
+
if [ "$p" -ge 0 ] 2>/dev/null; then
|
|
314
|
+
[ "$p" -ge 100 ]
|
|
315
|
+
return
|
|
316
|
+
fi
|
|
317
|
+
return 1
|
|
318
|
+
fi
|
|
319
|
+
# No marker (the over-threshold backstop put it in valid-but-not-eligible):
|
|
320
|
+
# fresh telemetry's peak decides; stale/unknown reads as still serving.
|
|
321
|
+
p="$(cutoff_field "$1" max_percent)" || return 1
|
|
322
|
+
[ "$p" -ge 100 ]
|
|
323
|
+
}
|
|
324
|
+
|
|
283
325
|
# Peak of ALL buckets (session included) — the EXCLUSION signal. Stale/unknown => 50.
|
|
284
326
|
util_of() {
|
|
285
327
|
local v
|
|
@@ -682,6 +724,30 @@ expired_marked() { # $1 = acct dir
|
|
|
682
724
|
local m="$1/.expired" mt f soft reason
|
|
683
725
|
[ -f "$m" ] || return 1
|
|
684
726
|
reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
727
|
+
if [ "$reason" = "setup-token-invalid" ]; then
|
|
728
|
+
# A TOKEN-scoped park: it says the portable token is dead and nothing about the
|
|
729
|
+
# login beside it. Only a NEW token heals it — a login refresh rewrites
|
|
730
|
+
# .credentials.json / the Keychain item every few hours and used to lift it, so the
|
|
731
|
+
# next session that had to use the token exported the dead one, got 401 and parked
|
|
732
|
+
# it again (2026-08-29). And it only EXCLUDES the account from a session that would
|
|
733
|
+
# authenticate by that token: one that can use a live login here (a GUI terminal,
|
|
734
|
+
# the runner's LaunchAgent) runs the account on the login, token park or not.
|
|
735
|
+
mt="$(file_mtime "$m")"
|
|
736
|
+
if [ -f "$1/server.token" ] && [ "$(file_mtime "$1/server.token")" -gt "$mt" ]; then
|
|
737
|
+
rm -f "$m" 2>/dev/null
|
|
738
|
+
return 1
|
|
739
|
+
fi
|
|
740
|
+
if has_oauth "$1" && ! creds_dead "$1"; then
|
|
741
|
+
return 1 # the login carries this session
|
|
742
|
+
fi
|
|
743
|
+
soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
744
|
+
case "$soft" in
|
|
745
|
+
''|*[!0-9]*) return 0 ;;
|
|
746
|
+
*) [ "$now" -lt "$soft" ] && return 0
|
|
747
|
+
rm -f "$m" 2>/dev/null
|
|
748
|
+
return 1 ;;
|
|
749
|
+
esac
|
|
750
|
+
fi
|
|
685
751
|
# CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
|
|
686
752
|
# evidence they were about. A POLICY park (org-blocked) is about the account, not the
|
|
687
753
|
# credential: refreshing its token does not re-enable Claude Code for it. Letting a
|
|
@@ -985,19 +1051,50 @@ if [ "${#eligible[@]}" -gt 0 ]; then
|
|
|
985
1051
|
pick_best "${eligible[@]}"
|
|
986
1052
|
fi
|
|
987
1053
|
else
|
|
988
|
-
# Every account is limit-marked: degraded service beats a hard failure (100% rule)
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1054
|
+
# Every account is limit-marked: degraded service beats a hard failure (100% rule) —
|
|
1055
|
+
# but not every limited account is equally dead. A bucket at 90-99% still answers;
|
|
1056
|
+
# one at 100% (or a real client 429) rejects every request until its reset. Ranking
|
|
1057
|
+
# the fallback on weekly headroom alone handed out a session-exhausted account
|
|
1058
|
+
# (weekly=7%, session=100%) over one still serving at weekly 99% — a guaranteed
|
|
1059
|
+
# rejection chosen over a working session (operator report, 2026-08-29).
|
|
1060
|
+
soft=()
|
|
1061
|
+
hard=()
|
|
1062
|
+
for d in "${valid[@]}"; do
|
|
1063
|
+
if limited_hard_blocked "$d"; then hard+=("$d"); else soft+=("$d"); fi
|
|
1064
|
+
done
|
|
1065
|
+
if [ "${#soft[@]}" -gt 0 ]; then
|
|
1066
|
+
assess_telemetry "${soft[@]}"
|
|
1067
|
+
[ "$degraded" = 1 ] && SEL_DEGRADED=1
|
|
1068
|
+
pick_best "${soft[@]}"
|
|
1069
|
+
else
|
|
1070
|
+
# Every account is exhausted RIGHT NOW: nothing serves, so hand out the one
|
|
1071
|
+
# that unblocks first — its rejection window is the shortest.
|
|
1072
|
+
assess_telemetry "${valid[@]}"
|
|
1073
|
+
[ "$degraded" = 1 ] && SEL_DEGRADED=1
|
|
1074
|
+
PICK_DIR=""
|
|
1075
|
+
best_reset=0
|
|
1076
|
+
for d in "${hard[@]}"; do
|
|
1077
|
+
r="$(limited_reset_of "$d")"
|
|
1078
|
+
if [ -z "$PICK_DIR" ]; then
|
|
1079
|
+
PICK_DIR="$d"; best_reset="$r"; continue
|
|
1080
|
+
fi
|
|
1081
|
+
if [ "$r" -gt 0 ] && { [ "$best_reset" -eq 0 ] || [ "$r" -lt "$best_reset" ]; }; then
|
|
1082
|
+
PICK_DIR="$d"; best_reset="$r"
|
|
1083
|
+
fi
|
|
1084
|
+
done
|
|
1085
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
|
|
1086
|
+
fi
|
|
992
1087
|
# Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
|
|
993
1088
|
# `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
|
|
994
1089
|
# reading only this event concluded the choice had no usage input at all.
|
|
995
|
-
if [ "$
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1090
|
+
if [ "${#soft[@]}" -gt 0 ]; then
|
|
1091
|
+
if [ "$degraded" = 1 ]; then
|
|
1092
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(stale_weekly "$PICK_DIR" || echo '?')% ranking=DEGRADED"
|
|
1093
|
+
elif [ "$blind" = 1 ]; then
|
|
1094
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=?% ranking=BLIND"
|
|
1095
|
+
else
|
|
1096
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
1097
|
+
fi
|
|
1001
1098
|
fi
|
|
1002
1099
|
fi
|
|
1003
1100
|
pick="$PICK_DIR"
|
package/bin/claude-accounts
CHANGED
|
@@ -507,7 +507,7 @@ cmd_add() {
|
|
|
507
507
|
if [ "$token" = "1" ]; then
|
|
508
508
|
# Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
|
|
509
509
|
echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
|
|
510
|
-
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
|
|
510
|
+
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
|
|
511
511
|
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
|
|
512
512
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
513
513
|
else
|
|
@@ -907,12 +907,17 @@ ceremony_token_check() { # $1 = token
|
|
|
907
907
|
# token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
|
|
908
908
|
# $2 = token, $3 = acct id (messages only).
|
|
909
909
|
commit_ceremony_token() {
|
|
910
|
-
local d="$1" tok="$2" id="$3" rc=0
|
|
910
|
+
local d="$1" tok="$2" id="$3" src="${4:-captured}" rc=0
|
|
911
911
|
ceremony_token_check "$tok" || rc=$?
|
|
912
|
-
# A short
|
|
912
|
+
# A short token is an incomplete one unless Claude itself has just answered with it:
|
|
913
913
|
# the length alone convicts it, so an inconclusive probe (429, network) must not let
|
|
914
914
|
# it through as merely "unverified" — that is the exact token the fleet then rejects.
|
|
915
|
+
# The cause named depends on where the token came from: a CAPTURED one was wrapped by
|
|
916
|
+
# the ceremony's terminal; a PASTED one was cut before it reached the clipboard.
|
|
915
917
|
if [ "$rc" -ne 0 ] && [ "${#tok}" -lt "$SETUP_TOKEN_FULL_LEN" ]; then
|
|
918
|
+
if [ "$src" = "pasted" ]; then
|
|
919
|
+
die "the pasted token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}) — it was cut before it reached the clipboard (a narrow terminal wraps the token when it is shown). Nothing saved for $id. Copy the WHOLE token and paste again."
|
|
920
|
+
fi
|
|
916
921
|
die "the captured token is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN (${CEREMONY_CHECK_DETAIL:-not proven by a real call}): the sign-in terminal wrapped it and only its first line was captured. Nothing saved for $id. Mint again from a terminal at least $CEREMONY_TTY_MIN_COLS columns wide, or from an updated panel."
|
|
917
922
|
fi
|
|
918
923
|
if [ "$rc" -eq 1 ]; then
|
|
@@ -929,9 +934,47 @@ commit_ceremony_token() {
|
|
|
929
934
|
fi
|
|
930
935
|
}
|
|
931
936
|
|
|
937
|
+
# When a ceremony ends without a token, the CLIENT said why — "Your account is on
|
|
938
|
+
# hold", a policy refusal, a subscription it could not find — and that sentence is
|
|
939
|
+
# the only thing that lets anyone fix it. It used to vanish with the capture file,
|
|
940
|
+
# leaving "no token captured" (operator, 2026-08-29). The last visible lines are
|
|
941
|
+
# kept (ANSI-stripped, spinners/logo/URL/prompt dropped, any sk-ant-… redacted) and
|
|
942
|
+
# the whole redacted transcript is written beside the pool for a closer look.
|
|
943
|
+
CEREMONY_LAST_WORDS=""
|
|
944
|
+
CEREMONY_TRANSCRIPT=""
|
|
945
|
+
ceremony_debrief() { # $1 = capture file, $2 = redacted transcript to write; prints the last words
|
|
946
|
+
"$PYBIN" - "$1" "$2" <<'PYEOF'
|
|
947
|
+
import os, re, sys
|
|
948
|
+
raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
|
|
949
|
+
txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
|
|
950
|
+
txt = re.sub(r'\x1b\[[0-9;?]*[A-Za-z]', '', txt) # CSI (colour, cursor)
|
|
951
|
+
txt = re.sub(r'\x1b[()][A-Z0-9]', '', txt).replace('\r', '\n')
|
|
952
|
+
txt = re.sub(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+', 'sk-ant-***', txt)
|
|
953
|
+
lines = []
|
|
954
|
+
for ln in txt.splitlines():
|
|
955
|
+
ln = ln.strip()
|
|
956
|
+
if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
|
|
957
|
+
continue
|
|
958
|
+
if 'https://' in ln or re.search(r'Paste\s*code\s*here', ln): # the sign-in link, the paste prompt
|
|
959
|
+
continue
|
|
960
|
+
if lines and lines[-1] == ln:
|
|
961
|
+
continue
|
|
962
|
+
lines.append(ln)
|
|
963
|
+
try:
|
|
964
|
+
fd = os.open(sys.argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
965
|
+
with os.fdopen(fd, 'w') as f:
|
|
966
|
+
f.write('\n'.join(lines) + '\n')
|
|
967
|
+
except OSError:
|
|
968
|
+
pass
|
|
969
|
+
print(' | '.join(lines[-3:])[:400])
|
|
970
|
+
PYEOF
|
|
971
|
+
}
|
|
972
|
+
|
|
932
973
|
CEREMONY_TOKEN=""
|
|
933
974
|
run_token_ceremony() { # $1 = config dir
|
|
934
975
|
CEREMONY_TOKEN=""
|
|
976
|
+
CEREMONY_LAST_WORDS=""
|
|
977
|
+
CEREMONY_TRANSCRIPT=""
|
|
935
978
|
local d="$1" real cap old_umask
|
|
936
979
|
real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
|
|
937
980
|
mkdir -p "$ACC_ROOT/tmp"
|
|
@@ -973,8 +1016,16 @@ TIP
|
|
|
973
1016
|
# frame rendered while the terminal was still narrow holds a wrapped fragment.
|
|
974
1017
|
CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" \
|
|
975
1018
|
| awk '{ if (length($0) > length(best)) best = $0 } END { if (best != "") print best }')"
|
|
1019
|
+
if [ -z "$CEREMONY_TOKEN" ]; then
|
|
1020
|
+
CEREMONY_TRANSCRIPT="$ACC_ROOT/tmp/mint-failed.$(date -u +%Y%m%dT%H%M%SZ).log"
|
|
1021
|
+
CEREMONY_LAST_WORDS="$(ceremony_debrief "$cap" "$CEREMONY_TRANSCRIPT" 2>/dev/null)"
|
|
1022
|
+
[ -s "$CEREMONY_TRANSCRIPT" ] || CEREMONY_TRANSCRIPT=""
|
|
1023
|
+
rm -f "$cap"
|
|
1024
|
+
[ -n "$CEREMONY_LAST_WORDS" ] && warn "the sign-in ended without a token — the client said: $CEREMONY_LAST_WORDS"
|
|
1025
|
+
[ -n "$CEREMONY_TRANSCRIPT" ] && warn "redacted transcript kept at $CEREMONY_TRANSCRIPT"
|
|
1026
|
+
return 1
|
|
1027
|
+
fi
|
|
976
1028
|
rm -f "$cap"
|
|
977
|
-
[ -n "$CEREMONY_TOKEN" ] || return 1
|
|
978
1029
|
valid_subscription_token "$CEREMONY_TOKEN" || {
|
|
979
1030
|
CEREMONY_TOKEN=""
|
|
980
1031
|
warn "captured credential is not a subscription setup-token"
|
|
@@ -1092,7 +1143,7 @@ cmd_mint() {
|
|
|
1092
1143
|
tok="$(printf '%s' "$tok" | tr -d '[:space:]')"
|
|
1093
1144
|
else
|
|
1094
1145
|
echo "Running 'claude setup-token' for $id — approve in a browser signed in as ${email:-THIS account}."
|
|
1095
|
-
run_token_ceremony "$d" || die "no token captured — mint failed"
|
|
1146
|
+
run_token_ceremony "$d" || die "no token captured — mint failed${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
|
|
1096
1147
|
tok="$CEREMONY_TOKEN"
|
|
1097
1148
|
fi
|
|
1098
1149
|
[ -n "$tok" ] || die "no token captured — mint failed"
|
|
@@ -1102,7 +1153,7 @@ cmd_mint() {
|
|
|
1102
1153
|
if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
1103
1154
|
die "that token authenticates as $got but $id is $email — nothing saved"
|
|
1104
1155
|
fi
|
|
1105
|
-
commit_ceremony_token "$d" "$tok" "$id"
|
|
1156
|
+
commit_ceremony_token "$d" "$tok" "$id" "$([ "$paste" = "1" ] && echo pasted || echo captured)"
|
|
1106
1157
|
clear_auth_markers "$d"
|
|
1107
1158
|
log_to ops.log "mint $id"
|
|
1108
1159
|
echo "Token saved to $d/server.token"
|
|
@@ -1134,7 +1185,7 @@ cmd_login() {
|
|
|
1134
1185
|
if [ "$token" = "1" ]; then
|
|
1135
1186
|
trap 'restore_ceremony_tty; exit 130' INT TERM
|
|
1136
1187
|
trap 'restore_ceremony_tty' EXIT
|
|
1137
|
-
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
|
|
1188
|
+
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed${CEREMONY_LAST_WORDS:+ — the client said: $CEREMONY_LAST_WORDS}${CEREMONY_TRANSCRIPT:+ (transcript: $CEREMONY_TRANSCRIPT)}"
|
|
1138
1189
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
1139
1190
|
if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
|
|
1140
1191
|
die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
|
|
@@ -1153,7 +1204,7 @@ cmd_login() {
|
|
|
1153
1204
|
if [ -z "$got" ] && [ "$force" != "1" ]; then
|
|
1154
1205
|
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
1155
1206
|
fi
|
|
1156
|
-
clear_auth_markers "$d"
|
|
1207
|
+
clear_auth_markers "$d" keep-token-park
|
|
1157
1208
|
if [ -s "$d/.credentials.json" ]; then
|
|
1158
1209
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
1159
1210
|
else
|
|
@@ -1518,12 +1569,20 @@ def mark_expired(d, slug, detail=''):
|
|
|
1518
1569
|
|
|
1519
1570
|
def clear_expired(d):
|
|
1520
1571
|
"""A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
|
|
1521
|
-
EXCEPT
|
|
1522
|
-
|
|
1523
|
-
|
|
1572
|
+
EXCEPT:
|
|
1573
|
+
- org-blocked: those accounts authenticate perfectly (telemetry works), they are
|
|
1574
|
+
just barred from Claude Code inference, so telemetry says nothing about them.
|
|
1575
|
+
- setup-token-invalid: the bearer behind a usage fetch or a refresh grant is the
|
|
1576
|
+
machine-local OAUTH login, and its success says nothing about the PORTABLE
|
|
1577
|
+
token a real inference rejected. On the one Mac holding both, this probe
|
|
1578
|
+
un-parked every dead-token account minutes after the shim parked it, and every
|
|
1579
|
+
fresh `claude` walked back into the same 401 all day (2026-08-29). Only a NEW
|
|
1580
|
+
token (the shim's newer-file rule) or a real call that used the token lifts it.
|
|
1581
|
+
Only a passing `verify` (a real call) or a re-login lifts the excepted ones."""
|
|
1524
1582
|
mpath = os.path.join(d, '.expired')
|
|
1525
1583
|
try:
|
|
1526
|
-
|
|
1584
|
+
body = open(mpath, errors='replace').read()
|
|
1585
|
+
if 'reason=org-blocked' in body or 'reason=setup-token-invalid' in body:
|
|
1527
1586
|
return False
|
|
1528
1587
|
except OSError:
|
|
1529
1588
|
return False
|
|
@@ -2306,11 +2365,20 @@ for acct in manifest.get('accounts', []):
|
|
|
2306
2365
|
dt = time.time() - t0
|
|
2307
2366
|
out = (r.stdout or '').strip()
|
|
2308
2367
|
if r.returncode == 0 and 'ok' in out.lower():
|
|
2309
|
-
# A real call succeeded: this account is definitively alive
|
|
2368
|
+
# A real call succeeded: this account is definitively alive — but WHICH
|
|
2369
|
+
# credential answered matters. A pass on the OAuth login says nothing about a
|
|
2370
|
+
# portable token a real call rejected; clearing that park here re-opened the
|
|
2371
|
+
# 401 loop the marker exists to stop (the shim re-picked the account, exported
|
|
2372
|
+
# the dead token, parked it again — 2026-08-29).
|
|
2310
2373
|
try:
|
|
2311
|
-
|
|
2374
|
+
marker = open(os.path.join(d, '.expired'), errors='replace').read()
|
|
2312
2375
|
except OSError:
|
|
2313
|
-
|
|
2376
|
+
marker = ''
|
|
2377
|
+
if uses_token or 'reason=setup-token-invalid' not in marker:
|
|
2378
|
+
try:
|
|
2379
|
+
os.remove(os.path.join(d, '.expired'))
|
|
2380
|
+
except OSError:
|
|
2381
|
+
pass
|
|
2314
2382
|
if uses_token:
|
|
2315
2383
|
try:
|
|
2316
2384
|
digest = hashlib.sha256(open(tpath, 'rb').read().strip()).hexdigest()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/audit.py
CHANGED
|
@@ -83,7 +83,16 @@ def expired_marked(d, now=None):
|
|
|
83
83
|
if not os.path.isfile(mpath):
|
|
84
84
|
return False
|
|
85
85
|
detail = marker_reason(mpath)
|
|
86
|
-
|
|
86
|
+
slug = marker_slug(detail)
|
|
87
|
+
if slug == 'setup-token-invalid':
|
|
88
|
+
# A TOKEN park heals only when a NEW token lands (bin/claude expired_marked):
|
|
89
|
+
# a login refresh rewriting .credentials.json or the Keychain item says
|
|
90
|
+
# nothing about the portable token.
|
|
91
|
+
mt = _mtime(mpath)
|
|
92
|
+
p = os.path.join(d, 'server.token')
|
|
93
|
+
if os.path.isfile(p) and _mtime(p) > mt:
|
|
94
|
+
return False
|
|
95
|
+
elif slug != 'org-blocked':
|
|
87
96
|
mt = _mtime(mpath)
|
|
88
97
|
for name in ('.credentials.json', 'server.token'):
|
|
89
98
|
p = os.path.join(d, name)
|
|
@@ -277,6 +286,17 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
277
286
|
row['reason'] = ('this account\'s organization has disabled Claude Code '
|
|
278
287
|
'subscription access')
|
|
279
288
|
elif slug == 'setup-token-invalid':
|
|
289
|
+
login = oauth_login(d, now)
|
|
290
|
+
if login['state'] == 'ok':
|
|
291
|
+
# A token park says nothing about the working login beside it: the
|
|
292
|
+
# shim runs this account on the login here (bin/claude
|
|
293
|
+
# expired_marked), so this machine reads it as usable — while the
|
|
294
|
+
# token stays unproven for the fleet (token_verified False).
|
|
295
|
+
row['store'] = login['store']
|
|
296
|
+
row['state'] = 'ok'
|
|
297
|
+
row['reason'] = (f"{login['reason']}; portable setup-token was rejected "
|
|
298
|
+
"here and is not used")
|
|
299
|
+
return done(row)
|
|
280
300
|
row['state'] = 'token-invalid'
|
|
281
301
|
row['reason'] = 'portable setup-token was rejected by a real inference'
|
|
282
302
|
else:
|
package/lib/common.sh
CHANGED
|
@@ -491,7 +491,15 @@ mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
|
|
|
491
491
|
# Called wherever fresh auth lands (login/add/mint, successful usage fetch): the
|
|
492
492
|
# account is provably alive again, so both the dead-auth marker and any refresh
|
|
493
493
|
# backoff must go, or it would stay parked until the next re-login.
|
|
494
|
-
clear_auth_markers() { # $1 = acct dir
|
|
494
|
+
clear_auth_markers() { # $1 = acct dir, $2 = "keep-token-park" after a LOGIN-only ceremony
|
|
495
|
+
# A fresh login proves the login; it says nothing about a portable token a real
|
|
496
|
+
# call rejected, so that park outlives it — only a new token (mint / login --token)
|
|
497
|
+
# or a real call through the token lifts it.
|
|
498
|
+
if [ "${2:-}" = "keep-token-park" ] \
|
|
499
|
+
&& grep -q 'reason=setup-token-invalid' "$1/.expired" 2>/dev/null; then
|
|
500
|
+
rm -f "$1/.oauth-refresh.json" 2>/dev/null || true
|
|
501
|
+
return 0
|
|
502
|
+
fi
|
|
495
503
|
rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
|
|
496
504
|
}
|
|
497
505
|
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -73,7 +73,7 @@ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
|
|
|
73
73
|
fi
|
|
74
74
|
if [ "${1:-}" = "setup-token" ]; then
|
|
75
75
|
echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
|
|
76
|
-
[ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
|
|
76
|
+
[ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "${FAKE_TOKEN_FAIL_MSG:-sign-in aborted}" >&2; exit 1; }
|
|
77
77
|
if [ -n "${FAKE_TOKEN_FULL:-}" ]; then
|
|
78
78
|
# The real client's shape: a 108-character token that the TUI HARD-WRAPS at the
|
|
79
79
|
# terminal's width — an unsized pty (0 columns) renders as 80, and a 79-character
|
|
@@ -436,6 +436,88 @@ grep -q "all-limited fallback=acct-02" "$ACC/selection.log" \
|
|
|
436
436
|
&& t_ok "fallback logged" || t_fail "fallback logged" "no all-limited line in selection.log"
|
|
437
437
|
rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
|
|
438
438
|
|
|
439
|
+
# ---- 9a2. the fallback tells "still serving" from "rejected right now" --------------
|
|
440
|
+
# acct-01: weekly at 99% — worse headroom, but still answering requests.
|
|
441
|
+
# acct-02: session at 100% — far better weekly (7%), but every request bounces until
|
|
442
|
+
# the reset. Ranking on headroom alone handed out the guaranteed rejection
|
|
443
|
+
# (operator report 2026-08-29: "claude keeps starting on an out-of-limits account").
|
|
444
|
+
printf '%s\nbucket=weekly_scoped:Fable percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
445
|
+
printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
|
|
446
|
+
printf '{"fetched_at":%s,"max_percent":99,"weekly_percent":99,"session_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
447
|
+
printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":7,"session_percent":100,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
448
|
+
out="$(claude 2>&1)"
|
|
449
|
+
check "a still-serving limited account beats an exhausted one with more headroom" "CFG=acct-01" "$out"
|
|
450
|
+
# A real client rejection (a 429 the server sent) is exhausted whatever percent says.
|
|
451
|
+
printf '%s\nbucket=five_hour marked_at=x reason=client-rate-limit\n' "$((now+600))" > "$ACC/acct-02/.limited"
|
|
452
|
+
out="$(claude 2>&1)"
|
|
453
|
+
check "a client-rejected account is not the fallback while another still serves" "CFG=acct-01" "$out"
|
|
454
|
+
# Every account exhausted RIGHT NOW: hand out the one that unblocks first.
|
|
455
|
+
printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+7200))" > "$ACC/acct-01/.limited"
|
|
456
|
+
printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
|
|
457
|
+
out="$(claude 2>&1)"
|
|
458
|
+
check "all exhausted: the soonest reset is handed out" "CFG=acct-02" "$out"
|
|
459
|
+
grep -q "all-exhausted resets_in=" "$ACC/selection.log" \
|
|
460
|
+
&& t_ok "the all-exhausted pick is logged with its reset" || t_fail "all-exhausted log" "no line"
|
|
461
|
+
rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
|
|
462
|
+
printf '{"fetched_at":%s,"max_percent":97,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
463
|
+
printf '{"fetched_at":%s,"max_percent":91,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
464
|
+
|
|
465
|
+
# ---- 9a3. a token park is credential-scoped ----------------------------------------
|
|
466
|
+
# A dead portable token beside a LIVE login: this session runs the account on the
|
|
467
|
+
# login — the park neither excludes it nor exports the token. Only a session that
|
|
468
|
+
# would have to use the token is kept away, and only a NEW token heals the park (a
|
|
469
|
+
# login refresh rewriting the credential must not — that rewrite happened every few
|
|
470
|
+
# hours and re-opened the 401 loop, 2026-08-29).
|
|
471
|
+
rm -rf "$WORK/bak01"; mkdir -p "$WORK/bak01"
|
|
472
|
+
for f in server.token .credentials.json .expired .server-token-verified limits.json; do
|
|
473
|
+
[ -e "$ACC/acct-01/$f" ] && cp -p "$ACC/acct-01/$f" "$WORK/bak01/$f"
|
|
474
|
+
done
|
|
475
|
+
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited" # acct-01 is the only free one
|
|
476
|
+
printf '{"fetched_at":%s,"max_percent":10,"weekly_percent":10,"session_percent":5,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
477
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
478
|
+
printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
|
|
479
|
+
touch -t 202001010000 "$ACC/acct-01/server.token" # older than the park
|
|
480
|
+
rm -f "$ACC/acct-01/.server-token-verified"
|
|
481
|
+
printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
|
|
482
|
+
out="$(claude 2>&1)"
|
|
483
|
+
check "a live login runs the account despite a token park" "CFG=acct-01" "$out"
|
|
484
|
+
check "the parked token is not exported beside a live login" "TOK=none" "$out"
|
|
485
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "the token park stays on record for token-only sessions" \
|
|
486
|
+
|| t_fail "token park record" "marker removed"
|
|
487
|
+
out="$(claude-accounts list --json 2>/dev/null | python3 -c '
|
|
488
|
+
import json, sys
|
|
489
|
+
rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
|
|
490
|
+
print(rows["acct-01"]["status"], rows["acct-01"].get("token_verified"))')"
|
|
491
|
+
[ "$out" = "active False" ] && t_ok "the audit reads a live login beside a parked token as usable, token unproven" \
|
|
492
|
+
|| t_fail "audit token park" "expected 'active False', got '$out'"
|
|
493
|
+
# The login dies: now the token would carry the session, and the park excludes it.
|
|
494
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
495
|
+
touch -t 202101010000 "$ACC/acct-01/.expired" # the credential above is NEWER
|
|
496
|
+
rm -f "$ACC/acct-02/.limited"
|
|
497
|
+
out="$(claude 2>&1)"
|
|
498
|
+
check "a dead login makes the token park bite" "CFG=acct-02" "$out"
|
|
499
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "a newer credential does not heal a token park" \
|
|
500
|
+
|| t_fail "credential heal" "the login refresh lifted the token park"
|
|
501
|
+
out="$(claude-accounts list --json 2>/dev/null | python3 -c '
|
|
502
|
+
import json, sys
|
|
503
|
+
rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
|
|
504
|
+
print(rows["acct-01"]["status"])')"
|
|
505
|
+
[ "$out" = "token-invalid" ] && t_ok "the audit reads a parked token beside a dead login as token-invalid" \
|
|
506
|
+
|| t_fail "audit token-invalid" "got '$out'"
|
|
507
|
+
# A NEW token heals it — and the healed account is selected again, on the token.
|
|
508
|
+
printf 'sk-ant-oat01-%s' "$(printf '%95s' '' | tr ' ' N)" > "$ACC/acct-01/server.token"
|
|
509
|
+
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
|
|
510
|
+
out="$(claude 2>&1)"
|
|
511
|
+
check "a new token heals the park" "CFG=acct-01" "$out"
|
|
512
|
+
check "the healed account runs on its new token" "TOK=sk-ant-oat01-NNN" "$out"
|
|
513
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "the healed park is gone" || t_fail "healed park" "marker still present"
|
|
514
|
+
# Hand acct-01 back exactly as it was.
|
|
515
|
+
rm -f "$ACC/acct-02/.limited" "$ACC/acct-01/server.token" "$ACC/acct-01/.credentials.json" \
|
|
516
|
+
"$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified" "$ACC/acct-01/limits.json"
|
|
517
|
+
for f in server.token .credentials.json .expired .server-token-verified limits.json; do
|
|
518
|
+
[ -e "$WORK/bak01/$f" ] && cp -p "$WORK/bak01/$f" "$ACC/acct-01/$f"
|
|
519
|
+
done
|
|
520
|
+
|
|
439
521
|
# ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
|
|
440
522
|
# empty .credentials.json must NOT count as auth (interrupted write)
|
|
441
523
|
mkdir -p "$ACC/acct-06"
|
|
@@ -1333,6 +1415,24 @@ check "the inconclusive save says so" "saved as UNVERIFIED" "$out"
|
|
|
1333
1415
|
[ ! -f "$ACC/acct-04/.server-token-verified" ] && t_ok "an unproven save records no proof" \
|
|
1334
1416
|
|| t_fail "unproven proof marker" "marker present"
|
|
1335
1417
|
printf '%s' "$orig" > "$ACC/acct-04/server.token"
|
|
1418
|
+
# The client ended the ceremony without a token and said why: that sentence must
|
|
1419
|
+
# reach the operator (the panel shows the mint's own error line), and the redacted
|
|
1420
|
+
# transcript must be kept for a closer look — "no token captured" alone left an
|
|
1421
|
+
# operator with nothing to act on (2026-08-29).
|
|
1422
|
+
out="$(FAKE_TOKEN_FAIL=1 FAKE_TOKEN_FAIL_MSG="Your account is on hold. You can close this window." claude-accounts mint acct-04 2>&1 </dev/null)"
|
|
1423
|
+
rc=$?
|
|
1424
|
+
[ "$rc" != "0" ] && t_ok "a ceremony that ends without a token fails the mint" || t_fail "no-token mint rc" "rc=0"
|
|
1425
|
+
check "the mint error carries the client's last words" "the client said: Your account is on hold" "$out"
|
|
1426
|
+
check "the mint error names the kept transcript" "transcript: $ACC/tmp/mint-failed." "$out"
|
|
1427
|
+
tr="$(printf '%s' "$out" | sed -n 's/.*transcript: \([^)]*\)).*/\1/p' | head -1)"
|
|
1428
|
+
[ -n "$tr" ] && [ -s "$tr" ] && t_ok "the redacted transcript exists" || t_fail "transcript file" "missing: '$tr'"
|
|
1429
|
+
grep -q 'Your account is on hold' "$tr" 2>/dev/null && t_ok "the transcript holds the client's words" \
|
|
1430
|
+
|| t_fail "transcript content" "message missing"
|
|
1431
|
+
# (python, not stat: GNU stat reads -f as "file system" and answers something else)
|
|
1432
|
+
[ "$(python3 -c 'import os, sys; print(oct(os.stat(sys.argv[1]).st_mode & 0o777))' "$tr")" = "0o600" ] \
|
|
1433
|
+
&& t_ok "the transcript is private" || t_fail "transcript mode" "not 0600"
|
|
1434
|
+
[ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a ceremony without a token saves nothing" \
|
|
1435
|
+
|| t_fail "no-token save" "server.token was overwritten"
|
|
1336
1436
|
# A pasted token gets the same proof: a revoked one is refused, not saved.
|
|
1337
1437
|
# (A complete-length token, so the verdict is the rejection itself and not the
|
|
1338
1438
|
# length rule that catches wrapped fragments first.)
|
|
@@ -1342,6 +1442,18 @@ rc=$?
|
|
|
1342
1442
|
check "the paste refusal says Claude rejected it" "Claude rejected the captured token" "$out"
|
|
1343
1443
|
[ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a rejected paste saves nothing" \
|
|
1344
1444
|
|| t_fail "rejected paste" "server.token was overwritten"
|
|
1445
|
+
# A SHORT rejected paste blames the paste — not a ceremony terminal this command
|
|
1446
|
+
# never opened.
|
|
1447
|
+
out="$(printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' | claude-accounts mint acct-04 --paste 2>&1)"
|
|
1448
|
+
rc=$?
|
|
1449
|
+
[ "$rc" != "0" ] && t_ok "a short rejected paste is refused" || t_fail "short paste rc" "rc=0"
|
|
1450
|
+
check "the short-paste refusal blames the paste" "cut before it reached the clipboard" "$out"
|
|
1451
|
+
case "$out" in
|
|
1452
|
+
*"sign-in terminal wrapped it"*) t_fail "short paste must not blame the ceremony terminal" "wrong provenance" ;;
|
|
1453
|
+
*) t_ok "short paste must not blame the ceremony terminal" ;;
|
|
1454
|
+
esac
|
|
1455
|
+
[ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a short rejected paste saves nothing" \
|
|
1456
|
+
|| t_fail "short paste" "server.token was overwritten"
|
|
1345
1457
|
|
|
1346
1458
|
# ---- 15c1b. the ceremony widens an unsized pty, so the whole token is captured ------
|
|
1347
1459
|
# The panel drives the ceremony over a pty it never sized (0x0 -> the TUI renders 80
|
|
@@ -1438,9 +1550,15 @@ check "wrong-account sign-in skips (already added)" "a@test is already added as
|
|
|
1438
1550
|
|
|
1439
1551
|
# ---- 15f. login command completes auth for an existing auth-less account -------------
|
|
1440
1552
|
claude-accounts import pending@test --id acct-08 --no-sync >/dev/null 2>&1
|
|
1553
|
+
# A parked (dead) token beside the account: a LOGIN-only ceremony must not lift that
|
|
1554
|
+
# park — it proves the login, not the token.
|
|
1555
|
+
printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-08/server.token"
|
|
1556
|
+
printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-08/.expired"
|
|
1441
1557
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 2>&1)"
|
|
1442
1558
|
check "login completes existing account (full login)" "acct-08 login saved" "$out"
|
|
1443
1559
|
[ -f "$ACC/acct-08/.credentials.json" ] && t_ok "login writes .credentials.json" || t_fail "login creds" "missing"
|
|
1560
|
+
[ -f "$ACC/acct-08/.expired" ] && t_ok "a login-only ceremony keeps the token park" \
|
|
1561
|
+
|| t_fail "login keeps token park" "marker removed"
|
|
1444
1562
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=other@test claude-accounts login acct-08 2>&1)"
|
|
1445
1563
|
rc=$?
|
|
1446
1564
|
check "login email mismatch refused" "nothing saved" "$out"
|
|
@@ -1449,6 +1567,7 @@ check "login email mismatch refused" "nothing saved" "$out"
|
|
|
1449
1567
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=pending@test claude-accounts login acct-08 --token 2>&1)"
|
|
1450
1568
|
check "login --token saves a portable token" "acct-08 token saved" "$out"
|
|
1451
1569
|
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
|
|
1570
|
+
[ ! -f "$ACC/acct-08/.expired" ] && t_ok "a new token lifts the token park" || t_fail "token lifts park" "marker still present"
|
|
1452
1571
|
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
1453
1572
|
|
|
1454
1573
|
# ---- 15f2. expired: the re-login worklist, and relogin fixes it ----------------------
|
|
@@ -2645,6 +2764,49 @@ out="$(claude-accounts verify 2>&1)"
|
|
|
2645
2764
|
check "verify calls a rejected setup-token what it is" "portable setup-token is invalid" "$out"
|
|
2646
2765
|
check "verify recommends replacing a rejected setup-token" \
|
|
2647
2766
|
"claude-accounts login acct-01 --token" "$out"
|
|
2767
|
+
|
|
2768
|
+
# ---- 17c. a login-proven pass never lifts a TOKEN park -------------------------------
|
|
2769
|
+
# One Mac can hold both credentials: a working OAuth login beside a dead portable
|
|
2770
|
+
# token. The limits probe's bearer is the LOGIN, and its success used to clear the
|
|
2771
|
+
# token's park ("dead-auth marker cleared") — the next selection exported the dead
|
|
2772
|
+
# token, got 401, parked it again, all day (2026-08-29).
|
|
2773
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
2774
|
+
printf 'sk-ant-oat01-REVOKED%s' "$(printf '%88s' '' | tr ' ' R)" > "$ACC/acct-01/server.token"
|
|
2775
|
+
printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
|
|
2776
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
2777
|
+
case "$out" in
|
|
2778
|
+
*"acct-01: dead-auth marker cleared"*) t_fail "limits probe must not lift a token park" "the login's success cleared the token's marker" ;;
|
|
2779
|
+
*) t_ok "limits probe must not lift a token park" ;;
|
|
2780
|
+
esac
|
|
2781
|
+
[ -f "$ACC/acct-01/.expired" ] && grep -q 'setup-token-invalid' "$ACC/acct-01/.expired" \
|
|
2782
|
+
&& t_ok "the token park survives a login-proven probe" || t_fail "token park survival" ".expired gone"
|
|
2783
|
+
# ...but an ordinary login park IS lifted — a working bearer proves exactly that.
|
|
2784
|
+
printf '%s\nreason=auth-error marked_at=x detail=a real call came back not-authenticated\n' "$now" > "$ACC/acct-01/.expired"
|
|
2785
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
2786
|
+
check "an auth-error park is still lifted by a working bearer" "dead-auth marker cleared" "$out"
|
|
2787
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "the login park was lifted" || t_fail "login park" "still present"
|
|
2788
|
+
# verify: a PASS on the LOGIN keeps the token park; a PASS on the TOKEN lifts it.
|
|
2789
|
+
printf '%s\nreason=setup-token-invalid marked_at=x detail=portable OAuth token rejected\n' "$now" > "$ACC/acct-01/.expired"
|
|
2790
|
+
out="$(claude-accounts verify 2>&1)"
|
|
2791
|
+
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify passes on the login" ;; \
|
|
2792
|
+
*) t_fail "verify login pass" "$(printf '%s' "$out" | grep acct-01 | head -1)" ;; esac
|
|
2793
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "a login pass does not lift the token park" \
|
|
2794
|
+
|| t_fail "verify token park" ".expired gone"
|
|
2795
|
+
# A dead login beside a fresh valid token: the pass rides the TOKEN and lifts its park.
|
|
2796
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
2797
|
+
printf 'sk-ant-oat01-%s' "$(printf '%95s' '' | tr ' ' G)" > "$ACC/acct-01/server.token"
|
|
2798
|
+
rm -f "$ACC/acct-01/.server-token-verified"
|
|
2799
|
+
out="$(claude-accounts verify 2>&1)"
|
|
2800
|
+
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify passes on the token" ;; \
|
|
2801
|
+
*) t_fail "verify token pass" "$(printf '%s' "$out" | grep acct-01 | head -1)" ;; esac
|
|
2802
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a token pass lifts the token park" \
|
|
2803
|
+
|| t_fail "token pass park" "still present"
|
|
2804
|
+
# Hand back the state the next checks expect: a dead login beside a REVOKED token,
|
|
2805
|
+
# parked and unproven — exactly where 17b left acct-01.
|
|
2806
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
2807
|
+
printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' > "$ACC/acct-01/server.token"
|
|
2808
|
+
rm -f "$ACC/acct-01/.server-token-verified"
|
|
2809
|
+
printf '%s\nreason=setup-token-invalid marked_at=x detail=portable setup-token failed a real inference\n' "$now" > "$ACC/acct-01/.expired"
|
|
2648
2810
|
out="$(claude-accounts expired 2>&1)"
|
|
2649
2811
|
check "verify marker retains setup-token classification" "TOKEN INVALID" "$out"
|
|
2650
2812
|
check "verify marker retains setup-token recovery" \
|