claude-multiacc 1.0.7 → 1.0.9

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.
@@ -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