claude-multiacc 2.0.41 → 2.0.43

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.
Files changed (33) hide show
  1. package/README.md +60 -2
  2. package/bin/claude +414 -5
  3. package/bin/claude-accounts +36 -11
  4. package/bin/codex +406 -4
  5. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  6. package/docs/AUTORESUME.md +321 -0
  7. package/docs/CODEX.md +12 -1
  8. package/docs/VERIFICATION.md +9 -1
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/autoresume.py +2271 -0
  20. package/lib/common.sh +24 -1
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  30. package/tests/fake_security.sh +90 -0
  31. package/tests/run-tests.sh +1178 -67
  32. package/tests/test_autoresume.py +2565 -0
  33. package/tests/test_keychain.py +323 -50
@@ -5,7 +5,8 @@
5
5
  set -u
6
6
 
7
7
  REPO_DIR="$(cd "$(dirname "$0")/.." && pwd -P)"
8
- WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")"
8
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/multiacc-test.XXXXXX")" || exit 1
9
+ [ -n "$WORK" ] && [ -d "$WORK" ] || exit 1
9
10
  # Loopback stub servers register their pid here. Deleting $WORK does not kill a running
10
11
  # python process, so an interrupted run (CI cancel, Ctrl-C) would otherwise leave one
11
12
  # listening until the machine goes away.
@@ -16,7 +17,13 @@ cleanup() {
16
17
  kill "$p" 2>/dev/null
17
18
  wait "$p" 2>/dev/null || true
18
19
  done
19
- rm -rf "$WORK"
20
+ # The auto-resume sections reap their own processes; this is the backstop for an
21
+ # interrupted run. Every fake client and watcher they start carries $WORK in its argv.
22
+ # Never with an empty WORK: `pkill -f /` would match nearly every process the user owns.
23
+ if [ -n "${WORK:-}" ] && [ -d "$WORK" ]; then
24
+ pkill -KILL -f "$WORK/" 2>/dev/null || true
25
+ rm -rf "$WORK"
26
+ fi
20
27
  }
21
28
  trap cleanup EXIT
22
29
 
@@ -118,7 +125,9 @@ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
118
125
  h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
119
126
  hex="$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' \
120
127
  | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
121
- security add-generic-password -U -a tester -s "Claude Code-credentials-$h" -X "$hex"
128
+ # The real client's account name is FA(): $USER, and Bun's "unknown" without it.
129
+ [ -n "${FAKE_LOGIN_SAW:-}" ] && printf '%s\n' "${USER:-<unset>}" > "$FAKE_LOGIN_SAW"
130
+ security add-generic-password -U -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -X "$hex"
122
131
  echo "Logged in."
123
132
  exit 0
124
133
  fi
@@ -169,6 +178,56 @@ if [ "${1:-}" = "setup-token" ]; then
169
178
  echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
170
179
  exit 0
171
180
  fi
181
+ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
182
+ # The auto-resume sections' INTERACTIVE client: it behaves the way the real TUI does at
183
+ # an API error — registers sessions/<pid>.json in the account dir, writes the error
184
+ # record into the (shared) transcript and then IDLES; SIGTERM deletes the registry and
185
+ # exits 143, and a crash (SIGKILL) leaves the registry behind. The scripted error only
186
+ # fires on a fresh launch; a `--resume <sid>` launch appends what the real client does
187
+ # after the auto-resume prompt — a normal reply — unless FAKE_TUI_QUIET is set.
188
+ acct="$(basename "$CLAUDE_CONFIG_DIR")"
189
+ sid=""; prev=""
190
+ for a in "$@"; do
191
+ case "$prev" in -r|--resume) sid="$a" ;; esac
192
+ case "$a" in --resume=*) sid="${a#--resume=}" ;; esac
193
+ prev="$a"
194
+ done
195
+ resumed=0; [ -n "$sid" ] && resumed=1
196
+ [ -n "$sid" ] || sid="${FAKE_TUI_SID:-5f0c1d2e-3a4b-4c5d-8e9f-a0b1c2d3e4f5}"
197
+ [ -n "${FAKE_TUI_PIDS:-}" ] && echo "$$" >> "$FAKE_TUI_PIDS"
198
+ echo "CFG=$acct ARGS=$*" >> "${FAKE_TUI_LOG:-/dev/null}"
199
+ proj="$CLAUDE_CONFIG_DIR/projects/-fake-tui"
200
+ mkdir -p "$CLAUDE_CONFIG_DIR/sessions" "$proj"
201
+ tr="$proj/$sid.jsonl"
202
+ ts() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
203
+ apierr() { # $1 error code, $2 extra JSON members (with a leading comma) or empty
204
+ printf '{"type":"assistant","isApiErrorMessage":true,"error":"%s"%s,"timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"content":[{"type":"text","text":"API Error"}]}}\n' \
205
+ "$1" "${2:-}" "$(ts)" "$sid" >> "$tr"
206
+ }
207
+ if [ "$resumed" = 1 ] && [ -z "${FAKE_TUI_QUIET:-}" ]; then
208
+ printf '{"type":"assistant","timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"content":[{"type":"text","text":"Resumed."}]}}\n' \
209
+ "$(ts)" "$sid" >> "$tr"
210
+ fi
211
+ reg="$CLAUDE_CONFIG_DIR/sessions/$$.json"
212
+ printf '{"pid":%s,"sessionId":"%s","cwd":"%s","kind":"interactive","status":"busy","startedAt":%s000}\n' \
213
+ "$$" "$sid" "$PWD" "$(date +%s)" > "$reg"
214
+ trap 'rm -f "$reg"; exit 143' TERM
215
+ trap 'rm -f "$reg"; exit 129' HUP
216
+ if [ "$resumed" = 0 ]; then
217
+ sleep "${FAKE_TUI_DELAY:-0.3}"
218
+ case "$FAKE_TUI" in
219
+ quota) apierr rate_limit ",\"quotaLimits\":{\"status\":\"rejected\",\"resetsAt\":$(( $(date +%s) + 7200 )),\"rateLimitType\":\"five_hour\"}" ;;
220
+ auth) apierr authentication_failed ;;
221
+ invalid) apierr invalid_request ;;
222
+ crash) sleep 0.3; kill -9 $$ ;;
223
+ esac
224
+ fi
225
+ # Bounded, in short sleeps so a TERM is handled within a tenth of a second.
226
+ i=0
227
+ while [ "$i" -lt "${FAKE_TUI_LIFE:-400}" ]; do sleep 0.1; i=$((i + 1)); done
228
+ rm -f "$reg"
229
+ exit 0
230
+ fi
172
231
  if [ $# -eq 0 ] && [ -n "${FAKE_DO_LOGIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
173
232
  # simulate an interactive session in which the user completed /login
174
233
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-new","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$CLAUDE_CONFIG_DIR/.credentials.json"
@@ -192,6 +251,17 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
192
251
  *) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
193
252
  esac ;;
194
253
  esac
254
+ if [ -n "${FAKE_KEYCHAIN_CLIENT:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] \
255
+ && [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
256
+ # The real macOS client's login lookup: the Keychain item under its FA() name ONLY
257
+ # ($USER; Bun's "unknown" without it), then .credentials.json — nothing else.
258
+ h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
259
+ if ! security find-generic-password -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -w >/dev/null 2>&1 \
260
+ && [ ! -f "$CLAUDE_CONFIG_DIR/.credentials.json" ]; then
261
+ echo "Not logged in · Please run /login" >&2
262
+ exit 1
263
+ fi
264
+ fi
195
265
  if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
196
266
  # An infrastructure fault, not a login verdict: the CLI never reached the API.
197
267
  # Verbatim from my-mini, 2026-09-10. Note it carries no auth vocabulary at all
@@ -260,67 +330,21 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
260
330
  sleep "${FAKE_SESSION_HOLD:-4}"
261
331
  rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
262
332
  fi
333
+ # what an interactive client would inherit from the shim (auto-resume must leak nothing)
334
+ [ -n "${FAKE_PRINT_ENV:-}" ] && echo "AR_ENV=$(env | grep -c '^CLAUDE_MULTIACC_AR')"
335
+ [ -n "${FAKE_PRINT_USER:-}" ] && echo "USER=${USER:-<unset>} LOGNAME=${LOGNAME:-<unset>}"
263
336
  echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
264
337
  EOF
265
338
  chmod +x "$FAKEBIN/claude"
266
339
 
267
- # Fake macOS `security` (generic-password verbs only): items live as files under
268
- # $FAKE_KEYCHAIN_DIR/<service> (content = the secret) + <service>.acct (account name).
340
+ # Fake macOS `security` (generic-password verbs only), shared with tests/test_keychain.py:
341
+ # items keyed by (service, account) under $FAKE_KEYCHAIN_DIR, -a selecting its own and
342
+ # no -a the OLDEST, exactly as the real tool behaves (see the file's header).
269
343
  # FAKE_KEYCHAIN_LOCKED=1 simulates a session that cannot open the keychain — secret
270
344
  # reads and every write exit 36 while ATTRIBUTE reads still answer, exactly the split
271
345
  # the real tool exhibits over ssh. Shadows any real /usr/bin/security via PATH, so a
272
346
  # macOS dev run of this suite can never touch the developer's actual keychain.
273
- cat > "$FAKEBIN/security" <<'EOF'
274
- #!/usr/bin/env bash
275
- KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
276
- cmd="${1:-}"; shift || true
277
- svc=""; acct=""; want_pw=0; hexdata=""
278
- while [ $# -gt 0 ]; do
279
- case "$1" in
280
- -s) svc="$2"; shift 2 ;;
281
- -a) acct="$2"; shift 2 ;;
282
- -w) want_pw=1; shift ;;
283
- -X) hexdata="$2"; shift 2 ;;
284
- *) shift ;;
285
- esac
286
- done
287
- # The modification stamp is RECORDED AT WRITE TIME in a sidecar rather than derived
288
- # from the item file's mtime: `stat`/`date` flags differ between BSD and GNU (on Linux
289
- # `stat -f %m` even "succeeds" with garbage), and this fake must behave identically on
290
- # a developer's Mac and on Linux CI.
291
- item_mdat() { cat "$KC/$svc.mdat" 2>/dev/null || echo 00000000000000; }
292
- case "$cmd" in
293
- show-keychain-info)
294
- [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
295
- exit 0 ;;
296
- find-generic-password)
297
- [ -f "$KC/$svc" ] || exit 44
298
- if [ "$want_pw" = "1" ]; then
299
- [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
300
- cat "$KC/$svc"
301
- exit 0
302
- fi
303
- a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
304
- printf 'keychain: "login.keychain-db"\nclass: "genp"\nattributes:\n'
305
- printf ' "acct"<blob>="%s"\n' "$a"
306
- printf ' "mdat"<timedate>=0x00 "%sZ\\000"\n' "$(item_mdat)"
307
- printf ' "svce"<blob>="%s"\n' "$svc"
308
- exit 0 ;;
309
- add-generic-password)
310
- [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
311
- mkdir -p "$KC"
312
- printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
313
- printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
314
- date -u +%Y%m%d%H%M%S > "$KC/$svc.mdat"
315
- exit 0 ;;
316
- delete-generic-password)
317
- [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
318
- [ -f "$KC/$svc" ] || exit 44
319
- rm -f "$KC/$svc" "$KC/$svc.acct" "$KC/$svc.mdat"
320
- exit 0 ;;
321
- *) exit 1 ;;
322
- esac
323
- EOF
347
+ cp "$REPO_DIR/tests/fake_security.sh" "$FAKEBIN/security"
324
348
  chmod +x "$FAKEBIN/security"
325
349
 
326
350
  export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
@@ -352,6 +376,12 @@ export CLAUDE_MULTIACC_KEYCHAIN=0
352
376
  # runs under the operator's real HOME, whose rc files are not under test — section 18
353
377
  # turns the probe on against its own fake HOME.
354
378
  export CLAUDE_MULTIACC_PATH_PROBE=0
379
+ # Auto-resume spawns a detached watcher for an interactive launch inside tmux. The suite
380
+ # may itself run inside tmux, and no legacy section is about auto-resume, so it is off
381
+ # (and the pane invisible) everywhere except the sections that turn it on against a fake
382
+ # tmux — a watcher here would otherwise type into the developer's REAL pane.
383
+ export CLAUDE_MULTIACC_AUTORESUME=0 CODEX_MULTIACC_AUTORESUME=0
384
+ unset TMUX TMUX_PANE 2>/dev/null || true
355
385
 
356
386
  now="$(date +%s)"
357
387
 
@@ -4852,6 +4882,61 @@ with open(path + ".tmp", "w") as f:
4852
4882
  os.replace(path + ".tmp", path)
4853
4883
  EOF
4854
4884
 
4885
+ # The auto-resume sections' INTERACTIVE codex (sourced by the fake below when FAKE_TUI is
4886
+ # set): it writes a rollout the way the real TUI does — line 1 session_meta (id ==
4887
+ # session_id, cwd, originator codex-tui), held open like the native binary holds it — then
4888
+ # the scripted token_count + task_complete error, and IDLES; SIGTERM exits 0 (what the node
4889
+ # wrapper reports). A `resume <sid>` launch appends task_started (the resumed turn) to the
4890
+ # SAME rollout instead of an error.
4891
+ cat > "$WORK/fake-tui-codex.sh" <<'EOF'
4892
+ acct="$(basename "$CODEX_HOME")"
4893
+ sid=""; resumed=0
4894
+ if [ "${1:-}" = resume ]; then
4895
+ resumed=1
4896
+ for a in "$@"; do
4897
+ case "$a" in
4898
+ *' '*) ;;
4899
+ ????????-????-????-????-????????????) [ -n "$sid" ] || sid="$a" ;;
4900
+ esac
4901
+ done
4902
+ fi
4903
+ [ -n "${FAKE_TUI_PIDS:-}" ] && echo "$$" >> "$FAKE_TUI_PIDS"
4904
+ echo "CFG=$acct ARGS=$*" >> "${FAKE_TUI_LOG:-/dev/null}"
4905
+ ts() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
4906
+ ro=""
4907
+ if [ -n "$sid" ]; then
4908
+ for f in "$CODEX_HOME"/sessions/*/*/*/rollout-*-"$sid".jsonl; do [ -f "$f" ] && ro="$f"; done
4909
+ fi
4910
+ if [ -z "$ro" ]; then
4911
+ [ -n "$sid" ] || sid="${FAKE_TUI_SID:-6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d}"
4912
+ day="$CODEX_HOME/sessions/$(date +%Y/%m/%d)"
4913
+ mkdir -p "$day"
4914
+ ro="$day/rollout-$(date +%Y-%m-%dT%H-%M-%S)-$sid.jsonl"
4915
+ printf '{"timestamp":"%s","type":"session_meta","payload":{"id":"%s","session_id":"%s","timestamp":"%s","cwd":"%s","originator":"codex-tui","cli_version":"0.0.0","source":"cli"}}\n' \
4916
+ "$(ts)" "$sid" "$sid" "$(ts)" "$PWD" > "$ro"
4917
+ fi
4918
+ exec 9>>"$ro"
4919
+ trap 'exit 0' TERM
4920
+ trap 'exit 0' HUP
4921
+ if [ "$resumed" = 1 ]; then
4922
+ [ -n "${FAKE_TUI_QUIET:-}" ] \
4923
+ || printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_started","turn_id":"2"}}\n' "$(ts)" >&9
4924
+ else
4925
+ sleep "${FAKE_TUI_DELAY:-0.3}"
4926
+ case "$FAKE_TUI" in
4927
+ quota)
4928
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"premium","primary":null,"secondary":null}}}\n' "$(ts)" >&9
4929
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"codex","primary":{"used_percent":100.0,"window_minutes":300,"resets_at":%s},"secondary":{"used_percent":40.0,"window_minutes":10080,"resets_at":%s}}}}\n' \
4930
+ "$(ts)" "$(( $(date +%s) + 5400 ))" "$(( $(date +%s) + 500000 ))" >&9
4931
+ printf '{"timestamp":"%s","type":"event_msg","payload":{"type":"task_complete","turn_id":"1","last_agent_message":null,"error":{"message":"You'"'"'ve hit your usage limit. Upgrade to Pro, or try again at Sep 26th, 2026 11:22 AM.","codex_error_info":"usage_limit_exceeded"},"completed_at":%s}}\n' \
4932
+ "$(ts)" "$(date +%s)" >&9 ;;
4933
+ esac
4934
+ fi
4935
+ i=0
4936
+ while [ "$i" -lt "${FAKE_TUI_LIFE:-400}" ]; do sleep 0.1; i=$((i + 1)); done
4937
+ exit 0
4938
+ EOF
4939
+
4855
4940
  # Fake "real" codex: prints which CODEX_HOME it ran under; scriptable login +
4856
4941
  # failures via a control file. (Note: must not contain the hyphenated shim marker.)
4857
4942
  cat > "$FAKEBIN/codex" <<EOF
@@ -4864,6 +4949,7 @@ if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
4864
4949
  [ "\$rc" = 0 ] && [ -n "\${FAKE_MCP_WRITE_THEN_FAIL:-}" ] && exit 1
4865
4950
  exit \$rc
4866
4951
  fi
4952
+ if [ -n "\${FAKE_TUI:-}" ] && [ -n "\${CODEX_HOME:-}" ]; then . "$WORK/fake-tui-codex.sh"; fi
4867
4953
  if [ "\${1:-}" = "login" ]; then
4868
4954
  shift
4869
4955
  # record the flags the CLI chose (device-auth default vs --browser opt-out)
@@ -4899,6 +4985,8 @@ for a in "\$@"; do
4899
4985
  prev="\$a"
4900
4986
  done
4901
4987
  [ -n "\$outfile" ] && printf 'OK' > "\$outfile"
4988
+ # what an interactive client would inherit from the shim (auto-resume must leak nothing)
4989
+ [ -n "\${FAKE_PRINT_ENV:-}" ] && echo "AR_ENV=\$(env | grep -c '^CODEX_MULTIACC_AR')"
4902
4990
  echo "CFG=\$acct"
4903
4991
  EOF
4904
4992
  chmod +x "$FAKEBIN/codex"
@@ -6117,7 +6205,7 @@ printf '%s' "$out" | grep -q "acct-01" \
6117
6205
  && t_fail "codex fetch throttle" "fresh account was re-fetched: $out" \
6118
6206
  || t_ok "codex: fresh account skipped by the fetch throttle"
6119
6207
  # 429 backoff honored
6120
- python3 - "$CX/acct-02/limits.json" "$now" <<'EOF'
6208
+ python3 - "$CX/acct-02/limits.json" "$(date +%s)" <<'EOF'
6121
6209
  import json, sys
6122
6210
  json.dump({'fetched_at': 0, 'retry_after': int(sys.argv[2]) + 300, 'backoff': 120},
6123
6211
  open(sys.argv[1], 'w'))
@@ -7646,11 +7734,22 @@ cat > "$KCP/accounts.json" <<EOF
7646
7734
  EOF
7647
7735
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
7648
7736
  kc_hash() { printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8; }
7649
- kc_svc_file() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
7650
- kc_put() { # kc_put <acct dir> <credential json> — store it the way the client would
7651
- security add-generic-password -U -a tester -s "Claude Code-credentials-$(kc_hash "$1")" \
7737
+ kc_svc_dir() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
7738
+ kc_put() { # kc_put <acct dir> <credential json> [account] — store it the way a client the
7739
+ # pool launches would: under its FA() name, $USER, which lib/common.sh and
7740
+ # bin/claude fill from `id -un` when the caller's env has none
7741
+ security add-generic-password -U -a "${3:-${USER:-$(id -un)}}" -s "Claude Code-credentials-$(kc_hash "$1")" \
7652
7742
  -X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
7653
7743
  }
7744
+ kc_items() { # kc_items <acct dir> -> the account names holding an item, one per line
7745
+ local i
7746
+ for i in "$(kc_svc_dir "$1")"/*/; do [ -d "$i" ] && basename "$i"; done
7747
+ return 0
7748
+ }
7749
+ kc_secret() { # kc_secret <acct dir> [account] -> the stored document
7750
+ cat "$(kc_svc_dir "$1")/${2:-${USER:-$(id -un)}}/secret" 2>/dev/null
7751
+ }
7752
+ KC_ME="$(id -un)" # the canonical name: what the CLI fills USER with when it is unset
7654
7753
  kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7655
7754
  export CLAUDE_MULTIACC_KEYCHAIN=1
7656
7755
 
@@ -7711,13 +7810,25 @@ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("sourc
7711
7810
 
7712
7811
  # 17e. refresh: an expired keychain credential refreshes via the grant and is written
7713
7812
  # BACK to the keychain — never copied out into a .credentials.json
7714
- kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
7813
+ # Beside it: a copy of the grant the refresh starts from (spent once the grant rotates —
7814
+ # it goes) and ANOTHER live grant (the right account's login beside a wrong-account
7815
+ # sign-in's, say — only a verified sign-in may drop that one).
7816
+ KC_OLD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
7817
+ KC_OTHER='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kother","refreshToken":"sk-ant-ort01-other","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7818
+ kc_put "$KCP/acct-02" "$KC_OLD"
7819
+ kc_put "$KCP/acct-02" "$KC_OLD" unknown
7820
+ kc_put "$KCP/acct-02" "$KC_OTHER" claude-code-user
7715
7821
  rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
7716
7822
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
7717
7823
  check "keychain credential refreshes via the grant" "acct-02: oauth access token refreshed" "$out"
7718
- grep -q 'sk-ant-oat01-refreshednew' "$(kc_svc_file "$KCP/acct-02")" \
7824
+ kc_secret "$KCP/acct-02" | grep -q 'sk-ant-oat01-refreshednew' \
7719
7825
  && t_ok "rotated credential written back to the keychain" \
7720
7826
  || t_fail "keychain write-back" "item not updated"
7827
+ [ "$(kc_items "$KCP/acct-02" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" claude-code-user | sort | tr '\n' ' ')" ] \
7828
+ && [ "$(kc_secret "$KCP/acct-02" claude-code-user)" = "$KC_OTHER" ] \
7829
+ && t_ok "the write-back drops the spent copy of the grant and keeps another live grant" \
7830
+ || t_fail "keychain write-back siblings" "items: $(kc_items "$KCP/acct-02" | tr '\n' ' ')"
7831
+ security delete-generic-password -a claude-code-user -s "Claude Code-credentials-$(kc_hash "$KCP/acct-02")" >/dev/null 2>&1
7721
7832
  [ ! -f "$KCP/acct-02/.credentials.json" ] \
7722
7833
  && t_ok "refresh never copies the keychain credential to a file" \
7723
7834
  || t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
@@ -7731,14 +7842,30 @@ CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
7731
7842
  && t_ok ".expired self-heals on a newer keychain credential" \
7732
7843
  || t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
7733
7844
 
7734
- # 17g. a login ceremony that lands in the keychain registers instead of dying
7845
+ # 17g. a login ceremony that lands in the keychain registers instead of dying. The slot
7846
+ # it reserves (acct-03) can carry leftover items from an account once removed there; they
7847
+ # go only AFTER the identity read back and the registration committed — a sign-in as an
7848
+ # account the pool already has is skipped and must leave them alone.
7849
+ kc_put "$KCP/acct-03" "$KC_OTHER" unknown
7850
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kchain@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add 2>&1)"
7851
+ check "an add that signs in as a registered account is skipped" "already added as acct-02" "$out"
7852
+ [ "$(kc_secret "$KCP/acct-03" unknown)" = "$KC_OTHER" ] \
7853
+ && t_ok "a skipped add prunes nothing" \
7854
+ || t_fail "skipped add prune" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
7855
+ grep -q 'keychain prune acct-03' "$KCP/ops.log" 2>/dev/null \
7856
+ && t_fail "skipped add prune" "a prune ran before the registration" \
7857
+ || t_ok "no prune ran for the skipped add"
7735
7858
  out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kc3@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add kc3@test 2>&1)"
7736
7859
  check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
7737
7860
  [ ! -f "$KCP/acct-03/.credentials.json" ] \
7738
7861
  && t_ok "keychain add leaves no plaintext credential" \
7739
7862
  || t_fail "keychain add" "unexpected .credentials.json"
7740
- [ -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "the sign-in landed in the keychain" \
7741
- || t_fail "keychain add item" "no keychain item for acct-03"
7863
+ [ "$(kc_items "$KCP/acct-03")" = "$KC_ME" ] && kc_secret "$KCP/acct-03" | grep -q 'sk-ant-oat01-login' \
7864
+ && t_ok "the registered sign-in is the one item left, under \$(id -un)" \
7865
+ || t_fail "keychain add item" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
7866
+ grep -q "keychain prune acct-03: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
7867
+ && t_ok "the add's prune is logged" \
7868
+ || t_fail "add prune log" "no prune line for acct-03 in ops.log"
7742
7869
 
7743
7870
  # 17h. export refuses a keychain-held machine-local credential, naming the store
7744
7871
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
@@ -7748,7 +7875,7 @@ check "export names the keychain as the machine-local store" "macOS Keychain" "$
7748
7875
 
7749
7876
  # 17i. remove deletes the keychain item with the account
7750
7877
  CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-03 --yes >/dev/null 2>&1
7751
- [ ! -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
7878
+ [ -z "$(kc_items "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
7752
7879
  || t_fail "keychain remove" "the item survived removal"
7753
7880
 
7754
7881
  # 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
@@ -7766,12 +7893,187 @@ mkdir -p "$KCP/acct-04" "$KCP/acct-05"
7766
7893
  kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7767
7894
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
7768
7895
  check "dedupe removes the credential-less duplicate, not the keychain one" "will remove acct-04 (kdup@test)" "$out"
7769
- [ -f "$(kc_svc_file "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
7896
+ [ -n "$(kc_items "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
7770
7897
  || t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
7771
7898
  grep -q '"acct-04"' "$KCP/accounts.json" \
7772
7899
  && t_fail "dedupe manifest" "acct-04 still registered" \
7773
7900
  || t_ok "the credential-less duplicate left the manifest"
7774
7901
 
7902
+ # 17k. several items per service (2026-09-24). Claude Code names its item after $USER and,
7903
+ # under Bun, a client with no USER calls the user "unknown": app-robot's panel ran
7904
+ # `claude-accounts login acct-02` from an env without USER, the fresh login landed under
7905
+ # "unknown" beside the dead item under the real name, and the check read the dead one
7906
+ # (`security -s` without -a answers with the OLDEST item) — "login failed … nothing
7907
+ # changed". Now the CLI fills USER, every lookup reads the canonical item (the one the
7908
+ # client reads), and a sign-in prunes the siblings only once its identity checked out.
7909
+ python3 - "$KCP/accounts.json" <<'EOF2'
7910
+ import json, sys
7911
+ doc = json.load(open(sys.argv[1]))
7912
+ doc['accounts'].append({'id': 'acct-06', 'email': 'kfix@test', 'home': 'mac',
7913
+ 'added_at': '2026-09-24T00:00:00Z'})
7914
+ json.dump(doc, open(sys.argv[1], 'w'))
7915
+ EOF2
7916
+ mkdir -p "$KCP/acct-06"
7917
+ KC_DEAD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdead","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":1000}}'
7918
+ KC_UNK='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kunk","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7919
+ FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7920
+ FAKE_KEYCHAIN_MDAT=20260924095747 kc_put "$KCP/acct-06" "$KC_UNK" unknown
7921
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
7922
+ python3 - "$out" <<'EOF2'
7923
+ import json, sys
7924
+ a6 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-06']
7925
+ assert a6['selectable'] is False and a6['needs_login'] is True, a6
7926
+ EOF2
7927
+ [ $? -eq 0 ] && t_ok "the pool judges the canonical (dead) item its client reads, not the newer sibling" \
7928
+ || t_fail "keychain canonical read" "see assertions above"
7929
+ # A sign-in as the WRONG account: the client itself overwrites the canonical item, but
7930
+ # nothing multiacc does may touch the rest — the live sibling can be the right account's.
7931
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=intruder@test \
7932
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7933
+ [ "$rc" != "0" ] && t_ok "a wrong-identity login fails" || t_fail "wrong-identity login" "rc=0"
7934
+ check "a wrong-identity login says nothing was saved" "nothing saved" "$out"
7935
+ [ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
7936
+ && [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_UNK" ] \
7937
+ && t_ok "a wrong-identity login leaves every sibling item intact" \
7938
+ || t_fail "wrong-identity keychain login" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7939
+ grep -q 'keychain prune acct-06' "$KCP/ops.log" 2>/dev/null \
7940
+ && t_fail "wrong-identity keychain login" "pruned before the identity check" \
7941
+ || t_ok "no prune ran for the wrong-identity login"
7942
+ FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7943
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kfix@test \
7944
+ FAKE_LOGIN_SAW="$WORK/kc-login-saw" CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7945
+ [ "$rc" = "0" ] && t_ok "a USER-less login over a dead canonical + live sibling succeeds" \
7946
+ || t_fail "USER-less keychain login" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
7947
+ check "the USER-less login reports the keychain store" "acct-06 login saved (macOS Keychain" "$out"
7948
+ [ "$(cat "$WORK/kc-login-saw" 2>/dev/null)" = "$KC_ME" ] \
7949
+ && t_ok "the client the ceremony starts gets USER=\$(id -un), not Bun's 'unknown'" \
7950
+ || t_fail "ceremony USER fill" "client saw USER=$(cat "$WORK/kc-login-saw" 2>/dev/null)"
7951
+ [ "$(kc_items "$KCP/acct-06")" = "$KC_ME" ] \
7952
+ && t_ok "the verified login leaves exactly ONE item, under \$(id -un)" \
7953
+ || t_fail "keychain prune" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7954
+ kc_secret "$KCP/acct-06" "$KC_ME" | grep -q 'sk-ant-oat01-login' \
7955
+ && t_ok "the surviving item is the fresh login" \
7956
+ || t_fail "keychain prune" "the canonical item does not hold the fresh login"
7957
+ grep -q "keychain prune acct-06: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
7958
+ && t_ok "the prune is logged (non-secret) to ops.log" \
7959
+ || t_fail "keychain prune log" "no prune line in ops.log"
7960
+ grep -q 'sk-ant' "$KCP/ops.log" 2>/dev/null \
7961
+ && t_fail "keychain prune log" "a credential reached ops.log" \
7962
+ || t_ok "ops.log carries no credential"
7963
+
7964
+ # 17l. a failed ceremony changes nothing — not even the stale items ("nothing changed")
7965
+ FAKE_KEYCHAIN_MDAT=20260912000000 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7966
+ FAKE_KEYCHAIN_MDAT=20260913000000 kc_put "$KCP/acct-06" "$KC_DEAD" unknown
7967
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 FAKE_EMAIL=kfix@test \
7968
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7969
+ [ "$rc" != "0" ] && [ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
7970
+ && [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_DEAD" ] \
7971
+ && t_ok "an aborted login leaves every item as it was" \
7972
+ || t_fail "aborted keychain login" "rc=$rc items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7973
+
7974
+ # 17m. remove drops EVERY item for the dir, whatever name it sits under
7975
+ FAKE_KEYCHAIN_MDAT=20260101000000 kc_put "$KCP/acct-06" "$KC_DEAD" claude-code-user
7976
+ FAKE_KEYCHAIN_MDAT=20260102000000 kc_put "$KCP/acct-06" "$KC_DEAD" renamed-user
7977
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-06 --yes >/dev/null 2>&1
7978
+ [ -z "$(kc_items "$KCP/acct-06")" ] && t_ok "remove leaves zero items when duplicates exist" \
7979
+ || t_fail "keychain remove duplicates" "left: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7980
+
7981
+ # 17n. the shim judges exactly the item its client will read: -a <$USER, filled>. A
7982
+ # live login under any other name is invisible to that client, so it is no auth here.
7983
+ KSP="$WORK/kcshim"
7984
+ mkdir -p "$KSP/tmp" "$KSP/acct-01" "$KSP/acct-02"
7985
+ : > "$KSP/.limits-kick"
7986
+ cat > "$KSP/accounts.json" <<EOF2
7987
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
7988
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
7989
+ "accounts": [
7990
+ {"id": "acct-01", "email": "sfile@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"},
7991
+ {"id": "acct-02", "email": "schain@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"}
7992
+ ] }
7993
+ EOF2
7994
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-sfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KSP/acct-01/.credentials.json"
7995
+ printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$KSP/acct-01/.limited"
7996
+ KC_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-slive","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7997
+ kc_put "$KSP/acct-02" "$KC_LIVE" unknown
7998
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
7999
+ check "shim ignores a live item under a name its client never reads" "CFG=acct-01" "$out"
8000
+ FAKE_KEYCHAIN_MDAT=20260910000000 kc_put "$KSP/acct-02" "$KC_LIVE" "$KC_ME"
8001
+ FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KSP/acct-02" "$KC_DEAD" unknown
8002
+ out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
8003
+ check "shim reads the -a \$(id -un) item even when a newer sibling is dead" "CFG=acct-02" "$out"
8004
+ check "the client the shim starts inherits the filled USER/LOGNAME" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
8005
+ out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
8006
+ check "a passthrough launch gets the filled USER too" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
8007
+ out="$(env USER=other.user LOGNAME=keep FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
8008
+ check "a caller's own USER/LOGNAME are never overridden" "USER=other.user LOGNAME=keep" "$out"
8009
+
8010
+ # 17o. an item under a name the client does not read is NO login to anything that judges
8011
+ # the account — list, audit, verify — just as it is none to the shim and the client. Only
8012
+ # the limits pass looks further, and moves it under the canonical name. my-mini's
8013
+ # acct-06/acct-21 shape (the only item under "unknown"), with and without a setup token,
8014
+ # plus the reverse order: a stale sibling OLDER than the canonical item.
8015
+ KLP="$WORK/kclegacy"
8016
+ # The accounts live on THIS machine (CI runs on Linux): a login-less account whose home
8017
+ # is another machine is 'remote', not one that needs a login here.
8018
+ KLP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || KLP_HOME=server
8019
+ mkdir -p "$KLP/tmp" "$KLP/acct-01" "$KLP/acct-02" "$KLP/acct-03"
8020
+ : > "$KLP/.limits-kick"
8021
+ cat > "$KLP/accounts.json" <<EOF2
8022
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
8023
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
8024
+ "accounts": [
8025
+ {"id": "acct-01", "email": "ltok@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
8026
+ {"id": "acct-02", "email": "lonly@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
8027
+ {"id": "acct-03", "email": "lorder@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"}
8028
+ ] }
8029
+ EOF2
8030
+ printf 'sk-ant-oat01-legacy-token-01' > "$KLP/acct-01/server.token"
8031
+ KC_L1='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l1","refreshToken":"sk-ant-ort01-l1","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
8032
+ KC_L2='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l2","refreshToken":"sk-ant-ort01-l2","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
8033
+ kc_put "$KLP/acct-01" "$KC_L1" unknown
8034
+ kc_put "$KLP/acct-02" "$KC_L2" unknown
8035
+ FAKE_KEYCHAIN_MDAT=20260901000000 kc_put "$KLP/acct-03" "$KC_DEAD" unknown
8036
+ FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KLP/acct-03" "$KC_LIVE" "$KC_ME"
8037
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
8038
+ python3 - "$out" <<'EOF2'
8039
+ import json, sys
8040
+ rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
8041
+ a1, a2, a3 = rows['acct-01'], rows['acct-02'], rows['acct-03']
8042
+ assert a1['credentials']['oauth_store'] is None, a1['credentials']
8043
+ assert a2['selectable'] is False and a2['needs_login'] is True, a2
8044
+ assert a2['credentials']['oauth_store'] is None, a2['credentials']
8045
+ assert a3['selectable'] is True and a3['credentials']['keychain'] == 'readable', a3
8046
+ EOF2
8047
+ [ $? -eq 0 ] && t_ok "list --json counts only the item its client reads (none under another name; canonical over an older sibling)" \
8048
+ || t_fail "keychain legacy list" "see assertions above"
8049
+ out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
8050
+ check "verify runs a token-backed account on its token when its login is out of the client's reach" "acct-01 ltok@test: PASS" "$out"
8051
+ check "verify skips an account whose only login its client cannot read" "acct-02 lonly@test: SKIP" "$out"
8052
+ check "verify passes the canonical item beside an older stale sibling" "acct-03 lorder@test: PASS" "$out"
8053
+ [ ! -f "$KLP/acct-01/.expired" ] && [ -f "$KLP/acct-01/.server-token-verified" ] \
8054
+ && t_ok "verify neither parks it nor forgets its token proof" \
8055
+ || t_fail "keychain legacy verify" "acct-01: $(cat "$KLP/acct-01/.expired" 2>/dev/null | tail -1)"
8056
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
8057
+ check "the limits pass moves a login filed under another name" "acct-02: Keychain login moved under $KC_ME" "$out"
8058
+ check "and reads usage with it" "acct-02: ok" "$out"
8059
+ [ "$(kc_items "$KLP/acct-01")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-01")" = "$KC_L1" ] \
8060
+ && [ "$(kc_items "$KLP/acct-02")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-02")" = "$KC_L2" ] \
8061
+ && t_ok "each moved login is the one item left, under \$(id -un), unchanged" \
8062
+ || t_fail "keychain legacy move" "acct-01: $(kc_items "$KLP/acct-01" | tr '\n' ' ') acct-02: $(kc_items "$KLP/acct-02" | tr '\n' ' ')"
8063
+ [ "$(kc_items "$KLP/acct-03" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
8064
+ && t_ok "the pass leaves an account with a canonical item alone (no write, no prune)" \
8065
+ || t_fail "keychain legacy move" "acct-03: $(kc_items "$KLP/acct-03" | tr '\n' ' ')"
8066
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
8067
+ python3 - "$out" <<'EOF2'
8068
+ import json, sys
8069
+ a2 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-02']
8070
+ assert a2['selectable'] is True and a2['credentials']['keychain'] == 'readable', a2
8071
+ EOF2
8072
+ [ $? -eq 0 ] && t_ok "once moved, the login counts everywhere" \
8073
+ || t_fail "keychain legacy move list" "see assertions above"
8074
+ out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
8075
+ check "and its client can use it" "acct-02 lonly@test: PASS" "$out"
8076
+
7775
8077
  export CLAUDE_MULTIACC_KEYCHAIN=0
7776
8078
  unset FAKE_KEYCHAIN_DIR
7777
8079
 
@@ -7885,6 +8187,808 @@ fi
7885
8187
  out="$(HOME="$RCH" claude-accounts status 2>&1)"
7886
8188
  check "status honours CLAUDE_MULTIACC_PATH_PROBE=0" "probe disabled" "$out"
7887
8189
 
8190
+ # ============================ AUTO-RESUME (end to end) ============================
8191
+ # An interactive session that stops on a usage limit, a dead login or a crash is resumed
8192
+ # on another account: the shim spawns lib/autoresume.py beside its UNCHANGED exec, the
8193
+ # watcher tails the session's transcript (codex: rollout), stops the TUI and types the
8194
+ # relaunch into the pane's SHELL, and the relaunched shim marks the old account before it
8195
+ # selects. Here the TUI is the fakes' FAKE_TUI mode and tmux is a fake that never talks to
8196
+ # a server: display-message answers from the environment, and Enter runs the typed line
8197
+ # with `bash -c` in the background — the pane's shell, with the pane's environment (none
8198
+ # of the client's CLAUDE_CONFIG_DIR/CODEX_HOME exports). Every case gets its own pool, cut-
8199
+ # down timing knobs and hard timeouts, and ends in ar_reap, which kills what it started.
8200
+ ARB="$WORK/ar"
8201
+ ARH="$WORK/ar-home" # a sandbox HOME: nothing here may touch the operator's
8202
+ ARW="$WORK/ar-work dir" # the session's directory (with a space, on purpose)
8203
+ ARPY="$(command -v python3)"
8204
+ mkdir -p "$ARB" "$ARH" "$ARW"
8205
+ cat > "$FAKEBIN/tmux" <<'EOF'
8206
+ #!/usr/bin/env bash
8207
+ # fake tmux for the auto-resume sections (never a server; shadows any real tmux on PATH)
8208
+ st="${FAKE_TMUX_DIR:-}"
8209
+ [ -n "$st" ] && [ -d "$st" ] || exit 1
8210
+ printf '%s\n' "$*" >> "$st/calls"
8211
+ while [ $# -gt 0 ]; do
8212
+ case "$1" in -S|-L) shift; [ $# -gt 0 ] && shift ;; *) break ;; esac
8213
+ done
8214
+ verb="${1:-}"; [ $# -gt 0 ] && shift
8215
+ case "$verb" in
8216
+ display-message)
8217
+ # -p answers the format the watcher asked for; without -p it is a status-line notice.
8218
+ p=0; fmt=""
8219
+ while [ $# -gt 0 ]; do
8220
+ case "$1" in
8221
+ -p) p=1; shift ;;
8222
+ -t) shift; [ $# -gt 0 ] && shift ;;
8223
+ *) fmt="$1"; shift ;;
8224
+ esac
8225
+ done
8226
+ [ "$p" = 1 ] || exit 0
8227
+ printf '%s\n' "$fmt" | sed -e "s/#{pane_pid}/${FAKE_TMUX_PANE_PID:-1}/g" \
8228
+ -e "s/#{pane_current_command}/${FAKE_TMUX_PANE_CMD:-zsh}/g" \
8229
+ -e "s/#{pane_in_mode}/${FAKE_TMUX_PANE_MODE:-0}/g" \
8230
+ -e "s/#{synchronize-panes}/${FAKE_TMUX_PANE_SYNC:-0}/g" ;;
8231
+ send-keys)
8232
+ lit=0
8233
+ while [ $# -gt 0 ]; do
8234
+ case "$1" in
8235
+ -t) shift; [ $# -gt 0 ] && shift; continue ;;
8236
+ -R) shift; continue ;;
8237
+ -l) lit=1; shift; continue ;;
8238
+ esac
8239
+ if [ "$lit" = 1 ]; then
8240
+ printf '%s' "$1" >> "$st/line"
8241
+ else
8242
+ case "$1" in
8243
+ C-u) : > "$st/line" ;;
8244
+ Enter)
8245
+ line="$(cat "$st/line" 2>/dev/null)"; : > "$st/line"
8246
+ printf '%s\n' "$line" >> "$st/typed"
8247
+ # The trailing `:` keeps bash from exec'ing the command in its own place: like
8248
+ # the pane's real shell, it stays the relaunched shim's parent (its PPID).
8249
+ ( unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_SHIM_ACTIVE CODEX_HOME CODEX_SHIM_ACTIVE
8250
+ exec bash -c "$line; :" ) </dev/null >> "$st/shell.out" 2>&1 & ;;
8251
+ esac
8252
+ fi
8253
+ shift
8254
+ done ;;
8255
+ esac
8256
+ exit 0
8257
+ EOF
8258
+ chmod +x "$FAKEBIN/tmux"
8259
+ # Records a spawn instead of watching: the gate cases need a watcher that leaves its state
8260
+ # file in place, so "no state file" can only mean "no spawn".
8261
+ cat > "$ARB/stub-python" <<'EOF'
8262
+ #!/bin/sh
8263
+ echo "$*" >> "${FAKE_STUB_PY_LOG:-/dev/null}"
8264
+ EOF
8265
+ chmod +x "$ARB/stub-python"
8266
+
8267
+ ar_wait() { # ar_wait <seconds> <cmd...>: poll every 0.1 s until cmd succeeds; 1 on timeout
8268
+ local n=0 lim=$(($1 * 10))
8269
+ shift
8270
+ while ! "$@"; do
8271
+ n=$((n + 1)); [ "$n" -lt "$lim" ] || return 1
8272
+ sleep 0.1
8273
+ done
8274
+ return 0
8275
+ }
8276
+ ar_lines() { [ -f "$2" ] && [ "$(($(wc -l < "$2")))" -ge "$1" ]; } # <n> <file>
8277
+ ar_logged() { grep -Eq "$2" "$1/selection.log" 2>/dev/null; } # <pool> <ERE>
8278
+ ar_no_watcher() { ! pgrep -f "$1/tmp/autoresume/" >/dev/null 2>&1; } # <pool>
8279
+ ar_acct_of() { printf '%s' "$1" | sed -n 's/^CFG=\(acct-[0-9]*\) .*/\1/p'; }
8280
+ # A stopped client may linger as a zombie until its parent reaps it: that is gone, not alive.
8281
+ ar_running() { kill -0 "$1" 2>/dev/null && ! ps -o stat= -p "$1" 2>/dev/null | grep -q '^Z'; }
8282
+ ar_stopped() { ! ar_running "$1"; }
8283
+ ar_start() { # ar_start [VAR=val ...] -- <cmd> [args...]: the launch typed in the pane, backgrounded
8284
+ local pre=()
8285
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8286
+ [ $# -gt 0 ] && shift
8287
+ ( cd "$ARW" && exec env "${AREC[@]}" ${pre[@]+"${pre[@]}"} "$@" ) </dev/null >> "$ARB/tui.out" 2>&1 &
8288
+ AR_PID=$!
8289
+ # Not a job of this shell: the crash case SIGKILLs it, and bash would print a job notice.
8290
+ # bash still reaps it (waitpid(-1) on SIGCHLD); ar_reap kills it by pid.
8291
+ disown "$AR_PID" 2>/dev/null || true
8292
+ }
8293
+ ar_reap() { # ar_reap <pool>: stop every client and watcher the case started, then prove it
8294
+ local p n=0
8295
+ : > "$1/autoresume.off" 2>/dev/null || true # running watchers leave on their next tick
8296
+ if [ -f "$ARB/pids" ]; then
8297
+ while read -r p; do kill -TERM "$p" 2>/dev/null; done < "$ARB/pids"
8298
+ fi
8299
+ while ! ar_no_watcher "$1" && [ "$n" -lt 50 ]; do sleep 0.1; n=$((n + 1)); done
8300
+ pkill -KILL -f "$1/tmp/autoresume/" 2>/dev/null || true
8301
+ if [ -f "$ARB/pids" ]; then
8302
+ while read -r p; do kill -KILL "$p" 2>/dev/null; done < "$ARB/pids"
8303
+ fi
8304
+ [ -n "${AR_PID:-}" ] && kill -KILL "$AR_PID" 2>/dev/null
8305
+ AR_PID=""
8306
+ }
8307
+ AR_SID=5f0c1d2e-3a4b-4c5d-8e9f-a0b1c2d3e4f5 # the fake claude's default session id
8308
+ AR_CXSID=6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d # the fake codex's
8309
+ ar_typed_ok() { # ar_typed_ok <CLAUDE|CODEX> <sid> <ROOTVAR=root or ""> <shim>: exactly one line, exactly this
8310
+ local line tok
8311
+ [ -f "$ARB/tmux/typed" ] && [ "$(($(wc -l < "$ARB/tmux/typed")))" = 1 ] || return 1
8312
+ line="$(cat "$ARB/tmux/typed")"
8313
+ tok="${line#" $1_MULTIACC_AR="}"; tok="${tok%%:*}"
8314
+ printf '%s\n' "$tok" | grep -Eqx '[A-Za-z0-9]{16,64}' || return 1
8315
+ [ "$line" = " $1_MULTIACC_AR=$tok:$2 ${3:+$3 }$4" ]
8316
+ }
8317
+ ar_pty() { # ar_pty <both|stdin> <cmd...>: stdin on a pty; stdout on that pty too, or on a pipe
8318
+ python3 -c '
8319
+ import pty, subprocess, sys
8320
+ m, s = pty.openpty()
8321
+ r = subprocess.run(sys.argv[2:], stdin=s, stdout=s if sys.argv[1] == "both" else subprocess.PIPE)
8322
+ if r.stdout:
8323
+ sys.stdout.buffer.write(r.stdout)
8324
+ sys.exit(r.returncode)' "$@"
8325
+ }
8326
+ ar_plant() { # ar_plant <pool> <token> <provider> <class> <left acct> -- <argv...>: a watcher's relaunch files
8327
+ local pool="$1" tok="$2" prov="$3" cls="$4" acct="$5" sid="$AR_SID"
8328
+ shift 5; [ "${1:-}" = "--" ] && shift
8329
+ [ "$prov" = codex ] && sid="$AR_CXSID"
8330
+ mkdir -p "$pool/tmp/autoresume"
8331
+ printf '%s\n' v=1 "provider=$prov" "class=$cls" "acct=$acct" "sid=$sid" "cwd=$ARW" depth=1 chain=PlantTst \
8332
+ > "$pool/tmp/autoresume/r-$tok.relaunch"
8333
+ printf '%s\0' "$@" > "$pool/tmp/autoresume/r-$tok.argv"
8334
+ }
8335
+ # A typed relaunch whose token cannot be honoured names the session and exits 2: the line
8336
+ # has no argv of its own, so running on would open a FRESH session in the stopped one's place.
8337
+ ar_dead() { # ar_dead <claude|codex> <name> <expected output> [VAR=val ...]
8338
+ local prov="$1" name="$2" want="$3" out rc
8339
+ shift 3
8340
+ if [ "$prov" = claude ]; then
8341
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" "$@" claude </dev/null 2>&1)"; rc=$?
8342
+ else
8343
+ out="$(cd "$ARH" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" "$@" codex </dev/null 2>&1)"; rc=$?
8344
+ fi
8345
+ [ "$rc" = 2 ] && [ "$out" = "$want" ] \
8346
+ && t_ok "AR dead token ($prov): $name -> the resume hint, exit 2, nothing started" \
8347
+ || t_fail "AR dead token ($prov): $name" "rc=$rc out=$out"
8348
+ }
8349
+ AR_DEAD_MSG="auto-resume could not continue automatically (the resume token expired)."
8350
+
8351
+ # ---- AR1. claude: fixture pool, environment, and the gate --------------------------------
8352
+ ARC="$WORK/ar-claude"
8353
+ ar_claude_pool() { # a fresh two-account pool whose accounts share one projects tree
8354
+ local i
8355
+ rm -rf "$ARC" "$ARB/tmux" "$ARB/pids" "$ARB/tui.log" "$ARB/stub.log"
8356
+ mkdir -p "$ARC/tmp" "$ARC/shared-projects" "$ARB/tmux"
8357
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s]}\n' \
8358
+ '{"id": "acct-01", "email": "a@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8359
+ '{"id": "acct-02", "email": "b@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8360
+ > "$ARC/accounts.json"
8361
+ for i in 01 02; do
8362
+ mkdir -p "$ARC/acct-$i"
8363
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-ar%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' "$i" \
8364
+ > "$ARC/acct-$i/.credentials.json"
8365
+ printf '{"fetched_at":%s,"weekly_percent":20,"session_percent":10,"max_percent":20,"buckets":[]}' "$(date +%s)" \
8366
+ > "$ARC/acct-$i/limits.json"
8367
+ ln -s "$ARC/shared-projects" "$ARC/acct-$i/projects"
8368
+ done
8369
+ : > "$ARC/.limits-kick"
8370
+ }
8371
+ AREC=(CLAUDE_ACCOUNTS_DIR="$ARC" HOME="$ARH" TMUX="$ARB/tmux-sock,4242,0" TMUX_PANE=%1
8372
+ CLAUDE_MULTIACC_AUTORESUME=1 CLAUDE_MULTIACC_PYTHON="$ARPY" CLAUDE_MULTIACC_AR_TEST_TTY=1
8373
+ CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE=1 CLAUDE_MULTIACC_AR_POLL=0.1 CLAUDE_MULTIACC_AR_GRACE=0.5
8374
+ CLAUDE_MULTIACC_AR_TERM_GRACE=3 CLAUDE_MULTIACC_AR_PANE_WAIT=5 CLAUDE_MULTIACC_AR_PROBE_TIMEOUT=15
8375
+ CLAUDE_MULTIACC_AR_CRASH_MIN_RUNTIME=0 CLAUDE_MULTIACC_AUTORESUME_DEBUG=1
8376
+ FAKE_TMUX_DIR="$ARB/tmux" FAKE_TUI_LOG="$ARB/tui.log" FAKE_TUI_PIDS="$ARB/pids")
8377
+
8378
+ # The gate: only the plain interactive exec in tmux, with an argv the relaunch can rebuild,
8379
+ # gets a watcher; everything else is exactly today's exec (same argv, no state file).
8380
+ ar_claude_pool
8381
+ ar_gate() { # ar_gate <name> <watcher|none> [VAR=val ...] -- <claude args...>
8382
+ local name="$1" want="$2" pre=() out states
8383
+ shift 2
8384
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8385
+ [ $# -gt 0 ] && shift
8386
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8387
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8388
+ FAKE_STUB_PY_LOG="$ARB/stub.log" ${pre[@]+"${pre[@]}"} claude "$@" </dev/null 2>&1)"
8389
+ states="$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8390
+ case "$out" in
8391
+ *"ARGS=$*") ;;
8392
+ *) t_fail "auto-resume gate: $name execs the argv unchanged" "$out"; return ;;
8393
+ esac
8394
+ if [ "$want" = watcher ]; then
8395
+ if [ "$states" = 1 ] && ar_wait 5 grep -qs -- "-I .*lib/autoresume.py watch --state $ARC/tmp/autoresume/[0-9]*\.state" "$ARB/stub.log"; then
8396
+ t_ok "auto-resume gate: $name gets a watcher, exec unchanged"
8397
+ else
8398
+ t_fail "auto-resume gate: $name gets a watcher" "states=$states stub=$(cat "$ARB/stub.log" 2>/dev/null)"
8399
+ fi
8400
+ else
8401
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8402
+ && t_ok "auto-resume gate: $name gets no watcher, exec unchanged" \
8403
+ || t_fail "auto-resume gate: $name gets no watcher" "states=$states"
8404
+ fi
8405
+ }
8406
+ ar_gate "a prompt with the permission flag" watcher -- --dangerously-skip-permissions "fix the bug please"
8407
+ ar_gate "--resume <uuid> with a model" watcher -- --model opus --resume "$AR_SID"
8408
+ ar_gate "-p" none -- -p "hi there"
8409
+ ar_gate "a pinned run" none CLAUDE_ACCOUNT=acct-01 -- --dangerously-skip-permissions
8410
+ ar_gate "outside tmux" none TMUX= -- --dangerously-skip-permissions
8411
+ ar_gate "piped stdio" none CLAUDE_MULTIACC_AR_TEST_TTY= -- --dangerously-skip-permissions
8412
+ ar_gate "an unknown flag" none -- --fork-session --dangerously-skip-permissions
8413
+ ar_gate "a single-word positional (maybe a subcommand)" none -- doctor
8414
+ ar_gate "CLAUDE_MULTIACC_AUTORESUME=0" none CLAUDE_MULTIACC_AUTORESUME=0 -- --dangerously-skip-permissions
8415
+ : > "$ARC/autoresume.off"
8416
+ ar_gate "the pool's autoresume.off" none -- --dangerously-skip-permissions
8417
+ rm -f "$ARC/autoresume.off"
8418
+ # the watcher's argv is the ORIGINAL one, before the fallback-model pin adds --model
8419
+ ar_gate "no argv at all" watcher --
8420
+ af="$(ls "$ARC/tmp/autoresume/"*.argv 2>/dev/null | head -1)"
8421
+ [ -n "$af" ] && [ ! -s "$af" ] \
8422
+ && t_ok "auto-resume: a bare launch records an empty original argv" \
8423
+ || t_fail "auto-resume: a bare launch records an empty original argv" "$af"
8424
+ # ...and it stays empty when the pin DOES fire: every account's Fable bucket is spent, so
8425
+ # the exec carries --model, but the watcher must rebuild from what the user typed (the
8426
+ # relaunch re-runs selection and pins again for the NEW pick).
8427
+ for i in 01 02; do
8428
+ printf '%s\nbucket=weekly_scoped:Fable percent=100 marked_at=x reason=limits\n' "$(( $(date +%s) + 3600 ))" \
8429
+ > "$ARC/acct-$i/.limited"
8430
+ printf '{"fetched_at":%s,"max_percent":100,"weekly_percent":20,"session_percent":10,"buckets":[{"name":"session","percent":10},{"name":"weekly_all","percent":20},{"name":"weekly_scoped:Fable","percent":100}]}' \
8431
+ "$(date +%s)" > "$ARC/acct-$i/limits.json"
8432
+ done
8433
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8434
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8435
+ claude </dev/null 2>&1)"
8436
+ af="$(ls "$ARC/tmp/autoresume/"*.argv 2>/dev/null | head -1)"
8437
+ case "$out" in
8438
+ *"ARGS=--model claude-opus-5")
8439
+ [ -n "$af" ] && [ -f "$af" ] && [ ! -s "$af" ] \
8440
+ && t_ok "auto-resume: the watcher gets the argv from BEFORE the fallback-model pin" \
8441
+ || t_fail "auto-resume: the .argv file carries the pin" "$(tr '\0' ' ' < "$af" 2>/dev/null)" ;;
8442
+ *) t_fail "auto-resume: the fallback-model pin fired" "$out" ;;
8443
+ esac
8444
+ ar_claude_pool
8445
+ ar_gate "an invalid pin (random fallback)" none CLAUDE_ACCOUNT=acct-99 -- --dangerously-skip-permissions
8446
+ # The relaunch line is typed into the shell unquoted: a pool root or a shim path that would
8447
+ # need quoting gets no watcher, and the launch itself is unchanged.
8448
+ ln -s "$ARC" "$WORK/ar claude root"
8449
+ ar_gate "a pool root that would need quoting" none CLAUDE_ACCOUNTS_DIR="$WORK/ar claude root" -- --dangerously-skip-permissions
8450
+ mkdir -p "$WORK/ar shim copy"
8451
+ cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$WORK/ar shim copy/"
8452
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8453
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8454
+ "$WORK/ar shim copy/bin/claude" --dangerously-skip-permissions </dev/null 2>&1)"
8455
+ case "$out" in
8456
+ *"ARGS=--dangerously-skip-permissions")
8457
+ [ -z "$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep '\.state$')" ] && [ ! -s "$ARB/stub.log" ] \
8458
+ && t_ok "auto-resume gate: a shim path that would need quoting gets no watcher, exec unchanged" \
8459
+ || t_fail "auto-resume gate: a shim path that would need quoting gets no watcher" "$(ls "$ARC/tmp/autoresume/" 2>/dev/null)" ;;
8460
+ *) t_fail "auto-resume gate: a shim path that would need quoting execs the argv unchanged" "$out" ;;
8461
+ esac
8462
+ # stdin on a terminal but stdout piped (`claude "x y" | tee log`): no watcher. The same
8463
+ # launch with stdout on the terminal too does get one, so the pipe is what decided it.
8464
+ for mode in stdin both; do
8465
+ rm -rf "$ARC/tmp/autoresume" "$ARB/stub.log"
8466
+ out="$(cd "$ARW" && ar_pty "$mode" env "${AREC[@]}" CLAUDE_MULTIACC_AR_TEST_TTY= \
8467
+ CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8468
+ claude --dangerously-skip-permissions 2>&1)"
8469
+ states="$(ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8470
+ if [ "$mode" = stdin ]; then
8471
+ case "$out" in *"ARGS=--dangerously-skip-permissions") ;; *) states="exec:$out" ;; esac
8472
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8473
+ && t_ok "auto-resume gate: a terminal on stdin with stdout piped gets no watcher, exec unchanged" \
8474
+ || t_fail "auto-resume gate: stdout piped gets no watcher" "states=$states"
8475
+ else
8476
+ [ "$states" = 1 ] && t_ok "auto-resume gate: stdin and stdout on a terminal get a watcher (control)" \
8477
+ || t_fail "auto-resume gate: both on a terminal get a watcher" "states=$states"
8478
+ fi
8479
+ done
8480
+ # The client never sees an auto-resume variable (a nested claude must not inherit a probe).
8481
+ rm -rf "$ARC/tmp/autoresume"
8482
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8483
+ CLAUDE_MULTIACC_AR_AVOID=acct-01:9999999999 FAKE_PRINT_ENV=1 \
8484
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8485
+ check "auto-resume: the client never sees an auto-resume variable" "AR_ENV=0" "$out"
8486
+ st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
8487
+ grep -q "^pid=[0-9]" "$st" 2>/dev/null && grep -q "^tmux=$ARB/tmux-sock,4242,0\$" "$st" \
8488
+ && grep -q '^pane=%1$' "$st" && grep -q "^acc_root=$ARC\$" "$st" && grep -q '^depth=0$' "$st" \
8489
+ && t_ok "auto-resume: the state file names the pid, pane, pool and chain depth" \
8490
+ || t_fail "auto-resume state file" "$(cat "$st" 2>/dev/null)"
8491
+
8492
+ # ---- AR2. claude probe mode: the real candidate loop, never an exec ----------------------
8493
+ ar_claude_pool
8494
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 claude --dangerously-skip-permissions </dev/null 2>&1)"; rc=$?
8495
+ case "$rc:$out" in
8496
+ 0:pick=acct-0[12]" tier=eligible") t_ok "probe prints pick/tier and exits 0" ;;
8497
+ *) t_fail "probe prints pick/tier" "rc=$rc out=$out" ;;
8498
+ esac
8499
+ case "$out" in *CFG=*) t_fail "probe never execs the client" "$out" ;; *) t_ok "probe never execs the client" ;; esac
8500
+ all2=1
8501
+ for _ in 1 2 3 4 5; do
8502
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
8503
+ CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) - 5 ))" \
8504
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8505
+ [ "$out" = "pick=acct-02 tier=eligible" ] || all2=0
8506
+ done
8507
+ [ "$all2" = 1 ] && t_ok "probe honours AVOID (an expired entry is ignored)" \
8508
+ || t_fail "probe AVOID" "$out"
8509
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 \
8510
+ CLAUDE_MULTIACC_AR_AVOID="acct-01:$(( $(date +%s) + 3600 )),acct-02:$(( $(date +%s) + 3600 ))" \
8511
+ claude --dangerously-skip-permissions </dev/null 2>&1)"
8512
+ case "$out" in "pick=acct-0"[12]" tier=soft") t_ok "probe with every account avoided answers tier=soft" ;;
8513
+ *) t_fail "probe tier=soft" "$out" ;; esac
8514
+ ar_logged "$ARC" 'all-limited fallback' \
8515
+ && t_fail "a probe logs no fallback pick" "$(grep 'all-limited' "$ARC/selection.log")" \
8516
+ || t_ok "a probe logs no fallback pick"
8517
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 CLAUDE_MULTIACC_DISABLE=1 claude </dev/null 2>&1)"; rc=$?
8518
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "probe on a passthrough answers tier=none (exit 3)" \
8519
+ || t_fail "probe passthrough" "rc=$rc out=$out"
8520
+ [ ! -f "$ARC/.last-pick" ] && t_ok "a probe does not count as a pick (no rotation memory)" \
8521
+ || t_fail "a probe does not count as a pick" "$(cat "$ARC/.last-pick")"
8522
+ # No valid login anywhere: a launch would fall back to the stock client — a probe must not.
8523
+ rm -f "$ARC"/acct-0*/.credentials.json
8524
+ out="$(cd "$ARW" && env "${AREC[@]}" CLAUDE_MULTIACC_AR_PROBE=1 claude --dangerously-skip-permissions </dev/null 2>&1)"; rc=$?
8525
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "probe on a pool with no valid login answers tier=none (exit 3), never execs" \
8526
+ || t_fail "probe with no valid login" "rc=$rc out=$out"
8527
+
8528
+ # ---- AR3. claude quota: stop, relaunch on the other account, same session ----------------
8529
+ # No ANYPANE here: the pane's pid is this shell, which IS the launching shim's parent. The
8530
+ # transcript scan (12f) is off, so the old account's marker can only come from the relaunch.
8531
+ ar_claude_pool
8532
+ ar_start FAKE_TUI=quota CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE= FAKE_TMUX_PANE_PID=$$ \
8533
+ CLAUDE_MULTIACC_CLIENT_LIMITS=0 -- claude --dangerously-skip-permissions "fix the bug please"
8534
+ first=$AR_PID
8535
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8536
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8537
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8538
+ check "AR quota: the first launch ran the user's argv" \
8539
+ "ARGS=--dangerously-skip-permissions fix the bug please" "$l1"
8540
+ [ -n "$from" ] && [ -n "$to" ] && [ "$from" != "$to" ] \
8541
+ && t_ok "AR quota: relaunched on the OTHER account ($from -> $to)" \
8542
+ || t_fail "AR quota: relaunched on the other account" "$l1 | $l2"
8543
+ check "AR quota: the relaunch resumes the same session with the auto-resume prompt" \
8544
+ "ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account hit its usage limit." "$l2"
8545
+ case "$l2" in *"fix the bug please"*) t_fail "AR quota: the original prompt is not replayed" "$l2" ;;
8546
+ *) t_ok "AR quota: the original prompt is not replayed" ;; esac
8547
+ ar_wait 5 ar_stopped "$first" && t_ok "AR quota: the stuck client was stopped" \
8548
+ || t_fail "AR quota: the stuck client was stopped" "pid $first alive"
8549
+ grep -q 'bucket=client:five_hour percent=100' "$ARC/$from/.limited" 2>/dev/null \
8550
+ && t_ok "AR quota: the old account carries a client:five_hour marker" \
8551
+ || t_fail "AR quota: marker on the old account" "$(cat "$ARC/$from/.limited" 2>/dev/null)"
8552
+ ar_logged "$ARC" "autoresume switch provider=claude chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_SID depth=1 probe=$to" \
8553
+ && t_ok "AR quota: the watcher logged the switch (field 2 = autoresume)" \
8554
+ || t_fail "AR quota: switch log line" "$(grep autoresume "$ARC/selection.log")"
8555
+ chain="$(sed -n 's/.*autoresume watch provider=claude chain=\([A-Za-z0-9]*\) .*/\1/p' "$ARC/selection.log" | head -1)"
8556
+ ar_logged "$ARC" "autoresume relaunch chain=$chain from=$from class=quota depth=1\$" \
8557
+ && t_ok "AR quota: the relaunched shim consumed the token and kept the chain" \
8558
+ || t_fail "AR quota: relaunch log line" "$(grep autoresume "$ARC/selection.log")"
8559
+ ar_typed_ok CLAUDE "$AR_SID" "CLAUDE_ACCOUNTS_ROOT=$(cd "$ARC" && pwd)" "$REPO_DIR/bin/claude" \
8560
+ && t_ok "AR quota: exactly one token line was typed: token:sid, the pool root, the shim's path" \
8561
+ || t_fail "AR quota: typed line" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8562
+ seq_ok="$(grep -E 'display-message|send-keys' "$ARB/tmux/calls" | sed 's/ -l .*/ -l <line>/' | tr '\n' '|')"
8563
+ case "$seq_ok" in
8564
+ *"-S $ARB/tmux-sock display-message -p -t %1 #{pane_pid}"*"-S $ARB/tmux-sock send-keys -R -t %1|-S $ARB/tmux-sock send-keys -t %1 C-u|-S $ARB/tmux-sock send-keys -t %1 -l <line>|-S $ARB/tmux-sock send-keys -t %1 Enter|")
8565
+ t_ok "AR quota: pane check, then -R, C-u, the line, Enter — on the launching pane" ;;
8566
+ *) t_fail "AR quota: tmux sequence" "$seq_ok" ;;
8567
+ esac
8568
+ ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8569
+ && t_fail "AR quota: the relaunch token is single use" "$(ls "$ARC/tmp/autoresume/")" \
8570
+ || t_ok "AR quota: the relaunch token is single use (files consumed)"
8571
+ ar_wait 10 ar_logged "$ARC" "autoresume watch provider=claude chain=$chain pid=[0-9]+ acct=$to depth=1\$" \
8572
+ && t_ok "AR quota: the relaunched session is supervised again (same chain, depth 1)" \
8573
+ || t_fail "AR quota: second watcher" "$(grep autoresume "$ARC/selection.log")"
8574
+ else
8575
+ t_fail "AR quota: relaunched within 30 s" "tui=$(cat "$ARB/tui.log" 2>/dev/null) log=$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8576
+ fi
8577
+ ar_reap "$ARC"
8578
+ ar_no_watcher "$ARC" && t_ok "AR quota: no watcher left running" || t_fail "AR quota: watcher left running"
8579
+
8580
+ # ---- AR4. claude: a pane that is not the launching shell is never typed into -----------
8581
+ ar_claude_pool
8582
+ ar_start FAKE_TUI=quota CLAUDE_MULTIACC_AR_TEST_TMUX_ANYPANE= FAKE_TMUX_PANE_PID=1 \
8583
+ -- claude --dangerously-skip-permissions "fix the bug please"
8584
+ if ar_wait 20 ar_logged "$ARC" 'autoresume giveup provider=claude .*reason=pane'; then
8585
+ sleep 0.3
8586
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8587
+ && ! ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8588
+ && t_ok "AR pane: another pane's process -> give up before stopping anything" \
8589
+ || t_fail "AR pane: nothing stopped, nothing typed" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8590
+ else
8591
+ t_fail "AR pane: giveup logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8592
+ fi
8593
+ ar_reap "$ARC"
8594
+
8595
+ # ---- AR5. claude auth: the old login is parked, the session moves ------------------------
8596
+ ar_claude_pool
8597
+ ar_start FAKE_TUI=auth -- claude --dangerously-skip-permissions "fix the bug please"
8598
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8599
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8600
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8601
+ [ -n "$from" ] && [ "$from" != "$to" ] && [ -f "$ARC/$from/.expired" ] \
8602
+ && t_ok "AR auth: the old account is parked (.expired) and the session moved ($from -> $to)" \
8603
+ || t_fail "AR auth: park + rotate" "$l1 | $l2 | $(ls -a "$ARC/$from" 2>/dev/null | tr '\n' ' ')"
8604
+ check "AR auth: resumed with the login reason" \
8605
+ "--resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account's login failed." "$l2"
8606
+ else
8607
+ t_fail "AR auth: relaunched within 30 s" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8608
+ fi
8609
+ ar_reap "$ARC"
8610
+
8611
+ # ---- AR6. claude crash: the registry left behind means a crash; relaunch in place -------
8612
+ ar_claude_pool
8613
+ ar_start FAKE_TUI=crash -- claude --dangerously-skip-permissions "fix the bug please"
8614
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8615
+ l2="$(sed -n 2p "$ARB/tui.log")"
8616
+ check "AR crash: resumed with the crash reason, no 'another account'" \
8617
+ "--resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically because the previous process exited unexpectedly." "$l2"
8618
+ ar_logged "$ARC" 'autoresume crash provider=claude .* runtime=[0-9]+' \
8619
+ && ar_logged "$ARC" 'autoresume relaunch chain=[A-Za-z0-9]+ from=acct-0[12] class=crash depth=1$' \
8620
+ && t_ok "AR crash: logged as a crash and relaunched (no marker class)" \
8621
+ || t_fail "AR crash: log lines" "$(grep autoresume "$ARC/selection.log")"
8622
+ ls "$ARC"/acct-0*/.limited >/dev/null 2>&1 \
8623
+ && t_fail "AR crash: no account is marked" "$(ls "$ARC"/acct-0*/.limited)" \
8624
+ || t_ok "AR crash: no account is marked"
8625
+ else
8626
+ t_fail "AR crash: relaunched within 30 s" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8627
+ fi
8628
+ ar_reap "$ARC"
8629
+
8630
+ # ---- AR7. claude: errors auto-resume must NOT act on ---------------------------------------
8631
+ # invalid_request: another account would fail the same way — logged once, left alone.
8632
+ ar_claude_pool
8633
+ ar_start FAKE_TUI=invalid -- claude --dangerously-skip-permissions "fix the bug please"
8634
+ if ar_wait 15 ar_logged "$ARC" 'autoresume never provider=claude .*code=invalid_request'; then
8635
+ sleep 1.5
8636
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8637
+ && t_ok "AR never: invalid_request is logged and the session left alone" \
8638
+ || t_fail "AR never: the session was stopped"
8639
+ else
8640
+ t_fail "AR never: logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8641
+ fi
8642
+ ar_reap "$ARC"
8643
+ # An OLD rejection in a resumed transcript never fires: only records from this launch count.
8644
+ ar_claude_pool
8645
+ mkdir -p "$ARC/shared-projects/-fake-tui"
8646
+ printf '{"type":"assistant","isApiErrorMessage":true,"error":"rate_limit","quotaLimits":{"status":"rejected","resetsAt":%s,"rateLimitType":"five_hour"},"timestamp":"2026-01-01T00:00:00.000Z","sessionId":"%s","message":{"content":[{"type":"text","text":"limit"}]}}\n' \
8647
+ "$(( $(date +%s) + 3600 ))" "$AR_SID" > "$ARC/shared-projects/-fake-tui/$AR_SID.jsonl"
8648
+ ar_start FAKE_TUI=idle FAKE_TUI_QUIET=1 -- claude --dangerously-skip-permissions --resume "$AR_SID" "carry on please"
8649
+ if ar_wait 15 grep -qs "transcript .*$AR_SID.jsonl" "$ARC/tmp/autoresume/$AR_PID.log"; then
8650
+ sleep 1.5
8651
+ ar_logged "$ARC" 'autoresume (detect|switch)' || ! ar_running "$AR_PID" \
8652
+ && t_fail "AR resumed transcript: an old rejection fired" "$(grep autoresume "$ARC/selection.log")" \
8653
+ || t_ok "AR resumed transcript: an old rejection does not fire"
8654
+ else
8655
+ t_fail "AR resumed transcript: watcher followed the transcript" "$(cat "$ARC/tmp/autoresume/$AR_PID.log" 2>/dev/null)"
8656
+ fi
8657
+ ar_reap "$ARC"
8658
+
8659
+ # ---- AR8. claude: no account to move to -> HOLD, nothing is stopped ----------------------
8660
+ ar_claude_pool
8661
+ # acct-02 is out for the week (a weekly client marker outlives every telemetry pass).
8662
+ printf '%s\nbucket=client:seven_day percent=100 marked_at=%s reason=client-rate-limit\n' \
8663
+ "$(( $(date +%s) + 86400 ))" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$ARC/acct-02/.limited"
8664
+ ar_start FAKE_TUI=quota -- claude --dangerously-skip-permissions "fix the bug please"
8665
+ if ar_wait 20 ar_logged "$ARC" 'autoresume hold provider=claude .*class=quota .*reason=no-room'; then
8666
+ sleep 1
8667
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] \
8668
+ && ! ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-' \
8669
+ && t_ok "AR hold: no eligible account -> hold, no kill, nothing typed" \
8670
+ || t_fail "AR hold: the session was stopped"
8671
+ grep -q 'CFG=acct-01' "$ARB/tui.log" && t_ok "AR hold: the first launch took the only eligible account" \
8672
+ || t_fail "AR hold: first pick" "$(cat "$ARB/tui.log")"
8673
+ else
8674
+ t_fail "AR hold: logged" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8675
+ fi
8676
+ ar_reap "$ARC"
8677
+
8678
+ # ---- AR9. claude: autoresume.off stops a RUNNING watcher ---------------------------------
8679
+ ar_claude_pool
8680
+ ar_start FAKE_TUI=quota FAKE_TUI_DELAY=2.5 -- claude --dangerously-skip-permissions "fix the bug please"
8681
+ if ar_wait 15 ar_logged "$ARC" 'autoresume watch provider=claude'; then
8682
+ : > "$ARC/autoresume.off"
8683
+ ar_wait 5 ar_no_watcher "$ARC" && t_ok "AR off: the running watcher exits on autoresume.off" \
8684
+ || t_fail "AR off: the running watcher exits on autoresume.off"
8685
+ ar_wait 10 grep -qs '"rate_limit"' "$ARC/shared-projects/-fake-tui/$AR_SID.jsonl"
8686
+ sleep 1.2
8687
+ ar_running "$AR_PID" && [ ! -s "$ARB/tmux/typed" ] && ! ar_logged "$ARC" 'autoresume (detect|switch)' \
8688
+ && t_ok "AR off: the limit after the kill switch stops nothing" \
8689
+ || t_fail "AR off: the session was stopped"
8690
+ else
8691
+ t_fail "AR off: watcher started" "$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
8692
+ fi
8693
+ ar_reap "$ARC"
8694
+
8695
+ # ---- AR11. a relaunch re-entering through the token preflight keeps its chain -----------
8696
+ # The relaunch left acct-01 on a MODEL limit (no marker: the chain's AVOID list is all that
8697
+ # keeps the session off it). acct-02 leads on headroom but its portable token is revoked,
8698
+ # so the preflight parks it and re-enters the shim with `exec "$SELF"` — the token is spent
8699
+ # by then, so the chain rides along in the environment. Without it the re-entered selection
8700
+ # takes acct-01 (the best weekly left) and the next watcher restarts at depth 0.
8701
+ ar_claude_pool
8702
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s, %s]}\n' \
8703
+ '{"id": "acct-01", "email": "a@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8704
+ '{"id": "acct-02", "email": "b@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8705
+ '{"id": "acct-03", "email": "c@ar", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8706
+ > "$ARC/accounts.json"
8707
+ mkdir -p "$ARC/acct-03"
8708
+ cp "$ARC/acct-01/.credentials.json" "$ARC/acct-03/.credentials.json"
8709
+ ln -s "$ARC/shared-projects" "$ARC/acct-03/projects"
8710
+ rm -f "$ARC/acct-02/.credentials.json"
8711
+ printf 'sk-ant-oat01-REVOKED-carry\n' > "$ARC/acct-02/server.token"
8712
+ nowc="$(date +%s)"
8713
+ for i in 01:5 02:10 03:20; do
8714
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8715
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8716
+ done
8717
+ mkdir -p "$ARC/tmp/autoresume"
8718
+ tok=CarryTok0123456789
8719
+ printf '%s\n' v=1 provider=claude class=model acct=acct-01 reset= rtype= marked= "sid=$AR_SID" \
8720
+ "cwd=$ARW" depth=2 "avoid=acct-01:$((nowc + 18000))" "hist=model:$((nowc - 100))" chain=CarryTst \
8721
+ > "$ARC/tmp/autoresume/r-$tok.relaunch"
8722
+ printf '%s\0' --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please" \
8723
+ > "$ARC/tmp/autoresume/r-$tok.argv"
8724
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
8725
+ CLAUDE_MULTIACC_HEADROOM_BAND=0 CLAUDE_MULTIACC_AR="$tok" claude </dev/null 2>&1)"
8726
+ check "AR carry: the re-entered selection still skips the account the chain left" \
8727
+ "CFG=acct-03 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
8728
+ ar_logged "$ARC" 'acct-02 parked \(setup-token-invalid\)' \
8729
+ && t_ok "AR carry: the revoked token was parked by the preflight (the re-entry happened)" \
8730
+ || t_fail "AR carry: preflight park" "$(cat "$ARC/selection.log")"
8731
+ st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
8732
+ grep -q '^depth=2$' "$st" 2>/dev/null && grep -q '^chain=CarryTst$' "$st" \
8733
+ && grep -q "^avoid=acct-01:$((nowc + 18000))\$" "$st" && grep -q '^cwd=/.*/ar-work dir$' "$st" \
8734
+ && t_ok "AR carry: the next watcher inherits depth, chain, AVOID and the session's cwd" \
8735
+ || t_fail "AR carry: state file" "$(cat "$st" 2>/dev/null)"
8736
+ [ "$(grep -c 'autoresume relaunch chain=CarryTst from=acct-01 class=model depth=2$' "$ARC/selection.log")" = 1 ] \
8737
+ && t_ok "AR carry: the relaunch is logged once, not again by the re-entry" \
8738
+ || t_fail "AR carry: relaunch log" "$(grep autoresume "$ARC/selection.log")"
8739
+ ar_reap "$ARC"
8740
+
8741
+ # ---- AR12. claude relaunch tokens: the auth park, dead tokens, the trust mirror ------------
8742
+ # Planted directly (no watcher, no TUI), so each assertion can only come from the relaunch
8743
+ # entry itself. acct-01 leads on headroom and HEADROOM_BAND=0 makes the lead decisive.
8744
+ ar_claude_pool
8745
+ nowc="$(date +%s)"
8746
+ for i in 01:5 02:20; do
8747
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8748
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8749
+ done
8750
+ # class=auth: no auth record in any transcript, so only the relaunch can park acct-01.
8751
+ ar_plant "$ARC" AuthTok0123456789 claude auth acct-01 -- \
8752
+ --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
8753
+ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
8754
+ CLAUDE_MULTIACC_AR="AuthTok0123456789:$AR_SID" claude </dev/null 2>&1)"
8755
+ [ -f "$ARC/acct-01/.expired" ] && ar_logged "$ARC" 'acct-01 parked \(' \
8756
+ && t_ok "AR auth token: the relaunch parks the account it left (.expired)" \
8757
+ || t_fail "AR auth token: park" "$(ls -a "$ARC/acct-01" | tr '\n' ' ')"
8758
+ check "AR auth token: ...and the run lands on the other account, resuming the session" \
8759
+ "CFG=acct-02 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
8760
+ # Dead tokens: missing, expired (older than 10 min), malformed, a FIFO in its place.
8761
+ ar_dead claude "a missing relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8762
+ CLAUDE_MULTIACC_AR="MissingTok0123456:$AR_SID"
8763
+ ar_plant "$ARC" ExpiredTok0123456 claude quota acct-01 -- --resume "$AR_SID" "(claude-multiacc auto-resume) x y"
8764
+ touch -t 202601010000 "$ARC/tmp/autoresume/r-ExpiredTok0123456.relaunch"
8765
+ ar_dead claude "an expired relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8766
+ CLAUDE_MULTIACC_AR="ExpiredTok0123456:$AR_SID"
8767
+ ls "$ARC/tmp/autoresume/" 2>/dev/null | grep -q '^r-ExpiredTok' \
8768
+ && t_fail "AR dead token (claude): an expired token is consumed" "$(ls "$ARC/tmp/autoresume/")" \
8769
+ || t_ok "AR dead token (claude): an expired token is consumed"
8770
+ ar_dead claude "a malformed token" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
8771
+ CLAUDE_MULTIACC_AR="../bad/tok:$AR_SID"
8772
+ ar_dead claude "an unusable session id (no hint)" "claude-multiacc: $AR_DEAD_MSG" \
8773
+ CLAUDE_MULTIACC_AR='MissingTok0123456:$(id)'
8774
+ mkfifo "$ARC/tmp/autoresume/r-FifoTok0123456789.relaunch"
8775
+ ( cd "$ARH" && exec env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" \
8776
+ CLAUDE_MULTIACC_AR="FifoTok0123456789:$AR_SID" claude ) </dev/null > "$ARB/fifo.out" 2>&1 &
8777
+ fp=$!
8778
+ if ar_wait 10 ar_stopped "$fp"; then
8779
+ wait "$fp"; rc=$?
8780
+ [ "$rc" = 2 ] && ! grep -q 'CFG=' "$ARB/fifo.out" \
8781
+ && t_ok "AR dead token (claude): a FIFO named like a relaunch file never blocks the launch" \
8782
+ || t_fail "AR dead token (claude): FIFO" "rc=$rc $(cat "$ARB/fifo.out")"
8783
+ else
8784
+ kill -KILL "$fp" 2>/dev/null; wait "$fp" 2>/dev/null
8785
+ t_fail "AR dead token (claude): a FIFO named like a relaunch file blocked the launch"
8786
+ fi
8787
+ rm -f "$ARC/tmp/autoresume/r-FifoTok0123456789.relaunch"
8788
+ # Trust: the relaunch marks its cwd trusted in the PICKED account's .claude.json (Claude
8789
+ # shows a blocking trust dialog in a new directory even with --dangerously-skip-permissions),
8790
+ # keeping every other key. The real python runs it; TMUX= keeps the watcher out of the way.
8791
+ # The key is the directory as the relaunch's own `cd` spells it, plus its physical path
8792
+ # when that differs (Claude may key projects by either; macOS TMPDIR is a symlink).
8793
+ ar_claude_pool
8794
+ nowc="$(date +%s)"
8795
+ arw_key="$(cd "$ARW" && pwd)"
8796
+ for i in 01:20 02:5; do
8797
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8798
+ "$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
8799
+ done
8800
+ printf '{"numStartups": 7, "projects": {"%s": {"allowedTools": ["Bash"]}, "/elsewhere": {"hasTrustDialogAccepted": false}}}\n' \
8801
+ "$arw_key" > "$ARC/acct-02/.claude.json"
8802
+ chmod 600 "$ARC/acct-02/.claude.json"
8803
+ printf '{"numStartups": 1}\n' > "$ARC/acct-01/.claude.json"
8804
+ cp "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json"
8805
+ ar_plant "$ARC" TrustTok0123456789 claude crash acct-01 -- \
8806
+ --dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
8807
+ out="$(cd "$ARH" && env "${AREC[@]}" TMUX= CLAUDE_MULTIACC_HEADROOM_BAND=0 \
8808
+ CLAUDE_MULTIACC_AR="TrustTok0123456789:$AR_SID" claude </dev/null 2>&1)"
8809
+ check "AR trust: the relaunch ran on the picked account" "CFG=acct-02 " "$out"
8810
+ if python3 - "$ARC/acct-02/.claude.json" "$arw_key" <<'PYEOF'
8811
+ import json, os, stat, sys
8812
+ path, cwd = sys.argv[1], sys.argv[2]
8813
+ d = json.load(open(path))
8814
+ p = d["projects"]
8815
+ real = os.path.realpath(cwd)
8816
+ ok = (p[cwd] == {"allowedTools": ["Bash"], "hasTrustDialogAccepted": True}
8817
+ and (real == cwd or p.get(real) == {"hasTrustDialogAccepted": True})
8818
+ and len(p) == (2 if real == cwd else 3) and d.get("numStartups") == 7
8819
+ and p["/elsewhere"] == {"hasTrustDialogAccepted": False}
8820
+ and stat.S_IMODE(os.stat(path).st_mode) == 0o600)
8821
+ sys.exit(0 if ok else 1)
8822
+ PYEOF
8823
+ then
8824
+ t_ok "AR trust: projects[cwd].hasTrustDialogAccepted=true, every other key and the file mode kept"
8825
+ else
8826
+ t_fail "AR trust: .claude.json after the relaunch" "$(cat "$ARC/acct-02/.claude.json")"
8827
+ fi
8828
+ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
8829
+ && t_ok "AR trust: the account the session left is not touched" \
8830
+ || t_fail "AR trust: the left account's .claude.json changed" "$(cat "$ARC/acct-01/.claude.json")"
8831
+ # A plain launch (no token) never edits trust.
8832
+ printf '{"numStartups": 7}\n' > "$ARC/acct-02/.claude.json"
8833
+ cp "$ARC/acct-02/.claude.json" "$ARB/acct-02.claude.json"
8834
+ (cd "$ARW" && env "${AREC[@]}" TMUX= claude --dangerously-skip-permissions </dev/null >/dev/null 2>&1)
8835
+ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
8836
+ && cmp -s "$ARC/acct-02/.claude.json" "$ARB/acct-02.claude.json" \
8837
+ && t_ok "AR trust: a launch that is not a relaunch never edits .claude.json" \
8838
+ || t_fail "AR trust: a plain launch edited .claude.json" "$(cat "$ARC"/acct-0*/.claude.json)"
8839
+ ar_reap "$ARC"
8840
+
8841
+ # ---- AR10. codex: the same machinery through bin/codex ------------------------------------
8842
+ ARX="$WORK/ar-codex"
8843
+ ar_codex_pool() { # a fresh two-account codex pool sharing one sessions tree (the installed layout)
8844
+ local i
8845
+ rm -rf "$ARX" "$ARB/tmux" "$ARB/pids" "$ARB/tui.log" "$ARB/stub.log"
8846
+ mkdir -p "$ARX/tmp" "$ARX/shared-sessions" "$ARB/tmux"
8847
+ printf '{"version": 1, "threshold": 90, "accounts": [%s, %s]}\n' \
8848
+ '{"id": "acct-01", "email": "a@arx", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8849
+ '{"id": "acct-02", "email": "b@arx", "home": "mac", "added_at": "2026-09-23T00:00:00Z"}' \
8850
+ > "$ARX/accounts.json"
8851
+ for i in 01 02; do
8852
+ mkdir -p "$ARX/acct-$i"
8853
+ mk_cx_auth "$ARX/acct-$i/auth.json" "$i@arx" "$FUTURE_EXP"
8854
+ printf '{"fetched_at":%s,"weekly_percent":20,"session_percent":10,"max_percent":20,"buckets":[]}' "$(date +%s)" \
8855
+ > "$ARX/acct-$i/limits.json"
8856
+ ln -s "$ARX/shared-sessions" "$ARX/acct-$i/sessions"
8857
+ done
8858
+ : > "$ARX/.limits-kick"
8859
+ }
8860
+ AREX=(CODEX_ACCOUNTS_DIR="$ARX" HOME="$ARH" TMUX="$ARB/tmux-sock,4243,0" TMUX_PANE=%2
8861
+ CODEX_MULTIACC_AUTORESUME=1 CODEX_MULTIACC_PYTHON="$ARPY" CODEX_MULTIACC_AR_TEST_TTY=1
8862
+ CODEX_MULTIACC_AR_TEST_TMUX_ANYPANE=1 CODEX_MULTIACC_AR_POLL=0.1 CODEX_MULTIACC_AR_GRACE=0.5
8863
+ CODEX_MULTIACC_AR_TERM_GRACE=3 CODEX_MULTIACC_AR_PANE_WAIT=5 CODEX_MULTIACC_AR_PROBE_TIMEOUT=15
8864
+ CODEX_MULTIACC_AR_DISCOVER_TIMEOUT=20 CODEX_MULTIACC_AUTORESUME_DEBUG=1
8865
+ FAKE_TMUX_DIR="$ARB/tmux" FAKE_TUI_LOG="$ARB/tui.log" FAKE_TUI_PIDS="$ARB/pids")
8866
+ ar_codex_pool
8867
+ cx_gate() { # cx_gate <name> <watcher|none> [VAR=val ...] -- <codex args...>
8868
+ local name="$1" want="$2" pre=() out states
8869
+ shift 2
8870
+ while [ $# -gt 0 ] && [ "$1" != "--" ]; do pre+=("$1"); shift; done
8871
+ [ $# -gt 0 ] && shift
8872
+ rm -rf "$ARX/tmp/autoresume" "$ARB/stub.log"
8873
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" \
8874
+ FAKE_STUB_PY_LOG="$ARB/stub.log" ${pre[@]+"${pre[@]}"} codex "$@" </dev/null 2>&1)"
8875
+ states="$(ls "$ARX/tmp/autoresume/" 2>/dev/null | grep -c '\.state$')"
8876
+ case "$out" in *CFG=acct-0*) ;; *) t_fail "codex auto-resume gate: $name execs" "$out"; return ;; esac
8877
+ if [ "$want" = watcher ]; then
8878
+ [ "$states" = 1 ] && ar_wait 5 grep -qs -- "-I .*lib/autoresume.py watch --state $ARX/tmp/autoresume/[0-9]*\.state" "$ARB/stub.log" \
8879
+ && t_ok "codex auto-resume gate: $name gets a watcher" \
8880
+ || t_fail "codex auto-resume gate: $name gets a watcher" "states=$states"
8881
+ else
8882
+ [ "$states" = 0 ] && [ ! -s "$ARB/stub.log" ] \
8883
+ && t_ok "codex auto-resume gate: $name gets no watcher" \
8884
+ || t_fail "codex auto-resume gate: $name gets no watcher" "states=$states"
8885
+ fi
8886
+ }
8887
+ cx_gate "a prompt with the bypass flag" watcher -- --dangerously-bypass-approvals-and-sandbox "fix the bug please"
8888
+ cx_gate "resume <uuid>" watcher -- resume "$AR_CXSID"
8889
+ cx_gate "exec" none -- exec "do it now"
8890
+ cx_gate "-p (profile)" none -- -p work
8891
+ cx_gate "-c overrides" none -- -c model=o3
8892
+ cx_gate "a pinned run" none CODEX_ACCOUNT=acct-01 --
8893
+ cx_gate "an invalid pin (random fallback)" none CODEX_ACCOUNT=acct-99 --
8894
+ cx_gate "outside tmux" none TMUX_PANE= --
8895
+ cx_gate "CODEX_MULTIACC_AUTORESUME=off" none CODEX_MULTIACC_AUTORESUME=off --
8896
+ ln -s "$ARX" "$WORK/ar codex root"
8897
+ cx_gate "a pool root that would need quoting" none CODEX_ACCOUNTS_DIR="$WORK/ar codex root" -- --yolo
8898
+ # The client never sees an auto-resume variable (AREX itself exports several).
8899
+ rm -rf "$ARX/tmp/autoresume"
8900
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" FAKE_PRINT_ENV=1 \
8901
+ codex --yolo </dev/null 2>&1)"
8902
+ case "$out" in
8903
+ *"AR_ENV=0"*) [ -n "$(ls "$ARX/tmp/autoresume/" 2>/dev/null | grep '\.state$')" ] \
8904
+ && t_ok "codex auto-resume: the client never sees an auto-resume variable (watcher spawned)" \
8905
+ || t_fail "codex auto-resume: env scrub case spawned no watcher" "$out" ;;
8906
+ *) t_fail "codex auto-resume: the client never sees an auto-resume variable" "$out" ;;
8907
+ esac
8908
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 codex --yolo </dev/null 2>&1)"; rc=$?
8909
+ case "$rc:$out" in 0:pick=acct-0[12]" tier=eligible") t_ok "codex probe prints pick/tier, never execs" ;;
8910
+ *) t_fail "codex probe" "rc=$rc out=$out" ;; esac
8911
+ all1=1
8912
+ for _ in 1 2 3 4 5; do
8913
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 \
8914
+ CODEX_MULTIACC_AR_AVOID="acct-02:$(( $(date +%s) + 3600 ))" codex --yolo </dev/null 2>&1)"
8915
+ [ "$out" = "pick=acct-01 tier=eligible" ] || all1=0
8916
+ done
8917
+ [ "$all1" = 1 ] && t_ok "codex probe honours AVOID (5 of 5)" || t_fail "codex probe AVOID" "$out"
8918
+ # codex relaunch tokens, planted directly: acct-01 leads, so only the relaunch's own park
8919
+ # keeps the run off it (the shared sessions tree turns the rollout scan off).
8920
+ nowx="$(date +%s)"
8921
+ for i in 01:5 02:20; do
8922
+ printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
8923
+ "$nowx" "${i#*:}" "${i#*:}" > "$ARX/acct-${i%%:*}/limits.json"
8924
+ done
8925
+ rm -f "$ARX/.last-pick"
8926
+ ar_plant "$ARX" CxAuthTok01234567 codex auth acct-01 -- \
8927
+ resume --yolo "$AR_CXSID" "(claude-multiacc auto-resume) carry on please"
8928
+ out="$(cd "$ARH" && env "${AREX[@]}" CODEX_MULTIACC_PYTHON="$ARB/stub-python" CODEX_MULTIACC_HEADROOM_BAND=0 \
8929
+ CODEX_MULTIACC_AR="CxAuthTok01234567:$AR_CXSID" codex </dev/null 2>&1)"
8930
+ grep -q 'reason=auth-error soft_until=' "$ARX/acct-01/.expired" 2>/dev/null \
8931
+ && t_ok "AR codex auth token: the relaunch parks the account it left (auth-error, soft)" \
8932
+ || t_fail "AR codex auth token: park" "$(cat "$ARX/acct-01/.expired" 2>/dev/null)"
8933
+ check "AR codex auth token: ...and the run lands on the other account" "CFG=acct-02" "$out"
8934
+ ar_dead codex "a missing relaunch file" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8935
+ CODEX_MULTIACC_AR="MissingTok0123456:$AR_CXSID"
8936
+ ar_plant "$ARX" ExpiredTok0123456 codex quota acct-01 -- resume "$AR_CXSID" "(claude-multiacc auto-resume) x y"
8937
+ touch -t 202601010000 "$ARX/tmp/autoresume/r-ExpiredTok0123456.relaunch"
8938
+ ar_dead codex "an expired relaunch file" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8939
+ CODEX_MULTIACC_AR="ExpiredTok0123456:$AR_CXSID"
8940
+ ls "$ARX/tmp/autoresume/" 2>/dev/null | grep -q '^r-ExpiredTok' \
8941
+ && t_fail "AR dead token (codex): an expired token is consumed" "$(ls "$ARX/tmp/autoresume/")" \
8942
+ || t_ok "AR dead token (codex): an expired token is consumed"
8943
+ ar_dead codex "a malformed token" "codex-multiacc: $AR_DEAD_MSG Resume with: codex resume $AR_CXSID" \
8944
+ CODEX_MULTIACC_AR="../bad/tok:$AR_CXSID"
8945
+ ar_dead codex "an unusable session id (no hint)" "codex-multiacc: $AR_DEAD_MSG" \
8946
+ CODEX_MULTIACC_AR='MissingTok0123456:$(id)'
8947
+ # No valid login anywhere: a launch would fall back to the stock client — a probe must not.
8948
+ ar_codex_pool
8949
+ rm -f "$ARX"/acct-0*/auth.json
8950
+ out="$(cd "$ARW" && env "${AREX[@]}" CODEX_MULTIACC_AR_PROBE=1 codex --yolo </dev/null 2>&1)"; rc=$?
8951
+ [ "$rc:$out" = "3:pick= tier=none" ] && t_ok "codex probe on a pool with no valid login answers tier=none (exit 3), never execs" \
8952
+ || t_fail "codex probe with no valid login" "rc=$rc out=$out"
8953
+
8954
+ ar_codex_pool
8955
+ AREC=("${AREX[@]}") # ar_start launches with AREC
8956
+ ar_start FAKE_TUI=quota -- codex --dangerously-bypass-approvals-and-sandbox "fix the bug please"
8957
+ first=$AR_PID
8958
+ if ar_wait 30 ar_lines 2 "$ARB/tui.log"; then
8959
+ l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
8960
+ from="$(ar_acct_of "$l1")"; to="$(ar_acct_of "$l2")"
8961
+ [ -n "$from" ] && [ -n "$to" ] && [ "$from" != "$to" ] \
8962
+ && t_ok "AR codex quota: relaunched on the OTHER account ($from -> $to)" \
8963
+ || t_fail "AR codex quota: relaunched on the other account" "$l1 | $l2"
8964
+ check "AR codex quota: \`resume <opts> <sid> <prompt>\`" \
8965
+ "ARGS=resume --dangerously-bypass-approvals-and-sandbox $AR_CXSID (claude-multiacc auto-resume) This session was restarted automatically on another account because the previous account hit its usage limit." "$l2"
8966
+ ar_wait 5 ar_stopped "$first" && t_ok "AR codex quota: the stuck client was stopped" \
8967
+ || t_fail "AR codex quota: the stuck client was stopped" "pid $first alive"
8968
+ m1="$(sed -n 1p "$ARX/$from/.limited" 2>/dev/null)"; m2="$(sed -n 2p "$ARX/$from/.limited" 2>/dev/null)"
8969
+ nowx="$(date +%s)"
8970
+ case "$m1" in ''|*[!0-9]*) m1=0 ;; esac
8971
+ [ "$m1" -gt "$((nowx + 500))" ] && [ "$m1" -le "$((nowx + 600))" ] \
8972
+ && case "$m2" in "bucket=error-cooldown percent=? marked_at="*" reason=error-cooldown") true ;; *) false ;; esac \
8973
+ && t_ok "AR codex quota: the old account gets the 10-minute error-cooldown, never client:7d" \
8974
+ || t_fail "AR codex quota: cooldown marker" "$m1 / $m2"
8975
+ ar_logged "$ARX" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=quota depth=1\$" \
8976
+ && ar_logged "$ARX" "autoresume switch provider=codex chain=[A-Za-z0-9]+ from=$from class=quota sid=$AR_CXSID depth=1 probe=$to" \
8977
+ && t_ok "AR codex quota: switch + relaunch logged" \
8978
+ || t_fail "AR codex quota: log lines" "$(grep autoresume "$ARX/selection.log")"
8979
+ ar_typed_ok CODEX "$AR_CXSID" "CODEX_ACCOUNTS_ROOT=$(cd "$ARX" && pwd)" "$REPO_DIR/bin/codex" \
8980
+ && grep -q -- "send-keys -R -t %2" "$ARB/tmux/calls" \
8981
+ && t_ok "AR codex quota: terminal reset, then the token line typed" \
8982
+ || t_fail "AR codex quota: typed line" "$(cat "$ARB/tmux/typed" 2>/dev/null)"
8983
+ ar_wait 10 ar_logged "$ARX" "autoresume watch provider=codex chain=[A-Za-z0-9]+ pid=[0-9]+ acct=$to depth=1\$" \
8984
+ && t_ok "AR codex quota: the resumed session is supervised again" \
8985
+ || t_fail "AR codex quota: second watcher" "$(grep autoresume "$ARX/selection.log")"
8986
+ else
8987
+ t_fail "AR codex quota: relaunched within 30 s" "tui=$(cat "$ARB/tui.log" 2>/dev/null) log=$(grep autoresume "$ARX/selection.log" 2>/dev/null)"
8988
+ fi
8989
+ ar_reap "$ARX"
8990
+ ar_no_watcher "$ARX" && t_ok "AR codex: no watcher left running" || t_fail "AR codex: watcher left running"
8991
+
7888
8992
  # The reset-credit contract is easier to prove against a stateful local HTTP server
7889
8993
  # than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
7890
8994
  if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
@@ -7909,6 +9013,13 @@ if CLAUDE_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests
7909
9013
  else
7910
9014
  t_fail "claude automatic reset suite" "see unittest output above"
7911
9015
  fi
9016
+ # ...and the auto-resume watcher, unit by unit: classifiers, argv sanitizer, relaunch
9017
+ # files, budgets, tmux commands and a fake-runner walk through every stop/relaunch branch.
9018
+ if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_autoresume.py'; then
9019
+ t_ok "auto-resume watcher unit suite"
9020
+ else
9021
+ t_fail "auto-resume watcher unit suite" "see unittest output above"
9022
+ fi
7912
9023
 
7913
9024
  # ---- summary ---------------------------------------------------------------------
7914
9025
  echo