claude-multiacc 1.0.7 → 1.0.8
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 +78 -8
- package/bin/cli.mjs +22 -6
- package/bin/codex +453 -0
- package/bin/codex-accounts +1663 -0
- package/install.sh +90 -12
- package/lib/codex_audit.py +238 -0
- package/lib/common.sh +70 -24
- package/package.json +6 -2
- package/tests/run-tests.sh +791 -2
|
@@ -0,0 +1,1663 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# codex-accounts — manage the codex-multiacc account pool (OpenAI Codex CLI).
|
|
3
|
+
# Subcommands: list status add import adopt dedupe remove login expired relogin
|
|
4
|
+
# sync verify limits post-sync health self-update
|
|
5
|
+
set -u
|
|
6
|
+
# lib/codex_audit.py is imported by several subcommands; keep the install tree free
|
|
7
|
+
# of __pycache__ (it may be root-owned, read-only, or an npm global prefix).
|
|
8
|
+
export PYTHONDONTWRITEBYTECODE=1
|
|
9
|
+
|
|
10
|
+
_self="$0"
|
|
11
|
+
while [ -L "$_self" ]; do
|
|
12
|
+
_t="$(readlink "$_self")"
|
|
13
|
+
case "$_t" in /*) _self="$_t" ;; *) _self="$(dirname "$_self")/$_t" ;; esac
|
|
14
|
+
done
|
|
15
|
+
BIN_DIR="$(cd "$(dirname "$_self")" && pwd -P)"
|
|
16
|
+
REPO_DIR="$(dirname "$BIN_DIR")"
|
|
17
|
+
# Selects the codex pool (~/.codex-accounts, codex_audit.py, the ChatGPT usage
|
|
18
|
+
# endpoint) in everything lib/common.sh defines. Exported so subshells agree.
|
|
19
|
+
export MULTIACC_PROVIDER=codex
|
|
20
|
+
# shellcheck source=lib/common.sh
|
|
21
|
+
. "$REPO_DIR/lib/common.sh"
|
|
22
|
+
|
|
23
|
+
usage() {
|
|
24
|
+
cat <<'EOF'
|
|
25
|
+
codex-accounts — multi-account pool manager for codex-multiacc (OpenAI Codex CLI)
|
|
26
|
+
|
|
27
|
+
USAGE
|
|
28
|
+
codex-accounts list brief account list
|
|
29
|
+
codex-accounts status full health: auth, per-window limits, markers
|
|
30
|
+
codex-accounts add [email] [--browser] [--force]
|
|
31
|
+
login-FIRST: runs the Codex DEVICE-CODE sign-in by default — it prints a URL
|
|
32
|
+
+ one-time code you can open in ANY browser (this machine, your laptop, a
|
|
33
|
+
phone), so it works identically on a local Mac, over SSH, and on servers.
|
|
34
|
+
Registers only after the signed-in email is read back. Email is OPTIONAL
|
|
35
|
+
(derived from the sign-in). Stores an auto-refreshing auth.json valid on
|
|
36
|
+
THIS machine. Duplicates refused.
|
|
37
|
+
--browser uses the localhost browser-callback flow instead (only works when
|
|
38
|
+
the browser runs on THIS machine — the callback goes to localhost:1455).
|
|
39
|
+
codex-accounts login <acct-NN> [--browser] [--force]
|
|
40
|
+
complete/refresh auth for an existing account (device-code flow, or --browser)
|
|
41
|
+
codex-accounts expired [--quiet]
|
|
42
|
+
which accounts CANNOT authenticate (dead refresh grant, no login on this
|
|
43
|
+
machine) and why. These are excluded from selection — `codex` never runs
|
|
44
|
+
under them. Exits 1 when any account needs a human. --quiet prints bare ids.
|
|
45
|
+
codex-accounts relogin [acct-NN ...] [--all] [--browser] [--yes]
|
|
46
|
+
sign in again, one account at a time (device-code flow by default). With no
|
|
47
|
+
arguments it re-authenticates exactly what `expired` lists; --all covers
|
|
48
|
+
every account. Syncs once at the end.
|
|
49
|
+
codex-accounts import <email> [opts] register an account, optionally with auth
|
|
50
|
+
--id acct-NN explicit id (default: next free)
|
|
51
|
+
--home mac|server which machine owns the login (default: this one)
|
|
52
|
+
--auth PATH existing auth.json to adopt
|
|
53
|
+
--mode copy|move|link how to adopt --auth (default copy)
|
|
54
|
+
--no-sync skip the automatic server sync
|
|
55
|
+
codex-accounts adopt <acct-NN> make acct-NN THIS machine's existing
|
|
56
|
+
default ~/.codex login (dir symlink —
|
|
57
|
+
single credential file, no grant fork)
|
|
58
|
+
codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
|
|
59
|
+
codex-accounts dedupe [--yes] remove any account registered twice
|
|
60
|
+
(same email), keeping one per email
|
|
61
|
+
codex-accounts sync push manifest+seeds to the server (Mac only;
|
|
62
|
+
auth.json is machine-local, NEVER synced)
|
|
63
|
+
codex-accounts verify [--quick] auth matrix; full mode runs a real
|
|
64
|
+
`codex exec` per account
|
|
65
|
+
codex-accounts limits [--quiet] [--force]
|
|
66
|
+
refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
|
|
67
|
+
Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
|
|
68
|
+
(rotated credential is persisted), so idle accounts keep fresh telemetry and
|
|
69
|
+
stay selectable. Skips accounts fetched in the last 45s and honors
|
|
70
|
+
429/refresh backoff; --force ignores all three.
|
|
71
|
+
codex-accounts health limits + full verify; logs to health.log
|
|
72
|
+
codex-accounts self-update update the addon (npm i -g @latest, or git
|
|
73
|
+
pull + reinstall); logs to update.log
|
|
74
|
+
codex-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
75
|
+
|
|
76
|
+
ENV
|
|
77
|
+
CODEX_ACCOUNTS_DIR override ~/.codex-accounts
|
|
78
|
+
CODEX_ACCOUNT pin the shim to one account
|
|
79
|
+
CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
|
|
80
|
+
CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
81
|
+
EOF
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
require_manifest() { [ -f "$MANIFEST" ] || die "no manifest at $MANIFEST — run install.sh first"; }
|
|
85
|
+
|
|
86
|
+
next_id() {
|
|
87
|
+
local n=1 id
|
|
88
|
+
while :; do
|
|
89
|
+
id="$(printf 'acct-%02d' "$n")"
|
|
90
|
+
if [ ! -d "$ACC_ROOT/$id" ] && ! account_ids | grep -qx "$id"; then
|
|
91
|
+
printf '%s\n' "$id"
|
|
92
|
+
return 0
|
|
93
|
+
fi
|
|
94
|
+
n=$((n+1))
|
|
95
|
+
[ "$n" -gt 99 ] && die "no free account slot"
|
|
96
|
+
done
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
manifest_add_account() { # id email home
|
|
100
|
+
"$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
|
|
101
|
+
import json, sys, time
|
|
102
|
+
path, aid, email, home = sys.argv[1:5]
|
|
103
|
+
doc = json.load(open(path))
|
|
104
|
+
accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
|
|
105
|
+
accounts.append({
|
|
106
|
+
'id': aid,
|
|
107
|
+
'email': email,
|
|
108
|
+
'home': home,
|
|
109
|
+
'added_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
110
|
+
})
|
|
111
|
+
accounts.sort(key=lambda a: a['id'])
|
|
112
|
+
doc['accounts'] = accounts
|
|
113
|
+
import os
|
|
114
|
+
with open(path + '.tmp', 'w') as f:
|
|
115
|
+
json.dump(doc, f, indent=2)
|
|
116
|
+
f.write('\n')
|
|
117
|
+
os.replace(path + '.tmp', path)
|
|
118
|
+
PYEOF
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
email_owner() { # prints the id that owns <email>, empty if unregistered
|
|
122
|
+
[ -f "$MANIFEST" ] || return 0
|
|
123
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
|
|
124
|
+
import json, sys
|
|
125
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
126
|
+
if a.get('email', '').lower() == sys.argv[2].lower():
|
|
127
|
+
print(a['id'])
|
|
128
|
+
break
|
|
129
|
+
PYEOF
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
manifest_del_account() { # id
|
|
133
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
|
|
134
|
+
import json, sys
|
|
135
|
+
path, aid = sys.argv[1:3]
|
|
136
|
+
doc = json.load(open(path))
|
|
137
|
+
doc['accounts'] = [a for a in doc.get('accounts', []) if a['id'] != aid]
|
|
138
|
+
import os
|
|
139
|
+
with open(path + '.tmp', 'w') as f:
|
|
140
|
+
json.dump(doc, f, indent=2)
|
|
141
|
+
f.write('\n')
|
|
142
|
+
os.replace(path + '.tmp', path)
|
|
143
|
+
PYEOF
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
147
|
+
[ "$(machine_kind)" = "mac" ] || return 0
|
|
148
|
+
[ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
|
|
149
|
+
# subshell: cmd_sync exits on failure and must not take the CLI down with it
|
|
150
|
+
( cmd_sync ) || warn "server sync failed — run 'codex-accounts sync' manually"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
cmd_list() {
|
|
154
|
+
require_manifest
|
|
155
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
156
|
+
import json, os, re, sys
|
|
157
|
+
doc = json.load(open(sys.argv[1]))
|
|
158
|
+
root = sys.argv[2]
|
|
159
|
+
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
160
|
+
from codex_audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
161
|
+
machine = sys.argv[4]
|
|
162
|
+
# Only render well-formed ids — a hand-edited manifest must not surface a traversal id.
|
|
163
|
+
accounts = [a for a in doc.get('accounts', [])
|
|
164
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
165
|
+
if not accounts:
|
|
166
|
+
print('(no accounts yet)')
|
|
167
|
+
for a in accounts:
|
|
168
|
+
d = os.path.join(root, a['id'])
|
|
169
|
+
apath = os.path.join(d, 'auth.json')
|
|
170
|
+
auth = 'chatgpt' if (os.path.isfile(apath) and os.path.getsize(apath) > 0) else 'NONE'
|
|
171
|
+
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
172
|
+
st = audit_account(root, a, machine=machine)
|
|
173
|
+
flags = []
|
|
174
|
+
if st['state'] == 'expired':
|
|
175
|
+
flags.append('EXPIRED-LOGIN')
|
|
176
|
+
elif st['state'] == 'blocked':
|
|
177
|
+
flags.append('ORG-BLOCKED')
|
|
178
|
+
elif st['state'] == 'missing':
|
|
179
|
+
flags.append('NO-LOGIN')
|
|
180
|
+
if limited:
|
|
181
|
+
flags.append('LIMITED')
|
|
182
|
+
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} "
|
|
183
|
+
f"auth={auth:<11} {' '.join(flags)}")
|
|
184
|
+
bad = [a for a in accounts
|
|
185
|
+
if audit_account(root, a, machine=machine)['state']
|
|
186
|
+
in ('expired', 'blocked', 'missing')]
|
|
187
|
+
if bad:
|
|
188
|
+
print()
|
|
189
|
+
print(f"{len(bad)} account(s) are NOT usable — details: codex-accounts expired")
|
|
190
|
+
seen = {}
|
|
191
|
+
for a in accounts:
|
|
192
|
+
seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
193
|
+
dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
|
|
194
|
+
if dups:
|
|
195
|
+
print()
|
|
196
|
+
for e, ids in dups.items():
|
|
197
|
+
print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'codex-accounts dedupe'")
|
|
198
|
+
PYEOF
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
cmd_status() {
|
|
202
|
+
require_manifest
|
|
203
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
204
|
+
import json, os, sys, time
|
|
205
|
+
doc = json.load(open(sys.argv[1]))
|
|
206
|
+
root = sys.argv[2]
|
|
207
|
+
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
208
|
+
from codex_audit import audit_account, jwt_claims # noqa: E402
|
|
209
|
+
machine = sys.argv[4]
|
|
210
|
+
now = time.time()
|
|
211
|
+
|
|
212
|
+
def last_pick(aid):
|
|
213
|
+
path = os.path.join(root, 'selection.log')
|
|
214
|
+
if not os.path.isfile(path):
|
|
215
|
+
return '-'
|
|
216
|
+
last = '-'
|
|
217
|
+
try:
|
|
218
|
+
with open(path, errors='replace') as f:
|
|
219
|
+
for line in f:
|
|
220
|
+
parts = line.split()
|
|
221
|
+
if len(parts) >= 2 and parts[1] == aid:
|
|
222
|
+
last = parts[0]
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
return last
|
|
226
|
+
|
|
227
|
+
print(f"pool root : {root}")
|
|
228
|
+
print(f"server : {doc.get('server','-')} (root: {doc.get('server_root','-')})")
|
|
229
|
+
print(f"threshold : {doc.get('threshold', 90)}% (any window at/above => account excluded)")
|
|
230
|
+
print()
|
|
231
|
+
needs_login = []
|
|
232
|
+
for a in doc.get('accounts', []):
|
|
233
|
+
aid = a['id']
|
|
234
|
+
d = os.path.join(root, aid)
|
|
235
|
+
st = audit_account(root, a, machine=machine)
|
|
236
|
+
if st['state'] in ('expired', 'blocked', 'missing'):
|
|
237
|
+
needs_login.append(aid)
|
|
238
|
+
banner = {'ok': '', 'remote': ' [not logged in here — login lives elsewhere]',
|
|
239
|
+
'missing': ' ** NO LOGIN — codex-accounts relogin %s **' % aid,
|
|
240
|
+
'expired': ' ** LOGIN EXPIRED — codex-accounts relogin %s **' % aid,
|
|
241
|
+
'blocked': ' ** ORG BLOCKED — Codex disabled for this account by an admin **',
|
|
242
|
+
}[st['state']]
|
|
243
|
+
print(f"{aid} {a['email']} [home={a.get('home','?')}]{banner}")
|
|
244
|
+
print(f" selectable : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
|
|
245
|
+
cpath = os.path.join(d, 'auth.json')
|
|
246
|
+
if os.path.isfile(cpath):
|
|
247
|
+
try:
|
|
248
|
+
c = json.load(open(cpath))
|
|
249
|
+
tokens = c.get('tokens') or {}
|
|
250
|
+
claims = jwt_claims(tokens.get('access_token'))
|
|
251
|
+
exp = float(claims.get('exp') or 0)
|
|
252
|
+
plan = ((jwt_claims(tokens.get('id_token'))
|
|
253
|
+
.get('https://api.openai.com/auth') or {})
|
|
254
|
+
.get('chatgpt_plan_type') or '?')
|
|
255
|
+
state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
|
|
256
|
+
rstate = 'present' if tokens.get('refresh_token') else 'MISSING — re-login needed'
|
|
257
|
+
when = time.strftime('%Y-%m-%d', time.gmtime(exp)) if exp else '?'
|
|
258
|
+
print(f" chatgpt auth: {state} (access token until {when}); "
|
|
259
|
+
f"refresh token {rstate}; plan {plan}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
print(f" chatgpt auth: unreadable ({e})")
|
|
262
|
+
else:
|
|
263
|
+
print(" chatgpt auth: none on this machine")
|
|
264
|
+
lpath = os.path.join(d, 'limits.json')
|
|
265
|
+
if os.path.isfile(lpath):
|
|
266
|
+
try:
|
|
267
|
+
lim = json.load(open(lpath))
|
|
268
|
+
age = int(now - lim.get('fetched_at', 0))
|
|
269
|
+
parts = [f"{b['name']}={b['percent']}%" for b in lim.get('buckets', [])]
|
|
270
|
+
print(f" limits : {' '.join(parts) or '(none)'} [{age}s old, max {lim.get('max_percent')}%]")
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f" limits : unreadable ({e})")
|
|
273
|
+
else:
|
|
274
|
+
print(" limits : never fetched")
|
|
275
|
+
mpath = os.path.join(d, '.limited')
|
|
276
|
+
if os.path.isfile(mpath):
|
|
277
|
+
try:
|
|
278
|
+
lines = open(mpath).read().splitlines()
|
|
279
|
+
reset = int(lines[0]) if lines and lines[0].isdigit() else 0
|
|
280
|
+
detail = lines[1] if len(lines) > 1 else ''
|
|
281
|
+
mins = max(0, int((reset - now) / 60))
|
|
282
|
+
print(f" marker : LIMITED ({detail}) — clears in ~{mins}m")
|
|
283
|
+
except Exception:
|
|
284
|
+
print(" marker : LIMITED (unreadable marker)")
|
|
285
|
+
else:
|
|
286
|
+
print(" marker : none (eligible)")
|
|
287
|
+
print(f" last picked : {last_pick(aid)}")
|
|
288
|
+
print()
|
|
289
|
+
if needs_login:
|
|
290
|
+
print(f"{len(needs_login)} account(s) are EXCLUDED from selection: {', '.join(needs_login)}")
|
|
291
|
+
print("What each one needs: codex-accounts expired")
|
|
292
|
+
PYEOF
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# The signed-in identity of a codex config dir, read OFFLINE from the id_token in
|
|
296
|
+
# auth.json (no network, no real binary needed). Prints '' when unknown.
|
|
297
|
+
codex_dir_email() { # $1 = config dir
|
|
298
|
+
"$PYBIN" - "$LIB_DIR" "$1/auth.json" <<'PYEOF' 2>/dev/null || echo ""
|
|
299
|
+
import sys
|
|
300
|
+
sys.path = [sys.argv[1]] + [p for p in sys.path if p not in ('', '.')]
|
|
301
|
+
from codex_audit import auth_email # noqa: E402
|
|
302
|
+
print(auth_email(sys.argv[2]))
|
|
303
|
+
PYEOF
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# Interactive sign-in ceremony with CODEX_HOME pointed at the account dir, so the
|
|
307
|
+
# resulting auth.json lands exactly there. DEFAULT is the device-code flow
|
|
308
|
+
# (`codex login --device-auth`): it prints a URL + one-time code that can be opened
|
|
309
|
+
# in ANY browser, so it behaves the same on a local Mac, over SSH, and on servers —
|
|
310
|
+
# the browser-callback flow only works when the browser runs on this machine
|
|
311
|
+
# (localhost:1455), which is why it is the opt-in (--browser), not the default.
|
|
312
|
+
# Success is a credential that can AUTHENTICATE (codex_audit's rule), not merely a
|
|
313
|
+
# file that exists.
|
|
314
|
+
run_codex_login_ceremony() { # $1 = config dir, $2 = email hint, $3 = mode (device|browser)
|
|
315
|
+
local d="$1" hint="${2:-}" mode="${3:-device}" real
|
|
316
|
+
real="$(find_real_codex "$_self")" || { warn "real codex binary not found"; return 1; }
|
|
317
|
+
if [ "$mode" = "browser" ]; then
|
|
318
|
+
cat <<'TIP'
|
|
319
|
+
A browser will open (or a sign-in link will be printed) for the Codex ChatGPT
|
|
320
|
+
login. The sign-in must finish in a browser running on THIS machine (the
|
|
321
|
+
callback goes to localhost:1455) — from SSH/remote sessions use the default
|
|
322
|
+
device-code flow instead (drop --browser). Sign in as the account you're
|
|
323
|
+
adding; if the browser is already signed into a different ChatGPT account, use
|
|
324
|
+
"Sign in with a different account" (or a private window). This draws on the
|
|
325
|
+
ChatGPT subscription — no API keys.
|
|
326
|
+
TIP
|
|
327
|
+
else
|
|
328
|
+
cat <<'TIP'
|
|
329
|
+
Device sign-in (works from anywhere): a URL and a one-time code will appear
|
|
330
|
+
below. Open the URL in ANY browser — this machine, your laptop, even a phone —
|
|
331
|
+
sign in to the ChatGPT account you're adding (use a private window if that
|
|
332
|
+
browser is already signed into a different ChatGPT account), and enter the code.
|
|
333
|
+
TIP
|
|
334
|
+
fi
|
|
335
|
+
[ -n "$hint" ] && printf 'Sign in as: %s\n' "$hint"
|
|
336
|
+
# Run codex login DIRECTLY on the user's terminal (no PTY wrapper): the credential
|
|
337
|
+
# is written to <dir>/auth.json, we capture nothing.
|
|
338
|
+
if [ "$mode" = "browser" ]; then
|
|
339
|
+
CODEX_HOME="$d" CODEX_SHIM_ACTIVE=1 "$real" login || true
|
|
340
|
+
else
|
|
341
|
+
CODEX_HOME="$d" CODEX_SHIM_ACTIVE=1 "$real" login --device-auth || true
|
|
342
|
+
fi
|
|
343
|
+
# Re-login targets already have a (dead) auth.json on disk, so a bare existence
|
|
344
|
+
# test would call an aborted sign-in a success, clear the dead-auth marker, and
|
|
345
|
+
# hand the account straight back to the pool.
|
|
346
|
+
[ -s "$d/auth.json" ] || return 1
|
|
347
|
+
chmod 600 "$d/auth.json" 2>/dev/null || true
|
|
348
|
+
creds_alive "$d"
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
cmd_add() {
|
|
352
|
+
# Login-FIRST: the account is registered (and synced) only after sign-in succeeds
|
|
353
|
+
# and the authenticated email is read back from the account itself. The email
|
|
354
|
+
# argument is OPTIONAL — omit it and it's derived from whoever you sign in as.
|
|
355
|
+
# An aborted or failed sign-in leaves zero traces.
|
|
356
|
+
require_manifest
|
|
357
|
+
local email="" force=0 mode="device"
|
|
358
|
+
while [ $# -gt 0 ]; do
|
|
359
|
+
case "$1" in
|
|
360
|
+
--force) force=1; shift ;;
|
|
361
|
+
--browser) mode="browser"; shift ;; # localhost callback flow (browser on THIS machine)
|
|
362
|
+
--device) mode="device"; shift ;; # accepted for compatibility; device is the default
|
|
363
|
+
--*) die "unknown option: $1" ;;
|
|
364
|
+
*)
|
|
365
|
+
if [ -z "$email" ]; then email="$1"; shift
|
|
366
|
+
else die "unexpected argument: $1 (usage: codex-accounts add [email] [--browser] [--force])"; fi ;;
|
|
367
|
+
esac
|
|
368
|
+
done
|
|
369
|
+
# If an email was named and it's already in the pool, SKIP before any sign-in — no
|
|
370
|
+
# duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
|
|
371
|
+
# a no-op, not an error. Re-auth an existing account with 'login'.)
|
|
372
|
+
local owner
|
|
373
|
+
if [ -n "$email" ]; then
|
|
374
|
+
owner="$(email_owner "$email")"
|
|
375
|
+
if [ -n "$owner" ]; then
|
|
376
|
+
echo "$email is already added as $owner — skipping (nothing to do; run 'codex-accounts login $owner' to re-authenticate it)."
|
|
377
|
+
return 0
|
|
378
|
+
fi
|
|
379
|
+
fi
|
|
380
|
+
if [ ! -t 0 ] && [ -z "${CODEX_MULTIACC_FORCE_TTY:-}" ]; then
|
|
381
|
+
die "add is interactive (it completes sign-in before registering) — run it from a terminal"
|
|
382
|
+
fi
|
|
383
|
+
find_real_codex "$_self" >/dev/null || die "real codex binary not found"
|
|
384
|
+
|
|
385
|
+
# Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
|
|
386
|
+
# add gets the next one). The long browser sign-in below runs WITHOUT the lock.
|
|
387
|
+
# RESERVED_DIR drives a trap that removes the half-made dir on any failure/abort
|
|
388
|
+
# BEFORE registration.
|
|
389
|
+
local id d got elabel
|
|
390
|
+
elabel="${email:-the account you sign in as}"
|
|
391
|
+
RESERVED_DIR=""
|
|
392
|
+
trap 'add_cleanup_reserved; exit 130' INT TERM
|
|
393
|
+
trap 'add_cleanup_reserved' EXIT
|
|
394
|
+
mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
|
|
395
|
+
id="$(next_id)"
|
|
396
|
+
d="$ACC_ROOT/$id"
|
|
397
|
+
seed_account_dir "$d"
|
|
398
|
+
RESERVED_DIR="$d"
|
|
399
|
+
mutate_unlock
|
|
400
|
+
|
|
401
|
+
echo "Preparing $id for $elabel — NOTHING is registered until login completes."
|
|
402
|
+
run_codex_login_ceremony "$d" "$email" "$mode" || die "login failed or aborted — nothing was created"
|
|
403
|
+
got="$(codex_dir_email "$d")"
|
|
404
|
+
if [ -z "$got" ]; then
|
|
405
|
+
if [ -n "$email" ] && [ "$force" = "1" ]; then
|
|
406
|
+
warn "identity unverified — registering as $email because --force was given"
|
|
407
|
+
got="$email"
|
|
408
|
+
else
|
|
409
|
+
die "signed in, but the account identity could not be read back — nothing was created (retry; or 'add <email> --force' to trust a named email)"
|
|
410
|
+
fi
|
|
411
|
+
fi
|
|
412
|
+
# Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
|
|
413
|
+
# registered the same email meanwhile — the loser skips gracefully). This is the
|
|
414
|
+
# guarantee that a sign-in to an already-added account never yields a second entry.
|
|
415
|
+
mutate_lock || die "could not acquire the account lock — try again"
|
|
416
|
+
owner="$(email_owner "$got")"
|
|
417
|
+
if [ -n "$owner" ]; then
|
|
418
|
+
mutate_unlock
|
|
419
|
+
echo "$got is already added as $owner — skipping (nothing added)."
|
|
420
|
+
return 0 # RESERVED_DIR still set -> trap removes the temp dir
|
|
421
|
+
fi
|
|
422
|
+
if [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
423
|
+
warn "signed in as $got (you named $email) — registering the account that actually authenticated"
|
|
424
|
+
fi
|
|
425
|
+
manifest_add_account "$id" "$got" "$(machine_kind)"
|
|
426
|
+
clear_auth_markers "$d"
|
|
427
|
+
mutate_unlock
|
|
428
|
+
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
429
|
+
trap - EXIT INT TERM
|
|
430
|
+
log_to ops.log "add $id $got (auth-verified)"
|
|
431
|
+
auto_sync
|
|
432
|
+
cat <<EOF
|
|
433
|
+
Registered $id for $got (sign-in verified) — usable immediately.
|
|
434
|
+
Optional:
|
|
435
|
+
codex-accounts verify # confirm the 100% matrix
|
|
436
|
+
EOF
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
add_cleanup_reserved() {
|
|
440
|
+
# EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
|
|
441
|
+
# we died mid-critical-section, release the lock).
|
|
442
|
+
[ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
|
|
443
|
+
mutate_unlock 2>/dev/null || true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
cmd_import() {
|
|
447
|
+
require_manifest
|
|
448
|
+
local email="${1:-}"
|
|
449
|
+
[ -n "$email" ] || die "usage: codex-accounts import <email> [--id acct-NN] [--home mac|server] [--auth PATH] [--mode copy|move|link] [--no-sync]"
|
|
450
|
+
shift
|
|
451
|
+
local id="" home="" auth="" mode="copy" no_sync=0 force=0
|
|
452
|
+
while [ $# -gt 0 ]; do
|
|
453
|
+
case "$1" in
|
|
454
|
+
--id) id="$2"; shift 2 ;;
|
|
455
|
+
--home) home="$2"; shift 2 ;;
|
|
456
|
+
--auth) auth="$2"; shift 2 ;;
|
|
457
|
+
--mode) mode="$2"; shift 2 ;;
|
|
458
|
+
--no-sync) no_sync=1; shift ;;
|
|
459
|
+
--force) force=1; shift ;;
|
|
460
|
+
*) die "unknown option: $1" ;;
|
|
461
|
+
esac
|
|
462
|
+
done
|
|
463
|
+
[ -n "$home" ] || home="$(machine_kind)"
|
|
464
|
+
# Validate ALL inputs before creating anything, so a bad auth path leaves no
|
|
465
|
+
# half-made account dir behind.
|
|
466
|
+
[ -n "$auth" ] && { [ -f "$auth" ] || die "auth file not found: $auth"; }
|
|
467
|
+
case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
|
|
468
|
+
# STAGE the auth payload BEFORE taking the lock: the lock's stale-reclaim window
|
|
469
|
+
# is 30s, so the critical section below must stay milliseconds-fast (renames and
|
|
470
|
+
# a manifest write) — never a copy of caller-sized data.
|
|
471
|
+
local stage="" link_target=""
|
|
472
|
+
if [ -n "$auth" ]; then
|
|
473
|
+
case "$mode" in
|
|
474
|
+
link) link_target="$(canon_path "$auth")" ;;
|
|
475
|
+
*)
|
|
476
|
+
mkdir -p "$ACC_ROOT/tmp"
|
|
477
|
+
stage="$ACC_ROOT/tmp/import-auth.$$"
|
|
478
|
+
case "$mode" in
|
|
479
|
+
copy) ( umask 077; cp "$auth" "$stage" ) ;;
|
|
480
|
+
move) mv "$auth" "$stage" ;;
|
|
481
|
+
esac || { rm -f "$stage" 2>/dev/null; die "could not stage $auth (mode=$mode) — nothing registered"; }
|
|
482
|
+
;;
|
|
483
|
+
esac
|
|
484
|
+
fi
|
|
485
|
+
# Id allocation + duplicate check + manifest write run under the mutation lock, so
|
|
486
|
+
# two parallel imports can never claim the same slot or drop each other's entry.
|
|
487
|
+
mutate_lock || { rm -f "$stage" 2>/dev/null; die "could not acquire the account lock (another op is stuck?) — try again"; }
|
|
488
|
+
# shellcheck disable=SC2064
|
|
489
|
+
trap "mutate_unlock 2>/dev/null || true; rm -f '$stage' 2>/dev/null || true" EXIT
|
|
490
|
+
fail_locked() { mutate_unlock; rm -f "$stage" 2>/dev/null; die "$@"; }
|
|
491
|
+
[ -n "$id" ] || id="$(next_id)"
|
|
492
|
+
case "$id" in acct-[0-9][0-9]) ;; *) fail_locked "id must look like acct-NN" ;; esac
|
|
493
|
+
local owner
|
|
494
|
+
owner="$(email_owner "$email")"
|
|
495
|
+
if [ -n "$owner" ] && [ "$owner" != "$id" ] && [ "$force" != "1" ]; then
|
|
496
|
+
fail_locked "$email is already registered as $owner — use --id $owner to update it, or --force to register a duplicate"
|
|
497
|
+
fi
|
|
498
|
+
local d="$ACC_ROOT/$id"
|
|
499
|
+
seed_account_dir "$d"
|
|
500
|
+
if [ -n "$stage" ]; then
|
|
501
|
+
# A failed adopt must not register an account that claims auth it does not have.
|
|
502
|
+
mv -f "$stage" "$d/auth.json" \
|
|
503
|
+
|| fail_locked "could not adopt $auth into $d (mode=$mode) — nothing registered"
|
|
504
|
+
stage=""
|
|
505
|
+
chmod 600 "$d/auth.json" 2>/dev/null || true
|
|
506
|
+
elif [ -n "$link_target" ]; then
|
|
507
|
+
ln -sf "$link_target" "$d/auth.json" \
|
|
508
|
+
|| fail_locked "could not link $auth into $d — nothing registered"
|
|
509
|
+
fi
|
|
510
|
+
manifest_add_account "$id" "$email" "$home" \
|
|
511
|
+
|| fail_locked "manifest update failed — $id was seeded but NOT registered (re-run import)"
|
|
512
|
+
mutate_unlock
|
|
513
|
+
trap - EXIT
|
|
514
|
+
log_to ops.log "import $id $email home=$home auth=${auth:+yes} mode=$mode"
|
|
515
|
+
echo "Imported $id ($email, home=$home)."
|
|
516
|
+
[ "$no_sync" = "1" ] || auto_sync
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# Prints duplicate account ids to REMOVE, one per line: for every email that appears
|
|
520
|
+
# more than once, keep exactly one (prefer an account that has auth on this machine,
|
|
521
|
+
# then the lowest id) and list the rest. Empty output => pool is already clean.
|
|
522
|
+
dup_ids_to_remove() {
|
|
523
|
+
[ -f "$MANIFEST" ] || return 0
|
|
524
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
|
|
525
|
+
import json, os, re, sys
|
|
526
|
+
manifest, root = sys.argv[1], sys.argv[2]
|
|
527
|
+
accts = [a for a in json.load(open(manifest)).get('accounts', [])
|
|
528
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
529
|
+
def has_auth(aid):
|
|
530
|
+
p = os.path.join(root, aid, 'auth.json')
|
|
531
|
+
return os.path.isfile(p) and os.path.getsize(p) > 0
|
|
532
|
+
by_email = {}
|
|
533
|
+
for a in accts:
|
|
534
|
+
by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
535
|
+
for email, ids in by_email.items():
|
|
536
|
+
if len(ids) < 2:
|
|
537
|
+
continue
|
|
538
|
+
# keep: authed first, then lowest id
|
|
539
|
+
keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
|
|
540
|
+
for i in ids:
|
|
541
|
+
if i != keep:
|
|
542
|
+
print(i)
|
|
543
|
+
PYEOF
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
cmd_dedupe() {
|
|
547
|
+
require_manifest
|
|
548
|
+
local yes=0
|
|
549
|
+
[ "${1:-}" = "--yes" ] && yes=1
|
|
550
|
+
local dups
|
|
551
|
+
dups="$(dup_ids_to_remove)"
|
|
552
|
+
if [ -z "$dups" ]; then
|
|
553
|
+
echo "No duplicate accounts — every email appears once."
|
|
554
|
+
return 0
|
|
555
|
+
fi
|
|
556
|
+
echo "Duplicate accounts (same email registered more than once):"
|
|
557
|
+
local id email
|
|
558
|
+
for id in $dups; do
|
|
559
|
+
email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
|
|
560
|
+
import json, sys
|
|
561
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
562
|
+
if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
|
|
563
|
+
PYEOF
|
|
564
|
+
)"
|
|
565
|
+
echo " will remove $id ($email)"
|
|
566
|
+
done
|
|
567
|
+
if [ "$yes" != "1" ]; then
|
|
568
|
+
printf 'Remove these duplicates (keeps one per email)? [y/N] '
|
|
569
|
+
read -r ans
|
|
570
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
571
|
+
fi
|
|
572
|
+
for id in $dups; do
|
|
573
|
+
if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
|
|
574
|
+
manifest_del_account "$id"
|
|
575
|
+
log_to ops.log "dedupe removed $id"
|
|
576
|
+
done
|
|
577
|
+
echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
|
|
578
|
+
auto_sync
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
cmd_adopt() {
|
|
582
|
+
# Make <acct-NN> THIS machine's existing default login (~/.codex) without
|
|
583
|
+
# forking its OAuth grant: the account dir becomes a symlink to ~/.codex, so
|
|
584
|
+
# there is exactly one auth.json no matter which path refreshes it.
|
|
585
|
+
require_manifest
|
|
586
|
+
local id="${1:-}"
|
|
587
|
+
[ -n "$id" ] || die "usage: codex-accounts adopt <acct-NN>"
|
|
588
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
589
|
+
account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
|
|
590
|
+
local d="$ACC_ROOT/$id" default="$HOME/.codex"
|
|
591
|
+
[ -s "$default/auth.json" ] || die "no default login at $default to adopt"
|
|
592
|
+
if [ -L "$d" ]; then
|
|
593
|
+
echo "$id already adopted ($(readlink "$d"))"
|
|
594
|
+
return 0
|
|
595
|
+
fi
|
|
596
|
+
if [ -d "$d" ]; then
|
|
597
|
+
[ -s "$d/auth.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
|
|
598
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
599
|
+
fi
|
|
600
|
+
ln -s "$default" "$d"
|
|
601
|
+
log_to ops.log "adopt $id -> $default"
|
|
602
|
+
echo "$id now runs the default $default login (symlinked, single credential file)."
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
cmd_remove() {
|
|
606
|
+
require_manifest
|
|
607
|
+
local id="${1:-}" yes="${2:-}"
|
|
608
|
+
[ -n "$id" ] || die "usage: codex-accounts remove <acct-NN> [--yes]"
|
|
609
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
610
|
+
account_ids | grep -qx "$id" || die "unknown account: $id"
|
|
611
|
+
if [ "$yes" != "--yes" ]; then
|
|
612
|
+
printf 'Remove %s and propagate deletion to the server? [y/N] ' "$id"
|
|
613
|
+
read -r ans
|
|
614
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
615
|
+
fi
|
|
616
|
+
if [ -L "$ACC_ROOT/$id" ]; then
|
|
617
|
+
rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
|
|
618
|
+
else
|
|
619
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
620
|
+
fi
|
|
621
|
+
manifest_del_account "$id"
|
|
622
|
+
log_to ops.log "remove $id"
|
|
623
|
+
auto_sync
|
|
624
|
+
echo "Removed $id."
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
cmd_login() {
|
|
628
|
+
# Complete (or refresh) auth for an EXISTING account: the device-code flow by
|
|
629
|
+
# default (works locally and over SSH), or --browser for the localhost callback
|
|
630
|
+
# flow. Verifies the signed-in email matches the manifest.
|
|
631
|
+
require_manifest
|
|
632
|
+
local id="" force=0 mode="device"
|
|
633
|
+
while [ $# -gt 0 ]; do
|
|
634
|
+
case "$1" in
|
|
635
|
+
--force) force=1; shift ;;
|
|
636
|
+
--browser) mode="browser"; shift ;;
|
|
637
|
+
--device) mode="device"; shift ;; # accepted for compatibility; device is the default
|
|
638
|
+
--*) die "unknown option: $1" ;;
|
|
639
|
+
*) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
|
|
640
|
+
esac
|
|
641
|
+
done
|
|
642
|
+
[ -n "$id" ] || die "usage: codex-accounts login <acct-NN> [--browser] [--force]"
|
|
643
|
+
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
644
|
+
account_ids | grep -qx "$id" || die "unknown account: $id"
|
|
645
|
+
local d="$ACC_ROOT/$id" email got
|
|
646
|
+
seed_account_dir "$d"
|
|
647
|
+
email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
|
|
648
|
+
import json, sys
|
|
649
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
650
|
+
if a['id'] == sys.argv[2]:
|
|
651
|
+
print(a.get('email', ''))
|
|
652
|
+
break
|
|
653
|
+
PYEOF
|
|
654
|
+
)"
|
|
655
|
+
echo "Sign in as $email for $id."
|
|
656
|
+
# Snapshot the credential BEFORE the ceremony: `codex login` writes auth.json into
|
|
657
|
+
# the dir directly, so a sign-in to the WRONG account would otherwise leave that
|
|
658
|
+
# account's credential installed under $id — selectable, and burning the wrong
|
|
659
|
+
# subscription. On any refused/aborted outcome the prior state is restored exactly;
|
|
660
|
+
# a snapshot that cannot be taken ABORTS (never risk destroying the only copy).
|
|
661
|
+
LOGIN_SNAP=""
|
|
662
|
+
LOGIN_HAD_CRED=0
|
|
663
|
+
LOGIN_TARGET="$d"
|
|
664
|
+
if [ -f "$d/auth.json" ]; then
|
|
665
|
+
LOGIN_HAD_CRED=1
|
|
666
|
+
LOGIN_SNAP="$ACC_ROOT/tmp/login-snap.$$.json"
|
|
667
|
+
mkdir -p "$ACC_ROOT/tmp"
|
|
668
|
+
( umask 077; cp "$d/auth.json" "$LOGIN_SNAP" 2>/dev/null ) \
|
|
669
|
+
|| die "cannot snapshot the current credential ($d/auth.json) — aborting before any sign-in"
|
|
670
|
+
fi
|
|
671
|
+
restore_snap() { # put the pre-ceremony credential state back, exactly
|
|
672
|
+
if [ "$LOGIN_HAD_CRED" = "1" ]; then
|
|
673
|
+
[ -f "$LOGIN_SNAP" ] && mv -f "$LOGIN_SNAP" "$LOGIN_TARGET/auth.json" 2>/dev/null
|
|
674
|
+
else
|
|
675
|
+
rm -f "$LOGIN_TARGET/auth.json" 2>/dev/null
|
|
676
|
+
fi
|
|
677
|
+
rm -f "$LOGIN_SNAP" 2>/dev/null
|
|
678
|
+
}
|
|
679
|
+
# The transaction window (ceremony -> identity check) must not leave a wrong
|
|
680
|
+
# credential behind on Ctrl-C/kill either.
|
|
681
|
+
trap 'restore_snap; exit 130' INT TERM
|
|
682
|
+
run_codex_login_ceremony "$d" "$email" "$mode" \
|
|
683
|
+
|| { restore_snap; trap - INT TERM; die "login failed or aborted (no working credential landed) — nothing changed"; }
|
|
684
|
+
got="$(codex_dir_email "$d")"
|
|
685
|
+
# Case-insensitive identity match (same rule the duplicate check applies).
|
|
686
|
+
got_lc="$(printf '%s' "$got" | tr '[:upper:]' '[:lower:]')"
|
|
687
|
+
email_lc="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
|
|
688
|
+
if [ -n "$got" ] && [ "$got_lc" != "$email_lc" ] && [ "$force" != "1" ]; then
|
|
689
|
+
restore_snap
|
|
690
|
+
trap - INT TERM
|
|
691
|
+
die "you signed in as $got but $id is $email — nothing saved, previous credential restored (use --force to override)"
|
|
692
|
+
fi
|
|
693
|
+
if [ -z "$got" ] && [ "$force" != "1" ]; then
|
|
694
|
+
restore_snap
|
|
695
|
+
trap - INT TERM
|
|
696
|
+
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
697
|
+
fi
|
|
698
|
+
trap - INT TERM
|
|
699
|
+
rm -f "$LOGIN_SNAP" 2>/dev/null
|
|
700
|
+
clear_auth_markers "$d"
|
|
701
|
+
echo "$id login saved (auth.json, this machine, auto-refreshing)."
|
|
702
|
+
log_to ops.log "login $id verified=${got:-unverified} mode=$mode"
|
|
703
|
+
auto_sync
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
cmd_expired() {
|
|
707
|
+
# Which accounts CANNOT authenticate right now — the pool's re-login worklist.
|
|
708
|
+
# Same rule the shim selects by (lib/codex_audit.py), so what is listed here is
|
|
709
|
+
# exactly what is excluded from selection.
|
|
710
|
+
require_manifest
|
|
711
|
+
local quiet=0
|
|
712
|
+
while [ $# -gt 0 ]; do
|
|
713
|
+
case "$1" in
|
|
714
|
+
--quiet|--ids) quiet=1; shift ;; # ids only, for scripts
|
|
715
|
+
*) die "unknown option: $1 (usage: codex-accounts expired [--quiet])" ;;
|
|
716
|
+
esac
|
|
717
|
+
done
|
|
718
|
+
local rows bad
|
|
719
|
+
rows="$(account_audit)" || die "the account audit failed — cannot say which logins are dead"
|
|
720
|
+
if [ -z "$rows" ]; then
|
|
721
|
+
# No rows at all: either the pool is genuinely empty, or the manifest lost its
|
|
722
|
+
# accounts. Never render that as "all clear".
|
|
723
|
+
if [ -z "$(account_ids)" ]; then
|
|
724
|
+
echo "No accounts registered yet — add one with: codex-accounts add"
|
|
725
|
+
return 0
|
|
726
|
+
fi
|
|
727
|
+
die "the manifest lists accounts but none could be audited — check $MANIFEST"
|
|
728
|
+
fi
|
|
729
|
+
bad="$(printf '%s\n' "$rows" | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }')"
|
|
730
|
+
if [ "$quiet" = "1" ]; then
|
|
731
|
+
[ -n "$bad" ] || return 0
|
|
732
|
+
printf '%s\n' "$bad"
|
|
733
|
+
return 1
|
|
734
|
+
fi
|
|
735
|
+
printf '%s\n' "$rows" | awk -F'\t' '
|
|
736
|
+
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0 }
|
|
737
|
+
NF < 4 || $1 == "" { next } # never invent an account from a blank line
|
|
738
|
+
$4 == "ok" { ok++; next }
|
|
739
|
+
$4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
|
|
740
|
+
{
|
|
741
|
+
bad++
|
|
742
|
+
relogin++
|
|
743
|
+
printf " %-9s %-28s %-9s %s\n", $1, $2, $5, $6
|
|
744
|
+
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
745
|
+
}
|
|
746
|
+
END {
|
|
747
|
+
if (bad == 0) printf "All %d account(s) with a login on this machine can authenticate.\n", ok
|
|
748
|
+
else printf "\n%d account(s) cannot be used, %d fine.\n", bad, ok
|
|
749
|
+
if (remote > 0) {
|
|
750
|
+
printf "\nNot logged in here on purpose (another machine owns the login):\n"
|
|
751
|
+
printf "%s", rem
|
|
752
|
+
}
|
|
753
|
+
if (relogin > 0) {
|
|
754
|
+
printf "\nRe-authenticate them:\n"
|
|
755
|
+
printf " codex-accounts relogin # every account that needs it\n"
|
|
756
|
+
printf " codex-accounts relogin acct-NN # just one\n"
|
|
757
|
+
}
|
|
758
|
+
}'
|
|
759
|
+
# Exit 1 when something needs a human, so cron/health checks can alert on it.
|
|
760
|
+
[ -z "$bad" ]
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
cmd_relogin() {
|
|
764
|
+
# Re-authenticate accounts whose login died. With no arguments it targets exactly
|
|
765
|
+
# what `expired` lists; ids (or --all) override that. Runs the same verified login
|
|
766
|
+
# ceremony as `login`, one account at a time, and syncs ONCE at the end.
|
|
767
|
+
require_manifest
|
|
768
|
+
local all=0 yes=0 browser=0 ids=""
|
|
769
|
+
while [ $# -gt 0 ]; do
|
|
770
|
+
case "$1" in
|
|
771
|
+
--all) all=1; shift ;;
|
|
772
|
+
--yes|-y) yes=1; shift ;;
|
|
773
|
+
--browser) browser=1; shift ;;
|
|
774
|
+
--device) browser=0; shift ;; # accepted for compatibility; device is the default
|
|
775
|
+
--*) die "unknown option: $1" ;;
|
|
776
|
+
*)
|
|
777
|
+
valid_acct_id "$1" || die "not a valid account id: $1"
|
|
778
|
+
account_ids | grep -qx "$1" || die "unknown account: $1"
|
|
779
|
+
ids="$ids $1"; shift ;;
|
|
780
|
+
esac
|
|
781
|
+
done
|
|
782
|
+
if [ -n "$ids" ] && [ "$all" = "1" ]; then
|
|
783
|
+
die "give account ids OR --all, not both"
|
|
784
|
+
fi
|
|
785
|
+
if [ -z "$ids" ]; then
|
|
786
|
+
if [ "$all" = "1" ]; then
|
|
787
|
+
ids="$(account_ids | tr '\n' ' ')"
|
|
788
|
+
else
|
|
789
|
+
# A FAILED audit must not read as "nothing to do".
|
|
790
|
+
account_audit >/dev/null || die "the account audit failed — refusing to guess what needs a re-login"
|
|
791
|
+
ids="$(accounts_needing_login | tr '\n' ' ')"
|
|
792
|
+
fi
|
|
793
|
+
fi
|
|
794
|
+
ids="$(printf '%s' "$ids" | tr -s ' ' | sed 's/^ //; s/ $//')"
|
|
795
|
+
if [ -z "$ids" ]; then
|
|
796
|
+
echo "Nothing to re-authenticate — every account on this machine can be used."
|
|
797
|
+
return 0
|
|
798
|
+
fi
|
|
799
|
+
local count rows
|
|
800
|
+
count="$(printf '%s\n' "$ids" | tr ' ' '\n' | grep -c .)"
|
|
801
|
+
rows="$(account_audit)"
|
|
802
|
+
echo "Accounts to re-authenticate ($count):"
|
|
803
|
+
local id
|
|
804
|
+
for id in $ids; do
|
|
805
|
+
printf ' %s %s\n' "$id" \
|
|
806
|
+
"$(printf '%s\n' "$rows" | awk -F'\t' -v i="$id" '$1 == i { print $2 " (" $5 ")" }')"
|
|
807
|
+
done
|
|
808
|
+
if [ "$yes" != "1" ]; then
|
|
809
|
+
if [ ! -t 0 ]; then
|
|
810
|
+
die "relogin is interactive (each account needs a sign-in) — run it from a terminal, or pass --yes"
|
|
811
|
+
fi
|
|
812
|
+
printf 'Sign in to each of them now? [y/N] '
|
|
813
|
+
read -r ans
|
|
814
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
815
|
+
fi
|
|
816
|
+
# One sync at the end instead of one per account: each auto_sync is an ssh round trip.
|
|
817
|
+
local prev_no_sync="${CODEX_MULTIACC_NO_SYNC:-0}" failed="" done_ok=0
|
|
818
|
+
export CODEX_MULTIACC_NO_SYNC=1
|
|
819
|
+
for id in $ids; do
|
|
820
|
+
echo
|
|
821
|
+
echo "=== $id ==============================================================="
|
|
822
|
+
if [ "$browser" = "1" ]; then
|
|
823
|
+
( cmd_login "$id" --browser ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
|
|
824
|
+
else
|
|
825
|
+
( cmd_login "$id" ) && done_ok=$((done_ok + 1)) || failed="$failed $id"
|
|
826
|
+
fi
|
|
827
|
+
done
|
|
828
|
+
export CODEX_MULTIACC_NO_SYNC="$prev_no_sync"
|
|
829
|
+
[ "$prev_no_sync" = "0" ] && unset CODEX_MULTIACC_NO_SYNC
|
|
830
|
+
echo
|
|
831
|
+
echo "re-authenticated $done_ok of $count account(s)."
|
|
832
|
+
if [ -n "$failed" ]; then
|
|
833
|
+
warn "still failing:$failed (re-run: codex-accounts relogin$failed)"
|
|
834
|
+
fi
|
|
835
|
+
[ "$done_ok" -gt 0 ] && auto_sync
|
|
836
|
+
[ -z "$failed" ]
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
cmd_limits() {
|
|
840
|
+
require_manifest
|
|
841
|
+
local quiet=0 force=0
|
|
842
|
+
while [ $# -gt 0 ]; do
|
|
843
|
+
case "$1" in
|
|
844
|
+
--quiet) quiet=1; shift ;;
|
|
845
|
+
--force) force=1; shift ;; # ignore freshness/backoff (manual override)
|
|
846
|
+
*) die "unknown option: $1" ;;
|
|
847
|
+
esac
|
|
848
|
+
done
|
|
849
|
+
local lock="$ACC_ROOT/.locks/limits"
|
|
850
|
+
mkdir -p "$ACC_ROOT/.locks"
|
|
851
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
852
|
+
local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
|
|
853
|
+
if [ "$age" -lt 120 ]; then
|
|
854
|
+
[ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
|
|
855
|
+
return 0
|
|
856
|
+
fi
|
|
857
|
+
rm -rf "$lock"
|
|
858
|
+
mkdir "$lock" 2>/dev/null || return 0
|
|
859
|
+
fi
|
|
860
|
+
# shellcheck disable=SC2064
|
|
861
|
+
trap "rm -rf '$lock'" EXIT
|
|
862
|
+
rotate_log limits.log
|
|
863
|
+
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
864
|
+
# never loosen it, or an account could sit at 95% and still be selected.
|
|
865
|
+
local threshold
|
|
866
|
+
threshold="$(manifest_get threshold 90)"
|
|
867
|
+
case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
|
|
868
|
+
[ "$threshold" -gt 90 ] && threshold=90
|
|
869
|
+
[ "$threshold" -lt 1 ] && threshold=90
|
|
870
|
+
"$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
|
|
871
|
+
import base64, datetime, json, os, sys, time, urllib.request
|
|
872
|
+
|
|
873
|
+
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
874
|
+
now = time.time()
|
|
875
|
+
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
876
|
+
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '45'))
|
|
877
|
+
|
|
878
|
+
# OAuth refresh-token grant — the same endpoint + public client id the Codex CLI
|
|
879
|
+
# itself uses to keep auth.json alive. An account that sits idle past its
|
|
880
|
+
# access-token TTL would otherwise drop out of telemetry (stale data ranks
|
|
881
|
+
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
|
|
882
|
+
TOKEN_URL = os.environ.get('CODEX_MULTIACC_TOKEN_URL',
|
|
883
|
+
'https://auth.openai.com/oauth/token')
|
|
884
|
+
CLIENT_ID = os.environ.get('CODEX_MULTIACC_CLIENT_ID',
|
|
885
|
+
'app_EMoamEEZ73f0CkXaXp7hrann')
|
|
886
|
+
# Only refresh a token that has been expired for a while: a LIVE session refreshes
|
|
887
|
+
# its own credential within moments of expiry, so a long-expired one proves no
|
|
888
|
+
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
|
|
889
|
+
REFRESH_MIN_EXPIRED = 300
|
|
890
|
+
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
891
|
+
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
892
|
+
|
|
893
|
+
def say(msg):
|
|
894
|
+
if not quiet:
|
|
895
|
+
print(msg)
|
|
896
|
+
with open(os.path.join(root, 'limits.log'), 'a') as f:
|
|
897
|
+
f.write(time.strftime('%Y-%m-%dT%H:%M:%SZ ', time.gmtime()) + msg + '\n')
|
|
898
|
+
|
|
899
|
+
def jwt_claims(token):
|
|
900
|
+
try:
|
|
901
|
+
payload = str(token).split('.')[1]
|
|
902
|
+
payload += '=' * (-len(payload) % 4)
|
|
903
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
904
|
+
return claims if isinstance(claims, dict) else {}
|
|
905
|
+
except Exception:
|
|
906
|
+
return {}
|
|
907
|
+
|
|
908
|
+
def jwt_exp(token):
|
|
909
|
+
try:
|
|
910
|
+
return float(jwt_claims(token).get('exp') or 0)
|
|
911
|
+
except (TypeError, ValueError):
|
|
912
|
+
return 0.0
|
|
913
|
+
|
|
914
|
+
# `.expired` — the persistent "this account cannot authenticate" marker the shim
|
|
915
|
+
# honors. Written only for a PROVEN dead grant (no refresh token, or an
|
|
916
|
+
# invalid_grant from the token endpoint), never for a transient hiccup.
|
|
917
|
+
def mark_expired(d, slug, detail=''):
|
|
918
|
+
mpath = os.path.join(d, '.expired')
|
|
919
|
+
try:
|
|
920
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
921
|
+
f.write(f'{int(now)}\n')
|
|
922
|
+
f.write(f"reason={slug} marked_at="
|
|
923
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
924
|
+
os.replace(mpath + '.tmp', mpath)
|
|
925
|
+
except Exception:
|
|
926
|
+
pass
|
|
927
|
+
|
|
928
|
+
def clear_expired(d):
|
|
929
|
+
"""A usage fetch that succeeded PROVES the bearer works — drop any dead-auth mark.
|
|
930
|
+
EXCEPT an org-blocked one: those accounts authenticate perfectly (telemetry works),
|
|
931
|
+
they are just barred from Codex inference, so telemetry says nothing about them.
|
|
932
|
+
Only a passing `verify` (a real call) or a re-login lifts that."""
|
|
933
|
+
mpath = os.path.join(d, '.expired')
|
|
934
|
+
try:
|
|
935
|
+
if 'reason=org-blocked' in open(mpath, errors='replace').read():
|
|
936
|
+
return False
|
|
937
|
+
except OSError:
|
|
938
|
+
return False
|
|
939
|
+
try:
|
|
940
|
+
os.remove(mpath)
|
|
941
|
+
return True
|
|
942
|
+
except OSError:
|
|
943
|
+
return False
|
|
944
|
+
|
|
945
|
+
try:
|
|
946
|
+
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
947
|
+
except Exception as e:
|
|
948
|
+
sys.exit(f'cannot read manifest: {e}')
|
|
949
|
+
|
|
950
|
+
def refresh_oauth(aid, d, cpath):
|
|
951
|
+
"""Refresh a long-expired access token via the OAuth refresh-token grant and
|
|
952
|
+
persist the ROTATED credential atomically (0600). Returns the new bearer, or
|
|
953
|
+
None (fail open: the on-disk credential is never touched on failure).
|
|
954
|
+
Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
|
|
955
|
+
because telemetry state must only ever reflect real usage fetches."""
|
|
956
|
+
spath = os.path.join(d, '.oauth-refresh.json')
|
|
957
|
+
try:
|
|
958
|
+
doc = json.load(open(cpath))
|
|
959
|
+
tokens = doc.get('tokens')
|
|
960
|
+
# Present-but-null/non-object tokens (interrupted or reset credential
|
|
961
|
+
# write) must degrade THIS account only, like every other malformed input.
|
|
962
|
+
if not isinstance(doc, dict) or not isinstance(tokens, dict):
|
|
963
|
+
return None
|
|
964
|
+
except Exception:
|
|
965
|
+
return None
|
|
966
|
+
if not tokens.get('access_token'):
|
|
967
|
+
return None
|
|
968
|
+
if not tokens.get('refresh_token'):
|
|
969
|
+
mark_expired(d, 'no-refresh-token',
|
|
970
|
+
'credential has no refresh token and its access token expired')
|
|
971
|
+
return None
|
|
972
|
+
if jwt_exp(tokens['access_token']) > now - REFRESH_MIN_EXPIRED:
|
|
973
|
+
return None # not expired long enough to prove no live session owns it
|
|
974
|
+
if not force:
|
|
975
|
+
try:
|
|
976
|
+
if json.load(open(spath)).get('retry_after', 0) > now:
|
|
977
|
+
return None # earlier refresh failure still backing off
|
|
978
|
+
except Exception:
|
|
979
|
+
pass
|
|
980
|
+
|
|
981
|
+
def back_off(wait, why, denials=0):
|
|
982
|
+
try:
|
|
983
|
+
with open(spath + '.tmp', 'w') as f:
|
|
984
|
+
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
985
|
+
'denials': denials,
|
|
986
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
987
|
+
os.replace(spath + '.tmp', spath)
|
|
988
|
+
except Exception:
|
|
989
|
+
pass
|
|
990
|
+
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
991
|
+
|
|
992
|
+
# The grant ROTATES the refresh token, so it must not be consumed unless the
|
|
993
|
+
# rotated credential can actually be persisted afterwards: probe that the
|
|
994
|
+
# atomic-write path works (0600 temp file in the same dir) BEFORE the request.
|
|
995
|
+
# The probe uses its OWN pid-scoped name — never the real .tmp staging path,
|
|
996
|
+
# which a concurrent writer could be mid-flight on.
|
|
997
|
+
probe = f'{cpath}.probe.{os.getpid()}'
|
|
998
|
+
try:
|
|
999
|
+
fd = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1000
|
+
os.close(fd)
|
|
1001
|
+
os.remove(probe)
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
back_off(REFRESH_FAIL_BACKOFF, f'cannot persist a rotated credential ({str(e)[:120]}); refresh not attempted')
|
|
1004
|
+
return None
|
|
1005
|
+
body = json.dumps({'client_id': CLIENT_ID,
|
|
1006
|
+
'grant_type': 'refresh_token',
|
|
1007
|
+
'refresh_token': tokens['refresh_token'],
|
|
1008
|
+
'scope': 'openid profile email'}).encode()
|
|
1009
|
+
req = urllib.request.Request(TOKEN_URL, data=body, headers={
|
|
1010
|
+
'Content-Type': 'application/json',
|
|
1011
|
+
'User-Agent': 'codex-multiacc/1.0',
|
|
1012
|
+
})
|
|
1013
|
+
try:
|
|
1014
|
+
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
1015
|
+
except urllib.error.HTTPError as e:
|
|
1016
|
+
if e.code in (400, 401, 403):
|
|
1017
|
+
# A 4xx from the TOKEN endpoint is only proof of a dead grant when the
|
|
1018
|
+
# server says so (OAuth's invalid_grant). Everything else 4xx — a bad
|
|
1019
|
+
# client_id, an endpoint change, a WAF page, a provider incident — would
|
|
1020
|
+
# hit EVERY account at once, so it must never park the whole pool on the
|
|
1021
|
+
# first try: back off, and only park after this account has been refused
|
|
1022
|
+
# repeatedly.
|
|
1023
|
+
resp_body = ''
|
|
1024
|
+
try:
|
|
1025
|
+
resp_body = e.read().decode('utf-8', 'replace')[:400]
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
denials = 1
|
|
1029
|
+
try:
|
|
1030
|
+
denials = int(json.load(open(spath)).get('denials', 0)) + 1
|
|
1031
|
+
except Exception:
|
|
1032
|
+
pass
|
|
1033
|
+
if 'invalid_grant' in resp_body:
|
|
1034
|
+
mark_expired(d, f'refresh-denied-http-{e.code}',
|
|
1035
|
+
'the refresh grant was refused as invalid_grant (revoked or rotated away)')
|
|
1036
|
+
elif denials >= 3:
|
|
1037
|
+
mark_expired(d, f'refresh-denied-http-{e.code}',
|
|
1038
|
+
f'the refresh grant was refused {denials} times in a row')
|
|
1039
|
+
back_off(REFRESH_DENIED_BACKOFF,
|
|
1040
|
+
f'HTTP {e.code} — refresh token may be revoked; re-login needed',
|
|
1041
|
+
denials=denials)
|
|
1042
|
+
else:
|
|
1043
|
+
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
1044
|
+
return None
|
|
1045
|
+
except Exception as e:
|
|
1046
|
+
back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
|
|
1047
|
+
return None
|
|
1048
|
+
tok = data.get('access_token') if isinstance(data, dict) else None
|
|
1049
|
+
if not tok:
|
|
1050
|
+
back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
|
|
1051
|
+
return None
|
|
1052
|
+
tokens['access_token'] = tok
|
|
1053
|
+
# The grant ROTATES the refresh token: persist it (and the fresh id_token) or
|
|
1054
|
+
# the account is stranded — hence atomic write, and a loud message if it fails.
|
|
1055
|
+
if data.get('refresh_token'):
|
|
1056
|
+
tokens['refresh_token'] = data['refresh_token']
|
|
1057
|
+
if data.get('id_token'):
|
|
1058
|
+
tokens['id_token'] = data['id_token']
|
|
1059
|
+
doc['tokens'] = tokens
|
|
1060
|
+
doc['last_refresh'] = datetime.datetime.now(datetime.timezone.utc) \
|
|
1061
|
+
.isoformat().replace('+00:00', 'Z')
|
|
1062
|
+
try:
|
|
1063
|
+
fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1064
|
+
with os.fdopen(fd, 'w') as f:
|
|
1065
|
+
json.dump(doc, f, indent=2)
|
|
1066
|
+
os.replace(cpath + '.tmp', cpath)
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
say(f'{aid}: token refreshed but auth.json NOT persisted ({e}) — re-login may be needed')
|
|
1069
|
+
return None
|
|
1070
|
+
try:
|
|
1071
|
+
os.remove(spath)
|
|
1072
|
+
except OSError:
|
|
1073
|
+
pass
|
|
1074
|
+
# The grant answered: whatever parked this account before, it authenticates now.
|
|
1075
|
+
if clear_expired(d):
|
|
1076
|
+
say(f'{aid}: dead-auth marker cleared (refresh grant works again)')
|
|
1077
|
+
say(f'{aid}: access token refreshed via refresh-token grant')
|
|
1078
|
+
return tok
|
|
1079
|
+
|
|
1080
|
+
for acct in manifest.get('accounts', []):
|
|
1081
|
+
aid = acct['id']
|
|
1082
|
+
d = os.path.join(root, aid)
|
|
1083
|
+
if not os.path.isdir(d):
|
|
1084
|
+
continue
|
|
1085
|
+
|
|
1086
|
+
# The usage endpoint rate-limits per account. Several callers can fire at once
|
|
1087
|
+
# (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
|
|
1088
|
+
# this account's data is already fresh, and honor any backoff a 429 set earlier.
|
|
1089
|
+
# Checked FIRST so a skipped account never burns an oauth refresh for nothing.
|
|
1090
|
+
lpath = os.path.join(d, 'limits.json')
|
|
1091
|
+
prev = {}
|
|
1092
|
+
if os.path.isfile(lpath):
|
|
1093
|
+
try:
|
|
1094
|
+
prev = json.load(open(lpath))
|
|
1095
|
+
except Exception:
|
|
1096
|
+
prev = {}
|
|
1097
|
+
if not force:
|
|
1098
|
+
age = now - prev.get('fetched_at', 0)
|
|
1099
|
+
if age < MIN_FETCH_INTERVAL:
|
|
1100
|
+
continue
|
|
1101
|
+
retry_at = prev.get('retry_after', 0)
|
|
1102
|
+
if retry_at > now:
|
|
1103
|
+
say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
|
|
1104
|
+
continue
|
|
1105
|
+
|
|
1106
|
+
bearer = None
|
|
1107
|
+
account_id = ''
|
|
1108
|
+
cpath = os.path.join(d, 'auth.json')
|
|
1109
|
+
if os.path.isfile(cpath):
|
|
1110
|
+
try:
|
|
1111
|
+
c = json.load(open(cpath))
|
|
1112
|
+
tokens = c.get('tokens') or {}
|
|
1113
|
+
if isinstance(tokens, dict) and tokens.get('access_token') \
|
|
1114
|
+
and jwt_exp(tokens['access_token']) > now + 60:
|
|
1115
|
+
bearer = tokens['access_token']
|
|
1116
|
+
if isinstance(tokens, dict):
|
|
1117
|
+
account_id = str(tokens.get('account_id') or '')
|
|
1118
|
+
except Exception:
|
|
1119
|
+
pass
|
|
1120
|
+
if not bearer:
|
|
1121
|
+
# Hard fail-open guard: NOTHING a single account's refresh does may abort
|
|
1122
|
+
# the loop — every account after it would silently starve of telemetry.
|
|
1123
|
+
try:
|
|
1124
|
+
tok = refresh_oauth(aid, d, cpath)
|
|
1125
|
+
except Exception as e:
|
|
1126
|
+
say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
|
|
1127
|
+
tok = None
|
|
1128
|
+
if tok:
|
|
1129
|
+
bearer = tok
|
|
1130
|
+
if not bearer:
|
|
1131
|
+
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
1132
|
+
say(f'{aid}: no fresh bearer (expired or missing chatgpt auth); limits left as-is')
|
|
1133
|
+
continue
|
|
1134
|
+
if not account_id:
|
|
1135
|
+
account_id = str((jwt_claims(bearer).get('https://api.openai.com/auth') or {})
|
|
1136
|
+
.get('chatgpt_account_id') or '')
|
|
1137
|
+
|
|
1138
|
+
headers = {
|
|
1139
|
+
'Authorization': 'Bearer ' + bearer,
|
|
1140
|
+
'Content-Type': 'application/json',
|
|
1141
|
+
'User-Agent': 'codex_cli_rs (codex-multiacc)',
|
|
1142
|
+
'originator': 'codex_cli_rs',
|
|
1143
|
+
}
|
|
1144
|
+
if account_id:
|
|
1145
|
+
headers['chatgpt-account-id'] = account_id
|
|
1146
|
+
req = urllib.request.Request(url, headers=headers)
|
|
1147
|
+
try:
|
|
1148
|
+
resp = urllib.request.urlopen(req, timeout=15)
|
|
1149
|
+
data = json.loads(resp.read().decode())
|
|
1150
|
+
except urllib.error.HTTPError as e:
|
|
1151
|
+
if e.code == 429:
|
|
1152
|
+
# Respect Retry-After; otherwise exponential backoff capped at 30 min.
|
|
1153
|
+
try:
|
|
1154
|
+
wait = int(e.headers.get('Retry-After') or 0)
|
|
1155
|
+
except (TypeError, ValueError):
|
|
1156
|
+
wait = 0
|
|
1157
|
+
if wait <= 0:
|
|
1158
|
+
wait = min(1800, max(120, int(prev.get('backoff', 60)) * 2))
|
|
1159
|
+
prev['retry_after'] = int(now + wait)
|
|
1160
|
+
prev['backoff'] = wait
|
|
1161
|
+
tmp = lpath + '.tmp'
|
|
1162
|
+
with open(tmp, 'w') as f:
|
|
1163
|
+
json.dump(prev, f, indent=1)
|
|
1164
|
+
os.replace(tmp, lpath)
|
|
1165
|
+
say(f'{aid}: rate limited (429); backing off {wait}s; failing open')
|
|
1166
|
+
else:
|
|
1167
|
+
say(f'{aid}: usage fetch failed (HTTP {e.code}); failing open')
|
|
1168
|
+
continue
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
say(f'{aid}: usage fetch failed ({e}); failing open')
|
|
1171
|
+
continue
|
|
1172
|
+
|
|
1173
|
+
def pct_of(v):
|
|
1174
|
+
try:
|
|
1175
|
+
return max(0, min(100, int(round(float(v)))))
|
|
1176
|
+
except (TypeError, ValueError):
|
|
1177
|
+
return None
|
|
1178
|
+
|
|
1179
|
+
# Shape-agnostic bucket extraction. The payload nests rate-limit WINDOWS
|
|
1180
|
+
# (used_percent + limit_window_seconds + reset_at) under several scopes:
|
|
1181
|
+
# the overall rate_limit, code_review_rate_limit, and one per model family in
|
|
1182
|
+
# additional_rate_limits[] (each model tracked as its own bucket — the codex
|
|
1183
|
+
# analog of the per-model Fable bucket requirement). Window length classifies
|
|
1184
|
+
# session (~5h, self-healing) vs weekly (multi-day, expensive): <=6h => session.
|
|
1185
|
+
# If the payload reshapes, whatever windows remain are still found (recursive
|
|
1186
|
+
# fallback), unparseable entries are skipped, and a payload the code cannot
|
|
1187
|
+
# read at all degrades that one account (fail open), never the run.
|
|
1188
|
+
def win_bucket(scope, which, win):
|
|
1189
|
+
if not isinstance(win, dict):
|
|
1190
|
+
return None
|
|
1191
|
+
pct = pct_of(win.get('used_percent'))
|
|
1192
|
+
if pct is None:
|
|
1193
|
+
return None
|
|
1194
|
+
try:
|
|
1195
|
+
secs = int(win.get('limit_window_seconds') or 0)
|
|
1196
|
+
except (TypeError, ValueError):
|
|
1197
|
+
secs = 0
|
|
1198
|
+
if secs and secs <= 21600:
|
|
1199
|
+
dur, group = f'{max(1, secs // 3600)}h', 'session'
|
|
1200
|
+
elif secs:
|
|
1201
|
+
dur, group = f'{max(1, secs // 86400)}d', 'weekly'
|
|
1202
|
+
else:
|
|
1203
|
+
# Unknown durable windows default to 'weekly' so they are never under-weighted.
|
|
1204
|
+
dur, group = which, 'weekly'
|
|
1205
|
+
name = f'{scope}:{dur}' if scope else dur
|
|
1206
|
+
try:
|
|
1207
|
+
reset_epoch = int(float(win.get('reset_at')))
|
|
1208
|
+
except (TypeError, ValueError):
|
|
1209
|
+
reset_epoch = int(now + (secs or 3600))
|
|
1210
|
+
return {
|
|
1211
|
+
'name': name,
|
|
1212
|
+
'kind': which,
|
|
1213
|
+
'group': group,
|
|
1214
|
+
'percent': pct,
|
|
1215
|
+
'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
|
|
1216
|
+
'resets_epoch': reset_epoch,
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
def scope_buckets(scope, rl):
|
|
1220
|
+
out = []
|
|
1221
|
+
if not isinstance(rl, dict):
|
|
1222
|
+
return out
|
|
1223
|
+
for which in ('primary_window', 'secondary_window'):
|
|
1224
|
+
b = win_bucket(scope, which, rl.get(which))
|
|
1225
|
+
if b:
|
|
1226
|
+
out.append(b)
|
|
1227
|
+
# A hard "limit reached / not allowed" verdict without a >=threshold window
|
|
1228
|
+
# (payload skew) must still exclude: synthesize a 100% bucket that resets
|
|
1229
|
+
# with the scope's furthest-out window (or in 1h if none is readable).
|
|
1230
|
+
if (rl.get('limit_reached') is True or rl.get('allowed') is False) \
|
|
1231
|
+
and not any(b['percent'] >= threshold for b in out):
|
|
1232
|
+
reset_epoch = max([b['resets_epoch'] for b in out] or [int(now + 3600)])
|
|
1233
|
+
out.append({
|
|
1234
|
+
'name': f'{scope}:limit_reached' if scope else 'limit_reached',
|
|
1235
|
+
'kind': 'limit_reached', 'group': 'weekly', 'percent': 100,
|
|
1236
|
+
'resets_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(reset_epoch)),
|
|
1237
|
+
'resets_epoch': reset_epoch,
|
|
1238
|
+
})
|
|
1239
|
+
return out
|
|
1240
|
+
|
|
1241
|
+
buckets = []
|
|
1242
|
+
try:
|
|
1243
|
+
buckets += scope_buckets('', data.get('rate_limit'))
|
|
1244
|
+
buckets += scope_buckets('code_review', data.get('code_review_rate_limit'))
|
|
1245
|
+
for extra in (data.get('additional_rate_limits') or []):
|
|
1246
|
+
if not isinstance(extra, dict):
|
|
1247
|
+
continue
|
|
1248
|
+
scope = str(extra.get('limit_name') or extra.get('metered_feature') or 'model')
|
|
1249
|
+
buckets += scope_buckets(scope, extra.get('rate_limit'))
|
|
1250
|
+
if not buckets:
|
|
1251
|
+
# Fallback for a reshaped payload: find ANY window-shaped object (carries
|
|
1252
|
+
# used_percent) anywhere in the document, naming it by its path. Missing
|
|
1253
|
+
# one would let an exhausted bucket go unnoticed.
|
|
1254
|
+
def walk(node, path):
|
|
1255
|
+
if isinstance(node, dict):
|
|
1256
|
+
if 'used_percent' in node:
|
|
1257
|
+
b = win_bucket(':'.join(path[:-1]), path[-1] if path else 'window', node)
|
|
1258
|
+
if b:
|
|
1259
|
+
buckets.append(b)
|
|
1260
|
+
return
|
|
1261
|
+
for k in sorted(node):
|
|
1262
|
+
walk(node[k], path + [str(k)])
|
|
1263
|
+
elif isinstance(node, list):
|
|
1264
|
+
for i, item in enumerate(node):
|
|
1265
|
+
walk(item, path + [str(i)])
|
|
1266
|
+
walk(data, [])
|
|
1267
|
+
except Exception as e:
|
|
1268
|
+
say(f'{aid}: unexpected usage payload shape ({e}); failing open')
|
|
1269
|
+
continue
|
|
1270
|
+
# Successful fetch: fresh buckets replace everything, backoff state is dropped.
|
|
1271
|
+
# THREE selection signals, same reset asymmetry as the claude pool:
|
|
1272
|
+
# max_percent — peak of ALL buckets; drives >=90% EXCLUSION.
|
|
1273
|
+
# weekly_percent — peak of the durable buckets; the PRIMARY ranking signal.
|
|
1274
|
+
# session_percent— peak of the self-healing ~5h buckets; a soft tiebreaker.
|
|
1275
|
+
maxp = max([b['percent'] for b in buckets] or [0])
|
|
1276
|
+
weekly = [b['percent'] for b in buckets if b['group'] != 'session']
|
|
1277
|
+
session = [b['percent'] for b in buckets if b['group'] == 'session']
|
|
1278
|
+
weeklyp = max(weekly) if weekly else maxp
|
|
1279
|
+
sessionp = max(session) if session else 0
|
|
1280
|
+
out = {'fetched_at': int(now), 'source': 'chatgpt', 'max_percent': maxp,
|
|
1281
|
+
'weekly_percent': weeklyp, 'session_percent': sessionp,
|
|
1282
|
+
'plan': str(data.get('plan_type') or ''), 'buckets': buckets}
|
|
1283
|
+
tmp = lpath + '.tmp'
|
|
1284
|
+
with open(tmp, 'w') as f:
|
|
1285
|
+
json.dump(out, f, indent=1)
|
|
1286
|
+
os.replace(tmp, lpath)
|
|
1287
|
+
# The fetch went through with this account's own bearer => its auth is alive.
|
|
1288
|
+
if clear_expired(d):
|
|
1289
|
+
say(f'{aid}: dead-auth marker cleared (authenticated successfully)')
|
|
1290
|
+
offenders = [b for b in buckets if b['percent'] >= threshold]
|
|
1291
|
+
mpath = os.path.join(d, '.limited')
|
|
1292
|
+
if offenders:
|
|
1293
|
+
worst = max(offenders, key=lambda b: b['percent'])
|
|
1294
|
+
reset_epoch = max(int(b['resets_epoch']) for b in offenders)
|
|
1295
|
+
# Atomic: a concurrent shim must never read a half-written marker.
|
|
1296
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
1297
|
+
f.write(f'{reset_epoch}\n')
|
|
1298
|
+
f.write(f"bucket={worst['name']} percent={worst['percent']} "
|
|
1299
|
+
f"marked_at={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
|
|
1300
|
+
f"reason=limits resets_at={worst['resets_at']}\n")
|
|
1301
|
+
os.replace(mpath + '.tmp', mpath)
|
|
1302
|
+
say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
|
|
1303
|
+
else:
|
|
1304
|
+
if os.path.exists(mpath):
|
|
1305
|
+
# A shim-written error-cooldown marker outlives a clean limits pass:
|
|
1306
|
+
# the account failed a real call moments ago; give the cooldown its window.
|
|
1307
|
+
keep = False
|
|
1308
|
+
try:
|
|
1309
|
+
txt = open(mpath).read()
|
|
1310
|
+
first = txt.splitlines()[0] if txt else ''
|
|
1311
|
+
if 'reason=error-cooldown' in txt and first.isdigit() and int(first) > now:
|
|
1312
|
+
keep = True
|
|
1313
|
+
except Exception:
|
|
1314
|
+
pass
|
|
1315
|
+
if not keep:
|
|
1316
|
+
os.remove(mpath)
|
|
1317
|
+
say(f'{aid}: marker cleared (max {maxp}%)')
|
|
1318
|
+
if not quiet:
|
|
1319
|
+
detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
|
|
1320
|
+
print(f'{aid}: ok {detail}')
|
|
1321
|
+
PYEOF
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
cmd_verify() {
|
|
1325
|
+
require_manifest
|
|
1326
|
+
local quick=0
|
|
1327
|
+
[ "${1:-}" = "--quick" ] && quick=1
|
|
1328
|
+
local real=""
|
|
1329
|
+
if [ "$quick" = "0" ]; then
|
|
1330
|
+
real="$(find_real_codex "$_self")" || die "real codex binary not found"
|
|
1331
|
+
fi
|
|
1332
|
+
"$PYBIN" - "$ACC_ROOT" "$quick" "$real" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
1333
|
+
import json, os, re, subprocess, sys, tempfile, time
|
|
1334
|
+
|
|
1335
|
+
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
1336
|
+
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
1337
|
+
from codex_audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
1338
|
+
machine = sys.argv[5]
|
|
1339
|
+
now = time.time()
|
|
1340
|
+
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
1341
|
+
os.makedirs(os.path.join(root, 'tmp'), exist_ok=True)
|
|
1342
|
+
failures = 0
|
|
1343
|
+
tested = 0
|
|
1344
|
+
|
|
1345
|
+
# Same failure vocabulary the shim retries on (bin/codex: PARK_AUTH / PARK_ORG).
|
|
1346
|
+
AUTH_ERR = re.compile(
|
|
1347
|
+
r'not (logged|signed) in|authentication (required|failed|error)'
|
|
1348
|
+
r'|please run `?codex login|run `?codex login`? to|401|unauthorized'
|
|
1349
|
+
r'|token .{0,12}(expired|revoked|invalid)|refresh token.{0,20}(expired|invalid|revoked)'
|
|
1350
|
+
r'|invalid_grant|could not refresh|re-?authenticate', re.I)
|
|
1351
|
+
ORG_ERR = re.compile(
|
|
1352
|
+
r'disabled by (your )?(workspace )?admin|admin (has )?disabled'
|
|
1353
|
+
r'|(workspace|organization) has disabled (codex|chatgpt)'
|
|
1354
|
+
r'|codex.{0,20}disabled for (your|this) (workspace|organization)', re.I)
|
|
1355
|
+
|
|
1356
|
+
def mark_expired(d, slug, detail=''):
|
|
1357
|
+
"""Park an account the shim must stop selecting. Verify is the strongest signal
|
|
1358
|
+
there is — a real inference call that came back 'not authenticated'."""
|
|
1359
|
+
mpath = os.path.join(d, '.expired')
|
|
1360
|
+
try:
|
|
1361
|
+
with open(mpath + '.tmp', 'w') as f:
|
|
1362
|
+
f.write(f'{int(time.time())}\n')
|
|
1363
|
+
f.write(f"reason={slug} marked_at="
|
|
1364
|
+
f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} detail={detail}\n")
|
|
1365
|
+
os.replace(mpath + '.tmp', mpath)
|
|
1366
|
+
except Exception:
|
|
1367
|
+
pass
|
|
1368
|
+
|
|
1369
|
+
for acct in manifest.get('accounts', []):
|
|
1370
|
+
aid = acct['id']
|
|
1371
|
+
d = os.path.join(root, aid)
|
|
1372
|
+
cpath = os.path.join(d, 'auth.json')
|
|
1373
|
+
has_creds = os.path.isfile(cpath) and os.path.getsize(cpath) > 0
|
|
1374
|
+
if not has_creds:
|
|
1375
|
+
print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
|
|
1376
|
+
continue
|
|
1377
|
+
tested += 1
|
|
1378
|
+
if quick:
|
|
1379
|
+
# Quick mode must agree with what the shim will actually do — presence of a
|
|
1380
|
+
# credential file is not proof it can authenticate.
|
|
1381
|
+
st = audit_account(root, acct, machine=machine)
|
|
1382
|
+
if st['state'] == 'ok':
|
|
1383
|
+
print(f'{aid} {acct["email"]}: OK (quick, chatgpt auth present)')
|
|
1384
|
+
else:
|
|
1385
|
+
print(f'{aid} {acct["email"]}: FAIL ({st["label"]} — {st["reason"]})'
|
|
1386
|
+
+ (f'; fix: {st["fix"]}' if st['fix'] else ''))
|
|
1387
|
+
failures += 1
|
|
1388
|
+
continue
|
|
1389
|
+
env = dict(os.environ)
|
|
1390
|
+
env['CODEX_HOME'] = d
|
|
1391
|
+
env.pop('OPENAI_API_KEY', None)
|
|
1392
|
+
env.pop('CODEX_ACCOUNT', None)
|
|
1393
|
+
env['CODEX_SHIM_ACTIVE'] = '1'
|
|
1394
|
+
# --output-last-message: codex exec prints progress/log lines around the answer,
|
|
1395
|
+
# so the agent's final message is read from a file instead of scraping stdout.
|
|
1396
|
+
outfile = tempfile.NamedTemporaryFile(prefix='verify.', suffix='.txt',
|
|
1397
|
+
dir=os.path.join(root, 'tmp'), delete=False)
|
|
1398
|
+
outfile.close()
|
|
1399
|
+
t0 = time.time()
|
|
1400
|
+
try:
|
|
1401
|
+
r = subprocess.run([real, 'exec', '--skip-git-repo-check',
|
|
1402
|
+
'--sandbox', 'read-only', '--color', 'never',
|
|
1403
|
+
'-o', outfile.name, 'Reply with exactly: OK'],
|
|
1404
|
+
env=env, capture_output=True, text=True, timeout=240,
|
|
1405
|
+
stdin=subprocess.DEVNULL, cwd=root)
|
|
1406
|
+
except subprocess.TimeoutExpired:
|
|
1407
|
+
print(f'{aid} {acct["email"]}: FAIL (timeout after 240s)')
|
|
1408
|
+
failures += 1
|
|
1409
|
+
try:
|
|
1410
|
+
os.remove(outfile.name)
|
|
1411
|
+
except OSError:
|
|
1412
|
+
pass
|
|
1413
|
+
continue
|
|
1414
|
+
dt = time.time() - t0
|
|
1415
|
+
try:
|
|
1416
|
+
last = open(outfile.name, errors='replace').read().strip()
|
|
1417
|
+
except OSError:
|
|
1418
|
+
last = ''
|
|
1419
|
+
try:
|
|
1420
|
+
os.remove(outfile.name)
|
|
1421
|
+
except OSError:
|
|
1422
|
+
pass
|
|
1423
|
+
out = (r.stdout or '').strip()
|
|
1424
|
+
if r.returncode == 0 and ('ok' in last.lower() or 'ok' in out.lower()):
|
|
1425
|
+
# A real call succeeded: this account is definitively alive.
|
|
1426
|
+
try:
|
|
1427
|
+
os.remove(os.path.join(d, '.expired'))
|
|
1428
|
+
except OSError:
|
|
1429
|
+
pass
|
|
1430
|
+
print(f'{aid} {acct["email"]}: PASS ({dt:.1f}s) -> {(last or out)[:60]!r}')
|
|
1431
|
+
else:
|
|
1432
|
+
err = (r.stderr or '').strip()[:200]
|
|
1433
|
+
hint = ''
|
|
1434
|
+
if ORG_ERR.search(out) or ORG_ERR.search(err):
|
|
1435
|
+
# Not an auth problem: the account authenticates fine, a workspace admin
|
|
1436
|
+
# has simply turned Codex access off. Park it — a re-login changes
|
|
1437
|
+
# nothing — and say what actually helps.
|
|
1438
|
+
mark_expired(d, 'org-blocked',
|
|
1439
|
+
'Codex access is disabled for the account by a workspace admin')
|
|
1440
|
+
hint = (f' — ORG BLOCKED, excluded from the pool; '
|
|
1441
|
+
f'try: codex-accounts relogin {aid}')
|
|
1442
|
+
elif AUTH_ERR.search(out) or AUTH_ERR.search(err):
|
|
1443
|
+
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
1444
|
+
hint = f' — login is dead, run: codex-accounts relogin {aid}'
|
|
1445
|
+
print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
|
|
1446
|
+
f'out={(last or out)[:120]!r} err={err!r}{hint}')
|
|
1447
|
+
failures += 1
|
|
1448
|
+
|
|
1449
|
+
print()
|
|
1450
|
+
print(f'verified {tested} account(s), {failures} failure(s)')
|
|
1451
|
+
sys.exit(1 if failures else 0)
|
|
1452
|
+
PYEOF
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
cmd_sync() {
|
|
1456
|
+
require_manifest
|
|
1457
|
+
[ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
|
|
1458
|
+
local allow_empty=0
|
|
1459
|
+
[ "${1:-}" = "--allow-empty" ] && allow_empty=1
|
|
1460
|
+
local server sroot srepo rc=0
|
|
1461
|
+
server="$(manifest_get server "$DEFAULT_SERVER")"
|
|
1462
|
+
sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
|
|
1463
|
+
srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
|
|
1464
|
+
# These land inside a remote shell command — anything but a plain target/path is a
|
|
1465
|
+
# command-injection vector from a corrupted or hand-edited manifest.
|
|
1466
|
+
valid_ssh_target "$server" || die "manifest 'server' is not a plain user@host: $server"
|
|
1467
|
+
valid_remote_path "$sroot" || die "manifest 'server_root' is not a plain absolute path: $sroot"
|
|
1468
|
+
valid_remote_path "$srepo" || die "manifest 'server_repo' is not a plain absolute path: $srepo"
|
|
1469
|
+
rotate_log sync.log
|
|
1470
|
+
slog() { log_to sync.log "$*"; }
|
|
1471
|
+
fail() { slog "FAIL: $*"; printf 'codex-accounts sync: FAILED: %s\n' "$*" >&2; exit 1; }
|
|
1472
|
+
slog "sync start -> $server:$sroot"
|
|
1473
|
+
|
|
1474
|
+
# Hard validation before anything destructive: a corrupt or accountless manifest must
|
|
1475
|
+
# never be pushed (it would blank the server's pool), and must never make the removal
|
|
1476
|
+
# loop below wipe the server's account dirs. Emptying the pool on purpose is possible
|
|
1477
|
+
# via `sync --allow-empty`, so this can never happen by accident.
|
|
1478
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null || fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
|
|
1479
|
+
import json, re, sys
|
|
1480
|
+
doc = json.load(open(sys.argv[1]))
|
|
1481
|
+
accounts = doc.get('accounts')
|
|
1482
|
+
if not isinstance(accounts, list):
|
|
1483
|
+
sys.exit(1)
|
|
1484
|
+
for a in accounts:
|
|
1485
|
+
if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
|
|
1486
|
+
or not str(a.get('email', '')).strip():
|
|
1487
|
+
sys.exit(1)
|
|
1488
|
+
PYEOF
|
|
1489
|
+
if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
|
|
1490
|
+
fail "manifest has zero accounts — refusing to blank the server pool (use 'sync --allow-empty' if that is really intended)"
|
|
1491
|
+
fi
|
|
1492
|
+
|
|
1493
|
+
ssh -o BatchMode=yes -o ConnectTimeout=10 "$server" "mkdir -p '$sroot'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1494
|
+
|| fail "cannot reach $server"
|
|
1495
|
+
rsync -az "$MANIFEST" "$server:$sroot/accounts.json" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1496
|
+
|| fail "manifest push failed"
|
|
1497
|
+
|
|
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
|
+
local id d
|
|
1503
|
+
for id in $(account_ids); do
|
|
1504
|
+
d="$ACC_ROOT/$id"
|
|
1505
|
+
[ -d "$d" ] || continue
|
|
1506
|
+
ssh -o BatchMode=yes "$server" "mkdir -p '$sroot/$id'" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1507
|
+
|| fail "mkdir $id failed"
|
|
1508
|
+
local seed
|
|
1509
|
+
for seed in config.toml; do
|
|
1510
|
+
if [ -f "$d/$seed" ]; then
|
|
1511
|
+
rsync -az --ignore-existing "$d/$seed" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1512
|
+
|| fail "seed push for $id/$seed failed"
|
|
1513
|
+
fi
|
|
1514
|
+
done
|
|
1515
|
+
# Advisory limit state for accounts the server may lack a bearer for.
|
|
1516
|
+
local extra
|
|
1517
|
+
for extra in limits.json .limited; do
|
|
1518
|
+
if [ -f "$d/$extra" ]; then
|
|
1519
|
+
rsync -az "$d/$extra" "$server:$sroot/$id/" >>"$ACC_ROOT/sync.log" 2>&1 || true
|
|
1520
|
+
fi
|
|
1521
|
+
done
|
|
1522
|
+
done
|
|
1523
|
+
|
|
1524
|
+
# Removal propagation: any server acct dir not in the manifest gets deleted.
|
|
1525
|
+
# Safety: an EMPTY id list (parse error, or a truly emptied pool) never deletes —
|
|
1526
|
+
# wiping every server credential must be an explicit manual act, not a side effect.
|
|
1527
|
+
local ids_list ids_spaced
|
|
1528
|
+
ids_list="$(account_ids)"
|
|
1529
|
+
if [ -z "$ids_list" ]; then
|
|
1530
|
+
slog "removal propagation skipped: empty account list (safety guard)"
|
|
1531
|
+
else
|
|
1532
|
+
ids_spaced=" $(printf '%s' "$ids_list" | tr '\n' ' ') "
|
|
1533
|
+
ssh -o BatchMode=yes "$server" "cd '$sroot' 2>/dev/null || exit 0
|
|
1534
|
+
for dd in acct-*; do
|
|
1535
|
+
[ -d \"\$dd\" ] || continue
|
|
1536
|
+
case '$ids_spaced' in
|
|
1537
|
+
*\" \$dd \"*) ;;
|
|
1538
|
+
*) rm -rf -- \"\$dd\" ;;
|
|
1539
|
+
esac
|
|
1540
|
+
done" >>"$ACC_ROOT/sync.log" 2>&1 || fail "removal propagation failed"
|
|
1541
|
+
fi
|
|
1542
|
+
|
|
1543
|
+
# Post-sync hook: server seeds dirs and re-runs its quick verification matrix.
|
|
1544
|
+
ssh -o BatchMode=yes "$server" "[ -x '$srepo/bin/codex-accounts' ] && '$srepo/bin/codex-accounts' post-sync" \
|
|
1545
|
+
>>"$ACC_ROOT/sync.log" 2>&1 || slog "post-sync hook not available yet (server not bootstrapped?)"
|
|
1546
|
+
|
|
1547
|
+
slog "sync ok"
|
|
1548
|
+
echo "sync ok -> $server:$sroot"
|
|
1549
|
+
return $rc
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
cmd_post_sync() {
|
|
1553
|
+
require_manifest
|
|
1554
|
+
local id d
|
|
1555
|
+
for id in $(account_ids); do
|
|
1556
|
+
d="$ACC_ROOT/$id"
|
|
1557
|
+
seed_account_dir "$d"
|
|
1558
|
+
[ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
|
|
1559
|
+
done
|
|
1560
|
+
log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
|
|
1561
|
+
( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
|
|
1562
|
+
cmd_verify --quick
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
cmd_self_update() {
|
|
1566
|
+
# Update the addon in place. npm global install => npm i -g @latest (its postinstall
|
|
1567
|
+
# re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
|
|
1568
|
+
# no-op with a hint. Best-effort and fully logged; never disrupts a running codex.
|
|
1569
|
+
local quiet=0
|
|
1570
|
+
[ "${1:-}" = "--quiet" ] && quiet=1
|
|
1571
|
+
rotate_log update.log
|
|
1572
|
+
ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
|
|
1573
|
+
case "$REPO_DIR" in
|
|
1574
|
+
*/node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
|
|
1575
|
+
command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
|
|
1576
|
+
local cur lat
|
|
1577
|
+
cur="$(npm ls -g --depth=0 claude-multiacc 2>/dev/null | sed -n 's/.*claude-multiacc@//p' | head -1)"
|
|
1578
|
+
lat="$(npm view claude-multiacc version 2>/dev/null)"
|
|
1579
|
+
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
1580
|
+
ulog "self-update: already latest ($cur)"
|
|
1581
|
+
return 0
|
|
1582
|
+
fi
|
|
1583
|
+
ulog "self-update: npm $cur -> ${lat:-latest}"
|
|
1584
|
+
if npm install -g claude-multiacc@latest >>"$ACC_ROOT/update.log" 2>&1; then
|
|
1585
|
+
ulog "self-update: npm update ok"
|
|
1586
|
+
else
|
|
1587
|
+
ulog "self-update: npm update FAILED (see update.log)"
|
|
1588
|
+
return 1
|
|
1589
|
+
fi
|
|
1590
|
+
;;
|
|
1591
|
+
*)
|
|
1592
|
+
if [ -d "$REPO_DIR/.git" ] && command -v git >/dev/null 2>&1; then
|
|
1593
|
+
ulog "self-update: git pull in $REPO_DIR"
|
|
1594
|
+
if git -C "$REPO_DIR" pull --ff-only >>"$ACC_ROOT/update.log" 2>&1; then
|
|
1595
|
+
"$REPO_DIR/install.sh" >>"$ACC_ROOT/update.log" 2>&1 \
|
|
1596
|
+
&& ulog "self-update: git update + reinstall ok" \
|
|
1597
|
+
|| { ulog "self-update: reinstall FAILED"; return 1; }
|
|
1598
|
+
else
|
|
1599
|
+
ulog "self-update: git pull FAILED (local changes? see update.log)"
|
|
1600
|
+
return 1
|
|
1601
|
+
fi
|
|
1602
|
+
else
|
|
1603
|
+
ulog "self-update: not an npm or git install ($REPO_DIR) — update manually"
|
|
1604
|
+
fi
|
|
1605
|
+
;;
|
|
1606
|
+
esac
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
cmd_health() {
|
|
1610
|
+
require_manifest
|
|
1611
|
+
rotate_log health.log
|
|
1612
|
+
local out rc=0
|
|
1613
|
+
# verify first: its real codex runs refresh any expired tokens, so the
|
|
1614
|
+
# limits pass that follows always has fresh bearers.
|
|
1615
|
+
out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
|
|
1616
|
+
printf '%s\n' "$out"
|
|
1617
|
+
printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
|
|
1618
|
+
if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
|
|
1619
|
+
osascript -e 'display notification "codex-multiacc health check FAILED — run codex-accounts status" with title "claude-multiacc"' 2>/dev/null || true
|
|
1620
|
+
fi
|
|
1621
|
+
return $rc
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
cmd_init_pool() {
|
|
1625
|
+
# Called by install.sh: create the codex pool skeleton + manifest (idempotent).
|
|
1626
|
+
# An explicit server override updates an existing manifest too (same behavior
|
|
1627
|
+
# install.sh applies to the claude manifest).
|
|
1628
|
+
manifest_init "${1:-}"
|
|
1629
|
+
if [ -n "${1:-}" ]; then
|
|
1630
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
|
|
1631
|
+
import json, os, sys
|
|
1632
|
+
doc = json.load(open(sys.argv[1]))
|
|
1633
|
+
doc['server'] = sys.argv[2]
|
|
1634
|
+
with open(sys.argv[1] + '.tmp', 'w') as f:
|
|
1635
|
+
json.dump(doc, f, indent=2)
|
|
1636
|
+
f.write('\n')
|
|
1637
|
+
os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
1638
|
+
PYEOF
|
|
1639
|
+
fi
|
|
1640
|
+
echo "codex account pool: $ACC_ROOT (manifest ready)"
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
case "${1:-help}" in
|
|
1644
|
+
list) shift; cmd_list "$@" ;;
|
|
1645
|
+
status) shift; cmd_status "$@" ;;
|
|
1646
|
+
add) shift; cmd_add "$@" ;;
|
|
1647
|
+
import) shift; cmd_import "$@" ;;
|
|
1648
|
+
adopt) shift; cmd_adopt "$@" ;;
|
|
1649
|
+
dedupe) shift; cmd_dedupe "$@" ;;
|
|
1650
|
+
remove) shift; cmd_remove "$@" ;;
|
|
1651
|
+
login) shift; cmd_login "$@" ;;
|
|
1652
|
+
expired) shift; cmd_expired "$@" ;;
|
|
1653
|
+
relogin|re-login) shift; cmd_relogin "$@" ;;
|
|
1654
|
+
sync) shift; cmd_sync "$@" ;;
|
|
1655
|
+
verify) shift; cmd_verify "$@" ;;
|
|
1656
|
+
limits) shift; cmd_limits "$@" ;;
|
|
1657
|
+
post-sync) shift; cmd_post_sync "$@" ;;
|
|
1658
|
+
health) shift; cmd_health "$@" ;;
|
|
1659
|
+
self-update) shift; cmd_self_update "$@" ;;
|
|
1660
|
+
init-pool) shift; cmd_init_pool "$@" ;;
|
|
1661
|
+
help|--help|-h) usage ;;
|
|
1662
|
+
*) usage; exit 1 ;;
|
|
1663
|
+
esac
|