claude-multiacc 2.0.15 → 2.0.17
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 +159 -3
- package/bin/claude-accounts +9 -60
- 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/ceremony.py +193 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +88 -3
package/bin/claude
CHANGED
|
@@ -160,8 +160,119 @@ sel_log() {
|
|
|
160
160
|
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
# ---- model-scoped limits ------------------------------------------------------
|
|
164
|
+
# A usage bucket can be scoped to ONE MODEL ("weekly_scoped:Fable"), and such a bucket
|
|
165
|
+
# says nothing about the account's ability to serve a DIFFERENT model. The limits
|
|
166
|
+
# refresher parks the whole account for it all the same, and the >=90% cutoff reads the
|
|
167
|
+
# peak across every bucket — so on 2026-08-29 acct-02/06/07 sat at Fable 100% with their
|
|
168
|
+
# session buckets at 51/23/25%, were excluded from every selection, and a pool of eleven
|
|
169
|
+
# accounts offered nothing usable: the all-limited fallback handed out a
|
|
170
|
+
# session-exhausted account that rejected the operator's very first request. The retry
|
|
171
|
+
# layer already knew a scoped limit means "switch model, not account" (MODEL_LIMITPAT
|
|
172
|
+
# below); selection has to know it too.
|
|
173
|
+
FALLBACK_MODEL="${CLAUDE_MULTIACC_FALLBACK_MODEL:-claude-opus-5}"
|
|
174
|
+
|
|
175
|
+
# The model THIS invocation asks for ("" when it pins none), read from the caller's own
|
|
176
|
+
# argv: an explicit --model is never second-guessed, so an account whose Fable bucket is
|
|
177
|
+
# full stays genuinely unusable for `--model claude-fable-5`.
|
|
178
|
+
RUN_MODEL=""
|
|
179
|
+
_sel_next_is_model=0
|
|
180
|
+
for _sel_arg in "$@"; do
|
|
181
|
+
if [ "$_sel_next_is_model" = 1 ]; then
|
|
182
|
+
RUN_MODEL="$_sel_arg"; _sel_next_is_model=0; continue
|
|
183
|
+
fi
|
|
184
|
+
case "$_sel_arg" in
|
|
185
|
+
--model) _sel_next_is_model=1 ;;
|
|
186
|
+
--model=*) RUN_MODEL="${_sel_arg#--model=}" ;;
|
|
187
|
+
esac
|
|
188
|
+
done
|
|
189
|
+
unset _sel_arg _sel_next_is_model
|
|
190
|
+
|
|
191
|
+
sel_lc() { printf '%s' "${1:-}" | LC_ALL=C tr 'A-Z' 'a-z'; }
|
|
192
|
+
|
|
193
|
+
# The model token a `.limited` marker is scoped to ("Fable"); empty when the marker is
|
|
194
|
+
# not model-scoped (session, weekly_all, client:…).
|
|
195
|
+
scoped_tok_of_marker() { # $1 = acct dir
|
|
196
|
+
local line=""
|
|
197
|
+
[ -f "$1/.limited" ] && line="$(sed -n 2p "$1/.limited" 2>/dev/null)"
|
|
198
|
+
case "$line" in
|
|
199
|
+
*bucket=weekly_scoped:*) line="${line#*bucket=weekly_scoped:}"; printf '%s' "${line%% *}" ;;
|
|
200
|
+
esac
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Does a bucket scoped to model token $1 constrain THIS run?
|
|
204
|
+
# pinned to that model -> yes; the caller asked for exactly it
|
|
205
|
+
# pinned to another model -> no
|
|
206
|
+
# unpinned -> no, PROVIDED the fallback model escapes the bucket. The
|
|
207
|
+
# pick path then pins that fallback before exec, so an
|
|
208
|
+
# unpinned run never walks into the exhausted model.
|
|
209
|
+
scoped_blocks_run() { # $1 = model token
|
|
210
|
+
local tok run fb
|
|
211
|
+
tok="$(sel_lc "$1")"
|
|
212
|
+
[ -n "$tok" ] || return 1
|
|
213
|
+
run="$(sel_lc "${RUN_MODEL:-}")"
|
|
214
|
+
if [ -n "$run" ]; then
|
|
215
|
+
case "$run" in *"$tok"*) return 0 ;; *) return 1 ;; esac
|
|
216
|
+
fi
|
|
217
|
+
fb="$(sel_lc "$FALLBACK_MODEL")"
|
|
218
|
+
case "$fb" in *"$tok"*) return 0 ;; esac # nothing left to switch to
|
|
219
|
+
return 1
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Peak percent over the buckets that constrain THIS run — scoped buckets belonging to
|
|
223
|
+
# other models are skipped. Prints nothing when the reading predates per-bucket
|
|
224
|
+
# telemetry, so the caller falls back to the flat max_percent it used to trust.
|
|
225
|
+
applicable_peak() { # $1 = acct dir
|
|
226
|
+
local f="$1/limits.json"
|
|
227
|
+
[ -f "$f" ] || return 1
|
|
228
|
+
LC_ALL=C tr '{},' '\n\n\n' < "$f" 2>/dev/null \
|
|
229
|
+
| LC_ALL=C awk -v run="$(sel_lc "${RUN_MODEL:-}")" -v fb="$(sel_lc "$FALLBACK_MODEL")" '
|
|
230
|
+
function applies(name, tok) {
|
|
231
|
+
if (index(name, "weekly_scoped:") != 1) return 1
|
|
232
|
+
tok = substr(name, 15)
|
|
233
|
+
if (run != "") return index(run, tok) > 0
|
|
234
|
+
return index(fb, tok) > 0
|
|
235
|
+
}
|
|
236
|
+
/"name"[[:space:]]*:/ {
|
|
237
|
+
n = $0
|
|
238
|
+
sub(/.*"name"[[:space:]]*:[[:space:]]*"/, "", n); sub(/".*/, "", n)
|
|
239
|
+
name = tolower(n); next
|
|
240
|
+
}
|
|
241
|
+
/"percent"[[:space:]]*:/ {
|
|
242
|
+
p = $0
|
|
243
|
+
sub(/.*"percent"[[:space:]]*:[[:space:]]*/, "", p); sub(/[^0-9].*/, "", p)
|
|
244
|
+
if (p != "") { seen = 1; if (applies(name) && p + 0 > best) best = p + 0 }
|
|
245
|
+
next
|
|
246
|
+
}
|
|
247
|
+
END { if (seen) print best + 0 }
|
|
248
|
+
'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# The model token of a scoped bucket that is itself at/over the threshold ("" when
|
|
252
|
+
# none) — what the pick path needs to know to pin the fallback for an unpinned run.
|
|
253
|
+
scoped_over_tok() { # $1 = acct dir
|
|
254
|
+
local f="$1/limits.json"
|
|
255
|
+
[ -f "$f" ] || return 0
|
|
256
|
+
LC_ALL=C tr '{},' '\n\n\n' < "$f" 2>/dev/null \
|
|
257
|
+
| LC_ALL=C awk -v thr="${CLAUDE_MULTIACC_THRESHOLD:-90}" '
|
|
258
|
+
/"name"[[:space:]]*:/ {
|
|
259
|
+
n = $0
|
|
260
|
+
sub(/.*"name"[[:space:]]*:[[:space:]]*"/, "", n); sub(/".*/, "", n)
|
|
261
|
+
name = n; next
|
|
262
|
+
}
|
|
263
|
+
/"percent"[[:space:]]*:/ {
|
|
264
|
+
p = $0
|
|
265
|
+
sub(/.*"percent"[[:space:]]*:[[:space:]]*/, "", p); sub(/[^0-9].*/, "", p)
|
|
266
|
+
if (p != "" && p + 0 >= thr + 0 && index(tolower(name), "weekly_scoped:") == 1) {
|
|
267
|
+
print substr(name, 15); exit
|
|
268
|
+
}
|
|
269
|
+
next
|
|
270
|
+
}
|
|
271
|
+
'
|
|
272
|
+
}
|
|
273
|
+
|
|
163
274
|
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
164
|
-
local m="$1/.limited" reset=""
|
|
275
|
+
local m="$1/.limited" reset="" tok
|
|
165
276
|
[ -f "$m" ] || return 1
|
|
166
277
|
IFS= read -r reset < "$m" 2>/dev/null || reset=""
|
|
167
278
|
if ! num_ok "$reset"; then
|
|
@@ -174,6 +285,11 @@ marker_active() { # true if $1/.limited is still in force; clears cleanly-expire
|
|
|
174
285
|
rm -f "$m" 2>/dev/null
|
|
175
286
|
return 1
|
|
176
287
|
fi
|
|
288
|
+
# A park scoped to ONE model does not stop a run that will use another one.
|
|
289
|
+
tok="$(scoped_tok_of_marker "$1")"
|
|
290
|
+
if [ -n "$tok" ] && ! scoped_blocks_run "$tok"; then
|
|
291
|
+
return 1
|
|
292
|
+
fi
|
|
177
293
|
return 0
|
|
178
294
|
}
|
|
179
295
|
|
|
@@ -305,10 +421,15 @@ limited_percent_of() { # $1 acct dir -> the marked bucket's percent, or -1 (unkn
|
|
|
305
421
|
# fallback lives on, because "degraded service beats a hard failure" only holds
|
|
306
422
|
# for an account that can actually serve.
|
|
307
423
|
limited_hard_blocked() { # $1 acct dir
|
|
308
|
-
local m="$1/.limited" line="" p
|
|
424
|
+
local m="$1/.limited" line="" p tok
|
|
309
425
|
if [ -f "$m" ]; then
|
|
310
426
|
line="$(sed -n 2p "$m" 2>/dev/null)"
|
|
311
427
|
case "$line" in *reason=client-rate-limit*|*reason=error-cooldown*) return 0 ;; esac
|
|
428
|
+
# Exhausted for ONE model is not exhausted for the model this run will use.
|
|
429
|
+
tok="$(scoped_tok_of_marker "$1")"
|
|
430
|
+
if [ -n "$tok" ] && ! scoped_blocks_run "$tok"; then
|
|
431
|
+
return 1
|
|
432
|
+
fi
|
|
312
433
|
p="$(limited_percent_of "$1")"
|
|
313
434
|
if [ "$p" -ge 0 ] 2>/dev/null; then
|
|
314
435
|
[ "$p" -ge 100 ]
|
|
@@ -335,7 +456,12 @@ util_of() {
|
|
|
335
456
|
# (fail open — telemetry must never invent exclusions).
|
|
336
457
|
over_threshold() { # $1 = acct dir
|
|
337
458
|
local v
|
|
338
|
-
|
|
459
|
+
within_window "$1" "$EXCLUDE_STALE_AFTER" || return 1
|
|
460
|
+
# Buckets that constrain THIS run; a reading with no bucket list falls back to the
|
|
461
|
+
# flat peak, which is what this check always used.
|
|
462
|
+
v="$(applicable_peak "$1")"
|
|
463
|
+
[ -n "$v" ] || v="$(cutoff_field "$1" max_percent)" || return 1
|
|
464
|
+
num_ok "$v" || return 1
|
|
339
465
|
[ "$v" -ge "${CLAUDE_MULTIACC_THRESHOLD:-90}" ]
|
|
340
466
|
}
|
|
341
467
|
|
|
@@ -1083,6 +1209,22 @@ else
|
|
|
1083
1209
|
fi
|
|
1084
1210
|
done
|
|
1085
1211
|
sel_log "all-limited fallback=$(basename "$PICK_DIR") all-exhausted resets_in=$((best_reset > now ? best_reset - now : 0))s"
|
|
1212
|
+
# Every candidate rejects right now, so this pick WILL fail: say so on a terminal
|
|
1213
|
+
# instead of letting the operator read the client's bare limit error as a bad
|
|
1214
|
+
# choice by the pool (2026-08-29). Throttled, and never on a service's stderr.
|
|
1215
|
+
if [ -t 2 ]; then
|
|
1216
|
+
exn="$ACC_ROOT/.exhausted-notice"
|
|
1217
|
+
exlast=0
|
|
1218
|
+
[ -f "$exn" ] && exlast="$(file_mtime "$exn")"
|
|
1219
|
+
if [ $((now - exlast)) -gt 600 ]; then
|
|
1220
|
+
: 2>/dev/null > "$exn" || true
|
|
1221
|
+
exextra=""
|
|
1222
|
+
[ "${#expired[@]}" -gt 0 ] \
|
|
1223
|
+
&& exextra=" ${#expired[@]} further account(s) are unusable — see: claude-accounts expired."
|
|
1224
|
+
printf 'claude-multiacc: every usable account is at a limit right now; %s frees up in %ds and was chosen for that.%s\n' \
|
|
1225
|
+
"$(basename "$PICK_DIR")" "$((best_reset > now ? best_reset - now : 0))" "$exextra" >&2
|
|
1226
|
+
fi
|
|
1227
|
+
fi
|
|
1086
1228
|
fi
|
|
1087
1229
|
# Report the number this fallback ACTUALLY ranked on. Asking fresh_field here printed
|
|
1088
1230
|
# `weekly=?%` even when the pick was made on a perfectly good stale reading, so anyone
|
|
@@ -1113,6 +1255,20 @@ if [ -n "$tok" ] && ! token_preflight "$pick" "$tok"; then
|
|
|
1113
1255
|
fi
|
|
1114
1256
|
fi
|
|
1115
1257
|
unset CLAUDE_MULTIACC_PREFLIGHT_DEPTH
|
|
1258
|
+
|
|
1259
|
+
# The picked account may be eligible only because its exhausted bucket belongs to a
|
|
1260
|
+
# model this run did not ask for. Handing it the default model anyway would open the
|
|
1261
|
+
# session on "You've reached your Fable 5 limit" — the exact failure this selection
|
|
1262
|
+
# path exists to avoid (operator, tmux 136) — so the fallback model is pinned once,
|
|
1263
|
+
# before exec. An explicit --model is never touched: such a run does not get here.
|
|
1264
|
+
if [ -z "$RUN_MODEL" ]; then
|
|
1265
|
+
sel_tok="$(scoped_tok_of_marker "$pick")"
|
|
1266
|
+
[ -n "$sel_tok" ] || sel_tok="$(scoped_over_tok "$pick")"
|
|
1267
|
+
if [ -n "$sel_tok" ] && ! scoped_blocks_run "$sel_tok"; then
|
|
1268
|
+
set -- --model "$FALLBACK_MODEL" "$@"
|
|
1269
|
+
sel_log "$(basename "$pick") -> --model $FALLBACK_MODEL (its $sel_tok bucket is full)"
|
|
1270
|
+
fi
|
|
1271
|
+
fi
|
|
1116
1272
|
# Remember the pick so the NEXT run does not hand back the same account. An explicit
|
|
1117
1273
|
# CLAUDE_ACCOUNT pin deliberately does not: a pin is a caller overriding selection,
|
|
1118
1274
|
# not a turn in the rotation.
|
package/bin/claude-accounts
CHANGED
|
@@ -943,69 +943,18 @@ commit_ceremony_token() {
|
|
|
943
943
|
CEREMONY_LAST_WORDS=""
|
|
944
944
|
CEREMONY_TRANSCRIPT=""
|
|
945
945
|
ceremony_debrief() { # $1 = capture file, $2 = redacted transcript to write; prints the last words
|
|
946
|
-
"$PYBIN"
|
|
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, private modes too ([>4m, [<u)
|
|
951
|
-
txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
|
|
952
|
-
txt = txt.replace('\r', '\n')
|
|
953
|
-
lines = []
|
|
954
|
-
flat = re.sub(r'\s+', '', txt)
|
|
955
|
-
api_key = bool(re.search(r'sk-ant-api\d{2}-', flat)) and not re.search(r'sk-ant-oat\d{2}-', flat)
|
|
956
|
-
txt = re.sub(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+', 'sk-ant-***', txt)
|
|
957
|
-
for ln in txt.splitlines():
|
|
958
|
-
ln = ln.strip()
|
|
959
|
-
if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
|
|
960
|
-
continue
|
|
961
|
-
if 'https://' in ln or re.search(r'Paste\s*code\s*here', ln): # the sign-in link, the paste prompt
|
|
962
|
-
continue
|
|
963
|
-
if lines and lines[-1] == ln:
|
|
964
|
-
continue
|
|
965
|
-
lines.append(ln)
|
|
966
|
-
if api_key:
|
|
967
|
-
# Said LAST, so it is what the operator reads: the client minted an API key, not a
|
|
968
|
-
# subscription token — the browser session was signed into a Console org.
|
|
969
|
-
lines.append('the client minted an API KEY (sk-ant-api…), not a subscription setup-token: '
|
|
970
|
-
'the browser session belongs to a Console / API-billing organization — sign in '
|
|
971
|
-
'as the subscription account and try again')
|
|
972
|
-
try:
|
|
973
|
-
fd = os.open(sys.argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
974
|
-
with os.fdopen(fd, 'w') as f:
|
|
975
|
-
f.write('\n'.join(lines) + '\n')
|
|
976
|
-
except OSError:
|
|
977
|
-
pass
|
|
978
|
-
print(' | '.join(lines[-3:])[:400])
|
|
979
|
-
PYEOF
|
|
946
|
+
"$PYBIN" "$LIB_DIR/ceremony.py" debrief "$1" "$2" 2>/dev/null
|
|
980
947
|
}
|
|
981
948
|
|
|
982
|
-
# The token, out of the transcript.
|
|
983
|
-
#
|
|
984
|
-
#
|
|
985
|
-
#
|
|
986
|
-
#
|
|
987
|
-
#
|
|
988
|
-
#
|
|
989
|
-
# real inference in commit_ceremony_token then proves whatever came out.
|
|
949
|
+
# The token, out of the transcript. `claude setup-token` is a TUI: its renderer places
|
|
950
|
+
# words with absolute cursor moves, so the bytes are terminal OPERATIONS and the token
|
|
951
|
+
# exists only in the RENDERED result. Stripping escapes reassembles it wrongly — a
|
|
952
|
+
# `sk-ant-\x1b[10Gat01-…` stream loses the `o` and three mints failed as "no token
|
|
953
|
+
# captured" seconds after the client said the token was created (2026-08-29). lib/
|
|
954
|
+
# ceremony.py replays the transcript onto a virtual screen and reads what the operator
|
|
955
|
+
# saw; the real inference in commit_ceremony_token then proves whatever came out.
|
|
990
956
|
ceremony_extract() { # $1 = capture file -> the setup-token, or nothing
|
|
991
|
-
"$PYBIN"
|
|
992
|
-
import re, sys
|
|
993
|
-
raw = open(sys.argv[1], 'rb').read().decode('utf-8', 'ignore')
|
|
994
|
-
txt = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', raw) # OSC (hyperlink payloads)
|
|
995
|
-
txt = re.sub(r'\x1b\[[0-9;?<>=]*[A-Za-z]', '', txt) # CSI, private modes too ([>4m, [<u)
|
|
996
|
-
txt = re.sub(r'\x1b[()][A-Z0-9]|\x1b[78=>]|[\x0e\x0f]', '', txt) # charset, save/restore cursor, SI/SO
|
|
997
|
-
pat = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}')
|
|
998
|
-
cands = []
|
|
999
|
-
for m in re.finditer(r'Your\s*OAuth\s*token[^\n]{0,80}?:', txt):
|
|
1000
|
-
block = txt[m.end(): m.end() + 4000]
|
|
1001
|
-
stop = re.search(r'Store\s*this\s*token', block)
|
|
1002
|
-
if stop:
|
|
1003
|
-
block = block[: stop.start()]
|
|
1004
|
-
cands += pat.findall(re.sub(r'\s+', '', block))
|
|
1005
|
-
cands += pat.findall(txt)
|
|
1006
|
-
cands += pat.findall(re.sub(r'[ \t]+', '', txt))
|
|
1007
|
-
print(max(cands, key=len) if cands else '')
|
|
1008
|
-
PYEOF
|
|
957
|
+
"$PYBIN" "$LIB_DIR/ceremony.py" extract "$1" 2>/dev/null
|
|
1009
958
|
}
|
|
1010
959
|
|
|
1011
960
|
CEREMONY_TOKEN=""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/ceremony.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read what the sign-in ceremony actually PUT ON THE SCREEN.
|
|
2
|
+
|
|
3
|
+
`claude setup-token` is a TUI. Its renderer places words with absolute cursor
|
|
4
|
+
moves rather than spaces, so the bytes it emits are terminal OPERATIONS, not
|
|
5
|
+
text: on 2026-08-29 a perfectly good token arrived as
|
|
6
|
+
|
|
7
|
+
sk-ant-\x1b[10Gat01-w_C6…
|
|
8
|
+
|
|
9
|
+
Stripping the escapes yields `sk-ant-at01-…` — the sequence's own final byte ate
|
|
10
|
+
the token's `o` — and no amount of whitespace-joining puts it back. Three mints
|
|
11
|
+
failed as "no token captured" seconds after the client printed
|
|
12
|
+
"✓ Long-lived authentication token created successfully!".
|
|
13
|
+
|
|
14
|
+
So the transcript is REPLAYED onto a virtual screen and the result is read, which
|
|
15
|
+
is by definition what the operator saw. Only the handful of sequences this TUI
|
|
16
|
+
uses are honoured; everything else is skipped, and any byte that would move the
|
|
17
|
+
cursor off-screen is clamped rather than trusted.
|
|
18
|
+
|
|
19
|
+
Run directly: ceremony.py extract <capture> -> the setup-token, or nothing
|
|
20
|
+
ceremony.py debrief <capture> <out> -> last words; writes a
|
|
21
|
+
redacted transcript (0600)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
TOKEN = re.compile(r'sk-ant-oat\d{2}-[A-Za-z0-9_-]{40,}')
|
|
29
|
+
ANY_CRED = re.compile(r'sk-ant-[A-Za-z0-9]+-[A-Za-z0-9_-]+')
|
|
30
|
+
API_KEY = re.compile(r'sk-ant-api\d{2}-')
|
|
31
|
+
_CSI = re.compile(r'\x1b\[([0-9;?<>=]*)([@-~])')
|
|
32
|
+
_CHARSET = re.compile(r'\x1b[()][A-Z0-9]|\x1b[78=>]')
|
|
33
|
+
# A screen this TUI could never legitimately need; a corrupt stream must not make
|
|
34
|
+
# the renderer allocate without bound.
|
|
35
|
+
MAX_ROWS = 2000
|
|
36
|
+
MAX_COLS = 4000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(raw):
|
|
40
|
+
"""The transcript's final screen, as a list of lines."""
|
|
41
|
+
rows, cur, col = [[]], 0, 0
|
|
42
|
+
|
|
43
|
+
def put(ch):
|
|
44
|
+
nonlocal col
|
|
45
|
+
if cur >= MAX_ROWS or col >= MAX_COLS:
|
|
46
|
+
return
|
|
47
|
+
while len(rows) <= cur:
|
|
48
|
+
rows.append([])
|
|
49
|
+
row = rows[cur]
|
|
50
|
+
while len(row) <= col:
|
|
51
|
+
row.append(' ')
|
|
52
|
+
row[col] = ch
|
|
53
|
+
col += 1
|
|
54
|
+
|
|
55
|
+
i, n = 0, len(raw)
|
|
56
|
+
while i < n:
|
|
57
|
+
ch = raw[i]
|
|
58
|
+
if ch == '\x1b':
|
|
59
|
+
if raw.startswith('\x1b]', i): # OSC … BEL | ST
|
|
60
|
+
bel, st = raw.find('\x07', i), raw.find('\x1b\\', i)
|
|
61
|
+
ends = [x for x in ((bel, 1), (st, 2)) if x[0] != -1]
|
|
62
|
+
i = min(ends)[0] + min(ends)[1] if ends else n
|
|
63
|
+
continue
|
|
64
|
+
m = _CSI.match(raw, i)
|
|
65
|
+
if m:
|
|
66
|
+
params, fin = m.group(1), m.group(2)
|
|
67
|
+
nums = [int(p) for p in params.split(';') if p.isdigit()]
|
|
68
|
+
a = nums[0] if nums else 1
|
|
69
|
+
if fin == 'G':
|
|
70
|
+
col = min(max(0, a - 1), MAX_COLS)
|
|
71
|
+
elif fin == 'C':
|
|
72
|
+
col = min(col + a, MAX_COLS)
|
|
73
|
+
elif fin == 'D':
|
|
74
|
+
col = max(0, col - a)
|
|
75
|
+
elif fin == 'A':
|
|
76
|
+
cur = max(0, cur - a)
|
|
77
|
+
elif fin == 'B':
|
|
78
|
+
cur = min(cur + a, MAX_ROWS)
|
|
79
|
+
elif fin == 'H':
|
|
80
|
+
cur = min(max(0, a - 1), MAX_ROWS)
|
|
81
|
+
col = min(max(0, (nums[1] if len(nums) > 1 else 1) - 1), MAX_COLS)
|
|
82
|
+
elif fin == 'K':
|
|
83
|
+
while len(rows) <= cur:
|
|
84
|
+
rows.append([])
|
|
85
|
+
if params.startswith('2'):
|
|
86
|
+
rows[cur] = []
|
|
87
|
+
elif not params or params == '0':
|
|
88
|
+
rows[cur] = rows[cur][:col]
|
|
89
|
+
elif fin == 'J' and params.startswith('2'):
|
|
90
|
+
rows, cur, col = [[]], 0, 0
|
|
91
|
+
i = m.end()
|
|
92
|
+
continue
|
|
93
|
+
m = _CHARSET.match(raw, i)
|
|
94
|
+
i = m.end() if m else i + 1
|
|
95
|
+
continue
|
|
96
|
+
if ch == '\r':
|
|
97
|
+
col = 0
|
|
98
|
+
elif ch == '\n':
|
|
99
|
+
cur = min(cur + 1, MAX_ROWS)
|
|
100
|
+
col = 0
|
|
101
|
+
elif ord(ch) >= 32:
|
|
102
|
+
put(ch)
|
|
103
|
+
i += 1
|
|
104
|
+
return [''.join(r).rstrip() for r in rows]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# The block the client prints the token inside. Joining WITHIN it is what survives a
|
|
108
|
+
# terminal narrow enough to wrap the token across rows, without gluing the token to
|
|
109
|
+
# whatever the next paragraph happens to say.
|
|
110
|
+
_BLOCK_START = re.compile(r'Your\s*OAuth\s*token[^\n]{0,80}?:')
|
|
111
|
+
_BLOCK_END = re.compile(r'Store\s*this\s*token')
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_token(raw):
|
|
115
|
+
"""The setup-token the ceremony printed, or ''.
|
|
116
|
+
|
|
117
|
+
Every reading is a candidate and the LONGEST wins, because the two shapes fail
|
|
118
|
+
each other's method: a token painted out of order is whole only on the rendered
|
|
119
|
+
line, while one the terminal wrapped is whole only across rows. A per-line match
|
|
120
|
+
on a wrapped token returns its first 79 characters — exactly the truncation this
|
|
121
|
+
whole path exists to prevent — so the joined reading has to compete with it.
|
|
122
|
+
"""
|
|
123
|
+
lines = render(raw)
|
|
124
|
+
text = '\n'.join(lines)
|
|
125
|
+
cands = [m.group(0) for ln in lines for m in [TOKEN.search(ln)] if m]
|
|
126
|
+
for m in _BLOCK_START.finditer(text):
|
|
127
|
+
block = text[m.end(): m.end() + 4000]
|
|
128
|
+
stop = _BLOCK_END.search(block)
|
|
129
|
+
if stop:
|
|
130
|
+
block = block[: stop.start()]
|
|
131
|
+
cands += TOKEN.findall(re.sub(r'\s+', '', block))
|
|
132
|
+
if not cands: # a TUI this renderer misreads
|
|
133
|
+
flat = _CSI.sub('', _CHARSET.sub('', raw))
|
|
134
|
+
cands = TOKEN.findall(flat) + TOKEN.findall(re.sub(r'\s+', '', flat))
|
|
135
|
+
return max(cands, key=len) if cands else ''
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def debrief(raw):
|
|
139
|
+
"""(readable lines, api_key_minted). The screen the operator was left looking
|
|
140
|
+
at, with every credential-shaped string masked."""
|
|
141
|
+
api_key = False
|
|
142
|
+
for ln in render(raw):
|
|
143
|
+
if API_KEY.search(ln):
|
|
144
|
+
api_key = True
|
|
145
|
+
if extract_token(raw):
|
|
146
|
+
api_key = False
|
|
147
|
+
lines = []
|
|
148
|
+
for ln in render(raw):
|
|
149
|
+
ln = ANY_CRED.sub('sk-ant-***', ln).strip()
|
|
150
|
+
if not ln or re.fullmatch(r'[\W_]+', ln): # spinner frames, logo art
|
|
151
|
+
continue
|
|
152
|
+
if 'https://' in ln or re.search(r'Paste\s*code\s*here', ln):
|
|
153
|
+
continue
|
|
154
|
+
if lines and lines[-1] == ln:
|
|
155
|
+
continue
|
|
156
|
+
lines.append(ln)
|
|
157
|
+
if api_key:
|
|
158
|
+
# Said LAST, so it is what the operator reads.
|
|
159
|
+
lines.append('the client minted an API KEY (sk-ant-api…), not a subscription '
|
|
160
|
+
'setup-token: the browser session belongs to a Console / '
|
|
161
|
+
'API-billing organization — sign in as the subscription account '
|
|
162
|
+
'and try again')
|
|
163
|
+
return lines, api_key
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read(path):
|
|
167
|
+
with open(path, 'rb') as f:
|
|
168
|
+
return f.read().decode('utf-8', 'ignore')
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv):
|
|
172
|
+
if len(argv) < 2:
|
|
173
|
+
return 2
|
|
174
|
+
verb, path = argv[0], argv[1]
|
|
175
|
+
if verb == 'extract':
|
|
176
|
+
sys.stdout.write(extract_token(_read(path)))
|
|
177
|
+
return 0
|
|
178
|
+
if verb == 'debrief':
|
|
179
|
+
lines, _ = debrief(_read(path))
|
|
180
|
+
if len(argv) > 2:
|
|
181
|
+
try:
|
|
182
|
+
fd = os.open(argv[2], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
183
|
+
with os.fdopen(fd, 'w') as f:
|
|
184
|
+
f.write('\n'.join(lines) + '\n')
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
print(' | '.join(lines[-3:])[:400])
|
|
188
|
+
return 0
|
|
189
|
+
return 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == '__main__':
|
|
193
|
+
sys.exit(main(sys.argv[1:]))
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -94,6 +94,16 @@ if [ "${1:-}" = "setup-token" ]; then
|
|
|
94
94
|
printf 'Store\033[7Gthis\033[12Gtoken\033[18Gsecurely.\n'
|
|
95
95
|
exit 0
|
|
96
96
|
fi
|
|
97
|
+
if [ -n "${FAKE_TOKEN_CURSOR:-}" ]; then
|
|
98
|
+
# The 2026-08-29 shape: the TUI paints the token OUT OF ORDER with absolute cursor
|
|
99
|
+
# moves, so the byte stream is `sk-ant-<ESC>[10Gat01-…` and escape-stripping loses
|
|
100
|
+
# the `o`. Only replaying the transcript onto a screen recovers it.
|
|
101
|
+
tok="sk-ant-oat01-$(printf '%66s' '' | tr ' ' W)$(printf '%23s' '' | tr ' ' T)TAILOK"
|
|
102
|
+
printf ' Your\033[6GOAuth\033[12Gtoken\033[18G(valid\033[25Gfor\033[29G1\033[31Gyear):\n'
|
|
103
|
+
printf ' %s\033[10G%s\033[9G%s\n' "${tok:0:7}" "${tok:8}" "${tok:7:1}"
|
|
104
|
+
printf ' Store\033[7Gthis\033[12Gtoken\033[18Gsecurely.\n'
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
97
107
|
if [ -n "${FAKE_TOKEN_APIKEY:-}" ]; then
|
|
98
108
|
# A browser session signed into a Console (API-billing) org: the client mints an
|
|
99
109
|
# API key, which is not a subscription token and must not be saved.
|
|
@@ -421,7 +431,8 @@ check "pinned dead-credential account still runs under its token" "TOK=sk-ant-oa
|
|
|
421
431
|
rm -rf "$ACC/acct-07"
|
|
422
432
|
|
|
423
433
|
# ---- 7. limited marker excludes account ------------------------------------
|
|
424
|
-
|
|
434
|
+
# (An account-wide bucket. A MODEL-SCOPED one deliberately does not exclude — 9a4.)
|
|
435
|
+
printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
425
436
|
all2=1
|
|
426
437
|
for _ in $(seq 1 15); do
|
|
427
438
|
out="$(claude 2>&1)"
|
|
@@ -454,7 +465,7 @@ rm -f "$ACC/acct-01/.limited" "$ACC/acct-02/.limited"
|
|
|
454
465
|
# acct-02: session at 100% — far better weekly (7%), but every request bounces until
|
|
455
466
|
# the reset. Ranking on headroom alone handed out the guaranteed rejection
|
|
456
467
|
# (operator report 2026-08-29: "claude keeps starting on an out-of-limits account").
|
|
457
|
-
printf '%s\nbucket=
|
|
468
|
+
printf '%s\nbucket=weekly_all percent=99 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
458
469
|
printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
|
|
459
470
|
printf '{"fetched_at":%s,"max_percent":99,"weekly_percent":99,"session_percent":10,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
460
471
|
printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":7,"session_percent":100,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
@@ -531,6 +542,58 @@ for f in server.token .credentials.json .expired .server-token-verified limits.j
|
|
|
531
542
|
[ -e "$WORK/bak01/$f" ] && cp -p "$WORK/bak01/$f" "$ACC/acct-01/$f"
|
|
532
543
|
done
|
|
533
544
|
|
|
545
|
+
# ---- 9a4. a MODEL-scoped limit does not park the whole account ----------------------
|
|
546
|
+
# "weekly_scoped:Fable at 100%" says the account cannot serve Fable — nothing more. It
|
|
547
|
+
# used to park the account outright (and max_percent re-excluded it anyway), so on
|
|
548
|
+
# 2026-08-29 three accounts sitting at Fable 100% with their session buckets at 51/23/25%
|
|
549
|
+
# were invisible to selection, the pool offered nothing, and the fallback handed out a
|
|
550
|
+
# session-exhausted account that rejected the operator's first request.
|
|
551
|
+
rm -rf "$WORK/bak94"; mkdir -p "$WORK/bak94"
|
|
552
|
+
for a in acct-01 acct-02; do
|
|
553
|
+
for f in .limited limits.json; do
|
|
554
|
+
[ -e "$ACC/$a/$f" ] && cp -p "$ACC/$a/$f" "$WORK/bak94/$a.$f"
|
|
555
|
+
done
|
|
556
|
+
done
|
|
557
|
+
# acct-01: only its Fable bucket is spent. acct-02: session spent — dead for every model.
|
|
558
|
+
printf '%s\nbucket=weekly_scoped:Fable percent=100 marked_at=x reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
|
|
559
|
+
printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":100,"session_percent":12,"buckets":[{"name":"session","percent":12},{"name":"weekly_all","percent":40},{"name":"weekly_scoped:Fable","percent":100}]}' "$now" > "$ACC/acct-01/limits.json"
|
|
560
|
+
printf '%s\nbucket=session percent=100 marked_at=x reason=limits\n' "$((now+600))" > "$ACC/acct-02/.limited"
|
|
561
|
+
printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":30,"session_percent":100,"buckets":[{"name":"session","percent":100},{"name":"weekly_all","percent":30}]}' "$now" > "$ACC/acct-02/limits.json"
|
|
562
|
+
out="$(claude 2>&1)"
|
|
563
|
+
check "a Fable-only park still serves an unpinned run" "CFG=acct-01" "$out"
|
|
564
|
+
check "...and that run is pinned to the fallback model up front" "ARGS=--model claude-opus-5" "$out"
|
|
565
|
+
grep -q 'acct-01 -> --model claude-opus-5 (its Fable bucket is full)' "$ACC/selection.log" \
|
|
566
|
+
&& t_ok "the model pin is logged with its reason" || t_fail "model pin log" "no line in selection.log"
|
|
567
|
+
# A run that ASKS for the exhausted model must not be sent to that account.
|
|
568
|
+
out="$(claude --model claude-fable-5 2>&1)"
|
|
569
|
+
check "a run pinning the exhausted model does not get that account" "CFG=acct-02" "$out"
|
|
570
|
+
# A run that pins another model uses it as-is — never pinned twice.
|
|
571
|
+
out="$(claude --model claude-opus-5 2>&1)"
|
|
572
|
+
check "a run pinning another model gets the Fable-spent account" "CFG=acct-01" "$out"
|
|
573
|
+
case "$out" in
|
|
574
|
+
*"--model claude-opus-5 --model"*) t_fail "an explicit model is not pinned twice" "double --model" ;;
|
|
575
|
+
*) t_ok "an explicit model is not pinned twice" ;;
|
|
576
|
+
esac
|
|
577
|
+
# The >=90% cutoff reads the same way: no marker at all, Fable spent in telemetry only.
|
|
578
|
+
rm -f "$ACC/acct-01/.limited"
|
|
579
|
+
out="$(claude 2>&1)"
|
|
580
|
+
check "the cutoff ignores a scoped bucket the run will not use" "CFG=acct-01" "$out"
|
|
581
|
+
out="$(claude --model claude-fable-5 2>&1)"
|
|
582
|
+
check "the cutoff still excludes it for the model that IS spent" "CFG=acct-02" "$out"
|
|
583
|
+
# A reading with no bucket list keeps the old flat behaviour (fail closed at >=90%),
|
|
584
|
+
# with a plainly healthy neighbour so nothing but that exclusion decides the pick.
|
|
585
|
+
printf '{"fetched_at":%s,"max_percent":97,"weekly_percent":97,"session_percent":97}' "$now" > "$ACC/acct-01/limits.json"
|
|
586
|
+
rm -f "$ACC/acct-02/.limited"
|
|
587
|
+
printf '{"fetched_at":%s,"max_percent":20,"weekly_percent":20,"session_percent":20,"buckets":[{"name":"session","percent":20},{"name":"weekly_all","percent":20}]}' "$now" > "$ACC/acct-02/limits.json"
|
|
588
|
+
out="$(claude 2>&1)"
|
|
589
|
+
check "a bucket-less reading still excludes at the flat peak" "CFG=acct-02" "$out"
|
|
590
|
+
for a in acct-01 acct-02; do
|
|
591
|
+
rm -f "$ACC/$a/.limited" "$ACC/$a/limits.json"
|
|
592
|
+
for f in .limited limits.json; do
|
|
593
|
+
[ -e "$WORK/bak94/$a.$f" ] && cp -p "$WORK/bak94/$a.$f" "$ACC/$a/$f"
|
|
594
|
+
done
|
|
595
|
+
done
|
|
596
|
+
|
|
534
597
|
# ---- 9b. codex-review regressions: auth/marker/threshold hardening -------------
|
|
535
598
|
# empty .credentials.json must NOT count as auth (interrupted write)
|
|
536
599
|
mkdir -p "$ACC/acct-06"
|
|
@@ -1422,6 +1485,28 @@ esac
|
|
|
1422
1485
|
[ -f "$ACC/acct-04/.server-token-verified" ] && t_ok "the joined token was proven by a real call" \
|
|
1423
1486
|
|| t_fail "split token proof" "marker missing"
|
|
1424
1487
|
printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
|
|
1488
|
+
# The token painted OUT OF ORDER with absolute cursor moves — the shape that made three
|
|
1489
|
+
# real mints fail as "no token captured" right after "token created successfully".
|
|
1490
|
+
out="$(FAKE_TOKEN_CURSOR=1 claude-accounts mint acct-04 2>&1 </dev/null)"
|
|
1491
|
+
rc=$?
|
|
1492
|
+
[ "$rc" = "0" ] && t_ok "a cursor-painted token is read off the rendered screen" \
|
|
1493
|
+
|| t_fail "cursor-painted mint rc" "rc=$rc: $(printf '%s' "$out" | tail -c 220)"
|
|
1494
|
+
case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
|
|
1495
|
+
sk-ant-oat01-WWW*TAILOK) [ "$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')" = "108" ] \
|
|
1496
|
+
&& t_ok "the rendered token is complete and in order" || t_fail "rendered token" "wrong length" ;;
|
|
1497
|
+
*) t_fail "rendered token" "wrong content — escape-stripping order bug" ;;
|
|
1498
|
+
esac
|
|
1499
|
+
printf '%s' "$orig" > "$ACC/acct-04/server.token"; rm -f "$ACC/acct-04/.server-token-verified"
|
|
1500
|
+
# The renderer itself, on the exact byte pattern from the field.
|
|
1501
|
+
printf ' sk-ant-\033[10Gat01-%s\033[9Go\n' "$(printf '%95s' '' | tr ' ' Z)" > "$WORK/painted.raw"
|
|
1502
|
+
n="$(python3 "$REPO_DIR/lib/ceremony.py" extract "$WORK/painted.raw" | wc -c | tr -d ' ')"
|
|
1503
|
+
[ "$n" = "108" ] && t_ok "ceremony.py renders a cursor-painted token whole" \
|
|
1504
|
+
|| t_fail "ceremony.py extract" "got $n characters, expected 108"
|
|
1505
|
+
# ...and the debrief reads as prose, not as words jammed together by the stripping.
|
|
1506
|
+
printf ' Store\033[8Gthis\033[13Gtoken\033[19Gsecurely.\n' > "$WORK/prose.raw"
|
|
1507
|
+
out="$(python3 "$REPO_DIR/lib/ceremony.py" debrief "$WORK/prose.raw" "$WORK/prose.out")"
|
|
1508
|
+
check "the debrief renders readable words" "Store this token securely." "$out"
|
|
1509
|
+
|
|
1425
1510
|
# A browser session on a Console (API-billing) org: the client mints an API KEY. Not a
|
|
1426
1511
|
# subscription token — refused, with the cause named and the raw capture kept.
|
|
1427
1512
|
before_raw="$(ls "$ACC"/tmp/mint-failed.*.raw 2>/dev/null | wc -l | tr -d ' ')"
|
|
@@ -4752,7 +4837,7 @@ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list 2>&1)"
|
|
|
4752
4837
|
check "keychain login shown in the plain list" "auth=keychain" "$out"
|
|
4753
4838
|
|
|
4754
4839
|
# 17b. the shim runs under a keychain-only account (file account parked by a limit)
|
|
4755
|
-
printf '%s\nbucket=
|
|
4840
|
+
printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
|
|
4756
4841
|
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
|
|
4757
4842
|
check "shim selects the keychain-only account" "CFG=acct-02" "$out"
|
|
4758
4843
|
|