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
package/README.md
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# claude-multiacc
|
|
2
2
|
|
|
3
|
-
Multi-account addon for Claude Code
|
|
3
|
+
Multi-account addon for **Claude Code AND OpenAI Codex CLI**: every `claude` /
|
|
4
|
+
`claude -p` invocation — and every `codex` / `codex exec` invocation — runs under a
|
|
4
5
|
randomly picked **subscription** account with limit headroom (no API keys, ever). The
|
|
5
|
-
account
|
|
6
|
+
account sets mirror automatically from the Mac (source of truth) to the deploy server.
|
|
6
7
|
|
|
7
|
-
Installs as
|
|
8
|
-
no binary edits, no install-dir writes, survives `claude update`
|
|
9
|
-
resolved dynamically at exec time.
|
|
8
|
+
Installs as PATH shims + helper CLIs from this repo. **Never touches the Claude Code or
|
|
9
|
+
Codex apps**: no binary edits, no install-dir writes, survives `claude update` /
|
|
10
|
+
`codex update` — the real binaries are resolved dynamically at exec time.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
One install covers both: the same `install.sh` / `npm i -g claude-multiacc` wires up
|
|
13
|
+
the `claude` shim + `claude-accounts` CLI (pool at `~/.claude-accounts`) and the
|
|
14
|
+
`codex` shim + `codex-accounts` CLI (pool at `~/.codex-accounts`). The two pools are
|
|
15
|
+
completely independent — separate manifests, credentials, telemetry, and logs — so
|
|
16
|
+
either provider can be used, re-authenticated, or emptied without touching the other.
|
|
17
|
+
|
|
18
|
+
Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207+, Codex CLI 0.147) and Ubuntu 24.04
|
|
19
|
+
(bash 5.2). Test suite: `tests/run-tests.sh` (348 sandboxed tests, no network / no
|
|
20
|
+
quota — passes on both platforms).
|
|
14
21
|
|
|
15
22
|
---
|
|
16
23
|
|
|
@@ -130,6 +137,65 @@ an account, and if one slips through it returns to the pool by itself. Output is
|
|
|
130
137
|
never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
|
|
131
138
|
and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
|
|
132
139
|
|
|
140
|
+
## Codex support (OpenAI Codex CLI)
|
|
141
|
+
|
|
142
|
+
The exact same machinery, instantiated a second time for Codex. Everything documented
|
|
143
|
+
above about the claude pool holds for the codex pool with these translations:
|
|
144
|
+
|
|
145
|
+
| Claude side | Codex side |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `bin/claude` shim, `claude-accounts` CLI | `bin/codex` shim, `codex-accounts` CLI |
|
|
148
|
+
| pool `~/.claude-accounts`, server `/root/.claude-accounts` | pool `~/.codex-accounts`, server `/root/.codex-accounts` |
|
|
149
|
+
| `CLAUDE_CONFIG_DIR` per-account dirs | `CODEX_HOME` per-account dirs |
|
|
150
|
+
| `.credentials.json` (OAuth, machine-local) | `auth.json` (ChatGPT OAuth, machine-local) |
|
|
151
|
+
| `claude -p` auto-retry | `codex exec` auto-retry |
|
|
152
|
+
| Anthropic OAuth usage endpoint, per-model Fable bucket | `chatgpt.com/backend-api/codex/usage`, per-model buckets (e.g. `GPT-5.3-Codex-Spark:5h/7d`) |
|
|
153
|
+
| `CLAUDE_ACCOUNT` / `CLAUDE_SHIM_RETRY` / `CLAUDE_MULTIACC_DISABLE` / … | `CODEX_ACCOUNT` / `CODEX_SHIM_RETRY` / `CODEX_MULTIACC_DISABLE` / … |
|
|
154
|
+
|
|
155
|
+
Same selection rule (weekly headroom primary, session tiebreak, ≥90% any-bucket
|
|
156
|
+
exclusion), same marker semantics (`.limited` cooldowns, `.expired` parks with
|
|
157
|
+
credential/policy scoping and soft expiry), same fail-open guarantees, same sync
|
|
158
|
+
safety guards. The `codex` shim engages the buffered auto-retry only for
|
|
159
|
+
`codex exec` runs with finite stdin, exactly like `-p` on the claude side.
|
|
160
|
+
|
|
161
|
+
Codex-specific notes:
|
|
162
|
+
|
|
163
|
+
- **Auth is the Codex device-code sign-in by default**: `codex-accounts add` prints
|
|
164
|
+
a URL + one-time code you can open in ANY browser (this machine, your laptop, a
|
|
165
|
+
phone), so it works identically on a local Mac, over SSH, and on servers — there
|
|
166
|
+
is no portable setup-token equivalent for Codex, so a server account is signed in
|
|
167
|
+
ON the server the same way. `--browser` opts into the localhost browser-callback
|
|
168
|
+
flow instead (only works when the browser runs on the same machine — the callback
|
|
169
|
+
goes to `localhost:1455`). Either way the login runs with `CODEX_HOME` pointed at
|
|
170
|
+
the account dir, and identity is verified offline from the id-token before
|
|
171
|
+
anything is registered.
|
|
172
|
+
- **`auth.json` is never synced** in either direction, for the same reason
|
|
173
|
+
`.credentials.json` never is: the refresh token rotates, and two machines
|
|
174
|
+
refreshing one grant strand each other. Sync pushes manifest + `config.toml`
|
|
175
|
+
seeds + advisory limit state only.
|
|
176
|
+
- **Windows are classified by length**, not by name: any window ≤6h counts as the
|
|
177
|
+
self-healing session signal, anything longer as durable/weekly. A hard
|
|
178
|
+
`limit_reached`/`allowed:false` verdict marks the account even if no window shows
|
|
179
|
+
≥90%, and a reshaped payload is still scanned recursively for window-shaped
|
|
180
|
+
objects (fail open if nothing parses).
|
|
181
|
+
- **Idle-account telemetry** stays fresh the same way: a long-expired access token is
|
|
182
|
+
renewed via the OAuth refresh-token grant (`auth.openai.com/oauth/token`, the CLI's
|
|
183
|
+
own public client id) and the rotated credential is persisted 0600. Overrides for
|
|
184
|
+
tests: `CODEX_MULTIACC_TOKEN_URL`, `CODEX_MULTIACC_CLIENT_ID`,
|
|
185
|
+
`CODEX_MULTIACC_USAGE_URL`.
|
|
186
|
+
- **API-key logins are rejected** — ChatGPT subscription accounts only, matching the
|
|
187
|
+
addon's no-API-keys rule.
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
codex-accounts add # sign in a new ChatGPT account (device code —
|
|
191
|
+
# open the URL in any browser, enter the code)
|
|
192
|
+
codex-accounts add --browser # localhost browser-callback flow instead
|
|
193
|
+
codex-accounts import <email> --id acct-01 && codex-accounts adopt acct-01
|
|
194
|
+
# adopt this machine's existing ~/.codex login
|
|
195
|
+
codex-accounts list | status | expired | relogin | verify | limits | health | sync
|
|
196
|
+
claude-multiacc codex <cmd> # same commands via the npm wrapper
|
|
197
|
+
```
|
|
198
|
+
|
|
133
199
|
## Install / update / uninstall
|
|
134
200
|
|
|
135
201
|
### npm (recommended)
|
|
@@ -308,6 +374,10 @@ failures are loud and non-zero.
|
|
|
308
374
|
| `CLAUDE_SHIM_RETRY=0` | disable the `-p` auto-retry |
|
|
309
375
|
| `CLAUDE_ACCOUNTS_DIR=...` | relocate the pool (used by the test suite) |
|
|
310
376
|
|
|
377
|
+
The codex shim honors the same switches spelled `CODEX_*`: `CODEX_ACCOUNT`,
|
|
378
|
+
`CODEX_HOME` (passthrough), `CODEX_MULTIACC_DISABLE`, `CODEX_SHIM_RETRY`,
|
|
379
|
+
`CODEX_SHIM_SELECT`, `CODEX_ACCOUNTS_DIR`, `CODEX_MULTIACC_THRESHOLD`.
|
|
380
|
+
|
|
311
381
|
## Verification
|
|
312
382
|
|
|
313
383
|
```bash
|
package/bin/cli.mjs
CHANGED
|
@@ -42,24 +42,31 @@ function runAccounts(args) {
|
|
|
42
42
|
return sh('bash', [join(ROOT, 'bin', 'claude-accounts'), ...args]);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function runCodexAccounts(args) {
|
|
46
|
+
return sh('bash', [join(ROOT, 'bin', 'codex-accounts'), ...args]);
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
const HELP = `claude-multiacc ${pkg.version}
|
|
46
50
|
|
|
47
|
-
Every 'claude' / 'claude -p'
|
|
48
|
-
most usage headroom; the account
|
|
51
|
+
Every 'claude' / 'claude -p' AND every 'codex' / 'codex exec' runs under a
|
|
52
|
+
randomly-picked subscription account with the most usage headroom; the account sets
|
|
53
|
+
mirror to a deploy server. No API keys — Claude and ChatGPT subscription logins only.
|
|
49
54
|
|
|
50
55
|
USAGE
|
|
51
56
|
claude-multiacc [install] install or update the addon (idempotent)
|
|
52
57
|
claude-multiacc uninstall [--purge-data]
|
|
53
58
|
claude-multiacc self-update update to the latest npm release + re-install
|
|
54
|
-
claude-multiacc doctor show environment + account health
|
|
59
|
+
claude-multiacc doctor show environment + account health (both pools)
|
|
55
60
|
claude-multiacc <cmd> [args...] run a claude-accounts command, e.g.
|
|
56
61
|
list | status | add <email> | login <acct-NN> |
|
|
57
62
|
remove <acct-NN> | sync | verify | limits | health
|
|
63
|
+
claude-multiacc codex <cmd> [args...] same commands for the Codex pool
|
|
64
|
+
(runs codex-accounts <cmd>)
|
|
58
65
|
claude-multiacc -v | --version
|
|
59
66
|
claude-multiacc -h | --help
|
|
60
67
|
|
|
61
68
|
INSTALL
|
|
62
|
-
npm install -g claude-multiacc # postinstall wires up the PATH
|
|
69
|
+
npm install -g claude-multiacc # postinstall wires up the PATH shims + schedules
|
|
63
70
|
# or, no global install:
|
|
64
71
|
npx claude-multiacc # runs the installer once
|
|
65
72
|
|
|
@@ -91,8 +98,17 @@ switch (cmd) {
|
|
|
91
98
|
console.log('Updated. Open a new shell to pick up any PATH changes.');
|
|
92
99
|
break;
|
|
93
100
|
}
|
|
94
|
-
case 'doctor':
|
|
95
|
-
|
|
101
|
+
case 'doctor': {
|
|
102
|
+
console.log('== claude pool ==');
|
|
103
|
+
const claudeStatus = runAccounts(['status']);
|
|
104
|
+
console.log('\n== codex pool ==');
|
|
105
|
+
const codexStatus = runCodexAccounts(['status']);
|
|
106
|
+
status = claudeStatus || codexStatus;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'codex':
|
|
110
|
+
// Codex-pool passthrough: claude-multiacc codex list | status | add | ...
|
|
111
|
+
status = argv.length > 1 ? runCodexAccounts(argv.slice(1)) : runCodexAccounts(['help']);
|
|
96
112
|
break;
|
|
97
113
|
case '-v':
|
|
98
114
|
case '--version':
|
package/bin/codex
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# codex-multiacc-shim — PATH-shadows the real `codex` binary; never replaces or edits it.
|
|
3
|
+
# Every invocation runs under a randomly picked ChatGPT subscription account with limit
|
|
4
|
+
# headroom. Self-contained on purpose: no sourcing, so a broken repo file can never
|
|
5
|
+
# break `codex`.
|
|
6
|
+
# Selection: CODEX_HOME passthrough > CODEX_ACCOUNT pin > most-headroom among
|
|
7
|
+
# limit-eligible accounts > least-utilized fallback (degraded beats down).
|
|
8
|
+
# Accounts whose login is DEAD (a `.expired` marker from a failed refresh/verify/run)
|
|
9
|
+
# are never selected — not even as the all-limited fallback — because they fail every
|
|
10
|
+
# call outright; `codex-accounts expired` / `relogin` fix them.
|
|
11
|
+
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
# ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
|
|
15
|
+
# must still fail OPEN into plain passthrough, never abort on an unbound variable.
|
|
16
|
+
ACC_ROOT="${CODEX_ACCOUNTS_DIR:-${HOME:-/nonexistent}/.codex-accounts}"
|
|
17
|
+
MANIFEST="$ACC_ROOT/accounts.json"
|
|
18
|
+
|
|
19
|
+
canon_path() {
|
|
20
|
+
local p="$1" t i=0 d b
|
|
21
|
+
case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
|
|
22
|
+
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
|
|
23
|
+
t="$(readlink "$p")" || break
|
|
24
|
+
case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
|
|
25
|
+
i=$((i+1))
|
|
26
|
+
done
|
|
27
|
+
d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
|
|
28
|
+
b="$(basename "$p")"
|
|
29
|
+
if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# 'multiacc-shim' matches this file AND bin/claude — a shim must never exec a shim.
|
|
33
|
+
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }
|
|
34
|
+
|
|
35
|
+
SELF="$(canon_path "$0")"
|
|
36
|
+
SELF_DIR="$(dirname "$SELF")"
|
|
37
|
+
|
|
38
|
+
find_real() {
|
|
39
|
+
local cand c d
|
|
40
|
+
local oldifs="$IFS"
|
|
41
|
+
IFS=':'; set -f
|
|
42
|
+
# shellcheck disable=SC2086
|
|
43
|
+
set -- $PATH
|
|
44
|
+
IFS="$oldifs"; set +f
|
|
45
|
+
for d in "$@"; do
|
|
46
|
+
[ -n "$d" ] || continue
|
|
47
|
+
cand="$d/codex"
|
|
48
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
49
|
+
c="$(canon_path "$cand")"
|
|
50
|
+
[ "$c" = "$SELF" ] && continue
|
|
51
|
+
case "$c" in "$ACC_ROOT"/*) continue ;; esac
|
|
52
|
+
is_shim_file "$c" && continue
|
|
53
|
+
printf '%s\n' "$cand"; return 0
|
|
54
|
+
done
|
|
55
|
+
# Fallbacks: resolved dynamically at exec time, so `codex update`/reinstalls keep working.
|
|
56
|
+
for cand in "${HOME:-/nonexistent}/.local/bin/codex" /usr/local/bin/codex /opt/homebrew/bin/codex /usr/bin/codex; do
|
|
57
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
58
|
+
c="$(canon_path "$cand")"
|
|
59
|
+
[ "$c" = "$SELF" ] && continue
|
|
60
|
+
is_shim_file "$c" && continue
|
|
61
|
+
printf '%s\n' "$cand"; return 0
|
|
62
|
+
done
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
REAL="$(find_real)" || {
|
|
67
|
+
printf 'codex-multiacc shim: real codex binary not found (PATH or fallback locations)\n' >&2
|
|
68
|
+
exit 127
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Fast passthrough: caller pinned a config dir, addon disabled, recursion guard, or
|
|
72
|
+
# no account data yet. Byte-identical behavior to stock codex.
|
|
73
|
+
if [ -n "${CODEX_HOME:-}" ] \
|
|
74
|
+
|| [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CODEX_SHIM_ACTIVE:-}" ] \
|
|
75
|
+
|| [ ! -f "$MANIFEST" ]; then
|
|
76
|
+
exec "$REAL" "$@"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
now="$(date +%s)"
|
|
80
|
+
|
|
81
|
+
# Threshold used by the telemetry backstop below; the manifest is the source of
|
|
82
|
+
# truth, but a corrupt/unreadable manifest must never break selection => plain
|
|
83
|
+
# sed with a safe default, never a JSON parse.
|
|
84
|
+
if [ -z "${CODEX_MULTIACC_THRESHOLD:-}" ]; then
|
|
85
|
+
CODEX_MULTIACC_THRESHOLD="$(sed -n 's/.*"threshold"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$MANIFEST" 2>/dev/null | head -1)"
|
|
86
|
+
case "$CODEX_MULTIACC_THRESHOLD" in ''|*[!0-9]*) CODEX_MULTIACC_THRESHOLD=90 ;; esac
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
if [ "$(uname -s)" = "Darwin" ]; then
|
|
90
|
+
file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
91
|
+
else
|
|
92
|
+
file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
sel_log() {
|
|
96
|
+
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$ACC_ROOT/selection.log" 2>/dev/null || true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
marker_active() { # true if $1/.limited is still in force; clears cleanly-expired markers
|
|
100
|
+
local m="$1/.limited" reset=""
|
|
101
|
+
[ -f "$m" ] || return 1
|
|
102
|
+
IFS= read -r reset < "$m" 2>/dev/null || reset=""
|
|
103
|
+
case "$reset" in
|
|
104
|
+
''|*[!0-9]*)
|
|
105
|
+
# Empty/partial/garbled marker — e.g. read during a concurrent rewrite.
|
|
106
|
+
# Treat as ACTIVE and never delete: deleting here could destroy a marker
|
|
107
|
+
# another process is mid-write. The next limits refresh rewrites or clears it.
|
|
108
|
+
return 0 ;;
|
|
109
|
+
esac
|
|
110
|
+
if [ "$now" -ge "$reset" ]; then
|
|
111
|
+
rm -f "$m" 2>/dev/null
|
|
112
|
+
return 1
|
|
113
|
+
fi
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
STALE_AFTER=900
|
|
118
|
+
|
|
119
|
+
fresh_field() { # fresh_field <acct dir> <json key> -> integer if telemetry fresh, else fail
|
|
120
|
+
local f="$1/limits.json" fetched v
|
|
121
|
+
[ -f "$f" ] || return 1
|
|
122
|
+
fetched="$(sed -n 's/.*"fetched_at"[^0-9]*\([0-9][0-9]*\).*/\1/p' "$f" 2>/dev/null | head -1)"
|
|
123
|
+
case "$fetched" in ''|*[!0-9]*) return 1 ;; esac
|
|
124
|
+
[ $((now - fetched)) -le "$STALE_AFTER" ] || return 1
|
|
125
|
+
v="$(sed -n "s/.*\"$2\"[^0-9]*\([0-9][0-9]*\).*/\1/p" "$f" 2>/dev/null | head -1)"
|
|
126
|
+
case "$v" in ''|*[!0-9]*) return 1 ;; esac
|
|
127
|
+
printf '%s\n' "$v"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# RANKING score — lower is better (more headroom). Weekly headroom dominates: a weekly
|
|
131
|
+
# window only refills on its multi-day reset, while the ~5h window self-heals, so
|
|
132
|
+
# session is a mild tiebreaker only (same reset asymmetry as the claude pool).
|
|
133
|
+
# score = weekly%*1000 + session% weekly,session in [0,100]
|
|
134
|
+
# Stale/unreadable telemetry ranks NEUTRAL (weekly 50, session 50), never "free".
|
|
135
|
+
sel_score_of() { # $1 = acct dir
|
|
136
|
+
local w s
|
|
137
|
+
w="$(fresh_field "$1" weekly_percent)" || w="$(fresh_field "$1" max_percent)" || w=50
|
|
138
|
+
s="$(fresh_field "$1" session_percent)" || s=50
|
|
139
|
+
printf '%s\n' $((w * 1000 + s))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Backstop for a lost/failed marker write: fresh telemetry with ANY bucket at/over the
|
|
143
|
+
# threshold excludes the account even if .limited is missing. Stale/unreadable => not
|
|
144
|
+
# over (fail open — telemetry must never invent exclusions).
|
|
145
|
+
over_threshold() { # $1 = acct dir
|
|
146
|
+
local v
|
|
147
|
+
v="$(fresh_field "$1" max_percent)" || return 1
|
|
148
|
+
[ "$v" -ge "${CODEX_MULTIACC_THRESHOLD:-90}" ]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Auth is a ChatGPT login: auth.json carrying a non-empty access token. An API-key-only
|
|
152
|
+
# auth.json is NOT auth here (subscription-only by design), and an empty file is NOT
|
|
153
|
+
# auth (an interrupted write must not make a dead account selectable).
|
|
154
|
+
has_auth() {
|
|
155
|
+
[ -s "$1/auth.json" ] \
|
|
156
|
+
&& LC_ALL=C grep -q '"access_token"[[:space:]]*:[[:space:]]*"[^"]' "$1/auth.json" 2>/dev/null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# `.expired` is the persistent "this account needs a re-login" marker: written by
|
|
160
|
+
# codex-accounts (refresh grant expired/revoked, failed verify) and by the retry path
|
|
161
|
+
# below when a real call fails with an auth error. It SELF-HEALS: any auth.json written
|
|
162
|
+
# after the marker (successful re-login, or a refresh by another process) clears it.
|
|
163
|
+
# A marker written by the SHIM (a guess from one failed run) also carries
|
|
164
|
+
# `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
|
|
165
|
+
# a misread never costs an account permanently. Markers written by codex-accounts —
|
|
166
|
+
# a refresh grant that answered invalid_grant, a failed real call — carry no soft_until
|
|
167
|
+
# and stay until the account provably works again.
|
|
168
|
+
expired_marked() { # $1 = acct dir
|
|
169
|
+
local m="$1/.expired" mt soft reason
|
|
170
|
+
[ -f "$m" ] || return 1
|
|
171
|
+
reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
172
|
+
# CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
|
|
173
|
+
# evidence they were about. A POLICY park (org-blocked: a workspace admin turned
|
|
174
|
+
# Codex off for the account) is about the account, not the credential: refreshing
|
|
175
|
+
# its token does not re-enable Codex, so only a passing real call or a re-login
|
|
176
|
+
# lifts it.
|
|
177
|
+
if [ "$reason" != "org-blocked" ]; then
|
|
178
|
+
mt="$(file_mtime "$m")"
|
|
179
|
+
if [ -f "$1/auth.json" ] && [ "$(file_mtime "$1/auth.json")" -gt "$mt" ]; then
|
|
180
|
+
rm -f "$m" 2>/dev/null
|
|
181
|
+
return 1
|
|
182
|
+
fi
|
|
183
|
+
fi
|
|
184
|
+
soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
185
|
+
case "$soft" in
|
|
186
|
+
''|*[!0-9]*) return 0 ;; # no soft stamp => proven dead, keep
|
|
187
|
+
*) [ "$now" -lt "$soft" ] && return 0
|
|
188
|
+
rm -f "$m" 2>/dev/null # soft window elapsed: give it another go
|
|
189
|
+
return 1 ;;
|
|
190
|
+
esac
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Codex access-token expiry lives inside a JWT (not scrapeable with sed), so unlike
|
|
194
|
+
# the claude shim there is no plaintext creds_dead check here: the codex CLI refreshes
|
|
195
|
+
# a stale token itself at startup, and a DEAD refresh grant is detected by the limits
|
|
196
|
+
# refresher / verify / the retry path below — all of which write `.expired`.
|
|
197
|
+
auth_dead() { expired_marked "$1"; }
|
|
198
|
+
|
|
199
|
+
# Explicit pin wins over everything — markers, and even missing auth: the
|
|
200
|
+
# add/login ceremony pins to a dir that has no credentials yet, and the login
|
|
201
|
+
# must land exactly there, never in a randomly selected account's dir.
|
|
202
|
+
if [ -n "${CODEX_ACCOUNT:-}" ]; then
|
|
203
|
+
d="$ACC_ROOT/$CODEX_ACCOUNT"
|
|
204
|
+
if [ -d "$d" ]; then
|
|
205
|
+
sel_log "$CODEX_ACCOUNT pinned pwd=$PWD"
|
|
206
|
+
export CODEX_HOME="$d"
|
|
207
|
+
export CODEX_SHIM_ACTIVE=1
|
|
208
|
+
exec "$REAL" "$@"
|
|
209
|
+
fi
|
|
210
|
+
sel_log "pin-invalid account=$CODEX_ACCOUNT (no such dir; random fallback)"
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
valid=()
|
|
214
|
+
eligible=()
|
|
215
|
+
expired=()
|
|
216
|
+
for d in "$ACC_ROOT"/acct-*; do
|
|
217
|
+
[ -d "$d" ] || continue
|
|
218
|
+
has_auth "$d" || continue
|
|
219
|
+
# Expired logins are excluded BEFORE anything else: unlike a limit marker (degraded
|
|
220
|
+
# but working), dead auth guarantees a hard failure, so it can never be the
|
|
221
|
+
# "degraded beats down" fallback either.
|
|
222
|
+
if auth_dead "$d"; then
|
|
223
|
+
expired+=("$d")
|
|
224
|
+
continue
|
|
225
|
+
fi
|
|
226
|
+
valid+=("$d")
|
|
227
|
+
marker_active "$d" && continue
|
|
228
|
+
over_threshold "$d" && continue
|
|
229
|
+
eligible+=("$d")
|
|
230
|
+
done
|
|
231
|
+
|
|
232
|
+
if [ "${#expired[@]}" -gt 0 ]; then
|
|
233
|
+
ids=""
|
|
234
|
+
for d in "${expired[@]}"; do ids="$ids $(basename "$d")"; done
|
|
235
|
+
sel_log "skipped-expired:$ids (unusable — see: codex-accounts expired)"
|
|
236
|
+
# One actionable line, at most hourly, and only on a terminal — a service-spawned
|
|
237
|
+
# `codex exec` must keep its stderr byte-clean.
|
|
238
|
+
if [ -t 2 ]; then
|
|
239
|
+
n="$ACC_ROOT/.expired-notice"
|
|
240
|
+
last=0
|
|
241
|
+
[ -f "$n" ] && last="$(file_mtime "$n")"
|
|
242
|
+
if [ $((now - last)) -gt 3600 ]; then
|
|
243
|
+
: > "$n" 2>/dev/null || true
|
|
244
|
+
printf 'codex-multiacc: %s account(s) unusable (%s) — see: codex-accounts expired\n' \
|
|
245
|
+
"${#expired[@]}" "${ids# }" >&2
|
|
246
|
+
fi
|
|
247
|
+
fi
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
# No usable accounts => stock behavior (fail open, never block work), but say WHY when
|
|
251
|
+
# the pool is merely un-authenticated: this is the one case the user can actually fix.
|
|
252
|
+
if [ "${#valid[@]}" -eq 0 ]; then
|
|
253
|
+
if [ "${#expired[@]}" -gt 0 ]; then
|
|
254
|
+
sel_log "all-expired: falling back to the default login (see: codex-accounts expired)"
|
|
255
|
+
# Terminal only: a service-spawned `codex exec` must keep its stderr byte-clean,
|
|
256
|
+
# and the fallback may well succeed on the machine's own login.
|
|
257
|
+
[ -t 2 ] && printf 'codex-multiacc: no pool account is usable (%s) — see: codex-accounts expired, then: codex-accounts relogin\n' \
|
|
258
|
+
"${ids# }" >&2
|
|
259
|
+
fi
|
|
260
|
+
exec "$REAL" "$@"
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
# Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
|
|
264
|
+
# session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
|
|
265
|
+
# Sets PICK_DIR/PICK_SCORE as globals — it must never touch "$@", which holds the
|
|
266
|
+
# user's codex arguments.
|
|
267
|
+
PICK_DIR=""
|
|
268
|
+
PICK_SCORE=""
|
|
269
|
+
pick_best() { # args: candidate dirs
|
|
270
|
+
local d v best="" bestv=1000000 ties=1
|
|
271
|
+
for d in "$@"; do
|
|
272
|
+
v="$(sel_score_of "$d")"
|
|
273
|
+
if [ "$v" -lt "$bestv" ]; then
|
|
274
|
+
bestv="$v"; best="$d"; ties=1
|
|
275
|
+
elif [ "$v" -eq "$bestv" ]; then
|
|
276
|
+
ties=$((ties + 1))
|
|
277
|
+
[ $((RANDOM % ties)) -eq 0 ] && best="$d" # reservoir-sample among equals
|
|
278
|
+
fi
|
|
279
|
+
done
|
|
280
|
+
PICK_DIR="$best"
|
|
281
|
+
PICK_SCORE="$bestv"
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if [ "${#eligible[@]}" -gt 0 ]; then
|
|
285
|
+
if [ "${CODEX_SHIM_SELECT:-headroom}" = "random" ]; then
|
|
286
|
+
PICK_DIR="${eligible[$((RANDOM % ${#eligible[@]}))]}"
|
|
287
|
+
else
|
|
288
|
+
pick_best "${eligible[@]}"
|
|
289
|
+
fi
|
|
290
|
+
else
|
|
291
|
+
# Every account is limit-marked: degraded service beats a hard failure (100% rule).
|
|
292
|
+
pick_best "${valid[@]}"
|
|
293
|
+
sel_log "all-limited fallback=$(basename "$PICK_DIR") weekly=$(fresh_field "$PICK_DIR" weekly_percent || echo '?')%"
|
|
294
|
+
fi
|
|
295
|
+
pick="$PICK_DIR"
|
|
296
|
+
|
|
297
|
+
# Opportunistic limits refresh: non-blocking, throttled, backgrounded.
|
|
298
|
+
kick="$ACC_ROOT/.limits-kick"
|
|
299
|
+
stale=0
|
|
300
|
+
for d in "${valid[@]}"; do
|
|
301
|
+
f="$d/limits.json"
|
|
302
|
+
if [ ! -f "$f" ] || [ $((now - $(file_mtime "$f"))) -gt 180 ]; then stale=1; break; fi
|
|
303
|
+
done
|
|
304
|
+
if [ "$stale" = 1 ] && [ -x "$SELF_DIR/codex-accounts" ]; then
|
|
305
|
+
last=0
|
|
306
|
+
[ -f "$kick" ] && last="$(file_mtime "$kick")"
|
|
307
|
+
if [ $((now - last)) -gt 120 ]; then
|
|
308
|
+
: > "$kick" 2>/dev/null || true
|
|
309
|
+
( "$SELF_DIR/codex-accounts" limits --quiet >/dev/null 2>&1 & ) >/dev/null 2>&1
|
|
310
|
+
fi
|
|
311
|
+
fi
|
|
312
|
+
|
|
313
|
+
acct="$(basename "$pick")"
|
|
314
|
+
sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
|
|
315
|
+
|
|
316
|
+
export CODEX_SHIM_ACTIVE=1
|
|
317
|
+
|
|
318
|
+
# Auto-retry applies only to `codex exec` runs with an alternative account available,
|
|
319
|
+
# and only when stdin is finite (tty, regular file, or char device like /dev/null).
|
|
320
|
+
# A service-spawned pipe that never EOFs must take the plain exec path, or the
|
|
321
|
+
# stdin pre-buffering below would hang the call.
|
|
322
|
+
wants_retry=0
|
|
323
|
+
if [ "${CODEX_SHIM_RETRY:-1}" != "0" ] && [ "${#eligible[@]}" -ge 2 ]; then
|
|
324
|
+
for a in "$@"; do
|
|
325
|
+
case "$a" in exec|e) wants_retry=1; break ;; esac
|
|
326
|
+
done
|
|
327
|
+
if [ "$wants_retry" = "1" ]; then
|
|
328
|
+
# A TTY cannot be buffered or replayed: `codex exec` with no prompt argument reads
|
|
329
|
+
# stdin, and the retry path would hand it /dev/null. Plain exec instead —
|
|
330
|
+
# stdin is inherited untouched. A pipe that never EOFs would hang the pre-buffer,
|
|
331
|
+
# so only finite stdin (regular file, /dev/null-style char device) takes the retry
|
|
332
|
+
# path; everything else execs directly.
|
|
333
|
+
if [ -t 0 ]; then
|
|
334
|
+
wants_retry=0
|
|
335
|
+
elif [ -f /dev/fd/0 ] || [ -c /dev/fd/0 ]; then
|
|
336
|
+
:
|
|
337
|
+
else
|
|
338
|
+
wants_retry=0
|
|
339
|
+
fi
|
|
340
|
+
fi
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
if [ "$wants_retry" = "0" ]; then
|
|
344
|
+
export CODEX_HOME="$pick"
|
|
345
|
+
exec "$REAL" "$@"
|
|
346
|
+
fi
|
|
347
|
+
|
|
348
|
+
# Retry path: buffer stdio so a retried call never double-emits partial output.
|
|
349
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
350
|
+
tmpd="$(mktemp -d "$ACC_ROOT/tmp/shim.XXXXXX" 2>/dev/null)" || {
|
|
351
|
+
export CODEX_HOME="$pick"
|
|
352
|
+
exec "$REAL" "$@"
|
|
353
|
+
}
|
|
354
|
+
trap 'rm -rf "$tmpd"' EXIT
|
|
355
|
+
|
|
356
|
+
# The output buffers must be writable BEFORE the run: if redirection failed at exec
|
|
357
|
+
# time (disk full), the real binary would never launch and the shim would exit
|
|
358
|
+
# nonzero — a hard failure. Verify now, fall back to plain exec if we cannot.
|
|
359
|
+
if ! : > "$tmpd/out" 2>/dev/null || ! : > "$tmpd/err" 2>/dev/null; then
|
|
360
|
+
export CODEX_HOME="$pick"
|
|
361
|
+
exec "$REAL" "$@"
|
|
362
|
+
fi
|
|
363
|
+
|
|
364
|
+
stdin_file=""
|
|
365
|
+
if [ ! -t 0 ]; then
|
|
366
|
+
stdin_file="$tmpd/in"
|
|
367
|
+
# If buffering fails midway (disk full) stdin is already partly consumed and cannot
|
|
368
|
+
# be rewound — keep whatever landed rather than silently substituting /dev/null.
|
|
369
|
+
cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
# ERRPAT decides whether to RETRY at all (deliberately broad). The two PARK patterns
|
|
373
|
+
# below decide whether the failed account is also taken out of the pool, and they are
|
|
374
|
+
# deliberately NARROW: this grep also sees the model's own answer on stdout (an exec
|
|
375
|
+
# run that merely *discusses* a 401 must not cost an account).
|
|
376
|
+
# PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
|
|
377
|
+
# is parked until a re-login / a refresh that works clears it.
|
|
378
|
+
# PARK_ORG — a workspace admin turned Codex access off for the account; no
|
|
379
|
+
# re-login fixes that.
|
|
380
|
+
# Both shim-written parks carry a soft_until stamp, so even a false positive returns to
|
|
381
|
+
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
382
|
+
PARK_AUTH='not (logged|signed) in|authentication (required|failed)|please run `?codex login|run `?codex login`? to|401 unauthorized|token .{0,12}(expired|revoked)|refresh token.{0,20}(expired|invalid|revoked)|invalid_grant|could not refresh'
|
|
383
|
+
PARK_ORG='disabled by (your )?(workspace )?admin|admin (has )?disabled|(workspace|organization) has disabled (codex|chatgpt)|codex.{0,20}disabled for (your|this) (workspace|organization)'
|
|
384
|
+
LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|too many requests|"?429"?|quota exceeded|hit your usage limit'
|
|
385
|
+
ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
|
|
386
|
+
PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
|
|
387
|
+
PARK_SOFT_ORG=21600 # 6h: a workspace policy will not change in minutes
|
|
388
|
+
|
|
389
|
+
attempt=1
|
|
390
|
+
cur="$pick"
|
|
391
|
+
rc=0
|
|
392
|
+
while :; do
|
|
393
|
+
if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
|
|
394
|
+
CODEX_HOME="$cur" "$REAL" "$@" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
395
|
+
rc=$?
|
|
396
|
+
exec 3<&-
|
|
397
|
+
if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
|
|
398
|
+
&& grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
399
|
+
# Atomic marker writes: a reader must never observe a half-written marker
|
|
400
|
+
# (it would parse as garbage and, before, could be deleted as "expired").
|
|
401
|
+
# A rate limit wins the classification: a limit message that happens to mention an
|
|
402
|
+
# auth word must get the self-expiring cooldown, never a park.
|
|
403
|
+
park_reason=""
|
|
404
|
+
park_soft=0
|
|
405
|
+
if grep -qiE "$LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
406
|
+
:
|
|
407
|
+
elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
408
|
+
park_reason="org-blocked"
|
|
409
|
+
park_detail="a workspace admin has disabled Codex access for the account"
|
|
410
|
+
park_soft=$((now + PARK_SOFT_ORG))
|
|
411
|
+
elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
412
|
+
park_reason="auth-error"
|
|
413
|
+
park_detail="run failed to authenticate"
|
|
414
|
+
park_soft=$((now + PARK_SOFT_AUTH))
|
|
415
|
+
fi
|
|
416
|
+
if [ -n "$park_reason" ]; then
|
|
417
|
+
{
|
|
418
|
+
echo "$now"
|
|
419
|
+
echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
420
|
+
} > "$cur/.expired.$$" 2>/dev/null \
|
|
421
|
+
&& mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
|
|
422
|
+
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
423
|
+
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: codex-accounts expired"
|
|
424
|
+
else
|
|
425
|
+
{
|
|
426
|
+
echo $((now + 600))
|
|
427
|
+
echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
|
|
428
|
+
} > "$cur/.limited.$$" 2>/dev/null \
|
|
429
|
+
&& mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
|
|
430
|
+
|| rm -f "$cur/.limited.$$" 2>/dev/null || true
|
|
431
|
+
fi
|
|
432
|
+
next=""
|
|
433
|
+
n="${#eligible[@]}"
|
|
434
|
+
start=$((RANDOM % n))
|
|
435
|
+
i=0
|
|
436
|
+
while [ "$i" -lt "$n" ]; do
|
|
437
|
+
c="${eligible[$(((start + i) % n))]}"
|
|
438
|
+
if [ "$c" != "$cur" ]; then next="$c"; break; fi
|
|
439
|
+
i=$((i+1))
|
|
440
|
+
done
|
|
441
|
+
if [ -n "$next" ]; then
|
|
442
|
+
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
443
|
+
cur="$next"
|
|
444
|
+
attempt=2
|
|
445
|
+
continue
|
|
446
|
+
fi
|
|
447
|
+
fi
|
|
448
|
+
break
|
|
449
|
+
done
|
|
450
|
+
|
|
451
|
+
cat "$tmpd/out"
|
|
452
|
+
cat "$tmpd/err" >&2
|
|
453
|
+
exit "$rc"
|