claude-multiacc 1.0.9 → 1.0.11

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
@@ -217,8 +217,9 @@ claude-multiacc list | status | add <email> | ... # passthrough to claude-acco
217
217
 
218
218
  **Automatic updates:** a globally-installed package re-runs `install.sh` from its own
219
219
  postinstall, so `npm i -g claude-multiacc@latest` fully updates the shim, CLI, and jobs.
220
- A weekly job (launchd on macOS, cron on Linux) runs `claude-accounts self-update` to pull
221
- the latest npm release on its own; set `CLAUDE_MULTIACC_AUTOUPDATE=0` before install to
220
+ A daily job (launchd on macOS, cron on Linux, 04:07) runs `claude-accounts self-update`
221
+ to pull the latest release on its own `npm i -g @latest` for npm installs, `git pull +
222
+ reinstall` for git checkouts; set `CLAUDE_MULTIACC_AUTOUPDATE=0` before install to
222
223
  opt out. `update-notifier` also prints a one-line nudge when a newer version is on npm.
223
224
  (Note: newer npm versions gate install scripts; if postinstall is blocked, just run
224
225
  `claude-multiacc install` once.)
@@ -239,16 +240,25 @@ git pull && ./install.sh # update (data untouch
239
240
 
240
241
  `self-update` auto-detects which of these you used (npm global vs git checkout).
241
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
+
242
250
  What install does (all reversible, nothing else):
243
251
 
244
252
  - **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
245
253
  (+ bash rc files if present) — end-of-file placement matters because those files
246
- re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` (60s) and
247
- `.health` (weekly Mon 09:17). Warns if this Mac stores credentials in the Keychain
254
+ re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
255
+ `.codex-limits` (60s), `.health` + `.codex-health` (weekly Mon morning), and
256
+ `.update` (daily 04:07). Warns if this Mac stores credentials in the Keychain
248
257
  (file-based credentials are required for per-dir logins).
249
258
  - **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`,
250
- shim symlink at `/usr/local/bin/claude` (shadows via PATH order — on the systemd
251
- default PATH too; the original binary is untouched), cron entries for limits/health.
259
+ shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
260
+ PATH order — on the systemd default PATH too; the original binaries are untouched),
261
+ cron entries for limits/health (both providers) + the daily auto-update.
252
262
  - Both: `~/.claude-accounts/` skeleton + `accounts.json` manifest.
253
263
 
254
264
  ## Accounts
@@ -348,21 +358,39 @@ login, so each runs on the machine that holds its credential.
348
358
  claude-accounts remove acct-NN # deletes locally, propagates to the server
349
359
  ```
350
360
 
351
- ## Server sync
361
+ ## Sync (multi-machine)
352
362
 
353
- Mac server, one-way (Mac = source of truth), fired automatically by
354
- `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:
355
366
 
356
367
  ```bash
357
- 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"}]
358
377
  ```
359
378
 
360
- Pushes the manifest + per-account `server.token` (0600) + one-time seeds; **never**
361
- syncs `.credentials.json` in either direction (two machines refreshing one OAuth grant
362
- invalidate each other). Removals propagate; an empty/corrupt manifest refuses to sync
363
- (guard against wiping the server pool). After every push the server re-seeds dirs and
364
- re-runs its quick verification matrix (`post-sync` hook). Everything logs to `sync.log`;
365
- 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.
366
394
 
367
395
  ## Pinning & env switches
368
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
  }
@@ -1452,67 +1458,36 @@ sys.exit(1 if failures else 0)
1452
1458
  PYEOF
1453
1459
  }
1454
1460
 
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
1461
+ # Push the pool to ONE remote target. $1 = user@host, $2 = remote pool root,
1462
+ # $3 = remote addon repo (for the post-sync hook). Uses the caller's slog/fail.
1463
+ # NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
1464
+ # token inside it rotates, and two machines refreshing one grant invalidate each
1465
+ # other. A codex account that should run on a target machine is signed in THERE
1466
+ # (codex-accounts add / login — device-code flow). Sync aligns the account LIST,
1467
+ # seeds, and advisory limit state.
1468
+ sync_push_target() {
1469
+ local server="$1" sroot="$2" srepo="$3"
1470
+ slog "push -> $server:$sroot"
1492
1471
 
1493
1472
  ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
1494
1473
  || fail "cannot reach $server"
1495
1474
  rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
1496
- || fail "manifest push failed"
1475
+ || fail "manifest push to $server failed"
1497
1476
 
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
1477
  local id d
1503
1478
  for id in $(account_ids); do
1504
1479
  d="$ACC_ROOT/$id"
1505
1480
  [ -d "$d" ] || continue
1506
1481
  ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
1507
- || fail "mkdir $id failed"
1482
+ || fail "mkdir $id on $server failed"
1508
1483
  local seed
1509
1484
  for seed in config.toml; do
1510
1485
  if [ -f "$d/$seed" ]; then
1511
1486
  rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
1512
- || fail "seed push for $id/$seed failed"
1487
+ || fail "seed push for $id/$seed to $server failed"
1513
1488
  fi
1514
1489
  done
1515
- # Advisory limit state for accounts the server may lack a bearer for.
1490
+ # Advisory limit state for accounts the target may lack a bearer for.
1516
1491
  local extra
1517
1492
  for extra in limits.json .limited; do
1518
1493
  if [ -f "$d/$extra" ]; then
@@ -1521,9 +1496,10 @@ PYEOF
1521
1496
  done
1522
1497
  done
1523
1498
 
1524
- # Removal propagation: any server acct dir not in the manifest gets deleted.
1499
+ # Removal propagation: any target acct dir not in the manifest gets deleted.
1525
1500
  # 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.
1501
+ # wiping every target's account dirs must be an explicit manual act, never a
1502
+ # side effect.
1527
1503
  local ids_list ids_spaced
1528
1504
  ids_list="$(account_ids)"
1529
1505
  if [ -z "$ids_list" ]; then
@@ -1537,16 +1513,124 @@ for dd in acct-*; do
1537
1513
  *\" \$dd \"*) ;;
1538
1514
  *) rm -rf -- \"\$dd\" ;;
1539
1515
  esac
1540
- done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
1516
+ done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed"
1541
1517
  fi
1542
1518
 
1543
- # Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
1519
+ # Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
1520
+ # Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
1521
+ # awaiting a login THERE) must not fail the push that just succeeded.
1544
1522
  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?)"
1523
+ >>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
1524
+ }
1525
+
1526
+ # Prints manifest peers (extra sync targets, e.g. a second Mac), one per line:
1527
+ # target<TAB>root<TAB>repo. Empty output = no peers.
1528
+ manifest_peers() {
1529
+ [ -f "$MANIFEST" ] || return 0
1530
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
1531
+ import json, sys
1532
+ peers = json.load(open(sys.argv[1])).get('peers')
1533
+ if peers is None:
1534
+ sys.exit(0)
1535
+ if not isinstance(peers, list):
1536
+ print('MALFORMED\t\t')
1537
+ sys.exit(0)
1538
+ for p in peers:
1539
+ if not isinstance(p, dict):
1540
+ print('MALFORMED\t\t')
1541
+ continue
1542
+ print('%s\t%s\t%s' % (p.get('target', ''), p.get('root', ''), p.get('repo', '')))
1543
+ PYEOF
1544
+ }
1545
+
1546
+ # True when this pool is a sync REPLICA: it receives pushes from the source
1547
+ # machine and must never push back (two writers racing = last-writer-wins chaos).
1548
+ # The marker is a machine-local side file — deliberately NOT in the manifest,
1549
+ # because the manifest itself is what gets pushed to replicas.
1550
+ sync_is_replica() {
1551
+ [ -f "$ACC_ROOT/sync-role" ] || return 1
1552
+ # Whole-line match: only a line saying exactly 'replica' counts — a value like
1553
+ # 'not-replica' must never silently disable sync.
1554
+ LC_ALL=C grep -qixE '[[:space:]]*replica[[:space:]]*' "$ACC_ROOT/sync-role"
1555
+ }
1556
+
1557
+ cmd_sync() {
1558
+ require_manifest
1559
+ [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1560
+ if sync_is_replica; then
1561
+ echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1562
+ return 0
1563
+ fi
1564
+ local allow_empty=0
1565
+ [ "${1:-}" = "--allow-empty" ] && allow_empty=1
1566
+ local server sroot srepo
1567
+ server="$(manifest_get server "$DEFAULT_SERVER")"
1568
+ sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
1569
+ srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
1570
+ # These land inside remote shell commands — anything but a plain target/path is a
1571
+ # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1572
+ # (primary and peers) is validated before anything is pushed anywhere.
1573
+ valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
1574
+ valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
1575
+ valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
1576
+ local peers pt pr pp tab
1577
+ tab="$(printf '\t')"
1578
+ peers="$(manifest_peers)"
1579
+ if [ -n "$peers" ]; then
1580
+ while IFS="$tab" read -r pt pr pp; do
1581
+ [ -n "$pt$pr$pp" ] || continue # blank line only — a partial entry is fatal below
1582
+ [ "$pt" = "MALFORMED" ] && die "manifest 'peers' contains a malformed entry — each peer needs target, root and repo"
1583
+ { [ -n "$pt" ] && [ -n "$pr" ] && [ -n "$pp" ]; } \
1584
+ || die "manifest peer entry is incomplete (target, root and repo are all required): target='$pt' root='$pr' repo='$pp'"
1585
+ valid_ssh_target "$pt" || die "manifest peer target is not a plain user@host: $pt"
1586
+ valid_remote_path "$pr" || die "manifest peer root is not a plain absolute path: $pr"
1587
+ valid_remote_path "$pp" || die "manifest peer repo is not a plain absolute path: $pp"
1588
+ done <<EOF
1589
+ $peers
1590
+ EOF
1591
+ fi
1592
+ rotate_log sync.log
1593
+ slog() { log_to sync.log "$*"; }
1594
+ fail() { slog "FAIL: $*"; printf 'codex-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
1595
+ slog "sync start -> $server:$sroot${peers:+ (+ peers)}"
1596
+
1597
+ # Hard validation before anything destructive: a corrupt or accountless manifest must
1598
+ # never be pushed (it would blank the target pools), and must never make the removal
1599
+ # propagation wipe a target's account dirs. Emptying the pool on purpose is possible
1600
+ # via `sync --allow-empty`, so this can never happen by accident.
1601
+ "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1602
+ import json, re, sys
1603
+ doc = json.load(open(sys.argv[1]))
1604
+ accounts = doc.get('accounts')
1605
+ if not isinstance(accounts, list):
1606
+ sys.exit(1)
1607
+ for a in accounts:
1608
+ if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
1609
+ or not str(a.get('email', '')).strip():
1610
+ sys.exit(1)
1611
+ PYEOF
1612
+ if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1613
+ fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1614
+ fi
1615
+
1616
+ sync_push_target "$server" "$sroot" "$srepo"
1617
+ local npeers=0
1618
+ if [ -n "$peers" ]; then
1619
+ while IFS="$tab" read -r pt pr pp; do
1620
+ [ -n "$pt" ] || continue
1621
+ sync_push_target "$pt" "$pr" "$pp"
1622
+ npeers=$((npeers + 1))
1623
+ done <<EOF
1624
+ $peers
1625
+ EOF
1626
+ fi
1546
1627
 
1547
1628
  slog "sync ok"
1548
- echo "sync ok -> $server:$sroot"
1549
- return $rc
1629
+ if [ "$npeers" -gt 0 ]; then
1630
+ echo "sync ok -> $server:$sroot + $npeers peer(s)"
1631
+ else
1632
+ echo "sync ok -> $server:$sroot"
1633
+ fi
1550
1634
  }
1551
1635
 
1552
1636
  cmd_post_sync() {
package/install.sh CHANGED
@@ -108,7 +108,7 @@ EOF
108
108
  <key>StandardErrorPath</key><string>/dev/null</string>
109
109
  </dict></plist>
110
110
  EOF
111
- # Weekly auto-update (Sunday 04:07). No-op unless this is an npm or git install.
111
+ # Daily auto-update (04:07). No-op unless this is an npm or git install.
112
112
  cat > "$PLIST_UPDATE" <<EOF
113
113
  <?xml version="1.0" encoding="UTF-8"?>
114
114
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -120,7 +120,6 @@ EOF
120
120
  <string>--quiet</string>
121
121
  </array>
122
122
  <key>StartCalendarInterval</key><dict>
123
- <key>Weekday</key><integer>0</integer>
124
123
  <key>Hour</key><integer>4</integer>
125
124
  <key>Minute</key><integer>7</integer>
126
125
  </dict>
@@ -174,7 +173,7 @@ EOF
174
173
  launchctl load -w "$PLIST_CODEX_LIMITS" 2>/dev/null || true
175
174
  launchctl load -w "$PLIST_CODEX_HEALTH" 2>/dev/null || true
176
175
  [ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && launchctl load -w "$PLIST_UPDATE" 2>/dev/null || true
177
- echo " launchd: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
176
+ echo " launchd: limits refresh every 60s (claude + codex) + weekly health checks + daily auto-update"
178
177
  }
179
178
 
180
179
  mac_schedule_remove() {
@@ -196,11 +195,11 @@ linux_schedule_install() {
196
195
  echo "17 9 * * 1 $REPO_DIR/bin/claude-accounts health >/dev/null 2>&1 $CRON_TAG"
197
196
  echo "37 9 * * 1 $REPO_DIR/bin/codex-accounts health >/dev/null 2>&1 $CRON_TAG"
198
197
  [ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && \
199
- echo "7 4 * * 0 $REPO_DIR/bin/claude-accounts self-update --quiet >/dev/null 2>&1 $CRON_TAG"
198
+ echo "7 4 * * * $REPO_DIR/bin/claude-accounts self-update --quiet >/dev/null 2>&1 $CRON_TAG"
200
199
  } >> "$tmp"
201
200
  crontab "$tmp"
202
201
  rm -f "$tmp"
203
- echo " cron: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
202
+ echo " cron: limits refresh every 60s (claude + codex) + weekly health checks + daily auto-update"
204
203
  }
205
204
 
206
205
  linux_schedule_remove() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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
@@ -2020,9 +2139,36 @@ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1]
2020
2139
  EOF
2021
2140
  out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2022
2141
  rc=$?
2023
- check "codex: empty manifest refuses to sync" "refusing to blank the server pool" "$out"
2142
+ check "codex: empty manifest refuses to sync" "refusing to blank the target pools" "$out"
2024
2143
  [ "$rc" != "0" ] && t_ok "codex: empty-manifest sync exits nonzero" || t_fail "codex empty sync rc" "rc=0"
2025
2144
  cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2145
+
2146
+ # replica + peer guards, codex flavor
2147
+ printf 'replica\n' > "$CX/sync-role"
2148
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2149
+ rc=$?
2150
+ check "codex: replica pool refuses to push" "sync replica" "$out"
2151
+ [ "$rc" = "0" ] && t_ok "codex: replica sync exits 0" || t_fail "codex replica sync rc" "rc=$rc"
2152
+ # ...and a codex replica's auto_sync (fired by remove) is silent — no push, no nag
2153
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts import rep@cx --id acct-31 --no-sync 2>&1
2154
+ CODEX_MULTIACC_NO_SYNC=0 codex-accounts remove acct-31 --yes 2>&1)"
2155
+ case "$out" in *"sync failed"*) t_fail "codex replica auto_sync" "a replica mutation warned about sync: $out" ;;
2156
+ *) t_ok "codex: replica auto_sync is silent (no push, no warning)" ;; esac
2157
+ rm -f "$CX/sync-role"
2158
+ python3 - "$CX/accounts.json" <<'EOF'
2159
+ import json, os, sys
2160
+ d = json.load(open(sys.argv[1]))
2161
+ d['peers'] = [{'target': 'gas@peer', 'root': "/tmp/x'; touch /tmp/cx_multiacc_PEER_PWNED; #", 'repo': '/tmp/y'}]
2162
+ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
2163
+ EOF
2164
+ rm -f /tmp/cx_multiacc_PEER_PWNED
2165
+ out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
2166
+ rc=$?
2167
+ check "codex: injected peer root refused" "peer root is not a plain absolute path" "$out"
2168
+ [ "$rc" != "0" ] && t_ok "codex: bad peer exits nonzero" || t_fail "codex peer validation rc" "rc=0"
2169
+ [ ! -f /tmp/cx_multiacc_PEER_PWNED ] && t_ok "codex: peer injection never executed" \
2170
+ || { t_fail "codex peer injection" "peer root injection EXECUTED"; rm -f /tmp/cx_multiacc_PEER_PWNED; }
2171
+ cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
2026
2172
  else
2027
2173
  t_ok "codex: sync validation tests skipped (Mac-only feature)"
2028
2174
  fi