claude-multiacc 1.0.11 → 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
@@ -421,8 +451,25 @@ cmd_add() {
421
451
  mutate_lock || die "could not acquire the account lock — try again"
422
452
  owner="$(email_owner "$got")"
423
453
  if [ -n "$owner" ]; then
424
- mutate_unlock
425
- echo "$got is already added as $owner skipping (nothing added)."
454
+ # The sign-in itself succeeded and produced a fresh credential for an account
455
+ # that is already registered don't throw it away: save it to that account,
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.
459
+ local od="$ACC_ROOT/$owner"
460
+ seed_account_dir "$od"
461
+ if login_lock_acquire "$od/.login-lock" && mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
462
+ chmod 600 "$od/auth.json" 2>/dev/null || true
463
+ clear_auth_markers "$od"
464
+ login_lock_release "$od/.login-lock"
465
+ mutate_unlock
466
+ log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
467
+ echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
468
+ else
469
+ login_lock_release "$od/.login-lock"
470
+ mutate_unlock
471
+ echo "$got is already added as $owner — skipping (nothing added)."
472
+ fi
426
473
  return 0 # RESERVED_DIR still set -> trap removes the temp dir
427
474
  fi
428
475
  if [ -n "$email" ] && [ "$got" != "$email" ]; then
@@ -619,6 +666,10 @@ cmd_remove() {
619
666
  read -r ans
620
667
  case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
621
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"
622
673
  if [ -L "$ACC_ROOT/$id" ]; then
623
674
  rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
624
675
  else
@@ -658,6 +709,18 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
658
709
  break
659
710
  PYEOF
660
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
+
661
724
  echo "Sign in as $email for $id."
662
725
  # Snapshot the credential BEFORE the ceremony: `codex login` writes auth.json into
663
726
  # the dir directly, so a sign-in to the WRONG account would otherwise leave that
@@ -692,6 +755,36 @@ PYEOF
692
755
  got_lc="$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')"
693
756
  email_lc="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
694
757
  if [ -n "$got" ] && [ "$got_lc" != "$email_lc" ] && [ "$force" != "1" ]; then
758
+ # Signed in as a DIFFERENT account. If that account is registered in this pool,
759
+ # the sign-in was not a mistake worth discarding — route the fresh credential to
760
+ # ITS account dir (what `login <owner>` would have produced), restore this
761
+ # account's prior state, and report honestly that THIS account is still unfixed.
762
+ local owner
763
+ owner="$(email_owner "$got")"
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).
769
+ local od="$ACC_ROOT/$owner"
770
+ seed_account_dir "$od"
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."
786
+ fi
787
+ fi
695
788
  restore_snap
696
789
  trap - INT TERM
697
790
  die "you signed in as $got but $id is $email — nothing saved, previous credential restored (use --force to override)"
@@ -819,12 +912,27 @@ cmd_relogin() {
819
912
  read -r ans
820
913
  case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
821
914
  fi
915
+ # Accounts that were BROKEN when this run started: if one of them turns healthy
916
+ # mid-run (a redirected sign-in from an earlier iteration landed its credential),
917
+ # asking the user to sign it in again would be pure friction — it gets skipped.
918
+ # Accounts that were healthy at start (explicit ids / --all) are never skipped:
919
+ # the user asked to re-authenticate them on purpose.
920
+ local pre_bad
921
+ pre_bad=" $(accounts_needing_login 2>/dev/null | tr '\n' ' ') "
822
922
  # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
823
923
  local prev_no_sync="${CODEX_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
824
924
  export CODEX_MULTIACC_NO_SYNC=1
825
925
  for id in $ids; do
826
926
  echo
827
927
  echo "=== $id ==============================================================="
928
+ case "$pre_bad" in
929
+ *" $id "*)
930
+ if [ "$(account_audit 2>/dev/null | awk -F'\t' -v i="$id" '$1 == i { print $4 }')" = "ok" ]; then
931
+ echo "$id already has a working login (a credential landed during an earlier sign-in) — skipping."
932
+ done_ok=$((done_ok + 1))
933
+ continue
934
+ fi ;;
935
+ esac
828
936
  if [ "$browser" = "1" ]; then
829
937
  ( cmd_login "$id" --browser ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
830
938
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.11",
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": {
@@ -1757,6 +1757,62 @@ EOF
1757
1757
  )"
1758
1758
  [ "$got_email" = "d@cx" ] && t_ok "codex: refused login restores the prior credential" \
1759
1759
  || t_fail "codex login restore" "auth.json now belongs to: $got_email"
1760
+ # signing in as a DIFFERENT-but-REGISTERED account routes the credential to that
1761
+ # account instead of discarding it (and the target account stays honestly unfixed)
1762
+ rm -f "$CX/acct-01/auth.json"
1763
+ out="$(FAKE_EMAIL=a@cx codex-accounts login acct-04 2>&1)"
1764
+ rc=$?
1765
+ check "codex: cross-account sign-in is redirected, not discarded" "credential was saved to acct-01" "$out"
1766
+ check "codex: redirect says the target still needs its sign-in" "acct-04 (d@cx) still needs its own sign-in" "$out"
1767
+ [ "$rc" != "0" ] && t_ok "codex: redirected login still exits nonzero for the target" \
1768
+ || t_fail "codex redirect rc" "rc=0 for an account that was not fixed"
1769
+ [ -s "$CX/acct-01/auth.json" ] && t_ok "codex: redirected credential landed at its owner" \
1770
+ || t_fail "codex redirect landing" "acct-01/auth.json missing"
1771
+ got_email="$(python3 - "$CX/acct-04/auth.json" <<'EOF'
1772
+ import base64, json, sys
1773
+ t = json.load(open(sys.argv[1]))['tokens']['id_token']
1774
+ p = t.split('.')[1]; p += '=' * (-len(p) % 4)
1775
+ print(json.loads(base64.urlsafe_b64decode(p)).get('email', ''))
1776
+ EOF
1777
+ )"
1778
+ [ "$got_email" = "d@cx" ] && t_ok "codex: redirect restored the target's prior credential" \
1779
+ || t_fail "codex redirect restore" "acct-04 auth.json belongs to: $got_email"
1780
+ # `add` while signed into an already-registered account also keeps the credential
1781
+ rm -f "$CX/acct-01/auth.json"
1782
+ before_dirs="$(ls "$CX" | sort)"
1783
+ out="$(FAKE_EMAIL=a@cx codex-accounts add 2>&1)"
1784
+ rc=$?
1785
+ check "codex: add of a registered account saves the fresh sign-in to it" "fresh sign-in was saved to acct-01" "$out"
1786
+ [ "$rc" = "0" ] && t_ok "codex: add redirect exits 0 (nothing new to register)" || t_fail "codex add redirect rc" "rc=$rc"
1787
+ [ -s "$CX/acct-01/auth.json" ] && t_ok "codex: add redirect landed the credential" \
1788
+ || t_fail "codex add redirect" "acct-01/auth.json missing"
1789
+ [ "$before_dirs" = "$(ls "$CX" | sort)" ] && t_ok "codex: add redirect leaves no reserved dir behind" \
1790
+ || t_fail "codex add redirect cleanup" "pool dirs changed"
1791
+ n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
1792
+ [ "$n" = "1" ] && t_ok "codex: add redirect creates no duplicate entry" || t_fail "codex add redirect dup" "count=$n"
1793
+ # relogin end-to-end: a redirect mid-run fixes the OTHER account, whose own turn is
1794
+ # then skipped instead of demanding a second sign-in. Both accounts' home must be
1795
+ # THIS machine's kind, or a credless account reads as 'remote' (grant lives
1796
+ # elsewhere) on the other platform and never enters the worklist.
1797
+ cx_mk=mac; [ "$(uname -s)" = "Darwin" ] || cx_mk=linux
1798
+ python3 - "$CX/accounts.json" "$cx_mk" <<'EOF'
1799
+ import json, os, sys
1800
+ d = json.load(open(sys.argv[1]))
1801
+ for a in d['accounts']:
1802
+ if a['id'] in ('acct-01', 'acct-04'):
1803
+ a['home'] = sys.argv[2]
1804
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1805
+ EOF
1806
+ rm -f "$CX/acct-01/auth.json" "$CX/acct-04/auth.json"
1807
+ out="$(FAKE_EMAIL=d@cx codex-accounts relogin --yes 2>&1)"
1808
+ rc=$?
1809
+ check "codex: relogin redirect saves the mis-ordered sign-in" "credential was saved to acct-04" "$out"
1810
+ check "codex: relogin skips an account fixed mid-run" "already has a working login" "$out"
1811
+ check "codex: relogin counts the redirect-fixed account" "re-authenticated 1 of 2" "$out"
1812
+ check "codex: relogin still reports the unfixed account" "still failing: acct-01" "$out"
1813
+ [ "$rc" != "0" ] && t_ok "codex: relogin with an unfixed account exits nonzero" || t_fail "codex relogin redirect rc" "rc=0"
1814
+ mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
1815
+ mk_cx_auth "$CX/acct-04/auth.json" d@cx "$FUTURE_EXP"
1760
1816
  # the sign-in ceremony is DEVICE-CODE by default (the localhost browser callback
1761
1817
  # cannot reach a remote/SSH machine); --browser opts into the callback flow
1762
1818
  export FAKE_LOGIN_ARGS="$WORK/cx-login-args"
@@ -1772,6 +1828,69 @@ rc=$?
1772
1828
  { [ "$rc" = "0" ] && [ "$(cat "$FAKE_LOGIN_ARGS")" = "" ]; } \
1773
1829
  && t_ok "codex: --browser opts into the localhost callback flow" \
1774
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"
1775
1894
  unset FAKE_LOGIN_ARGS
1776
1895
 
1777
1896
  # a parked account shows in expired and relogin fixes exactly that