claude-multiacc 2.0.26 → 2.0.28

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.
@@ -4906,6 +4906,296 @@ check "codex: sign-in as registered email refused" "already added as acct-01" "$
4906
4906
  n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
4907
4907
  [ "$n" = "1" ] && t_ok "codex: no second entry for a re-signed-in email" || t_fail "codex dup signin" "count=$n"
4908
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
+
4909
5199
  # ---- C12. login command + expired worklist + relogin --------------------------------
4910
5200
  # an account with no auth on this machine: login completes it
4911
5201
  codex-accounts import d@cx --id acct-04 --no-sync >/dev/null 2>&1
@@ -5635,8 +5925,11 @@ EOF
5635
5925
  grep -q "rt-rotated-new" "$CX/limits.log" \
5636
5926
  && t_fail "codex token leak" "a refresh token leaked into limits.log" \
5637
5927
  || t_ok "codex: limits.log leaks no tokens"
5638
- # recently-expired token is left alone (a live codex session may own it) — even --force
5639
- mk_cx_auth "$CX/acct-01/auth.json" a@cx "$((now - 100))"
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 ))"
5640
5933
  cp "$CX/acct-01/auth.json" "$WORK/cx-recent.bak"
5641
5934
  rm -f "$CX/acct-01/limits.json" "$CX/acct-01/.oauth-refresh.json"
5642
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)"
@@ -0,0 +1,170 @@
1
+ """Exercise documented commands from npm's actual bin links, without shell rc files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import plistlib
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import unittest
10
+
11
+ from packaged_command_support import ROOT, install_package, run, seed_pools
12
+
13
+
14
+ @unittest.skipUnless(shutil.which("node") and shutil.which("npm"), "Node/npm required")
15
+ class PackagedCommandsTests(unittest.TestCase):
16
+ @classmethod
17
+ def setUpClass(cls):
18
+ install_package(cls)
19
+
20
+ def test_global_commands_work_without_postinstall_or_shell_rc(self):
21
+ for command in ("claude-multiacc", "claude-accounts", "codex-accounts", "multiacc-select"):
22
+ with self.subTest(command=command):
23
+ resolved = shutil.which(command, path=self.env["PATH"])
24
+ self.assertEqual(resolved, str(self.prefix / "bin" / command))
25
+ flag = "--version" if command == "multiacc-select" else "--help"
26
+ result = run([command, flag], self.env, self.work)
27
+ self.assertEqual(result.returncode, 0, result.stderr)
28
+ for shell in ("bash", "zsh"):
29
+ if not shutil.which(shell):
30
+ continue
31
+ result = run([shell, "-fc", "codex-accounts verify --help"], self.env, self.work)
32
+ self.assertEqual(result.returncode, 0, result.stderr)
33
+ for command in ("claude-accounts", "codex-accounts"):
34
+ result = run([command, "list"], self.env, self.work)
35
+ self.assertNotEqual(result.returncode, 0)
36
+ self.assertIn("run claude-multiacc install first", result.stderr)
37
+
38
+ def test_every_account_help_command_dispatches_directly_and_via_wrapper(self):
39
+ for provider in ("claude", "codex"):
40
+ direct = f"{provider}-accounts"
41
+ help_result = run([str(self.package / "bin" / direct), "--help"], self.env, self.work)
42
+ verbs = set(re.findall(rf"^ {direct} ([a-z-]+)", help_result.stdout, re.MULTILINE))
43
+ self.assertGreaterEqual(len(verbs), 18)
44
+ wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
45
+ for prefix in ([direct], wrapper):
46
+ for verb in sorted(verbs):
47
+ with self.subTest(prefix=prefix, verb=verb):
48
+ result = run(prefix + [verb, "--help"], self.env, self.work)
49
+ self.assertEqual(result.returncode, 0, result.stderr)
50
+ self.assertIn(f"{direct} {verb}", result.stdout)
51
+ result = run(prefix + ["not-a-command", "--help"], self.env, self.work)
52
+ self.assertNotEqual(result.returncode, 0)
53
+
54
+ def test_wrapper_and_installer_help_have_no_side_effects(self):
55
+ before = sorted(str(path) for path in self.home.rglob("*"))
56
+ for verb in ("help", "install", "update", "uninstall", "doctor", "self-update", "codex", "select"):
57
+ with self.subTest(verb=verb):
58
+ result = run(["claude-multiacc", verb, "--help"], self.env, self.work)
59
+ self.assertEqual(result.returncode, 0, result.stderr)
60
+ self.assertRegex(result.stdout, r"(?i)usage")
61
+ result = run([str(self.package / "install.sh"), "--help"], self.env, self.work)
62
+ self.assertEqual(result.returncode, 0, result.stderr)
63
+ self.assertEqual(before, sorted(str(path) for path in self.home.rglob("*")))
64
+
65
+ def test_verify_and_json_reports_use_the_correct_pool(self):
66
+ seed_pools(self)
67
+ for provider in ("claude", "codex"):
68
+ wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
69
+ for prefix in ([f"{provider}-accounts"], wrapper):
70
+ for verb in ("list", "status"):
71
+ result = run(prefix + [verb, "--json"], self.env, self.work)
72
+ self.assertEqual(result.returncode, 0, result.stderr)
73
+ self.assertEqual(json.loads(result.stdout)["provider"], provider)
74
+ for flags, expected in ((["--quick"], ": OK"), ([], ": PASS")):
75
+ result = run(prefix + ["verify"] + flags, self.env, self.work)
76
+ self.assertEqual(result.returncode, 0, result.stderr)
77
+ self.assertIn(f"{provider}@example.test{expected}", result.stdout)
78
+
79
+ def test_self_update_uses_the_running_install_prefix(self):
80
+ fake_npm = self.prefix / "bin/npm"
81
+ original = fake_npm.read_text()
82
+ fake_npm.write_text('#!/usr/bin/env bash\n'
83
+ 'if [ "$1" = view ]; then echo 999.0.0; exit 0; fi\n'
84
+ 'printf "%s\\n" "$@" > "$HOME/update-args"\nexit 42\n')
85
+ fake_npm.chmod(0o755)
86
+ try:
87
+ result = run(["claude-multiacc", "self-update"], self.env, self.work)
88
+ self.assertNotEqual(result.returncode, 0)
89
+ args = (self.home / "update-args").read_text().splitlines()
90
+ self.assertEqual(args, ["install", "-g", "--prefix", str(self.prefix), "claude-multiacc@latest"])
91
+ finally:
92
+ fake_npm.write_text(original)
93
+
94
+ def test_git_and_npx_path_entrypoint_matches_npm(self):
95
+ entrypoint = self.package / "bin/claude-multiacc"
96
+ self.assertTrue(entrypoint.is_file())
97
+ result = run([str(entrypoint), "--version"], self.env, self.work)
98
+ self.assertEqual(result.returncode, 0, result.stderr)
99
+ self.assertEqual(result.stdout.strip(), json.loads((self.package / "package.json").read_text())["version"])
100
+
101
+ def test_installer_is_idempotent_and_instances_leave_system_commands_alone(self):
102
+ uname = self.prefix / "bin/uname"
103
+ identity = self.prefix / "bin/id"
104
+ uname.write_text('#!/usr/bin/env bash\necho Darwin\n')
105
+ identity.write_text('#!/usr/bin/env bash\necho 0\n')
106
+ uname.chmod(0o755)
107
+ identity.chmod(0o755)
108
+ try:
109
+ for _ in range(2):
110
+ result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
111
+ self.assertEqual(result.returncode, 0, result.stderr)
112
+ self.assertNotIn("status |", result.stdout)
113
+ for rc in (".zshenv", ".zprofile", ".zshrc"):
114
+ self.assertEqual((self.home / rc).read_text().count("# >>> claude-multiacc >>>"), 1)
115
+ calls = (self.home / "tool-calls").read_text()
116
+ self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
117
+ script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
118
+ result = run(["bash", "-c", script, str(self.package / "install.sh")], self.env, self.work)
119
+ self.assertEqual(result.returncode, 0, result.stderr)
120
+ plists = list((self.home / "Library/LaunchAgents").glob("*.plist"))
121
+ self.assertEqual(len(plists), 5)
122
+ for path in plists:
123
+ job = plistlib.loads(path.read_bytes())
124
+ self.assertIn("PATH", job["EnvironmentVariables"])
125
+ self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
126
+ result = run(["claude-multiacc", "uninstall"], self.env, self.work)
127
+ self.assertEqual(result.returncode, 0, result.stderr)
128
+ self.assertNotIn("claude-multiacc", (self.home / ".zshrc").read_text())
129
+ self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
130
+ uname.write_text('#!/usr/bin/env bash\necho Linux\n')
131
+ (self.home / "tool-calls").write_text("")
132
+ args = ["install", "--instance", "sandbox", "--no-server", "--no-schedule"]
133
+ result = run(["claude-multiacc"] + args, self.env, self.work)
134
+ self.assertEqual(result.returncode, 0, result.stderr)
135
+ self.assertNotIn("/ln ", (self.home / "tool-calls").read_text())
136
+ finally:
137
+ uname.unlink()
138
+ identity.unlink()
139
+
140
+ def test_selector_transport_and_version_match_across_entrypoints(self):
141
+ expected = None
142
+ for prefix in (["multiacc-select"], ["claude-multiacc", "select"]):
143
+ version = run(prefix + ["--version"], self.env, self.work)
144
+ self.assertEqual(version.returncode, 0, version.stderr)
145
+ result = run(prefix + ["--request-json", "-", "--response-json", "-"], self.env, self.work, input="{}")
146
+ self.assertEqual(result.returncode, 0, result.stderr)
147
+ response = json.loads(result.stdout)
148
+ self.assertEqual(response["selector_version"], version.stdout.strip())
149
+ self.assertEqual(response["error_code"], "invalid_request")
150
+ if expected is not None:
151
+ self.assertEqual(response, expected)
152
+ expected = response
153
+
154
+ def test_documented_shell_examples_parse_and_command_names_exist(self):
155
+ documents = [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))]
156
+ for document in documents:
157
+ for block in re.findall(r"```(?:bash|sh)\n(.*?)```", document.read_text(), re.DOTALL):
158
+ result = run(["bash", "-n"], self.env, self.work, input=block)
159
+ self.assertEqual(result.returncode, 0, f"{document.name}: {result.stderr}")
160
+ for line in block.splitlines():
161
+ tokens = shlex.split(line, comments=True)
162
+ if not tokens or tokens[0] not in ("claude-accounts", "codex-accounts", "claude-multiacc"):
163
+ continue
164
+ self.assertNotIn("|", tokens, f"{document.name}: alternatives must be separate commands")
165
+ self.assertIsNone(re.search(r"--[a-z-]+#", line), line)
166
+ self.assertIsNotNone(shutil.which(tokens[0], path=self.env["PATH"]), line)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()