claude-multiacc 1.0.5 → 1.0.7
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 +89 -11
- package/bin/claude +165 -12
- package/bin/claude-accounts +345 -18
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +411 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ no binary edits, no install-dir writes, survives `claude update` — the real bi
|
|
|
9
9
|
resolved dynamically at exec time.
|
|
10
10
|
|
|
11
11
|
Tested on: macOS (bash 3.2, zsh, Claude Code 2.1.207) and Ubuntu 24.04
|
|
12
|
-
(bash 5.2, Claude Code 2.1.201). Test suite: `tests/run-tests.sh` (
|
|
12
|
+
(bash 5.2, Claude Code 2.1.201). Test suite: `tests/run-tests.sh` (221 sandboxed tests,
|
|
13
13
|
no network / no quota — passes on both platforms).
|
|
14
14
|
|
|
15
15
|
---
|
|
@@ -31,12 +31,18 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
|
|
|
31
31
|
|
|
32
32
|
1. `CLAUDE_CONFIG_DIR` or `CLAUDE_CODE_OAUTH_TOKEN` already set → pass straight through.
|
|
33
33
|
2. `CLAUDE_ACCOUNT=acct-NN` → use that account (even if limit-marked — explicit wins).
|
|
34
|
-
3.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
4.
|
|
39
|
-
|
|
34
|
+
3. Drop every account that cannot serve a request — a **dead login** (expired refresh
|
|
35
|
+
token, revoked grant, a run that failed to authenticate) or an **org-blocked** one
|
|
36
|
+
(Claude Code disabled for that account). Those can only fail, so they never enter
|
|
37
|
+
selection.
|
|
38
|
+
4. Among the accounts that remain valid **on this machine** (`.credentials.json` or
|
|
39
|
+
`server.token` present) and not limit-excluded, pick the one with the **most remaining
|
|
40
|
+
headroom** (see below). Ties break randomly to spread load. `CLAUDE_SHIM_SELECT=random`
|
|
41
|
+
restores plain uniform-random selection.
|
|
42
|
+
5. If every account is limit-excluded → the highest-headroom *authenticable* account
|
|
43
|
+
anyway + a warning in `selection.log` (degraded beats down: the 100% rule).
|
|
44
|
+
6. If nothing is usable at all → stock passthrough, with the reason in `selection.log`
|
|
45
|
+
(and, on a terminal, one line naming the accounts to fix).
|
|
40
46
|
|
|
41
47
|
**Which account has "the most headroom"?** A Max subscription has three limits that a
|
|
42
48
|
Fable request consumes at once: the 5-hour **session** bucket, the **weekly all-models**
|
|
@@ -51,6 +57,35 @@ lowest wins.) **Exclusion** still fires on *any* bucket ≥ 90% — a full sessi
|
|
|
51
57
|
really does block right now — but that marker expires when the session resets, not days
|
|
52
58
|
later.
|
|
53
59
|
|
|
60
|
+
**Unusable accounts are excluded, not rotated into.** An account that cannot serve a
|
|
61
|
+
request is dropped from selection before anything else is considered — it is not eligible
|
|
62
|
+
as the all-limited fallback either, because a limit-marked account is *degraded* while
|
|
63
|
+
this one is *down*, and handing work to it guarantees a hard failure. Two kinds:
|
|
64
|
+
|
|
65
|
+
- **Dead login** (`EXPIRED`) — refresh token expired, grant revoked, or a real call that
|
|
66
|
+
came back *"OAuth session expired and could not be refreshed"*. Fixed by
|
|
67
|
+
`claude-accounts relogin`.
|
|
68
|
+
- **Org-blocked** (`BLOCKED`) — the account authenticates fine, but the run comes back
|
|
69
|
+
*"Your organization has disabled Claude subscription access for Claude Code"*. Handled
|
|
70
|
+
like any other dead login (`claude-accounts relogin` re-issues the grant and normally
|
|
71
|
+
clears it); if it comes back BLOCKED after a re-login, an admin has to enable Claude
|
|
72
|
+
Code for that account. Telemetry cannot see this — those accounts' usage endpoint
|
|
73
|
+
answers normally — so only a real call (`verify`, or a `-p` run) detects it, and the
|
|
74
|
+
park deliberately survives credential refreshes (a new access token says nothing about
|
|
75
|
+
an org policy).
|
|
76
|
+
|
|
77
|
+
`claude-accounts expired` lists exactly what is excluded, why, and the fix for each.
|
|
78
|
+
Exclusion self-heals, but only against the evidence it was based on: a **credential**
|
|
79
|
+
park (dead login) clears as soon as a newer credential lands — a re-login, or a refresh
|
|
80
|
+
by another process — or when a usage fetch authenticates; a **policy** park (org block)
|
|
81
|
+
ignores both, because a fresh access token says nothing about whether the org re-enabled
|
|
82
|
+
Claude Code, and only a passing real call or a re-login lifts it. Parks the **shim**
|
|
83
|
+
writes from a single failed run additionally carry a `soft_until` stamp and expire on
|
|
84
|
+
their own — the shim's guess never outlives the evidence for it — while parks
|
|
85
|
+
`claude-accounts` writes from proof (an `invalid_grant` refresh response, a failed real
|
|
86
|
+
call) stay until the account works again. If *every* account is unusable the shim still
|
|
87
|
+
fails open into stock `claude`, and logs why to `selection.log`.
|
|
88
|
+
|
|
54
89
|
The shim prints nothing, logs `timestamp account cwd` (never prompt text) to
|
|
55
90
|
`selection.log`, and `exec`s the real binary — stdin/stdout/exit codes pass through
|
|
56
91
|
byte-identically. If anything is missing (no manifest, no accounts, unreadable state,
|
|
@@ -86,8 +121,12 @@ then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
|
|
|
86
121
|
`CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
|
|
87
122
|
|
|
88
123
|
**Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
|
|
89
|
-
auth
|
|
90
|
-
|
|
124
|
+
auth- or rate-limit-looking failure the shim retries once on a different account and
|
|
125
|
+
marks the failed one — a **10-minute cooldown** for a rate limit (it heals on its own),
|
|
126
|
+
or an **`.expired`** park for an authentication failure / org block (they do not). The
|
|
127
|
+
park patterns are deliberately narrow and carry a `soft_until` stamp, because this grep
|
|
128
|
+
also sees the model's own answer: a `-p` run that merely *mentions* a 403 must never cost
|
|
129
|
+
an account, and if one slips through it returns to the pool by itself. Output is buffered so a retried call
|
|
91
130
|
never double-emits. Only engages when stdin is finite (tty / regular file / `/dev/null`)
|
|
92
131
|
and ≥2 accounts are eligible; service-spawned pipes take the plain exec path untouched.
|
|
93
132
|
|
|
@@ -151,10 +190,42 @@ What install does (all reversible, nothing else):
|
|
|
151
190
|
```bash
|
|
152
191
|
claude-accounts list # pool at a glance
|
|
153
192
|
claude-accounts status # per-account auth health, per-bucket limits incl. Fable, markers, last pick
|
|
193
|
+
claude-accounts expired # ONLY the accounts that cannot authenticate, and why (exit 1 if any)
|
|
194
|
+
claude-accounts relogin # sign those back in, one at a time (ids or --all to pick your own)
|
|
154
195
|
```
|
|
155
196
|
|
|
156
197
|
Every account in the pool is authenticated — `add` cannot register one otherwise.
|
|
157
198
|
|
|
199
|
+
### When an account stops working
|
|
200
|
+
|
|
201
|
+
Subscription logins expire (the refresh token has a finite life), can be revoked from
|
|
202
|
+
claude.ai, and an organization can disable Claude Code for one of its accounts entirely.
|
|
203
|
+
Such an account is **never selected** — `claude` keeps working on the healthy ones — and
|
|
204
|
+
shows up here:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
claude-accounts expired
|
|
208
|
+
# acct-01 support@example.com EXPIRED refresh token expired 2026-08-05 (2d ago)
|
|
209
|
+
# fix: claude-accounts relogin acct-01
|
|
210
|
+
# acct-08 dev@example.com BLOCKED this account's organization has disabled
|
|
211
|
+
# Claude Code subscription access
|
|
212
|
+
# fix: claude-accounts relogin acct-08
|
|
213
|
+
|
|
214
|
+
claude-accounts relogin # re-authenticate everything a sign-in can fix
|
|
215
|
+
claude-accounts relogin acct-01 # or just one
|
|
216
|
+
claude-accounts relogin acct-01 --token # portable token instead (Mac + server)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`relogin` covers `BLOCKED` accounts too — a fresh sign-in re-issues the grant and usually
|
|
220
|
+
clears the block. Use `claude-accounts verify` (a real `-p` call per account) to re-test
|
|
221
|
+
one at any time; a PASS clears the park automatically.
|
|
222
|
+
|
|
223
|
+
`relogin` runs the same verified sign-in as `login` (it refuses to save a credential for
|
|
224
|
+
the wrong email), one account at a time, and syncs to the server once at the end.
|
|
225
|
+
`expired` exits **1** when anything needs a human, so it drops straight into a cron or
|
|
226
|
+
monitoring check. `list` tags such accounts `EXPIRED-LOGIN`, and `status` says
|
|
227
|
+
`selectable: NO` with the reason.
|
|
228
|
+
|
|
158
229
|
### Adopt this machine's existing login (no browser needed)
|
|
159
230
|
|
|
160
231
|
```bash
|
|
@@ -240,7 +311,7 @@ failures are loud and non-zero.
|
|
|
240
311
|
## Verification
|
|
241
312
|
|
|
242
313
|
```bash
|
|
243
|
-
tests/run-tests.sh #
|
|
314
|
+
tests/run-tests.sh # 221 sandboxed tests, no quota
|
|
244
315
|
claude-accounts verify # real matrix: `claude -p "reply OK"` per authed account
|
|
245
316
|
claude-accounts verify --quick# auth presence/expiry only, no inference
|
|
246
317
|
claude-accounts limits # live per-bucket usage incl. the Fable bucket
|
|
@@ -259,7 +330,14 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
259
330
|
that the marked block is the LAST PATH manipulation in your rc file
|
|
260
331
|
(`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
|
|
261
332
|
- **An account never gets picked** — `claude-accounts status`: no auth on this machine,
|
|
262
|
-
|
|
333
|
+
a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
|
|
334
|
+
marker (shows bucket + minutes to reset).
|
|
335
|
+
- **"Failed to authenticate: OAuth session expired and could not be refreshed"** — the
|
|
336
|
+
login behind that run is dead. Run `claude-accounts expired` to see which, then
|
|
337
|
+
`claude-accounts relogin`. Accounts in that state are excluded from selection
|
|
338
|
+
automatically, so this only surfaces when *every* account needs a re-login (the shim
|
|
339
|
+
then falls back to the machine's own `~/.claude` login) or when it was pinned with
|
|
340
|
+
`CLAUDE_ACCOUNT`.
|
|
263
341
|
- **Everything marked limited** — the shim still runs (least-utilized fallback);
|
|
264
342
|
check `selection.log` for `all-limited` lines.
|
|
265
343
|
- **Sync fails** — `tail ~/.claude-accounts/sync.log`; it's ssh/rsync to the manifest's
|
package/bin/claude
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# Self-contained on purpose: no sourcing, so a broken repo file can never break `claude`.
|
|
5
5
|
# Selection: CLAUDE_CONFIG_DIR/CLAUDE_CODE_OAUTH_TOKEN passthrough > CLAUDE_ACCOUNT pin >
|
|
6
6
|
# random among limit-eligible accounts > least-utilized fallback (degraded beats down).
|
|
7
|
+
# Accounts whose login is DEAD (expired refresh token, or a `.expired` marker from a
|
|
8
|
+
# failed auth) are never selected — not even as the all-limited fallback — because they
|
|
9
|
+
# fail every call outright; `claude-accounts expired` / `relogin` fix them.
|
|
7
10
|
|
|
8
11
|
set -u
|
|
9
12
|
|
|
@@ -157,6 +160,77 @@ over_threshold() { # $1 = acct dir
|
|
|
157
160
|
# dead account selectable and turn a working stock run into an auth failure).
|
|
158
161
|
has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
|
|
159
162
|
|
|
163
|
+
cred_num() { # cred_num <file> <json key> -> integer (epoch ms) or empty
|
|
164
|
+
# The leading quote makes "expiresAt" unambiguous: "refreshTokenExpiresAt" cannot match it.
|
|
165
|
+
LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" "$1" 2>/dev/null | head -1
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# TRUE when <dir>/.credentials.json exists but can no longer authenticate: the access
|
|
169
|
+
# token is expired AND nothing can renew it (no refresh token, or the refresh token is
|
|
170
|
+
# itself expired). Such an account fails EVERY run with "OAuth session expired and
|
|
171
|
+
# could not be refreshed", so selecting it is strictly worse than not having it.
|
|
172
|
+
# Anything unparseable fails OPEN (treated as alive) — telemetry-free guesswork must
|
|
173
|
+
# never take a working account out of the pool.
|
|
174
|
+
creds_dead() { # $1 = acct dir
|
|
175
|
+
local f="$1/.credentials.json" exp rexp
|
|
176
|
+
[ -s "$f" ] || return 1
|
|
177
|
+
exp="$(cred_num "$f" expiresAt)"
|
|
178
|
+
case "$exp" in ''|*[!0-9]*) exp=0 ;; esac
|
|
179
|
+
[ $((exp / 1000)) -gt "$now" ] && return 1 # access token still valid
|
|
180
|
+
# Access token expired: only a live refresh token can save it.
|
|
181
|
+
grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' "$f" 2>/dev/null || return 0
|
|
182
|
+
rexp="$(cred_num "$f" refreshTokenExpiresAt)"
|
|
183
|
+
case "$rexp" in ''|*[!0-9]*) return 1 ;; esac # unknown expiry => fail open
|
|
184
|
+
[ $((rexp / 1000)) -le "$now" ]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# `.expired` is the persistent "this account needs a re-login" marker: written by
|
|
188
|
+
# claude-accounts (refresh grant expired/revoked) and by the retry path below when a
|
|
189
|
+
# real call fails with an auth error. It SELF-HEALS: any credential written after the
|
|
190
|
+
# marker (successful re-login, or a refresh by another process) clears it, and a
|
|
191
|
+
# successful `claude-accounts limits` fetch removes it outright.
|
|
192
|
+
# A marker written by the SHIM (a guess from one failed run) also carries
|
|
193
|
+
# `soft_until=<epoch>`: once that passes the account returns to the pool by itself, so
|
|
194
|
+
# a misread never costs an account permanently. Markers written by claude-accounts —
|
|
195
|
+
# a refresh grant that answered "revoked", a real verify call — carry no soft_until and
|
|
196
|
+
# stay until the account provably works again.
|
|
197
|
+
expired_marked() { # $1 = acct dir
|
|
198
|
+
local m="$1/.expired" mt f soft reason
|
|
199
|
+
[ -f "$m" ] || return 1
|
|
200
|
+
reason="$(LC_ALL=C sed -n 's/.*reason=\([A-Za-z0-9._-][A-Za-z0-9._-]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
201
|
+
# CREDENTIAL-scoped parks clear the moment a newer credential lands — that is the
|
|
202
|
+
# evidence they were about. A POLICY park (org-blocked) is about the account, not the
|
|
203
|
+
# credential: refreshing its token does not re-enable Claude Code for it. Letting a
|
|
204
|
+
# credential rewrite clear it silently returned blocked accounts to the pool every
|
|
205
|
+
# few hours (the limits refresher rewrites credentials), so runs kept failing with
|
|
206
|
+
# "Your organization has disabled Claude subscription access".
|
|
207
|
+
if [ "$reason" != "org-blocked" ]; then
|
|
208
|
+
mt="$(file_mtime "$m")"
|
|
209
|
+
for f in "$1/.credentials.json" "$1/server.token"; do
|
|
210
|
+
if [ -f "$f" ] && [ "$(file_mtime "$f")" -gt "$mt" ]; then
|
|
211
|
+
rm -f "$m" 2>/dev/null
|
|
212
|
+
return 1
|
|
213
|
+
fi
|
|
214
|
+
done
|
|
215
|
+
fi
|
|
216
|
+
soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
|
|
217
|
+
case "$soft" in
|
|
218
|
+
''|*[!0-9]*) return 0 ;; # no soft stamp => proven dead, keep
|
|
219
|
+
*) [ "$now" -lt "$soft" ] && return 0
|
|
220
|
+
rm -f "$m" 2>/dev/null # soft window elapsed: give it another go
|
|
221
|
+
return 1 ;;
|
|
222
|
+
esac
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Auth that cannot work right now. A non-empty server.token is an independent
|
|
226
|
+
# credential (no expiry we can read), so it keeps an account alive even when the
|
|
227
|
+
# OAuth credential beside it is dead — the token is exported instead.
|
|
228
|
+
auth_dead() { # $1 = acct dir
|
|
229
|
+
expired_marked "$1" && return 0
|
|
230
|
+
[ -s "$1/server.token" ] && return 1
|
|
231
|
+
creds_dead "$1"
|
|
232
|
+
}
|
|
233
|
+
|
|
160
234
|
# Explicit pin wins over everything — markers, and even missing auth: the
|
|
161
235
|
# add/login ceremony pins to a dir that has no credentials yet, and the login
|
|
162
236
|
# must land exactly there, never in a randomly selected account's dir.
|
|
@@ -177,17 +251,54 @@ fi
|
|
|
177
251
|
|
|
178
252
|
valid=()
|
|
179
253
|
eligible=()
|
|
254
|
+
expired=()
|
|
180
255
|
for d in "$ACC_ROOT"/acct-*; do
|
|
181
256
|
[ -d "$d" ] || continue
|
|
182
257
|
has_auth "$d" || continue
|
|
258
|
+
# Expired logins are excluded BEFORE anything else: unlike a limit marker (degraded
|
|
259
|
+
# but working), dead auth guarantees a hard "OAuth session expired" failure, so it
|
|
260
|
+
# can never be the "degraded beats down" fallback either.
|
|
261
|
+
if auth_dead "$d"; then
|
|
262
|
+
expired+=("$d")
|
|
263
|
+
continue
|
|
264
|
+
fi
|
|
183
265
|
valid+=("$d")
|
|
184
266
|
marker_active "$d" && continue
|
|
185
267
|
over_threshold "$d" && continue
|
|
186
268
|
eligible+=("$d")
|
|
187
269
|
done
|
|
188
270
|
|
|
189
|
-
|
|
190
|
-
|
|
271
|
+
if [ "${#expired[@]}" -gt 0 ]; then
|
|
272
|
+
ids=""
|
|
273
|
+
for d in "${expired[@]}"; do ids="$ids $(basename "$d")"; done
|
|
274
|
+
sel_log "skipped-expired:$ids (unusable — see: claude-accounts expired)"
|
|
275
|
+
# One actionable line, at most hourly, and only on a terminal — a service-spawned
|
|
276
|
+
# `claude -p` must keep its stderr byte-clean.
|
|
277
|
+
if [ -t 2 ]; then
|
|
278
|
+
n="$ACC_ROOT/.expired-notice"
|
|
279
|
+
last=0
|
|
280
|
+
[ -f "$n" ] && last="$(file_mtime "$n")"
|
|
281
|
+
if [ $((now - last)) -gt 3600 ]; then
|
|
282
|
+
: > "$n" 2>/dev/null || true
|
|
283
|
+
printf 'claude-multiacc: %s account(s) unusable (%s) — see: claude-accounts expired\n' \
|
|
284
|
+
"${#expired[@]}" "${ids# }" >&2
|
|
285
|
+
fi
|
|
286
|
+
fi
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
# No usable accounts => stock behavior (fail open, never block work), but say WHY when
|
|
290
|
+
# the pool is merely un-authenticated: this is the one case the user can actually fix.
|
|
291
|
+
if [ "${#valid[@]}" -eq 0 ]; then
|
|
292
|
+
if [ "${#expired[@]}" -gt 0 ]; then
|
|
293
|
+
sel_log "all-expired: falling back to the default login (see: claude-accounts expired)"
|
|
294
|
+
# Terminal only: a service-spawned `claude -p` must keep its stderr byte-clean, and
|
|
295
|
+
# the fallback may well succeed on the machine's own login. The reason is always in
|
|
296
|
+
# selection.log, and `claude-accounts expired` spells it out.
|
|
297
|
+
[ -t 2 ] && printf 'claude-multiacc: no pool account is usable (%s) — see: claude-accounts expired, then: claude-accounts relogin\n' \
|
|
298
|
+
"${ids# }" >&2
|
|
299
|
+
fi
|
|
300
|
+
exec "$REAL" "$@"
|
|
301
|
+
fi
|
|
191
302
|
|
|
192
303
|
# Pick the account with the MOST headroom (lowest ranking score = most weekly headroom,
|
|
193
304
|
# session as tiebreaker). Ties break randomly so equally-idle accounts still spread load.
|
|
@@ -242,8 +353,10 @@ fi
|
|
|
242
353
|
acct="$(basename "$pick")"
|
|
243
354
|
sel_log "$acct weekly=$(fresh_field "$pick" weekly_percent || echo '?')% session=$(fresh_field "$pick" session_percent || echo '?')% pwd=$PWD"
|
|
244
355
|
|
|
245
|
-
acct_token() { # $1 = acct dir; prints token if dir
|
|
246
|
-
|
|
356
|
+
acct_token() { # $1 = acct dir; prints token if the dir must authenticate by token
|
|
357
|
+
# Token-auth dirs (no local creds), and dirs whose OAuth credential is dead but which
|
|
358
|
+
# still carry a portable setup-token — the token is the only thing that can work there.
|
|
359
|
+
if [ -s "$1/server.token" ] && { [ ! -f "$1/.credentials.json" ] || creds_dead "$1"; }; then
|
|
247
360
|
tr -d '[:space:]' < "$1/server.token"
|
|
248
361
|
fi
|
|
249
362
|
}
|
|
@@ -310,7 +423,22 @@ if [ ! -t 0 ]; then
|
|
|
310
423
|
cat > "$stdin_file" 2>/dev/null || [ -s "$stdin_file" ] || stdin_file=""
|
|
311
424
|
fi
|
|
312
425
|
|
|
313
|
-
ERRPAT
|
|
426
|
+
# ERRPAT decides whether to RETRY at all (deliberately broad, unchanged behavior).
|
|
427
|
+
# The two PARK patterns below decide whether the failed account is also taken out of
|
|
428
|
+
# the pool, and they are deliberately NARROW: this grep also sees the model's own
|
|
429
|
+
# answer on stdout (a `-p` run that merely *discusses* a 403 must not cost an account).
|
|
430
|
+
# PARK_AUTH — the credential is dead: a cooldown would just re-fail, so the account
|
|
431
|
+
# is parked until a re-login / a fetch that authenticates clears it.
|
|
432
|
+
# PARK_ORG — the account's organization turned Claude Code access off; no re-login
|
|
433
|
+
# fixes that.
|
|
434
|
+
# Both shim-written parks carry a soft_until stamp, so even a false positive returns to
|
|
435
|
+
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
436
|
+
PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
|
|
437
|
+
PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
|
|
438
|
+
LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|credit balance'
|
|
439
|
+
ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
|
|
440
|
+
PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
|
|
441
|
+
PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
|
|
314
442
|
|
|
315
443
|
attempt=1
|
|
316
444
|
cur="$pick"
|
|
@@ -327,14 +455,39 @@ while :; do
|
|
|
327
455
|
exec 3<&-
|
|
328
456
|
if [ "$rc" -ne 0 ] && [ "$attempt" -eq 1 ] \
|
|
329
457
|
&& grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
330
|
-
# Atomic marker
|
|
458
|
+
# Atomic marker writes: a reader must never observe a half-written marker
|
|
331
459
|
# (it would parse as garbage and, before, could be deleted as "expired").
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
460
|
+
# A rate limit wins the classification: a limit message that happens to mention an
|
|
461
|
+
# auth word must get the self-expiring cooldown, never a park.
|
|
462
|
+
park_reason=""
|
|
463
|
+
park_soft=0
|
|
464
|
+
if grep -qiE "$LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
465
|
+
:
|
|
466
|
+
elif grep -qiE "$PARK_ORG" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
467
|
+
park_reason="org-blocked"
|
|
468
|
+
park_detail="the account's organization has disabled Claude Code subscription access"
|
|
469
|
+
park_soft=$((now + PARK_SOFT_ORG))
|
|
470
|
+
elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
471
|
+
park_reason="auth-error"
|
|
472
|
+
park_detail="run failed to authenticate"
|
|
473
|
+
park_soft=$((now + PARK_SOFT_AUTH))
|
|
474
|
+
fi
|
|
475
|
+
if [ -n "$park_reason" ]; then
|
|
476
|
+
{
|
|
477
|
+
echo "$now"
|
|
478
|
+
echo "reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
479
|
+
} > "$cur/.expired.$$" 2>/dev/null \
|
|
480
|
+
&& mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
|
|
481
|
+
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
482
|
+
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
|
|
483
|
+
else
|
|
484
|
+
{
|
|
485
|
+
echo $((now + 600))
|
|
486
|
+
echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
|
|
487
|
+
} > "$cur/.limited.$$" 2>/dev/null \
|
|
488
|
+
&& mv -f "$cur/.limited.$$" "$cur/.limited" 2>/dev/null \
|
|
489
|
+
|| rm -f "$cur/.limited.$$" 2>/dev/null || true
|
|
490
|
+
fi
|
|
338
491
|
next=""
|
|
339
492
|
n="${#eligible[@]}"
|
|
340
493
|
start=$((RANDOM % n))
|