claude-multiacc 1.0.12 → 1.0.14

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.
@@ -2,6 +2,7 @@
2
2
  # codex-accounts — manage the codex-multiacc account pool (OpenAI Codex CLI).
3
3
  # Subcommands: list status add import adopt dedupe remove login expired relogin
4
4
  # sync verify limits post-sync health self-update
5
+ # export-credential import-credential
5
6
  set -u
6
7
  # lib/codex_audit.py is imported by several subcommands; keep the install tree free
7
8
  # of __pycache__ (it may be root-owned, read-only, or an npm global prefix).
@@ -25,8 +26,8 @@ usage() {
25
26
  codex-accounts — multi-account pool manager for codex-multiacc (OpenAI Codex CLI)
26
27
 
27
28
  USAGE
28
- codex-accounts list brief account list
29
- codex-accounts status full health: auth, per-window limits, markers
29
+ codex-accounts list [--json] brief account list (--json: machine-readable)
30
+ codex-accounts status [--json] full health: auth, per-window limits, markers
30
31
  codex-accounts add [email] [--browser] [--force]
31
32
  login-FIRST: runs the Codex DEVICE-CODE sign-in by default — it prints a URL
32
33
  + one-time code you can open in ANY browser (this machine, your laptop, a
@@ -55,17 +56,32 @@ USAGE
55
56
  codex-accounts adopt <acct-NN> make acct-NN THIS machine's existing
56
57
  default ~/.codex login (dir symlink —
57
58
  single credential file, no grant fork)
59
+ codex-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
60
+ codex has NO portable credential class: auth.json carries a rotating refresh
61
+ token, so a copy breaks both machines. This ALWAYS refuses a credential
62
+ export (exit 3) and says what to do instead (sign in on that machine with
63
+ the device-code flow). --identity-only exports the registry entry
64
+ (id/email/home) so a daemon can seed the account list on another machine.
65
+ codex-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
66
+ [--force] [--no-sync]
67
+ install a blob from export-credential (stdin by default). For codex that is
68
+ always an identity-only blob: the account is registered and then needs ONE
69
+ interactive sign-in here (codex-accounts login <acct-NN>).
58
70
  codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
59
71
  codex-accounts dedupe [--yes] remove any account registered twice
60
72
  (same email), keeping one per email
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
73
+ codex-accounts sync [--no-server] push manifest+seeds to the sync target AND
74
+ any manifest 'peers' (extra machines). Mac
75
+ only. auth.json is machine-local, NEVER
76
+ synced. A pool with a 'sync-role' file saying
65
77
  'replica' never pushes (it receives).
78
+ --no-server (or a pool whose target is
79
+ 'none') keeps sync LOCAL: validate + seed +
80
+ fix perms, push nowhere — for pools a panel
81
+ or runner daemon distributes.
66
82
  codex-accounts verify [--quick] auth matrix; full mode runs a real
67
83
  `codex exec` per account
68
- codex-accounts limits [--quiet] [--force]
84
+ codex-accounts limits [--quiet] [--force] [--json]
69
85
  refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
70
86
  Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
71
87
  (rotated credential is persisted), so idle accounts keep fresh telemetry and
@@ -77,7 +93,12 @@ USAGE
77
93
  codex-accounts post-sync (server side) seed dirs, fix perms, quick verify
78
94
 
79
95
  ENV
80
- CODEX_ACCOUNTS_DIR override ~/.codex-accounts
96
+ CODEX_ACCOUNTS_ROOT pool root, overriding ~/.codex-accounts — one isolated pool
97
+ per app-robot instance on a shared machine (legacy spelling
98
+ CODEX_ACCOUNTS_DIR still works)
99
+ CODEX_MULTIACC_SYNC_TARGET sync target (user@host), overriding the manifest;
100
+ 'none' = local-only, nothing is pushed anywhere
101
+ CODEX_MULTIACC_SYNC_ROOT / _SYNC_REPO remote pool root / addon repo for it
81
102
  CODEX_ACCOUNT pin the shim to one account
82
103
  CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
83
104
  CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
@@ -99,17 +120,19 @@ next_id() {
99
120
  done
100
121
  }
101
122
 
102
- manifest_add_account() { # id email home
103
- "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
123
+ manifest_add_account() { # id email home [added_at]
124
+ "$PYBIN" - "$MANIFEST" "$1" "$2" "$3" "${4:-}" <<'PYEOF'
104
125
  import json, sys, time
105
- path, aid, email, home = sys.argv[1:5]
126
+ path, aid, email, home, added_at = sys.argv[1:6]
106
127
  doc = json.load(open(path))
107
128
  accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
108
129
  accounts.append({
109
130
  'id': aid,
110
131
  'email': email,
111
132
  'home': home,
112
- 'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
133
+ # An imported account keeps the added_at it was registered with on the machine it
134
+ # came from, so the same account reads identically across the fleet.
135
+ 'added_at': added_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
113
136
  })
114
137
  accounts.sort(key=lambda a: a['id'])
115
138
  doc['accounts'] = accounts
@@ -132,6 +155,17 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
132
155
  PYEOF
133
156
  }
134
157
 
158
+ manifest_email_of() { # prints the email registered for <id>, empty if unknown
159
+ [ -f "$MANIFEST" ] || return 0
160
+ "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
161
+ import json, sys
162
+ for a in json.load(open(sys.argv[1])).get('accounts', []):
163
+ if isinstance(a, dict) and a.get('id') == sys.argv[2]:
164
+ print(a.get('email', ''))
165
+ break
166
+ PYEOF
167
+ }
168
+
135
169
  manifest_del_account() { # id
136
170
  "$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
137
171
  import json, sys
@@ -146,9 +180,42 @@ os.replace(path + '.tmp', path)
146
180
  PYEOF
147
181
  }
148
182
 
183
+ # ---- per-account login lock ----------------------------------------------------
184
+ # Two concurrent logins for the SAME account corrupt each other: the later one
185
+ # snapshots the OLD credential and its abort/refusal "restores" that stale
186
+ # credential over the fresh one the first sign-in produced. A ceremony can take
187
+ # minutes, so the lock is only reclaimed after 30 minutes (holder presumed dead).
188
+ # Stale reclaim is ATOMIC between contenders: rename steals the stale lock
189
+ # (exactly one mv succeeds); the loser finds a live lock and refuses. Release is
190
+ # OWNERSHIP-GUARDED (owner.<pid> marker), so a process can never remove a lock a
191
+ # later contender legitimately re-took.
192
+ login_lock_acquire() { # $1 = lock dir; nonzero when someone else holds it
193
+ local L="$1"
194
+ if ! mkdir "$L" 2>/dev/null; then
195
+ [ $(( $(epoch_now) - $(file_mtime "$L") )) -gt 1800 ] || return 1
196
+ mv "$L" "$L.stale.$$" 2>/dev/null || return 1
197
+ rm -rf "$L.stale.$$" 2>/dev/null
198
+ mkdir "$L" 2>/dev/null || return 1
199
+ fi
200
+ : > "$L/owner.$$" 2>/dev/null || true
201
+ return 0
202
+ }
203
+ login_lock_release() { # $1 = lock dir; removes it ONLY if this process owns it
204
+ local L="$1"
205
+ [ -e "$L/owner.$$" ] && rm -rf "$L" 2>/dev/null
206
+ return 0
207
+ }
208
+ login_lock_busy() { # $1 = acct dir; true when a LIVE login ceremony holds it
209
+ [ -d "$1/.login-lock" ] \
210
+ && [ $(( $(epoch_now) - $(file_mtime "$1/.login-lock") )) -le 1800 ]
211
+ }
212
+
149
213
  auto_sync() { # best effort after mutations, Mac only, loud on failure
150
- [ "$(machine_kind)" = "mac" ] || return 0
151
214
  [ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
215
+ # Local-only pool (target 'none'): there is nothing to push — the panel/runner
216
+ # daemon distributes accounts — so a mutation must not warn about a missing server.
217
+ sync_target_is_local "$(sync_target)" && return 0
218
+ [ "$(machine_kind)" = "mac" ] || return 0
152
219
  # A replica pool never pushes (the source machine owns the account set) —
153
220
  # silently, so every mutation on a replica does not nag about it.
154
221
  sync_is_replica && return 0
@@ -158,6 +225,14 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
158
225
 
159
226
  cmd_list() {
160
227
  require_manifest
228
+ local json=0
229
+ while [ $# -gt 0 ]; do
230
+ case "$1" in
231
+ --json) json=1; shift ;;
232
+ *) die "unknown option: $1 (usage: codex-accounts list [--json])" ;;
233
+ esac
234
+ done
235
+ if [ "$json" = "1" ]; then emit_report_json list; return $?; fi
161
236
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
162
237
  import json, os, re, sys
163
238
  doc = json.load(open(sys.argv[1]))
@@ -206,6 +281,14 @@ PYEOF
206
281
 
207
282
  cmd_status() {
208
283
  require_manifest
284
+ local json=0
285
+ while [ $# -gt 0 ]; do
286
+ case "$1" in
287
+ --json) json=1; shift ;;
288
+ *) die "unknown option: $1 (usage: codex-accounts status [--json])" ;;
289
+ esac
290
+ done
291
+ if [ "$json" = "1" ]; then emit_report_json status; return $?; fi
209
292
  "$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
210
293
  import json, os, sys, time
211
294
  doc = json.load(open(sys.argv[1]))
@@ -424,15 +507,19 @@ cmd_add() {
424
507
  # The sign-in itself succeeded and produced a fresh credential for an account
425
508
  # that is already registered — don't throw it away: save it to that account,
426
509
  # exactly as `login $owner` would have. This machine now runs $owner.
510
+ # Needs the owner's login lock (non-blocking): writing under a mid-ceremony
511
+ # login for that account would corrupt it.
427
512
  local od="$ACC_ROOT/$owner"
428
513
  seed_account_dir "$od"
429
- if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
514
+ if login_lock_acquire "$od/.login-lock" && mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
430
515
  chmod 600 "$od/auth.json" 2>/dev/null || true
431
516
  clear_auth_markers "$od"
517
+ login_lock_release "$od/.login-lock"
432
518
  mutate_unlock
433
519
  log_to ops.log "add redirect: signed in as $got (already $owner) — credential saved to $owner"
434
520
  echo "$got is already added as $owner — the fresh sign-in was saved to $owner (usable on this machine now)."
435
521
  else
522
+ login_lock_release "$od/.login-lock"
436
523
  mutate_unlock
437
524
  echo "$got is already added as $owner — skipping (nothing added)."
438
525
  fi
@@ -493,20 +580,29 @@ cmd_import() {
493
580
  link) link_target="$(canon_path "$auth")" ;;
494
581
  *)
495
582
  mkdir -p "$ACC_ROOT/tmp"
496
- stage="$ACC_ROOT/tmp/import-auth.$$"
583
+ # mktemp, not a predictable name: the staged file briefly holds a live
584
+ # credential, and a predictable path in a shared pool root could be
585
+ # pre-planted as a symlink and written through.
586
+ stage="$(mktemp "$ACC_ROOT/tmp/import-auth.XXXXXX")" \
587
+ || die "could not create a staging file under $ACC_ROOT/tmp"
588
+ IMPORT_STAGE="$stage"
589
+ trap import_cred_cleanup EXIT
497
590
  case "$mode" in
498
- copy) ( umask 077; cp "$auth" "$stage" ) ;;
499
- move) mv "$auth" "$stage" ;;
500
- esac || { rm -f "$stage" 2>/dev/null; die "could not stage $auth (mode=$mode) — nothing registered"; }
591
+ copy) ( umask 077; cat "$auth" > "$stage" ) ;;
592
+ move) cat "$auth" > "$stage" && rm -f "$auth" ;;
593
+ esac || { import_cred_cleanup; trap - EXIT; die "could not stage $auth (mode=$mode) — nothing registered"; }
594
+ chmod 600 "$stage" 2>/dev/null || true
501
595
  ;;
502
596
  esac
503
597
  fi
504
598
  # Id allocation + duplicate check + manifest write run under the mutation lock, so
505
599
  # two parallel imports can never claim the same slot or drop each other's entry.
506
- mutate_lock || { rm -f "$stage" 2>/dev/null; die "could not acquire the account lock (another op is stuck?) — try again"; }
507
- # shellcheck disable=SC2064
508
- trap "mutate_unlock 2>/dev/null || true; rm -f '$stage' 2>/dev/null || true" EXIT
509
- fail_locked() { mutate_unlock; rm -f "$stage" 2>/dev/null; die "$@"; }
600
+ IMPORT_STAGE="$stage"
601
+ mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
602
+ # A CONSTANT trap program (import_cred_cleanup): interpolating the staged path would
603
+ # let a pool root containing a quote inject shell code into the trap body.
604
+ trap import_cred_cleanup EXIT
605
+ fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
510
606
  [ -n "$id" ] || id="$(next_id)"
511
607
  case "$id" in acct-[0-9][0-9]) ;; *) fail_locked "id must look like acct-NN" ;; esac
512
608
  local owner
@@ -521,6 +617,7 @@ cmd_import() {
521
617
  mv -f "$stage" "$d/auth.json" \
522
618
  || fail_locked "could not adopt $auth into $d (mode=$mode) — nothing registered"
523
619
  stage=""
620
+ IMPORT_STAGE=""
524
621
  chmod 600 "$d/auth.json" 2>/dev/null || true
525
622
  elif [ -n "$link_target" ]; then
526
623
  ln -sf "$link_target" "$d/auth.json" \
@@ -632,6 +729,10 @@ cmd_remove() {
632
729
  read -r ans
633
730
  case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
634
731
  fi
732
+ # Never delete an account out from under a live sign-in ceremony: the login's
733
+ # cleanup would then operate on a removed (or later recreated) path.
734
+ login_lock_busy "$ACC_ROOT/$id" \
735
+ && die "a login for $id is in progress — finish or abort it first"
635
736
  if [ -L "$ACC_ROOT/$id" ]; then
636
737
  rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
637
738
  else
@@ -671,6 +772,18 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
671
772
  break
672
773
  PYEOF
673
774
  )"
775
+ # PER-ACCOUNT login lock (observed in the field: parallel relogin runs left a
776
+ # months-dead token inside a minutes-old auth.json — see login_lock_acquire).
777
+ local llock="$d/.login-lock"
778
+ login_lock_acquire "$llock" \
779
+ || die "another login for $id is already in progress (this terminal or another) — finish or abort it first"
780
+ # Released on EVERY exit: normal returns, die (exit fires EXIT), Ctrl-C (the INT
781
+ # handler exits, which fires EXIT). Runs in a subshell under relogin, so the trap
782
+ # scope is exactly this one login. Ownership-guarded: never removes a lock a
783
+ # later contender re-took.
784
+ # shellcheck disable=SC2064
785
+ trap "login_lock_release '$llock'" EXIT
786
+
674
787
  echo "Sign in as $email for $id."
675
788
  # Snapshot the credential BEFORE the ceremony: `codex login` writes auth.json into
676
789
  # the dir directly, so a sign-in to the WRONG account would otherwise leave that
@@ -712,17 +825,27 @@ PYEOF
712
825
  local owner
713
826
  owner="$(email_owner "$got")"
714
827
  if [ -n "$owner" ] && [ "$owner" != "$id" ]; then
828
+ # The redirect mutates the OWNER's credential, so it needs the owner's login
829
+ # lock too (non-blocking: if a login for that account is mid-ceremony, writing
830
+ # under it would recreate exactly the corruption this lock prevents — skip the
831
+ # redirect and fall through to the plain refusal instead).
715
832
  local od="$ACC_ROOT/$owner"
716
833
  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
834
+ if login_lock_acquire "$od/.login-lock"; then
835
+ if mv -f "$d/auth.json" "$od/auth.json" 2>/dev/null; then
836
+ chmod 600 "$od/auth.json" 2>/dev/null || true
837
+ clear_auth_markers "$od"
838
+ login_lock_release "$od/.login-lock"
839
+ restore_snap
840
+ trap - INT TERM
841
+ log_to ops.log "login redirect: signed in as $got -> credential saved to $owner (target was $id)"
842
+ echo "you signed in as $got — that is $owner, so the credential was saved to $owner (usable now)."
843
+ echo "$id ($email) still needs its own sign-in: codex-accounts login $id"
844
+ return 1
845
+ fi
846
+ login_lock_release "$od/.login-lock"
847
+ else
848
+ echo "note: a login for $owner is in progress — not saving this credential there."
726
849
  fi
727
850
  fi
728
851
  restore_snap
@@ -892,11 +1015,12 @@ cmd_relogin() {
892
1015
 
893
1016
  cmd_limits() {
894
1017
  require_manifest
895
- local quiet=0 force=0
1018
+ local quiet=0 force=0 json=0
896
1019
  while [ $# -gt 0 ]; do
897
1020
  case "$1" in
898
1021
  --quiet) quiet=1; shift ;;
899
1022
  --force) force=1; shift ;; # ignore freshness/backoff (manual override)
1023
+ --json) json=1; quiet=1; shift ;; # refresh silently, then emit the report
900
1024
  *) die "unknown option: $1" ;;
901
1025
  esac
902
1026
  done
@@ -905,14 +1029,20 @@ cmd_limits() {
905
1029
  if ! mkdir "$lock" 2>/dev/null; then
906
1030
  local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
907
1031
  if [ "$age" -lt 120 ]; then
1032
+ # A --json caller still gets the document (built from the state on disk) —
1033
+ # a machine-readable verb must never answer a concurrent run with silence.
1034
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
908
1035
  [ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
909
1036
  return 0
910
1037
  fi
911
1038
  rm -rf "$lock"
912
- mkdir "$lock" 2>/dev/null || return 0
1039
+ if ! mkdir "$lock" 2>/dev/null; then
1040
+ if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
1041
+ return 0
1042
+ fi
913
1043
  fi
914
- # shellcheck disable=SC2064
915
- trap "rm -rf '$lock'" EXIT
1044
+ LIMITS_LOCK="$lock"
1045
+ trap limits_lock_release EXIT
916
1046
  rotate_log limits.log
917
1047
  # The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
918
1048
  # never loosen it, or an account could sit at 95% and still be selected.
@@ -927,7 +1057,7 @@ import base64, datetime, json, os, sys, time, urllib.request
927
1057
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
928
1058
  now = time.time()
929
1059
  # Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
930
- MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '45'))
1060
+ MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
931
1061
 
932
1062
  # OAuth refresh-token grant — the same endpoint + public client id the Codex CLI
933
1063
  # itself uses to keep auth.json alive. An account that sits idle past its
@@ -1356,13 +1486,20 @@ for acct in manifest.get('accounts', []):
1356
1486
  say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
1357
1487
  else:
1358
1488
  if os.path.exists(mpath):
1359
- # A shim-written error-cooldown marker outlives a clean limits pass:
1360
- # the account failed a real call moments ago; give the cooldown its window.
1489
+ # A shim-written marker outlives a clean limits pass while its own window
1490
+ # is still open:
1491
+ # error-cooldown — the account failed a real call moments ago.
1492
+ # client-rate-limit — a real codex run was told by the server that this
1493
+ # window is spent, and recorded the reset it was
1494
+ # handed. That is first-hand evidence; a usage
1495
+ # payload that disagrees must not unpark the account
1496
+ # early and send work straight back into the wall.
1361
1497
  keep = False
1362
1498
  try:
1363
1499
  txt = open(mpath).read()
1364
1500
  first = txt.splitlines()[0] if txt else ''
1365
- if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
1501
+ if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
1502
+ and first.isdigit() and int(first) > now:
1366
1503
  keep = True
1367
1504
  except Exception:
1368
1505
  pass
@@ -1373,6 +1510,18 @@ for acct in manifest.get('accounts', []):
1373
1510
  detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
1374
1511
  print(f'{aid}: ok {detail}')
1375
1512
  PYEOF
1513
+ local refresh_rc=$?
1514
+ # Release the lock BEFORE the report: a --json caller must not hold the refresh
1515
+ # lock while a consumer reads its output.
1516
+ limits_lock_release
1517
+ trap - EXIT
1518
+ # The refresher fails open per account; a NON-zero status means the pass itself
1519
+ # broke (unreadable manifest, dead python). Report the state anyway — stale data
1520
+ # beats silence — but hand the caller the failure, exactly as before --json existed.
1521
+ if [ "$json" = "1" ]; then
1522
+ emit_report_json limits || return $?
1523
+ fi
1524
+ return "$refresh_rc"
1376
1525
  }
1377
1526
 
1378
1527
  cmd_verify() {
@@ -1604,17 +1753,45 @@ sync_is_replica() {
1604
1753
 
1605
1754
  cmd_sync() {
1606
1755
  require_manifest
1756
+ local allow_empty=0 no_server=0
1757
+ while [ $# -gt 0 ]; do
1758
+ case "$1" in
1759
+ --allow-empty) allow_empty=1; shift ;;
1760
+ --no-server) no_server=1; shift ;; # this run pushes nowhere, whatever the manifest says
1761
+ *) die "unknown option: $1 (usage: codex-accounts sync [--no-server] [--allow-empty])" ;;
1762
+ esac
1763
+ done
1764
+ local server sroot srepo
1765
+ server="$(sync_target)"
1766
+ sroot="$(sync_target_root)"
1767
+ srepo="$(sync_target_repo)"
1768
+ # LOCAL-ONLY: no ssh target at all, because a panel/runner daemon distributes this
1769
+ # pool. Validate + fix up locally and stop — the same verb keeps working, it simply
1770
+ # has nowhere to push. (Not Mac-gated: a local pool is legitimate on any host.)
1771
+ # This mode NARROWS the replica rule, it never widens it: a replica pushes nothing,
1772
+ # and a local-only pool pushes nothing whether or not it is a replica. The marker is
1773
+ # still honored and still reported, so a replica can never start pushing by having
1774
+ # its sync target changed.
1775
+ if [ "$no_server" = "1" ] || sync_target_is_local "$server"; then
1776
+ rotate_log sync.log
1777
+ manifest_well_formed || { log_to sync.log "FAIL: manifest malformed (local sync)"; \
1778
+ die "manifest is not valid JSON or has no well-formed accounts — fix $MANIFEST"; }
1779
+ local role="source"
1780
+ sync_is_replica && role="replica"
1781
+ log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
1782
+ local_pool_fixup
1783
+ if [ "$role" = "replica" ]; then
1784
+ echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
1785
+ else
1786
+ echo "sync ok (local-only: pool at $ACC_ROOT validated and re-seeded; nothing pushed)"
1787
+ fi
1788
+ return 0
1789
+ fi
1607
1790
  [ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
1608
1791
  if sync_is_replica; then
1609
1792
  echo "this pool is a sync replica — the source machine pushes here; nothing sent"
1610
1793
  return 0
1611
1794
  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
1795
  # These land inside remote shell commands — anything but a plain target/path is a
1619
1796
  # command-injection vector from a corrupted or hand-edited manifest. EVERY target
1620
1797
  # (primary and peers) is validated before anything is pushed anywhere.
@@ -1646,17 +1823,8 @@ EOF
1646
1823
  # never be pushed (it would blank the target pools), and must never make the removal
1647
1824
  # propagation wipe a target's account dirs. Emptying the pool on purpose is possible
1648
1825
  # 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
1826
+ manifest_well_formed \
1827
+ || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
1660
1828
  if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
1661
1829
  fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
1662
1830
  fi
@@ -1683,12 +1851,7 @@ EOF
1683
1851
 
1684
1852
  cmd_post_sync() {
1685
1853
  require_manifest
1686
- local id d
1687
- for id in $(account_ids); do
1688
- d="$ACC_ROOT/$id"
1689
- seed_account_dir "$d"
1690
- [ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
1691
- done
1854
+ local_pool_fixup
1692
1855
  log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
1693
1856
  ( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
1694
1857
  cmd_verify --quick
@@ -1777,6 +1940,8 @@ case "${1:-help}" in
1777
1940
  status) shift; cmd_status "$@" ;;
1778
1941
  add) shift; cmd_add "$@" ;;
1779
1942
  import) shift; cmd_import "$@" ;;
1943
+ export-credential|export-cred) shift; cmd_export_credential "$@" ;;
1944
+ import-credential|import-cred) shift; cmd_import_credential "$@" ;;
1780
1945
  adopt) shift; cmd_adopt "$@" ;;
1781
1946
  dedupe) shift; cmd_dedupe "$@" ;;
1782
1947
  remove) shift; cmd_remove "$@" ;;