claude-multiacc 1.0.12 → 1.0.13
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/bin/codex-accounts +70 -10
- package/package.json +1 -1
- package/tests/run-tests.sh +63 -0
package/bin/codex-accounts
CHANGED
|
@@ -146,6 +146,36 @@ os.replace(path + '.tmp', path)
|
|
|
146
146
|
PYEOF
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
# ---- per-account login lock ----------------------------------------------------
|
|
150
|
+
# Two concurrent logins for the SAME account corrupt each other: the later one
|
|
151
|
+
# snapshots the OLD credential and its abort/refusal "restores" that stale
|
|
152
|
+
# credential over the fresh one the first sign-in produced. A ceremony can take
|
|
153
|
+
# minutes, so the lock is only reclaimed after 30 minutes (holder presumed dead).
|
|
154
|
+
# Stale reclaim is ATOMIC between contenders: rename steals the stale lock
|
|
155
|
+
# (exactly one mv succeeds); the loser finds a live lock and refuses. Release is
|
|
156
|
+
# OWNERSHIP-GUARDED (owner.<pid> marker), so a process can never remove a lock a
|
|
157
|
+
# later contender legitimately re-took.
|
|
158
|
+
login_lock_acquire() { # $1 = lock dir; nonzero when someone else holds it
|
|
159
|
+
local L="$1"
|
|
160
|
+
if ! mkdir "$L" 2>/dev/null; then
|
|
161
|
+
[ $(( $(epoch_now) - $(file_mtime "$L") )) -gt 1800 ] || return 1
|
|
162
|
+
mv "$L" "$L.stale.$$" 2>/dev/null || return 1
|
|
163
|
+
rm -rf "$L.stale.$$" 2>/dev/null
|
|
164
|
+
mkdir "$L" 2>/dev/null || return 1
|
|
165
|
+
fi
|
|
166
|
+
: > "$L/owner.$$" 2>/dev/null || true
|
|
167
|
+
return 0
|
|
168
|
+
}
|
|
169
|
+
login_lock_release() { # $1 = lock dir; removes it ONLY if this process owns it
|
|
170
|
+
local L="$1"
|
|
171
|
+
[ -e "$L/owner.$$" ] && rm -rf "$L" 2>/dev/null
|
|
172
|
+
return 0
|
|
173
|
+
}
|
|
174
|
+
login_lock_busy() { # $1 = acct dir; true when a LIVE login ceremony holds it
|
|
175
|
+
[ -d "$1/.login-lock" ] \
|
|
176
|
+
&& [ $(( $(epoch_now) - $(file_mtime "$1/.login-lock") )) -le 1800 ]
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
150
180
|
[ "$(machine_kind)" = "mac" ] || return 0
|
|
151
181
|
[ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
|
|
@@ -424,15 +454,19 @@ cmd_add() {
|
|
|
424
454
|
# The sign-in itself succeeded and produced a fresh credential for an account
|
|
425
455
|
# that is already registered — don't throw it away: save it to that account,
|
|
426
456
|
# exactly as `login $owner` would have. This machine now runs $owner.
|
|
457
|
+
# Needs the owner's login lock (non-blocking): writing under a mid-ceremony
|
|
458
|
+
# login for that account would corrupt it.
|
|
427
459
|
local od="$ACC_ROOT/$owner"
|
|
428
460
|
seed_account_dir "$od"
|
|
429
|
-
if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
|
|
461
|
+
if login_lock_acquire "$od/.login-lock" && mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
|
|
430
462
|
chmod 600 "$od/auth.json" 2>/dev/null || true
|
|
431
463
|
clear_auth_markers "$od"
|
|
464
|
+
login_lock_release "$od/.login-lock"
|
|
432
465
|
mutate_unlock
|
|
433
466
|
log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
|
|
434
467
|
echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
|
|
435
468
|
else
|
|
469
|
+
login_lock_release "$od/.login-lock"
|
|
436
470
|
mutate_unlock
|
|
437
471
|
echo "$got is already added as $owner — skipping (nothing added)."
|
|
438
472
|
fi
|
|
@@ -632,6 +666,10 @@ cmd_remove() {
|
|
|
632
666
|
read -r ans
|
|
633
667
|
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
634
668
|
fi
|
|
669
|
+
# Never delete an account out from under a live sign-in ceremony: the login's
|
|
670
|
+
# cleanup would then operate on a removed (or later recreated) path.
|
|
671
|
+
login_lock_busy "$ACC_ROOT/$id" \
|
|
672
|
+
&& die "a login for $id is in progress — finish or abort it first"
|
|
635
673
|
if [ -L "$ACC_ROOT/$id" ]; then
|
|
636
674
|
rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
|
|
637
675
|
else
|
|
@@ -671,6 +709,18 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
|
671
709
|
break
|
|
672
710
|
PYEOF
|
|
673
711
|
)"
|
|
712
|
+
# PER-ACCOUNT login lock (observed in the field: parallel relogin runs left a
|
|
713
|
+
# months-dead token inside a minutes-old auth.json — see login_lock_acquire).
|
|
714
|
+
local llock="$d/.login-lock"
|
|
715
|
+
login_lock_acquire "$llock" \
|
|
716
|
+
|| die "another login for $id is already in progress (this terminal or another) — finish or abort it first"
|
|
717
|
+
# Released on EVERY exit: normal returns, die (exit fires EXIT), Ctrl-C (the INT
|
|
718
|
+
# handler exits, which fires EXIT). Runs in a subshell under relogin, so the trap
|
|
719
|
+
# scope is exactly this one login. Ownership-guarded: never removes a lock a
|
|
720
|
+
# later contender re-took.
|
|
721
|
+
# shellcheck disable=SC2064
|
|
722
|
+
trap "login_lock_release '$llock'" EXIT
|
|
723
|
+
|
|
674
724
|
echo "Sign in as $email for $id."
|
|
675
725
|
# Snapshot the credential BEFORE the ceremony: `codex login` writes auth.json into
|
|
676
726
|
# the dir directly, so a sign-in to the WRONG account would otherwise leave that
|
|
@@ -712,17 +762,27 @@ PYEOF
|
|
|
712
762
|
local owner
|
|
713
763
|
owner="$(email_owner "$got")"
|
|
714
764
|
if [ -n "$owner" ] && [ "$owner" != "$id" ]; then
|
|
765
|
+
# The redirect mutates the OWNER's credential, so it needs the owner's login
|
|
766
|
+
# lock too (non-blocking: if a login for that account is mid-ceremony, writing
|
|
767
|
+
# under it would recreate exactly the corruption this lock prevents — skip the
|
|
768
|
+
# redirect and fall through to the plain refusal instead).
|
|
715
769
|
local od="$ACC_ROOT/$owner"
|
|
716
770
|
seed_account_dir "$od"
|
|
717
|
-
if
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
771
|
+
if login_lock_acquire "$od/.login-lock"; then
|
|
772
|
+
if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
|
|
773
|
+
chmod 600 "$od/auth.json" 2>/dev/null || true
|
|
774
|
+
clear_auth_markers "$od"
|
|
775
|
+
login_lock_release "$od/.login-lock"
|
|
776
|
+
restore_snap
|
|
777
|
+
trap - INT TERM
|
|
778
|
+
log_to ops.log "login redirect: signed in as $got -> credential saved to $owner (target was $id)"
|
|
779
|
+
echo "you signed in as $got — that is $owner, so the credential was saved to $owner (usable now)."
|
|
780
|
+
echo "$id ($email) still needs its own sign-in: codex-accounts login $id"
|
|
781
|
+
return 1
|
|
782
|
+
fi
|
|
783
|
+
login_lock_release "$od/.login-lock"
|
|
784
|
+
else
|
|
785
|
+
echo "note: a login for $owner is in progress — not saving this credential there."
|
|
726
786
|
fi
|
|
727
787
|
fi
|
|
728
788
|
restore_snap
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -1828,6 +1828,69 @@ rc=$?
|
|
|
1828
1828
|
{ [ "$rc" = "0" ] && [ "$(cat "$FAKE_LOGIN_ARGS")" = "" ]; } \
|
|
1829
1829
|
&& t_ok "codex: --browser opts into the localhost callback flow" \
|
|
1830
1830
|
|| t_fail "codex --browser opt-out" "rc=$rc login args: '$(cat "$FAKE_LOGIN_ARGS")'"
|
|
1831
|
+
# lock released after a completed login
|
|
1832
|
+
[ ! -d "$CX/acct-04/.login-lock" ] && t_ok "codex: login lock released after completion" \
|
|
1833
|
+
|| { t_fail "codex login lock release" ".login-lock survived a completed login"; rmdir "$CX/acct-04/.login-lock" 2>/dev/null; }
|
|
1834
|
+
# a SECOND concurrent login for the same account is refused before any ceremony —
|
|
1835
|
+
# a parallel run snapshotting the old credential could otherwise "restore" it over
|
|
1836
|
+
# the fresh one (field incident: months-dead token inside a minutes-old auth.json)
|
|
1837
|
+
mkdir "$CX/acct-04/.login-lock"
|
|
1838
|
+
export FAKE_LOGIN_ARGS="$WORK/cx-login-args"
|
|
1839
|
+
: > "$FAKE_LOGIN_ARGS"
|
|
1840
|
+
cp "$CX/acct-04/auth.json" "$WORK/cx-lock-auth.bak"
|
|
1841
|
+
out="$(FAKE_EMAIL=d@cx codex-accounts login acct-04 2>&1)"
|
|
1842
|
+
rc=$?
|
|
1843
|
+
check "codex: concurrent login for the same account is refused" "already in progress" "$out"
|
|
1844
|
+
[ "$rc" != "0" ] && t_ok "codex: concurrent login exits nonzero" || t_fail "codex login lock rc" "rc=0"
|
|
1845
|
+
[ ! -s "$FAKE_LOGIN_ARGS" ] && t_ok "codex: refused concurrent login never ran a ceremony" \
|
|
1846
|
+
|| t_fail "codex login lock ceremony" "codex login was invoked despite the lock"
|
|
1847
|
+
cmp -s "$CX/acct-04/auth.json" "$WORK/cx-lock-auth.bak" \
|
|
1848
|
+
&& t_ok "codex: refused concurrent login left the credential untouched" \
|
|
1849
|
+
|| t_fail "codex login lock credential" "auth.json changed"
|
|
1850
|
+
# a STALE lock (holder died >30min ago) is reclaimed, not fatal
|
|
1851
|
+
python3 - "$CX/acct-04/.login-lock" <<'EOF'
|
|
1852
|
+
import os, sys, time
|
|
1853
|
+
t = time.time() - 2000
|
|
1854
|
+
os.utime(sys.argv[1], (t, t))
|
|
1855
|
+
EOF
|
|
1856
|
+
out="$(FAKE_EMAIL=d@cx codex-accounts login acct-04 2>&1)"
|
|
1857
|
+
rc=$?
|
|
1858
|
+
[ "$rc" = "0" ] && t_ok "codex: stale login lock is reclaimed" || t_fail "codex stale login lock" "rc=$rc: $out"
|
|
1859
|
+
[ ! -d "$CX/acct-04/.login-lock" ] && t_ok "codex: reclaimed lock released again" \
|
|
1860
|
+
|| { t_fail "codex stale lock release" "lock left behind"; rmdir "$CX/acct-04/.login-lock" 2>/dev/null; }
|
|
1861
|
+
# lock released after a FAILED ceremony too
|
|
1862
|
+
FAKE_LOGIN_FAIL=1 FAKE_EMAIL=d@cx codex-accounts login acct-04 >/dev/null 2>&1
|
|
1863
|
+
[ ! -d "$CX/acct-04/.login-lock" ] && t_ok "codex: login lock released after a failed ceremony" \
|
|
1864
|
+
|| { t_fail "codex login lock on failure" "lock left behind"; rmdir "$CX/acct-04/.login-lock" 2>/dev/null; }
|
|
1865
|
+
# remove refuses to delete an account under a live ceremony
|
|
1866
|
+
mkdir "$CX/acct-04/.login-lock"
|
|
1867
|
+
out="$(codex-accounts remove acct-04 --yes 2>&1)"
|
|
1868
|
+
rc=$?
|
|
1869
|
+
check "codex: remove refuses during a live login" "login for acct-04 is in progress" "$out"
|
|
1870
|
+
[ "$rc" != "0" ] && [ -d "$CX/acct-04" ] && t_ok "codex: account survives a remove during login" \
|
|
1871
|
+
|| t_fail "codex remove guard" "rc=$rc dir_exists=$([ -d "$CX/acct-04" ] && echo yes || echo no)"
|
|
1872
|
+
rmdir "$CX/acct-04/.login-lock"
|
|
1873
|
+
# a redirect never writes under a mid-ceremony login on the OWNER account either:
|
|
1874
|
+
# it skips the save and falls back to the plain refusal
|
|
1875
|
+
mkdir "$CX/acct-01/.login-lock"
|
|
1876
|
+
mv "$CX/acct-01/auth.json" "$WORK/cx-a01.hold"
|
|
1877
|
+
out="$(FAKE_EMAIL=a@cx codex-accounts login acct-04 2>&1)"
|
|
1878
|
+
rc=$?
|
|
1879
|
+
check "codex: redirect skipped while the owner has a login in progress" "login for acct-01 is in progress" "$out"
|
|
1880
|
+
[ "$rc" != "0" ] && t_ok "codex: skipped redirect still refuses" || t_fail "codex busy-owner redirect rc" "rc=0"
|
|
1881
|
+
[ ! -f "$CX/acct-01/auth.json" ] && t_ok "codex: no write under the owner's live ceremony" \
|
|
1882
|
+
|| t_fail "codex busy-owner redirect" "auth.json written under an active login lock"
|
|
1883
|
+
rmdir "$CX/acct-01/.login-lock"
|
|
1884
|
+
mv "$WORK/cx-a01.hold" "$CX/acct-01/auth.json"
|
|
1885
|
+
got_email="$(python3 - "$CX/acct-04/auth.json" <<'EOF'
|
|
1886
|
+
import base64, json, sys
|
|
1887
|
+
t = json.load(open(sys.argv[1]))['tokens']['id_token']
|
|
1888
|
+
p = t.split('.')[1]; p += '=' * (-len(p) % 4)
|
|
1889
|
+
print(json.loads(base64.urlsafe_b64decode(p)).get('email', ''))
|
|
1890
|
+
EOF
|
|
1891
|
+
)"
|
|
1892
|
+
[ "$got_email" = "d@cx" ] && t_ok "codex: busy-owner refusal restored the target credential" \
|
|
1893
|
+
|| t_fail "codex busy-owner restore" "acct-04 auth belongs to: $got_email"
|
|
1831
1894
|
unset FAKE_LOGIN_ARGS
|
|
1832
1895
|
|
|
1833
1896
|
# a parked account shows in expired and relogin fixes exactly that
|