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.
- package/README.md +249 -14
- package/bin/claude +402 -38
- package/bin/claude-accounts +141 -45
- package/bin/codex +254 -26
- package/bin/codex-accounts +227 -62
- package/install.sh +175 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +355 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1116 -1
package/bin/codex-accounts
CHANGED
|
@@ -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
|
|
29
|
-
codex-accounts status
|
|
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
|
|
62
|
-
manifest 'peers' (extra machines). Mac
|
|
63
|
-
auth.json is machine-local, NEVER
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
499
|
-
move)
|
|
500
|
-
esac || {
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
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
|
-
|
|
915
|
-
trap
|
|
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', '
|
|
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
|
|
1360
|
-
#
|
|
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
|
|
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
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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 "$@" ;;
|