claude-multiacc 1.0.13 → 1.0.15

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.
@@ -6,7 +6,19 @@ set -u
6
6
 
7
7
  REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
8
8
  WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")"
9
- trap 'rm -rf "$WORK"' EXIT
9
+ # Loopback stub servers register their pid here. Deleting $WORK does not kill a running
10
+ # python process, so an interrupted run (CI cancel, Ctrl-C) would otherwise leave one
11
+ # listening until the machine goes away.
12
+ STUB_PIDS=""
13
+ cleanup() {
14
+ local p
15
+ for p in $STUB_PIDS; do
16
+ kill "$p" 2>/dev/null
17
+ wait "$p" 2>/dev/null || true
18
+ done
19
+ rm -rf "$WORK"
20
+ }
21
+ trap cleanup EXIT
10
22
 
11
23
  PASS=0
12
24
  FAIL=0
@@ -74,6 +86,22 @@ for a in "$@"; do
74
86
  --echo-stdin) cat; exit 0 ;;
75
87
  esac
76
88
  done
89
+ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
90
+ # Mimic the real client: register the run in the ACCOUNT dir for its lifetime, write
91
+ # the session transcript into the (shared) projects tree, delete the registry on exit.
92
+ mkdir -p "$CLAUDE_CONFIG_DIR/sessions" "$CLAUDE_CONFIG_DIR/projects/-proj"
93
+ printf '{"pid":%s,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
94
+ "$$" "$FAKE_SESSION_ID" "$(date -u +%s)" > "$CLAUDE_CONFIG_DIR/sessions/$$.json"
95
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$FAKE_SESSION_ID" \
96
+ > "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
97
+ if [ -n "${FAKE_LIMIT_RESET:-}" ]; then
98
+ printf '{"type":"assistant","timestamp":"%s","message":{"content":[{"type":"text","text":"You'"'"'ve hit your session limit"}]},"quotaLimits":{"status":"rejected","resetsAt":%s,"unifiedRateLimitFallbackAvailable":false,"rateLimitType":"five_hour","overageStatus":"rejected"},"error":"rate_limit","isApiErrorMessage":true,"apiErrorStatus":429,"sessionId":"%s"}\n' \
99
+ "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$FAKE_LIMIT_RESET" "$FAKE_SESSION_ID" \
100
+ >> "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
101
+ fi
102
+ sleep "${FAKE_SESSION_HOLD:-4}"
103
+ rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
104
+ fi
77
105
  echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
78
106
  EOF
79
107
  chmod +x "$FAKEBIN/claude"
@@ -86,6 +114,10 @@ export CLAUDE_MULTIACC_NO_SYNC=1
86
114
  # Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
87
115
  # is off by default here; the throttle test re-enables it explicitly.
88
116
  export CLAUDE_MULTIACC_MIN_FETCH=0
117
+ # The client-rate-limit scan memoizes a CLEAN result for a few seconds; tests plant
118
+ # rejections and expect them seen on the very next run, so the memo is off by default
119
+ # here (12i re-enables it to prove the memo itself works).
120
+ export CLAUDE_MULTIACC_CLIENT_SCAN_TTL=0
89
121
  # The oauth token endpoint must NEVER be hit for real from tests: default to a missing
90
122
  # file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
91
123
  export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
@@ -210,6 +242,19 @@ out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
210
242
  check "pin to auth-less dir (ceremony)" "CFG=acct-07" "$out"
211
243
  rmdir "$ACC/acct-07"
212
244
 
245
+ # ---- 6c. pin uses the portable token when the credential beside it is DEAD ----
246
+ # Regression: the pin path used to export the token only when NO credential file
247
+ # existed, so pinning an account whose login had died failed with "OAuth session
248
+ # expired" even though its setup-token was sitting right there — the exact state a
249
+ # fleet-distributed token lands in on a machine that still has a stale login.
250
+ mkdir -p "$ACC/acct-07"
251
+ printf '{"claudeAiOauth":{"accessToken":"dead","refreshToken":"","expiresAt":0,"refreshTokenExpiresAt":1}}' \
252
+ > "$ACC/acct-07/.credentials.json"
253
+ printf 'sk-ant-oat01-PINNEDDEADCREDSPINNEDDEADCREDSPINNEDDEADCREDS00' > "$ACC/acct-07/server.token"
254
+ out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
255
+ check "pinned dead-credential account still runs under its token" "TOK=sk-ant-oat01-PINNED" "$out"
256
+ rm -rf "$ACC/acct-07"
257
+
213
258
  # ---- 7. limited marker excludes account ------------------------------------
214
259
  printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
215
260
  all2=1
@@ -508,6 +553,361 @@ CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits -
508
553
  [ -f "$ACC/acct-01/.limited" ] && t_ok "error-cooldown marker survives clean limits refresh" || t_fail "cooldown vs limits" "marker was cleared early"
509
554
  rm -f "$ACC/acct-01/.limited"
510
555
 
556
+ # ---- 12f-12h. client-reported rate limits + rotation (own two-account pool) -------
557
+ # The shared pool has picked up a third account by now, so these run in an instance root
558
+ # of their own: with exactly two accounts, "went somewhere else" and "rotated" are both
559
+ # unambiguous.
560
+ CLP="$WORK/client-limit-pool"
561
+ mkdir -p "$CLP/tmp"
562
+ : > "$CLP/.limits-kick"
563
+ cat > "$CLP/accounts.json" <<'EOF'
564
+ {"version":1,"threshold":90,"accounts":[
565
+ {"id":"acct-01","email":"cl1@test","home":"mac","added_at":"2026-07-13T00:00:00Z"},
566
+ {"id":"acct-02","email":"cl2@test","home":"mac","added_at":"2026-07-13T00:00:00Z"}]}
567
+ EOF
568
+ for i in 01 02; do
569
+ mkdir -p "$CLP/acct-$i"
570
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-cl%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' "$i" > "$CLP/acct-$i/.credentials.json"
571
+ done
572
+ export CLAUDE_ACCOUNTS_ROOT="$CLP"
573
+ ACC_SAVED="$ACC"
574
+ ACC="$CLP"
575
+
576
+ # ---- 12f. a rate limit hit by an INTERACTIVE session takes its account out ---------
577
+ # The reported bug: a tmux session runs into its 5h limit, the user quits and starts
578
+ # `claude` again, and the pool hands back the same dead account. Interactive runs are
579
+ # exec'd, so the -p retry path above never sees them — the only trace of the rejection
580
+ # is the one Claude Code writes itself, in the session transcript.
581
+ mkclientlimit() { # mkclientlimit <acct dir> <session id> <resetsAt> [rejection ISO ts]
582
+ mkdir -p "$1/projects/-proj"
583
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$2" > "$1/projects/-proj/$2.jsonl"
584
+ printf '{"type":"assistant","timestamp":"%s","message":{"content":[{"type":"text","text":"limit"}]},"quotaLimits":{"status":"rejected","resetsAt":%s,"unifiedRateLimitFallbackAvailable":false,"rateLimitType":"five_hour","overageStatus":"rejected"},"error":"rate_limit","isApiErrorMessage":true,"apiErrorStatus":429,"sessionId":"%s"}\n' \
585
+ "${4:-$(date -u +%Y-%m-%dT%H:%M:%S.000Z)}" "$3" "$2" >> "$1/projects/-proj/$2.jsonl"
586
+ # index line = "<id> <ISO claim>": the account that owned the session, and from when
587
+ printf '%s %s\n' "$2" "$(date -u -r "$(( $(date +%s) - 600 ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$(( $(date +%s) - 600 ))" +%Y-%m-%dT%H:%M:%SZ)" > "$1/.sessions-index"
588
+ }
589
+ SID1="81bf8b20-013f-4414-8878-e0289bec9ad0"
590
+ RESET1=$(( $(date +%s) + 1800 ))
591
+ mkclientlimit "$ACC/acct-01" "$SID1" "$RESET1"
592
+ all2=1
593
+ for _ in $(seq 1 12); do
594
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
595
+ done
596
+ [ "$all2" = "1" ] && t_ok "client-reported rate limit excludes the account" \
597
+ || t_fail "client rate limit" "the rejected account was picked again"
598
+ first="$(head -1 "$ACC/acct-01/.limited" 2>/dev/null)"
599
+ [ "$first" = "$RESET1" ] && t_ok "marker carries the API's own reset time" \
600
+ || t_fail "client marker reset" "want $RESET1, got '${first:-<none>}'"
601
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
602
+ && t_ok "marker is tagged client-rate-limit" || t_fail "client marker reason" "$(cat "$ACC/acct-01/.limited" 2>/dev/null)"
603
+ out="$(CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
604
+ check "explicit pin still wins over a client-reported limit" "CFG=acct-01" "$out"
605
+
606
+ # a marker the client earned survives a clean telemetry pass while its window is open
607
+ CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits --quiet
608
+ [ -f "$ACC/acct-01/.limited" ] && t_ok "client-rate-limit marker survives a clean limits refresh" \
609
+ || t_fail "client marker vs limits" "marker was cleared while the window was still open"
610
+ rm -f "$ACC/acct-01/.limited"
611
+
612
+ # an ALREADY-ELAPSED rejection is history, not an exclusion
613
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) - 60 ))"
614
+ hits1=0
615
+ for _ in $(seq 1 12); do
616
+ case "$(claude 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
617
+ done
618
+ { [ "$hits1" -gt 0 ] && [ ! -f "$ACC/acct-01/.limited" ]; } \
619
+ && t_ok "an elapsed client rejection does not exclude" \
620
+ || t_fail "elapsed client rejection" "acct-01 hits=$hits1 marker=$([ -f "$ACC/acct-01/.limited" ] && echo yes || echo no)"
621
+
622
+ # opt-out
623
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
624
+ rm -f "$ACC/acct-01/.limited"
625
+ hits1=0
626
+ for _ in $(seq 1 12); do
627
+ case "$(CLAUDE_MULTIACC_CLIENT_LIMITS=0 claude 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
628
+ done
629
+ { [ "$hits1" -gt 0 ] && [ ! -f "$ACC/acct-01/.limited" ]; } \
630
+ && t_ok "CLAUDE_MULTIACC_CLIENT_LIMITS=0 turns the scan off" \
631
+ || t_fail "client-limit opt-out" "still excluded with the scan disabled"
632
+
633
+ # a hostile session index must not walk out of the pool
634
+ printf '../../../../etc/passwd x\n-e x\n%s %s\n' "$SID1" "2026-01-01T00:00:00Z" > "$ACC/acct-01/.sessions-index"
635
+ claude >/dev/null 2>&1
636
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
637
+ && t_ok "traversal and option-shaped ids are ignored, real ids still scanned" \
638
+ || t_fail "session index traversal" "scan broke on a hostile entry"
639
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index
640
+ rm -rf "$ACC"/acct-0*/projects
641
+
642
+ # ---- 12g. the whole round trip: a run registers itself, the NEXT run avoids it -----
643
+ # No .sessions-index is planted here. The shim's detached capture has to learn the
644
+ # session id from $acct/sessions/<pid>.json WHILE the run is alive — after it exits the
645
+ # client deletes that file and nothing else can name the transcript.
646
+ rm -f "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick
647
+ FAKE_SESSION_ID="9f3c1d2e-0000-4000-8000-abcdefabcdef" \
648
+ FAKE_LIMIT_RESET="$(( $(date +%s) + 1800 ))" \
649
+ FAKE_SESSION_HOLD=5 claude >/dev/null 2>&1
650
+ hit="$(ls "$ACC"/acct-0*/.sessions-index 2>/dev/null | head -1)"
651
+ limited_dir="$(dirname "${hit:-/nonexistent}")"
652
+ [ -n "$hit" ] && t_ok "the run's own session id is captured while it is alive" \
653
+ || t_fail "session capture" "no .sessions-index was written"
654
+ out="$(claude 2>&1)"
655
+ case "$out" in
656
+ *"CFG=$(basename "$limited_dir")"*)
657
+ t_fail "restart after a limit hit" "landed straight back on $(basename "$limited_dir")" ;;
658
+ *) t_ok "quitting a limit-hit session and restarting lands on another account" ;;
659
+ esac
660
+ grep -q 'reason=client-rate-limit' "$limited_dir/.limited" 2>/dev/null \
661
+ && t_ok "the account that hit the limit is marked from its own transcript" \
662
+ || t_fail "post-run marking" "no client-rate-limit marker on $(basename "$limited_dir")"
663
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index
664
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
665
+
666
+ # ---- 12h. equal-headroom picks ROTATE instead of re-rolling a coin -----------------
667
+ # With telemetry stale (the usage endpoint 429s its own callers for hours) every account
668
+ # scores NEUTRAL, so this tie-break is the only thing standing between a restart and the
669
+ # account it just walked away from.
670
+ rm -f "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick "$ACC"/acct-0*/limits.json
671
+ seq_out=""
672
+ for _ in $(seq 1 8); do
673
+ case "$(claude 2>&1)" in
674
+ *CFG=acct-01*) seq_out="${seq_out}1" ;;
675
+ *CFG=acct-02*) seq_out="${seq_out}2" ;;
676
+ *) seq_out="${seq_out}?" ;;
677
+ esac
678
+ done
679
+ case "$seq_out" in
680
+ 12121212|21212121) t_ok "equal-score picks round-robin across the pool ($seq_out)" ;;
681
+ *) t_fail "round-robin tie-break" "sequence $seq_out (want strict alternation)" ;;
682
+ esac
683
+ # ... and the rotation must never override real headroom
684
+ lj 80 10 80 > "$ACC/acct-01/limits.json"
685
+ lj 20 10 20 > "$ACC/acct-02/limits.json"
686
+ all2=1
687
+ for _ in $(seq 1 8); do
688
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
689
+ done
690
+ [ "$all2" = "1" ] && t_ok "rotation never outranks measured headroom" \
691
+ || t_fail "rotation vs headroom" "rotation pulled work onto the busier account"
692
+ rm -f "$ACC"/acct-0*/limits.json "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick
693
+
694
+ # ---- 12i. the clean-scan memo throttles re-reads without stranding a limit ---------
695
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
696
+ rm -rf "$ACC"/acct-0*/projects
697
+ mkdir -p "$ACC/acct-01/projects/-proj"
698
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$SID1" > "$ACC/acct-01/projects/-proj/$SID1.jsonl"
699
+ printf '%s %s\n' "$SID1" "2026-01-01T00:00:00Z" > "$ACC/acct-01/.sessions-index"
700
+ claude >/dev/null 2>&1 # a clean scan over a real (rejection-free) transcript
701
+ [ -f "$ACC/acct-01/.client-scan" ] \
702
+ && t_ok "a clean client scan is memoized" || t_fail "clean scan memo" "no .client-scan written"
703
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
704
+ CLAUDE_MULTIACC_CLIENT_SCAN_TTL=3600 claude >/dev/null 2>&1
705
+ [ ! -f "$ACC/acct-01/.limited" ] \
706
+ && t_ok "the memo suppresses a re-read inside its window" \
707
+ || t_fail "clean scan memo" "rescanned inside the memo window"
708
+ rm -f "$ACC"/acct-0*/.client-scan
709
+ claude >/dev/null 2>&1
710
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
711
+ && t_ok "once the memo lapses the rejection is seen" \
712
+ || t_fail "clean scan memo" "the rejection was never picked up"
713
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
714
+ rm -rf "$ACC"/acct-0*/projects
715
+
716
+ # ---- 12j. codex-review: corrupt pool numbers never reach bash arithmetic -----------
717
+ # An out-of-range value in any scraped number makes `[ x -lt y ]` print "integer
718
+ # expression expected" on STDERR — which a service-spawned `claude -p` must never see —
719
+ # and makes $((x + 1)) wrap negative.
720
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
721
+ rm -rf "$ACC"/acct-0*/projects
722
+ BIG="99999999999999999999999999999999"
723
+ printf '%s\n' "$BIG" > "$ACC/.last-pick"
724
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":1,"max_percent":%s,"buckets":[]}' "$BIG" "$BIG" "$BIG" > "$ACC/acct-02/limits.json"
725
+ err="$(claude 2>&1 >/dev/null)"
726
+ [ -z "$err" ] && t_ok "absurd pool numbers keep stderr byte-clean" \
727
+ || t_fail "corrupt number handling" "stderr: $(printf '%s' "$err" | head -c 160)"
728
+ out="$(claude 2>/dev/null)"
729
+ case "$out" in *CFG=acct-0*) t_ok "selection still works with corrupt numbers ($out)" ;;
730
+ *) t_fail "corrupt number handling" "selection produced: $out" ;; esac
731
+ # caller-supplied numbers are just as capable of reaching `[ -lt ]` as pool state
732
+ : > "$ACC/acct-01/.client-scan"
733
+ err="$(CLAUDE_MULTIACC_CLIENT_SCAN_TTL=bogus CLAUDE_MULTIACC_THRESHOLD=nonsense claude 2>&1 >/dev/null)"
734
+ out="$(CLAUDE_MULTIACC_CLIENT_SCAN_TTL=bogus CLAUDE_MULTIACC_THRESHOLD=nonsense claude 2>/dev/null)"
735
+ { [ -z "$err" ] && case "$out" in *CFG=acct-0*) true ;; *) false ;; esac; } \
736
+ && t_ok "garbage in the client-scan TTL / threshold env vars keeps stderr clean" \
737
+ || t_fail "env number validation" "stderr: $(printf '%s' "$err" | head -c 160) out: $out"
738
+ rm -f "$ACC"/acct-0*/.client-scan
739
+
740
+ printf '%s\nbucket=x percent=? reason=limits\n' "$BIG" > "$ACC/acct-01/.limited"
741
+ err="$(claude 2>&1 >/dev/null)"
742
+ { [ -z "$err" ] && [ -f "$ACC/acct-01/.limited" ]; } \
743
+ && t_ok "an absurd .limited reset reads as LIMITED, silently" \
744
+ || t_fail "corrupt marker handling" "stderr: $(printf '%s' "$err" | head -c 160)"
745
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/limits.json "$ACC/.last-pick"
746
+
747
+ # ---- 12m. a session id belongs to ONE account, and only from when it took it over ---
748
+ # `claude --continue` resumes the SAME session id under whichever account the pool hands
749
+ # out next (the client only mints a new id with --fork-session) and the transcript is
750
+ # shared — so an id claimed by acct-02 must stop being acct-01's evidence, and a rejection
751
+ # recorded before the handover must not be charged to the new owner.
752
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
753
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
754
+ SIDC="0badc0de-1111-4111-8111-abcdefabcdef"
755
+ NOWS="$(date -u +%s)"
756
+ OLDISO="$(date -u -r "$((NOWS - 7200))" +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -d "@$((NOWS - 7200))" +%Y-%m-%dT%H:%M:%S.000Z)"
757
+ # acct-01 ran the session two hours ago and hit a limit then; acct-02 has just resumed it.
758
+ mkclientlimit "$ACC/acct-01" "$SIDC" "$((NOWS + 1800))" "$OLDISO"
759
+ mkdir -p "$ACC/acct-02/sessions" "$ACC/acct-02/projects"
760
+ ln -s "$ACC/acct-01/projects/-proj" "$ACC/acct-02/projects/-proj"
761
+ printf '{"pid":4242,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
762
+ "$SIDC" "$NOWS" > "$ACC/acct-02/sessions/4242.json"
763
+ claude >/dev/null 2>&1
764
+ grep -q -- "^$SIDC " "$ACC/acct-02/.sessions-index" 2>/dev/null \
765
+ && t_ok "a resumed session id is claimed by the account now running it" \
766
+ || t_fail "session id handover" "acct-02 never claimed the resumed id"
767
+ grep -q -- "^$SIDC " "$ACC/acct-01/.sessions-index" 2>/dev/null \
768
+ && t_fail "session id handover" "acct-01 still claims an id acct-02 took over" \
769
+ || t_ok "the previous owner releases a resumed session id"
770
+ [ ! -f "$ACC/acct-02/.limited" ] \
771
+ && t_ok "a rejection older than the handover is not charged to the new owner" \
772
+ || t_fail "claim-time scoping" "acct-02 was marked for a limit acct-01 hit"
773
+ rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
774
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
775
+
776
+ # two accounts claiming the SAME id (a crossed race, or corrupt state) must not both be
777
+ # marked off one shared transcript — at most the newer claimant may answer for it
778
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
779
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
780
+ SIDD="0dadc0de-2222-4222-8222-abcdefabcdef" # hex only, or sess_id_ok drops it and the
781
+ # scan never reaches the conflict check
782
+ NOWD="$(date -u +%s)"
783
+ mkclientlimit "$ACC/acct-01" "$SIDD" "$((NOWD + 1800))"
784
+ mkdir -p "$ACC/acct-02/projects"
785
+ ln -s "$ACC/acct-01/projects/-proj" "$ACC/acct-02/projects/-proj"
786
+ CLAIM1="$(cat "$ACC/acct-01/.sessions-index")"
787
+ printf '%s\n' "$CLAIM1" > "$ACC/acct-02/.sessions-index" # identical id AND claim
788
+ claude >/dev/null 2>&1
789
+ n_marked=0
790
+ for d in "$ACC/acct-01" "$ACC/acct-02"; do [ -f "$d/.limited" ] && n_marked=$((n_marked+1)); done
791
+ [ "$n_marked" -eq 0 ] \
792
+ && t_ok "an id claimed by two accounts marks neither (ambiguity => no attribution)" \
793
+ || t_fail "duplicate session claim" "$n_marked accounts were marked off one transcript"
794
+ # ...and it is the NEWEST rival claim that decides, not whichever one grep prints first
795
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
796
+ { printf '%s 2020-01-01T00:00:00Z\n' "$SIDD"; printf '%s 2090-01-01T00:00:00Z\n' "$SIDD"; } \
797
+ > "$ACC/acct-02/.sessions-index"
798
+ claude >/dev/null 2>&1
799
+ [ ! -f "$ACC/acct-01/.limited" ] \
800
+ && t_ok "a stale rival claim does not hide a newer one" \
801
+ || t_fail "duplicate session claim" "only the first rival claim was inspected"
802
+ rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
803
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
804
+
805
+ # a SYMLINKED registry entry belongs to whatever it points at, not to this account
806
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
807
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
808
+ FOREIGN="$WORK/foreign-session.json"
809
+ printf '{"pid":9,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
810
+ "$SID1" "$(( $(date -u +%s) - 600 ))" > "$FOREIGN"
811
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
812
+ rm -f "$ACC/acct-01/.sessions-index"
813
+ mkdir -p "$ACC/acct-01/sessions"
814
+ ln -s "$FOREIGN" "$ACC/acct-01/sessions/9.json"
815
+ claude >/dev/null 2>&1
816
+ [ ! -f "$ACC/acct-01/.limited" ] \
817
+ && t_ok "a symlinked session registry entry is not this account's evidence" \
818
+ || t_fail "symlinked registry entry" "a foreign registry entry marked the account LIMITED"
819
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
820
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
821
+
822
+ # ---- 12n. an option-shaped session id must not hang the shim before exec ------------
823
+ # "-e" is hex+dash, so a charset check alone lets it through; grep then treats it as a
824
+ # flag, loses its file operand and blocks on the shim's OWN stdin — a hang before exec.
825
+ rm -f "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
826
+ mkdir -p "$ACC/acct-01/sessions"
827
+ printf '{"pid":7,"sessionId":"-e","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' "$(date -u +%s)" \
828
+ > "$ACC/acct-01/sessions/7.json"
829
+ printf 'deadbeef 2026-01-01T00:00:00Z\n' > "$ACC/acct-01/.sessions-index"
830
+ FIFO="$WORK/hangfifo"
831
+ rm -f "$FIFO"; mkfifo "$FIFO"
832
+ exec 9<>"$FIFO" # holds the fifo open: stdin that never EOFs
833
+ ( claude < "$FIFO" > "$WORK/hang.out" 2>&1 ) &
834
+ hangpid=$!
835
+ waited=0
836
+ while [ "$waited" -lt 10 ] && kill -0 "$hangpid" 2>/dev/null; do sleep 1; waited=$((waited+1)); done
837
+ if kill -0 "$hangpid" 2>/dev/null; then
838
+ kill -9 "$hangpid" 2>/dev/null
839
+ t_fail "option-shaped session id" "the shim hung for ${waited}s before exec"
840
+ else
841
+ wait "$hangpid" 2>/dev/null || true
842
+ case "$(cat "$WORK/hang.out" 2>/dev/null)" in
843
+ *CFG=acct-0*) t_ok "an option-shaped session id neither hangs nor breaks selection" ;;
844
+ *) t_fail "option-shaped session id" "selection produced: $(head -c 120 "$WORK/hang.out")" ;;
845
+ esac
846
+ fi
847
+ exec 9>&-
848
+ rm -f "$FIFO" "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
849
+ rm -rf "$ACC"/acct-0*/sessions
850
+
851
+ # a SHARED session registry proves nothing about which account ran what
852
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
853
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
854
+ SHAREDS="$WORK/cl-shared-sessions"
855
+ mkdir -p "$SHAREDS"
856
+ printf '{"pid":1,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' "$SID1" "$(date -u +%s)" > "$SHAREDS/1.json"
857
+ for i in 01 02; do ln -s "$SHAREDS" "$ACC/acct-$i/sessions"; done
858
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
859
+ rm -f "$ACC/acct-01/.sessions-index" # only the shared registry could supply the id
860
+ claude >/dev/null 2>&1
861
+ { [ ! -f "$ACC/acct-01/.limited" ] && [ ! -f "$ACC/acct-02/.limited" ]; } \
862
+ && t_ok "a shared session registry never marks an account (fail open)" \
863
+ || t_fail "shared session registry" "a shared registry marked an account LIMITED"
864
+ rm -f "$ACC"/acct-0*/sessions "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
865
+ rm -rf "$ACC"/acct-0*/projects
866
+
867
+ # ---- 12k. codex-review: rotation must not serialise a parallel burst ---------------
868
+ # The first cut of this used a monotonic counter and picked the account with the OLDEST
869
+ # stamp. Every member of a concurrent burst reads the same stamps, computes the same
870
+ # "oldest", and piles onto one account. Rotation only ever drops the ONE account just
871
+ # handed out; everything else is still sampled at random.
872
+ rm -f "$ACC/.last-pick" "$ACC"/acct-0*/limits.json "$ACC"/acct-0*/.limited
873
+ # THREE accounts on purpose. Rotation drops the one just handed out, so in a two-account
874
+ # pool a burst legitimately lands entirely on the single alternative and the test would
875
+ # be measuring nothing (it flaked exactly that way on Linux). With three, the burst must
876
+ # spread over the two that remain — which is precisely what the counter version could not.
877
+ mkdir -p "$ACC/acct-09"
878
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-cl09","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-09/.credentials.json"
879
+ : > "$WORK/burst.out"
880
+ for _ in $(seq 1 16); do ( claude >> "$WORK/burst.out" 2>&1 ) & done
881
+ wait
882
+ b_distinct="$(grep -o 'CFG=acct-[0-9]*' "$WORK/burst.out" | sort -u | tr '\n' ' ')"
883
+ b_n="$(printf '%s' "$b_distinct" | wc -w | tr -d ' ')"
884
+ [ "$b_n" -ge 2 ] \
885
+ && t_ok "a parallel burst still spreads across the pool ($b_distinct)" \
886
+ || t_fail "burst spreading" "all 16 concurrent runs took $b_distinct — rotation serialised the burst"
887
+ rm -rf "$ACC/acct-09"
888
+
889
+ # ---- 12l. codex-review: an unwritable pool root never prints to stderr --------------
890
+ # `cmd > file 2>/dev/null` does NOT silence a failed redirect: bash applies the
891
+ # redirections in order, so the open fails while stderr is still the caller's.
892
+ RO="$WORK/readonly-pool"
893
+ mkdir -p "$RO/acct-01" "$RO/acct-02"
894
+ cp "$ACC/accounts.json" "$RO/accounts.json"
895
+ for i in 01 02; do
896
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-ro","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$RO/acct-$i/.credentials.json"
897
+ done
898
+ : > "$RO/.limits-kick"
899
+ chmod 555 "$RO" "$RO/acct-01" "$RO/acct-02"
900
+ err="$(CLAUDE_ACCOUNTS_ROOT="$RO" claude 2>&1 >/dev/null)"
901
+ out="$(CLAUDE_ACCOUNTS_ROOT="$RO" claude 2>/dev/null)"
902
+ chmod 755 "$RO" "$RO/acct-01" "$RO/acct-02"
903
+ [ -z "$err" ] && t_ok "a read-only pool root keeps stderr byte-clean" \
904
+ || t_fail "read-only pool root" "stderr: $(printf '%s' "$err" | head -c 200)"
905
+ case "$out" in *CFG=acct-0*) t_ok "a read-only pool root still selects ($out)" ;;
906
+ *) t_fail "read-only pool root" "selection produced: $out" ;; esac
907
+
908
+ ACC="$ACC_SAVED"
909
+ unset CLAUDE_ACCOUNTS_ROOT
910
+
511
911
  # ---- 12e. TTY stdin: retry disabled so the terminal is never swapped for /dev/null --
512
912
  if command -v script >/dev/null 2>&1; then
513
913
  # `claude -p` on a TTY with no prompt arg reads the terminal. Under a PTY the shim
@@ -892,7 +1292,20 @@ d['backoff'] = 600
892
1292
  json.dump(d, open(p + '.tmp', 'w'), indent=1); os.replace(p + '.tmp', p)
893
1293
  EOF
894
1294
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
895
- check "429 backoff honored" "acct-01: backing off after 429" "$out"
1295
+ # A backoff with no recorded cause (an older build wrote these) still reports honestly
1296
+ # rather than blaming a 429 it never saw.
1297
+ check "backoff honored" "acct-01: backing off after a failed fetch" "$out"
1298
+ # ...and when the cause IS on record, the skip message names it. Calling every park a
1299
+ # 429 is exactly how an unauthorized account read as merely rate-limited for 11 days.
1300
+ python3 - "$ACC/acct-01/limits.json" <<'EOF'
1301
+ import json, os, sys, time
1302
+ p = sys.argv[1]
1303
+ d = json.load(open(p))
1304
+ d['last_error'] = 'HTTP 403 (source=token) — permanent, server said do not retry'
1305
+ json.dump(d, open(p + '.tmp', 'w'), indent=1); os.replace(p + '.tmp', p)
1306
+ EOF
1307
+ out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
1308
+ check "a skipped account names the error it is backing off from" "backing off after HTTP 403" "$out"
896
1309
  out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
897
1310
  check "--force overrides backoff" "acct-01: ok" "$out"
898
1311
  python3 -c "
@@ -993,17 +1406,30 @@ grep -q "sk-ant-oat01-recent" "$ACC/acct-05/.credentials.json" \
993
1406
  [ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "no backoff recorded for a gated (skipped) refresh" \
994
1407
  || t_fail "gated refresh backoff" ".oauth-refresh.json written despite the gate"
995
1408
 
996
- # ---- 16b7. server.token accounts are NEVER oauth-refreshed (token bearer wins) ---------
1409
+ # ---- 16b7. OAuth is preferred over a setup token FOR TELEMETRY ------------------------
1410
+ # This used to be the other way round — a server.token short-circuited the oauth refresh,
1411
+ # on the reasoning that a non-rotating credential is the safer one to spend. That
1412
+ # reasoning inverted the moment we learned the usage endpoint refuses setup tokens
1413
+ # outright (403, no user:profile scope): preferring the token means no telemetry AT ALL,
1414
+ # for an account whose refresh grant was perfectly good. Order is now oauth > refresh
1415
+ # grant > token, and the rotation-safety gate (16b6) still guards the grant itself.
997
1416
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
998
1417
  printf 'sk-ant-oat01-portable-token-05' > "$ACC/acct-05/server.token"
999
1418
  rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
1000
1419
  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)"
1001
- check "token-bearer account fetches without refresh" "acct-05: ok" "$out"
1420
+ check "an account with both credentials still fetches" "acct-05: ok" "$out"
1002
1421
  grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
1003
- && t_fail "server.token exempts oauth refresh" "oauth creds were rotated despite a portable token" \
1004
- || t_ok "server.token account never oauth-refreshed (grant not run)"
1422
+ && t_ok "a usable refresh grant is used even when a server.token sits beside it" \
1423
+ || t_fail "oauth preferred for telemetry" "the setup token short-circuited the refresh grant"
1424
+ python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='oauth' else 1)" \
1425
+ && t_ok "telemetry is fetched with the OAuth bearer, not the setup token" \
1426
+ || t_fail "bearer source" "source != oauth"
1427
+ # ...and the token is still the fallback when there is no oauth path at all.
1428
+ rm -f "$ACC/acct-05/.credentials.json" "$ACC/acct-05/limits.json"
1429
+ out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
1005
1430
  python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='token' else 1)" \
1006
- && t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
1431
+ && t_ok "with no oauth credential the setup token is still tried" \
1432
+ || t_fail "token fallback" "source != token"
1007
1433
  rm -f "$ACC/acct-05/server.token"
1008
1434
 
1009
1435
  # ---- 16b9. a 4xx from the TOKEN endpoint must not park the whole pool -----------------
@@ -1038,6 +1464,7 @@ EOF
1038
1464
  port=""
1039
1465
  python3 "$srv_script" > "$WORK/token-port" 2>/dev/null &
1040
1466
  srv_pid=$!
1467
+ STUB_PIDS="$STUB_PIDS $srv_pid"
1041
1468
  for _ in $(seq 1 20); do
1042
1469
  port="$(head -1 "$WORK/token-port" 2>/dev/null)"
1043
1470
  case "$port" in ''|*[!0-9]*) port=""; sleep 0.2 ;; *) break ;; esac
@@ -1073,6 +1500,459 @@ else
1073
1500
  rm -f "$dead_acct/.expired" "$dead_acct/.oauth-refresh.json"
1074
1501
  fi
1075
1502
 
1503
+ # ---- 16b9b. the usage endpoint's PERMANENT refusals ----------------------------------
1504
+ # 2026-08-22: every account in the fleet had ranked NEUTRAL for eleven days, so `claude`
1505
+ # was picking at random and a fresh session landed on the account already at 80% of its
1506
+ # weekly limit. The chain: the pool's OAuth grants lapsed, the fetcher fell back to the
1507
+ # portable setup token, and the usage endpoint refuses THAT with
1508
+ # 403 {"type":"permission_error","message":"OAuth token does not meet scope
1509
+ # requirement user:profile"} x-should-retry: false
1510
+ # because a setup token is minted without user:profile. Only a 429 used to record a
1511
+ # backoff, so the refusal was re-issued every scheduled pass from every machine — and
1512
+ # those retries are what earned the 429s that made an UNAUTHORIZED account look merely
1513
+ # RATE LIMITED, hiding the real cause behind a plausible one for eleven days.
1514
+ usrv="$WORK/usage-server.py"
1515
+ cat > "$usrv" <<'EOF'
1516
+ import http.server, json, sys
1517
+ LOG = sys.argv[1]
1518
+ class H(http.server.BaseHTTPRequestHandler):
1519
+ def log_message(self, *a): pass
1520
+ def do_GET(self):
1521
+ with open(LOG, 'a') as f:
1522
+ f.write(self.path + '\n')
1523
+ if self.path == '/scope-denied':
1524
+ raw = json.dumps({'type': 'error', 'error': {
1525
+ 'type': 'permission_error',
1526
+ 'message': 'OAuth token does not meet scope requirement user:profile'}}).encode()
1527
+ self.send_response(403)
1528
+ self.send_header('x-should-retry', 'false')
1529
+ elif self.path == '/boom':
1530
+ raw = b'{"error":"server"}'
1531
+ self.send_response(500)
1532
+ elif self.path == '/multibucket':
1533
+ # weekly_percent is the MAX durable bucket (80, five days out). The cheap
1534
+ # monthly bucket resets in an hour and says nothing about it.
1535
+ import time as _t
1536
+ def iso(dt):
1537
+ return _t.strftime('%Y-%m-%dT%H:%M:%S+00:00', _t.gmtime(_t.time() + dt))
1538
+ raw = json.dumps({'limits': [
1539
+ {'kind': 'session', 'percent': 1, 'resets_at': iso(3600), 'scope': None},
1540
+ {'kind': 'weekly_all', 'percent': 80, 'resets_at': iso(432000), 'scope': None},
1541
+ {'kind': 'monthly_all', 'percent': 10, 'resets_at': iso(3600), 'scope': None}]}).encode()
1542
+ self.send_response(200)
1543
+ else:
1544
+ raw = json.dumps({'limits': [
1545
+ {'kind': 'session', 'percent': 3, 'resets_at': '2099-01-01T00:00:00+00:00', 'scope': None},
1546
+ {'kind': 'weekly_all', 'percent': 7, 'resets_at': '2099-01-01T00:00:00+00:00', 'scope': None}]}).encode()
1547
+ self.send_response(200)
1548
+ self.send_header('Content-Type', 'application/json')
1549
+ self.send_header('Content-Length', str(len(raw)))
1550
+ self.end_headers()
1551
+ self.wfile.write(raw)
1552
+ srv = http.server.HTTPServer(('127.0.0.1', 0), H)
1553
+ print(srv.server_address[1], flush=True)
1554
+ srv.serve_forever()
1555
+ EOF
1556
+ uhits="$WORK/usage-hits"
1557
+ : > "$uhits"
1558
+ uport=""
1559
+ python3 "$usrv" "$uhits" > "$WORK/usage-port" 2>/dev/null &
1560
+ usrv_pid=$!
1561
+ STUB_PIDS="$STUB_PIDS $usrv_pid"
1562
+ for _ in $(seq 1 20); do
1563
+ uport="$(head -1 "$WORK/usage-port" 2>/dev/null)"
1564
+ case "$uport" in ''|*[!0-9]*) uport=""; sleep 0.2 ;; *) break ;; esac
1565
+ done
1566
+ if [ -z "$uport" ]; then
1567
+ kill "$usrv_pid" 2>/dev/null; wait "$usrv_pid" 2>/dev/null || true
1568
+ t_ok "usage-endpoint refusal tests skipped (cannot bind a loopback port here)"
1569
+ else
1570
+ # A pool in exactly the incident's shape: a portable setup token and NO OAuth grant.
1571
+ SD="$WORK/scope-denied-pool"
1572
+ mkdir -p "$SD/acct-01" "$SD/acct-02" "$SD/tmp"
1573
+ : > "$SD/.limits-kick"
1574
+ cat > "$SD/accounts.json" <<'EOF'
1575
+ {"version":1,"server":"none","threshold":90,"accounts":[
1576
+ {"id":"acct-01","email":"sd1@test","home":"mac","added_at":"2026-07-13T00:00:00Z"},
1577
+ {"id":"acct-02","email":"sd2@test","home":"mac","added_at":"2026-07-13T00:00:00Z"}]}
1578
+ EOF
1579
+ for i in 01 02; do
1580
+ printf 'sk-ant-oat01-SETUPTOKEN%s\n' "$i" > "$SD/acct-$i/server.token"
1581
+ chmod 600 "$SD/acct-$i/server.token"
1582
+ # Telemetry frozen eleven days ago — exactly what the incident left on disk.
1583
+ printf '{"fetched_at":%s,"source":"oauth","max_percent":2,"weekly_percent":2,"session_percent":0,"buckets":[]}' \
1584
+ "$((now - 950000))" > "$SD/acct-$i/limits.json"
1585
+ done
1586
+
1587
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1588
+ claude-accounts limits --force 2>&1)"
1589
+ check "a scope-denied 403 names the missing scope" "user:profile" "$out"
1590
+ check "a scope-denied 403 names the ceremony that fixes it" "claude-accounts login acct-01" "$out"
1591
+ check "a scope-denied 403 backs off instead of retrying" "Backing off" "$out"
1592
+ python3 - "$SD/acct-01/limits.json" "$now" <<'EOF'
1593
+ import json, sys
1594
+ lim = json.load(open(sys.argv[1]))
1595
+ now = int(sys.argv[2])
1596
+ assert lim['retry_after'] > now + 3600, lim # parked for hours, not minutes
1597
+ assert lim['fetched_at'] == now - 950000, lim # a FAILURE never invents freshness
1598
+ assert 'HTTP 403' in lim['last_error'], lim
1599
+ EOF
1600
+ [ $? -eq 0 ] && t_ok "a refused fetch records a long backoff and keeps its stale fetched_at" \
1601
+ || t_fail "403 backoff state" "see $SD/acct-01/limits.json"
1602
+
1603
+ # The whole point: the next scheduled pass must NOT spend another request. Before the
1604
+ # fix this retried every five minutes, from every machine, forever.
1605
+ before="$(wc -l < "$uhits")"
1606
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1607
+ claude-accounts limits 2>&1)"
1608
+ after="$(wc -l < "$uhits")"
1609
+ [ "$before" = "$after" ] && t_ok "a parked account is not re-fetched on the next pass" \
1610
+ || t_fail "403 retry storm" "endpoint hit again ($before -> $after requests)"
1611
+ check "the parked account says why it is waiting" "backing off after HTTP 403" "$out"
1612
+
1613
+ # Any other non-2xx backs off too — a 5xx retried every pass is the same storm.
1614
+ rm -f "$SD/acct-01/limits.json"
1615
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/boom" \
1616
+ claude-accounts limits --force 2>&1)"
1617
+ check "a 500 backs off as well" "backing off" "$out"
1618
+ python3 - "$SD/acct-01/limits.json" <<'EOF'
1619
+ import json, sys
1620
+ lim = json.load(open(sys.argv[1]))
1621
+ assert lim.get('retry_after', 0) > 0 and 'HTTP 500' in lim.get('last_error', ''), lim
1622
+ assert 'fetched_at' not in lim or not lim['fetched_at'], lim # never fetched != fresh
1623
+ EOF
1624
+ [ $? -eq 0 ] && t_ok "a 5xx records a backoff without faking a fetch" \
1625
+ || t_fail "500 backoff state" "see $SD/acct-01/limits.json"
1626
+
1627
+ # ---- the blind-ranking guard -------------------------------------------------
1628
+ # Stale telemetry scores every account the same NEUTRAL value, so pick_best sees one
1629
+ # pool-wide tie and selection silently becomes uniform random. It must say so.
1630
+ for i in 01 02; do
1631
+ printf '{"fetched_at":%s,"source":"oauth","max_percent":2,"weekly_percent":2,"session_percent":0,"buckets":[]}' \
1632
+ "$((now - 950000))" > "$SD/acct-$i/limits.json"
1633
+ done
1634
+ : > "$SD/selection.log"
1635
+ CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1
1636
+ grep -q "ranking=BLIND" "$SD/selection.log" \
1637
+ && t_ok "selection.log records that ranking ran blind" \
1638
+ || t_fail "blind ranking log" "no ranking=BLIND line: $(tail -1 "$SD/selection.log")"
1639
+ grep -q "telemetry-age=9[0-9]\{5\}s" "$SD/selection.log" \
1640
+ && t_ok "the blind line carries the age of the outage" \
1641
+ || t_fail "blind ranking age" "$(tail -1 "$SD/selection.log")"
1642
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts status 2>&1)"
1643
+ check "status calls a blind pool blind" "RANKING IS BLIND" "$out"
1644
+ check "status names the scope that is missing" "user:profile" "$out"
1645
+ check "status flags the stale reading itself" "<< STALE" "$out"
1646
+ # A panel drives off --json, so the outage has to be a FIELD, not just prose.
1647
+ CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts list --json > "$SD/blind.json" 2>/dev/null
1648
+ python3 - "$SD/blind.json" <<'EOF'
1649
+ import json, sys
1650
+ d = json.load(open(sys.argv[1]))
1651
+ assert d['summary']['ranking_blind'] is True, d['summary']
1652
+ assert all(a['usage']['stale'] is True for a in d['accounts']), d['accounts']
1653
+ EOF
1654
+ [ $? -eq 0 ] && t_ok "--json reports the pool-wide blindness and per-account staleness" \
1655
+ || t_fail "json blindness" "see $SD/blind.json"
1656
+
1657
+ # ...and telemetry INSIDE the window must still rank. 900s used to be the window,
1658
+ # which is below the ~3600s floor the endpoint itself enforces (Retry-After: 3600),
1659
+ # so a healthy pool spent most of every hour ranking neutral for no reason.
1660
+ for i in 01 02; do
1661
+ printf '{"fetched_at":%s,"source":"oauth","max_percent":%s,"weekly_percent":%s,"session_percent":1,"buckets":[]}' \
1662
+ "$((now - 1200))" "$((i + 3))" "$((i + 3))" > "$SD/acct-$i/limits.json"
1663
+ done
1664
+ : > "$SD/selection.log"
1665
+ CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1
1666
+ ! grep -q "ranking=BLIND" "$SD/selection.log" \
1667
+ && t_ok "20-minute-old telemetry still ranks (window matches the endpoint's own floor)" \
1668
+ || t_fail "stale window" "20-minute-old data was treated as blind"
1669
+ grep -q "acct-01 weekly=4%" "$SD/selection.log" \
1670
+ && t_ok "the pool ranks on real numbers and picks the account with more headroom" \
1671
+ || t_fail "headroom ranking" "$(tail -1 "$SD/selection.log")"
1672
+
1673
+ # ---- blind does not mean neutral --------------------------------------------
1674
+ # Ranking everything NEUTRAL when nothing is fresh throws away information that is
1675
+ # still TRUE: a weekly bucket only rises until its reset, so before that moment an
1676
+ # old weekly reading remains a valid lower bound. Neutral is only the right answer
1677
+ # while some other account has fresh data to be neutral against.
1678
+ printf '{"fetched_at":%s,"weekly_percent":81,"session_percent":0,"max_percent":81,"weekly_resets_epoch":%s,"buckets":[]}' \
1679
+ "$((now - 950000))" "$((now + 200000))" > "$SD/acct-01/limits.json"
1680
+ printf '{"fetched_at":%s,"weekly_percent":4,"session_percent":0,"max_percent":4,"weekly_resets_epoch":%s,"buckets":[]}' \
1681
+ "$((now - 950000))" "$((now + 200000))" > "$SD/acct-02/limits.json"
1682
+ : > "$SD/selection.log"
1683
+ rm -f "$SD/.last-pick"
1684
+ for _ in 1 2 3 4 5 6; do CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1; done
1685
+ if grep -q "acct-01 " "$SD/selection.log"; then
1686
+ t_fail "blind ranking still avoids a nearly-exhausted account" \
1687
+ "the account stale-reported at 81% weekly was picked: $(grep -c 'acct-01 ' "$SD/selection.log")/6 runs"
1688
+ else
1689
+ t_ok "blind ranking still avoids a nearly-exhausted account"
1690
+ fi
1691
+ # DEGRADED, not BLIND: the pool IS still ranking, on readings that remain true. An
1692
+ # operator told "random" would go hunting a bug that is not there.
1693
+ grep -q "ranking=DEGRADED" "$SD/selection.log" \
1694
+ && t_ok "a degraded pick is logged as degraded, not as blind" \
1695
+ || t_fail "degraded log" "$(tail -1 "$SD/selection.log")"
1696
+ grep -q "acct-02 weekly=4% .*ranking=DEGRADED" "$SD/selection.log" \
1697
+ && t_ok "the degraded line reports the stale reading it actually ranked on" \
1698
+ || t_fail "degraded weekly" "$(tail -1 "$SD/selection.log")"
1699
+
1700
+ # ...but a reading whose week has ALREADY reset describes a week that is over. It is
1701
+ # worth nothing, and must not be mistaken for a low-usage account.
1702
+ printf '{"fetched_at":%s,"weekly_percent":81,"session_percent":0,"max_percent":81,"weekly_resets_epoch":%s,"buckets":[]}' \
1703
+ "$((now - 950000))" "$((now + 200000))" > "$SD/acct-01/limits.json"
1704
+ printf '{"fetched_at":%s,"weekly_percent":4,"session_percent":0,"max_percent":4,"weekly_resets_epoch":%s,"buckets":[]}' \
1705
+ "$((now - 950000))" "$((now - 100))" > "$SD/acct-02/limits.json"
1706
+ : > "$SD/selection.log"
1707
+ rm -f "$SD/.last-pick"
1708
+ for _ in 1 2 3 4 5 6; do CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1; done
1709
+ grep -q "acct-02 " "$SD/selection.log" \
1710
+ && t_ok "an expired weekly reading falls back to neutral instead of reading as 4%" \
1711
+ || t_fail "expired weekly reading" "acct-02 never picked, so 81% still outranked an unknown"
1712
+
1713
+ # ---- the recorded horizon belongs to the bucket weekly_percent came from -----
1714
+ # Taking the earliest reset across ALL durable buckets would let a 10% monthly bucket
1715
+ # resetting in an hour throw away an 80% weekly reading that is good for five days —
1716
+ # and that account would then score neutral 50 and beat a neighbour honestly at 60%.
1717
+ rm -f "$SD/acct-01/limits.json"
1718
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/multibucket" \
1719
+ claude-accounts limits --force >/dev/null 2>&1
1720
+ python3 - "$SD/acct-01/limits.json" <<'EOF'
1721
+ import json, sys, time
1722
+ lim = json.load(open(sys.argv[1]))
1723
+ assert lim['weekly_percent'] == 80, lim
1724
+ horizon = lim['weekly_resets_epoch'] - time.time()
1725
+ assert horizon > 86400, lim # the 80% bucket's five days, not the monthly bucket's hour
1726
+ EOF
1727
+ [ $? -eq 0 ] && t_ok "the stale-reading horizon tracks the bucket weekly_percent came from" \
1728
+ || t_fail "weekly horizon" "see $SD/acct-01/limits.json"
1729
+
1730
+ # ---- an unknown horizon is not comparable, so nobody gets degraded ranking ----
1731
+ # A limits.json written before weekly_resets_epoch existed scores NEUTRAL 50 — which
1732
+ # would beat a neighbour's true-but-worse 70 and make the degraded path actively
1733
+ # wrong. Degraded ranking is therefore all-or-nothing across the candidates.
1734
+ printf '{"fetched_at":%s,"weekly_percent":70,"session_percent":0,"max_percent":70,"weekly_resets_epoch":%s,"buckets":[]}' \
1735
+ "$((now - 950000))" "$((now + 200000))" > "$SD/acct-01/limits.json"
1736
+ printf '{"fetched_at":%s,"weekly_percent":85,"session_percent":0,"max_percent":85,"buckets":[]}' \
1737
+ "$((now - 950000))" > "$SD/acct-02/limits.json" # legacy file: no horizon
1738
+ : > "$SD/selection.log"
1739
+ rm -f "$SD/.last-pick"
1740
+ for _ in 1 2 3 4 5 6; do CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1; done
1741
+ grep -q "ranking=BLIND" "$SD/selection.log" && ! grep -q "ranking=DEGRADED" "$SD/selection.log" \
1742
+ && t_ok "one horizon-less candidate turns degraded ranking off for the whole pool" \
1743
+ || t_fail "mixed degraded ranking" "$(tail -1 "$SD/selection.log")"
1744
+ grep -q "acct-02 " "$SD/selection.log" \
1745
+ && t_ok "with degraded ranking off, the legacy account is still reachable" \
1746
+ || t_fail "legacy starvation" "acct-02 never picked in 6 runs"
1747
+
1748
+ # ---- status/--json must agree with the shim, not just with each other --------
1749
+ # A status that says "picking at RANDOM" while the shim is ranking on valid stale
1750
+ # readings sends an operator after a bug that is not there; a status that says
1751
+ # "fine" while the shim is blind is how eleven days went by.
1752
+ for i in 01 02; do
1753
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":0,"max_percent":%s,"weekly_resets_epoch":%s,"buckets":[]}' \
1754
+ "$((now - 950000))" "$((i + 3))" "$((i + 3))" "$((now + 200000))" > "$SD/acct-$i/limits.json"
1755
+ done
1756
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts status 2>&1)"
1757
+ check "status reports DEGRADED when the shim is degraded" "RANKING IS DEGRADED" "$out"
1758
+ CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts list --json > "$SD/degraded.json" 2>/dev/null
1759
+ python3 - "$SD/degraded.json" <<'EOF'
1760
+ import json, sys
1761
+ d = json.load(open(sys.argv[1]))
1762
+ assert d['summary']['telemetry'] == 'degraded', d['summary']
1763
+ assert d['summary']['ranking_blind'] is False, d['summary']
1764
+ EOF
1765
+ [ $? -eq 0 ] && t_ok "--json reports degraded, and ranking_blind stays false" \
1766
+ || t_fail "json degraded" "see $SD/degraded.json"
1767
+
1768
+ # ---- a refused setup token is never spent on this endpoint again -------------
1769
+ # The 6h park expires; the refusal does not. Asking again can only 403 and only
1770
+ # burns the account's ~1-per-hour budget, which is what made an authorization
1771
+ # problem look like a rate limit.
1772
+ rm -f "$SD/acct-02/limits.json"
1773
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1774
+ claude-accounts limits --force >/dev/null 2>&1
1775
+ python3 - "$SD/acct-01/limits.json" <<'EOF'
1776
+ import json, os, sys
1777
+ p = sys.argv[1]
1778
+ d = json.load(open(p))
1779
+ assert d.get('token_scope_denied'), d # WHICH token was refused (digest)
1780
+ d['retry_after'] = 0 # the park has since expired
1781
+ json.dump(d, open(p + '.tmp', 'w')); os.replace(p + '.tmp', p)
1782
+ EOF
1783
+ before="$(wc -l < "$uhits")"
1784
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1785
+ claude-accounts limits 2>&1)"
1786
+ after="$(wc -l < "$uhits")"
1787
+ [ "$before" = "$after" ] && t_ok "a token already refused for scope is not offered again" \
1788
+ || t_fail "token re-offered" "endpoint hit again ($before -> $after)"
1789
+ check "and the message says what would fix it" "claude-accounts login acct-01" "$out"
1790
+ # Re-minting the token is a new credential, so it earns a fresh try.
1791
+ printf 'sk-ant-oat01-REMINTED01\n' > "$SD/acct-01/server.token"
1792
+ before="$(wc -l < "$uhits")"
1793
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
1794
+ claude-accounts limits >/dev/null 2>&1
1795
+ after="$(wc -l < "$uhits")"
1796
+ [ "$before" != "$after" ] && t_ok "a newly minted token is tried again" \
1797
+ || t_fail "remint not retried" "the new token was never offered"
1798
+
1799
+ # ---- a dead OAuth grant must not park an account whose TOKEN still works -----
1800
+ # This is the whole pool's shape after the incident: a working setup token beside a
1801
+ # lapsed grant. The shim's auth_dead() reads .expired BEFORE server.token, so parking
1802
+ # here would take every working account out of the pool at once — over a credential
1803
+ # the pool needs only for telemetry, never for work.
1804
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":1000}}' \
1805
+ > "$SD/acct-01/.credentials.json"
1806
+ rm -f "$SD/acct-01/limits.json" "$SD/acct-01/.expired" "$SD/acct-01/.oauth-refresh.json"
1807
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json" \
1808
+ claude-accounts limits --force 2>&1)"
1809
+ [ ! -f "$SD/acct-01/.expired" ] \
1810
+ && t_ok "a dead grant never parks an account that still has a working setup token" \
1811
+ || t_fail "portable account parked" "$(tail -1 "$SD/acct-01/.expired")"
1812
+ check "...and it says telemetry is what is broken, not the account" "TELEMETRY is dead" "$out"
1813
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts status 2>&1)"
1814
+ check "status keeps it selectable" "selectable : yes" "$out"
1815
+ # ...but with NO token, the same dead grant DOES park it: then nothing can authenticate.
1816
+ mv "$SD/acct-01/server.token" "$SD/acct-01/server.token.bak"
1817
+ rm -f "$SD/acct-01/limits.json" "$SD/acct-01/.oauth-refresh.json"
1818
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json" \
1819
+ claude-accounts limits --force >/dev/null 2>&1
1820
+ grep -q "reason=refresh-token-expired" "$SD/acct-01/.expired" 2>/dev/null \
1821
+ && t_ok "with no token to fall back on, a dead grant still parks the account" \
1822
+ || t_fail "dead grant not parked" "no .expired for an account with nothing that authenticates"
1823
+ mv "$SD/acct-01/server.token.bak" "$SD/acct-01/server.token"
1824
+ rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json" "$SD/acct-01/.oauth-refresh.json"
1825
+
1826
+ # ---- a live refresh token recovers even from a husk credential ---------------
1827
+ # A credential whose ACCESS token was cleared but whose REFRESH token is alive is
1828
+ # exactly what a grant exists to recover from. Requiring the dead half to be present
1829
+ # meant such an account could never come back — and with a setup token beside it, it
1830
+ # went dark for telemetry permanently.
1831
+ printf '{"claudeAiOauth":{"accessToken":"","refreshToken":"sk-ant-ort01-live","expiresAt":0,"refreshTokenExpiresAt":9999999999999}}' \
1832
+ > "$SD/acct-01/.credentials.json"
1833
+ rm -f "$SD/acct-01/limits.json" "$SD/acct-01/.oauth-refresh.json" "$SD/acct-01/.expired"
1834
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" \
1835
+ CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" claude-accounts limits --force 2>&1)"
1836
+ check "a husk credential with a live refresh token is refreshed" "refreshed via refresh-token grant" "$out"
1837
+ python3 -c "import json,sys; sys.exit(0 if json.load(open('$SD/acct-01/limits.json'))['source']=='oauth' else 1)" \
1838
+ && t_ok "...and telemetry comes back on the OAuth bearer" \
1839
+ || t_fail "husk recovery" "source != oauth"
1840
+
1841
+ # ---- a credential rotated mid-flight by someone else is never overwritten -----
1842
+ # The grant rotates; a live claude session refreshes the same file. Losing that race
1843
+ # by overwriting destroys the session's newer credential and strands the account.
1844
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-MINE","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' \
1845
+ > "$SD/acct-01/.credentials.json"
1846
+ rm -f "$SD/acct-01/limits.json" "$SD/acct-01/.oauth-refresh.json"
1847
+ # token-ok.json is a file:// fixture, so the "other writer" can land while the grant
1848
+ # is in flight simply by writing a different refresh token first.
1849
+ cat > "$SD/racer.sh" <<'RACER'
1850
+ #!/usr/bin/env bash
1851
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-SESSION","refreshToken":"sk-ant-ort01-THEIRS","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$1"
1852
+ RACER
1853
+ chmod +x "$SD/racer.sh"
1854
+ "$SD/racer.sh" "$SD/acct-01/.credentials.json.race"
1855
+ # simulate: the grant was issued against MINE, but THEIRS is what is on disk now
1856
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-MINE","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' \
1857
+ > "$SD/acct-01/.credentials.json"
1858
+ ( sleep 0.1; cp "$SD/acct-01/.credentials.json.race" "$SD/acct-01/.credentials.json" ) &
1859
+ racer_pid=$!
1860
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" \
1861
+ CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" claude-accounts limits --force >/dev/null 2>&1
1862
+ wait "$racer_pid" 2>/dev/null || true
1863
+ grep -q "sk-ant-ort01-THEIRS" "$SD/acct-01/.credentials.json" \
1864
+ && t_ok "a credential rotated by another writer survives our refresh" \
1865
+ || t_ok "refresh committed before the other writer landed (race not exercised)"
1866
+ rm -f "$SD/acct-01/.credentials.json" "$SD/acct-01/.credentials.json.race" "$SD/racer.sh" \
1867
+ "$SD/acct-01/.oauth-refresh.json" "$SD/acct-01/.expired"
1868
+
1869
+ # ---- an org block is never downgraded by a weaker reason ---------------------
1870
+ # clear_expired refuses to lift an org block, but nothing stopped mark_expired from
1871
+ # REWRITING its reason — after which the next successful fetch lifts it happily.
1872
+ printf '%s\nreason=org-blocked marked_at=now detail=test\n' "$now" > "$SD/acct-01/.expired"
1873
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-x","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":1000}}' \
1874
+ > "$SD/acct-01/.credentials.json"
1875
+ rm -f "$SD/acct-01/limits.json"
1876
+ CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
1877
+ claude-accounts limits --force >/dev/null 2>&1
1878
+ grep -q "reason=org-blocked" "$SD/acct-01/.expired" 2>/dev/null \
1879
+ && t_ok "a dead refresh grant never overwrites an org-blocked marker" \
1880
+ || t_fail "org block downgraded" "marker is now: $(cat "$SD/acct-01/.expired" 2>/dev/null | tail -1)"
1881
+ rm -f "$SD/acct-01/.expired" "$SD/acct-01/.credentials.json"
1882
+
1883
+ # ---- the >=90% cutoff keeps the TIGHT window --------------------------------
1884
+ # Ranking may trust an hour-old number; declaring an account UNUSABLE may not. The
1885
+ # cutoff window is EXCLUDE_STALE_AFTER (900s), so this is tested on both sides of it.
1886
+ # acct-01 is deliberately the BEST-RANKING account (weekly 1%) while being over the
1887
+ # threshold on its session bucket (max 91%). So it is picked whenever it is eligible,
1888
+ # and skipped only when the cutoff actually fires — which isolates the cutoff window
1889
+ # from the ranking window instead of conflating "excluded" with "outranked".
1890
+ mk_cutoff_pool() { # $1 = age of both readings, in seconds
1891
+ printf '{"fetched_at":%s,"weekly_percent":1,"session_percent":91,"max_percent":91,"weekly_resets_epoch":%s,"buckets":[]}' \
1892
+ "$((now - $1))" "$((now + 200000))" > "$SD/acct-01/limits.json"
1893
+ printf '{"fetched_at":%s,"weekly_percent":50,"session_percent":5,"max_percent":50,"weekly_resets_epoch":%s,"buckets":[]}' \
1894
+ "$((now - $1))" "$((now + 200000))" > "$SD/acct-02/limits.json"
1895
+ : > "$SD/selection.log"
1896
+ rm -f "$SD/.last-pick"
1897
+ local _i
1898
+ for _i in 1 2 3 4; do CLAUDE_ACCOUNTS_ROOT="$SD" claude >/dev/null 2>&1; done
1899
+ }
1900
+ mk_cutoff_pool 800 # inside the 900s cutoff window
1901
+ ! grep -q "acct-01 " "$SD/selection.log" \
1902
+ && t_ok "a 91% reading inside the cutoff window excludes the account" \
1903
+ || t_fail "threshold exclusion" "a 91% account was selected on 800s-old data"
1904
+ mk_cutoff_pool 1000 # past the cutoff window, still inside the RANKING window
1905
+ grep -q "acct-01 " "$SD/selection.log" \
1906
+ && t_ok "past the cutoff window a 91% reading no longer excludes (fail open)" \
1907
+ || t_fail "cutoff fail-open" "a 1000s-old 91% reading still excluded the account"
1908
+ ! grep -qE "ranking=(BLIND|DEGRADED)" "$SD/selection.log" \
1909
+ && t_ok "...but it is still fresh enough to RANK on (the two windows differ)" \
1910
+ || t_fail "ranking window" "1000s-old data was treated as unrankable"
1911
+ grep -q "acct-01 weekly=1%" "$SD/selection.log" \
1912
+ && t_ok "and ranking still prefers the account with more weekly headroom" \
1913
+ || t_fail "ranking preference" "$(tail -1 "$SD/selection.log")"
1914
+
1915
+ # ---- one corrupt limits.json costs exactly one account ----------------------
1916
+ # `[]` is valid JSON. Every prev.get() in the refresher would raise on it, OUTSIDE
1917
+ # the per-account try — starving every account after it, which is the same pool-wide
1918
+ # telemetry blackout this whole section is about.
1919
+ printf '[]' > "$SD/acct-01/limits.json"
1920
+ rm -f "$SD/acct-02/limits.json"
1921
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
1922
+ claude-accounts limits --force 2>&1)"
1923
+ rc=$?
1924
+ [ "$rc" = "0" ] && t_ok "a limits.json that is not an object exits 0" || t_fail "corrupt limits rc" "rc=$rc: $out"
1925
+ [ -s "$SD/acct-02/limits.json" ] \
1926
+ && t_ok "accounts after a corrupt limits.json still refresh" \
1927
+ || t_fail "corrupt limits starves the loop" "acct-02 was never fetched"
1928
+
1929
+ # status must survive the same file — it is the one command that reports the outage.
1930
+ printf '{"fetched_at":"yesterday"}' > "$SD/acct-01/limits.json"
1931
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" claude-accounts status 2>&1)"
1932
+ rc=$?
1933
+ [ "$rc" = "0" ] && t_ok "status survives a limits.json with a non-numeric fetched_at" \
1934
+ || t_fail "status crash" "rc=$rc: $(printf '%s' "$out" | tail -3)"
1935
+ check "status still reaches the accounts after the corrupt one" "acct-02" "$out"
1936
+
1937
+ # A successful fetch must clear the whole backoff record, or one bad hour would keep
1938
+ # an account parked long after the endpoint came back.
1939
+ printf '{"fetched_at":%s,"weekly_percent":2,"session_percent":0,"max_percent":2,"buckets":[]}' \
1940
+ "$((now - 950000))" > "$SD/acct-01/limits.json"
1941
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/scope-denied" \
1942
+ claude-accounts limits --force 2>&1)"
1943
+ out="$(CLAUDE_ACCOUNTS_ROOT="$SD" CLAUDE_MULTIACC_USAGE_URL="http://127.0.0.1:$uport/ok" \
1944
+ claude-accounts limits --force 2>&1)"
1945
+ python3 - "$SD/acct-01/limits.json" <<'EOF'
1946
+ import json, sys
1947
+ lim = json.load(open(sys.argv[1]))
1948
+ assert 'retry_after' not in lim and 'last_error' not in lim, lim
1949
+ assert lim['weekly_percent'] == 7 and lim['session_percent'] == 3, lim
1950
+ EOF
1951
+ [ $? -eq 0 ] && t_ok "a successful fetch drops every trace of the backoff" \
1952
+ || t_fail "backoff cleared" "see $SD/acct-01/limits.json"
1953
+ kill "$usrv_pid" 2>/dev/null; wait "$usrv_pid" 2>/dev/null || true
1954
+ fi
1955
+
1076
1956
  # ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
1077
1957
  # {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
1078
1958
  # must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
@@ -1347,7 +2227,13 @@ if command -v node >/dev/null 2>&1; then
1347
2227
  # self-update on a non-npm, non-git tree is a logged no-op (never errors)
1348
2228
  out="$(claude-accounts self-update 2>&1)"
1349
2229
  rc=$?
1350
- [ "$rc" = "0" ] && t_ok "self-update no-op exits 0 on a plain checkout" || t_fail "self-update rc" "rc=$rc"
2230
+ # A git checkout that cannot fast-forward (feature branch, detached CI checkout) is
2231
+ # SUPPOSED to fail loudly; what must never happen is a crash with no explanation.
2232
+ case "$rc:$out" in
2233
+ 0:*) t_ok "self-update no-op exits 0 on a plain checkout" ;;
2234
+ *:*"git pull FAILED"*) t_ok "self-update reports a git checkout it cannot fast-forward" ;;
2235
+ *) t_fail "self-update rc" "rc=$rc: $out" ;;
2236
+ esac
1351
2237
  case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
1352
2238
  *) t_fail "self-update message" "unexpected: $out" ;; esac
1353
2239
  # postinstall must SKIP for a non-global install and never fail
@@ -1382,6 +2268,7 @@ mkdir -p "$CX/tmp"
1382
2268
  unset CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
1383
2269
  export CODEX_MULTIACC_NO_SYNC=1
1384
2270
  export CODEX_MULTIACC_MIN_FETCH=0
2271
+ export CODEX_MULTIACC_CLIENT_SCAN_TTL=0
1385
2272
  export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
1386
2273
  # Default usage URL is an offline missing fixture: the SHIM's opportunistic
1387
2274
  # background `limits --quiet` kick must never reach a real endpoint from tests
@@ -1556,6 +2443,211 @@ codex >/dev/null 2>&1
1556
2443
  [ ! -f "$CX/acct-01/.limited" ] && t_ok "codex: expired .limited marker self-clears" \
1557
2444
  || t_fail "codex marker expiry" ".limited survived its reset time"
1558
2445
 
2446
+ # ---- C5b. client-reported rate limits (rollouts) + rotation -------------------------
2447
+ # Parity with the claude shim: never depend on the usage endpoint to notice an account
2448
+ # ran dry. The codex CLI writes the windows the server reported into every run's
2449
+ # rollout, and rollouts live inside the account dir — so no session index is needed.
2450
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/limits.json "$CX/.pick-seq" "$CX"/acct-0*/.last-pick
2451
+ rm -rf "$CX"/acct-0*/sessions
2452
+ mkrollout() { # mkrollout <acct dir> <used_percent> <resets_at>
2453
+ local day="$1/sessions/2026/08/20"
2454
+ mkdir -p "$day"
2455
+ {
2456
+ printf '{"timestamp":"2026-08-19T22:43:21.299Z","type":"session_meta","payload":{"session_id":"01a01c31","cwd":"/proj"}}\n'
2457
+ printf '{"timestamp":"2026-08-19T22:49:54.120Z","type":"event_msg","payload":{"type":"token_count","info":{"model_context_window":258400},"rate_limits":{"limit_id":"codex","limit_name":null,"primary":{"used_percent":%s,"window_minutes":10080,"resets_at":%s},"secondary":null,"credits":{"has_credits":false,"unlimited":false,"balance":"0"}}}}\n' "$2" "$3"
2458
+ } > "$day/rollout-2026-08-20T01-43-21-01a01c31-7fc3-7291-a0fc-7b4e2b035f1a.jsonl"
2459
+ }
2460
+ mkrollout "$CX/acct-01" "97.4" "$((now + 3600))"
2461
+ all2=1
2462
+ for _ in $(seq 1 12); do
2463
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
2464
+ done
2465
+ [ "$all2" = "1" ] && t_ok "codex: a spent window in the account's own rollout excludes it" \
2466
+ || t_fail "codex client rate limit" "the spent account was picked again"
2467
+ first="$(head -1 "$CX/acct-01/.limited" 2>/dev/null)"
2468
+ [ "$first" = "$((now + 3600))" ] && t_ok "codex: marker carries the reported reset time" \
2469
+ || t_fail "codex client marker reset" "want $((now + 3600)), got '${first:-<none>}'"
2470
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
2471
+ && t_ok "codex: marker is tagged client-rate-limit" \
2472
+ || t_fail "codex client marker reason" "$(cat "$CX/acct-01/.limited" 2>/dev/null)"
2473
+ rm -f "$CX/acct-01/.limited"
2474
+
2475
+ # under the threshold is just usage, not an exclusion
2476
+ mkrollout "$CX/acct-01" "40.0" "$((now + 3600))"
2477
+ hits1=0
2478
+ for _ in $(seq 1 12); do
2479
+ case "$(codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
2480
+ done
2481
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
2482
+ && t_ok "codex: a sub-threshold window does not exclude" \
2483
+ || t_fail "codex sub-threshold rollout" "acct-01 hits=$hits1"
2484
+
2485
+ # a window that has already reset is history
2486
+ mkrollout "$CX/acct-01" "99.0" "$((now - 60))"
2487
+ hits1=0
2488
+ for _ in $(seq 1 12); do
2489
+ case "$(codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
2490
+ done
2491
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
2492
+ && t_ok "codex: an elapsed window does not exclude" \
2493
+ || t_fail "codex elapsed rollout window" "acct-01 hits=$hits1"
2494
+
2495
+ # opt-out
2496
+ mkrollout "$CX/acct-01" "99.0" "$((now + 3600))"
2497
+ hits1=0
2498
+ for _ in $(seq 1 12); do
2499
+ case "$(CODEX_MULTIACC_CLIENT_LIMITS=0 codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
2500
+ done
2501
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
2502
+ && t_ok "codex: CODEX_MULTIACC_CLIENT_LIMITS=0 turns the scan off" \
2503
+ || t_fail "codex client-limit opt-out" "still excluded with the scan disabled"
2504
+ rm -rf "$CX"/acct-0*/sessions
2505
+ rm -f "$CX"/acct-0*/.limited
2506
+
2507
+ # equal-headroom picks rotate instead of re-rolling a coin
2508
+ rm -f "$CX/.pick-seq" "$CX"/acct-0*/.last-pick "$CX"/acct-0*/limits.json
2509
+ seq_out=""
2510
+ for _ in $(seq 1 8); do
2511
+ case "$(codex 2>&1)" in
2512
+ *CFG=acct-01*) seq_out="${seq_out}1" ;;
2513
+ *CFG=acct-02*) seq_out="${seq_out}2" ;;
2514
+ *) seq_out="${seq_out}?" ;;
2515
+ esac
2516
+ done
2517
+ case "$seq_out" in
2518
+ 12121212|21212121) t_ok "codex: equal-score picks round-robin across the pool ($seq_out)" ;;
2519
+ *) t_fail "codex round-robin tie-break" "sequence $seq_out (want strict alternation)" ;;
2520
+ esac
2521
+ rm -f "$CX/.pick-seq" "$CX"/acct-0*/.last-pick
2522
+
2523
+
2524
+ # codex-review finding: the bounded rollout scan must start at the NEWEST file, or a
2525
+ # busy account whose only over-threshold report is its latest run stays eligible.
2526
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2527
+ rm -rf "$CX"/acct-0*/sessions
2528
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2529
+ i=1
2530
+ while [ "$i" -le 12 ]; do
2531
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":10.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2532
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T0$(printf '%01d' $((i % 10)))-0$i-old$i.jsonl"
2533
+ i=$((i + 1))
2534
+ done
2535
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2536
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T99-99-newest.jsonl"
2537
+ codex >/dev/null 2>&1
2538
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
2539
+ && t_ok "codex: the newest rollout is read even past the file cap" \
2540
+ || t_fail "codex rollout scan order" "13 rollouts, only the newest over threshold — missed it"
2541
+ rm -rf "$CX"/acct-0*/sessions
2542
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2543
+
2544
+ # A SHARED sessions tree (the layout lib/common.sh actually installs: acct/sessions is a
2545
+ # symlink to ~/.codex/sessions so `codex resume` finds every session) proves nothing about
2546
+ # who spent the quota. One spent window there must not mark the whole pool.
2547
+ SHARED="$WORK/cx-shared-sessions"
2548
+ mkdir -p "$SHARED/2026/08/20"
2549
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2550
+ "$((now + 3600))" > "$SHARED/2026/08/20/rollout-2026-08-20T01-00-00-shared.jsonl"
2551
+ for i in 01 02; do ln -s "$SHARED" "$CX/acct-$i/sessions"; done
2552
+ codex >/dev/null 2>&1
2553
+ { [ ! -f "$CX/acct-01/.limited" ] && [ ! -f "$CX/acct-02/.limited" ]; } \
2554
+ && t_ok "codex: a shared sessions symlink never marks an account (fail open)" \
2555
+ || t_fail "codex shared sessions" "a shared rollout tree marked the pool LIMITED"
2556
+ out="$(codex 2>&1)"
2557
+ case "$out" in *CFG=acct-0*) t_ok "codex: the pool still selects with a shared sessions tree" ;;
2558
+ *) t_fail "codex shared sessions" "selection produced: $out" ;; esac
2559
+ rm -f "$CX"/acct-0*/sessions "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2560
+
2561
+ # second codex-review pass: a NESTED symlink inside a real sessions/ dir must not smuggle
2562
+ # another tree's rollouts in, and a huge stale rollout directory must not be walked.
2563
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2564
+ rm -rf "$CX"/acct-0*/sessions
2565
+ OUTSIDE="$WORK/cx-outside"
2566
+ mkdir -p "$OUTSIDE/08/20" "$CX/acct-01/sessions"
2567
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2568
+ "$((now + 3600))" > "$OUTSIDE/08/20/rollout-2026-08-20T01-00-00-outside.jsonl"
2569
+ ln -s "$OUTSIDE" "$CX/acct-01/sessions/2026"
2570
+ codex >/dev/null 2>&1
2571
+ [ ! -f "$CX/acct-01/.limited" ] \
2572
+ && t_ok "codex: a nested symlink out of the account tree never marks it" \
2573
+ || t_fail "codex nested sessions symlink" "rollouts outside the account tree marked it LIMITED"
2574
+ rm -f "$CX/acct-01/sessions/2026"
2575
+
2576
+ # a day dir stuffed with stale rollouts must cost a bounded amount of work, and the
2577
+ # newest (over-threshold) file must still be the one that decides
2578
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2579
+ i=1
2580
+ while [ "$i" -le 60 ]; do
2581
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":5.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2582
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T00-00-$(printf '%02d' "$i")-bulk.jsonl"
2583
+ i=$((i + 1))
2584
+ done
2585
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2586
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T23-59-59-newest.jsonl"
2587
+ codex >/dev/null 2>&1
2588
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
2589
+ && t_ok "codex: 61 rollouts in one day dir — the newest still decides" \
2590
+ || t_fail "codex bulk rollout dir" "the newest rollout was not the one read"
2591
+ rm -rf "$CX"/acct-0*/sessions
2592
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2593
+
2594
+ # third codex-review pass: find(1) output is newline-delimited, so a pool file whose NAME
2595
+ # contains a newline arrives as two lines and its tail resolves relative to $PWD.
2596
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2597
+ rm -rf "$CX"/acct-0*/sessions
2598
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2599
+ ESCDIR="$WORK/cx-escape-dir"
2600
+ mkdir -p "$ESCDIR"
2601
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2602
+ "$((now + 3600))" > "$ESCDIR/escape.jsonl"
2603
+ # ONE file whose name embeds a newline; find prints it as two lines, and the second
2604
+ # ("escape.jsonl") would resolve against the caller's cwd — outside the pool entirely.
2605
+ HOSTILE=$'rollout-a\nescape.jsonl'
2606
+ : > "$CX/acct-01/sessions/2026/08/20/$HOSTILE"
2607
+ [ -e "$CX/acct-01/sessions/2026/08/20/$HOSTILE" ] \
2608
+ || t_fail "codex newline filename" "fixture not created — the guard would go unexercised"
2609
+ ( cd "$ESCDIR" && codex >/dev/null 2>&1 )
2610
+ [ ! -f "$CX/acct-01/.limited" ] \
2611
+ && t_ok "codex: a newline in a rollout name cannot pull in a file outside the pool" \
2612
+ || t_fail "codex newline filename" "a file outside the pool marked the account LIMITED"
2613
+ rm -rf "$CX"/acct-0*/sessions
2614
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2615
+
2616
+ # corrupt pool state must never reach bash arithmetic here either
2617
+ CXBIG="99999999999999999999999999999999"
2618
+ printf '%s\n' "$CXBIG" > "$CX/.last-pick"
2619
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":1,"max_percent":%s,"buckets":[]}' "$CXBIG" "$CXBIG" "$CXBIG" > "$CX/acct-02/limits.json"
2620
+ err="$(codex 2>&1 >/dev/null)"
2621
+ [ -z "$err" ] && t_ok "codex: absurd pool numbers keep stderr byte-clean" \
2622
+ || t_fail "codex corrupt number handling" "stderr: $(printf '%s' "$err" | head -c 160)"
2623
+ rm -f "$CX/.last-pick" "$CX"/acct-0*/limits.json
2624
+ : > "$CX/acct-01/.client-scan"
2625
+ err="$(CODEX_MULTIACC_CLIENT_SCAN_TTL=bogus CODEX_MULTIACC_THRESHOLD=nonsense codex 2>&1 >/dev/null)"
2626
+ out="$(CODEX_MULTIACC_CLIENT_SCAN_TTL=bogus CODEX_MULTIACC_THRESHOLD=nonsense codex 2>/dev/null)"
2627
+ { [ -z "$err" ] && case "$out" in *CFG=acct-0*) true ;; *) false ;; esac; } \
2628
+ && t_ok "codex: garbage in the TTL / threshold env vars keeps stderr clean" \
2629
+ || t_fail "codex env number validation" "stderr: $(printf '%s' "$err" | head -c 160) out: $out"
2630
+ rm -f "$CX"/acct-0*/.client-scan
2631
+
2632
+ # a parallel burst still spreads (rotation drops only the account just handed out).
2633
+ # Three accounts, for the same reason as the claude side: with two, landing entirely on
2634
+ # the single alternative is correct behaviour and the assertion would measure nothing.
2635
+ mkdir -p "$CX/acct-09"
2636
+ "$MKAUTH" > "$CX/acct-09/auth.json" 2>/dev/null || \
2637
+ printf '{"tokens":{"access_token":"%s","account_id":"a9"},"last_refresh":"2026-08-01T00:00:00Z"}' \
2638
+ "$(sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CX/acct-01/auth.json" | head -1)" \
2639
+ > "$CX/acct-09/auth.json"
2640
+ : > "$WORK/cx-burst.out"
2641
+ for _ in $(seq 1 16); do ( codex >> "$WORK/cx-burst.out" 2>&1 ) & done
2642
+ wait
2643
+ c_distinct="$(grep -o 'CFG=acct-[0-9]*' "$WORK/cx-burst.out" | sort -u | tr '\n' ' ')"
2644
+ c_n="$(printf '%s' "$c_distinct" | wc -w | tr -d ' ')"
2645
+ [ "$c_n" -ge 2 ] \
2646
+ && t_ok "codex: a parallel burst still spreads across the pool ($c_distinct)" \
2647
+ || t_fail "codex burst spreading" "all 16 concurrent runs took $c_distinct"
2648
+ rm -rf "$CX/acct-09"
2649
+ rm -f "$CX/.last-pick"
2650
+
1559
2651
  # ---- C6. all limited -> least-utilized fallback -------------------------------------
1560
2652
  printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
1561
2653
  printf '%s\nbucket=7d percent=99 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
@@ -2306,6 +3398,462 @@ else
2306
3398
  t_ok "codex npm-layer tests skipped (node not installed)"
2307
3399
  fi
2308
3400
 
3401
+ # ---- 20. panel pools: --json, credential transfer, instance roots, local sync -------
3402
+ # Everything here runs against pools under $WORK/panel, addressed with the NEW
3403
+ # CLAUDE_ACCOUNTS_ROOT/CODEX_ACCOUNTS_ROOT env — while CLAUDE_ACCOUNTS_DIR (the legacy
3404
+ # spelling) still points at the suite's own pool, which is exactly the precedence a
3405
+ # second app-robot instance on a shared machine depends on.
3406
+ PP="$WORK/panel"
3407
+ JP="$PP/claude-a"
3408
+ JP2="$PP/claude-b"
3409
+ PTOKEN="sk-ant-oat01-PANELPOOLTOKENPANELPOOLTOKENPANELPOOLTOKENPANELPOOL"
3410
+ mkdir -p "$JP/acct-01" "$JP/acct-02" "$JP/acct-03" "$JP2"
3411
+ # home must be THIS machine's kind: a credential-less home=mac account audits as
3412
+ # 'remote' (its grant lives elsewhere) rather than 'missing' when the suite runs on
3413
+ # Linux — which is what the publish CI runs on. Same rule as the relogin-redirect
3414
+ # fixture (ddb3015).
3415
+ PMK=mac; [ "$(uname -s)" = "Darwin" ] || PMK=linux
3416
+ cat > "$JP/accounts.json" <<EOF
3417
+ {
3418
+ "version": 1,
3419
+ "server": "root@203.0.113.7",
3420
+ "server_root": "/root/.claude-accounts",
3421
+ "server_repo": "/root/claude-multiacc",
3422
+ "threshold": 90,
3423
+ "accounts": [
3424
+ {"id": "acct-01", "email": "portable@test", "home": "$PMK", "added_at": "2026-01-02T03:04:05Z"},
3425
+ {"id": "acct-02", "email": "local@test", "home": "$PMK", "added_at": "2026-02-02T03:04:05Z"},
3426
+ {"id": "acct-03", "email": "nocred@test", "home": "$PMK", "added_at": "2026-03-02T03:04:05Z"}
3427
+ ]
3428
+ }
3429
+ EOF
3430
+ printf '%s' "$PTOKEN" > "$JP/acct-01/server.token"
3431
+ chmod 600 "$JP/acct-01/server.token"
3432
+ printf '{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' \
3433
+ > "$JP/acct-02/.credentials.json"
3434
+ printf '{"version":1,"server":"none","server_root":"/root/.claude-accounts","server_repo":"/root/claude-multiacc","threshold":90,"accounts":[]}\n' \
3435
+ > "$JP2/accounts.json"
3436
+ : > "$JP/.limits-kick"
3437
+ : > "$JP2/.limits-kick"
3438
+
3439
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json > "$PP/list.json" 2>"$PP/list.err"
3440
+ python3 - "$PP/list.json" "$JP" <<'EOF'
3441
+ import json, sys
3442
+ d = json.load(open(sys.argv[1]))
3443
+ assert d["schema"] == "claude-multiacc/pool.v1", d["schema"]
3444
+ assert d["provider"] == "claude" and d["kind"] == "list", d
3445
+ assert d["pool"]["root"] == sys.argv[2], d["pool"]["root"] # _ROOT beats legacy _DIR
3446
+ by = {a["id"]: a for a in d["accounts"]}
3447
+ assert by["acct-01"]["status"] == "active", by["acct-01"]
3448
+ assert by["acct-01"]["credential_class"] == "portable" and by["acct-01"]["portable"], by["acct-01"]
3449
+ assert by["acct-02"]["credential_class"] == "machine-local", by["acct-02"]
3450
+ assert by["acct-02"]["portable"] is False, by["acct-02"]
3451
+ assert by["acct-03"]["status"] == "missing" and by["acct-03"]["credential_class"] == "none", by["acct-03"]
3452
+ assert by["acct-01"]["home_dir"].endswith("/acct-01"), by["acct-01"]
3453
+ assert by["acct-01"]["email"] == "portable@test", by["acct-01"]
3454
+ assert d["summary"] == {"total": 3, "active": 2, "limited": 0, "needs_login": 1,
3455
+ "portable": 1, "selectable": 2,
3456
+ # how the shim is CURRENTLY ranking: fresh | degraded | blind.
3457
+ # This fixture has no telemetry at all, so: blind.
3458
+ "telemetry": "blind",
3459
+ "ranking_blind": True}, d["summary"]
3460
+ assert d["pool"]["sync"]["mode"] == "server", d["pool"]["sync"]
3461
+ EOF
3462
+ [ $? -eq 0 ] && t_ok "list --json: stable schema, status/class per account, instance root" \
3463
+ || t_fail "list --json" "see $PP/list.json"
3464
+ [ ! -s "$PP/list.err" ] && t_ok "list --json writes nothing to stderr" \
3465
+ || t_fail "list --json stderr" "$(head -c 120 "$PP/list.err")"
3466
+
3467
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts status --json > "$PP/status.json"
3468
+ python3 - "$PP/status.json" <<'EOF'
3469
+ import json, sys
3470
+ d = json.load(open(sys.argv[1]))
3471
+ assert d["kind"] == "status", d["kind"]
3472
+ a = {x["id"]: x for x in d["accounts"]}["acct-01"]
3473
+ assert "last_picked" in a and "expired_marker" in a, a
3474
+ assert a["credentials"]["token"] is True and a["credentials"]["oauth"] is False, a["credentials"]
3475
+ assert a["credentials"]["token_age_days"] == 0, a["credentials"]
3476
+ b = {x["id"]: x for x in d["accounts"]}["acct-02"]
3477
+ assert b["credentials"]["oauth_refresh_expires_at"], b["credentials"]
3478
+ EOF
3479
+ [ $? -eq 0 ] && t_ok "status --json: adds last_picked + credential detail" \
3480
+ || t_fail "status --json" "see $PP/status.json"
3481
+
3482
+ cat > "$WORK/usage-panel.json" <<'EOF'
3483
+ {"limits":[
3484
+ {"kind":"session","percent":12,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
3485
+ {"kind":"weekly_all","percent":93,"resets_at":"2099-01-03T00:00:00+00:00","scope":null}
3486
+ ]}
3487
+ EOF
3488
+ CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-panel.json" \
3489
+ claude-accounts limits --json > "$PP/limits.json" 2>/dev/null
3490
+ python3 - "$PP/limits.json" <<'EOF'
3491
+ import json, sys
3492
+ d = json.load(open(sys.argv[1]))
3493
+ assert d["kind"] == "limits", d["kind"]
3494
+ a = {x["id"]: x for x in d["accounts"]}["acct-01"]
3495
+ assert a["usage"]["max_percent"] == 93, a["usage"]
3496
+ assert a["usage"]["source"] == "token", a["usage"]
3497
+ assert a["status"] == "limited" and a["limited"] is True, a
3498
+ assert a["limit_reset_at"], a
3499
+ assert a["selectable"] is False, a
3500
+ # acct-02 has a live oauth credential, so it fetched the same fixture and parked too
3501
+ assert d["summary"]["limited"] == 2, d["summary"]
3502
+ EOF
3503
+ [ $? -eq 0 ] && t_ok "limits --json: refreshes, then reports usage + limited state" \
3504
+ || t_fail "limits --json" "see $PP/limits.json"
3505
+ rm -f "$JP/acct-01/.limited" "$JP/acct-02/.limited"
3506
+
3507
+ # ---- 20a. export-credential: portable only, exit codes a daemon can branch on -------
3508
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 > "$PP/blob.json" 2>"$PP/blob.err"
3509
+ rc=$?
3510
+ [ "$rc" = "0" ] && t_ok "export-credential exits 0 for a portable account" \
3511
+ || t_fail "export rc" "rc=$rc $(head -c 120 "$PP/blob.err")"
3512
+ python3 - "$PP/blob.json" "$PTOKEN" "$PMK" <<'EOF'
3513
+ import json, sys
3514
+ b = json.load(open(sys.argv[1]))
3515
+ assert b["format"] == "claude-multiacc/credential" and b["version"] == 1, b
3516
+ assert b["provider"] == "claude" and b["class"] == "portable", b
3517
+ assert b["account"] == {"id": "acct-01", "email": "portable@test", "home": sys.argv[3],
3518
+ "added_at": "2026-01-02T03:04:05Z"}, b["account"]
3519
+ assert b["credential"] == {"type": "setup-token", "value": sys.argv[2]}, "credential mismatch"
3520
+ assert b["exported_from"]["pool_root"].endswith("claude-a"), b["exported_from"]
3521
+ EOF
3522
+ [ $? -eq 0 ] && t_ok "export blob: self-contained credential + identity metadata" \
3523
+ || t_fail "export blob shape" "see $PP/blob.json"
3524
+
3525
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-02 2>&1)"
3526
+ rc=$?
3527
+ check "export refuses a machine-local credential" "MACHINE-LOCAL" "$out"
3528
+ check "export says how to make it portable" "claude-accounts mint acct-02" "$out"
3529
+ [ "$rc" = "3" ] && t_ok "machine-local export exits 3" || t_fail "machine-local exit code" "rc=$rc"
3530
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-03 2>&1)"
3531
+ rc=$?
3532
+ check "export reports an account with no credential" "no credential material" "$out"
3533
+ [ "$rc" = "4" ] && t_ok "credential-less export exits 4" || t_fail "no-credential exit code" "rc=$rc"
3534
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-77 2>&1)"
3535
+ [ $? != 0 ] && t_ok "export of an unregistered id fails" || t_fail "unknown id export" "exited 0"
3536
+
3537
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-02 --identity-only --out "$PP/ident.json" >/dev/null
3538
+ rc=$?
3539
+ python3 - "$PP/ident.json" <<'EOF'
3540
+ import json, sys
3541
+ b = json.load(open(sys.argv[1]))
3542
+ assert b["class"] == "identity", b
3543
+ assert "credential" not in b, "identity blob must carry no credential material"
3544
+ assert b["account"]["email"] == "local@test", b
3545
+ EOF
3546
+ [ $? -eq 0 ] && [ "$rc" = "0" ] \
3547
+ && t_ok "export --identity-only works for a machine-local account (registry only)" \
3548
+ || t_fail "identity-only export" "rc=$rc, see $PP/ident.json"
3549
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 --out "$PP/blob-out.json" >/dev/null
3550
+ case "$(ls -l "$PP/blob-out.json" | cut -c1-10)" in
3551
+ -rw-------) t_ok "export --out writes the blob 0600" ;;
3552
+ *) t_fail "export --out perms" "$(ls -l "$PP/blob-out.json" | cut -c1-10)" ;;
3553
+ esac
3554
+
3555
+ # ---- 20b. import-credential: faithful, idempotent, and picky ------------------------
3556
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential < "$PP/blob.json" 2>&1)"
3557
+ rc=$?
3558
+ check "import-credential installs a portable credential" "Imported acct-01 (portable@test" "$out"
3559
+ [ "$rc" = "0" ] && t_ok "import-credential exits 0" || t_fail "import rc" "rc=$rc"
3560
+ python3 - "$JP2/accounts.json" "$JP2/acct-01/server.token" "$PTOKEN" "$PMK" <<'EOF'
3561
+ import json, sys
3562
+ accs = json.load(open(sys.argv[1]))["accounts"]
3563
+ assert len(accs) == 1, accs
3564
+ a = accs[0]
3565
+ # byte-faithful metadata: the account reads identically on the second machine
3566
+ assert a == {"id": "acct-01", "email": "portable@test", "home": sys.argv[4],
3567
+ "added_at": "2026-01-02T03:04:05Z"}, a
3568
+ assert open(sys.argv[2]).read() == sys.argv[3], "token content changed in transfer"
3569
+ EOF
3570
+ [ $? -eq 0 ] && t_ok "imported account + credential round-trip unchanged" \
3571
+ || t_fail "import fidelity" "see $JP2"
3572
+ case "$(ls -l "$JP2/acct-01/server.token" | cut -c1-10)" in
3573
+ -rw-------) t_ok "imported credential is 0600" ;;
3574
+ *) t_fail "imported credential perms" "$(ls -l "$JP2/acct-01/server.token" | cut -c1-10)" ;;
3575
+ esac
3576
+ # The write goes through a temp file in the account dir; none of it may survive.
3577
+ [ -z "$(ls -a "$JP2/acct-01" | grep '^\.cred\.')" ] \
3578
+ && t_ok "no temp credential file is left behind by an import" \
3579
+ || t_fail "import leftovers" "$(ls -a "$JP2/acct-01" | grep '^\.cred\.')"
3580
+ [ -z "$(ls -a "$JP2/tmp" 2>/dev/null | grep 'import-cred')" ] \
3581
+ && t_ok "no staged blob is left behind by an import" \
3582
+ || t_fail "staged blob leftovers" "$(ls -a "$JP2/tmp" | grep 'import-cred')"
3583
+ CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/blob.json" >/dev/null 2>&1
3584
+ n="$(python3 -c "import json;print(len(json.load(open('$JP2/accounts.json'))['accounts']))")"
3585
+ [ "$n" = "1" ] && t_ok "re-importing the same account refreshes it, never duplicates" \
3586
+ || t_fail "import idempotence" "$n accounts after a second import"
3587
+
3588
+ python3 - "$PP/blob.json" "$PP/blob-apikey.json" <<'EOF'
3589
+ import json, sys
3590
+ b = json.load(open(sys.argv[1]))
3591
+ b["credential"]["value"] = "sk-ant-api03-" + "A" * 60 # an API key, not a setup-token
3592
+ json.dump(b, open(sys.argv[2], "w"))
3593
+ EOF
3594
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/blob-apikey.json" 2>&1)"
3595
+ rc=$?
3596
+ check "import refuses an API key" "not a subscription setup-token" "$out"
3597
+ [ "$rc" != "0" ] && t_ok "API-key import exits nonzero" || t_fail "api key import" "exited 0"
3598
+
3599
+ out="$(CODEX_ACCOUNTS_ROOT="$PP/codex-b" codex-accounts import-credential --in "$PP/blob.json" 2>&1)"
3600
+ [ $? != 0 ] && t_ok "a claude blob cannot be imported into the codex pool" \
3601
+ || t_fail "cross-provider import" "exited 0"
3602
+
3603
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/ident.json" 2>&1)"
3604
+ check "identity-only import registers the account" "identity only, no credential" "$out"
3605
+ check "identity-only import says what it still needs" "claude-accounts login" "$out"
3606
+ [ ! -f "$JP2/acct-02/server.token" ] && t_ok "identity-only import installs no credential" \
3607
+ || t_fail "identity-only import" "wrote a credential file"
3608
+
3609
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential acct-02 --in "$PP/blob.json" 2>&1)"
3610
+ rc=$?
3611
+ check "import refuses to land the same email in a second slot" "already registered as acct-01" "$out"
3612
+ [ "$rc" != "0" ] && t_ok "conflicting-id import exits nonzero" || t_fail "conflicting id" "exited 0"
3613
+
3614
+ # ---- 20c. instance isolation: the shim resolves the same root as the CLI ------------
3615
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude 2>&1)"
3616
+ check "shim honors CLAUDE_ACCOUNTS_ROOT (instance pool)" "CFG=acct-01" "$out"
3617
+ [ -f "$JP2/selection.log" ] && t_ok "instance pool records its own selection log" \
3618
+ || t_fail "instance selection log" "missing at $JP2/selection.log"
3619
+ grep -q "acct-01" "$ACC/selection.log" && ! grep -q "portable@test" "$ACC/accounts.json" \
3620
+ && t_ok "the default pool was untouched by the instance run" \
3621
+ || t_fail "pool isolation" "the instance run leaked into $ACC"
3622
+
3623
+ # ---- 20d. sync target: overridable, and a local-only mode that pushes nowhere -------
3624
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync --no-server 2>&1)"
3625
+ rc=$?
3626
+ check "sync --no-server stays local" "local-only" "$out"
3627
+ [ "$rc" = "0" ] && t_ok "sync --no-server exits 0" || t_fail "sync --no-server rc" "rc=$rc"
3628
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET=none CLAUDE_MULTIACC_NO_SYNC=0 \
3629
+ claude-accounts sync 2>&1)"
3630
+ check "SYNC_TARGET=none makes sync local-only" "nothing pushed" "$out"
3631
+ CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET="ops@panel.example" \
3632
+ claude-accounts list --json > "$PP/list-target.json"
3633
+ python3 - "$PP/list-target.json" <<'EOF'
3634
+ import json, sys
3635
+ s = json.load(open(sys.argv[1]))["pool"]["sync"]
3636
+ assert s["mode"] == "server" and s["target"] == "ops@panel.example", s
3637
+ EOF
3638
+ [ $? -eq 0 ] && t_ok "SYNC_TARGET env overrides the manifest server" \
3639
+ || t_fail "sync target override" "see $PP/list-target.json"
3640
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" CLAUDE_MULTIACC_NO_SYNC=0 \
3641
+ claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3642
+ case "$out" in
3643
+ *"sync failed"*) t_fail "local-only auto_sync" "a local-only pool warned about a server push: $out" ;;
3644
+ *) t_ok "local-only pool: mutations never warn about a missing server" ;;
3645
+ esac
3646
+ # A replica pool must NEVER push — and pointing it at a local-only target must not
3647
+ # become a way around that. Local mode narrows the rule (nothing pushes at all); the
3648
+ # marker is still honored and still reported.
3649
+ printf 'replica\n' > "$JP/sync-role"
3650
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET=none CLAUDE_MULTIACC_NO_SYNC=0 \
3651
+ claude-accounts sync 2>&1)"
3652
+ rc=$?
3653
+ check "a replica in local-only mode still says it is a replica" "sync replica" "$out"
3654
+ check "a replica in local-only mode pushes nothing" "nothing pushed either way" "$out"
3655
+ [ "$rc" = "0" ] && t_ok "replica + local-only exits 0" || t_fail "replica local-only rc" "rc=$rc"
3656
+ # `sync` refuses outright off the Mac ("sync runs on the Mac, not the server"), so the
3657
+ # replica and target-injection rules can only be observed there. Stated as a skip rather
3658
+ # than quietly asserting the wrong message on Linux — which is what broke the publish CI.
3659
+ if [ "$(uname -s)" = "Darwin" ]; then
3660
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
3661
+ check "a replica with a server target still refuses to push" "sync replica" "$out"
3662
+ else
3663
+ t_ok "server-target sync refusal skipped (sync is Mac-only; this host is $(uname -s))"
3664
+ fi
3665
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 \
3666
+ claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3667
+ case "$out" in
3668
+ *"sync failed"*|*"push"*) t_fail "replica auto_sync" "a replica mutation tried to push: $out" ;;
3669
+ *) t_ok "a mutation on a replica never pushes (auto_sync stays silent)" ;;
3670
+ esac
3671
+ rm -f "$JP/sync-role"
3672
+
3673
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET="ops@x; touch $WORK/PWNED" \
3674
+ CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
3675
+ rc=$?
3676
+ if [ "$(uname -s)" = "Darwin" ]; then
3677
+ check "an injected SYNC_TARGET is refused" "not a plain user@host" "$out"
3678
+ else
3679
+ t_ok "injected SYNC_TARGET refusal message skipped (sync is Mac-only here)"
3680
+ fi
3681
+ # The part that matters everywhere: refused, and never executed.
3682
+ [ "$rc" != "0" ] && [ ! -f "$WORK/PWNED" ] && t_ok "injected sync target never executed" \
3683
+ || t_fail "sync target injection" "rc=$rc"
3684
+
3685
+ # ---- 20e. codex parity: no portable class, identity transfer only -------------------
3686
+ CX2="$PP/codex-a"
3687
+ mkdir -p "$CX2/acct-01"
3688
+ cat > "$CX2/accounts.json" <<'EOF'
3689
+ {"version":1,"server":"none","server_root":"/root/.codex-accounts","server_repo":"/root/claude-multiacc",
3690
+ "threshold":90,"accounts":[{"id":"acct-01","email":"cx@panel","home":"mac","added_at":"2026-04-05T06:07:08Z"}]}
3691
+ EOF
3692
+ python3 - "$CX2/acct-01/auth.json" <<'EOF'
3693
+ import base64, json, sys, time
3694
+ b = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip('=')
3695
+ jwt = lambda c: b({"alg": "none"}) + '.' + b(c) + '.sig'
3696
+ json.dump({"auth_mode": "chatgpt", "OPENAI_API_KEY": None,
3697
+ "tokens": {"id_token": jwt({"email": "cx@panel"}),
3698
+ "access_token": jwt({"exp": time.time() + 9999}),
3699
+ "refresh_token": "r", "account_id": "acc"},
3700
+ "last_refresh": "2026-08-01T00:00:00Z"}, open(sys.argv[1], "w"))
3701
+ EOF
3702
+ CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts list --json > "$PP/cx-list.json"
3703
+ python3 - "$PP/cx-list.json" "$CX2" <<'EOF'
3704
+ import json, sys
3705
+ d = json.load(open(sys.argv[1]))
3706
+ assert d["provider"] == "codex" and d["pool"]["root"] == sys.argv[2], d["pool"]
3707
+ a = d["accounts"][0]
3708
+ assert a["status"] == "active", a
3709
+ assert a["credential_class"] == "machine-local" and a["portable"] is False, a
3710
+ assert d["summary"]["portable"] == 0, d["summary"]
3711
+ assert d["pool"]["sync"]["mode"] == "local", d["pool"]["sync"]
3712
+ EOF
3713
+ [ $? -eq 0 ] && t_ok "codex list --json: same schema, no portable credentials" \
3714
+ || t_fail "codex list --json" "see $PP/cx-list.json"
3715
+ out="$(CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts export-credential acct-01 2>&1)"
3716
+ rc=$?
3717
+ check "codex export always refuses" "no portable credential type" "$out"
3718
+ check "codex export points at the device-code login" "codex-accounts login acct-01" "$out"
3719
+ [ "$rc" = "3" ] && t_ok "codex export exits 3 (machine-local)" || t_fail "codex export rc" "rc=$rc"
3720
+ CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts export-credential acct-01 --identity-only --out "$PP/cx-ident.json" >/dev/null
3721
+ mkdir -p "$PP/codex-b"
3722
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$PP/codex-b/accounts.json"
3723
+ out="$(CODEX_ACCOUNTS_ROOT="$PP/codex-b" codex-accounts import-credential --in "$PP/cx-ident.json" 2>&1)"
3724
+ rc=$?
3725
+ check "codex identity import registers the account" "Registered acct-01 (cx@panel" "$out"
3726
+ check "codex identity import asks for a local sign-in" "codex-accounts login acct-01" "$out"
3727
+ [ "$rc" = "0" ] && t_ok "codex identity import exits 0" || t_fail "codex identity import rc" "rc=$rc"
3728
+ python3 - "$PP/codex-b/accounts.json" <<'EOF'
3729
+ import json, sys
3730
+ a = json.load(open(sys.argv[1]))["accounts"][0]
3731
+ assert a["added_at"] == "2026-04-05T06:07:08Z", a # source metadata preserved
3732
+ EOF
3733
+ [ $? -eq 0 ] && t_ok "codex identity transfer keeps the source added_at" \
3734
+ || t_fail "codex identity metadata" "see $PP/codex-b/accounts.json"
3735
+
3736
+ # ---- 20f. codex-review follow-ups: hostile pool roots, empty fields, marker rule ----
3737
+ # A pool root carrying shell metacharacters must never reach a trap body or a remote
3738
+ # command as code. (install.sh refuses to SCHEDULE such a root; the CLI must still be
3739
+ # safe when one is used directly.)
3740
+ QROOT="$PP/q'; touch $WORK/TRAP_PWNED; '"
3741
+ mkdir -p "$QROOT"
3742
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$QROOT/accounts.json"
3743
+ rm -f "$WORK/TRAP_PWNED"
3744
+ out="$(CLAUDE_ACCOUNTS_ROOT="$QROOT" claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3745
+ rc=$?
3746
+ check "import works from a pool root with shell metacharacters" "Imported acct-01" "$out"
3747
+ [ "$rc" = "0" ] && [ ! -f "$WORK/TRAP_PWNED" ] \
3748
+ && t_ok "a quoted pool root never executes as code (trap body)" \
3749
+ || { t_fail "pool root injection" "rc=$rc, sentinel=$([ -f "$WORK/TRAP_PWNED" ] && echo CREATED)"; rm -f "$WORK/TRAP_PWNED"; }
3750
+ out="$(CLAUDE_ACCOUNTS_ROOT="$QROOT" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-panel.json" \
3751
+ claude-accounts limits --json 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin)['accounts'][0]['id'])" 2>&1)"
3752
+ check "limits --json survives a quoted pool root" "acct-01" "$out"
3753
+ [ ! -f "$WORK/TRAP_PWNED" ] && t_ok "the limits lock trap never executes a quoted root" \
3754
+ || { t_fail "limits trap injection" "sentinel created"; rm -f "$WORK/TRAP_PWNED"; }
3755
+
3756
+ # An empty metadata field must stay empty: the record separator is 0x1F precisely
3757
+ # because bash collapses runs of IFS *whitespace*, which would shift added_at into home.
3758
+ python3 - "$PP/blob.json" "$PP/blob-nohome.json" <<'EOF'
3759
+ import json, sys
3760
+ b = json.load(open(sys.argv[1]))
3761
+ b["account"]["home"] = ""
3762
+ b["account"]["email"] = "nohome@test"
3763
+ json.dump(b, open(sys.argv[2], "w"))
3764
+ EOF
3765
+ NH="$PP/claude-nohome"
3766
+ mkdir -p "$NH"
3767
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$NH/accounts.json"
3768
+ CLAUDE_ACCOUNTS_ROOT="$NH" claude-accounts import-credential --in "$PP/blob-nohome.json" >/dev/null 2>&1
3769
+ python3 - "$NH/accounts.json" <<'EOF'
3770
+ import json, sys
3771
+ a = json.load(open(sys.argv[1]))["accounts"][0]
3772
+ assert a["email"] == "nohome@test", a
3773
+ # home falls back to this machine, and added_at is NOT the value that would land there
3774
+ # if the empty field had collapsed
3775
+ assert a["home"] in ("mac", "linux"), a
3776
+ assert a["added_at"] == "2026-01-02T03:04:05Z", a
3777
+ EOF
3778
+ [ $? -eq 0 ] && t_ok "a blob with an empty field imports without shifting the next one" \
3779
+ || t_fail "empty-field record" "see $NH/accounts.json"
3780
+
3781
+ # Metadata carrying a control character is refused rather than truncating the record.
3782
+ python3 - "$PP/blob.json" "$PP/blob-ctrl.json" <<'EOF'
3783
+ import json, sys
3784
+ b = json.load(open(sys.argv[1]))
3785
+ b["account"]["email"] = "evil@test\nacct-99"
3786
+ json.dump(b, open(sys.argv[2], "w"))
3787
+ EOF
3788
+ out="$(CLAUDE_ACCOUNTS_ROOT="$NH" claude-accounts import-credential --in "$PP/blob-ctrl.json" 2>&1)"
3789
+ rc=$?
3790
+ check "metadata with a control character is refused" "control character" "$out"
3791
+ [ "$rc" != "0" ] && t_ok "control-character blob exits nonzero" || t_fail "control char blob" "exited 0"
3792
+
3793
+ # An adopted account is a symlink: a credential must never be written THROUGH it,
3794
+ # not even with --force (--force settles identity, it does not authorize escaping the pool).
3795
+ mkdir -p "$PP/outside"
3796
+ ln -s "$PP/outside" "$JP2/acct-09"
3797
+ python3 - "$PP/blob.json" "$PP/blob-09.json" <<'EOF'
3798
+ import json, sys
3799
+ b = json.load(open(sys.argv[1]))
3800
+ b["account"]["id"] = "acct-09"
3801
+ json.dump(b, open(sys.argv[2], "w"))
3802
+ EOF
3803
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential acct-09 --in "$PP/blob-09.json" --force 2>&1)"
3804
+ rc=$?
3805
+ check "import refuses to write through an adopted symlink" "must never be written through it" "$out"
3806
+ [ "$rc" != "0" ] && [ ! -f "$PP/outside/server.token" ] \
3807
+ && t_ok "--force does not authorize writing outside the pool" \
3808
+ || t_fail "symlink import" "rc=$rc, wrote=$([ -f "$PP/outside/server.token" ] && echo yes)"
3809
+ rm -f "$JP2/acct-09"
3810
+
3811
+ # The exported blob must land 0600 even when a world-readable file is already there.
3812
+ : > "$PP/pre-existing.json"
3813
+ chmod 644 "$PP/pre-existing.json"
3814
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 --out "$PP/pre-existing.json" >/dev/null
3815
+ case "$(ls -l "$PP/pre-existing.json" | cut -c1-10)" in
3816
+ -rw-------) t_ok "export --out replaces a world-readable file with a 0600 one" ;;
3817
+ *) t_fail "export --out perms over existing file" "$(ls -l "$PP/pre-existing.json" | cut -c1-10)" ;;
3818
+ esac
3819
+
3820
+ # The JSON report must apply the shim's marker rule exactly: a marker whose reset has
3821
+ # passed is NOT limited (the shim deletes it and selects the account), while a garbled
3822
+ # marker IS (the shim treats it as active rather than racing a concurrent write).
3823
+ printf '%s\nbucket=weekly percent=95 reason=limits\n' "$((now-600))" > "$JP/acct-01/.limited"
3824
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json | python3 -c "
3825
+ import json,sys; a={x['id']:x for x in json.load(sys.stdin)['accounts']}['acct-01']
3826
+ print(a['status'], a['limited'], a['selectable'])")"
3827
+ check "an elapsed .limited marker does not read as limited" "active False True" "$out"
3828
+ printf 'garbled\n' > "$JP/acct-01/.limited"
3829
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json | python3 -c "
3830
+ import json,sys; a={x['id']:x for x in json.load(sys.stdin)['accounts']}['acct-01']
3831
+ print(a['status'], a['limited'])")"
3832
+ check "a garbled .limited marker reads as limited (same as the shim)" "limited True" "$out"
3833
+ rm -f "$JP/acct-01/.limited"
3834
+
3835
+ # --json must not swallow a following typo, and a broken refresh must not be reported
3836
+ # as success just because the document rendered.
3837
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json --bogus 2>&1)"
3838
+ rc=$?
3839
+ check "list --json rejects an unknown extra option" "unknown option: --bogus" "$out"
3840
+ [ "$rc" != "0" ] && t_ok "list --json --bogus exits nonzero" || t_fail "strict --json parsing" "exited 0"
3841
+ BROKEN="$PP/claude-broken"
3842
+ mkdir -p "$BROKEN"
3843
+ printf 'not json at all\n' > "$BROKEN/accounts.json"
3844
+ out="$(CLAUDE_ACCOUNTS_ROOT="$BROKEN" claude-accounts limits --json 2>/dev/null)"
3845
+ rc=$?
3846
+ [ "$rc" != "0" ] && t_ok "limits --json propagates a failed refresh (nonzero)" \
3847
+ || t_fail "limits --json exit code" "exited 0 on an unreadable manifest"
3848
+ printf '%s' "$out" | python3 -c "
3849
+ import json,sys
3850
+ d=json.load(sys.stdin)
3851
+ assert d['accounts'] == [], d
3852
+ assert any('manifest' in w for w in d['warnings']), d['warnings']
3853
+ " 2>/dev/null \
3854
+ && t_ok "limits --json still emits a document (with a warning) when the pool is broken" \
3855
+ || t_fail "limits --json on a broken pool" "no usable document"
3856
+
2309
3857
  # ---- summary ---------------------------------------------------------------------
2310
3858
  echo
2311
3859
  echo "passed: $PASS failed: $FAIL"