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 +44 -16
- package/bin/claude-accounts +137 -51
- package/bin/codex-accounts +138 -54
- package/install.sh +4 -5
- package/package.json +1 -1
- package/tests/run-tests.sh +148 -2
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
|
|
221
|
-
the latest
|
|
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`
|
|
247
|
-
`.
|
|
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
|
|
251
|
-
default PATH too; the original
|
|
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
|
-
##
|
|
361
|
+
## Sync (multi-machine)
|
|
352
362
|
|
|
353
|
-
|
|
354
|
-
`add`/`import`/`remove`/`mint`
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
package/bin/claude-accounts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
sroot="$
|
|
1507
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1597
|
-
|
|
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() {
|
package/bin/codex-accounts
CHANGED
|
@@ -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
|
|
62
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1549
|
-
|
|
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
|
-
#
|
|
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 +
|
|
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 * *
|
|
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 +
|
|
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.
|
|
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": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|