claude-multiacc 2.0.25 → 2.0.27
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 +39 -0
- package/bin/claude-accounts +28 -8
- package/bin/codex +124 -0
- package/install.sh +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.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/common.sh +92 -2
- package/package.json +1 -1
- 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/run-tests.sh +313 -2
package/README.md
CHANGED
|
@@ -315,6 +315,45 @@ same ownership rule. Note that the **installed layout deliberately shares that t
|
|
|
315
315
|
default codex pool this scan stays off and the usage endpoint remains codex's only limit
|
|
316
316
|
signal. Claude accounts keep a private `sessions/` registry, so it is fully active there.
|
|
317
317
|
|
|
318
|
+
A shared tree also gets **one shared rollout index**. codex refuses to start until its
|
|
319
|
+
`state_<schema>.sqlite` has indexed every rollout under `$CODEX_HOME/sessions` (each file
|
|
320
|
+
read whole), so a private index per account meant one full scan of the shared tree per
|
|
321
|
+
account — 10 GB × 12 on a busy Mac — and any launch that met another process's unfinished
|
|
322
|
+
scan died after 30 s with "timed out waiting for state db backfill … (status: running)",
|
|
323
|
+
surfaced as the CLI's "local database appears to be damaged". A scan cut short (a probe with
|
|
324
|
+
a timeout, Ctrl-C) also left its 15-minute worker lease behind, keeping the account
|
|
325
|
+
unstartable long after its killer was gone. Since 2.0.27 `<acct>/state_N.sqlite` is a symlink
|
|
326
|
+
to `~/.codex/state_N.sqlite`, exactly like the tree it describes: SQLite resolves the link
|
|
327
|
+
before naming its `-wal`/`-shm` companions, so every process shares one lock set — the same
|
|
328
|
+
multi-process mode a single `CODEX_HOME` already runs in. The shim applies this at every
|
|
329
|
+
launch, and the index name the installed binary carries is linked ahead of time, so a codex
|
|
330
|
+
schema bump still costs one backfill per machine rather than one per account.
|
|
331
|
+
|
|
332
|
+
An account with **no index of its own** — every newly added one, and the case the change
|
|
333
|
+
exists for — is simply linked. Nothing is renamed and nothing about the shared index's state
|
|
334
|
+
can block it. An account that already has one is only ever moved while **nothing can open
|
|
335
|
+
it** (no `-wal` and no `-shm` beside it). codex holds the state database through an sqlx pool
|
|
336
|
+
whose connections are opened lazily and *by path* (`max_connections(5)`,
|
|
337
|
+
`create_if_missing(true)`), so renaming an index under a live holder would leave its first
|
|
338
|
+
connection on the old inode while every later one follows the new link — one process, two
|
|
339
|
+
databases. A codex killed mid-write leaves the pair behind too, and the next clean session on
|
|
340
|
+
that account removes it, so an account excluded this way rejoins on its own. Once it is
|
|
341
|
+
closed, the account's index is either **retired** beside the link as `*.private` (never
|
|
342
|
+
deleted, never overwritten — a second retirement gets its own suffix) when the home already
|
|
343
|
+
has one, or **promoted** into the home when it does not. The promote goes through `link(2)`,
|
|
344
|
+
which refuses an existing target: two shims racing to be the first to promote could otherwise
|
|
345
|
+
rename one's fresh symlink onto the file the other had just promoted, leaving the shared index
|
|
346
|
+
pointing at itself — `ELOOP`, every account on the machine unable to start, and repaired by
|
|
347
|
+
neither codex nor the shim.
|
|
348
|
+
|
|
349
|
+
Two things stop the sharing entirely. An account whose `sessions/` is a real directory keeps a
|
|
350
|
+
private index — the rule is structural, not a heuristic. And codex's own corruption recovery
|
|
351
|
+
has the last word: when it judges a database damaged it renames it (with its `-wal`/`-shm`)
|
|
352
|
+
into `<CODEX_HOME>/db-backups/sqlite-<ts>-<n>/` and rebuilds, which under a link renames the
|
|
353
|
+
*link*. A link sitting in that folder pointing at the shared file is codex's verdict on that
|
|
354
|
+
file, so the account keeps the index codex rebuilt for it and that name is left alone —
|
|
355
|
+
handing the link back would hand the damage back.
|
|
356
|
+
|
|
318
357
|
**Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
|
|
319
358
|
auth- or rate-limit-looking failure the shim retries once on a different account and
|
|
320
359
|
marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
|
package/bin/claude-accounts
CHANGED
|
@@ -1434,7 +1434,21 @@ limits_distribute() {
|
|
|
1434
1434
|
sync_is_replica && return 0
|
|
1435
1435
|
command -v rsync >/dev/null 2>&1 || return 0
|
|
1436
1436
|
[ "${CLAUDE_MULTIACC_NO_DISTRIBUTE:-0}" = "1" ] && return 0
|
|
1437
|
-
|
|
1437
|
+
# In its OWN session, not merely backgrounded. launchd tears down the job's
|
|
1438
|
+
# whole process group the moment `limits` exits, and a `( … & )` subshell is
|
|
1439
|
+
# still in that group: every scheduled pass spawned its push and launchd killed
|
|
1440
|
+
# it before one rsync had finished, with nothing logged — while this machine
|
|
1441
|
+
# refreshed every 15 minutes, the seven runner Macs ranked on readings last
|
|
1442
|
+
# pushed at 21:13Z until 01:10Z on 2026-09-09 (a foreground pass delivered them
|
|
1443
|
+
# in seconds). setsid puts the push outside the group; install.sh also marks
|
|
1444
|
+
# the agent AbandonProcessGroup, and either one alone is enough.
|
|
1445
|
+
"$PYBIN" -c '
|
|
1446
|
+
import os, sys
|
|
1447
|
+
if os.fork():
|
|
1448
|
+
sys.exit(0)
|
|
1449
|
+
os.setsid()
|
|
1450
|
+
os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
|
|
1451
|
+
' "$BIN_DIR/claude-accounts" >/dev/null 2>&1 </dev/null &
|
|
1438
1452
|
return 0
|
|
1439
1453
|
}
|
|
1440
1454
|
|
|
@@ -1472,21 +1486,26 @@ limits_distribute_now() {
|
|
|
1472
1486
|
done
|
|
1473
1487
|
if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
|
|
1474
1488
|
|
|
1489
|
+
local pushed=0 failed=0
|
|
1475
1490
|
server="$(sync_target)"
|
|
1476
1491
|
sroot="$(sync_target_root)"
|
|
1477
1492
|
if [ -n "$server" ] && ! sync_target_is_local "$server" \
|
|
1478
1493
|
&& valid_ssh_target "$server" && valid_remote_path "$sroot"; then
|
|
1479
|
-
limits_push_to "$server" "$sroot" "$list"
|
|
1494
|
+
if limits_push_to "$server" "$sroot" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
|
|
1480
1495
|
fi
|
|
1481
1496
|
# Peers are the OTHER Macs — the ones actually running tasks, and so the ones
|
|
1482
|
-
# whose selection goes blind without this.
|
|
1483
|
-
|
|
1497
|
+
# whose selection goes blind without this. Read through process substitution,
|
|
1498
|
+
# not a pipe: the counters below must survive the loop.
|
|
1499
|
+
while IFS="$(printf '\t')" read -r pt pr pp; do
|
|
1484
1500
|
[ "$pt" = "MALFORMED" ] && continue
|
|
1485
1501
|
[ -n "$pt" ] && [ -n "$pr" ] || continue
|
|
1486
1502
|
valid_ssh_target "$pt" || continue
|
|
1487
1503
|
valid_remote_path "$pr" || continue
|
|
1488
|
-
limits_push_to "$pt" "$pr" "$list"
|
|
1489
|
-
done
|
|
1504
|
+
if limits_push_to "$pt" "$pr" "$list"; then pushed=$((pushed + 1)); else failed=$((failed + 1)); fi
|
|
1505
|
+
done < <(manifest_peers)
|
|
1506
|
+
# Every pass writes its outcome: a silent push is indistinguishable from a
|
|
1507
|
+
# killed one, which is exactly how the 2026-09-08 outage went unnoticed.
|
|
1508
|
+
log_to sync.log "limits distributed to $pushed target(s), $failed failed"
|
|
1490
1509
|
rm -f "$list"
|
|
1491
1510
|
rmdir "$lock" 2>/dev/null || true
|
|
1492
1511
|
trap - EXIT
|
|
@@ -1497,8 +1516,7 @@ limits_push_to() { # $1 target, $2 remote root, $3 file list
|
|
|
1497
1516
|
rsync -az --timeout=20 \
|
|
1498
1517
|
-e 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new' \
|
|
1499
1518
|
--files-from="$3" "$ACC_ROOT/" "$1:$2/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1500
|
-
|| log_to sync.log "limits push to $1 failed (telemetry there will age)"
|
|
1501
|
-
return 0
|
|
1519
|
+
|| { log_to sync.log "limits push to $1 failed (telemetry there will age)"; return 1; }
|
|
1502
1520
|
}
|
|
1503
1521
|
|
|
1504
1522
|
cmd_limits() {
|
|
@@ -2956,6 +2974,8 @@ case "${1:-help}" in
|
|
|
2956
2974
|
sync) shift; cmd_sync "$@" ;;
|
|
2957
2975
|
verify) shift; cmd_verify "$@" ;;
|
|
2958
2976
|
limits) shift; cmd_limits "$@" ;;
|
|
2977
|
+
# Internal: the detached telemetry push `limits` starts in its own session.
|
|
2978
|
+
limits-distribute-now) shift; limits_distribute_now ;;
|
|
2959
2979
|
post-sync) shift; cmd_post_sync "$@" ;;
|
|
2960
2980
|
health) shift; cmd_health "$@" ;;
|
|
2961
2981
|
self-update) shift; cmd_self_update "$@" ;;
|
package/bin/codex
CHANGED
|
@@ -122,6 +122,127 @@ sel_log() {
|
|
|
122
122
|
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" 2>/dev/null >> "$ACC_ROOT/selection.log" || true
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
# ---- one index for the shared session tree -----------------------------------------
|
|
126
|
+
# codex keeps a SQLite index of the rollout tree beside it (state_<schema>.sqlite) and
|
|
127
|
+
# refuses to start until that index has been BACKFILLED from every rollout under
|
|
128
|
+
# $CODEX_HOME/sessions — each file read whole. The installed layout shares one tree
|
|
129
|
+
# across every account (<acct>/sessions -> ~/.codex/sessions), so a private index per
|
|
130
|
+
# account meant one full scan of the whole tree per account: 10 GB × 12 on a busy Mac,
|
|
131
|
+
# minutes each, and a launch that met another process's unfinished scan died after 30 s
|
|
132
|
+
# with "timed out waiting for state db backfill … (status: running)" — surfaced as the
|
|
133
|
+
# CLI's damaged-database message. A scan cut short (a probe with a timeout, Ctrl-C) also
|
|
134
|
+
# left its 15-minute worker lease behind, so the account stayed unstartable long after
|
|
135
|
+
# its killer was gone (my-mini 2026-09-09: six fresh accounts, every launch refused).
|
|
136
|
+
#
|
|
137
|
+
# A shared tree gets ONE shared index: <acct>/state_N.sqlite is a symlink to the same
|
|
138
|
+
# file under ~/.codex, exactly like the tree it describes. SQLite resolves that symlink
|
|
139
|
+
# before naming its -wal/-shm companions, so every process shares one lock set — the
|
|
140
|
+
# multi-process mode a single CODEX_HOME already runs in. A private index that already
|
|
141
|
+
# exists and nothing holds open is retired beside the link (never deleted), or promoted
|
|
142
|
+
# into the home when the home has none yet; and the index the installed binary will
|
|
143
|
+
# create next is linked ahead of time, so a schema bump still costs one backfill, not one
|
|
144
|
+
# per account. Structural on purpose: a real (private) session tree keeps a private index.
|
|
145
|
+
# lib/common.sh carries the same function for seeding — keep the two in step.
|
|
146
|
+
# CORE — byte-identical in lib/common.sh; tests diff them.
|
|
147
|
+
state_index_names() { # $1 acct dir, $2 home dir -> the index file names either side holds
|
|
148
|
+
local f name seen=" "
|
|
149
|
+
for f in "$2"/state_[0-9]*.sqlite "$1"/state_[0-9]*.sqlite; do
|
|
150
|
+
[ -e "$f" ] || [ -L "$f" ] || continue # an unmatched glob is the pattern itself
|
|
151
|
+
name="${f##*/}"
|
|
152
|
+
case "$seen" in *" $name "*) continue ;; esac
|
|
153
|
+
seen="$seen$name "
|
|
154
|
+
printf '%s\n' "$name"
|
|
155
|
+
done
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
installed_state_index_name() { # the index file the installed binary creates; memoized per binary
|
|
159
|
+
local cache="$ACC_ROOT/.state-index" bin="$REAL" real id line name="" nat
|
|
160
|
+
# macOS `stat` reports a SYMLINK's own mtime, and codex is usually installed behind a
|
|
161
|
+
# stable launcher symlink — so the memo has to key on the file the link resolves to.
|
|
162
|
+
real="$(canon_path "$bin")"
|
|
163
|
+
id="$real:$(file_mtime "$real")"
|
|
164
|
+
if [ -f "$cache" ]; then
|
|
165
|
+
IFS= read -r line < "$cache" 2>/dev/null || line=""
|
|
166
|
+
case "$line" in "$id "*) printf '%s\n' "${line#"$id "}"; return 0 ;; esac
|
|
167
|
+
fi
|
|
168
|
+
# The npm launcher is a script; the schema name lives in the native binary vendored
|
|
169
|
+
# beside it. Try the launcher first (a bare binary answers directly), then the vendor.
|
|
170
|
+
for nat in "$bin" "$(dirname "$(canon_path "$bin")")"/../node_modules/@openai/codex-*/vendor/*/bin/codex; do
|
|
171
|
+
[ -f "$nat" ] || continue
|
|
172
|
+
name="$(LC_ALL=C grep -a -o -m1 'state_[0-9][0-9]*\.sqlite' "$nat" 2>/dev/null | head -1)"
|
|
173
|
+
[ -n "$name" ] && break
|
|
174
|
+
done
|
|
175
|
+
case "$name" in *[!A-Za-z0-9_.]*) name="" ;; esac
|
|
176
|
+
{ printf '%s %s\n' "$id" "$name" > "$cache.$$" && mv -f "$cache.$$" "$cache"; } 2>/dev/null \
|
|
177
|
+
|| rm -f "$cache.$$" 2>/dev/null
|
|
178
|
+
printf '%s\n' "$name"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# CORE — byte-identical in lib/common.sh; tests diff them.
|
|
182
|
+
shared_index_rejected() { # $1 acct dir, $2 index name, $3 shared target
|
|
183
|
+
# True when codex's own corruption recovery moved THIS account's link out of the way:
|
|
184
|
+
# it renames the database it judged damaged (and its -wal/-shm) into
|
|
185
|
+
# <CODEX_HOME>/db-backups/sqlite-<ts>-<n>/ and rebuilds, and under a link that renames
|
|
186
|
+
# the LINK. A link sitting in there pointing at the shared file is codex's verdict on
|
|
187
|
+
# that file, so this account keeps the index codex rebuilt for it and this name is
|
|
188
|
+
# left alone — handing the link back would hand the damage back.
|
|
189
|
+
local marker
|
|
190
|
+
for marker in "$1"/db-backups/*/"$2"; do
|
|
191
|
+
[ -L "$marker" ] || continue
|
|
192
|
+
[ "$(readlink "$marker" 2>/dev/null)" = "$3" ] && return 0
|
|
193
|
+
done
|
|
194
|
+
return 1
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# CORE — byte-identical in lib/common.sh; tests diff them.
|
|
198
|
+
share_state_index_links() { # $1 acct dir, $2 home dir, $3 extra index name ('' for none)
|
|
199
|
+
local d="$1" home="$2" name names target link retired n
|
|
200
|
+
[ -L "$d" ] && return 0 # adopted: the dir IS the home
|
|
201
|
+
[ -L "$d/sessions" ] || return 0 # a private tree keeps its private index
|
|
202
|
+
[ "$(readlink "$d/sessions" 2>/dev/null)" = "$home/sessions" ] || return 0
|
|
203
|
+
[ -d "$home" ] || return 0
|
|
204
|
+
names="$(state_index_names "$d" "$home")"
|
|
205
|
+
case "${3:-}" in ''|*[!A-Za-z0-9_.]*) ;; *) names="$names $3" ;; esac
|
|
206
|
+
for name in $names; do
|
|
207
|
+
target="$home/$name"; link="$d/$name"
|
|
208
|
+
[ -L "$link" ] && continue # already shared (or pointed elsewhere on purpose)
|
|
209
|
+
shared_index_rejected "$d" "$name" "$target" && continue
|
|
210
|
+
if [ ! -e "$link" ]; then
|
|
211
|
+
ln -s "$target" "$link" 2>/dev/null || true # a new account: the whole point
|
|
212
|
+
continue
|
|
213
|
+
fi
|
|
214
|
+
# This account has an index of its own, and MOVING one that a process can still open
|
|
215
|
+
# is unsafe. codex holds the state database through an sqlx pool that opens its
|
|
216
|
+
# connections lazily and BY PATH (max_connections(5), create_if_missing(true) —
|
|
217
|
+
# codex-rs/state/src/sqlite.rs open_read_write_pool), so a rename under a live holder
|
|
218
|
+
# leaves connection 1 on the old inode while every connection the pool opens
|
|
219
|
+
# afterwards follows the new link: one process, two databases. A -wal or a -shm beside
|
|
220
|
+
# the file is that proof. A codex killed mid-write leaves them behind too, and the
|
|
221
|
+
# next clean session on the account removes them, so this heals itself in time.
|
|
222
|
+
{ [ -e "$link-wal" ] || [ -e "$link-shm" ]; } && continue
|
|
223
|
+
if [ -e "$target" ]; then
|
|
224
|
+
retired="$link.private"; n=0 # never overwrite an earlier copy
|
|
225
|
+
while [ -e "$retired" ] && [ "$n" -lt 100 ]; do n=$((n+1)); retired="$link.private.$n"; done
|
|
226
|
+
[ -e "$retired" ] && continue
|
|
227
|
+
mv "$link" "$retired" 2>/dev/null || continue
|
|
228
|
+
else
|
|
229
|
+
# Atomic or nothing: link(2) refuses an existing target, so two shims racing to be
|
|
230
|
+
# the first to promote cannot rename one's fresh symlink onto the file the other
|
|
231
|
+
# just promoted — which is how a self-referential shared index (ELOOP, and every
|
|
232
|
+
# account on the Mac unable to start) could appear. A cross-device link simply
|
|
233
|
+
# fails and this account keeps its own index.
|
|
234
|
+
ln "$link" "$target" 2>/dev/null || continue
|
|
235
|
+
[ -L "$link" ] || rm -f "$link"
|
|
236
|
+
fi
|
|
237
|
+
ln -s "$target" "$link" 2>/dev/null || true
|
|
238
|
+
done
|
|
239
|
+
return 0
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
share_state_index() { # $1 acct dir — at launch: the home's index, plus the one this binary will create
|
|
243
|
+
share_state_index_links "$1" "${HOME:-/nonexistent}/.codex" "$(installed_state_index_name)"
|
|
244
|
+
}
|
|
245
|
+
|
|
125
246
|
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
126
247
|
# Parity with bin/claude's client_marker_recovered, by construction: this shim has NO
|
|
127
248
|
# telemetry-based clearing path. A marker leaves here only when its OWN reset epoch has
|
|
@@ -521,6 +642,7 @@ if [ -n "${CODEX_ACCOUNT:-}" ]; then
|
|
|
521
642
|
d="$ACC_ROOT/$CODEX_ACCOUNT"
|
|
522
643
|
if [ -d "$d" ]; then
|
|
523
644
|
sel_log "$CODEX_ACCOUNT pinned pwd=$PWD"
|
|
645
|
+
share_state_index "$d"
|
|
524
646
|
export CODEX_HOME="$d"
|
|
525
647
|
export CODEX_SHIM_ACTIVE=1
|
|
526
648
|
exec "$REAL" "$@"
|
|
@@ -805,6 +927,7 @@ if [ "${CODEX_SHIM_RETRY:-1}" != "0" ] && [ "${#eligible[@]}" -ge 2 ]; then
|
|
|
805
927
|
fi
|
|
806
928
|
fi
|
|
807
929
|
|
|
930
|
+
share_state_index "$pick"
|
|
808
931
|
if [ "$wants_retry" = "0" ]; then
|
|
809
932
|
export CODEX_HOME="$pick"
|
|
810
933
|
exec "$REAL" "$@"
|
|
@@ -906,6 +1029,7 @@ while :; do
|
|
|
906
1029
|
if [ -n "$next" ]; then
|
|
907
1030
|
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
908
1031
|
cur="$next"
|
|
1032
|
+
share_state_index "$cur"
|
|
909
1033
|
# The account that actually serves the work is the one the next run should rotate
|
|
910
1034
|
# away from — not the one that bounced.
|
|
911
1035
|
remember_pick "$cur"
|
package/install.sh
CHANGED
|
@@ -183,6 +183,9 @@ mac_schedule_install() {
|
|
|
183
183
|
</array>
|
|
184
184
|
$(plist_env_block) <key>StartInterval</key><integer>900</integer>
|
|
185
185
|
<key>RunAtLoad</key><true/>
|
|
186
|
+
<!-- The pass hands its readings to the peers from a detached push; launchd
|
|
187
|
+
must not kill that push with the job's process group. -->
|
|
188
|
+
<key>AbandonProcessGroup</key><true/>
|
|
186
189
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
187
190
|
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
188
191
|
</dict></plist>
|
|
@@ -238,6 +241,7 @@ EOF
|
|
|
238
241
|
</array>
|
|
239
242
|
$(plist_env_block) <key>StartInterval</key><integer>300</integer>
|
|
240
243
|
<key>RunAtLoad</key><true/>
|
|
244
|
+
<key>AbandonProcessGroup</key><true/>
|
|
241
245
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
242
246
|
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
243
247
|
</dict></plist>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/common.sh
CHANGED
|
@@ -588,12 +588,101 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
|
588
588
|
PYEOF
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
# ---- one index for the shared session tree -----------------------------------------
|
|
592
|
+
# codex refuses to start until its rollout index (state_<schema>.sqlite) has been
|
|
593
|
+
# backfilled from every rollout under $CODEX_HOME/sessions. The codex layout shares that
|
|
594
|
+
# tree across accounts (<acct>/sessions -> ~/.codex/sessions), so the index is shared
|
|
595
|
+
# the same way: <acct>/state_N.sqlite -> ~/.codex/state_N.sqlite. Otherwise every new
|
|
596
|
+
# account re-reads the whole tree (10 GB on a busy Mac) before its first launch, and a
|
|
597
|
+
# launch that meets another process's unfinished scan dies after 30 s with the CLI's
|
|
598
|
+
# "local database appears to be damaged" message. bin/codex applies the same at every
|
|
599
|
+
# launch (and pre-links the name the installed binary will create); seeding covers a
|
|
600
|
+
# CODEX_HOME handed straight to the real binary. The two CORE functions below are
|
|
601
|
+
# byte-identical with bin/codex — tests/run-tests.sh diffs them.
|
|
602
|
+
# CORE — byte-identical in bin/codex; tests diff them.
|
|
603
|
+
state_index_names() { # $1 acct dir, $2 home dir -> the index file names either side holds
|
|
604
|
+
local f name seen=" "
|
|
605
|
+
for f in "$2"/state_[0-9]*.sqlite "$1"/state_[0-9]*.sqlite; do
|
|
606
|
+
[ -e "$f" ] || [ -L "$f" ] || continue # an unmatched glob is the pattern itself
|
|
607
|
+
name="${f##*/}"
|
|
608
|
+
case "$seen" in *" $name "*) continue ;; esac
|
|
609
|
+
seen="$seen$name "
|
|
610
|
+
printf '%s\n' "$name"
|
|
611
|
+
done
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# CORE — byte-identical in bin/codex; tests diff them.
|
|
615
|
+
shared_index_rejected() { # $1 acct dir, $2 index name, $3 shared target
|
|
616
|
+
# True when codex's own corruption recovery moved THIS account's link out of the way:
|
|
617
|
+
# it renames the database it judged damaged (and its -wal/-shm) into
|
|
618
|
+
# <CODEX_HOME>/db-backups/sqlite-<ts>-<n>/ and rebuilds, and under a link that renames
|
|
619
|
+
# the LINK. A link sitting in there pointing at the shared file is codex's verdict on
|
|
620
|
+
# that file, so this account keeps the index codex rebuilt for it and this name is
|
|
621
|
+
# left alone — handing the link back would hand the damage back.
|
|
622
|
+
local marker
|
|
623
|
+
for marker in "$1"/db-backups/*/"$2"; do
|
|
624
|
+
[ -L "$marker" ] || continue
|
|
625
|
+
[ "$(readlink "$marker" 2>/dev/null)" = "$3" ] && return 0
|
|
626
|
+
done
|
|
627
|
+
return 1
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
# CORE — byte-identical in bin/codex; tests diff them.
|
|
631
|
+
share_state_index_links() { # $1 acct dir, $2 home dir, $3 extra index name ('' for none)
|
|
632
|
+
local d="$1" home="$2" name names target link retired n
|
|
633
|
+
[ -L "$d" ] && return 0 # adopted: the dir IS the home
|
|
634
|
+
[ -L "$d/sessions" ] || return 0 # a private tree keeps its private index
|
|
635
|
+
[ "$(readlink "$d/sessions" 2>/dev/null)" = "$home/sessions" ] || return 0
|
|
636
|
+
[ -d "$home" ] || return 0
|
|
637
|
+
names="$(state_index_names "$d" "$home")"
|
|
638
|
+
case "${3:-}" in ''|*[!A-Za-z0-9_.]*) ;; *) names="$names $3" ;; esac
|
|
639
|
+
for name in $names; do
|
|
640
|
+
target="$home/$name"; link="$d/$name"
|
|
641
|
+
[ -L "$link" ] && continue # already shared (or pointed elsewhere on purpose)
|
|
642
|
+
shared_index_rejected "$d" "$name" "$target" && continue
|
|
643
|
+
if [ ! -e "$link" ]; then
|
|
644
|
+
ln -s "$target" "$link" 2>/dev/null || true # a new account: the whole point
|
|
645
|
+
continue
|
|
646
|
+
fi
|
|
647
|
+
# This account has an index of its own, and MOVING one that a process can still open
|
|
648
|
+
# is unsafe. codex holds the state database through an sqlx pool that opens its
|
|
649
|
+
# connections lazily and BY PATH (max_connections(5), create_if_missing(true) —
|
|
650
|
+
# codex-rs/state/src/sqlite.rs open_read_write_pool), so a rename under a live holder
|
|
651
|
+
# leaves connection 1 on the old inode while every connection the pool opens
|
|
652
|
+
# afterwards follows the new link: one process, two databases. A -wal or a -shm beside
|
|
653
|
+
# the file is that proof. A codex killed mid-write leaves them behind too, and the
|
|
654
|
+
# next clean session on the account removes them, so this heals itself in time.
|
|
655
|
+
{ [ -e "$link-wal" ] || [ -e "$link-shm" ]; } && continue
|
|
656
|
+
if [ -e "$target" ]; then
|
|
657
|
+
retired="$link.private"; n=0 # never overwrite an earlier copy
|
|
658
|
+
while [ -e "$retired" ] && [ "$n" -lt 100 ]; do n=$((n+1)); retired="$link.private.$n"; done
|
|
659
|
+
[ -e "$retired" ] && continue
|
|
660
|
+
mv "$link" "$retired" 2>/dev/null || continue
|
|
661
|
+
else
|
|
662
|
+
# Atomic or nothing: link(2) refuses an existing target, so two shims racing to be
|
|
663
|
+
# the first to promote cannot rename one's fresh symlink onto the file the other
|
|
664
|
+
# just promoted — which is how a self-referential shared index (ELOOP, and every
|
|
665
|
+
# account on the Mac unable to start) could appear. A cross-device link simply
|
|
666
|
+
# fails and this account keeps its own index.
|
|
667
|
+
ln "$link" "$target" 2>/dev/null || continue
|
|
668
|
+
[ -L "$link" ] || rm -f "$link"
|
|
669
|
+
fi
|
|
670
|
+
ln -s "$target" "$link" 2>/dev/null || true
|
|
671
|
+
done
|
|
672
|
+
return 0
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
codex_share_state_index() { # $1 acct dir — seed time: whatever index names exist already
|
|
676
|
+
share_state_index_links "$1" "${HOME:-/nonexistent}/.codex" ""
|
|
677
|
+
}
|
|
678
|
+
|
|
591
679
|
# Seed an account config dir so headless runs never prompt.
|
|
592
680
|
# claude: stripped .claude.json, copied settings.json, shared projects/ symlink
|
|
593
681
|
# (shared history => --continue/--resume work regardless of picked account).
|
|
594
682
|
# codex: copied config.toml (carries project trust + settings, no identity),
|
|
595
|
-
# shared sessions/ symlink (=> `codex resume` finds every session)
|
|
596
|
-
# shared
|
|
683
|
+
# shared sessions/ symlink (=> `codex resume` finds every session) with the
|
|
684
|
+
# shared rollout index that tree needs, and a shared AGENTS.md symlink so
|
|
685
|
+
# global instructions apply under any account.
|
|
597
686
|
seed_account_dir() { # $1 = acct dir
|
|
598
687
|
local d="$1"
|
|
599
688
|
mkdir -p "$d"
|
|
@@ -608,6 +697,7 @@ seed_account_dir() { # $1 = acct dir
|
|
|
608
697
|
if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
|
|
609
698
|
ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
|
|
610
699
|
fi
|
|
700
|
+
codex_share_state_index "$d"
|
|
611
701
|
return 0
|
|
612
702
|
fi
|
|
613
703
|
if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -3759,6 +3759,7 @@ mkdir -p "$RSFAKE"
|
|
|
3759
3759
|
cat > "$RSFAKE/rsync" <<'EOF'
|
|
3760
3760
|
#!/usr/bin/env bash
|
|
3761
3761
|
printf 'RSYNC %s\n' "$*" >> "${RSLOG:?}"
|
|
3762
|
+
printf 'PGID %s\n' "$(ps -o pgid= -p $$ | tr -d ' ')" >> "$RSLOG"
|
|
3762
3763
|
for a in "$@"; do
|
|
3763
3764
|
case "$a" in
|
|
3764
3765
|
--files-from=*)
|
|
@@ -3795,6 +3796,23 @@ for peer in gas@mini-3 gas@mini-4; do
|
|
|
3795
3796
|
&& t_ok "telemetry is pushed to manifest peer $peer" \
|
|
3796
3797
|
|| t_fail "limits distribute peer" "$peer never received a push: $(grep '^RSYNC' "$RSLOG")"
|
|
3797
3798
|
done
|
|
3799
|
+
# The push must run OUTSIDE the calling job's process group: launchd kills that group
|
|
3800
|
+
# the moment `limits` exits, and a `( … & )` subshell is still in it — on 2026-09-08 the
|
|
3801
|
+
# source Mac refreshed every 15 minutes while every scheduled push died before its first
|
|
3802
|
+
# rsync, and the seven runner Macs ranked on 21:13Z readings until 01:10Z the next day.
|
|
3803
|
+
own_pgid="$(ps -o pgid= -p $$ | tr -d ' ')"
|
|
3804
|
+
push_pgids="$(grep '^PGID ' "$RSLOG" | sort -u | sed 's/^PGID //' | tr '\n' ' ')"
|
|
3805
|
+
case " $push_pgids " in
|
|
3806
|
+
*" $own_pgid "*) t_fail "detached push session" "a push ran inside the caller's process group $own_pgid (launchd would kill it)" ;;
|
|
3807
|
+
*) [ -n "$push_pgids" ] && t_ok "the telemetry push runs in its own session, outside the job's process group" \
|
|
3808
|
+
|| t_fail "detached push session" "no push recorded a process group" ;;
|
|
3809
|
+
esac
|
|
3810
|
+
grep -q "limits distributed to 3 target(s), 0 failed" "$DPOOL/sync.log" \
|
|
3811
|
+
&& t_ok "every distribute pass records its outcome in sync.log" \
|
|
3812
|
+
|| t_fail "distribute summary" "sync.log: $(tail -3 "$DPOOL/sync.log" 2>/dev/null | tr '\n' '|')"
|
|
3813
|
+
[ "$(grep -c '<key>AbandonProcessGroup</key><true/>' "$REPO_DIR/install.sh")" = "2" ] \
|
|
3814
|
+
&& t_ok "install.sh abandons the process group of both limits agents" \
|
|
3815
|
+
|| t_fail "AbandonProcessGroup" "expected both limits plists to carry AbandonProcessGroup"
|
|
3798
3816
|
# ...and every target gets the SAME list: each account's reading, plus any marker.
|
|
3799
3817
|
for f in "acct-01/limits.json" "acct-02/limits.json" "acct-02/.limited"; do
|
|
3800
3818
|
[ "$(grep -c "^FILE $f\$" "$RSLOG")" = "3" ] \
|
|
@@ -4888,6 +4906,296 @@ check "codex: sign-in as registered email refused" "already added as acct-01" "$
|
|
|
4888
4906
|
n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
|
|
4889
4907
|
[ "$n" = "1" ] && t_ok "codex: no second entry for a re-signed-in email" || t_fail "codex dup signin" "count=$n"
|
|
4890
4908
|
|
|
4909
|
+
# ---- C11b. one index for the shared session tree ------------------------------------
|
|
4910
|
+
# codex refuses to start until state_<schema>.sqlite has indexed every rollout under
|
|
4911
|
+
# $CODEX_HOME/sessions. The layout shares that tree across accounts, so it must share
|
|
4912
|
+
# the index too — a private index per account re-read the whole tree per account and
|
|
4913
|
+
# stranded a 15-minute worker lease whenever a scan was cut short (my-mini 2026-09-09:
|
|
4914
|
+
# six fresh accounts refused every launch as "local database appears to be damaged").
|
|
4915
|
+
CXH="$WORK/cxhome"
|
|
4916
|
+
mkdir -p "$CXH/.codex/sessions"
|
|
4917
|
+
printf 'home index' > "$CXH/.codex/state_5.sqlite"
|
|
4918
|
+
# (a) seeding: a new account links the tree AND the index the home already has
|
|
4919
|
+
out="$(HOME="$CXH" FAKE_EMAIL=idx@cx codex-accounts add 2>&1)"
|
|
4920
|
+
nid="$(printf '%s' "$out" | sed -n 's/.*Registered \(acct-[0-9]*\) .*/\1/p' | head -1)"
|
|
4921
|
+
[ -n "$nid" ] || t_fail "codex shared index seed" "add did not register: $(printf '%s' "$out" | head -c 200)"
|
|
4922
|
+
[ "$(readlink "$CX/$nid/sessions" 2>/dev/null)" = "$CXH/.codex/sessions" ] \
|
|
4923
|
+
&& t_ok "codex: a new account shares the home's session tree" \
|
|
4924
|
+
|| t_fail "codex shared tree seed" "sessions -> $(readlink "$CX/$nid/sessions" 2>/dev/null)"
|
|
4925
|
+
[ "$(readlink "$CX/$nid/state_5.sqlite" 2>/dev/null)" = "$CXH/.codex/state_5.sqlite" ] \
|
|
4926
|
+
&& t_ok "codex: a new account shares the home's rollout index with that tree" \
|
|
4927
|
+
|| t_fail "codex shared index seed" "state_5.sqlite -> $(readlink "$CX/$nid/state_5.sqlite" 2>/dev/null)"
|
|
4928
|
+
[ -n "$nid" ] && codex-accounts remove "$nid" --yes >/dev/null 2>&1
|
|
4929
|
+
|
|
4930
|
+
# The launch-time heal, on its own pool so nothing here leaks into the other codex tests.
|
|
4931
|
+
CXS="$WORK/cx-state"
|
|
4932
|
+
mkdir -p "$CXS/tmp" "$CXS/acct-01" "$CXS/acct-02" "$CXS/acct-03"
|
|
4933
|
+
: > "$CXS/.limits-kick"
|
|
4934
|
+
cat > "$CXS/accounts.json" <<EOF2
|
|
4935
|
+
{"version": 1, "server": "root@203.0.113.1", "server_root": "/root/.codex-accounts",
|
|
4936
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
4937
|
+
"accounts": [
|
|
4938
|
+
{"id": "acct-01", "email": "s1@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
4939
|
+
{"id": "acct-02", "email": "s2@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
4940
|
+
{"id": "acct-03", "email": "s3@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"}]}
|
|
4941
|
+
EOF2
|
|
4942
|
+
for i in 01 02 03; do mk_cx_auth "$CXS/acct-$i/auth.json" "s$i@cx" "$FUTURE_EXP"; done
|
|
4943
|
+
CXH2="$WORK/cxhome2"
|
|
4944
|
+
mkdir -p "$CXH2/.codex/sessions"
|
|
4945
|
+
ln -s "$CXH2/.codex/sessions" "$CXS/acct-01/sessions"
|
|
4946
|
+
ln -s "$CXH2/.codex/sessions" "$CXS/acct-02/sessions"
|
|
4947
|
+
mkdir -p "$CXS/acct-03/sessions" # a PRIVATE tree
|
|
4948
|
+
printf 'closed private index' > "$CXS/acct-01/state_5.sqlite"
|
|
4949
|
+
printf 'open private index' > "$CXS/acct-02/state_5.sqlite"
|
|
4950
|
+
printf 'wal' > "$CXS/acct-02/state_5.sqlite-wal"
|
|
4951
|
+
printf 'shm' > "$CXS/acct-02/state_5.sqlite-shm"
|
|
4952
|
+
printf 'private tree index' > "$CXS/acct-03/state_5.sqlite"
|
|
4953
|
+
# (b) an OPEN private index (its -wal beside it) is never moved while the home has no index
|
|
4954
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4955
|
+
check "codex: launch on an account with an open private index" "CFG=acct-02" "$out"
|
|
4956
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -L "$CXS/acct-02/state_5.sqlite" ] \
|
|
4957
|
+
&& [ ! -e "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
4958
|
+
&& t_ok "codex: an open private index is never promoted (one inode, two -shm files, is how WAL corrupts)" \
|
|
4959
|
+
|| t_fail "codex open index promote" "$(ls -la "$CXS/acct-02" "$CXH2/.codex" 2>&1 | head -c 400)"
|
|
4960
|
+
# a -shm on its own is just as much a live database as a -wal is
|
|
4961
|
+
mv "$CXS/acct-02/state_5.sqlite-wal" "$CXS/acct-02/held-wal"
|
|
4962
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4963
|
+
check "codex: launch on an account whose index has only a -shm" "CFG=acct-02" "$out"
|
|
4964
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -e "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
4965
|
+
&& t_ok "codex: a -shm alone also refuses the promote" \
|
|
4966
|
+
|| t_fail "codex shm promote" "$(ls -la "$CXS/acct-02" "$CXH2/.codex" 2>&1 | head -c 400)"
|
|
4967
|
+
mv "$CXS/acct-02/held-wal" "$CXS/acct-02/state_5.sqlite-wal"
|
|
4968
|
+
# (c) a CLOSED private index is promoted into the home and linked back
|
|
4969
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 codex 2>&1)"
|
|
4970
|
+
check "codex: launch on an account with a closed private index" "CFG=acct-01" "$out"
|
|
4971
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "closed private index" ] \
|
|
4972
|
+
&& t_ok "codex: the first closed private index becomes the home's shared index" \
|
|
4973
|
+
|| t_fail "codex index promote" "home index: $(cat "$CXH2/.codex/state_5.sqlite" 2>&1 | head -c 80)"
|
|
4974
|
+
[ "$(readlink "$CXS/acct-01/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
4975
|
+
&& t_ok "codex: the promoting account links to the shared index" \
|
|
4976
|
+
|| t_fail "codex index promote link" "state_5.sqlite -> $(readlink "$CXS/acct-01/state_5.sqlite" 2>/dev/null)"
|
|
4977
|
+
# (d) the home has an index now, but the account's own is still OPEN: moving it would
|
|
4978
|
+
# split its holder across two databases (codex's sqlx pool re-opens BY PATH), so it
|
|
4979
|
+
# is left alone until the process that has it exits and clears its -wal/-shm.
|
|
4980
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4981
|
+
check "codex: relaunch on the account with the open private index" "CFG=acct-02" "$out"
|
|
4982
|
+
{ [ -f "$CXS/acct-02/state_5.sqlite" ] && [ ! -L "$CXS/acct-02/state_5.sqlite" ]; } \
|
|
4983
|
+
&& t_ok "codex: an index a process can still open is never moved, even once the home has one" \
|
|
4984
|
+
|| t_fail "codex open index retire" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
4985
|
+
# (d2) its holder exited and SQLite removed the pair: now it is retired beside the link
|
|
4986
|
+
rm -f "$CXS/acct-02/state_5.sqlite-wal" "$CXS/acct-02/state_5.sqlite-shm"
|
|
4987
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
4988
|
+
check "codex: relaunch once the private index is closed" "CFG=acct-02" "$out"
|
|
4989
|
+
[ "$(readlink "$CXS/acct-02/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
4990
|
+
&& t_ok "codex: a closed private index is retired for the shared one once the home has it" \
|
|
4991
|
+
|| t_fail "codex index retire" "state_5.sqlite -> $(readlink "$CXS/acct-02/state_5.sqlite" 2>/dev/null)"
|
|
4992
|
+
[ "$(cat "$CXS/acct-02/state_5.sqlite.private" 2>/dev/null)" = "open private index" ] \
|
|
4993
|
+
&& t_ok "codex: the retired index stays beside the link, never deleted" \
|
|
4994
|
+
|| t_fail "codex index retire files" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
4995
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "closed private index" ] \
|
|
4996
|
+
&& t_ok "codex: retiring never overwrites the shared index" \
|
|
4997
|
+
|| t_fail "codex index retire overwrite" "home index changed"
|
|
4998
|
+
# (d3) an account with NO index of its own is linked even while the shared one is in use —
|
|
4999
|
+
# the case the whole change exists for, and the one liveness must never block
|
|
5000
|
+
mkdir -p "$CXS/acct-06" && ln -s "$CXH2/.codex/sessions" "$CXS/acct-06/sessions"
|
|
5001
|
+
mk_cx_auth "$CXS/acct-06/auth.json" s6@cx "$FUTURE_EXP"
|
|
5002
|
+
printf 'wal' > "$CXH2/.codex/state_5.sqlite-wal"
|
|
5003
|
+
printf 'shm' > "$CXH2/.codex/state_5.sqlite-shm"
|
|
5004
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-06 codex 2>&1)"
|
|
5005
|
+
check "codex: launch on a brand-new account while the shared index is in use" "CFG=acct-06" "$out"
|
|
5006
|
+
[ "$(readlink "$CXS/acct-06/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
5007
|
+
&& t_ok "codex: an account with no index of its own is linked whatever the shared one is doing" \
|
|
5008
|
+
|| t_fail "codex new account link" "$(ls -la "$CXS/acct-06" 2>&1 | head -c 300)"
|
|
5009
|
+
rm -f "$CXH2/.codex/state_5.sqlite-wal" "$CXH2/.codex/state_5.sqlite-shm"
|
|
5010
|
+
# (e) a PRIVATE session tree keeps its private index
|
|
5011
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-03 codex 2>&1)"
|
|
5012
|
+
check "codex: launch on an account with its own session tree" "CFG=acct-03" "$out"
|
|
5013
|
+
{ [ -f "$CXS/acct-03/state_5.sqlite" ] && [ ! -L "$CXS/acct-03/state_5.sqlite" ]; } \
|
|
5014
|
+
&& t_ok "codex: an account with a private session tree keeps its private index" \
|
|
5015
|
+
|| t_fail "codex private tree index" "$(ls -la "$CXS/acct-03" 2>&1 | head -c 300)"
|
|
5016
|
+
# (f) the index the INSTALLED binary will create is linked ahead of time, so a schema bump
|
|
5017
|
+
# still costs one backfill per Mac, not one per account
|
|
5018
|
+
FAKEBIN_IDX="$WORK/fakebin-idx"
|
|
5019
|
+
mkdir -p "$FAKEBIN_IDX"
|
|
5020
|
+
{ cat "$FAKEBIN/codex"; printf '# rollout index name carried by the real binary: state_7.sqlite\n'; } > "$FAKEBIN_IDX/codex"
|
|
5021
|
+
chmod +x "$FAKEBIN_IDX/codex"
|
|
5022
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 PATH="$REPO_DIR/bin:$FAKEBIN_IDX:$PATH" codex 2>&1)"
|
|
5023
|
+
check "codex: launch through a binary that names a newer index" "CFG=acct-01" "$out"
|
|
5024
|
+
[ "$(readlink "$CXS/acct-01/state_7.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_7.sqlite" ] \
|
|
5025
|
+
&& t_ok "codex: the index the installed binary creates next is linked before it exists" \
|
|
5026
|
+
|| t_fail "codex index pre-link" "state_7.sqlite -> $(readlink "$CXS/acct-01/state_7.sqlite" 2>/dev/null)"
|
|
5027
|
+
[ ! -e "$CXH2/.codex/state_7.sqlite" ] && t_ok "codex: pre-linking creates nothing in the home itself" \
|
|
5028
|
+
|| t_fail "codex index pre-link home" "home gained state_7.sqlite"
|
|
5029
|
+
grep -q " state_7.sqlite$" "$CXS/.state-index" 2>/dev/null \
|
|
5030
|
+
&& t_ok "codex: the binary's index name is memoized per binary" \
|
|
5031
|
+
|| t_fail "codex index name memo" "$(cat "$CXS/.state-index" 2>&1)"
|
|
5032
|
+
# (f2) the launcher is a script that names no index; the schema name lives in the native
|
|
5033
|
+
# binary vendored beside it — the branch that actually fires on an npm install
|
|
5034
|
+
VENDOR="$WORK/npm/node_modules/@openai/codex/bin"
|
|
5035
|
+
mkdir -p "$VENDOR" "$WORK/npm/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin"
|
|
5036
|
+
{ cat "$FAKEBIN/codex"; printf '# a launcher script naming no index\n'; } > "$VENDOR/codex.js"
|
|
5037
|
+
chmod +x "$VENDOR/codex.js"
|
|
5038
|
+
printf 'binary bytes state_8.sqlite more bytes' \
|
|
5039
|
+
> "$WORK/npm/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex"
|
|
5040
|
+
FAKEBIN_VENDOR="$WORK/fakebin-vendor"
|
|
5041
|
+
mkdir -p "$FAKEBIN_VENDOR"
|
|
5042
|
+
ln -s "$VENDOR/codex.js" "$FAKEBIN_VENDOR/codex"
|
|
5043
|
+
rm -f "$CXS/.state-index"
|
|
5044
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 PATH="$REPO_DIR/bin:$FAKEBIN_VENDOR:$PATH" codex 2>&1)"
|
|
5045
|
+
check "codex: launch through an npm launcher with a vendored binary" "CFG=acct-01" "$out"
|
|
5046
|
+
[ "$(readlink "$CXS/acct-01/state_8.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_8.sqlite" ] \
|
|
5047
|
+
&& t_ok "codex: the index name is read from the vendored binary when the launcher names none" \
|
|
5048
|
+
|| t_fail "codex vendored index name" "$(ls "$CXS/acct-01" 2>&1 | tr '\n' ' '); memo=$(cat "$CXS/.state-index" 2>&1)"
|
|
5049
|
+
grep -q "^$(cd "$(dirname "$VENDOR/codex.js")" && pwd -P)/codex.js:" "$CXS/.state-index" 2>/dev/null \
|
|
5050
|
+
&& t_ok "codex: the memo keys on the file the launcher symlink resolves to, not the link" \
|
|
5051
|
+
|| t_fail "codex memo key" "$(cat "$CXS/.state-index" 2>&1)"
|
|
5052
|
+
rm -f "$CXS/.state-index" "$CXS/acct-01/state_8.sqlite"
|
|
5053
|
+
# (g) an adopted account (the dir IS the home) is never rewritten
|
|
5054
|
+
ln -s "$CXH2/.codex" "$CXS/acct-04"
|
|
5055
|
+
python3 - "$CXS/accounts.json" <<'PYEOF'
|
|
5056
|
+
import json, sys
|
|
5057
|
+
doc = json.load(open(sys.argv[1]))
|
|
5058
|
+
doc['accounts'].append({'id': 'acct-04', 'email': 'adopted@cx', 'home': 'mac', 'added_at': '2026-09-09T00:00:00Z'})
|
|
5059
|
+
json.dump(doc, open(sys.argv[1], 'w'), indent=2)
|
|
5060
|
+
PYEOF
|
|
5061
|
+
mk_cx_auth "$CXH2/.codex/auth.json" adopted@cx "$FUTURE_EXP"
|
|
5062
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-04 codex 2>&1)"
|
|
5063
|
+
check "codex: launch on an adopted account" "CFG=acct-04" "$out"
|
|
5064
|
+
{ [ -f "$CXH2/.codex/state_5.sqlite" ] && [ ! -L "$CXH2/.codex/state_5.sqlite" ]; } \
|
|
5065
|
+
&& t_ok "codex: the home's own index is never turned into a link" \
|
|
5066
|
+
|| t_fail "codex adopted index" "$(ls -la "$CXH2/.codex" 2>&1 | head -c 300)"
|
|
5067
|
+
# (i) codex's own corruption recovery moved this account's LINK into db-backups/ and
|
|
5068
|
+
# rebuilt a private index in its place: that is codex's verdict on the shared file,
|
|
5069
|
+
# so the account keeps what codex rebuilt and this name is left alone entirely.
|
|
5070
|
+
mkdir -p "$CXS/acct-01/db-backups/sqlite-1700000000-0"
|
|
5071
|
+
mv "$CXS/acct-01/state_5.sqlite" "$CXS/acct-01/db-backups/sqlite-1700000000-0/state_5.sqlite"
|
|
5072
|
+
printf 'rebuilt after damage' > "$CXS/acct-01/state_5.sqlite"
|
|
5073
|
+
homesum="$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)"
|
|
5074
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-01 codex 2>&1)"
|
|
5075
|
+
check "codex: launch after codex rejected the shared index for this account" "CFG=acct-01" "$out"
|
|
5076
|
+
{ [ "$(cat "$CXS/acct-01/state_5.sqlite" 2>/dev/null)" = "rebuilt after damage" ] \
|
|
5077
|
+
&& [ ! -L "$CXS/acct-01/state_5.sqlite" ]; } \
|
|
5078
|
+
&& t_ok "codex: an account codex rebuilt for keeps that index instead of the shared one" \
|
|
5079
|
+
|| t_fail "codex rejected shared index" "$(ls -la "$CXS/acct-01" 2>&1 | head -c 400)"
|
|
5080
|
+
[ "$(cat "$CXH2/.codex/state_5.sqlite" 2>/dev/null)" = "$homesum" ] \
|
|
5081
|
+
&& t_ok "codex: the rejected shared index is left exactly as it was, for its other users" \
|
|
5082
|
+
|| t_fail "codex rejected shared index home" "home index changed"
|
|
5083
|
+
# the marker only speaks for the file it names: a link pointing elsewhere is not a verdict
|
|
5084
|
+
mkdir -p "$CXS/acct-05/db-backups/sqlite-1700000000-0"
|
|
5085
|
+
mkdir -p "$CXS/acct-05" && ln -s "$CXH2/.codex/sessions" "$CXS/acct-05/sessions"
|
|
5086
|
+
mk_cx_auth "$CXS/acct-05/auth.json" s5@cx "$FUTURE_EXP"
|
|
5087
|
+
ln -s "$CXH2/.codex/state_9.sqlite" "$CXS/acct-05/db-backups/sqlite-1700000000-0/state_5.sqlite"
|
|
5088
|
+
printf 'unrelated private index' > "$CXS/acct-05/state_5.sqlite"
|
|
5089
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-05 codex 2>&1)"
|
|
5090
|
+
check "codex: launch with a db-backups link naming another file" "CFG=acct-05" "$out"
|
|
5091
|
+
[ "$(readlink "$CXS/acct-05/state_5.sqlite" 2>/dev/null)" = "$CXH2/.codex/state_5.sqlite" ] \
|
|
5092
|
+
&& t_ok "codex: a db-backups link that names a different file is not a verdict on the shared one" \
|
|
5093
|
+
|| t_fail "codex marker scope" "$(ls -la "$CXS/acct-05" 2>&1 | head -c 300)"
|
|
5094
|
+
# (j) a second retirement keeps the first retired copy
|
|
5095
|
+
rm "$CXS/acct-02/state_5.sqlite"
|
|
5096
|
+
printf 'second private index' > "$CXS/acct-02/state_5.sqlite"
|
|
5097
|
+
out="$(HOME="$CXH2" CODEX_ACCOUNTS_DIR="$CXS" CODEX_ACCOUNT=acct-02 codex 2>&1)"
|
|
5098
|
+
check "codex: launch on an account retiring a second private index" "CFG=acct-02" "$out"
|
|
5099
|
+
{ [ "$(cat "$CXS/acct-02/state_5.sqlite.private" 2>/dev/null)" = "open private index" ] \
|
|
5100
|
+
&& [ "$(cat "$CXS"/acct-02/state_5.sqlite.private.[0-9]* 2>/dev/null)" = "second private index" ] \
|
|
5101
|
+
&& [ -L "$CXS/acct-02/state_5.sqlite" ]; } \
|
|
5102
|
+
&& t_ok "codex: retiring never overwrites an earlier retired copy" \
|
|
5103
|
+
|| t_fail "codex retire unique" "$(ls "$CXS/acct-02" 2>&1 | tr '\n' ' ')"
|
|
5104
|
+
# (k) several processes promoting the same account at once. `mv` here could rename one
|
|
5105
|
+
# shim's fresh symlink onto the file another had just promoted, leaving
|
|
5106
|
+
# ~/.codex/state_N.sqlite pointing at itself — ELOOP, every account on the Mac unable
|
|
5107
|
+
# to start, and repaired by neither codex (ELOOP is not a corruption code) nor this
|
|
5108
|
+
# shim (`[ -L "$link" ]` would skip the name forever). link(2) refuses an existing
|
|
5109
|
+
# target, so the promote is atomic or it does not happen. The window is narrow, so
|
|
5110
|
+
# this drives the core function directly and repeats until the odds are not the test's.
|
|
5111
|
+
CORE="$WORK/state-index-core.sh"
|
|
5112
|
+
{ printf '#!/usr/bin/env bash\nset -u\n'
|
|
5113
|
+
awk '/^state_index_names\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5114
|
+
awk '/^shared_index_rejected\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5115
|
+
awk '/^share_state_index_links\(\) \{/,/^}$/' "$REPO_DIR/bin/codex"
|
|
5116
|
+
printf 'share_state_index_links "$1" "$2" ""\n'; } > "$CORE"
|
|
5117
|
+
chmod +x "$CORE"
|
|
5118
|
+
# The losing interleave is: the winner promotes and links, THEN the loser acts on a
|
|
5119
|
+
# decision it made before either happened. Forcing it beats waiting for it — a `ln`/`mv`
|
|
5120
|
+
# that sleeps puts the loser inside that window on purpose, and the winner is injected
|
|
5121
|
+
# there. With the rename promote this renames the loser's own link onto its target; with
|
|
5122
|
+
# link(2) it simply refuses.
|
|
5123
|
+
SLOWBIN="$WORK/slowbin"
|
|
5124
|
+
mkdir -p "$SLOWBIN"
|
|
5125
|
+
for cmd in ln mv; do
|
|
5126
|
+
printf '#!/usr/bin/env bash\nsleep 1\nfor c in /bin/%s /usr/bin/%s; do [ -x "$c" ] && exec "$c" "$@"; done\nexit 127\n' \
|
|
5127
|
+
"$cmd" "$cmd" > "$SLOWBIN/$cmd"
|
|
5128
|
+
chmod +x "$SLOWBIN/$cmd"
|
|
5129
|
+
done
|
|
5130
|
+
rd="$WORK/promote-interleave"
|
|
5131
|
+
mkdir -p "$rd/home/sessions" "$rd/acct"
|
|
5132
|
+
ln -s "$rd/home/sessions" "$rd/acct/sessions"
|
|
5133
|
+
printf 'the loser index' > "$rd/acct/state_5.sqlite"
|
|
5134
|
+
( PATH="$SLOWBIN:$PATH" "$CORE" "$rd/acct" "$rd/home" >/dev/null 2>&1 ) &
|
|
5135
|
+
slow=$!
|
|
5136
|
+
sleep 0.4 # the loser has decided the home is empty, and not yet acted
|
|
5137
|
+
printf 'the winner index' > "$rd/home/state_5.sqlite"
|
|
5138
|
+
rm -f "$rd/acct/state_5.sqlite"
|
|
5139
|
+
ln -s "$rd/home/state_5.sqlite" "$rd/acct/state_5.sqlite"
|
|
5140
|
+
wait "$slow" 2>/dev/null
|
|
5141
|
+
{ [ -f "$rd/home/state_5.sqlite" ] && [ ! -L "$rd/home/state_5.sqlite" ] \
|
|
5142
|
+
&& [ "$(cat "$rd/home/state_5.sqlite" 2>/dev/null)" = "the winner index" ]; } \
|
|
5143
|
+
&& t_ok "codex: a promote that loses the race refuses instead of renaming onto the winner" \
|
|
5144
|
+
|| t_fail "codex promote atomicity" "shared index is $(ls -la "$rd/home/state_5.sqlite" 2>&1 | head -c 160)"
|
|
5145
|
+
[ "$(cat "$rd/acct/state_5.sqlite" 2>/dev/null)" = "the winner index" ] \
|
|
5146
|
+
&& t_ok "codex: the losing account still reads the shared index through its link" \
|
|
5147
|
+
|| t_fail "codex promote atomicity" "acct reads $(cat "$rd/acct/state_5.sqlite" 2>&1 | head -c 120)"
|
|
5148
|
+
|
|
5149
|
+
races=0; bad=0; badwhy=""
|
|
5150
|
+
|
|
5151
|
+
# (l) the heal belongs to the LAUNCH, not to the pin: an unpinned selection and a retry
|
|
5152
|
+
# that rotates to another account must both link the account they actually serve.
|
|
5153
|
+
CXU="$WORK/cx-unpinned"; CXHU="$WORK/cxhome-unpinned"
|
|
5154
|
+
mkdir -p "$CXU/tmp" "$CXU/acct-01" "$CXU/acct-02" "$CXHU/.codex/sessions"
|
|
5155
|
+
: > "$CXU/.limits-kick"
|
|
5156
|
+
cat > "$CXU/accounts.json" <<EOF2
|
|
5157
|
+
{"version": 1, "server": "root@203.0.113.1", "server_root": "/root/.codex-accounts",
|
|
5158
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
5159
|
+
"accounts": [
|
|
5160
|
+
{"id": "acct-01", "email": "u1@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"},
|
|
5161
|
+
{"id": "acct-02", "email": "u2@cx", "home": "mac", "added_at": "2026-09-09T00:00:00Z"}]}
|
|
5162
|
+
EOF2
|
|
5163
|
+
for i in 01 02; do
|
|
5164
|
+
mk_cx_auth "$CXU/acct-$i/auth.json" "u$i@cx" "$FUTURE_EXP"
|
|
5165
|
+
ln -s "$CXHU/.codex/sessions" "$CXU/acct-$i/sessions"
|
|
5166
|
+
done
|
|
5167
|
+
printf 'the shared index' > "$CXHU/.codex/state_5.sqlite"
|
|
5168
|
+
cxlj 5 5 5 > "$CXU/acct-01/limits.json"
|
|
5169
|
+
cxlj 20 20 20 > "$CXU/acct-02/limits.json"
|
|
5170
|
+
out="$(HOME="$CXHU" CODEX_ACCOUNTS_DIR="$CXU" CODEX_MULTIACC_HEADROOM_BAND=0 codex 2>&1)"
|
|
5171
|
+
picked="$(printf '%s' "$out" | sed -n 's/.*CFG=\(acct-[0-9]*\).*/\1/p' | head -1)"
|
|
5172
|
+
{ [ -n "$picked" ] \
|
|
5173
|
+
&& [ "$(readlink "$CXU/$picked/state_5.sqlite" 2>/dev/null)" = "$CXHU/.codex/state_5.sqlite" ]; } \
|
|
5174
|
+
&& t_ok "codex: an unpinned launch links the account it selected ($picked)" \
|
|
5175
|
+
|| t_fail "codex unpinned heal" "picked=${picked:-none} $(ls -la "$CXU"/acct-0*/state_5.sqlite 2>&1 | head -c 300)"
|
|
5176
|
+
rm -f "$CXU"/acct-0*/state_5.sqlite
|
|
5177
|
+
echo "fail:acct-01" > "$FAKE_CTL2"
|
|
5178
|
+
out="$(HOME="$CXHU" CODEX_ACCOUNTS_DIR="$CXU" CODEX_MULTIACC_HEADROOM_BAND=0 codex exec "hello" < /dev/null 2>&1)"
|
|
5179
|
+
rm -f "$FAKE_CTL2" "$CXU/acct-01/.limited"
|
|
5180
|
+
case "$out" in *CFG=acct-02*) t_ok "codex: the retry rotated to the second account" ;;
|
|
5181
|
+
*) t_fail "codex retry heal" "did not land on acct-02: $(printf '%s' "$out" | head -c 200)" ;; esac
|
|
5182
|
+
[ "$(readlink "$CXU/acct-02/state_5.sqlite" 2>/dev/null)" = "$CXHU/.codex/state_5.sqlite" ] \
|
|
5183
|
+
&& t_ok "codex: the account a retry rotates onto is linked too" \
|
|
5184
|
+
|| t_fail "codex retry heal" "acct-02 -> $(readlink "$CXU/acct-02/state_5.sqlite" 2>/dev/null)"
|
|
5185
|
+
|
|
5186
|
+
# (h) the two copies of the core (shim + lib/common.sh) are byte-identical
|
|
5187
|
+
core_of() { # $1 file, $2 function name -> its body
|
|
5188
|
+
awk -v fn="$2" '$0 ~ "^"fn"\\(\\) \\{" {p=1} p {print} p && /^}$/ {exit}' "$1"
|
|
5189
|
+
}
|
|
5190
|
+
for fn in state_index_names shared_index_rejected share_state_index_links; do
|
|
5191
|
+
if [ "$(core_of "$REPO_DIR/bin/codex" "$fn")" = "$(core_of "$REPO_DIR/lib/common.sh" "$fn")" ] \
|
|
5192
|
+
&& [ -n "$(core_of "$REPO_DIR/bin/codex" "$fn")" ]; then
|
|
5193
|
+
t_ok "codex: $fn is byte-identical in bin/codex and lib/common.sh"
|
|
5194
|
+
else
|
|
5195
|
+
t_fail "codex shared-index parity" "$fn differs between bin/codex and lib/common.sh"
|
|
5196
|
+
fi
|
|
5197
|
+
done
|
|
5198
|
+
|
|
4891
5199
|
# ---- C12. login command + expired worklist + relogin --------------------------------
|
|
4892
5200
|
# an account with no auth on this machine: login completes it
|
|
4893
5201
|
codex-accounts import d@cx --id acct-04 --no-sync >/dev/null 2>&1
|
|
@@ -5617,8 +5925,11 @@ EOF
|
|
|
5617
5925
|
grep -q "rt-rotated-new" "$CX/limits.log" \
|
|
5618
5926
|
&& t_fail "codex token leak" "a refresh token leaked into limits.log" \
|
|
5619
5927
|
|| t_ok "codex: limits.log leaks no tokens"
|
|
5620
|
-
# recently-expired token is left alone (a live codex session may own it) — even --force
|
|
5621
|
-
|
|
5928
|
+
# recently-expired token is left alone (a live codex session may own it) — even --force.
|
|
5929
|
+
# The expiry is measured from NOW, not from the suite's start: the gate is a five-minute
|
|
5930
|
+
# window, and pinning it to $now silently makes this assertion depend on how long every
|
|
5931
|
+
# preceding test took.
|
|
5932
|
+
mk_cx_auth "$CX/acct-01/auth.json" a@cx "$(( $(date +%s) - 100 ))"
|
|
5622
5933
|
cp "$CX/acct-01/auth.json" "$WORK/cx-recent.bak"
|
|
5623
5934
|
rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
|
|
5624
5935
|
out="$(CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-ok.json" CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-low.json" codex-accounts limits --force 2>&1)"
|