claude-multiacc 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -10
- package/bin/claude-accounts +137 -51
- package/bin/codex-accounts +188 -56
- package/package.json +1 -1
- package/tests/run-tests.sh +204 -2
package/README.md
CHANGED
|
@@ -240,6 +240,13 @@ git pull && ./install.sh # update (data untouch
|
|
|
240
240
|
|
|
241
241
|
`self-update` auto-detects which of these you used (npm global vs git checkout).
|
|
242
242
|
|
|
243
|
+
**Servers / machines without repo access:** install via **npm** — the registry is
|
|
244
|
+
public, so the daily self-update needs no credentials at all (no rsync, no keys).
|
|
245
|
+
A git checkout self-updates too, but only if `git pull` can authenticate; for a
|
|
246
|
+
private repo on a server that means a read-only deploy key (the 138 server runs
|
|
247
|
+
this way: `core.sshCommand` pinned to its deploy key). A plain copied tree is the
|
|
248
|
+
one layout that can NOT self-update — don't ship the addon that way.
|
|
249
|
+
|
|
243
250
|
What install does (all reversible, nothing else):
|
|
244
251
|
|
|
245
252
|
- **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
|
|
@@ -351,21 +358,39 @@ login, so each runs on the machine that holds its credential.
|
|
|
351
358
|
claude-accounts remove acct-NN # deletes locally, propagates to the server
|
|
352
359
|
```
|
|
353
360
|
|
|
354
|
-
##
|
|
361
|
+
## Sync (multi-machine)
|
|
355
362
|
|
|
356
|
-
|
|
357
|
-
`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:
|
|
358
366
|
|
|
359
367
|
```bash
|
|
360
|
-
claude-accounts sync
|
|
368
|
+
claude-accounts sync # and codex-accounts sync for the codex pool
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Targets are the manifest's `server`/`server_root`/`server_repo` (the Linux box) plus
|
|
372
|
+
an optional `peers` array for additional machines (e.g. a second Mac):
|
|
373
|
+
|
|
374
|
+
```json
|
|
375
|
+
"peers": [{"target": "gas@gas-mini", "root": "/Users/gas/.claude-accounts",
|
|
376
|
+
"repo": "/Users/gas/claude-multiacc"}]
|
|
361
377
|
```
|
|
362
378
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
379
|
+
Every target gets the manifest + per-account `server.token` (0600, claude pool) +
|
|
380
|
+
one-time seeds + advisory limit state; **never** `.credentials.json` or codex
|
|
381
|
+
`auth.json` in either direction (their refresh tokens rotate — two machines
|
|
382
|
+
refreshing one grant invalidate each other; codex accounts are signed in per
|
|
383
|
+
machine with the device-code flow instead). Removals propagate to every target; an
|
|
384
|
+
empty/corrupt manifest — or one with an invalid target/peer shape — refuses to sync
|
|
385
|
+
before anything is pushed anywhere. After every push each target re-seeds dirs and
|
|
386
|
+
re-runs its quick verification matrix (`post-sync` hook). Everything logs to
|
|
387
|
+
`sync.log`; failures are loud and non-zero.
|
|
388
|
+
|
|
389
|
+
A target machine's pool carries a `sync-role` file containing `replica`: such a pool
|
|
390
|
+
**never pushes** (manually or via auto-sync) — the source machine owns the account
|
|
391
|
+
set, which is what prevents two machines from overwriting each other's manifests.
|
|
392
|
+
Make account changes on the source; sign-ins (`login`/`relogin`) still happen on
|
|
393
|
+
whichever machine needs the credential.
|
|
369
394
|
|
|
370
395
|
## Pinning & env switches
|
|
371
396
|
|
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
|
}
|
|
@@ -415,8 +421,21 @@ cmd_add() {
|
|
|
415
421
|
mutate_lock || die "could not acquire the account lock — try again"
|
|
416
422
|
owner="$(email_owner "$got")"
|
|
417
423
|
if [ -n "$owner" ]; then
|
|
418
|
-
|
|
419
|
-
|
|
424
|
+
# The sign-in itself succeeded and produced a fresh credential for an account
|
|
425
|
+
# that is already registered — don't throw it away: save it to that account,
|
|
426
|
+
# exactly as `login $owner` would have. This machine now runs $owner.
|
|
427
|
+
local od="$ACC_ROOT/$owner"
|
|
428
|
+
seed_account_dir "$od"
|
|
429
|
+
if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
|
|
430
|
+
chmod 600 "$od/auth.json" 2>/dev/null || true
|
|
431
|
+
clear_auth_markers "$od"
|
|
432
|
+
mutate_unlock
|
|
433
|
+
log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
|
|
434
|
+
echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
|
|
435
|
+
else
|
|
436
|
+
mutate_unlock
|
|
437
|
+
echo "$got is already added as $owner — skipping (nothing added)."
|
|
438
|
+
fi
|
|
420
439
|
return 0 # RESERVED_DIR still set -> trap removes the temp dir
|
|
421
440
|
fi
|
|
422
441
|
if [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
@@ -686,6 +705,26 @@ PYEOF
|
|
|
686
705
|
got_lc="$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')"
|
|
687
706
|
email_lc="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
|
|
688
707
|
if [ -n "$got" ] && [ "$got_lc" != "$email_lc" ] && [ "$force" != "1" ]; then
|
|
708
|
+
# Signed in as a DIFFERENT account. If that account is registered in this pool,
|
|
709
|
+
# the sign-in was not a mistake worth discarding — route the fresh credential to
|
|
710
|
+
# ITS account dir (what `login <owner>` would have produced), restore this
|
|
711
|
+
# account's prior state, and report honestly that THIS account is still unfixed.
|
|
712
|
+
local owner
|
|
713
|
+
owner="$(email_owner "$got")"
|
|
714
|
+
if [ -n "$owner" ] && [ "$owner" != "$id" ]; then
|
|
715
|
+
local od="$ACC_ROOT/$owner"
|
|
716
|
+
seed_account_dir "$od"
|
|
717
|
+
if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
|
|
718
|
+
chmod 600 "$od/auth.json" 2>/dev/null || true
|
|
719
|
+
clear_auth_markers "$od"
|
|
720
|
+
restore_snap
|
|
721
|
+
trap - INT TERM
|
|
722
|
+
log_to ops.log "login redirect: signed in as $got -> credential saved to $owner (target was $id)"
|
|
723
|
+
echo "you signed in as $got — that is $owner, so the credential was saved to $owner (usable now)."
|
|
724
|
+
echo "$id ($email) still needs its own sign-in: codex-accounts login $id"
|
|
725
|
+
return 1
|
|
726
|
+
fi
|
|
727
|
+
fi
|
|
689
728
|
restore_snap
|
|
690
729
|
trap - INT TERM
|
|
691
730
|
die "you signed in as $got but $id is $email — nothing saved, previous credential restored (use --force to override)"
|
|
@@ -813,12 +852,27 @@ cmd_relogin() {
|
|
|
813
852
|
read -r ans
|
|
814
853
|
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
815
854
|
fi
|
|
855
|
+
# Accounts that were BROKEN when this run started: if one of them turns healthy
|
|
856
|
+
# mid-run (a redirected sign-in from an earlier iteration landed its credential),
|
|
857
|
+
# asking the user to sign it in again would be pure friction — it gets skipped.
|
|
858
|
+
# Accounts that were healthy at start (explicit ids / --all) are never skipped:
|
|
859
|
+
# the user asked to re-authenticate them on purpose.
|
|
860
|
+
local pre_bad
|
|
861
|
+
pre_bad=" $(accounts_needing_login 2>/dev/null | tr '\n' ' ') "
|
|
816
862
|
# One sync at the end instead of one per account: each auto_sync is an ssh round trip.
|
|
817
863
|
local prev_no_sync="${CODEX_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
|
|
818
864
|
export CODEX_MULTIACC_NO_SYNC=1
|
|
819
865
|
for id in $ids; do
|
|
820
866
|
echo
|
|
821
867
|
echo "=== $id ==============================================================="
|
|
868
|
+
case "$pre_bad" in
|
|
869
|
+
*" $id "*)
|
|
870
|
+
if [ "$(account_audit 2>/dev/null | awk -F'\t' -v i="$id" '$1 == i { print $4 }')" = "ok" ]; then
|
|
871
|
+
echo "$id already has a working login (a credential landed during an earlier sign-in) — skipping."
|
|
872
|
+
done_ok=$((done_ok + 1))
|
|
873
|
+
continue
|
|
874
|
+
fi ;;
|
|
875
|
+
esac
|
|
822
876
|
if [ "$browser" = "1" ]; then
|
|
823
877
|
( cmd_login "$id" --browser ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
|
|
824
878
|
else
|
|
@@ -1452,67 +1506,36 @@ sys.exit(1 if failures else 0)
|
|
|
1452
1506
|
PYEOF
|
|
1453
1507
|
}
|
|
1454
1508
|
|
|
1455
|
-
|
|
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
|
|
1509
|
+
# Push the pool to ONE remote target. $1 = user@host, $2 = remote pool root,
|
|
1510
|
+
# $3 = remote addon repo (for the post-sync hook). Uses the caller's slog/fail.
|
|
1511
|
+
# NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
|
|
1512
|
+
# token inside it rotates, and two machines refreshing one grant invalidate each
|
|
1513
|
+
# other. A codex account that should run on a target machine is signed in THERE
|
|
1514
|
+
# (codex-accounts add / login — device-code flow). Sync aligns the account LIST,
|
|
1515
|
+
# seeds, and advisory limit state.
|
|
1516
|
+
sync_push_target() {
|
|
1517
|
+
local server="$1" sroot="$2" srepo="$3"
|
|
1518
|
+
slog "push -> $server:$sroot"
|
|
1492
1519
|
|
|
1493
1520
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1494
1521
|
|| fail "cannot reach $server"
|
|
1495
1522
|
rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1496
|
-
|| fail "manifest push failed"
|
|
1523
|
+
|| fail "manifest push to $server failed"
|
|
1497
1524
|
|
|
1498
|
-
# NB: auth.json is deliberately NEVER pushed (in either direction): the refresh
|
|
1499
|
-
# token inside it rotates, and two machines refreshing one grant invalidate each
|
|
1500
|
-
# other. A codex account that should run on the server is signed in THERE
|
|
1501
|
-
# (codex-accounts add --device over SSH).
|
|
1502
1525
|
local id d
|
|
1503
1526
|
for id in $(account_ids); do
|
|
1504
1527
|
d="$ACC_ROOT/$id"
|
|
1505
1528
|
[ -d "$d" ] || continue
|
|
1506
1529
|
ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1507
|
-
|| fail "mkdir $id failed"
|
|
1530
|
+
|| fail "mkdir $id on $server failed"
|
|
1508
1531
|
local seed
|
|
1509
1532
|
for seed in config.toml; do
|
|
1510
1533
|
if [ -f "$d/$seed" ]; then
|
|
1511
1534
|
rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1512
|
-
|| fail "seed push for $id/$seed failed"
|
|
1535
|
+
|| fail "seed push for $id/$seed to $server failed"
|
|
1513
1536
|
fi
|
|
1514
1537
|
done
|
|
1515
|
-
# Advisory limit state for accounts the
|
|
1538
|
+
# Advisory limit state for accounts the target may lack a bearer for.
|
|
1516
1539
|
local extra
|
|
1517
1540
|
for extra in limits.json .limited; do
|
|
1518
1541
|
if [ -f "$d/$extra" ]; then
|
|
@@ -1521,9 +1544,10 @@ PYEOF
|
|
|
1521
1544
|
done
|
|
1522
1545
|
done
|
|
1523
1546
|
|
|
1524
|
-
# Removal propagation: any
|
|
1547
|
+
# Removal propagation: any target acct dir not in the manifest gets deleted.
|
|
1525
1548
|
# Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
|
|
1526
|
-
# wiping every
|
|
1549
|
+
# wiping every target's account dirs must be an explicit manual act, never a
|
|
1550
|
+
# side effect.
|
|
1527
1551
|
local ids_list ids_spaced
|
|
1528
1552
|
ids_list="$(account_ids)"
|
|
1529
1553
|
if [ -z "$ids_list" ]; then
|
|
@@ -1537,16 +1561,124 @@ for dd in acct-*; do
|
|
|
1537
1561
|
*\" \$dd \"*) ;;
|
|
1538
1562
|
*) rm -rf -- \"\$dd\" ;;
|
|
1539
1563
|
esac
|
|
1540
|
-
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
|
|
1564
|
+
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation on $server failed"
|
|
1541
1565
|
fi
|
|
1542
1566
|
|
|
1543
|
-
# Post-sync hook:
|
|
1567
|
+
# Post-sync hook: the target seeds dirs and re-runs its quick verification matrix.
|
|
1568
|
+
# Non-fatal: a hook that is absent (not bootstrapped) or exits nonzero (accounts
|
|
1569
|
+
# awaiting a login THERE) must not fail the push that just succeeded.
|
|
1544
1570
|
ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/codex-accounts' ] && '$srepo/bin/codex-accounts' post-sync" \
|
|
1545
|
-
>>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook
|
|
1571
|
+
>>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook on $server unavailable or unhappy (see its pool state)"
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
# Prints manifest peers (extra sync targets, e.g. a second Mac), one per line:
|
|
1575
|
+
# target<TAB>root<TAB>repo. Empty output = no peers.
|
|
1576
|
+
manifest_peers() {
|
|
1577
|
+
[ -f "$MANIFEST" ] || return 0
|
|
1578
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
|
|
1579
|
+
import json, sys
|
|
1580
|
+
peers = json.load(open(sys.argv[1])).get('peers')
|
|
1581
|
+
if peers is None:
|
|
1582
|
+
sys.exit(0)
|
|
1583
|
+
if not isinstance(peers, list):
|
|
1584
|
+
print('MALFORMED\t\t')
|
|
1585
|
+
sys.exit(0)
|
|
1586
|
+
for p in peers:
|
|
1587
|
+
if not isinstance(p, dict):
|
|
1588
|
+
print('MALFORMED\t\t')
|
|
1589
|
+
continue
|
|
1590
|
+
print('%s\t%s\t%s' % (p.get('target', ''), p.get('root', ''), p.get('repo', '')))
|
|
1591
|
+
PYEOF
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
# True when this pool is a sync REPLICA: it receives pushes from the source
|
|
1595
|
+
# machine and must never push back (two writers racing = last-writer-wins chaos).
|
|
1596
|
+
# The marker is a machine-local side file — deliberately NOT in the manifest,
|
|
1597
|
+
# because the manifest itself is what gets pushed to replicas.
|
|
1598
|
+
sync_is_replica() {
|
|
1599
|
+
[ -f "$ACC_ROOT/sync-role" ] || return 1
|
|
1600
|
+
# Whole-line match: only a line saying exactly 'replica' counts — a value like
|
|
1601
|
+
# 'not-replica' must never silently disable sync.
|
|
1602
|
+
LC_ALL=C grep -qixE '[[:space:]]*replica[[:space:]]*' "$ACC_ROOT/sync-role"
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
cmd_sync() {
|
|
1606
|
+
require_manifest
|
|
1607
|
+
[ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
|
|
1608
|
+
if sync_is_replica; then
|
|
1609
|
+
echo "this pool is a sync replica — the source machine pushes here; nothing sent"
|
|
1610
|
+
return 0
|
|
1611
|
+
fi
|
|
1612
|
+
local allow_empty=0
|
|
1613
|
+
[ "${1:-}" = "--allow-empty" ] && allow_empty=1
|
|
1614
|
+
local server sroot srepo
|
|
1615
|
+
server="$(manifest_get server "$DEFAULT_SERVER")"
|
|
1616
|
+
sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
|
|
1617
|
+
srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
|
|
1618
|
+
# These land inside remote shell commands — anything but a plain target/path is a
|
|
1619
|
+
# command-injection vector from a corrupted or hand-edited manifest. EVERY target
|
|
1620
|
+
# (primary and peers) is validated before anything is pushed anywhere.
|
|
1621
|
+
valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
|
|
1622
|
+
valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
|
|
1623
|
+
valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
|
|
1624
|
+
local peers pt pr pp tab
|
|
1625
|
+
tab="$(printf '\t')"
|
|
1626
|
+
peers="$(manifest_peers)"
|
|
1627
|
+
if [ -n "$peers" ]; then
|
|
1628
|
+
while IFS="$tab" read -r pt pr pp; do
|
|
1629
|
+
[ -n "$pt$pr$pp" ] || continue # blank line only — a partial entry is fatal below
|
|
1630
|
+
[ "$pt" = "MALFORMED" ] && die "manifest 'peers' contains a malformed entry — each peer needs target, root and repo"
|
|
1631
|
+
{ [ -n "$pt" ] && [ -n "$pr" ] && [ -n "$pp" ]; } \
|
|
1632
|
+
|| die "manifest peer entry is incomplete (target, root and repo are all required): target='$pt' root='$pr' repo='$pp'"
|
|
1633
|
+
valid_ssh_target "$pt" || die "manifest peer target is not a plain user@host: $pt"
|
|
1634
|
+
valid_remote_path "$pr" || die "manifest peer root is not a plain absolute path: $pr"
|
|
1635
|
+
valid_remote_path "$pp" || die "manifest peer repo is not a plain absolute path: $pp"
|
|
1636
|
+
done <<EOF
|
|
1637
|
+
$peers
|
|
1638
|
+
EOF
|
|
1639
|
+
fi
|
|
1640
|
+
rotate_log sync.log
|
|
1641
|
+
slog() { log_to sync.log "$*"; }
|
|
1642
|
+
fail() { slog "FAIL: $*"; printf 'codex-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
|
|
1643
|
+
slog "sync start -> $server:$sroot${peers:+ (+ peers)}"
|
|
1644
|
+
|
|
1645
|
+
# Hard validation before anything destructive: a corrupt or accountless manifest must
|
|
1646
|
+
# never be pushed (it would blank the target pools), and must never make the removal
|
|
1647
|
+
# propagation wipe a target's account dirs. Emptying the pool on purpose is possible
|
|
1648
|
+
# via `sync --allow-empty`, so this can never happen by accident.
|
|
1649
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
|
|
1650
|
+
import json, re, sys
|
|
1651
|
+
doc = json.load(open(sys.argv[1]))
|
|
1652
|
+
accounts = doc.get('accounts')
|
|
1653
|
+
if not isinstance(accounts, list):
|
|
1654
|
+
sys.exit(1)
|
|
1655
|
+
for a in accounts:
|
|
1656
|
+
if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
|
|
1657
|
+
or not str(a.get('email', '')).strip():
|
|
1658
|
+
sys.exit(1)
|
|
1659
|
+
PYEOF
|
|
1660
|
+
if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
|
|
1661
|
+
fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
|
|
1662
|
+
fi
|
|
1663
|
+
|
|
1664
|
+
sync_push_target "$server" "$sroot" "$srepo"
|
|
1665
|
+
local npeers=0
|
|
1666
|
+
if [ -n "$peers" ]; then
|
|
1667
|
+
while IFS="$tab" read -r pt pr pp; do
|
|
1668
|
+
[ -n "$pt" ] || continue
|
|
1669
|
+
sync_push_target "$pt" "$pr" "$pp"
|
|
1670
|
+
npeers=$((npeers + 1))
|
|
1671
|
+
done <<EOF
|
|
1672
|
+
$peers
|
|
1673
|
+
EOF
|
|
1674
|
+
fi
|
|
1546
1675
|
|
|
1547
1676
|
slog "sync ok"
|
|
1548
|
-
|
|
1549
|
-
|
|
1677
|
+
if [ "$npeers" -gt 0 ]; then
|
|
1678
|
+
echo "sync ok -> $server:$sroot + $npeers peer(s)"
|
|
1679
|
+
else
|
|
1680
|
+
echo "sync ok -> $server:$sroot"
|
|
1681
|
+
fi
|
|
1550
1682
|
}
|
|
1551
1683
|
|
|
1552
1684
|
cmd_post_sync() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
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
|
|
@@ -1638,6 +1757,62 @@ EOF
|
|
|
1638
1757
|
)"
|
|
1639
1758
|
[ "$got_email" = "d@cx" ] && t_ok "codex: refused login restores the prior credential" \
|
|
1640
1759
|
|| t_fail "codex login restore" "auth.json now belongs to: $got_email"
|
|
1760
|
+
# signing in as a DIFFERENT-but-REGISTERED account routes the credential to that
|
|
1761
|
+
# account instead of discarding it (and the target account stays honestly unfixed)
|
|
1762
|
+
rm -f "$CX/acct-01/auth.json"
|
|
1763
|
+
out="$(FAKE_EMAIL=a@cx codex-accounts login acct-04 2>&1)"
|
|
1764
|
+
rc=$?
|
|
1765
|
+
check "codex: cross-account sign-in is redirected, not discarded" "credential was saved to acct-01" "$out"
|
|
1766
|
+
check "codex: redirect says the target still needs its sign-in" "acct-04 (d@cx) still needs its own sign-in" "$out"
|
|
1767
|
+
[ "$rc" != "0" ] && t_ok "codex: redirected login still exits nonzero for the target" \
|
|
1768
|
+
|| t_fail "codex redirect rc" "rc=0 for an account that was not fixed"
|
|
1769
|
+
[ -s "$CX/acct-01/auth.json" ] && t_ok "codex: redirected credential landed at its owner" \
|
|
1770
|
+
|| t_fail "codex redirect landing" "acct-01/auth.json missing"
|
|
1771
|
+
got_email="$(python3 - "$CX/acct-04/auth.json" <<'EOF'
|
|
1772
|
+
import base64, json, sys
|
|
1773
|
+
t = json.load(open(sys.argv[1]))['tokens']['id_token']
|
|
1774
|
+
p = t.split('.')[1]; p += '=' * (-len(p) % 4)
|
|
1775
|
+
print(json.loads(base64.urlsafe_b64decode(p)).get('email', ''))
|
|
1776
|
+
EOF
|
|
1777
|
+
)"
|
|
1778
|
+
[ "$got_email" = "d@cx" ] && t_ok "codex: redirect restored the target's prior credential" \
|
|
1779
|
+
|| t_fail "codex redirect restore" "acct-04 auth.json belongs to: $got_email"
|
|
1780
|
+
# `add` while signed into an already-registered account also keeps the credential
|
|
1781
|
+
rm -f "$CX/acct-01/auth.json"
|
|
1782
|
+
before_dirs="$(ls "$CX" | sort)"
|
|
1783
|
+
out="$(FAKE_EMAIL=a@cx codex-accounts add 2>&1)"
|
|
1784
|
+
rc=$?
|
|
1785
|
+
check "codex: add of a registered account saves the fresh sign-in to it" "fresh sign-in was saved to acct-01" "$out"
|
|
1786
|
+
[ "$rc" = "0" ] && t_ok "codex: add redirect exits 0 (nothing new to register)" || t_fail "codex add redirect rc" "rc=$rc"
|
|
1787
|
+
[ -s "$CX/acct-01/auth.json" ] && t_ok "codex: add redirect landed the credential" \
|
|
1788
|
+
|| t_fail "codex add redirect" "acct-01/auth.json missing"
|
|
1789
|
+
[ "$before_dirs" = "$(ls "$CX" | sort)" ] && t_ok "codex: add redirect leaves no reserved dir behind" \
|
|
1790
|
+
|| t_fail "codex add redirect cleanup" "pool dirs changed"
|
|
1791
|
+
n="$(grep -c '"email": "a@cx"' "$CX/accounts.json")"
|
|
1792
|
+
[ "$n" = "1" ] && t_ok "codex: add redirect creates no duplicate entry" || t_fail "codex add redirect dup" "count=$n"
|
|
1793
|
+
# relogin end-to-end: a redirect mid-run fixes the OTHER account, whose own turn is
|
|
1794
|
+
# then skipped instead of demanding a second sign-in. Both accounts' home must be
|
|
1795
|
+
# THIS machine's kind, or a credless account reads as 'remote' (grant lives
|
|
1796
|
+
# elsewhere) on the other platform and never enters the worklist.
|
|
1797
|
+
cx_mk=mac; [ "$(uname -s)" = "Darwin" ] || cx_mk=linux
|
|
1798
|
+
python3 - "$CX/accounts.json" "$cx_mk" <<'EOF'
|
|
1799
|
+
import json, os, sys
|
|
1800
|
+
d = json.load(open(sys.argv[1]))
|
|
1801
|
+
for a in d['accounts']:
|
|
1802
|
+
if a['id'] in ('acct-01', 'acct-04'):
|
|
1803
|
+
a['home'] = sys.argv[2]
|
|
1804
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
1805
|
+
EOF
|
|
1806
|
+
rm -f "$CX/acct-01/auth.json" "$CX/acct-04/auth.json"
|
|
1807
|
+
out="$(FAKE_EMAIL=d@cx codex-accounts relogin --yes 2>&1)"
|
|
1808
|
+
rc=$?
|
|
1809
|
+
check "codex: relogin redirect saves the mis-ordered sign-in" "credential was saved to acct-04" "$out"
|
|
1810
|
+
check "codex: relogin skips an account fixed mid-run" "already has a working login" "$out"
|
|
1811
|
+
check "codex: relogin counts the redirect-fixed account" "re-authenticated 1 of 2" "$out"
|
|
1812
|
+
check "codex: relogin still reports the unfixed account" "still failing: acct-01" "$out"
|
|
1813
|
+
[ "$rc" != "0" ] && t_ok "codex: relogin with an unfixed account exits nonzero" || t_fail "codex relogin redirect rc" "rc=0"
|
|
1814
|
+
mk_cx_auth "$CX/acct-01/auth.json" a@cx "$FUTURE_EXP"
|
|
1815
|
+
mk_cx_auth "$CX/acct-04/auth.json" d@cx "$FUTURE_EXP"
|
|
1641
1816
|
# the sign-in ceremony is DEVICE-CODE by default (the localhost browser callback
|
|
1642
1817
|
# cannot reach a remote/SSH machine); --browser opts into the callback flow
|
|
1643
1818
|
export FAKE_LOGIN_ARGS="$WORK/cx-login-args"
|
|
@@ -2020,9 +2195,36 @@ json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1]
|
|
|
2020
2195
|
EOF
|
|
2021
2196
|
out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
|
|
2022
2197
|
rc=$?
|
|
2023
|
-
check "codex: empty manifest refuses to sync" "refusing to blank the
|
|
2198
|
+
check "codex: empty manifest refuses to sync" "refusing to blank the target pools" "$out"
|
|
2024
2199
|
[ "$rc" != "0" ] && t_ok "codex: empty-manifest sync exits nonzero" || t_fail "codex empty sync rc" "rc=0"
|
|
2025
2200
|
cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
|
|
2201
|
+
|
|
2202
|
+
# replica + peer guards, codex flavor
|
|
2203
|
+
printf 'replica\n' > "$CX/sync-role"
|
|
2204
|
+
out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
|
|
2205
|
+
rc=$?
|
|
2206
|
+
check "codex: replica pool refuses to push" "sync replica" "$out"
|
|
2207
|
+
[ "$rc" = "0" ] && t_ok "codex: replica sync exits 0" || t_fail "codex replica sync rc" "rc=$rc"
|
|
2208
|
+
# ...and a codex replica's auto_sync (fired by remove) is silent — no push, no nag
|
|
2209
|
+
out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts import rep@cx --id acct-31 --no-sync 2>&1
|
|
2210
|
+
CODEX_MULTIACC_NO_SYNC=0 codex-accounts remove acct-31 --yes 2>&1)"
|
|
2211
|
+
case "$out" in *"sync failed"*) t_fail "codex replica auto_sync" "a replica mutation warned about sync: $out" ;;
|
|
2212
|
+
*) t_ok "codex: replica auto_sync is silent (no push, no warning)" ;; esac
|
|
2213
|
+
rm -f "$CX/sync-role"
|
|
2214
|
+
python3 - "$CX/accounts.json" <<'EOF'
|
|
2215
|
+
import json, os, sys
|
|
2216
|
+
d = json.load(open(sys.argv[1]))
|
|
2217
|
+
d['peers'] = [{'target': 'gas@peer', 'root': "/tmp/x'; touch /tmp/cx_multiacc_PEER_PWNED; #", 'repo': '/tmp/y'}]
|
|
2218
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
2219
|
+
EOF
|
|
2220
|
+
rm -f /tmp/cx_multiacc_PEER_PWNED
|
|
2221
|
+
out="$(CODEX_MULTIACC_NO_SYNC=0 codex-accounts sync 2>&1)"
|
|
2222
|
+
rc=$?
|
|
2223
|
+
check "codex: injected peer root refused" "peer root is not a plain absolute path" "$out"
|
|
2224
|
+
[ "$rc" != "0" ] && t_ok "codex: bad peer exits nonzero" || t_fail "codex peer validation rc" "rc=0"
|
|
2225
|
+
[ ! -f /tmp/cx_multiacc_PEER_PWNED ] && t_ok "codex: peer injection never executed" \
|
|
2226
|
+
|| { t_fail "codex peer injection" "peer root injection EXECUTED"; rm -f /tmp/cx_multiacc_PEER_PWNED; }
|
|
2227
|
+
cp "$WORK/cx-manifest.bak" "$CX/accounts.json"
|
|
2026
2228
|
else
|
|
2027
2229
|
t_ok "codex: sync validation tests skipped (Mac-only feature)"
|
|
2028
2230
|
fi
|