claude-multiacc 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,6 +74,22 @@ for a in "$@"; do
74
74
  --echo-stdin) cat; exit 0 ;;
75
75
  esac
76
76
  done
77
+ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
78
+ # Mimic the real client: register the run in the ACCOUNT dir for its lifetime, write
79
+ # the session transcript into the (shared) projects tree, delete the registry on exit.
80
+ mkdir -p "$CLAUDE_CONFIG_DIR/sessions" "$CLAUDE_CONFIG_DIR/projects/-proj"
81
+ printf '{"pid":%s,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
82
+ "$$" "$FAKE_SESSION_ID" "$(date -u +%s)" > "$CLAUDE_CONFIG_DIR/sessions/$$.json"
83
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$FAKE_SESSION_ID" \
84
+ > "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
85
+ if [ -n "${FAKE_LIMIT_RESET:-}" ]; then
86
+ 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' \
87
+ "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$FAKE_LIMIT_RESET" "$FAKE_SESSION_ID" \
88
+ >> "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
89
+ fi
90
+ sleep "${FAKE_SESSION_HOLD:-4}"
91
+ rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
92
+ fi
77
93
  echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
78
94
  EOF
79
95
  chmod +x "$FAKEBIN/claude"
@@ -86,6 +102,10 @@ export CLAUDE_MULTIACC_NO_SYNC=1
86
102
  # Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
87
103
  # is off by default here; the throttle test re-enables it explicitly.
88
104
  export CLAUDE_MULTIACC_MIN_FETCH=0
105
+ # The client-rate-limit scan memoizes a CLEAN result for a few seconds; tests plant
106
+ # rejections and expect them seen on the very next run, so the memo is off by default
107
+ # here (12i re-enables it to prove the memo itself works).
108
+ export CLAUDE_MULTIACC_CLIENT_SCAN_TTL=0
89
109
  # The oauth token endpoint must NEVER be hit for real from tests: default to a missing
90
110
  # file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
91
111
  export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
@@ -210,6 +230,19 @@ out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
210
230
  check "pin to auth-less dir (ceremony)" "CFG=acct-07" "$out"
211
231
  rmdir "$ACC/acct-07"
212
232
 
233
+ # ---- 6c. pin uses the portable token when the credential beside it is DEAD ----
234
+ # Regression: the pin path used to export the token only when NO credential file
235
+ # existed, so pinning an account whose login had died failed with "OAuth session
236
+ # expired" even though its setup-token was sitting right there — the exact state a
237
+ # fleet-distributed token lands in on a machine that still has a stale login.
238
+ mkdir -p "$ACC/acct-07"
239
+ printf '{"claudeAiOauth":{"accessToken":"dead","refreshToken":"","expiresAt":0,"refreshTokenExpiresAt":1}}' \
240
+ > "$ACC/acct-07/.credentials.json"
241
+ printf 'sk-ant-oat01-PINNEDDEADCREDSPINNEDDEADCREDSPINNEDDEADCREDS00' > "$ACC/acct-07/server.token"
242
+ out="$(CLAUDE_ACCOUNT=acct-07 claude 2>&1)"
243
+ check "pinned dead-credential account still runs under its token" "TOK=sk-ant-oat01-PINNED" "$out"
244
+ rm -rf "$ACC/acct-07"
245
+
213
246
  # ---- 7. limited marker excludes account ------------------------------------
214
247
  printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$ACC/acct-01/.limited"
215
248
  all2=1
@@ -508,6 +541,361 @@ CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits -
508
541
  [ -f "$ACC/acct-01/.limited" ] && t_ok "error-cooldown marker survives clean limits refresh" || t_fail "cooldown vs limits" "marker was cleared early"
509
542
  rm -f "$ACC/acct-01/.limited"
510
543
 
544
+ # ---- 12f-12h. client-reported rate limits + rotation (own two-account pool) -------
545
+ # The shared pool has picked up a third account by now, so these run in an instance root
546
+ # of their own: with exactly two accounts, "went somewhere else" and "rotated" are both
547
+ # unambiguous.
548
+ CLP="$WORK/client-limit-pool"
549
+ mkdir -p "$CLP/tmp"
550
+ : > "$CLP/.limits-kick"
551
+ cat > "$CLP/accounts.json" <<'EOF'
552
+ {"version":1,"threshold":90,"accounts":[
553
+ {"id":"acct-01","email":"cl1@test","home":"mac","added_at":"2026-07-13T00:00:00Z"},
554
+ {"id":"acct-02","email":"cl2@test","home":"mac","added_at":"2026-07-13T00:00:00Z"}]}
555
+ EOF
556
+ for i in 01 02; do
557
+ mkdir -p "$CLP/acct-$i"
558
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-cl%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' "$i" > "$CLP/acct-$i/.credentials.json"
559
+ done
560
+ export CLAUDE_ACCOUNTS_ROOT="$CLP"
561
+ ACC_SAVED="$ACC"
562
+ ACC="$CLP"
563
+
564
+ # ---- 12f. a rate limit hit by an INTERACTIVE session takes its account out ---------
565
+ # The reported bug: a tmux session runs into its 5h limit, the user quits and starts
566
+ # `claude` again, and the pool hands back the same dead account. Interactive runs are
567
+ # exec'd, so the -p retry path above never sees them — the only trace of the rejection
568
+ # is the one Claude Code writes itself, in the session transcript.
569
+ mkclientlimit() { # mkclientlimit <acct dir> <session id> <resetsAt> [rejection ISO ts]
570
+ mkdir -p "$1/projects/-proj"
571
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$2" > "$1/projects/-proj/$2.jsonl"
572
+ 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' \
573
+ "${4:-$(date -u +%Y-%m-%dT%H:%M:%S.000Z)}" "$3" "$2" >> "$1/projects/-proj/$2.jsonl"
574
+ # index line = "<id> <ISO claim>": the account that owned the session, and from when
575
+ 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"
576
+ }
577
+ SID1="81bf8b20-013f-4414-8878-e0289bec9ad0"
578
+ RESET1=$(( $(date +%s) + 1800 ))
579
+ mkclientlimit "$ACC/acct-01" "$SID1" "$RESET1"
580
+ all2=1
581
+ for _ in $(seq 1 12); do
582
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
583
+ done
584
+ [ "$all2" = "1" ] && t_ok "client-reported rate limit excludes the account" \
585
+ || t_fail "client rate limit" "the rejected account was picked again"
586
+ first="$(head -1 "$ACC/acct-01/.limited" 2>/dev/null)"
587
+ [ "$first" = "$RESET1" ] && t_ok "marker carries the API's own reset time" \
588
+ || t_fail "client marker reset" "want $RESET1, got '${first:-<none>}'"
589
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
590
+ && t_ok "marker is tagged client-rate-limit" || t_fail "client marker reason" "$(cat "$ACC/acct-01/.limited" 2>/dev/null)"
591
+ out="$(CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
592
+ check "explicit pin still wins over a client-reported limit" "CFG=acct-01" "$out"
593
+
594
+ # a marker the client earned survives a clean telemetry pass while its window is open
595
+ CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-mid.json" claude-accounts limits --quiet
596
+ [ -f "$ACC/acct-01/.limited" ] && t_ok "client-rate-limit marker survives a clean limits refresh" \
597
+ || t_fail "client marker vs limits" "marker was cleared while the window was still open"
598
+ rm -f "$ACC/acct-01/.limited"
599
+
600
+ # an ALREADY-ELAPSED rejection is history, not an exclusion
601
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) - 60 ))"
602
+ hits1=0
603
+ for _ in $(seq 1 12); do
604
+ case "$(claude 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
605
+ done
606
+ { [ "$hits1" -gt 0 ] && [ ! -f "$ACC/acct-01/.limited" ]; } \
607
+ && t_ok "an elapsed client rejection does not exclude" \
608
+ || t_fail "elapsed client rejection" "acct-01 hits=$hits1 marker=$([ -f "$ACC/acct-01/.limited" ] && echo yes || echo no)"
609
+
610
+ # opt-out
611
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
612
+ rm -f "$ACC/acct-01/.limited"
613
+ hits1=0
614
+ for _ in $(seq 1 12); do
615
+ case "$(CLAUDE_MULTIACC_CLIENT_LIMITS=0 claude 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
616
+ done
617
+ { [ "$hits1" -gt 0 ] && [ ! -f "$ACC/acct-01/.limited" ]; } \
618
+ && t_ok "CLAUDE_MULTIACC_CLIENT_LIMITS=0 turns the scan off" \
619
+ || t_fail "client-limit opt-out" "still excluded with the scan disabled"
620
+
621
+ # a hostile session index must not walk out of the pool
622
+ printf '../../../../etc/passwd x\n-e x\n%s %s\n' "$SID1" "2026-01-01T00:00:00Z" > "$ACC/acct-01/.sessions-index"
623
+ claude >/dev/null 2>&1
624
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
625
+ && t_ok "traversal and option-shaped ids are ignored, real ids still scanned" \
626
+ || t_fail "session index traversal" "scan broke on a hostile entry"
627
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index
628
+ rm -rf "$ACC"/acct-0*/projects
629
+
630
+ # ---- 12g. the whole round trip: a run registers itself, the NEXT run avoids it -----
631
+ # No .sessions-index is planted here. The shim's detached capture has to learn the
632
+ # session id from $acct/sessions/<pid>.json WHILE the run is alive — after it exits the
633
+ # client deletes that file and nothing else can name the transcript.
634
+ rm -f "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick
635
+ FAKE_SESSION_ID="9f3c1d2e-0000-4000-8000-abcdefabcdef" \
636
+ FAKE_LIMIT_RESET="$(( $(date +%s) + 1800 ))" \
637
+ FAKE_SESSION_HOLD=5 claude >/dev/null 2>&1
638
+ hit="$(ls "$ACC"/acct-0*/.sessions-index 2>/dev/null | head -1)"
639
+ limited_dir="$(dirname "${hit:-/nonexistent}")"
640
+ [ -n "$hit" ] && t_ok "the run's own session id is captured while it is alive" \
641
+ || t_fail "session capture" "no .sessions-index was written"
642
+ out="$(claude 2>&1)"
643
+ case "$out" in
644
+ *"CFG=$(basename "$limited_dir")"*)
645
+ t_fail "restart after a limit hit" "landed straight back on $(basename "$limited_dir")" ;;
646
+ *) t_ok "quitting a limit-hit session and restarting lands on another account" ;;
647
+ esac
648
+ grep -q 'reason=client-rate-limit' "$limited_dir/.limited" 2>/dev/null \
649
+ && t_ok "the account that hit the limit is marked from its own transcript" \
650
+ || t_fail "post-run marking" "no client-rate-limit marker on $(basename "$limited_dir")"
651
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index
652
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
653
+
654
+ # ---- 12h. equal-headroom picks ROTATE instead of re-rolling a coin -----------------
655
+ # With telemetry stale (the usage endpoint 429s its own callers for hours) every account
656
+ # scores NEUTRAL, so this tie-break is the only thing standing between a restart and the
657
+ # account it just walked away from.
658
+ rm -f "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick "$ACC"/acct-0*/limits.json
659
+ seq_out=""
660
+ for _ in $(seq 1 8); do
661
+ case "$(claude 2>&1)" in
662
+ *CFG=acct-01*) seq_out="${seq_out}1" ;;
663
+ *CFG=acct-02*) seq_out="${seq_out}2" ;;
664
+ *) seq_out="${seq_out}?" ;;
665
+ esac
666
+ done
667
+ case "$seq_out" in
668
+ 12121212|21212121) t_ok "equal-score picks round-robin across the pool ($seq_out)" ;;
669
+ *) t_fail "round-robin tie-break" "sequence $seq_out (want strict alternation)" ;;
670
+ esac
671
+ # ... and the rotation must never override real headroom
672
+ lj 80 10 80 > "$ACC/acct-01/limits.json"
673
+ lj 20 10 20 > "$ACC/acct-02/limits.json"
674
+ all2=1
675
+ for _ in $(seq 1 8); do
676
+ case "$(claude 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
677
+ done
678
+ [ "$all2" = "1" ] && t_ok "rotation never outranks measured headroom" \
679
+ || t_fail "rotation vs headroom" "rotation pulled work onto the busier account"
680
+ rm -f "$ACC"/acct-0*/limits.json "$ACC/.pick-seq" "$ACC"/acct-0*/.last-pick
681
+
682
+ # ---- 12i. the clean-scan memo throttles re-reads without stranding a limit ---------
683
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
684
+ rm -rf "$ACC"/acct-0*/projects
685
+ mkdir -p "$ACC/acct-01/projects/-proj"
686
+ printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$SID1" > "$ACC/acct-01/projects/-proj/$SID1.jsonl"
687
+ printf '%s %s\n' "$SID1" "2026-01-01T00:00:00Z" > "$ACC/acct-01/.sessions-index"
688
+ claude >/dev/null 2>&1 # a clean scan over a real (rejection-free) transcript
689
+ [ -f "$ACC/acct-01/.client-scan" ] \
690
+ && t_ok "a clean client scan is memoized" || t_fail "clean scan memo" "no .client-scan written"
691
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
692
+ CLAUDE_MULTIACC_CLIENT_SCAN_TTL=3600 claude >/dev/null 2>&1
693
+ [ ! -f "$ACC/acct-01/.limited" ] \
694
+ && t_ok "the memo suppresses a re-read inside its window" \
695
+ || t_fail "clean scan memo" "rescanned inside the memo window"
696
+ rm -f "$ACC"/acct-0*/.client-scan
697
+ claude >/dev/null 2>&1
698
+ grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
699
+ && t_ok "once the memo lapses the rejection is seen" \
700
+ || t_fail "clean scan memo" "the rejection was never picked up"
701
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
702
+ rm -rf "$ACC"/acct-0*/projects
703
+
704
+ # ---- 12j. codex-review: corrupt pool numbers never reach bash arithmetic -----------
705
+ # An out-of-range value in any scraped number makes `[ x -lt y ]` print "integer
706
+ # expression expected" on STDERR — which a service-spawned `claude -p` must never see —
707
+ # and makes $((x + 1)) wrap negative.
708
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan "$ACC"/acct-0*/.sessions-index
709
+ rm -rf "$ACC"/acct-0*/projects
710
+ BIG="99999999999999999999999999999999"
711
+ printf '%s\n' "$BIG" > "$ACC/.last-pick"
712
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":1,"max_percent":%s,"buckets":[]}' "$BIG" "$BIG" "$BIG" > "$ACC/acct-02/limits.json"
713
+ err="$(claude 2>&1 >/dev/null)"
714
+ [ -z "$err" ] && t_ok "absurd pool numbers keep stderr byte-clean" \
715
+ || t_fail "corrupt number handling" "stderr: $(printf '%s' "$err" | head -c 160)"
716
+ out="$(claude 2>/dev/null)"
717
+ case "$out" in *CFG=acct-0*) t_ok "selection still works with corrupt numbers ($out)" ;;
718
+ *) t_fail "corrupt number handling" "selection produced: $out" ;; esac
719
+ # caller-supplied numbers are just as capable of reaching `[ -lt ]` as pool state
720
+ : > "$ACC/acct-01/.client-scan"
721
+ err="$(CLAUDE_MULTIACC_CLIENT_SCAN_TTL=bogus CLAUDE_MULTIACC_THRESHOLD=nonsense claude 2>&1 >/dev/null)"
722
+ out="$(CLAUDE_MULTIACC_CLIENT_SCAN_TTL=bogus CLAUDE_MULTIACC_THRESHOLD=nonsense claude 2>/dev/null)"
723
+ { [ -z "$err" ] && case "$out" in *CFG=acct-0*) true ;; *) false ;; esac; } \
724
+ && t_ok "garbage in the client-scan TTL / threshold env vars keeps stderr clean" \
725
+ || t_fail "env number validation" "stderr: $(printf '%s' "$err" | head -c 160) out: $out"
726
+ rm -f "$ACC"/acct-0*/.client-scan
727
+
728
+ printf '%s\nbucket=x percent=? reason=limits\n' "$BIG" > "$ACC/acct-01/.limited"
729
+ err="$(claude 2>&1 >/dev/null)"
730
+ { [ -z "$err" ] && [ -f "$ACC/acct-01/.limited" ]; } \
731
+ && t_ok "an absurd .limited reset reads as LIMITED, silently" \
732
+ || t_fail "corrupt marker handling" "stderr: $(printf '%s' "$err" | head -c 160)"
733
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/limits.json "$ACC/.last-pick"
734
+
735
+ # ---- 12m. a session id belongs to ONE account, and only from when it took it over ---
736
+ # `claude --continue` resumes the SAME session id under whichever account the pool hands
737
+ # out next (the client only mints a new id with --fork-session) and the transcript is
738
+ # shared — so an id claimed by acct-02 must stop being acct-01's evidence, and a rejection
739
+ # recorded before the handover must not be charged to the new owner.
740
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
741
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
742
+ SIDC="0badc0de-1111-4111-8111-abcdefabcdef"
743
+ NOWS="$(date -u +%s)"
744
+ 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)"
745
+ # acct-01 ran the session two hours ago and hit a limit then; acct-02 has just resumed it.
746
+ mkclientlimit "$ACC/acct-01" "$SIDC" "$((NOWS + 1800))" "$OLDISO"
747
+ mkdir -p "$ACC/acct-02/sessions" "$ACC/acct-02/projects"
748
+ ln -s "$ACC/acct-01/projects/-proj" "$ACC/acct-02/projects/-proj"
749
+ printf '{"pid":4242,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
750
+ "$SIDC" "$NOWS" > "$ACC/acct-02/sessions/4242.json"
751
+ claude >/dev/null 2>&1
752
+ grep -q -- "^$SIDC " "$ACC/acct-02/.sessions-index" 2>/dev/null \
753
+ && t_ok "a resumed session id is claimed by the account now running it" \
754
+ || t_fail "session id handover" "acct-02 never claimed the resumed id"
755
+ grep -q -- "^$SIDC " "$ACC/acct-01/.sessions-index" 2>/dev/null \
756
+ && t_fail "session id handover" "acct-01 still claims an id acct-02 took over" \
757
+ || t_ok "the previous owner releases a resumed session id"
758
+ [ ! -f "$ACC/acct-02/.limited" ] \
759
+ && t_ok "a rejection older than the handover is not charged to the new owner" \
760
+ || t_fail "claim-time scoping" "acct-02 was marked for a limit acct-01 hit"
761
+ rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
762
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
763
+
764
+ # two accounts claiming the SAME id (a crossed race, or corrupt state) must not both be
765
+ # marked off one shared transcript — at most the newer claimant may answer for it
766
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
767
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
768
+ SIDD="0dadc0de-2222-4222-8222-abcdefabcdef" # hex only, or sess_id_ok drops it and the
769
+ # scan never reaches the conflict check
770
+ NOWD="$(date -u +%s)"
771
+ mkclientlimit "$ACC/acct-01" "$SIDD" "$((NOWD + 1800))"
772
+ mkdir -p "$ACC/acct-02/projects"
773
+ ln -s "$ACC/acct-01/projects/-proj" "$ACC/acct-02/projects/-proj"
774
+ CLAIM1="$(cat "$ACC/acct-01/.sessions-index")"
775
+ printf '%s\n' "$CLAIM1" > "$ACC/acct-02/.sessions-index" # identical id AND claim
776
+ claude >/dev/null 2>&1
777
+ n_marked=0
778
+ for d in "$ACC/acct-01" "$ACC/acct-02"; do [ -f "$d/.limited" ] && n_marked=$((n_marked+1)); done
779
+ [ "$n_marked" -eq 0 ] \
780
+ && t_ok "an id claimed by two accounts marks neither (ambiguity => no attribution)" \
781
+ || t_fail "duplicate session claim" "$n_marked accounts were marked off one transcript"
782
+ # ...and it is the NEWEST rival claim that decides, not whichever one grep prints first
783
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
784
+ { printf '%s 2020-01-01T00:00:00Z\n' "$SIDD"; printf '%s 2090-01-01T00:00:00Z\n' "$SIDD"; } \
785
+ > "$ACC/acct-02/.sessions-index"
786
+ claude >/dev/null 2>&1
787
+ [ ! -f "$ACC/acct-01/.limited" ] \
788
+ && t_ok "a stale rival claim does not hide a newer one" \
789
+ || t_fail "duplicate session claim" "only the first rival claim was inspected"
790
+ rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
791
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
792
+
793
+ # a SYMLINKED registry entry belongs to whatever it points at, not to this account
794
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
795
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
796
+ FOREIGN="$WORK/foreign-session.json"
797
+ printf '{"pid":9,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' \
798
+ "$SID1" "$(( $(date -u +%s) - 600 ))" > "$FOREIGN"
799
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
800
+ rm -f "$ACC/acct-01/.sessions-index"
801
+ mkdir -p "$ACC/acct-01/sessions"
802
+ ln -s "$FOREIGN" "$ACC/acct-01/sessions/9.json"
803
+ claude >/dev/null 2>&1
804
+ [ ! -f "$ACC/acct-01/.limited" ] \
805
+ && t_ok "a symlinked session registry entry is not this account's evidence" \
806
+ || t_fail "symlinked registry entry" "a foreign registry entry marked the account LIMITED"
807
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
808
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
809
+
810
+ # ---- 12n. an option-shaped session id must not hang the shim before exec ------------
811
+ # "-e" is hex+dash, so a charset check alone lets it through; grep then treats it as a
812
+ # flag, loses its file operand and blocks on the shim's OWN stdin — a hang before exec.
813
+ rm -f "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
814
+ mkdir -p "$ACC/acct-01/sessions"
815
+ printf '{"pid":7,"sessionId":"-e","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' "$(date -u +%s)" \
816
+ > "$ACC/acct-01/sessions/7.json"
817
+ printf 'deadbeef 2026-01-01T00:00:00Z\n' > "$ACC/acct-01/.sessions-index"
818
+ FIFO="$WORK/hangfifo"
819
+ rm -f "$FIFO"; mkfifo "$FIFO"
820
+ exec 9<>"$FIFO" # holds the fifo open: stdin that never EOFs
821
+ ( claude < "$FIFO" > "$WORK/hang.out" 2>&1 ) &
822
+ hangpid=$!
823
+ waited=0
824
+ while [ "$waited" -lt 10 ] && kill -0 "$hangpid" 2>/dev/null; do sleep 1; waited=$((waited+1)); done
825
+ if kill -0 "$hangpid" 2>/dev/null; then
826
+ kill -9 "$hangpid" 2>/dev/null
827
+ t_fail "option-shaped session id" "the shim hung for ${waited}s before exec"
828
+ else
829
+ wait "$hangpid" 2>/dev/null || true
830
+ case "$(cat "$WORK/hang.out" 2>/dev/null)" in
831
+ *CFG=acct-0*) t_ok "an option-shaped session id neither hangs nor breaks selection" ;;
832
+ *) t_fail "option-shaped session id" "selection produced: $(head -c 120 "$WORK/hang.out")" ;;
833
+ esac
834
+ fi
835
+ exec 9>&-
836
+ rm -f "$FIFO" "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
837
+ rm -rf "$ACC"/acct-0*/sessions
838
+
839
+ # a SHARED session registry proves nothing about which account ran what
840
+ rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
841
+ rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
842
+ SHAREDS="$WORK/cl-shared-sessions"
843
+ mkdir -p "$SHAREDS"
844
+ printf '{"pid":1,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":%s000}\n' "$SID1" "$(date -u +%s)" > "$SHAREDS/1.json"
845
+ for i in 01 02; do ln -s "$SHAREDS" "$ACC/acct-$i/sessions"; done
846
+ mkclientlimit "$ACC/acct-01" "$SID1" "$(( $(date +%s) + 1800 ))"
847
+ rm -f "$ACC/acct-01/.sessions-index" # only the shared registry could supply the id
848
+ claude >/dev/null 2>&1
849
+ { [ ! -f "$ACC/acct-01/.limited" ] && [ ! -f "$ACC/acct-02/.limited" ]; } \
850
+ && t_ok "a shared session registry never marks an account (fail open)" \
851
+ || t_fail "shared session registry" "a shared registry marked an account LIMITED"
852
+ rm -f "$ACC"/acct-0*/sessions "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
853
+ rm -rf "$ACC"/acct-0*/projects
854
+
855
+ # ---- 12k. codex-review: rotation must not serialise a parallel burst ---------------
856
+ # The first cut of this used a monotonic counter and picked the account with the OLDEST
857
+ # stamp. Every member of a concurrent burst reads the same stamps, computes the same
858
+ # "oldest", and piles onto one account. Rotation only ever drops the ONE account just
859
+ # handed out; everything else is still sampled at random.
860
+ rm -f "$ACC/.last-pick" "$ACC"/acct-0*/limits.json "$ACC"/acct-0*/.limited
861
+ # THREE accounts on purpose. Rotation drops the one just handed out, so in a two-account
862
+ # pool a burst legitimately lands entirely on the single alternative and the test would
863
+ # be measuring nothing (it flaked exactly that way on Linux). With three, the burst must
864
+ # spread over the two that remain — which is precisely what the counter version could not.
865
+ mkdir -p "$ACC/acct-09"
866
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-cl09","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-09/.credentials.json"
867
+ : > "$WORK/burst.out"
868
+ for _ in $(seq 1 16); do ( claude >> "$WORK/burst.out" 2>&1 ) & done
869
+ wait
870
+ b_distinct="$(grep -o 'CFG=acct-[0-9]*' "$WORK/burst.out" | sort -u | tr '\n' ' ')"
871
+ b_n="$(printf '%s' "$b_distinct" | wc -w | tr -d ' ')"
872
+ [ "$b_n" -ge 2 ] \
873
+ && t_ok "a parallel burst still spreads across the pool ($b_distinct)" \
874
+ || t_fail "burst spreading" "all 16 concurrent runs took $b_distinct — rotation serialised the burst"
875
+ rm -rf "$ACC/acct-09"
876
+
877
+ # ---- 12l. codex-review: an unwritable pool root never prints to stderr --------------
878
+ # `cmd > file 2>/dev/null` does NOT silence a failed redirect: bash applies the
879
+ # redirections in order, so the open fails while stderr is still the caller's.
880
+ RO="$WORK/readonly-pool"
881
+ mkdir -p "$RO/acct-01" "$RO/acct-02"
882
+ cp "$ACC/accounts.json" "$RO/accounts.json"
883
+ for i in 01 02; do
884
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-ro","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$RO/acct-$i/.credentials.json"
885
+ done
886
+ : > "$RO/.limits-kick"
887
+ chmod 555 "$RO" "$RO/acct-01" "$RO/acct-02"
888
+ err="$(CLAUDE_ACCOUNTS_ROOT="$RO" claude 2>&1 >/dev/null)"
889
+ out="$(CLAUDE_ACCOUNTS_ROOT="$RO" claude 2>/dev/null)"
890
+ chmod 755 "$RO" "$RO/acct-01" "$RO/acct-02"
891
+ [ -z "$err" ] && t_ok "a read-only pool root keeps stderr byte-clean" \
892
+ || t_fail "read-only pool root" "stderr: $(printf '%s' "$err" | head -c 200)"
893
+ case "$out" in *CFG=acct-0*) t_ok "a read-only pool root still selects ($out)" ;;
894
+ *) t_fail "read-only pool root" "selection produced: $out" ;; esac
895
+
896
+ ACC="$ACC_SAVED"
897
+ unset CLAUDE_ACCOUNTS_ROOT
898
+
511
899
  # ---- 12e. TTY stdin: retry disabled so the terminal is never swapped for /dev/null --
512
900
  if command -v script >/dev/null 2>&1; then
513
901
  # `claude -p` on a TTY with no prompt arg reads the terminal. Under a PTY the shim
@@ -1347,7 +1735,13 @@ if command -v node >/dev/null 2>&1; then
1347
1735
  # self-update on a non-npm, non-git tree is a logged no-op (never errors)
1348
1736
  out="$(claude-accounts self-update 2>&1)"
1349
1737
  rc=$?
1350
- [ "$rc" = "0" ] && t_ok "self-update no-op exits 0 on a plain checkout" || t_fail "self-update rc" "rc=$rc"
1738
+ # A git checkout that cannot fast-forward (feature branch, detached CI checkout) is
1739
+ # SUPPOSED to fail loudly; what must never happen is a crash with no explanation.
1740
+ case "$rc:$out" in
1741
+ 0:*) t_ok "self-update no-op exits 0 on a plain checkout" ;;
1742
+ *:*"git pull FAILED"*) t_ok "self-update reports a git checkout it cannot fast-forward" ;;
1743
+ *) t_fail "self-update rc" "rc=$rc: $out" ;;
1744
+ esac
1351
1745
  case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
1352
1746
  *) t_fail "self-update message" "unexpected: $out" ;; esac
1353
1747
  # postinstall must SKIP for a non-global install and never fail
@@ -1382,6 +1776,7 @@ mkdir -p "$CX/tmp"
1382
1776
  unset CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
1383
1777
  export CODEX_MULTIACC_NO_SYNC=1
1384
1778
  export CODEX_MULTIACC_MIN_FETCH=0
1779
+ export CODEX_MULTIACC_CLIENT_SCAN_TTL=0
1385
1780
  export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
1386
1781
  # Default usage URL is an offline missing fixture: the SHIM's opportunistic
1387
1782
  # background `limits --quiet` kick must never reach a real endpoint from tests
@@ -1556,6 +1951,211 @@ codex >/dev/null 2>&1
1556
1951
  [ ! -f "$CX/acct-01/.limited" ] && t_ok "codex: expired .limited marker self-clears" \
1557
1952
  || t_fail "codex marker expiry" ".limited survived its reset time"
1558
1953
 
1954
+ # ---- C5b. client-reported rate limits (rollouts) + rotation -------------------------
1955
+ # Parity with the claude shim: never depend on the usage endpoint to notice an account
1956
+ # ran dry. The codex CLI writes the windows the server reported into every run's
1957
+ # rollout, and rollouts live inside the account dir — so no session index is needed.
1958
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/limits.json "$CX/.pick-seq" "$CX"/acct-0*/.last-pick
1959
+ rm -rf "$CX"/acct-0*/sessions
1960
+ mkrollout() { # mkrollout <acct dir> <used_percent> <resets_at>
1961
+ local day="$1/sessions/2026/08/20"
1962
+ mkdir -p "$day"
1963
+ {
1964
+ printf '{"timestamp":"2026-08-19T22:43:21.299Z","type":"session_meta","payload":{"session_id":"01a01c31","cwd":"/proj"}}\n'
1965
+ 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"
1966
+ } > "$day/rollout-2026-08-20T01-43-21-01a01c31-7fc3-7291-a0fc-7b4e2b035f1a.jsonl"
1967
+ }
1968
+ mkrollout "$CX/acct-01" "97.4" "$((now + 3600))"
1969
+ all2=1
1970
+ for _ in $(seq 1 12); do
1971
+ case "$(codex 2>&1)" in *CFG=acct-02*) ;; *) all2=0 ;; esac
1972
+ done
1973
+ [ "$all2" = "1" ] && t_ok "codex: a spent window in the account's own rollout excludes it" \
1974
+ || t_fail "codex client rate limit" "the spent account was picked again"
1975
+ first="$(head -1 "$CX/acct-01/.limited" 2>/dev/null)"
1976
+ [ "$first" = "$((now + 3600))" ] && t_ok "codex: marker carries the reported reset time" \
1977
+ || t_fail "codex client marker reset" "want $((now + 3600)), got '${first:-<none>}'"
1978
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
1979
+ && t_ok "codex: marker is tagged client-rate-limit" \
1980
+ || t_fail "codex client marker reason" "$(cat "$CX/acct-01/.limited" 2>/dev/null)"
1981
+ rm -f "$CX/acct-01/.limited"
1982
+
1983
+ # under the threshold is just usage, not an exclusion
1984
+ mkrollout "$CX/acct-01" "40.0" "$((now + 3600))"
1985
+ hits1=0
1986
+ for _ in $(seq 1 12); do
1987
+ case "$(codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
1988
+ done
1989
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
1990
+ && t_ok "codex: a sub-threshold window does not exclude" \
1991
+ || t_fail "codex sub-threshold rollout" "acct-01 hits=$hits1"
1992
+
1993
+ # a window that has already reset is history
1994
+ mkrollout "$CX/acct-01" "99.0" "$((now - 60))"
1995
+ hits1=0
1996
+ for _ in $(seq 1 12); do
1997
+ case "$(codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
1998
+ done
1999
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
2000
+ && t_ok "codex: an elapsed window does not exclude" \
2001
+ || t_fail "codex elapsed rollout window" "acct-01 hits=$hits1"
2002
+
2003
+ # opt-out
2004
+ mkrollout "$CX/acct-01" "99.0" "$((now + 3600))"
2005
+ hits1=0
2006
+ for _ in $(seq 1 12); do
2007
+ case "$(CODEX_MULTIACC_CLIENT_LIMITS=0 codex 2>&1)" in *CFG=acct-01*) hits1=$((hits1+1)) ;; esac
2008
+ done
2009
+ { [ "$hits1" -gt 0 ] && [ ! -f "$CX/acct-01/.limited" ]; } \
2010
+ && t_ok "codex: CODEX_MULTIACC_CLIENT_LIMITS=0 turns the scan off" \
2011
+ || t_fail "codex client-limit opt-out" "still excluded with the scan disabled"
2012
+ rm -rf "$CX"/acct-0*/sessions
2013
+ rm -f "$CX"/acct-0*/.limited
2014
+
2015
+ # equal-headroom picks rotate instead of re-rolling a coin
2016
+ rm -f "$CX/.pick-seq" "$CX"/acct-0*/.last-pick "$CX"/acct-0*/limits.json
2017
+ seq_out=""
2018
+ for _ in $(seq 1 8); do
2019
+ case "$(codex 2>&1)" in
2020
+ *CFG=acct-01*) seq_out="${seq_out}1" ;;
2021
+ *CFG=acct-02*) seq_out="${seq_out}2" ;;
2022
+ *) seq_out="${seq_out}?" ;;
2023
+ esac
2024
+ done
2025
+ case "$seq_out" in
2026
+ 12121212|21212121) t_ok "codex: equal-score picks round-robin across the pool ($seq_out)" ;;
2027
+ *) t_fail "codex round-robin tie-break" "sequence $seq_out (want strict alternation)" ;;
2028
+ esac
2029
+ rm -f "$CX/.pick-seq" "$CX"/acct-0*/.last-pick
2030
+
2031
+
2032
+ # codex-review finding: the bounded rollout scan must start at the NEWEST file, or a
2033
+ # busy account whose only over-threshold report is its latest run stays eligible.
2034
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2035
+ rm -rf "$CX"/acct-0*/sessions
2036
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2037
+ i=1
2038
+ while [ "$i" -le 12 ]; do
2039
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":10.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2040
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T0$(printf '%01d' $((i % 10)))-0$i-old$i.jsonl"
2041
+ i=$((i + 1))
2042
+ done
2043
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2044
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T99-99-newest.jsonl"
2045
+ codex >/dev/null 2>&1
2046
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
2047
+ && t_ok "codex: the newest rollout is read even past the file cap" \
2048
+ || t_fail "codex rollout scan order" "13 rollouts, only the newest over threshold — missed it"
2049
+ rm -rf "$CX"/acct-0*/sessions
2050
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2051
+
2052
+ # A SHARED sessions tree (the layout lib/common.sh actually installs: acct/sessions is a
2053
+ # symlink to ~/.codex/sessions so `codex resume` finds every session) proves nothing about
2054
+ # who spent the quota. One spent window there must not mark the whole pool.
2055
+ SHARED="$WORK/cx-shared-sessions"
2056
+ mkdir -p "$SHARED/2026/08/20"
2057
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2058
+ "$((now + 3600))" > "$SHARED/2026/08/20/rollout-2026-08-20T01-00-00-shared.jsonl"
2059
+ for i in 01 02; do ln -s "$SHARED" "$CX/acct-$i/sessions"; done
2060
+ codex >/dev/null 2>&1
2061
+ { [ ! -f "$CX/acct-01/.limited" ] && [ ! -f "$CX/acct-02/.limited" ]; } \
2062
+ && t_ok "codex: a shared sessions symlink never marks an account (fail open)" \
2063
+ || t_fail "codex shared sessions" "a shared rollout tree marked the pool LIMITED"
2064
+ out="$(codex 2>&1)"
2065
+ case "$out" in *CFG=acct-0*) t_ok "codex: the pool still selects with a shared sessions tree" ;;
2066
+ *) t_fail "codex shared sessions" "selection produced: $out" ;; esac
2067
+ rm -f "$CX"/acct-0*/sessions "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2068
+
2069
+ # second codex-review pass: a NESTED symlink inside a real sessions/ dir must not smuggle
2070
+ # another tree's rollouts in, and a huge stale rollout directory must not be walked.
2071
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2072
+ rm -rf "$CX"/acct-0*/sessions
2073
+ OUTSIDE="$WORK/cx-outside"
2074
+ mkdir -p "$OUTSIDE/08/20" "$CX/acct-01/sessions"
2075
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2076
+ "$((now + 3600))" > "$OUTSIDE/08/20/rollout-2026-08-20T01-00-00-outside.jsonl"
2077
+ ln -s "$OUTSIDE" "$CX/acct-01/sessions/2026"
2078
+ codex >/dev/null 2>&1
2079
+ [ ! -f "$CX/acct-01/.limited" ] \
2080
+ && t_ok "codex: a nested symlink out of the account tree never marks it" \
2081
+ || t_fail "codex nested sessions symlink" "rollouts outside the account tree marked it LIMITED"
2082
+ rm -f "$CX/acct-01/sessions/2026"
2083
+
2084
+ # a day dir stuffed with stale rollouts must cost a bounded amount of work, and the
2085
+ # newest (over-threshold) file must still be the one that decides
2086
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2087
+ i=1
2088
+ while [ "$i" -le 60 ]; do
2089
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":5.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2090
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T00-00-$(printf '%02d' "$i")-bulk.jsonl"
2091
+ i=$((i + 1))
2092
+ done
2093
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2094
+ "$((now + 3600))" > "$CX/acct-01/sessions/2026/08/20/rollout-2026-08-20T23-59-59-newest.jsonl"
2095
+ codex >/dev/null 2>&1
2096
+ grep -q 'reason=client-rate-limit' "$CX/acct-01/.limited" 2>/dev/null \
2097
+ && t_ok "codex: 61 rollouts in one day dir — the newest still decides" \
2098
+ || t_fail "codex bulk rollout dir" "the newest rollout was not the one read"
2099
+ rm -rf "$CX"/acct-0*/sessions
2100
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2101
+
2102
+ # third codex-review pass: find(1) output is newline-delimited, so a pool file whose NAME
2103
+ # contains a newline arrives as two lines and its tail resolves relative to $PWD.
2104
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2105
+ rm -rf "$CX"/acct-0*/sessions
2106
+ mkdir -p "$CX/acct-01/sessions/2026/08/20"
2107
+ ESCDIR="$WORK/cx-escape-dir"
2108
+ mkdir -p "$ESCDIR"
2109
+ printf '{"type":"event_msg","payload":{"rate_limits":{"primary":{"used_percent":100.0,"window_minutes":10080,"resets_at":%s},"secondary":null}}}\n' \
2110
+ "$((now + 3600))" > "$ESCDIR/escape.jsonl"
2111
+ # ONE file whose name embeds a newline; find prints it as two lines, and the second
2112
+ # ("escape.jsonl") would resolve against the caller's cwd — outside the pool entirely.
2113
+ HOSTILE=$'rollout-a\nescape.jsonl'
2114
+ : > "$CX/acct-01/sessions/2026/08/20/$HOSTILE"
2115
+ [ -e "$CX/acct-01/sessions/2026/08/20/$HOSTILE" ] \
2116
+ || t_fail "codex newline filename" "fixture not created — the guard would go unexercised"
2117
+ ( cd "$ESCDIR" && codex >/dev/null 2>&1 )
2118
+ [ ! -f "$CX/acct-01/.limited" ] \
2119
+ && t_ok "codex: a newline in a rollout name cannot pull in a file outside the pool" \
2120
+ || t_fail "codex newline filename" "a file outside the pool marked the account LIMITED"
2121
+ rm -rf "$CX"/acct-0*/sessions
2122
+ rm -f "$CX"/acct-0*/.limited "$CX"/acct-0*/.client-scan
2123
+
2124
+ # corrupt pool state must never reach bash arithmetic here either
2125
+ CXBIG="99999999999999999999999999999999"
2126
+ printf '%s\n' "$CXBIG" > "$CX/.last-pick"
2127
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":1,"max_percent":%s,"buckets":[]}' "$CXBIG" "$CXBIG" "$CXBIG" > "$CX/acct-02/limits.json"
2128
+ err="$(codex 2>&1 >/dev/null)"
2129
+ [ -z "$err" ] && t_ok "codex: absurd pool numbers keep stderr byte-clean" \
2130
+ || t_fail "codex corrupt number handling" "stderr: $(printf '%s' "$err" | head -c 160)"
2131
+ rm -f "$CX/.last-pick" "$CX"/acct-0*/limits.json
2132
+ : > "$CX/acct-01/.client-scan"
2133
+ err="$(CODEX_MULTIACC_CLIENT_SCAN_TTL=bogus CODEX_MULTIACC_THRESHOLD=nonsense codex 2>&1 >/dev/null)"
2134
+ out="$(CODEX_MULTIACC_CLIENT_SCAN_TTL=bogus CODEX_MULTIACC_THRESHOLD=nonsense codex 2>/dev/null)"
2135
+ { [ -z "$err" ] && case "$out" in *CFG=acct-0*) true ;; *) false ;; esac; } \
2136
+ && t_ok "codex: garbage in the TTL / threshold env vars keeps stderr clean" \
2137
+ || t_fail "codex env number validation" "stderr: $(printf '%s' "$err" | head -c 160) out: $out"
2138
+ rm -f "$CX"/acct-0*/.client-scan
2139
+
2140
+ # a parallel burst still spreads (rotation drops only the account just handed out).
2141
+ # Three accounts, for the same reason as the claude side: with two, landing entirely on
2142
+ # the single alternative is correct behaviour and the assertion would measure nothing.
2143
+ mkdir -p "$CX/acct-09"
2144
+ "$MKAUTH" > "$CX/acct-09/auth.json" 2>/dev/null || \
2145
+ printf '{"tokens":{"access_token":"%s","account_id":"a9"},"last_refresh":"2026-08-01T00:00:00Z"}' \
2146
+ "$(sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$CX/acct-01/auth.json" | head -1)" \
2147
+ > "$CX/acct-09/auth.json"
2148
+ : > "$WORK/cx-burst.out"
2149
+ for _ in $(seq 1 16); do ( codex >> "$WORK/cx-burst.out" 2>&1 ) & done
2150
+ wait
2151
+ c_distinct="$(grep -o 'CFG=acct-[0-9]*' "$WORK/cx-burst.out" | sort -u | tr '\n' ' ')"
2152
+ c_n="$(printf '%s' "$c_distinct" | wc -w | tr -d ' ')"
2153
+ [ "$c_n" -ge 2 ] \
2154
+ && t_ok "codex: a parallel burst still spreads across the pool ($c_distinct)" \
2155
+ || t_fail "codex burst spreading" "all 16 concurrent runs took $c_distinct"
2156
+ rm -rf "$CX/acct-09"
2157
+ rm -f "$CX/.last-pick"
2158
+
1559
2159
  # ---- C6. all limited -> least-utilized fallback -------------------------------------
1560
2160
  printf '%s\nbucket=7d percent=95 reason=limits\n' "$((now+3600))" > "$CX/acct-01/.limited"
1561
2161
  printf '%s\nbucket=7d percent=99 reason=limits\n' "$((now+3600))" > "$CX/acct-02/.limited"
@@ -2306,6 +2906,458 @@ else
2306
2906
  t_ok "codex npm-layer tests skipped (node not installed)"
2307
2907
  fi
2308
2908
 
2909
+ # ---- 20. panel pools: --json, credential transfer, instance roots, local sync -------
2910
+ # Everything here runs against pools under $WORK/panel, addressed with the NEW
2911
+ # CLAUDE_ACCOUNTS_ROOT/CODEX_ACCOUNTS_ROOT env — while CLAUDE_ACCOUNTS_DIR (the legacy
2912
+ # spelling) still points at the suite's own pool, which is exactly the precedence a
2913
+ # second app-robot instance on a shared machine depends on.
2914
+ PP="$WORK/panel"
2915
+ JP="$PP/claude-a"
2916
+ JP2="$PP/claude-b"
2917
+ PTOKEN="sk-ant-oat01-PANELPOOLTOKENPANELPOOLTOKENPANELPOOLTOKENPANELPOOL"
2918
+ mkdir -p "$JP/acct-01" "$JP/acct-02" "$JP/acct-03" "$JP2"
2919
+ # home must be THIS machine's kind: a credential-less home=mac account audits as
2920
+ # 'remote' (its grant lives elsewhere) rather than 'missing' when the suite runs on
2921
+ # Linux — which is what the publish CI runs on. Same rule as the relogin-redirect
2922
+ # fixture (ddb3015).
2923
+ PMK=mac; [ "$(uname -s)" = "Darwin" ] || PMK=linux
2924
+ cat > "$JP/accounts.json" <<EOF
2925
+ {
2926
+ "version": 1,
2927
+ "server": "root@203.0.113.7",
2928
+ "server_root": "/root/.claude-accounts",
2929
+ "server_repo": "/root/claude-multiacc",
2930
+ "threshold": 90,
2931
+ "accounts": [
2932
+ {"id": "acct-01", "email": "portable@test", "home": "$PMK", "added_at": "2026-01-02T03:04:05Z"},
2933
+ {"id": "acct-02", "email": "local@test", "home": "$PMK", "added_at": "2026-02-02T03:04:05Z"},
2934
+ {"id": "acct-03", "email": "nocred@test", "home": "$PMK", "added_at": "2026-03-02T03:04:05Z"}
2935
+ ]
2936
+ }
2937
+ EOF
2938
+ printf '%s' "$PTOKEN" > "$JP/acct-01/server.token"
2939
+ chmod 600 "$JP/acct-01/server.token"
2940
+ printf '{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' \
2941
+ > "$JP/acct-02/.credentials.json"
2942
+ printf '{"version":1,"server":"none","server_root":"/root/.claude-accounts","server_repo":"/root/claude-multiacc","threshold":90,"accounts":[]}\n' \
2943
+ > "$JP2/accounts.json"
2944
+ : > "$JP/.limits-kick"
2945
+ : > "$JP2/.limits-kick"
2946
+
2947
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json > "$PP/list.json" 2>"$PP/list.err"
2948
+ python3 - "$PP/list.json" "$JP" <<'EOF'
2949
+ import json, sys
2950
+ d = json.load(open(sys.argv[1]))
2951
+ assert d["schema"] == "claude-multiacc/pool.v1", d["schema"]
2952
+ assert d["provider"] == "claude" and d["kind"] == "list", d
2953
+ assert d["pool"]["root"] == sys.argv[2], d["pool"]["root"] # _ROOT beats legacy _DIR
2954
+ by = {a["id"]: a for a in d["accounts"]}
2955
+ assert by["acct-01"]["status"] == "active", by["acct-01"]
2956
+ assert by["acct-01"]["credential_class"] == "portable" and by["acct-01"]["portable"], by["acct-01"]
2957
+ assert by["acct-02"]["credential_class"] == "machine-local", by["acct-02"]
2958
+ assert by["acct-02"]["portable"] is False, by["acct-02"]
2959
+ assert by["acct-03"]["status"] == "missing" and by["acct-03"]["credential_class"] == "none", by["acct-03"]
2960
+ assert by["acct-01"]["home_dir"].endswith("/acct-01"), by["acct-01"]
2961
+ assert by["acct-01"]["email"] == "portable@test", by["acct-01"]
2962
+ assert d["summary"] == {"total": 3, "active": 2, "limited": 0, "needs_login": 1,
2963
+ "portable": 1, "selectable": 2}, d["summary"]
2964
+ assert d["pool"]["sync"]["mode"] == "server", d["pool"]["sync"]
2965
+ EOF
2966
+ [ $? -eq 0 ] && t_ok "list --json: stable schema, status/class per account, instance root" \
2967
+ || t_fail "list --json" "see $PP/list.json"
2968
+ [ ! -s "$PP/list.err" ] && t_ok "list --json writes nothing to stderr" \
2969
+ || t_fail "list --json stderr" "$(head -c 120 "$PP/list.err")"
2970
+
2971
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts status --json > "$PP/status.json"
2972
+ python3 - "$PP/status.json" <<'EOF'
2973
+ import json, sys
2974
+ d = json.load(open(sys.argv[1]))
2975
+ assert d["kind"] == "status", d["kind"]
2976
+ a = {x["id"]: x for x in d["accounts"]}["acct-01"]
2977
+ assert "last_picked" in a and "expired_marker" in a, a
2978
+ assert a["credentials"]["token"] is True and a["credentials"]["oauth"] is False, a["credentials"]
2979
+ assert a["credentials"]["token_age_days"] == 0, a["credentials"]
2980
+ b = {x["id"]: x for x in d["accounts"]}["acct-02"]
2981
+ assert b["credentials"]["oauth_refresh_expires_at"], b["credentials"]
2982
+ EOF
2983
+ [ $? -eq 0 ] && t_ok "status --json: adds last_picked + credential detail" \
2984
+ || t_fail "status --json" "see $PP/status.json"
2985
+
2986
+ cat > "$WORK/usage-panel.json" <<'EOF'
2987
+ {"limits":[
2988
+ {"kind":"session","percent":12,"resets_at":"2099-01-01T00:00:00+00:00","scope":null},
2989
+ {"kind":"weekly_all","percent":93,"resets_at":"2099-01-03T00:00:00+00:00","scope":null}
2990
+ ]}
2991
+ EOF
2992
+ CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-panel.json" \
2993
+ claude-accounts limits --json > "$PP/limits.json" 2>/dev/null
2994
+ python3 - "$PP/limits.json" <<'EOF'
2995
+ import json, sys
2996
+ d = json.load(open(sys.argv[1]))
2997
+ assert d["kind"] == "limits", d["kind"]
2998
+ a = {x["id"]: x for x in d["accounts"]}["acct-01"]
2999
+ assert a["usage"]["max_percent"] == 93, a["usage"]
3000
+ assert a["usage"]["source"] == "token", a["usage"]
3001
+ assert a["status"] == "limited" and a["limited"] is True, a
3002
+ assert a["limit_reset_at"], a
3003
+ assert a["selectable"] is False, a
3004
+ # acct-02 has a live oauth credential, so it fetched the same fixture and parked too
3005
+ assert d["summary"]["limited"] == 2, d["summary"]
3006
+ EOF
3007
+ [ $? -eq 0 ] && t_ok "limits --json: refreshes, then reports usage + limited state" \
3008
+ || t_fail "limits --json" "see $PP/limits.json"
3009
+ rm -f "$JP/acct-01/.limited" "$JP/acct-02/.limited"
3010
+
3011
+ # ---- 20a. export-credential: portable only, exit codes a daemon can branch on -------
3012
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 > "$PP/blob.json" 2>"$PP/blob.err"
3013
+ rc=$?
3014
+ [ "$rc" = "0" ] && t_ok "export-credential exits 0 for a portable account" \
3015
+ || t_fail "export rc" "rc=$rc $(head -c 120 "$PP/blob.err")"
3016
+ python3 - "$PP/blob.json" "$PTOKEN" "$PMK" <<'EOF'
3017
+ import json, sys
3018
+ b = json.load(open(sys.argv[1]))
3019
+ assert b["format"] == "claude-multiacc/credential" and b["version"] == 1, b
3020
+ assert b["provider"] == "claude" and b["class"] == "portable", b
3021
+ assert b["account"] == {"id": "acct-01", "email": "portable@test", "home": sys.argv[3],
3022
+ "added_at": "2026-01-02T03:04:05Z"}, b["account"]
3023
+ assert b["credential"] == {"type": "setup-token", "value": sys.argv[2]}, "credential mismatch"
3024
+ assert b["exported_from"]["pool_root"].endswith("claude-a"), b["exported_from"]
3025
+ EOF
3026
+ [ $? -eq 0 ] && t_ok "export blob: self-contained credential + identity metadata" \
3027
+ || t_fail "export blob shape" "see $PP/blob.json"
3028
+
3029
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-02 2>&1)"
3030
+ rc=$?
3031
+ check "export refuses a machine-local credential" "MACHINE-LOCAL" "$out"
3032
+ check "export says how to make it portable" "claude-accounts mint acct-02" "$out"
3033
+ [ "$rc" = "3" ] && t_ok "machine-local export exits 3" || t_fail "machine-local exit code" "rc=$rc"
3034
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-03 2>&1)"
3035
+ rc=$?
3036
+ check "export reports an account with no credential" "no credential material" "$out"
3037
+ [ "$rc" = "4" ] && t_ok "credential-less export exits 4" || t_fail "no-credential exit code" "rc=$rc"
3038
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-77 2>&1)"
3039
+ [ $? != 0 ] && t_ok "export of an unregistered id fails" || t_fail "unknown id export" "exited 0"
3040
+
3041
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-02 --identity-only --out "$PP/ident.json" >/dev/null
3042
+ rc=$?
3043
+ python3 - "$PP/ident.json" <<'EOF'
3044
+ import json, sys
3045
+ b = json.load(open(sys.argv[1]))
3046
+ assert b["class"] == "identity", b
3047
+ assert "credential" not in b, "identity blob must carry no credential material"
3048
+ assert b["account"]["email"] == "local@test", b
3049
+ EOF
3050
+ [ $? -eq 0 ] && [ "$rc" = "0" ] \
3051
+ && t_ok "export --identity-only works for a machine-local account (registry only)" \
3052
+ || t_fail "identity-only export" "rc=$rc, see $PP/ident.json"
3053
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 --out "$PP/blob-out.json" >/dev/null
3054
+ case "$(ls -l "$PP/blob-out.json" | cut -c1-10)" in
3055
+ -rw-------) t_ok "export --out writes the blob 0600" ;;
3056
+ *) t_fail "export --out perms" "$(ls -l "$PP/blob-out.json" | cut -c1-10)" ;;
3057
+ esac
3058
+
3059
+ # ---- 20b. import-credential: faithful, idempotent, and picky ------------------------
3060
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential < "$PP/blob.json" 2>&1)"
3061
+ rc=$?
3062
+ check "import-credential installs a portable credential" "Imported acct-01 (portable@test" "$out"
3063
+ [ "$rc" = "0" ] && t_ok "import-credential exits 0" || t_fail "import rc" "rc=$rc"
3064
+ python3 - "$JP2/accounts.json" "$JP2/acct-01/server.token" "$PTOKEN" "$PMK" <<'EOF'
3065
+ import json, sys
3066
+ accs = json.load(open(sys.argv[1]))["accounts"]
3067
+ assert len(accs) == 1, accs
3068
+ a = accs[0]
3069
+ # byte-faithful metadata: the account reads identically on the second machine
3070
+ assert a == {"id": "acct-01", "email": "portable@test", "home": sys.argv[4],
3071
+ "added_at": "2026-01-02T03:04:05Z"}, a
3072
+ assert open(sys.argv[2]).read() == sys.argv[3], "token content changed in transfer"
3073
+ EOF
3074
+ [ $? -eq 0 ] && t_ok "imported account + credential round-trip unchanged" \
3075
+ || t_fail "import fidelity" "see $JP2"
3076
+ case "$(ls -l "$JP2/acct-01/server.token" | cut -c1-10)" in
3077
+ -rw-------) t_ok "imported credential is 0600" ;;
3078
+ *) t_fail "imported credential perms" "$(ls -l "$JP2/acct-01/server.token" | cut -c1-10)" ;;
3079
+ esac
3080
+ # The write goes through a temp file in the account dir; none of it may survive.
3081
+ [ -z "$(ls -a "$JP2/acct-01" | grep '^\.cred\.')" ] \
3082
+ && t_ok "no temp credential file is left behind by an import" \
3083
+ || t_fail "import leftovers" "$(ls -a "$JP2/acct-01" | grep '^\.cred\.')"
3084
+ [ -z "$(ls -a "$JP2/tmp" 2>/dev/null | grep 'import-cred')" ] \
3085
+ && t_ok "no staged blob is left behind by an import" \
3086
+ || t_fail "staged blob leftovers" "$(ls -a "$JP2/tmp" | grep 'import-cred')"
3087
+ CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/blob.json" >/dev/null 2>&1
3088
+ n="$(python3 -c "import json;print(len(json.load(open('$JP2/accounts.json'))['accounts']))")"
3089
+ [ "$n" = "1" ] && t_ok "re-importing the same account refreshes it, never duplicates" \
3090
+ || t_fail "import idempotence" "$n accounts after a second import"
3091
+
3092
+ python3 - "$PP/blob.json" "$PP/blob-apikey.json" <<'EOF'
3093
+ import json, sys
3094
+ b = json.load(open(sys.argv[1]))
3095
+ b["credential"]["value"] = "sk-ant-api03-" + "A" * 60 # an API key, not a setup-token
3096
+ json.dump(b, open(sys.argv[2], "w"))
3097
+ EOF
3098
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/blob-apikey.json" 2>&1)"
3099
+ rc=$?
3100
+ check "import refuses an API key" "not a subscription setup-token" "$out"
3101
+ [ "$rc" != "0" ] && t_ok "API-key import exits nonzero" || t_fail "api key import" "exited 0"
3102
+
3103
+ out="$(CODEX_ACCOUNTS_ROOT="$PP/codex-b" codex-accounts import-credential --in "$PP/blob.json" 2>&1)"
3104
+ [ $? != 0 ] && t_ok "a claude blob cannot be imported into the codex pool" \
3105
+ || t_fail "cross-provider import" "exited 0"
3106
+
3107
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential --in "$PP/ident.json" 2>&1)"
3108
+ check "identity-only import registers the account" "identity only, no credential" "$out"
3109
+ check "identity-only import says what it still needs" "claude-accounts login" "$out"
3110
+ [ ! -f "$JP2/acct-02/server.token" ] && t_ok "identity-only import installs no credential" \
3111
+ || t_fail "identity-only import" "wrote a credential file"
3112
+
3113
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential acct-02 --in "$PP/blob.json" 2>&1)"
3114
+ rc=$?
3115
+ check "import refuses to land the same email in a second slot" "already registered as acct-01" "$out"
3116
+ [ "$rc" != "0" ] && t_ok "conflicting-id import exits nonzero" || t_fail "conflicting id" "exited 0"
3117
+
3118
+ # ---- 20c. instance isolation: the shim resolves the same root as the CLI ------------
3119
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude 2>&1)"
3120
+ check "shim honors CLAUDE_ACCOUNTS_ROOT (instance pool)" "CFG=acct-01" "$out"
3121
+ [ -f "$JP2/selection.log" ] && t_ok "instance pool records its own selection log" \
3122
+ || t_fail "instance selection log" "missing at $JP2/selection.log"
3123
+ grep -q "acct-01" "$ACC/selection.log" && ! grep -q "portable@test" "$ACC/accounts.json" \
3124
+ && t_ok "the default pool was untouched by the instance run" \
3125
+ || t_fail "pool isolation" "the instance run leaked into $ACC"
3126
+
3127
+ # ---- 20d. sync target: overridable, and a local-only mode that pushes nowhere -------
3128
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync --no-server 2>&1)"
3129
+ rc=$?
3130
+ check "sync --no-server stays local" "local-only" "$out"
3131
+ [ "$rc" = "0" ] && t_ok "sync --no-server exits 0" || t_fail "sync --no-server rc" "rc=$rc"
3132
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET=none CLAUDE_MULTIACC_NO_SYNC=0 \
3133
+ claude-accounts sync 2>&1)"
3134
+ check "SYNC_TARGET=none makes sync local-only" "nothing pushed" "$out"
3135
+ CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET="ops@panel.example" \
3136
+ claude-accounts list --json > "$PP/list-target.json"
3137
+ python3 - "$PP/list-target.json" <<'EOF'
3138
+ import json, sys
3139
+ s = json.load(open(sys.argv[1]))["pool"]["sync"]
3140
+ assert s["mode"] == "server" and s["target"] == "ops@panel.example", s
3141
+ EOF
3142
+ [ $? -eq 0 ] && t_ok "SYNC_TARGET env overrides the manifest server" \
3143
+ || t_fail "sync target override" "see $PP/list-target.json"
3144
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" CLAUDE_MULTIACC_NO_SYNC=0 \
3145
+ claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3146
+ case "$out" in
3147
+ *"sync failed"*) t_fail "local-only auto_sync" "a local-only pool warned about a server push: $out" ;;
3148
+ *) t_ok "local-only pool: mutations never warn about a missing server" ;;
3149
+ esac
3150
+ # A replica pool must NEVER push — and pointing it at a local-only target must not
3151
+ # become a way around that. Local mode narrows the rule (nothing pushes at all); the
3152
+ # marker is still honored and still reported.
3153
+ printf 'replica\n' > "$JP/sync-role"
3154
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET=none CLAUDE_MULTIACC_NO_SYNC=0 \
3155
+ claude-accounts sync 2>&1)"
3156
+ rc=$?
3157
+ check "a replica in local-only mode still says it is a replica" "sync replica" "$out"
3158
+ check "a replica in local-only mode pushes nothing" "nothing pushed either way" "$out"
3159
+ [ "$rc" = "0" ] && t_ok "replica + local-only exits 0" || t_fail "replica local-only rc" "rc=$rc"
3160
+ # `sync` refuses outright off the Mac ("sync runs on the Mac, not the server"), so the
3161
+ # replica and target-injection rules can only be observed there. Stated as a skip rather
3162
+ # than quietly asserting the wrong message on Linux — which is what broke the publish CI.
3163
+ if [ "$(uname -s)" = "Darwin" ]; then
3164
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
3165
+ check "a replica with a server target still refuses to push" "sync replica" "$out"
3166
+ else
3167
+ t_ok "server-target sync refusal skipped (sync is Mac-only; this host is $(uname -s))"
3168
+ fi
3169
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_NO_SYNC=0 \
3170
+ claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3171
+ case "$out" in
3172
+ *"sync failed"*|*"push"*) t_fail "replica auto_sync" "a replica mutation tried to push: $out" ;;
3173
+ *) t_ok "a mutation on a replica never pushes (auto_sync stays silent)" ;;
3174
+ esac
3175
+ rm -f "$JP/sync-role"
3176
+
3177
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" CLAUDE_MULTIACC_SYNC_TARGET="ops@x; touch $WORK/PWNED" \
3178
+ CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
3179
+ rc=$?
3180
+ if [ "$(uname -s)" = "Darwin" ]; then
3181
+ check "an injected SYNC_TARGET is refused" "not a plain user@host" "$out"
3182
+ else
3183
+ t_ok "injected SYNC_TARGET refusal message skipped (sync is Mac-only here)"
3184
+ fi
3185
+ # The part that matters everywhere: refused, and never executed.
3186
+ [ "$rc" != "0" ] && [ ! -f "$WORK/PWNED" ] && t_ok "injected sync target never executed" \
3187
+ || t_fail "sync target injection" "rc=$rc"
3188
+
3189
+ # ---- 20e. codex parity: no portable class, identity transfer only -------------------
3190
+ CX2="$PP/codex-a"
3191
+ mkdir -p "$CX2/acct-01"
3192
+ cat > "$CX2/accounts.json" <<'EOF'
3193
+ {"version":1,"server":"none","server_root":"/root/.codex-accounts","server_repo":"/root/claude-multiacc",
3194
+ "threshold":90,"accounts":[{"id":"acct-01","email":"cx@panel","home":"mac","added_at":"2026-04-05T06:07:08Z"}]}
3195
+ EOF
3196
+ python3 - "$CX2/acct-01/auth.json" <<'EOF'
3197
+ import base64, json, sys, time
3198
+ b = lambda o: base64.urlsafe_b64encode(json.dumps(o).encode()).decode().rstrip('=')
3199
+ jwt = lambda c: b({"alg": "none"}) + '.' + b(c) + '.sig'
3200
+ json.dump({"auth_mode": "chatgpt", "OPENAI_API_KEY": None,
3201
+ "tokens": {"id_token": jwt({"email": "cx@panel"}),
3202
+ "access_token": jwt({"exp": time.time() + 9999}),
3203
+ "refresh_token": "r", "account_id": "acc"},
3204
+ "last_refresh": "2026-08-01T00:00:00Z"}, open(sys.argv[1], "w"))
3205
+ EOF
3206
+ CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts list --json > "$PP/cx-list.json"
3207
+ python3 - "$PP/cx-list.json" "$CX2" <<'EOF'
3208
+ import json, sys
3209
+ d = json.load(open(sys.argv[1]))
3210
+ assert d["provider"] == "codex" and d["pool"]["root"] == sys.argv[2], d["pool"]
3211
+ a = d["accounts"][0]
3212
+ assert a["status"] == "active", a
3213
+ assert a["credential_class"] == "machine-local" and a["portable"] is False, a
3214
+ assert d["summary"]["portable"] == 0, d["summary"]
3215
+ assert d["pool"]["sync"]["mode"] == "local", d["pool"]["sync"]
3216
+ EOF
3217
+ [ $? -eq 0 ] && t_ok "codex list --json: same schema, no portable credentials" \
3218
+ || t_fail "codex list --json" "see $PP/cx-list.json"
3219
+ out="$(CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts export-credential acct-01 2>&1)"
3220
+ rc=$?
3221
+ check "codex export always refuses" "no portable credential type" "$out"
3222
+ check "codex export points at the device-code login" "codex-accounts login acct-01" "$out"
3223
+ [ "$rc" = "3" ] && t_ok "codex export exits 3 (machine-local)" || t_fail "codex export rc" "rc=$rc"
3224
+ CODEX_ACCOUNTS_ROOT="$CX2" codex-accounts export-credential acct-01 --identity-only --out "$PP/cx-ident.json" >/dev/null
3225
+ mkdir -p "$PP/codex-b"
3226
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$PP/codex-b/accounts.json"
3227
+ out="$(CODEX_ACCOUNTS_ROOT="$PP/codex-b" codex-accounts import-credential --in "$PP/cx-ident.json" 2>&1)"
3228
+ rc=$?
3229
+ check "codex identity import registers the account" "Registered acct-01 (cx@panel" "$out"
3230
+ check "codex identity import asks for a local sign-in" "codex-accounts login acct-01" "$out"
3231
+ [ "$rc" = "0" ] && t_ok "codex identity import exits 0" || t_fail "codex identity import rc" "rc=$rc"
3232
+ python3 - "$PP/codex-b/accounts.json" <<'EOF'
3233
+ import json, sys
3234
+ a = json.load(open(sys.argv[1]))["accounts"][0]
3235
+ assert a["added_at"] == "2026-04-05T06:07:08Z", a # source metadata preserved
3236
+ EOF
3237
+ [ $? -eq 0 ] && t_ok "codex identity transfer keeps the source added_at" \
3238
+ || t_fail "codex identity metadata" "see $PP/codex-b/accounts.json"
3239
+
3240
+ # ---- 20f. codex-review follow-ups: hostile pool roots, empty fields, marker rule ----
3241
+ # A pool root carrying shell metacharacters must never reach a trap body or a remote
3242
+ # command as code. (install.sh refuses to SCHEDULE such a root; the CLI must still be
3243
+ # safe when one is used directly.)
3244
+ QROOT="$PP/q'; touch $WORK/TRAP_PWNED; '"
3245
+ mkdir -p "$QROOT"
3246
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$QROOT/accounts.json"
3247
+ rm -f "$WORK/TRAP_PWNED"
3248
+ out="$(CLAUDE_ACCOUNTS_ROOT="$QROOT" claude-accounts import-credential --in "$PP/blob.json" 2>&1)"
3249
+ rc=$?
3250
+ check "import works from a pool root with shell metacharacters" "Imported acct-01" "$out"
3251
+ [ "$rc" = "0" ] && [ ! -f "$WORK/TRAP_PWNED" ] \
3252
+ && t_ok "a quoted pool root never executes as code (trap body)" \
3253
+ || { t_fail "pool root injection" "rc=$rc, sentinel=$([ -f "$WORK/TRAP_PWNED" ] && echo CREATED)"; rm -f "$WORK/TRAP_PWNED"; }
3254
+ out="$(CLAUDE_ACCOUNTS_ROOT="$QROOT" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-panel.json" \
3255
+ claude-accounts limits --json 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin)['accounts'][0]['id'])" 2>&1)"
3256
+ check "limits --json survives a quoted pool root" "acct-01" "$out"
3257
+ [ ! -f "$WORK/TRAP_PWNED" ] && t_ok "the limits lock trap never executes a quoted root" \
3258
+ || { t_fail "limits trap injection" "sentinel created"; rm -f "$WORK/TRAP_PWNED"; }
3259
+
3260
+ # An empty metadata field must stay empty: the record separator is 0x1F precisely
3261
+ # because bash collapses runs of IFS *whitespace*, which would shift added_at into home.
3262
+ python3 - "$PP/blob.json" "$PP/blob-nohome.json" <<'EOF'
3263
+ import json, sys
3264
+ b = json.load(open(sys.argv[1]))
3265
+ b["account"]["home"] = ""
3266
+ b["account"]["email"] = "nohome@test"
3267
+ json.dump(b, open(sys.argv[2], "w"))
3268
+ EOF
3269
+ NH="$PP/claude-nohome"
3270
+ mkdir -p "$NH"
3271
+ printf '{"version":1,"server":"none","threshold":90,"accounts":[]}\n' > "$NH/accounts.json"
3272
+ CLAUDE_ACCOUNTS_ROOT="$NH" claude-accounts import-credential --in "$PP/blob-nohome.json" >/dev/null 2>&1
3273
+ python3 - "$NH/accounts.json" <<'EOF'
3274
+ import json, sys
3275
+ a = json.load(open(sys.argv[1]))["accounts"][0]
3276
+ assert a["email"] == "nohome@test", a
3277
+ # home falls back to this machine, and added_at is NOT the value that would land there
3278
+ # if the empty field had collapsed
3279
+ assert a["home"] in ("mac", "linux"), a
3280
+ assert a["added_at"] == "2026-01-02T03:04:05Z", a
3281
+ EOF
3282
+ [ $? -eq 0 ] && t_ok "a blob with an empty field imports without shifting the next one" \
3283
+ || t_fail "empty-field record" "see $NH/accounts.json"
3284
+
3285
+ # Metadata carrying a control character is refused rather than truncating the record.
3286
+ python3 - "$PP/blob.json" "$PP/blob-ctrl.json" <<'EOF'
3287
+ import json, sys
3288
+ b = json.load(open(sys.argv[1]))
3289
+ b["account"]["email"] = "evil@test\nacct-99"
3290
+ json.dump(b, open(sys.argv[2], "w"))
3291
+ EOF
3292
+ out="$(CLAUDE_ACCOUNTS_ROOT="$NH" claude-accounts import-credential --in "$PP/blob-ctrl.json" 2>&1)"
3293
+ rc=$?
3294
+ check "metadata with a control character is refused" "control character" "$out"
3295
+ [ "$rc" != "0" ] && t_ok "control-character blob exits nonzero" || t_fail "control char blob" "exited 0"
3296
+
3297
+ # An adopted account is a symlink: a credential must never be written THROUGH it,
3298
+ # not even with --force (--force settles identity, it does not authorize escaping the pool).
3299
+ mkdir -p "$PP/outside"
3300
+ ln -s "$PP/outside" "$JP2/acct-09"
3301
+ python3 - "$PP/blob.json" "$PP/blob-09.json" <<'EOF'
3302
+ import json, sys
3303
+ b = json.load(open(sys.argv[1]))
3304
+ b["account"]["id"] = "acct-09"
3305
+ json.dump(b, open(sys.argv[2], "w"))
3306
+ EOF
3307
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP2" claude-accounts import-credential acct-09 --in "$PP/blob-09.json" --force 2>&1)"
3308
+ rc=$?
3309
+ check "import refuses to write through an adopted symlink" "must never be written through it" "$out"
3310
+ [ "$rc" != "0" ] && [ ! -f "$PP/outside/server.token" ] \
3311
+ && t_ok "--force does not authorize writing outside the pool" \
3312
+ || t_fail "symlink import" "rc=$rc, wrote=$([ -f "$PP/outside/server.token" ] && echo yes)"
3313
+ rm -f "$JP2/acct-09"
3314
+
3315
+ # The exported blob must land 0600 even when a world-readable file is already there.
3316
+ : > "$PP/pre-existing.json"
3317
+ chmod 644 "$PP/pre-existing.json"
3318
+ CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts export-credential acct-01 --out "$PP/pre-existing.json" >/dev/null
3319
+ case "$(ls -l "$PP/pre-existing.json" | cut -c1-10)" in
3320
+ -rw-------) t_ok "export --out replaces a world-readable file with a 0600 one" ;;
3321
+ *) t_fail "export --out perms over existing file" "$(ls -l "$PP/pre-existing.json" | cut -c1-10)" ;;
3322
+ esac
3323
+
3324
+ # The JSON report must apply the shim's marker rule exactly: a marker whose reset has
3325
+ # passed is NOT limited (the shim deletes it and selects the account), while a garbled
3326
+ # marker IS (the shim treats it as active rather than racing a concurrent write).
3327
+ printf '%s\nbucket=weekly percent=95 reason=limits\n' "$((now-600))" > "$JP/acct-01/.limited"
3328
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json | python3 -c "
3329
+ import json,sys; a={x['id']:x for x in json.load(sys.stdin)['accounts']}['acct-01']
3330
+ print(a['status'], a['limited'], a['selectable'])")"
3331
+ check "an elapsed .limited marker does not read as limited" "active False True" "$out"
3332
+ printf 'garbled\n' > "$JP/acct-01/.limited"
3333
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json | python3 -c "
3334
+ import json,sys; a={x['id']:x for x in json.load(sys.stdin)['accounts']}['acct-01']
3335
+ print(a['status'], a['limited'])")"
3336
+ check "a garbled .limited marker reads as limited (same as the shim)" "limited True" "$out"
3337
+ rm -f "$JP/acct-01/.limited"
3338
+
3339
+ # --json must not swallow a following typo, and a broken refresh must not be reported
3340
+ # as success just because the document rendered.
3341
+ out="$(CLAUDE_ACCOUNTS_ROOT="$JP" claude-accounts list --json --bogus 2>&1)"
3342
+ rc=$?
3343
+ check "list --json rejects an unknown extra option" "unknown option: --bogus" "$out"
3344
+ [ "$rc" != "0" ] && t_ok "list --json --bogus exits nonzero" || t_fail "strict --json parsing" "exited 0"
3345
+ BROKEN="$PP/claude-broken"
3346
+ mkdir -p "$BROKEN"
3347
+ printf 'not json at all\n' > "$BROKEN/accounts.json"
3348
+ out="$(CLAUDE_ACCOUNTS_ROOT="$BROKEN" claude-accounts limits --json 2>/dev/null)"
3349
+ rc=$?
3350
+ [ "$rc" != "0" ] && t_ok "limits --json propagates a failed refresh (nonzero)" \
3351
+ || t_fail "limits --json exit code" "exited 0 on an unreadable manifest"
3352
+ printf '%s' "$out" | python3 -c "
3353
+ import json,sys
3354
+ d=json.load(sys.stdin)
3355
+ assert d['accounts'] == [], d
3356
+ assert any('manifest' in w for w in d['warnings']), d['warnings']
3357
+ " 2>/dev/null \
3358
+ && t_ok "limits --json still emits a document (with a warning) when the pool is broken" \
3359
+ || t_fail "limits --json on a broken pool" "no usable document"
3360
+
2309
3361
  # ---- summary ---------------------------------------------------------------------
2310
3362
  echo
2311
3363
  echo "passed: $PASS failed: $FAIL"