claude-multiacc 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -11
- package/bin/claude +165 -12
- package/bin/claude-accounts +345 -18
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +411 -0
package/tests/run-tests.sh
CHANGED
|
@@ -57,6 +57,16 @@ if [ -f "$ctl" ] && grep -qx "fail:$acct" "$ctl" 2>/dev/null; then
|
|
|
57
57
|
echo "API Error: 429 rate limit exceeded" >&2
|
|
58
58
|
exit 1
|
|
59
59
|
fi
|
|
60
|
+
if [ -f "$ctl" ] && grep -qx "authfail:$acct" "$ctl" 2>/dev/null; then
|
|
61
|
+
# the exact failure a dead OAuth grant produces
|
|
62
|
+
echo "Failed to authenticate: OAuth session expired and could not be refreshed" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
if [ -f "$ctl" ] && grep -qx "orgfail:$acct" "$ctl" 2>/dev/null; then
|
|
66
|
+
# the exact failure an org-disabled account produces (authenticates, cannot infer)
|
|
67
|
+
echo "Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
60
70
|
for a in "$@"; do
|
|
61
71
|
case "$a" in
|
|
62
72
|
--exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
|
|
@@ -260,6 +270,191 @@ case "$out" in *CFG=acct-02*) t_ok "garbled marker treated as active (excluded)"
|
|
|
260
270
|
|| t_fail "garbled marker deleted" "shim destroyed a possibly-mid-write marker"
|
|
261
271
|
rm -f "$ACC"/acct-*/.limited "$ACC"/acct-*/limits.json
|
|
262
272
|
|
|
273
|
+
# ---- 9c. DEAD LOGINS are never selected --------------------------------------
|
|
274
|
+
# Regression: an account whose refresh token had expired stayed "valid" (it has a
|
|
275
|
+
# .credentials.json), so the shim kept picking it and every run died with
|
|
276
|
+
# "Failed to authenticate: OAuth session expired and could not be refreshed".
|
|
277
|
+
HEALTHY_CREDS='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-live","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"]}}'
|
|
278
|
+
DEAD_CREDS='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}'
|
|
279
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
|
|
280
|
+
all2=1
|
|
281
|
+
for _ in $(seq 1 12); do
|
|
282
|
+
out="$(claude 2>&1)"
|
|
283
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
284
|
+
done
|
|
285
|
+
[ "$all2" = "1" ] && t_ok "expired refresh token excluded from selection" \
|
|
286
|
+
|| t_fail "expired login excluded" "the dead account was still selected"
|
|
287
|
+
grep -q "skipped-expired: acct-01" "$ACC/selection.log" \
|
|
288
|
+
&& t_ok "skipped-expired logged" || t_fail "skipped-expired log" "no line in selection.log"
|
|
289
|
+
|
|
290
|
+
# ...and a dead login is not even the all-limited fallback: degraded beats down, but
|
|
291
|
+
# dead is DOWN — a limit-marked account that can still authenticate wins.
|
|
292
|
+
printf '%s\nx\n' "$((now+3600))" > "$ACC/acct-02/.limited"
|
|
293
|
+
out="$(claude 2>&1)"
|
|
294
|
+
check "limited-but-alive beats a dead login in the fallback" "CFG=acct-02" "$out"
|
|
295
|
+
rm -f "$ACC/acct-02/.limited"
|
|
296
|
+
|
|
297
|
+
# no refreshToken at all + expired access token = dead too (looped: with a single run a
|
|
298
|
+
# random tie-break would let a broken implementation pass half the time)
|
|
299
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-x","expiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
300
|
+
all2=1
|
|
301
|
+
for _ in $(seq 1 12); do
|
|
302
|
+
out="$(claude 2>&1)"
|
|
303
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
304
|
+
done
|
|
305
|
+
[ "$all2" = "1" ] && t_ok "credential with no refresh token is dead" \
|
|
306
|
+
|| t_fail "no-refresh-token dead" "the dead account was selected"
|
|
307
|
+
|
|
308
|
+
# expired ACCESS token with a live refresh token is NOT dead (claude refreshes it)
|
|
309
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-stale","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
310
|
+
rm -f "$ACC/acct-02/.credentials.json" # acct-01 is the only candidate
|
|
311
|
+
out="$(claude 2>&1)"
|
|
312
|
+
check "stale access token + live refresh token stays selectable" "CFG=acct-01" "$out"
|
|
313
|
+
printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
|
|
314
|
+
|
|
315
|
+
# a dead credential beside a portable token still authenticates — via the token
|
|
316
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-01/.credentials.json"
|
|
317
|
+
printf '%s' "$DEAD_CREDS" > "$ACC/acct-02/.credentials.json"
|
|
318
|
+
printf 'sk-ant-oat01-rescue-token' > "$ACC/acct-01/server.token"
|
|
319
|
+
out="$(claude 2>&1)"
|
|
320
|
+
check "dead creds + portable token still authenticate" "TOK=sk-ant-oat01-rescue-token" "$out"
|
|
321
|
+
rm -f "$ACC/acct-01/server.token"
|
|
322
|
+
|
|
323
|
+
# every account dead => stock passthrough. The reason lands in selection.log; the
|
|
324
|
+
# stderr hint is terminal-only, so a service-spawned `claude -p` stays byte-clean.
|
|
325
|
+
out="$(claude 2>&1)"
|
|
326
|
+
check "all logins dead -> stock passthrough" "CFG=none" "$out"
|
|
327
|
+
case "$out" in *claude-multiacc:*) t_fail "all-dead stderr" "wrote a notice to a non-tty stderr" ;;
|
|
328
|
+
*) t_ok "all logins dead -> no stderr noise for services" ;; esac
|
|
329
|
+
grep -q "all-expired: falling back" "$ACC/selection.log" \
|
|
330
|
+
&& t_ok "all logins dead -> logged with the fix" || t_fail "all-dead log" "no all-expired line"
|
|
331
|
+
printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-01/.credentials.json"
|
|
332
|
+
printf '%s' "$HEALTHY_CREDS" > "$ACC/acct-02/.credentials.json"
|
|
333
|
+
|
|
334
|
+
# ---- 9d. the .expired marker: excludes, and self-heals on a newer credential ----
|
|
335
|
+
printf '%s\nreason=auth-error marked_at=now detail=test\n' "$now" > "$ACC/acct-01/.expired"
|
|
336
|
+
touch -t 202001010101 "$ACC/acct-01/.credentials.json" # credential OLDER than the marker
|
|
337
|
+
all2=1
|
|
338
|
+
for _ in $(seq 1 10); do
|
|
339
|
+
out="$(claude 2>&1)"
|
|
340
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
341
|
+
done
|
|
342
|
+
[ "$all2" = "1" ] && t_ok ".expired marker excludes the account" \
|
|
343
|
+
|| t_fail ".expired marker" "marked account was still selected"
|
|
344
|
+
touch -t 202001010101 "$ACC/acct-01/.expired" # credential now NEWER than the marker
|
|
345
|
+
touch "$ACC/acct-01/.credentials.json"
|
|
346
|
+
out="$(claude 2>&1)" # any selection pass re-evaluates it
|
|
347
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a newer credential clears the .expired marker" \
|
|
348
|
+
|| t_fail ".expired self-heal" "marker survived a re-login"
|
|
349
|
+
rm -f "$ACC/acct-01/.expired"
|
|
350
|
+
|
|
351
|
+
# ---- 9e. an auth failure parks the account (not a 10-minute cooldown) ----------
|
|
352
|
+
# Rate limits heal on their own; a dead grant does not — so it gets .expired, and the
|
|
353
|
+
# run still completes on another account.
|
|
354
|
+
printf '{"fetched_at":%s,"max_percent":1,"weekly_percent":1,"session_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
355
|
+
printf '{"fetched_at":%s,"max_percent":50,"weekly_percent":50,"session_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
356
|
+
echo "authfail:acct-01" > "$FAKE_CTL"
|
|
357
|
+
out="$(claude -p hello < /dev/null 2>/dev/null)"
|
|
358
|
+
rc=$?
|
|
359
|
+
{ [ "$rc" = "0" ] && case "$out" in *CFG=acct-02*) true ;; *) false ;; esac; } \
|
|
360
|
+
&& t_ok "auth failure retries onto a healthy account" \
|
|
361
|
+
|| t_fail "auth failure retry" "rc=$rc out=$out"
|
|
362
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "auth failure writes .expired" \
|
|
363
|
+
|| t_fail "auth failure marker" ".expired missing"
|
|
364
|
+
[ ! -f "$ACC/acct-01/.limited" ] && t_ok "auth failure is not treated as a rate limit" \
|
|
365
|
+
|| t_fail "auth failure marker" "got a 10-minute .limited cooldown instead"
|
|
366
|
+
# The shim's park is a GUESS from one run, so it carries its own expiry: once the soft
|
|
367
|
+
# window passes the account returns to the pool with no external help.
|
|
368
|
+
grep -q "soft_until=" "$ACC/acct-01/.expired" \
|
|
369
|
+
&& t_ok "a shim-written park carries a soft expiry" \
|
|
370
|
+
|| t_fail "soft park" "no soft_until in the shim-written marker"
|
|
371
|
+
printf '%s\nreason=auth-error soft_until=%s detail=elapsed\n' "$((now-7200))" "$((now-10))" > "$ACC/acct-01/.expired"
|
|
372
|
+
touch -t 202001010101 "$ACC/acct-01/.credentials.json"
|
|
373
|
+
out="$(CLAUDE_ACCOUNT='' claude 2>&1)"
|
|
374
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "an elapsed soft park releases the account" \
|
|
375
|
+
|| t_fail "soft park expiry" "the account stayed parked past soft_until"
|
|
376
|
+
touch "$ACC/acct-01/.credentials.json"
|
|
377
|
+
rm -f "$FAKE_CTL" "$ACC/acct-01/.expired" "$ACC"/acct-*/limits.json
|
|
378
|
+
|
|
379
|
+
# ...and the park is NOT triggered by the model merely talking about auth. This is the
|
|
380
|
+
# hot path for `claude -p`: the grep also sees the model's own answer on stdout.
|
|
381
|
+
cat > "$FAKEBIN/claude-chatty" <<'EOF'
|
|
382
|
+
#!/usr/bin/env bash
|
|
383
|
+
echo "Your nginx returns 403 Forbidden. Please run: nginx -t to check the config."
|
|
384
|
+
echo "hook failed" >&2
|
|
385
|
+
exit 2
|
|
386
|
+
EOF
|
|
387
|
+
chmod +x "$FAKEBIN/claude-chatty"
|
|
388
|
+
mv "$FAKEBIN/claude" "$FAKEBIN/claude.real"; mv "$FAKEBIN/claude-chatty" "$FAKEBIN/claude"
|
|
389
|
+
claude -p "why does nginx 403" < /dev/null >/dev/null 2>&1
|
|
390
|
+
mv "$FAKEBIN/claude" "$FAKEBIN/claude-chatty"; mv "$FAKEBIN/claude.real" "$FAKEBIN/claude"
|
|
391
|
+
if [ -f "$ACC/acct-01/.expired" ] || [ -f "$ACC/acct-02/.expired" ]; then
|
|
392
|
+
t_fail "answer text must not park an account" "the model mentioning 403/'please run' parked an account"
|
|
393
|
+
else
|
|
394
|
+
t_ok "a model answer mentioning 403 does not park an account"
|
|
395
|
+
fi
|
|
396
|
+
rm -f "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
|
|
397
|
+
|
|
398
|
+
# ---- 9f. org-disabled accounts are parked too ---------------------------------
|
|
399
|
+
# "Your organization has disabled Claude subscription access for Claude Code" is not an
|
|
400
|
+
# auth failure (the credential is perfectly valid) and not a rate limit — but the
|
|
401
|
+
# account fails EVERY call, so it must leave the pool. A re-login cannot fix it.
|
|
402
|
+
printf '{"fetched_at":%s,"max_percent":1,"weekly_percent":1,"session_percent":1,"buckets":[]}' "$now" > "$ACC/acct-01/limits.json"
|
|
403
|
+
printf '{"fetched_at":%s,"max_percent":50,"weekly_percent":50,"session_percent":50,"buckets":[]}' "$now" > "$ACC/acct-02/limits.json"
|
|
404
|
+
echo "orgfail:acct-01" > "$FAKE_CTL"
|
|
405
|
+
out="$(claude -p hello < /dev/null 2>/dev/null)"
|
|
406
|
+
rc=$?
|
|
407
|
+
{ [ "$rc" = "0" ] && case "$out" in *CFG=acct-02*) true ;; *) false ;; esac; } \
|
|
408
|
+
&& t_ok "org-disabled account retries onto a healthy one" \
|
|
409
|
+
|| t_fail "org-block retry" "rc=$rc out=$out"
|
|
410
|
+
grep -q "reason=org-blocked" "$ACC/acct-01/.expired" 2>/dev/null \
|
|
411
|
+
&& t_ok "org-disabled account is parked as org-blocked" \
|
|
412
|
+
|| t_fail "org-block marker" "no reason=org-blocked marker"
|
|
413
|
+
rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json
|
|
414
|
+
# it stays out of the pool...
|
|
415
|
+
all2=1
|
|
416
|
+
for _ in $(seq 1 10); do
|
|
417
|
+
out="$(claude 2>&1)"
|
|
418
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
419
|
+
done
|
|
420
|
+
[ "$all2" = "1" ] && t_ok "org-blocked account excluded from selection" \
|
|
421
|
+
|| t_fail "org-block exclusion" "the blocked account was still selected"
|
|
422
|
+
# ...and a park for an ORG BLOCK survives a credential rewrite. The token refresher
|
|
423
|
+
# rewrites .credentials.json every few hours; treating that as "the account recovered"
|
|
424
|
+
# handed blocked accounts straight back to the pool and runs kept failing with
|
|
425
|
+
# "Your organization has disabled Claude subscription access".
|
|
426
|
+
touch "$ACC/acct-01/.credentials.json" # credential now NEWER than the marker
|
|
427
|
+
all2=1
|
|
428
|
+
for _ in $(seq 1 10); do
|
|
429
|
+
out="$(claude 2>&1)"
|
|
430
|
+
case "$out" in *CFG=acct-02*) ;; *) all2=0 ;; esac
|
|
431
|
+
done
|
|
432
|
+
[ "$all2" = "1" ] && t_ok "an org block survives a credential refresh" \
|
|
433
|
+
|| t_fail "org-block stickiness" "a rewritten credential un-parked a blocked account"
|
|
434
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "the org-block marker is not deleted by a refresh" \
|
|
435
|
+
|| t_fail "org-block stickiness" "marker removed by a credential rewrite"
|
|
436
|
+
# a CREDENTIAL park still self-heals on a newer credential (unchanged behavior)
|
|
437
|
+
printf '%s\nreason=auth-error detail=test\n' "$((now-100))" > "$ACC/acct-01/.expired"
|
|
438
|
+
touch -t 202001010101 "$ACC/acct-01/.expired" # marker older than the credential
|
|
439
|
+
touch "$ACC/acct-01/.credentials.json"
|
|
440
|
+
claude >/dev/null 2>&1
|
|
441
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a credential park still clears on a newer credential" \
|
|
442
|
+
|| t_fail "credential park" "marker survived a fresh credential"
|
|
443
|
+
printf '%s\nreason=org-blocked detail=test\n' "$((now-100))" > "$ACC/acct-01/.expired"
|
|
444
|
+
# `expired` explains it and points at the same fix as any other dead login
|
|
445
|
+
out="$(claude-accounts expired 2>&1)"
|
|
446
|
+
check "expired labels an org-blocked account" "BLOCKED" "$out"
|
|
447
|
+
check "expired explains the org block" "organization has disabled" "$out"
|
|
448
|
+
check "expired points org blocks at relogin" "relogin acct-01" "$out"
|
|
449
|
+
out="$(claude-accounts list 2>&1)"
|
|
450
|
+
check "list flags org-blocked accounts" "ORG-BLOCKED" "$out"
|
|
451
|
+
# relogin targets them like any other dead login
|
|
452
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts relogin --yes 2>&1)"
|
|
453
|
+
check "relogin targets org-blocked accounts" "acct-01 login saved" "$out"
|
|
454
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a successful re-login clears an org block" \
|
|
455
|
+
|| t_fail "org-block relogin" "marker survived the re-login"
|
|
456
|
+
rm -f "$ACC/acct-01/.expired"
|
|
457
|
+
|
|
263
458
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
264
459
|
mkdir -p "$ACC/acct-03"
|
|
265
460
|
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
|
|
@@ -446,6 +641,84 @@ check "login --token saves a portable token" "acct-08 token saved" "$out"
|
|
|
446
641
|
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
|
|
447
642
|
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
448
643
|
|
|
644
|
+
# ---- 15f2. expired: the re-login worklist, and relogin fixes it ----------------------
|
|
645
|
+
# `expired` must agree with the shim exactly: what it lists is what selection skips.
|
|
646
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
647
|
+
out="$(claude-accounts expired 2>&1)"
|
|
648
|
+
rc=$?
|
|
649
|
+
check "expired lists the dead account" "acct-01" "$out"
|
|
650
|
+
check "expired explains why" "refresh token expired" "$out"
|
|
651
|
+
check "expired prints the fix" "claude-accounts relogin acct-01" "$out"
|
|
652
|
+
[ "$rc" = "1" ] && t_ok "expired exits 1 when a login needs a human" || t_fail "expired rc" "rc=$rc"
|
|
653
|
+
out="$(claude-accounts expired --quiet 2>&1)"
|
|
654
|
+
[ "$out" = "acct-01" ] && t_ok "expired --quiet prints bare ids" || t_fail "expired --quiet" "got: $out"
|
|
655
|
+
out="$(claude-accounts list 2>&1)"
|
|
656
|
+
check "list flags the dead login" "EXPIRED-LOGIN" "$out"
|
|
657
|
+
out="$(claude-accounts status 2>&1)"
|
|
658
|
+
check "status marks it unselectable" "LOGIN EXPIRED" "$out"
|
|
659
|
+
# relogin with no arguments targets exactly that worklist
|
|
660
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts relogin --yes 2>&1)"
|
|
661
|
+
rc=$?
|
|
662
|
+
check "relogin re-authenticates the expired account" "acct-01 login saved" "$out"
|
|
663
|
+
check "relogin reports what it did" "re-authenticated 1 of 1" "$out"
|
|
664
|
+
[ "$rc" = "0" ] && t_ok "relogin exits 0 when every account recovered" || t_fail "relogin rc" "rc=$rc"
|
|
665
|
+
out="$(claude-accounts expired 2>&1)"
|
|
666
|
+
rc=$?
|
|
667
|
+
check "expired is clean after relogin" "can authenticate" "$out"
|
|
668
|
+
[ "$rc" = "0" ] && t_ok "expired exits 0 on a healthy pool" || t_fail "expired rc (healthy)" "rc=$rc"
|
|
669
|
+
out="$(claude 2>&1)"
|
|
670
|
+
case "$out" in *CFG=acct-0*) t_ok "re-authenticated account is selectable again" ;;
|
|
671
|
+
*) t_fail "post-relogin selection" "got: $out" ;; esac
|
|
672
|
+
# a stale .expired marker must not survive a successful login
|
|
673
|
+
printf '%s\nreason=auth-error detail=test\n' "$now" > "$ACC/acct-01/.expired"
|
|
674
|
+
touch -t 202001010101 "$ACC/acct-01/.credentials.json"
|
|
675
|
+
CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts relogin acct-01 --yes >/dev/null 2>&1
|
|
676
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "login clears the dead-auth marker" \
|
|
677
|
+
|| t_fail "login marker clear" ".expired survived a successful login"
|
|
678
|
+
# explicit ids are validated
|
|
679
|
+
out="$(claude-accounts relogin acct-99 --yes 2>&1)"
|
|
680
|
+
rc=$?
|
|
681
|
+
check "relogin rejects an unknown account" "unknown account" "$out"
|
|
682
|
+
[ "$rc" != "0" ] && t_ok "unknown relogin target exits nonzero" || t_fail "relogin unknown rc" "rc=0"
|
|
683
|
+
|
|
684
|
+
# ---- 15f3. a re-login that did NOT work must not be reported as fixed -----------------
|
|
685
|
+
# The target already HAS a (dead) .credentials.json, so "the file exists" proves nothing:
|
|
686
|
+
# an aborted sign-in would otherwise clear the dead-auth marker and hand the account back.
|
|
687
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
688
|
+
printf '%s\nreason=refresh-token-expired detail=test\n' "$now" > "$ACC/acct-01/.expired"
|
|
689
|
+
touch -t 202001010101 "$ACC/acct-01/.credentials.json"
|
|
690
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 FAKE_EMAIL=a@test claude-accounts relogin acct-01 --yes 2>&1)"
|
|
691
|
+
rc=$?
|
|
692
|
+
check "aborted re-login is reported as a failure" "login failed or aborted" "$out"
|
|
693
|
+
[ "$rc" != "0" ] && t_ok "aborted relogin exits nonzero" || t_fail "aborted relogin rc" "rc=0"
|
|
694
|
+
[ -f "$ACC/acct-01/.expired" ] && t_ok "aborted re-login leaves the account parked" \
|
|
695
|
+
|| t_fail "aborted relogin" "the dead-auth marker was cleared by a failed sign-in"
|
|
696
|
+
out="$(claude-accounts expired --quiet 2>&1)"
|
|
697
|
+
check "the account is still on the worklist after a failed re-login" "acct-01" "$out"
|
|
698
|
+
# a real re-login then fixes it
|
|
699
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts relogin acct-01 --yes 2>&1)"
|
|
700
|
+
check "a working re-login is reported as fixed" "re-authenticated 1 of 1" "$out"
|
|
701
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "a working re-login clears the park" \
|
|
702
|
+
|| t_fail "relogin clear" "marker survived a successful sign-in"
|
|
703
|
+
|
|
704
|
+
# ---- 15f4. audit robustness: bad data must never read as a healthy pool ---------------
|
|
705
|
+
# a non-numeric expiry must not crash the audit (it would blank the worklist silently)
|
|
706
|
+
cp "$ACC/acct-01/.credentials.json" "$WORK/creds.bak"
|
|
707
|
+
printf '{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":"soon","refreshTokenExpiresAt":[]}}' > "$ACC/acct-01/.credentials.json"
|
|
708
|
+
out="$(claude-accounts expired 2>&1)"
|
|
709
|
+
rc=$?
|
|
710
|
+
case "$out" in *Traceback*) t_fail "audit survives a weird credential" "python traceback" ;;
|
|
711
|
+
*) t_ok "a non-numeric expiry does not crash the audit" ;; esac
|
|
712
|
+
out="$(claude-accounts list 2>&1)"
|
|
713
|
+
check "list survives a weird credential" "acct-01" "$out"
|
|
714
|
+
# a truncated (mid-write) credential is judged the SAME way the shim judges it
|
|
715
|
+
printf '{"claudeAiOauth":{"accessToken":"live","refreshToken":"rt","expiresAt":9999999999999,' > "$ACC/acct-01/.credentials.json"
|
|
716
|
+
out="$(claude-accounts expired --quiet 2>&1)"
|
|
717
|
+
case "$out" in *acct-01*) t_fail "audit agrees with the shim on a truncated credential" \
|
|
718
|
+
"audit calls it dead while the shim still selects it" ;;
|
|
719
|
+
*) t_ok "a truncated credential is judged like the shim judges it" ;; esac
|
|
720
|
+
cp "$WORK/creds.bak" "$ACC/acct-01/.credentials.json"
|
|
721
|
+
|
|
449
722
|
# ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
|
|
450
723
|
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
|
|
451
724
|
pA=$!
|
|
@@ -679,6 +952,24 @@ check "expired refresh token => re-login message" "re-login needed" "$out"
|
|
|
679
952
|
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
680
953
|
&& t_fail "dead refresh token never used" "credentials rewritten from a dead refresh token" \
|
|
681
954
|
|| t_ok "dead refresh token never used"
|
|
955
|
+
# ...and the account is PARKED, so the shim stops handing work to a login that cannot work
|
|
956
|
+
grep -q "reason=refresh-token-expired" "$ACC/acct-05/.expired" 2>/dev/null \
|
|
957
|
+
&& t_ok "limits parks an account with a dead refresh token" \
|
|
958
|
+
|| t_fail "limits .expired marker" "no .expired written for a dead refresh token"
|
|
959
|
+
# a later successful fetch (fresh login, or a token bearer) unparks it
|
|
960
|
+
printf 'sk-ant-oat01-portable-unpark' > "$ACC/acct-05/server.token"
|
|
961
|
+
rm -f "$ACC/acct-05/limits.json"
|
|
962
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
963
|
+
[ ! -f "$ACC/acct-05/.expired" ] && t_ok "a successful usage fetch clears the dead-auth marker" \
|
|
964
|
+
|| t_fail "unpark on success" ".expired survived a successful authenticated fetch"
|
|
965
|
+
# ...but an ORG-BLOCKED account authenticates fine — telemetry proves nothing about it,
|
|
966
|
+
# so its marker must survive a successful fetch (only a real call or re-login lifts it).
|
|
967
|
+
printf '%s\nreason=org-blocked marked_at=now detail=test\n' "$now" > "$ACC/acct-05/.expired"
|
|
968
|
+
rm -f "$ACC/acct-05/limits.json"
|
|
969
|
+
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
970
|
+
[ -f "$ACC/acct-05/.expired" ] && t_ok "a usage fetch does not unpark an org-blocked account" \
|
|
971
|
+
|| t_fail "org-block unpark" "telemetry cleared an org block it cannot observe"
|
|
972
|
+
rm -f "$ACC/acct-05/.expired" "$ACC/acct-05/server.token"
|
|
682
973
|
|
|
683
974
|
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
684
975
|
# The 5-min REFRESH_MIN_EXPIRED gate is the rotation-safety core: a token that expired
|
|
@@ -708,6 +999,73 @@ python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.j
|
|
|
708
999
|
&& t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
|
|
709
1000
|
rm -f "$ACC/acct-05/server.token"
|
|
710
1001
|
|
|
1002
|
+
# ---- 16b9. a 4xx from the TOKEN endpoint must not park the whole pool -----------------
|
|
1003
|
+
# Every account hits the same endpoint with the same client id, so a provider incident,
|
|
1004
|
+
# a WAF page or a client-id change 4xxs ALL of them at once. Only OAuth's own
|
|
1005
|
+
# invalid_grant (or a repeated refusal of this one account) is proof of a dead grant.
|
|
1006
|
+
srv_script="$WORK/token-server.py"
|
|
1007
|
+
cat > "$srv_script" <<'EOF'
|
|
1008
|
+
import http.server, json, sys, threading
|
|
1009
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1010
|
+
def log_message(self, *a): pass
|
|
1011
|
+
def _send(self, code, body):
|
|
1012
|
+
raw = body.encode()
|
|
1013
|
+
self.send_response(code)
|
|
1014
|
+
self.send_header('Content-Type', 'application/json')
|
|
1015
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
1016
|
+
self.end_headers()
|
|
1017
|
+
self.wfile.write(raw)
|
|
1018
|
+
def do_POST(self):
|
|
1019
|
+
try:
|
|
1020
|
+
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
1021
|
+
except Exception:
|
|
1022
|
+
pass
|
|
1023
|
+
if self.path == '/invalid-grant':
|
|
1024
|
+
self._send(400, json.dumps({'error': 'invalid_grant'}))
|
|
1025
|
+
else:
|
|
1026
|
+
self._send(400, '<html>gateway says no</html>')
|
|
1027
|
+
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
|
1028
|
+
print(srv.server_address[1], flush=True)
|
|
1029
|
+
srv.serve_forever()
|
|
1030
|
+
EOF
|
|
1031
|
+
port=""
|
|
1032
|
+
python3 "$srv_script" > "$WORK/token-port" 2>/dev/null &
|
|
1033
|
+
srv_pid=$!
|
|
1034
|
+
for _ in $(seq 1 20); do
|
|
1035
|
+
port="$(head -1 "$WORK/token-port" 2>/dev/null)"
|
|
1036
|
+
case "$port" in ''|*[!0-9]*) port=""; sleep 0.2 ;; *) break ;; esac
|
|
1037
|
+
done
|
|
1038
|
+
if [ -z "$port" ]; then
|
|
1039
|
+
kill "$srv_pid" 2>/dev/null; wait "$srv_pid" 2>/dev/null || true
|
|
1040
|
+
t_ok "token-endpoint 4xx tests skipped (cannot bind a loopback port here)"
|
|
1041
|
+
else
|
|
1042
|
+
dead_acct="$ACC/acct-05"
|
|
1043
|
+
mk_stale_creds() {
|
|
1044
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$dead_acct/.credentials.json"
|
|
1045
|
+
rm -f "$dead_acct/limits.json" "$dead_acct/.oauth-refresh.json" "$dead_acct/.expired"
|
|
1046
|
+
}
|
|
1047
|
+
# a NON-invalid_grant 400 (provider incident): back off, do NOT park
|
|
1048
|
+
mk_stale_creds
|
|
1049
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
1050
|
+
[ ! -f "$dead_acct/.expired" ] && t_ok "one opaque 4xx from the token endpoint does not park an account" \
|
|
1051
|
+
|| t_fail "token 4xx park" "a single non-invalid_grant 400 parked the account"
|
|
1052
|
+
[ -f "$dead_acct/.oauth-refresh.json" ] && t_ok "an opaque 4xx still records a backoff" \
|
|
1053
|
+
|| t_fail "token 4xx backoff" "no .oauth-refresh.json written"
|
|
1054
|
+
# ...but a repeatedly-refused account IS parked (3rd strike)
|
|
1055
|
+
CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force >/dev/null 2>&1
|
|
1056
|
+
CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/boom" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force >/dev/null 2>&1
|
|
1057
|
+
[ -f "$dead_acct/.expired" ] && t_ok "a repeatedly-refused grant is parked on the third strike" \
|
|
1058
|
+
|| t_fail "token 4xx strikes" "still not parked after three refusals"
|
|
1059
|
+
# invalid_grant is proof on the FIRST refusal
|
|
1060
|
+
mk_stale_creds
|
|
1061
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$port/invalid-grant" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
1062
|
+
grep -q "invalid_grant" "$dead_acct/.expired" 2>/dev/null \
|
|
1063
|
+
&& t_ok "invalid_grant parks the account immediately" \
|
|
1064
|
+
|| t_fail "invalid_grant park" "no marker for an explicit invalid_grant"
|
|
1065
|
+
kill "$srv_pid" 2>/dev/null; wait "$srv_pid" 2>/dev/null || true
|
|
1066
|
+
rm -f "$dead_acct/.expired" "$dead_acct/.oauth-refresh.json"
|
|
1067
|
+
fi
|
|
1068
|
+
|
|
711
1069
|
# ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
|
|
712
1070
|
# {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
|
|
713
1071
|
# must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
|
|
@@ -785,6 +1143,59 @@ check "import rejects an API key as token" "not a subscription setup-token" "$ou
|
|
|
785
1143
|
out="$(printf 'sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' | claude-accounts mint acct-01 --paste 2>&1)"
|
|
786
1144
|
check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
787
1145
|
|
|
1146
|
+
# ---- 17b. verify authenticates the way the SHIM would ---------------------------------
|
|
1147
|
+
# A dead credential next to a live portable token: the shim runs that account with the
|
|
1148
|
+
# TOKEN, so verify must too — testing it with the dead credential would fail a healthy
|
|
1149
|
+
# account and park it.
|
|
1150
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-dead","refreshToken":"r","expiresAt":1000000000000,"refreshTokenExpiresAt":1000000000000}}' > "$ACC/acct-01/.credentials.json"
|
|
1151
|
+
printf 'sk-ant-oat01-token-for-01' > "$ACC/acct-01/server.token"
|
|
1152
|
+
rm -f "$ACC/acct-01/.expired"
|
|
1153
|
+
out="$(claude-accounts verify 2>&1)"
|
|
1154
|
+
case "$out" in *"acct-01 a@test: PASS"*) t_ok "verify uses the portable token when the credential is dead" ;;
|
|
1155
|
+
*) t_fail "verify token fallback" "expected acct-01 PASS, got: $(printf '%s' "$out" | grep acct-01)" ;; esac
|
|
1156
|
+
[ ! -f "$ACC/acct-01/.expired" ] && t_ok "verify does not park an account its token can run" \
|
|
1157
|
+
|| t_fail "verify token fallback" "healthy token-auth account was parked"
|
|
1158
|
+
rm -f "$ACC/acct-01/server.token"
|
|
1159
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
1160
|
+
|
|
1161
|
+
# ---- 17c. audit: machine vocabulary (home=server vs machine_kind=linux) ---------------
|
|
1162
|
+
# `import --home` says mac|server; machine_kind() says mac|linux. If those are compared
|
|
1163
|
+
# raw, an un-authenticated account ON the server reads as "the grant lives elsewhere"
|
|
1164
|
+
# and silently drops off the re-login worklist.
|
|
1165
|
+
mkdir -p "$ACC/acct-06"
|
|
1166
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
1167
|
+
import json, sys
|
|
1168
|
+
doc = json.load(open(sys.argv[1]))
|
|
1169
|
+
doc['accounts'] = [a for a in doc['accounts'] if a['id'] != 'acct-06']
|
|
1170
|
+
doc['accounts'].append({'id': 'acct-06', 'email': 'srv@test', 'home': 'server'})
|
|
1171
|
+
doc['accounts'].sort(key=lambda a: a['id'])
|
|
1172
|
+
json.dump(doc, open(sys.argv[1], 'w'), indent=2)
|
|
1173
|
+
EOF
|
|
1174
|
+
out="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" linux | awk -F'\t' '$1=="acct-06"{print $4}')"
|
|
1175
|
+
[ "$out" = "missing" ] && t_ok "home=server on the server means NO LOGIN, not 'elsewhere'" \
|
|
1176
|
+
|| t_fail "home vocabulary" "expected missing on linux, got: $out"
|
|
1177
|
+
out="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" mac | awk -F'\t' '$1=="acct-06"{print $4}')"
|
|
1178
|
+
[ "$out" = "remote" ] && t_ok "home=server on the Mac is correctly 'elsewhere'" \
|
|
1179
|
+
|| t_fail "home vocabulary" "expected remote on mac, got: $out"
|
|
1180
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
1181
|
+
import json, sys
|
|
1182
|
+
doc = json.load(open(sys.argv[1]))
|
|
1183
|
+
doc['accounts'] = [a for a in doc['accounts'] if a['id'] != 'acct-06']
|
|
1184
|
+
json.dump(doc, open(sys.argv[1], 'w'), indent=2)
|
|
1185
|
+
EOF
|
|
1186
|
+
rm -rf "$ACC/acct-06"
|
|
1187
|
+
|
|
1188
|
+
# ---- 17d. an empty pool is never rendered as "all clear" ------------------------------
|
|
1189
|
+
emptypool="$WORK/emptypool"
|
|
1190
|
+
mkdir -p "$emptypool/tmp"
|
|
1191
|
+
printf '{"version":1,"threshold":90,"accounts":[]}' > "$emptypool/accounts.json"
|
|
1192
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$emptypool" claude-accounts expired 2>&1)"
|
|
1193
|
+
rc=$?
|
|
1194
|
+
check "expired on an empty pool says so" "No accounts registered yet" "$out"
|
|
1195
|
+
[ "$rc" = "0" ] && t_ok "empty pool exits 0" || t_fail "empty pool rc" "rc=$rc"
|
|
1196
|
+
case "$out" in *"NO LOGIN"*) t_fail "empty pool phantom row" "invented an account from a blank line" ;;
|
|
1197
|
+
*) t_ok "empty pool invents no phantom account" ;; esac
|
|
1198
|
+
|
|
788
1199
|
out="$(claude-accounts verify --quick 2>&1)"
|
|
789
1200
|
check "verify --quick passes oauth accounts" "acct-01 a@test: OK" "$out"
|
|
790
1201
|
check "verify --quick counts" "0 failure(s)" "$out"
|