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.
@@ -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 mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
718
- chmod 600 "$od/auth.json" 2>/dev/null || true
719
- clear_auth_markers "$od"
720
- restore_snap
721
- trap - INT TERM
722
- log_to ops.log "login redirect: signed in as $got -> credential saved to $owner (target was $id)"
723
- echo "you signed in as $got — that is $owner, so the credential was saved to $owner (usable now)."
724
- echo "$id ($email) still needs its own sign-in: codex-accounts login $id"
725
- return 1
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.12",
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": {
@@ -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