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.
- package/README.md +60 -2
- package/bin/claude +414 -5
- package/bin/claude-accounts +36 -11
- package/bin/codex +406 -4
- package/docs/ACCOUNT_OPERATIONS.md +7 -1
- package/docs/AUTORESUME.md +321 -0
- package/docs/CODEX.md +12 -1
- package/docs/VERIFICATION.md +9 -1
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +2271 -0
- package/lib/common.sh +24 -1
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/fake_security.sh +90 -0
- package/tests/run-tests.sh +1178 -67
- package/tests/test_autoresume.py +2565 -0
- package/tests/test_keychain.py +323 -50
package/tests/run-tests.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
268
|
-
#
|
|
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
|
-
|
|
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" "$
|
|
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
|
-
|
|
7650
|
-
kc_put() { # kc_put <acct dir> <credential json> — store it the way
|
|
7651
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
[
|
|
7741
|
-
|
|
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
|
-
[
|
|
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
|
-
[ -
|
|
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
|