claude-multiacc 1.0.10 → 1.0.12

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 CHANGED
@@ -240,6 +240,13 @@ git pull && ./install.sh # update (data untouch
240
240
 
241
241
  `self-update` auto-detects which of these you used (npm global vs git checkout).
242
242
 
243
+ **Servers / machines without repo access:** install via **npm** — the registry is
244
+ public, so the daily self-update needs no credentials at all (no rsync, no keys).
245
+ A git checkout self-updates too, but only if `git pull` can authenticate; for a
246
+ private repo on a server that means a read-only deploy key (the 138 server runs
247
+ this way: `core.sshCommand` pinned to its deploy key). A plain copied tree is the
248
+ one layout that can NOT self-update — don't ship the addon that way.
249
+
243
250
  What install does (all reversible, nothing else):
244
251
 
245
252
  - **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
@@ -351,21 +358,39 @@ login, so each runs on the machine that holds its credential.
351
358
  claude-accounts remove acct-NN # deletes locally, propagates to the server
352
359
  ```
353
360
 
354
- ## Server sync
361
+ ## Sync (multi-machine)
355
362
 
356
- Mac server, one-way (Mac = source of truth), fired automatically by
357
- `add`/`import`/`remove`/`mint` and manually via:
363
+ One machine is the **source of truth**; every other machine is a **target**. Sync is
364
+ one-way, fired automatically by `add`/`import`/`remove`/`mint` on the source and
365
+ manually via:
358
366
 
359
367
  ```bash
360
- claude-accounts sync
368
+ claude-accounts sync # and codex-accounts sync for the codex pool
369
+ ```
370
+
371
+ Targets are the manifest's `server`/`server_root`/`server_repo` (the Linux box) plus
372
+ an optional `peers` array for additional machines (e.g. a second Mac):
373
+
374
+ ```json
375
+ "peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
376
+ "repo": "/Users/gas/claude-multiacc"}]
361
377
  ```
362
378
 
363
- Pushes the manifest + per-account `server.token` (0600) + one-time seeds; **never**
364
- syncs `.credentials.json` in either direction (two machines refreshing one OAuth grant
365
- invalidate each other). Removals propagate; an empty/corrupt manifest refuses to sync
366
- (guard against wiping the server pool). After every push the server re-seeds dirs and
367
- re-runs its quick verification matrix (`post-sync` hook). Everything logs to `sync.log`;
368
- failures are loud and non-zero.
379
+ Every target gets the manifest + per-account `server.token` (0600, claude pool) +
380
+ one-time seeds + advisory limit state; **never** `.credentials.json` or codex
381
+ `auth.json` in either direction (their refresh tokens rotate two machines
382
+ refreshing one grant invalidate each other; codex accounts are signed in per
383
+ machine with the device-code flow instead). Removals propagate to every target; an
384
+ empty/corrupt manifest or one with an invalid target/peer shape — refuses to sync
385
+ before anything is pushed anywhere. After every push each target re-seeds dirs and
386
+ re-runs its quick verification matrix (`post-sync` hook). Everything logs to
387
+ `sync.log`; failures are loud and non-zero.
388
+
389
+ A target machine's pool carries a `sync-role` file containing `replica`: such a pool
390
+ **never pushes** (manually or via auto-sync) — the source machine owns the account
391
+ set, which is what prevents two machines from overwriting each other's manifests.
392
+ Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
393
+ whichever machine needs the credential.
369
394
 
370
395
  ## Pinning & env switches
371
396
 
@@ -56,7 +56,10 @@ USAGE
56
56
  (same email), keeping one per email
57
57
  claude-accounts mint <acct-NN> mint server token via `claude setup-token`
58
58
  --paste paste an already-minted token instead of running setup-token
59
- claude-accounts sync push manifest+tokens to the server (Mac only)
59
+ claude-accounts sync push manifest+tokens to the server AND any
60
+ manifest 'peers' (extra machines). Mac only.
61
+ A pool with a 'sync-role' file saying
62
+ 'replica' never pushes (it receives).
60
63
  claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
61
64
  claude-accounts limits [--quiet] [--force]
62
65
  refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
@@ -142,6 +145,9 @@ PYEOF
142
145
  auto_sync() { # best effort after mutations, Mac only, loud on failure
143
146
  [ "$(machine_kind)" = "mac" ] || return 0
144
147
  [ "${CLAUDE_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
148
+ # A replica pool never pushes (the source machine owns the account set) —
149
+ # silently, so every mutation on a replica does not nag about it.
150
+ sync_is_replica && return 0
145
151
  # subshell: cmd_sync exits on failure and must not take the CLI down with it
146
152
  ( cmd_sync ) || warn "server sync failed — run 'claude-accounts sync' manually"
147
153
  }
@@ -1496,71 +1502,43 @@ sys.exit(1 if failures else 0)
1496
1502
  PYEOF
1497
1503
  }
1498
1504
 
1499
- cmd_sync() {
1500
- require_manifest
1501
- [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1502
- local allow_empty=0
1503
- [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1504
- local server sroot srepo rc=0
1505
- server="$(manifest_get server "$DEFAULT_SERVER")"
1506
- sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1507
- srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1508
- # These land inside a remote shell command — anything but a plain target/path is a
1509
- # command-injection vector from a corrupted or hand-edited manifest.
1510
- valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
1511
- valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
1512
- valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
1513
- rotate_log sync.log
1514
- slog() { log_to sync.log "$*"; }
1515
- fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
1516
- slog "sync start -> $server:$sroot"
1517
-
1518
- # Hard validation before anything destructive: a corrupt or accountless manifest must
1519
- # never be pushed (it would blank the server's pool), and must never make the removal
1520
- # loop below wipe the server's credentials. Emptying the pool on purpose is possible
1521
- # via `sync --allow-empty`, so this can never happen by accident.
1522
- "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1523
- import json, re, sys
1524
- doc = json.load(open(sys.argv[1]))
1525
- accounts = doc.get('accounts')
1526
- if not isinstance(accounts, list):
1527
- sys.exit(1)
1528
- for a in accounts:
1529
- if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1530
- or not str(a.get('email', '')).strip():
1531
- sys.exit(1)
1532
- PYEOF
1533
- if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1534
- fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
1535
- fi
1505
+ # Push the pool to ONE remote target. $1 = user@host, $2 = remote pool root,
1506
+ # $3 = remote addon repo (for the post-sync hook). Uses the caller's slog/fail.
1507
+ # A target can be the Linux server (token-authenticated) or a peer Mac (which has
1508
+ # its own machine-local OAuth logins) — the pushed material is valid on both:
1509
+ # manifest, portable server.token files, seeds, and advisory limit state.
1510
+ # Credentials (.credentials.json) are NEVER pushed anywhere.
1511
+ sync_push_target() {
1512
+ local server="$1" sroot="$2" srepo="$3"
1513
+ slog "push -> $server:$sroot"
1536
1514
 
1537
1515
  ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
1538
1516
  || fail "cannot reach $server"
1539
1517
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
1540
- || fail "manifest push failed"
1518
+ || fail "manifest push to $server failed"
1541
1519
 
1542
1520
  local id d
1543
1521
  for id in $(account_ids); do
1544
1522
  d="$ACC_ROOT/$id"
1545
1523
  [ -d "$d" ] || continue
1546
1524
  ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
1547
- || fail "mkdir $id failed"
1525
+ || fail "mkdir $id on $server failed"
1548
1526
  if [ -s "$d/server.token" ]; then
1549
1527
  # NB: no --chmod — macOS 26 ships openrsync, which rejects it (the push would
1550
1528
  # fail outright). The mode is fixed with an explicit remote chmod instead.
1551
1529
  rsync -az "$d/server.token" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1552
- || fail "token push for $id failed"
1530
+ || fail "token push for $id to $server failed"
1553
1531
  ssh -o BatchMode=yes "$server" "chmod 600 '$sroot/$id/server.token'" \
1554
- >>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id failed"
1532
+ >>"$ACC_ROOT/sync.log" 2>&1 || fail "token chmod for $id on $server failed"
1555
1533
  fi
1556
1534
  local seed
1557
1535
  for seed in .claude.json settings.json; do
1558
1536
  if [ -f "$d/$seed" ]; then
1559
1537
  rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1560
- || fail "seed push for $id/$seed failed"
1538
+ || fail "seed push for $id/$seed to $server failed"
1561
1539
  fi
1562
1540
  done
1563
- # Advisory limit state for accounts the server may lack a bearer for.
1541
+ # Advisory limit state for accounts the target may lack a bearer for.
1564
1542
  local extra
1565
1543
  for extra in limits.json .limited; do
1566
1544
  if [ -f "$d/$extra" ]; then
@@ -1569,9 +1547,9 @@ PYEOF
1569
1547
  done
1570
1548
  done
1571
1549
 
1572
- # Removal propagation: any server acct dir not in the manifest gets deleted.
1550
+ # Removal propagation: any target acct dir not in the manifest gets deleted.
1573
1551
  # Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
1574
- # wiping every server credential must be an explicit manual act, not a side effect.
1552
+ # wiping every target credential must be an explicit manual act, not a side effect.
1575
1553
  local ids_list ids_spaced
1576
1554
  ids_list="$(account_ids)"
1577
1555
  if [ -z "$ids_list" ]; then
@@ -1585,16 +1563,124 @@ for dd in acct-*; do
1585
1563
  *\" \$dd \"*) ;;
1586
1564
  *) rm -rf -- \"\$dd\" ;;
1587
1565
  esac
1588
- done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
1566
+ done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed"
1589
1567
  fi
1590
1568
 
1591
- # Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
1569
+ # Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
1570
+ # Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
1571
+ # awaiting a login THERE) must not fail the push that just succeeded.
1592
1572
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/claude-accounts' ] && '$srepo/bin/claude-accounts' post-sync" \
1593
- >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"
1573
+ >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
1574
+ }
1575
+
1576
+ # Prints manifest peers (extra sync targets, e.g. a second Mac), one per line:
1577
+ # target<TAB>root<TAB>repo. Empty output = no peers.
1578
+ manifest_peers() {
1579
+ [ -f "$MANIFEST" ] || return 0
1580
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
1581
+ import json, sys
1582
+ peers = json.load(open(sys.argv[1])).get('peers')
1583
+ if peers is None:
1584
+ sys.exit(0)
1585
+ if not isinstance(peers, list):
1586
+ print('MALFORMED\t\t')
1587
+ sys.exit(0)
1588
+ for p in peers:
1589
+ if not isinstance(p, dict):
1590
+ print('MALFORMED\t\t')
1591
+ continue
1592
+ print('%s\t%s\t%s' % (p.get('target', ''), p.get('root', ''), p.get('repo', '')))
1593
+ PYEOF
1594
+ }
1595
+
1596
+ # True when this pool is a sync REPLICA: it receives pushes from the source
1597
+ # machine and must never push back (two writers racing = last-writer-wins chaos).
1598
+ # The marker is a machine-local side file — deliberately NOT in the manifest,
1599
+ # because the manifest itself is what gets pushed to replicas.
1600
+ sync_is_replica() {
1601
+ [ -f "$ACC_ROOT/sync-role" ] || return 1
1602
+ # Whole-line match: only a line saying exactly 'replica' counts — a value like
1603
+ # 'not-replica' must never silently disable sync.
1604
+ LC_ALL=C grep -qixE '[[:space:]]*replica[[:space:]]*' "$ACC_ROOT/sync-role"
1605
+ }
1606
+
1607
+ cmd_sync() {
1608
+ require_manifest
1609
+ [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1610
+ if sync_is_replica; then
1611
+ echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1612
+ return 0
1613
+ fi
1614
+ local allow_empty=0
1615
+ [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1616
+ local server sroot srepo
1617
+ server="$(manifest_get server "$DEFAULT_SERVER")"
1618
+ sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1619
+ srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1620
+ # These land inside remote shell commands — anything but a plain target/path is a
1621
+ # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1622
+ # (primary and peers) is validated before anything is pushed anywhere.
1623
+ valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
1624
+ valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
1625
+ valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
1626
+ local peers pt pr pp tab
1627
+ tab="$(printf '\t')"
1628
+ peers="$(manifest_peers)"
1629
+ if [ -n "$peers" ]; then
1630
+ while IFS="$tab" read -r pt pr pp; do
1631
+ [ -n "$pt$pr$pp" ] || continue # blank line only — a partial entry is fatal below
1632
+ [ "$pt" = "MALFORMED" ] && die "manifest 'peers' contains a malformed entry — each peer needs target, root and repo"
1633
+ { [ -n "$pt" ] && [ -n "$pr" ] && [ -n "$pp" ]; } \
1634
+ || die "manifest peer entry is incomplete (target, root and repo are all required): target='$pt' root='$pr' repo='$pp'"
1635
+ valid_ssh_target "$pt" || die "manifest peer target is not a plain user@host: $pt"
1636
+ valid_remote_path "$pr" || die "manifest peer root is not a plain absolute path: $pr"
1637
+ valid_remote_path "$pp" || die "manifest peer repo is not a plain absolute path: $pp"
1638
+ done <<EOF
1639
+ $peers
1640
+ EOF
1641
+ fi
1642
+ rotate_log sync.log
1643
+ slog() { log_to sync.log "$*"; }
1644
+ fail() { slog "FAIL: $*"; printf 'claude-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
1645
+ slog "sync start -> $server:$sroot${peers:+ (+ peers)}"
1646
+
1647
+ # Hard validation before anything destructive: a corrupt or accountless manifest must
1648
+ # never be pushed (it would blank the target pools), and must never make the removal
1649
+ # propagation wipe a target's credentials. Emptying the pool on purpose is possible
1650
+ # via `sync --allow-empty`, so this can never happen by accident.
1651
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1652
+ import json, re, sys
1653
+ doc = json.load(open(sys.argv[1]))
1654
+ accounts = doc.get('accounts')
1655
+ if not isinstance(accounts, list):
1656
+ sys.exit(1)
1657
+ for a in accounts:
1658
+ if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1659
+ or not str(a.get('email', '')).strip():
1660
+ sys.exit(1)
1661
+ PYEOF
1662
+ if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1663
+ fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1664
+ fi
1665
+
1666
+ sync_push_target "$server" "$sroot" "$srepo"
1667
+ local npeers=0
1668
+ if [ -n "$peers" ]; then
1669
+ while IFS="$tab" read -r pt pr pp; do
1670
+ [ -n "$pt" ] || continue
1671
+ sync_push_target "$pt" "$pr" "$pp"
1672
+ npeers=$((npeers + 1))
1673
+ done <<EOF
1674
+ $peers
1675
+ EOF
1676
+ fi
1594
1677
 
1595
1678
  slog "sync ok"
1596
- echo "sync ok -> $server:$sroot"
1597
- return $rc
1679
+ if [ "$npeers" -gt 0 ]; then
1680
+ echo "sync ok -> $server:$sroot + $npeers peer(s)"
1681
+ else
1682
+ echo "sync ok -> $server:$sroot"
1683
+ fi
1598
1684
  }
1599
1685
 
1600
1686
  cmd_post_sync() {
@@ -58,8 +58,11 @@ USAGE
58
58
  codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
59
59
  codex-accounts dedupe [--yes] remove any account registered twice
60
60
  (same email), keeping one per email
61
- codex-accounts sync push manifest+seeds to the server (Mac only;
62
- auth.json is machine-local, NEVER synced)
61
+ codex-accounts sync push manifest+seeds to the server AND any
62
+ manifest 'peers' (extra machines). Mac only.
63
+ auth.json is machine-local, NEVER synced.
64
+ A pool with a 'sync-role' file saying
65
+ 'replica' never pushes (it receives).
63
66
  codex-accounts verify [--quick] auth matrix; full mode runs a real
64
67
  `codex exec` per account
65
68
  codex-accounts limits [--quiet] [--force]
@@ -146,6 +149,9 @@ PYEOF
146
149
  auto_sync() { # best effort after mutations, Mac only, loud on failure
147
150
  [ "$(machine_kind)" = "mac" ] || return 0
148
151
  [ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
152
+ # A replica pool never pushes (the source machine owns the account set) —
153
+ # silently, so every mutation on a replica does not nag about it.
154
+ sync_is_replica && return 0
149
155
  # subshell: cmd_sync exits on failure and must not take the CLI down with it
150
156
  ( cmd_sync ) || warn "server sync failed — run 'codex-accounts sync' manually"
151
157
  }
@@ -415,8 +421,21 @@ cmd_add() {
415
421
  mutate_lock || die "could not acquire the account lock — try again"
416
422
  owner="$(email_owner "$got")"
417
423
  if [ -n "$owner" ]; then
418
- mutate_unlock
419
- echo "$got is already added as $owner skipping (nothing added)."
424
+ # The sign-in itself succeeded and produced a fresh credential for an account
425
+ # that is already registered don't throw it away: save it to that account,
426
+ # exactly as `login $owner` would have. This machine now runs $owner.
427
+ local od="$ACC_ROOT/$owner"
428
+ seed_account_dir "$od"
429
+ if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
430
+ chmod 600 "$od/auth.json" 2>/dev/null || true
431
+ clear_auth_markers "$od"
432
+ mutate_unlock
433
+ log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
434
+ echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
435
+ else
436
+ mutate_unlock
437
+ echo "$got is already added as $owner — skipping (nothing added)."
438
+ fi
420
439
  return 0 # RESERVED_DIR still set -> trap removes the temp dir
421
440
  fi
422
441
  if [ -n "$email" ] && [ "$got" != "$email" ]; then
@@ -686,6 +705,26 @@ PYEOF
686
705
  got_lc="$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')"
687
706
  email_lc="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
688
707
  if [ -n "$got" ] && [ "$got_lc" != "$email_lc" ] && [ "$force" != "1" ]; then
708
+ # Signed in as a DIFFERENT account. If that account is registered in this pool,
709
+ # the sign-in was not a mistake worth discarding — route the fresh credential to
710
+ # ITS account dir (what `login <owner>` would have produced), restore this
711
+ # account's prior state, and report honestly that THIS account is still unfixed.
712
+ local owner
713
+ owner="$(email_owner "$got")"
714
+ if [ -n "$owner" ] && [ "$owner" != "$id" ]; then
715
+ local od="$ACC_ROOT/$owner"
716
+ 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
726
+ fi
727
+ fi
689
728
  restore_snap
690
729
  trap - INT TERM
691
730
  die "you signed in as $got but $id is $email — nothing saved, previous credential restored (use --force to override)"
@@ -813,12 +852,27 @@ cmd_relogin() {
813
852
  read -r ans
814
853
  case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
815
854
  fi
855
+ # Accounts that were BROKEN when this run started: if one of them turns healthy
856
+ # mid-run (a redirected sign-in from an earlier iteration landed its credential),
857
+ # asking the user to sign it in again would be pure friction — it gets skipped.
858
+ # Accounts that were healthy at start (explicit ids / --all) are never skipped:
859
+ # the user asked to re-authenticate them on purpose.
860
+ local pre_bad
861
+ pre_bad=" $(accounts_needing_login 2>/dev/null | tr '\n' ' ') "
816
862
  # One sync at the end instead of one per account: each auto_sync is an ssh round trip.
817
863
  local prev_no_sync="${CODEX_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
818
864
  export CODEX_MULTIACC_NO_SYNC=1
819
865
  for id in $ids; do
820
866
  echo
821
867
  echo "=== $id ==============================================================="
868
+ case "$pre_bad" in
869
+ *" $id "*)
870
+ if [ "$(account_audit 2>/dev/null | awk -F'\t' -v i="$id" '$1 == i { print $4 }')" = "ok" ]; then
871
+ echo "$id already has a working login (a credential landed during an earlier sign-in) — skipping."
872
+ done_ok=$((done_ok + 1))
873
+ continue
874
+ fi ;;
875
+ esac
822
876
  if [ "$browser" = "1" ]; then
823
877
  ( cmd_login "$id" --browser ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
824
878
  else
@@ -1452,67 +1506,36 @@ sys.exit(1 if failures else 0)
1452
1506
  PYEOF
1453
1507
  }
1454
1508
 
1455
- cmd_sync() {
1456
- require_manifest
1457
- [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1458
- local allow_empty=0
1459
- [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1460
- local server sroot srepo rc=0
1461
- server="$(manifest_get server "$DEFAULT_SERVER")"
1462
- sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1463
- srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1464
- # These land inside a remote shell command — anything but a plain target/path is a
1465
- # command-injection vector from a corrupted or hand-edited manifest.
1466
- valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
1467
- valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
1468
- valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
1469
- rotate_log sync.log
1470
- slog() { log_to sync.log "$*"; }
1471
- fail() { slog "FAIL: $*"; printf 'codex-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
1472
- slog "sync start -> $server:$sroot"
1473
-
1474
- # Hard validation before anything destructive: a corrupt or accountless manifest must
1475
- # never be pushed (it would blank the server's pool), and must never make the removal
1476
- # loop below wipe the server's account dirs. Emptying the pool on purpose is possible
1477
- # via `sync --allow-empty`, so this can never happen by accident.
1478
- "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1479
- import json, re, sys
1480
- doc = json.load(open(sys.argv[1]))
1481
- accounts = doc.get('accounts')
1482
- if not isinstance(accounts, list):
1483
- sys.exit(1)
1484
- for a in accounts:
1485
- if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1486
- or not str(a.get('email', '')).strip():
1487
- sys.exit(1)
1488
- PYEOF
1489
- if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1490
- fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
1491
- fi
1509
+ # Push the pool to ONE remote target. $1 = user@host, $2 = remote pool root,
1510
+ # $3 = remote addon repo (for the post-sync hook). Uses the caller's slog/fail.
1511
+ # NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
1512
+ # token inside it rotates, and two machines refreshing one grant invalidate each
1513
+ # other. A codex account that should run on a target machine is signed in THERE
1514
+ # (codex-accounts add / login — device-code flow). Sync aligns the account LIST,
1515
+ # seeds, and advisory limit state.
1516
+ sync_push_target() {
1517
+ local server="$1" sroot="$2" srepo="$3"
1518
+ slog "push -> $server:$sroot"
1492
1519
 
1493
1520
  ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
1494
1521
  || fail "cannot reach $server"
1495
1522
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
1496
- || fail "manifest push failed"
1523
+ || fail "manifest push to $server failed"
1497
1524
 
1498
- # NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
1499
- # token inside it rotates, and two machines refreshing one grant invalidate each
1500
- # other. A codex account that should run on the server is signed in THERE
1501
- # (codex-accounts add --device over SSH).
1502
1525
  local id d
1503
1526
  for id in $(account_ids); do
1504
1527
  d="$ACC_ROOT/$id"
1505
1528
  [ -d "$d" ] || continue
1506
1529
  ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
1507
- || fail "mkdir $id failed"
1530
+ || fail "mkdir $id on $server failed"
1508
1531
  local seed
1509
1532
  for seed in config.toml; do
1510
1533
  if [ -f "$d/$seed" ]; then
1511
1534
  rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1512
- || fail "seed push for $id/$seed failed"
1535
+ || fail "seed push for $id/$seed to $server failed"
1513
1536
  fi
1514
1537
  done
1515
- # Advisory limit state for accounts the server may lack a bearer for.
1538
+ # Advisory limit state for accounts the target may lack a bearer for.
1516
1539
  local extra
1517
1540
  for extra in limits.json .limited; do
1518
1541
  if [ -f "$d/$extra" ]; then
@@ -1521,9 +1544,10 @@ PYEOF
1521
1544
  done
1522
1545
  done
1523
1546
 
1524
- # Removal propagation: any server acct dir not in the manifest gets deleted.
1547
+ # Removal propagation: any target acct dir not in the manifest gets deleted.
1525
1548
  # Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
1526
- # wiping every server credential must be an explicit manual act, not a side effect.
1549
+ # wiping every target's account dirs must be an explicit manual act, never a
1550
+ # side effect.
1527
1551
  local ids_list ids_spaced
1528
1552
  ids_list="$(account_ids)"
1529
1553
  if [ -z "$ids_list" ]; then
@@ -1537,16 +1561,124 @@ for dd in acct-*; do
1537
1561
  *\" \$dd \"*) ;;
1538
1562
  *) rm -rf -- \"\$dd\" ;;
1539
1563
  esac
1540
- done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
1564
+ done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed"
1541
1565
  fi
1542
1566
 
1543
- # Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
1567
+ # Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
1568
+ # Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
1569
+ # awaiting a login THERE) must not fail the push that just succeeded.
1544
1570
  ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/codex-accounts' ] && '$srepo/bin/codex-accounts' post-sync" \
1545
- >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"
1571
+ >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
1572
+ }
1573
+
1574
+ # Prints manifest peers (extra sync targets, e.g. a second Mac), one per line:
1575
+ # target<TAB>root<TAB>repo. Empty output = no peers.
1576
+ manifest_peers() {
1577
+ [ -f "$MANIFEST" ] || return 0
1578
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
1579
+ import json, sys
1580
+ peers = json.load(open(sys.argv[1])).get('peers')
1581
+ if peers is None:
1582
+ sys.exit(0)
1583
+ if not isinstance(peers, list):
1584
+ print('MALFORMED\t\t')
1585
+ sys.exit(0)
1586
+ for p in peers:
1587
+ if not isinstance(p, dict):
1588
+ print('MALFORMED\t\t')
1589
+ continue
1590
+ print('%s\t%s\t%s' % (p.get('target', ''), p.get('root', ''), p.get('repo', '')))
1591
+ PYEOF
1592
+ }
1593
+
1594
+ # True when this pool is a sync REPLICA: it receives pushes from the source
1595
+ # machine and must never push back (two writers racing = last-writer-wins chaos).
1596
+ # The marker is a machine-local side file — deliberately NOT in the manifest,
1597
+ # because the manifest itself is what gets pushed to replicas.
1598
+ sync_is_replica() {
1599
+ [ -f "$ACC_ROOT/sync-role" ] || return 1
1600
+ # Whole-line match: only a line saying exactly 'replica' counts — a value like
1601
+ # 'not-replica' must never silently disable sync.
1602
+ LC_ALL=C grep -qixE '[[:space:]]*replica[[:space:]]*' "$ACC_ROOT/sync-role"
1603
+ }
1604
+
1605
+ cmd_sync() {
1606
+ require_manifest
1607
+ [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1608
+ if sync_is_replica; then
1609
+ echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1610
+ return 0
1611
+ fi
1612
+ local allow_empty=0
1613
+ [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1614
+ local server sroot srepo
1615
+ server="$(manifest_get server "$DEFAULT_SERVER")"
1616
+ sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1617
+ srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1618
+ # These land inside remote shell commands — anything but a plain target/path is a
1619
+ # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1620
+ # (primary and peers) is validated before anything is pushed anywhere.
1621
+ valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
1622
+ valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
1623
+ valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
1624
+ local peers pt pr pp tab
1625
+ tab="$(printf '\t')"
1626
+ peers="$(manifest_peers)"
1627
+ if [ -n "$peers" ]; then
1628
+ while IFS="$tab" read -r pt pr pp; do
1629
+ [ -n "$pt$pr$pp" ] || continue # blank line only — a partial entry is fatal below
1630
+ [ "$pt" = "MALFORMED" ] && die "manifest 'peers' contains a malformed entry — each peer needs target, root and repo"
1631
+ { [ -n "$pt" ] && [ -n "$pr" ] && [ -n "$pp" ]; } \
1632
+ || die "manifest peer entry is incomplete (target, root and repo are all required): target='$pt' root='$pr' repo='$pp'"
1633
+ valid_ssh_target "$pt" || die "manifest peer target is not a plain user@host: $pt"
1634
+ valid_remote_path "$pr" || die "manifest peer root is not a plain absolute path: $pr"
1635
+ valid_remote_path "$pp" || die "manifest peer repo is not a plain absolute path: $pp"
1636
+ done <<EOF
1637
+ $peers
1638
+ EOF
1639
+ fi
1640
+ rotate_log sync.log
1641
+ slog() { log_to sync.log "$*"; }
1642
+ fail() { slog "FAIL: $*"; printf 'codex-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
1643
+ slog "sync start -> $server:$sroot${peers:+ (+ peers)}"
1644
+
1645
+ # Hard validation before anything destructive: a corrupt or accountless manifest must
1646
+ # never be pushed (it would blank the target pools), and must never make the removal
1647
+ # propagation wipe a target's account dirs. Emptying the pool on purpose is possible
1648
+ # via `sync --allow-empty`, so this can never happen by accident.
1649
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1650
+ import json, re, sys
1651
+ doc = json.load(open(sys.argv[1]))
1652
+ accounts = doc.get('accounts')
1653
+ if not isinstance(accounts, list):
1654
+ sys.exit(1)
1655
+ for a in accounts:
1656
+ if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1657
+ or not str(a.get('email', '')).strip():
1658
+ sys.exit(1)
1659
+ PYEOF
1660
+ if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1661
+ fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1662
+ fi
1663
+
1664
+ sync_push_target "$server" "$sroot" "$srepo"
1665
+ local npeers=0
1666
+ if [ -n "$peers" ]; then
1667
+ while IFS="$tab" read -r pt pr pp; do
1668
+ [ -n "$pt" ] || continue
1669
+ sync_push_target "$pt" "$pr" "$pp"
1670
+ npeers=$((npeers + 1))
1671
+ done <<EOF
1672
+ $peers
1673
+ EOF
1674
+ fi
1546
1675
 
1547
1676
  slog "sync ok"
1548
- echo "sync ok -> $server:$sroot"
1549
- return $rc
1677
+ if [ "$npeers" -gt 0 ]; then
1678
+ echo "sync ok -> $server:$sroot + $npeers peer(s)"
1679
+ else
1680
+ echo "sync ok -> $server:$sroot"
1681
+ fi
1550
1682
  }
1551
1683
 
1552
1684
  cmd_post_sync() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
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": {
@@ -1134,9 +1134,128 @@ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1]
1134
1134
  EOF
1135
1135
  out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1136
1136
  rc=$?
1137
- check "empty manifest refuses to sync" "refusing to blank the server pool" "$out"
1137
+ check "empty manifest refuses to sync" "refusing to blank the target pools" "$out"
1138
1138
  [ "$rc" != "0" ] && t_ok "empty-manifest sync exits nonzero" || t_fail "empty sync rc" "rc=0"
1139
1139
  cp "$WORK/manifest.bak" "$ACC/accounts.json"
1140
+
1141
+ # a REPLICA pool never pushes (side file, not manifest — the manifest is what
1142
+ # gets pushed TO replicas)
1143
+ printf 'replica\n' > "$ACC/sync-role"
1144
+ out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1145
+ rc=$?
1146
+ check "replica pool refuses to push" "sync replica" "$out"
1147
+ [ "$rc" = "0" ] && t_ok "replica sync exits 0 (informational, not an error)" || t_fail "replica sync rc" "rc=$rc"
1148
+ # ...and auto_sync (after a mutation) is silent about it
1149
+ out="$(CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts import replica-test@x --id acct-31 --no-sync 2>&1
1150
+ CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts remove acct-31 --yes 2>&1)"
1151
+ case "$out" in *"sync failed"*) t_fail "replica auto_sync" "a replica mutation warned about sync: $out" ;;
1152
+ *) t_ok "replica auto_sync is silent (no push, no warning)" ;; esac
1153
+ rm -f "$ACC/sync-role"
1154
+
1155
+ # peer targets are validated with the same injection guards as the primary.
1156
+ # A fake ssh/rsync harness (prepended to PATH only for these calls) records every
1157
+ # remote invocation, so "nothing pushed" and "both targets pushed, in order" are
1158
+ # verified from the actual command stream, not inferred from silence.
1159
+ SSHFAKE="$WORK/sshfake"
1160
+ SSHLOG="$WORK/sshfake.log"
1161
+ mkdir -p "$SSHFAKE"
1162
+ cat > "$SSHFAKE/ssh" <<'EOF'
1163
+ #!/usr/bin/env bash
1164
+ printf 'ssh %s\n' "$*" >> "${SSHLOG:?}"
1165
+ case "$*" in *"${SSH_FAIL_HOST:-@@none@@}"*) exit 1 ;; esac
1166
+ exit 0
1167
+ EOF
1168
+ cat > "$SSHFAKE/rsync" <<'EOF'
1169
+ #!/usr/bin/env bash
1170
+ printf 'rsync %s\n' "$*" >> "${SSHLOG:?}"
1171
+ exit 0
1172
+ EOF
1173
+ chmod +x "$SSHFAKE/ssh" "$SSHFAKE/rsync"
1174
+ export SSHLOG
1175
+
1176
+ python3 - "$ACC/accounts.json" <<'EOF'
1177
+ import json, os, sys
1178
+ d = json.load(open(sys.argv[1]))
1179
+ d['peers'] = [{'target': "gas@peer; touch /tmp/multiacc_PEER_PWNED", 'root': '/tmp/x', 'repo': '/tmp/y'}]
1180
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1181
+ EOF
1182
+ rm -f /tmp/multiacc_PEER_PWNED
1183
+ : > "$SSHLOG"
1184
+ out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1185
+ rc=$?
1186
+ check "injected peer target refused" "peer target is not a plain user@host" "$out"
1187
+ [ "$rc" != "0" ] && t_ok "bad peer exits nonzero" || t_fail "peer validation rc" "rc=0"
1188
+ [ ! -s "$SSHLOG" ] && t_ok "bad peer: nothing pushed anywhere (no ssh/rsync ran)" \
1189
+ || t_fail "peer pre-validation" "remote commands ran before peer validation: $(head -2 "$SSHLOG")"
1190
+ [ ! -f /tmp/multiacc_PEER_PWNED ] && t_ok "peer injection never executed" \
1191
+ || { t_fail "peer injection" "peer target injection EXECUTED"; rm -f /tmp/multiacc_PEER_PWNED; }
1192
+ cp "$WORK/manifest.bak" "$ACC/accounts.json"
1193
+
1194
+ # a MALFORMED peer entry (missing fields / not an object) is fatal, never skipped
1195
+ python3 - "$ACC/accounts.json" <<'EOF'
1196
+ import json, os, sys
1197
+ d = json.load(open(sys.argv[1]))
1198
+ d['peers'] = [{'root': '/tmp/x', 'repo': '/tmp/y'}] # no target
1199
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1200
+ EOF
1201
+ out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1202
+ rc=$?
1203
+ check "peer with missing field is fatal, not skipped" "incomplete" "$out"
1204
+ [ "$rc" != "0" ] && t_ok "incomplete peer exits nonzero" || t_fail "incomplete peer rc" "rc=0"
1205
+ python3 - "$ACC/accounts.json" <<'EOF'
1206
+ import json, os, sys
1207
+ d = json.load(open(sys.argv[1]))
1208
+ d['peers'] = ["gas@peer"] # not an object
1209
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1210
+ EOF
1211
+ out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1212
+ check "non-object peer entry is fatal" "malformed" "$out"
1213
+ cp "$WORK/manifest.bak" "$ACC/accounts.json"
1214
+
1215
+ # a SUCCESSFUL multi-target sync pushes primary first, then the peer — verified
1216
+ # from the recorded command stream
1217
+ python3 - "$ACC/accounts.json" <<'EOF'
1218
+ import json, os, sys
1219
+ d = json.load(open(sys.argv[1]))
1220
+ d['peers'] = [{'target': 'gas@peer1', 'root': '/Users/gas/.claude-accounts', 'repo': '/Users/gas/claude-multiacc'}]
1221
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
1222
+ EOF
1223
+ : > "$SSHLOG"
1224
+ out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1225
+ rc=$?
1226
+ [ "$rc" = "0" ] && t_ok "multi-target sync succeeds" || t_fail "multi-target sync" "rc=$rc: $out"
1227
+ check "multi-target sync reports the peer" "+ 1 peer(s)" "$out"
1228
+ grep -q "rsync.*root@203.0.113.1:/root/.claude-accounts/accounts.json" "$SSHLOG" \
1229
+ && grep -q "rsync.*gas@peer1:/Users/gas/.claude-accounts/accounts.json" "$SSHLOG" \
1230
+ && t_ok "manifest pushed to BOTH targets" \
1231
+ || t_fail "multi-target pushes" "missing a manifest push: $(grep accounts.json "$SSHLOG")"
1232
+ first_primary="$(grep -n "root@203.0.113.1" "$SSHLOG" | head -1 | cut -d: -f1)"
1233
+ first_peer="$(grep -n "gas@peer1" "$SSHLOG" | head -1 | cut -d: -f1)"
1234
+ [ -n "$first_primary" ] && [ -n "$first_peer" ] && [ "$first_primary" -lt "$first_peer" ] \
1235
+ && t_ok "primary target pushed before the peer" \
1236
+ || t_fail "target order" "primary=$first_primary peer=$first_peer"
1237
+ n_remov="$(grep -c "for dd in acct-" "$SSHLOG")"
1238
+ [ "$n_remov" = "2" ] && t_ok "removal propagation ran on each target" \
1239
+ || t_fail "per-target removal" "expected 2 removal sweeps, saw $n_remov"
1240
+ grep -q "post-sync" "$SSHLOG" && t_ok "post-sync hooks invoked" || t_fail "post-sync hooks" "none recorded"
1241
+ # a primary failure aborts the WHOLE sync: the peer is never contacted
1242
+ : > "$SSHLOG"
1243
+ out="$(PATH="$SSHFAKE:$PATH" SSH_FAIL_HOST="root@203.0.113.1" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1244
+ rc=$?
1245
+ [ "$rc" != "0" ] && t_ok "primary failure fails the sync" || t_fail "fail-fast rc" "rc=0"
1246
+ check "primary failure is loud" "cannot reach" "$out"
1247
+ grep -q "gas@peer1" "$SSHLOG" \
1248
+ && t_fail "fail-fast" "the peer was contacted after the primary failed" \
1249
+ || t_ok "peer not contacted after a primary failure"
1250
+ cp "$WORK/manifest.bak" "$ACC/accounts.json"
1251
+
1252
+ # only an EXACT 'replica' value suppresses sync ('not-replica' must still push)
1253
+ printf 'not-replica\n' > "$ACC/sync-role"
1254
+ out="$(PATH="$SSHFAKE:$PATH" CLAUDE_MULTIACC_NO_SYNC=0 claude-accounts sync 2>&1)"
1255
+ case "$out" in *"sync replica"*) t_fail "replica anchor" "'not-replica' suppressed sync" ;;
1256
+ *"sync ok"*) t_ok "only an exact 'replica' value suppresses sync" ;;
1257
+ *) t_fail "replica anchor" "unexpected: $out" ;; esac
1258
+ rm -f "$ACC/sync-role"
1140
1259
  else
1141
1260
  t_ok "sync validation tests skipped (Mac-only feature; server refuses sync by design)"
1142
1261
  fi
@@ -1638,6 +1757,62 @@ EOF
1638
1757
  )"
1639
1758
  [ "$got_email" = "d@cx" ] && t_ok "codex: refused login restores the prior credential" \
1640
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"
1641
1816
  # the sign-in ceremony is DEVICE-CODE by default (the localhost browser callback
1642
1817
  # cannot reach a remote/SSH machine); --browser opts into the callback flow
1643
1818
  export FAKE_LOGIN_ARGS="$WORK/cx-login-args"
@@ -2020,9 +2195,36 @@ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1]
2020
2195
  EOF
2021
2196
  out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2022
2197
  rc=$?
2023
- check "codex: empty manifest refuses to sync" "refusing to blank the server pool" "$out"
2198
+ check "codex: empty manifest refuses to sync" "refusing to blank the target pools" "$out"
2024
2199
  [ "$rc" != "0" ] && t_ok "codex: empty-manifest sync exits nonzero" || t_fail "codex empty sync rc" "rc=0"
2025
2200
  cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2201
+
2202
+ # replica + peer guards, codex flavor
2203
+ printf 'replica\n' > "$CX/sync-role"
2204
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2205
+ rc=$?
2206
+ check "codex: replica pool refuses to push" "sync replica" "$out"
2207
+ [ "$rc" = "0" ] && t_ok "codex: replica sync exits 0" || t_fail "codex replica sync rc" "rc=$rc"
2208
+ # ...and a codex replica's auto_sync (fired by remove) is silent — no push, no nag
2209
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts import rep@cx --id acct-31 --no-sync 2>&1
2210
+ CODEX_MULTIACC_NO_SYNC=0 codex-accounts remove acct-31 --yes 2>&1)"
2211
+ case "$out" in *"sync failed"*) t_fail "codex replica auto_sync" "a replica mutation warned about sync: $out" ;;
2212
+ *) t_ok "codex: replica auto_sync is silent (no push, no warning)" ;; esac
2213
+ rm -f "$CX/sync-role"
2214
+ python3 - "$CX/accounts.json" <<'EOF'
2215
+ import json, os, sys
2216
+ d = json.load(open(sys.argv[1]))
2217
+ d['peers'] = [{'target': 'gas@peer', 'root': "/tmp/x'; touch /tmp/cx_multiacc_PEER_PWNED; #", 'repo': '/tmp/y'}]
2218
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
2219
+ EOF
2220
+ rm -f /tmp/cx_multiacc_PEER_PWNED
2221
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2222
+ rc=$?
2223
+ check "codex: injected peer root refused" "peer root is not a plain absolute path" "$out"
2224
+ [ "$rc" != "0" ] && t_ok "codex: bad peer exits nonzero" || t_fail "codex peer validation rc" "rc=0"
2225
+ [ ! -f /tmp/cx_multiacc_PEER_PWNED ] && t_ok "codex: peer injection never executed" \
2226
+ || { t_fail "codex peer injection" "peer root injection EXECUTED"; rm -f /tmp/cx_multiacc_PEER_PWNED; }
2227
+ cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2026
2228
  else
2027
2229
  t_ok "codex: sync validation tests skipped (Mac-only feature)"
2028
2230
  fi