claude-multiacc 1.0.4 → 1.0.6
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 +99 -14
- package/bin/claude +165 -12
- package/bin/claude-accounts +487 -53
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +508 -2
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 ;;
|
|
@@ -75,6 +85,9 @@ export CLAUDE_MULTIACC_NO_SYNC=1
|
|
|
75
85
|
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
76
86
|
# is off by default here; the throttle test re-enables it explicitly.
|
|
77
87
|
export CLAUDE_MULTIACC_MIN_FETCH=0
|
|
88
|
+
# The oauth token endpoint must NEVER be hit for real from tests: default to a missing
|
|
89
|
+
# file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
|
|
90
|
+
export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
78
91
|
|
|
79
92
|
now="$(date +%s)"
|
|
80
93
|
|
|
@@ -257,6 +270,191 @@ case "$out" in *CFG=acct-02*) t_ok "garbled marker treated as active (excluded)"
|
|
|
257
270
|
|| t_fail "garbled marker deleted" "shim destroyed a possibly-mid-write marker"
|
|
258
271
|
rm -f "$ACC"/acct-*/.limited "$ACC"/acct-*/limits.json
|
|
259
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
|
+
|
|
260
458
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
261
459
|
mkdir -p "$ACC/acct-03"
|
|
262
460
|
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
|
|
@@ -443,6 +641,84 @@ check "login --token saves a portable token" "acct-08 token saved" "$out"
|
|
|
443
641
|
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
|
|
444
642
|
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
445
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
|
+
|
|
446
722
|
# ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
|
|
447
723
|
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
|
|
448
724
|
pA=$!
|
|
@@ -618,15 +894,192 @@ d = json.load(open('$ACC/acct-01/limits.json'))
|
|
|
618
894
|
sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
|
|
619
895
|
&& t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
|
|
620
896
|
|
|
621
|
-
# ---- 16b. expired-bearer account
|
|
897
|
+
# ---- 16b. expired-bearer account: oauth refresh attempted; fail-open when it fails ----
|
|
622
898
|
mkdir -p "$ACC/acct-05"
|
|
623
|
-
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"
|
|
899
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-oldrefresh","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
624
900
|
claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
|
|
625
901
|
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
626
902
|
rc=$?
|
|
903
|
+
check "failed oauth refresh logged with backoff" "acct-05: oauth refresh failed" "$out"
|
|
627
904
|
check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
|
|
628
905
|
[ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
|
|
629
906
|
[ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
|
|
907
|
+
[ -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "refresh failure recorded in .oauth-refresh.json" || t_fail "refresh backoff file" "missing"
|
|
908
|
+
|
|
909
|
+
# ---- 16b2. refresh backoff honored: even a now-working endpoint is not retried early --
|
|
910
|
+
cat > "$WORK/token-ok.json" <<'EOF'
|
|
911
|
+
{"access_token":"sk-ant-oat01-refreshednew","refresh_token":"sk-ant-ort01-rotatednew","expires_in":28800,"refresh_token_expires_in":2592000}
|
|
912
|
+
EOF
|
|
913
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
914
|
+
check "refresh backoff honored (no early retry)" "acct-05: no fresh bearer" "$out"
|
|
915
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
916
|
+
&& t_fail "backoff prevented refresh" "credentials rewritten inside the backoff window" \
|
|
917
|
+
|| t_ok "no refresh inside the backoff window"
|
|
918
|
+
|
|
919
|
+
# ---- 16b3. --force bypasses refresh backoff: rotated credential persisted + fetch ok --
|
|
920
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
921
|
+
check "--force refreshes the expired oauth token" "acct-05: oauth access token refreshed" "$out"
|
|
922
|
+
check "refreshed account fetches telemetry" "acct-05: ok" "$out"
|
|
923
|
+
python3 - "$ACC/acct-05/.credentials.json" <<'EOF'
|
|
924
|
+
import json, os, stat, sys, time
|
|
925
|
+
p = sys.argv[1]
|
|
926
|
+
o = json.load(open(p))['claudeAiOauth']
|
|
927
|
+
assert o['accessToken'] == 'sk-ant-oat01-refreshednew', o['accessToken']
|
|
928
|
+
assert o['refreshToken'] == 'sk-ant-ort01-rotatednew', 'refresh token was not rotated'
|
|
929
|
+
assert o['expiresAt'] / 1000.0 > time.time() + 3600, 'expiresAt not advanced'
|
|
930
|
+
assert o['refreshTokenExpiresAt'] / 1000.0 > time.time() + 86400, 'refreshTokenExpiresAt not advanced'
|
|
931
|
+
mode = stat.S_IMODE(os.stat(p).st_mode)
|
|
932
|
+
assert mode == 0o600, oct(mode)
|
|
933
|
+
EOF
|
|
934
|
+
[ $? -eq 0 ] && t_ok "rotated credential persisted with 0600" || t_fail "credential rotation" "see assertions above"
|
|
935
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "successful refresh clears the backoff file" || t_fail "refresh backoff clear" "file still present"
|
|
936
|
+
[ -f "$ACC/acct-05/limits.json" ] && t_ok "telemetry written right after refresh" || t_fail "limits.json after refresh" "missing"
|
|
937
|
+
grep -qE "sk-ant-ort01|sk-ant-oat01-refreshednew" "$ACC/limits.log" \
|
|
938
|
+
&& t_fail "limits.log leaks no tokens" "a token leaked into limits.log" \
|
|
939
|
+
|| t_ok "limits.log leaks no tokens"
|
|
940
|
+
|
|
941
|
+
# ---- 16b4. steady state: fresh data means no refresh and no fetch (quiet skip) --------
|
|
942
|
+
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
943
|
+
printf '%s' "$out" | grep -q "acct-05" \
|
|
944
|
+
&& t_fail "fresh account skipped silently" "unexpected acct-05 output: $out" \
|
|
945
|
+
|| t_ok "fresh account skipped silently (no refresh, no fetch)"
|
|
946
|
+
|
|
947
|
+
# ---- 16b5. an EXPIRED refresh token is never sent: clear re-login message -------------
|
|
948
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-dead","expiresAt":1000,"refreshTokenExpiresAt":1000}}' > "$ACC/acct-05/.credentials.json"
|
|
949
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
950
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
951
|
+
check "expired refresh token => re-login message" "re-login needed" "$out"
|
|
952
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
953
|
+
&& t_fail "dead refresh token never used" "credentials rewritten from a dead refresh token" \
|
|
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"
|
|
973
|
+
|
|
974
|
+
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
975
|
+
# The 5-min REFRESH_MIN_EXPIRED gate is the rotation-safety core: a token that expired
|
|
976
|
+
# moments ago may be mid-refresh by a live claude session; grants must not race it.
|
|
977
|
+
# Not even --force may bypass this.
|
|
978
|
+
recent_ms="$(python3 -c 'import time; print(int((time.time()-100)*1000))')"
|
|
979
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-recent","refreshToken":"sk-ant-ort01-live","expiresAt":%s,"refreshTokenExpiresAt":9999999999999}}' "$recent_ms" > "$ACC/acct-05/.credentials.json"
|
|
980
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
981
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
982
|
+
check "recently-expired token is not refreshed (even --force)" "acct-05: no fresh bearer" "$out"
|
|
983
|
+
grep -q "sk-ant-oat01-recent" "$ACC/acct-05/.credentials.json" \
|
|
984
|
+
&& t_ok "recently-expired credential left untouched" \
|
|
985
|
+
|| t_fail "REFRESH_MIN_EXPIRED gate" "credential was rewritten within the 5-min grace window"
|
|
986
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "no backoff recorded for a gated (skipped) refresh" \
|
|
987
|
+
|| t_fail "gated refresh backoff" ".oauth-refresh.json written despite the gate"
|
|
988
|
+
|
|
989
|
+
# ---- 16b7. server.token accounts are NEVER oauth-refreshed (token bearer wins) ---------
|
|
990
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
991
|
+
printf 'sk-ant-oat01-portable-token-05' > "$ACC/acct-05/server.token"
|
|
992
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
993
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
994
|
+
check "token-bearer account fetches without refresh" "acct-05: ok" "$out"
|
|
995
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
996
|
+
&& t_fail "server.token exempts oauth refresh" "oauth creds were rotated despite a portable token" \
|
|
997
|
+
|| t_ok "server.token account never oauth-refreshed (grant not run)"
|
|
998
|
+
python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='token' else 1)" \
|
|
999
|
+
&& t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
|
|
1000
|
+
rm -f "$ACC/acct-05/server.token"
|
|
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
|
+
|
|
1069
|
+
# ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
|
|
1070
|
+
# {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
|
|
1071
|
+
# must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
|
|
1072
|
+
# acct-01 is first in the manifest, so corrupting it exercises the loop guarantee.
|
|
1073
|
+
cp "$ACC/acct-01/.credentials.json" "$WORK/acct01-creds.bak"
|
|
1074
|
+
printf '{"claudeAiOauth": null}' > "$ACC/acct-01/.credentials.json"
|
|
1075
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.oauth-refresh.json" "$ACC/acct-02/limits.json"
|
|
1076
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
1077
|
+
rc=$?
|
|
1078
|
+
[ "$rc" = "0" ] && t_ok "null claudeAiOauth exits 0 (fail open)" || t_fail "null claudeAiOauth rc" "rc=$rc: $out"
|
|
1079
|
+
check "null claudeAiOauth degrades only that account" "acct-01: no fresh bearer" "$out"
|
|
1080
|
+
[ -f "$ACC/acct-02/limits.json" ] && t_ok "accounts after a malformed one still refresh" \
|
|
1081
|
+
|| t_fail "fail-open loop guarantee" "acct-02 was starved by acct-01's malformed creds"
|
|
1082
|
+
cp "$WORK/acct01-creds.bak" "$ACC/acct-01/.credentials.json"
|
|
630
1083
|
claude-accounts remove acct-05 --yes >/dev/null 2>&1
|
|
631
1084
|
|
|
632
1085
|
# ---- 16c. codex-review: security hardening -----------------------------------------
|
|
@@ -690,6 +1143,59 @@ check "import rejects an API key as token" "not a subscription setup-token" "$ou
|
|
|
690
1143
|
out="$(printf 'sk-ant-api03-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' | claude-accounts mint acct-01 --paste 2>&1)"
|
|
691
1144
|
check "mint --paste rejects an API key" "not a subscription setup-token" "$out"
|
|
692
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
|
+
|
|
693
1199
|
out="$(claude-accounts verify --quick 2>&1)"
|
|
694
1200
|
check "verify --quick passes oauth accounts" "acct-01 a@test: OK" "$out"
|
|
695
1201
|
check "verify --quick counts" "0 failure(s)" "$out"
|