claude-multiacc 1.0.3 → 1.0.5
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 +10 -3
- package/bin/claude-accounts +264 -77
- package/lib/common.sh +28 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +161 -7
package/README.md
CHANGED
|
@@ -74,9 +74,16 @@ code cannot read at all degrades that one account (fail open), never the run.
|
|
|
74
74
|
Telemetry failures never block work: no fresh data ⇒ account treated as available. The
|
|
75
75
|
endpoint rate-limits per account, so the refresher skips accounts fetched in the last 45s
|
|
76
76
|
and backs off exponentially (honoring `Retry-After`) on a 429 — `limits --force` overrides
|
|
77
|
-
both. If an account's OAuth access token has expired
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
both. If an account's OAuth access token has been expired for a while (idle account,
|
|
78
|
+
nothing ran claude under it for hours), the refresher renews it directly via the OAuth
|
|
79
|
+
**refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
|
|
80
|
+
and atomically persists the rotated credential (0600) back to that account's
|
|
81
|
+
`.credentials.json`. This is what keeps idle accounts' telemetry fresh so they win
|
|
82
|
+
selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
|
|
83
|
+
account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
|
|
84
|
+
`<acct>/.oauth-refresh.json` (10 min transient, 6 h when the grant looks revoked — the log
|
|
85
|
+
then says re-login is needed). Overrides: `CLAUDE_MULTIACC_TOKEN_URL`,
|
|
86
|
+
`CLAUDE_MULTIACC_CLIENT_ID` (used by the sandboxed tests; defaults are correct for real use).
|
|
80
87
|
|
|
81
88
|
**Auto-retry** (`-p`/`--print` only, default on, `CLAUDE_SHIM_RETRY=0` disables): on an
|
|
82
89
|
auth/rate-limit-looking failure the shim marks the account with a 10-minute error
|
package/bin/claude-accounts
CHANGED
|
@@ -40,13 +40,18 @@ USAGE
|
|
|
40
40
|
default ~/.claude login (dir symlink —
|
|
41
41
|
single credential file, no grant fork)
|
|
42
42
|
claude-accounts remove <acct-NN> [--yes] delete account (propagates to server)
|
|
43
|
+
claude-accounts dedupe [--yes] remove any account registered twice
|
|
44
|
+
(same email), keeping one per email
|
|
43
45
|
claude-accounts mint <acct-NN> mint server token via `claude setup-token`
|
|
44
46
|
--paste paste an already-minted token instead of running setup-token
|
|
45
47
|
claude-accounts sync push manifest+tokens to the server (Mac only)
|
|
46
48
|
claude-accounts verify [--quick] auth matrix; full mode runs `-p "reply OK"` per account
|
|
47
49
|
claude-accounts limits [--quiet] [--force]
|
|
48
|
-
refresh usage buckets, apply >=90% markers.
|
|
49
|
-
|
|
50
|
+
refresh usage buckets, apply >=90% markers. Auto-refreshes long-expired
|
|
51
|
+
OAuth access tokens via the refresh-token grant (rotated credential is
|
|
52
|
+
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
53
|
+
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
54
|
+
--force ignores all three.
|
|
50
55
|
claude-accounts health limits + full verify; logs to health.log
|
|
51
56
|
claude-accounts self-update update the addon (npm i -g @latest, or git
|
|
52
57
|
pull + reinstall); logs to update.log
|
|
@@ -149,6 +154,14 @@ for a in accounts:
|
|
|
149
154
|
auth.append('token')
|
|
150
155
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
151
156
|
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} auth={'+'.join(auth) or 'NONE':<11} {'LIMITED' if limited else ''}")
|
|
157
|
+
seen = {}
|
|
158
|
+
for a in accounts:
|
|
159
|
+
seen.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
160
|
+
dups = {e: ids for e, ids in seen.items() if len(ids) > 1}
|
|
161
|
+
if dups:
|
|
162
|
+
print()
|
|
163
|
+
for e, ids in dups.items():
|
|
164
|
+
print(f"WARNING: {e} is registered {len(ids)}x ({', '.join(ids)}) — run 'claude-accounts dedupe'")
|
|
152
165
|
PYEOF
|
|
153
166
|
}
|
|
154
167
|
|
|
@@ -249,89 +262,94 @@ cmd_add() {
|
|
|
249
262
|
else die "unexpected argument: $1 (usage: claude-accounts add [email] [--token] [--force])"; fi ;;
|
|
250
263
|
esac
|
|
251
264
|
done
|
|
252
|
-
# If an email was named
|
|
253
|
-
#
|
|
265
|
+
# If an email was named and it's already in the pool, SKIP before any sign-in — no
|
|
266
|
+
# duplicate is ever created. (A graceful skip, exit 0: adding an existing account is
|
|
267
|
+
# a no-op, not an error. Re-auth an existing account with 'login'.)
|
|
254
268
|
local owner
|
|
255
269
|
if [ -n "$email" ]; then
|
|
256
270
|
owner="$(email_owner "$email")"
|
|
257
|
-
[ -n "$owner" ]
|
|
271
|
+
if [ -n "$owner" ]; then
|
|
272
|
+
echo "$email is already added as $owner — skipping (nothing to do; run 'claude-accounts login $owner' to re-authenticate it)."
|
|
273
|
+
return 0
|
|
274
|
+
fi
|
|
258
275
|
fi
|
|
259
276
|
if [ ! -t 0 ] && [ -z "${CLAUDE_MULTIACC_FORCE_TTY:-}" ]; then
|
|
260
277
|
die "add is interactive (it completes sign-in before registering) — run it from a terminal"
|
|
261
278
|
fi
|
|
262
|
-
# Serialize the whole check->allocate->authenticate->register sequence: two concurrent
|
|
263
|
-
# adds must not race the duplicate check, collide on an id, or clobber the manifest.
|
|
264
|
-
local lock="$ACC_ROOT/.locks/mutate"
|
|
265
|
-
mkdir -p "$ACC_ROOT/.locks"
|
|
266
|
-
if ! mkdir "$lock" 2>/dev/null; then
|
|
267
|
-
if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 900 ]; then
|
|
268
|
-
rm -rf "$lock"; mkdir "$lock" 2>/dev/null || die "cannot acquire the account lock"
|
|
269
|
-
else
|
|
270
|
-
die "another claude-accounts add/login is in progress — try again when it finishes"
|
|
271
|
-
fi
|
|
272
|
-
fi
|
|
273
|
-
# shellcheck disable=SC2064
|
|
274
|
-
trap "rm -rf '$lock'" EXIT
|
|
275
279
|
local real
|
|
276
280
|
real="$(find_real_claude "$_self")" || die "real claude binary not found"
|
|
281
|
+
|
|
282
|
+
# Reserve a unique id under a BRIEF lock (creating the dir claims the id, so a parallel
|
|
283
|
+
# add gets the next one). The long browser sign-in below runs WITHOUT the lock, so
|
|
284
|
+
# several `add` in different terminals proceed in parallel. RESERVED_DIR drives a trap
|
|
285
|
+
# that removes the half-made dir on any failure/abort BEFORE registration.
|
|
277
286
|
local id d got elabel
|
|
278
287
|
elabel="${email:-the account you sign in as}"
|
|
288
|
+
RESERVED_DIR=""
|
|
289
|
+
trap 'add_cleanup_reserved; exit 130' INT TERM
|
|
290
|
+
trap 'add_cleanup_reserved' EXIT
|
|
291
|
+
mutate_lock || die "could not acquire the account lock (another op is stuck?) — try again"
|
|
279
292
|
id="$(next_id)"
|
|
280
293
|
d="$ACC_ROOT/$id"
|
|
281
294
|
seed_account_dir "$d"
|
|
295
|
+
RESERVED_DIR="$d"
|
|
296
|
+
mutate_unlock
|
|
297
|
+
|
|
282
298
|
if [ "$token" = "1" ]; then
|
|
283
299
|
# Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
|
|
284
300
|
echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
|
|
285
|
-
|
|
286
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
287
|
-
die "sign-in failed or aborted — nothing was created"
|
|
288
|
-
fi
|
|
301
|
+
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
|
|
289
302
|
( umask 077; printf '%s' "$CEREMONY_TOKEN" > "$d/server.token" )
|
|
290
303
|
chmod 600 "$d/server.token"
|
|
291
304
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
292
305
|
else
|
|
293
|
-
# Default: full Claude Code login (
|
|
294
|
-
#
|
|
295
|
-
# THIS machine (creds are never synced, so no cross-machine refresh races).
|
|
306
|
+
# Default: full Claude Code login (full scopes, no long-lived-token step-up).
|
|
307
|
+
# Writes auto-refreshing .credentials.json, valid on THIS machine.
|
|
296
308
|
echo "Preparing $id for $elabel — NOTHING is registered until login completes."
|
|
297
|
-
|
|
298
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
299
|
-
die "login failed or aborted — nothing was created"
|
|
300
|
-
fi
|
|
309
|
+
run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
|
|
301
310
|
got="$(config_dir_email "$d")"
|
|
302
311
|
fi
|
|
303
312
|
if [ -z "$got" ]; then
|
|
304
|
-
# Signed in, but we could not read back WHICH account. Without a verified email we
|
|
305
|
-
# cannot label or dedup the account, so refuse — unless an email was named AND
|
|
306
|
-
# --force was given (then we trust the named email).
|
|
307
313
|
if [ -n "$email" ] && [ "$force" = "1" ]; then
|
|
308
314
|
warn "identity unverified — registering as $email because --force was given"
|
|
309
315
|
got="$email"
|
|
310
316
|
else
|
|
311
|
-
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
312
317
|
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)"
|
|
313
318
|
fi
|
|
314
319
|
fi
|
|
315
|
-
#
|
|
320
|
+
# Dedup + register under a BRIEF lock, re-reading the manifest (a parallel add may have
|
|
321
|
+
# registered the same email meanwhile — the loser skips gracefully). This is the
|
|
322
|
+
# guarantee that a code pasted for an already-added account never yields a second entry.
|
|
323
|
+
mutate_lock || die "could not acquire the account lock — try again"
|
|
316
324
|
owner="$(email_owner "$got")"
|
|
317
325
|
if [ -n "$owner" ]; then
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
mutate_unlock
|
|
327
|
+
echo "$got is already added as $owner — skipping (nothing added)."
|
|
328
|
+
return 0 # RESERVED_DIR still set -> trap removes the temp dir
|
|
320
329
|
fi
|
|
321
330
|
if [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
322
331
|
warn "signed in as $got (you named $email) — registering the account that actually authenticated"
|
|
323
332
|
fi
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
333
|
+
manifest_add_account "$id" "$got" "$(machine_kind)"
|
|
334
|
+
mutate_unlock
|
|
335
|
+
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
336
|
+
trap - EXIT INT TERM
|
|
337
|
+
log_to ops.log "add $id $got (auth-verified)"
|
|
327
338
|
auto_sync
|
|
328
339
|
cat <<EOF
|
|
329
|
-
Registered $id for $
|
|
340
|
+
Registered $id for $got (sign-in verified) — usable immediately.
|
|
330
341
|
Optional:
|
|
331
342
|
claude-accounts verify # confirm the 100% matrix
|
|
332
343
|
EOF
|
|
333
344
|
}
|
|
334
345
|
|
|
346
|
+
add_cleanup_reserved() {
|
|
347
|
+
# EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
|
|
348
|
+
# we died mid-critical-section, release the lock).
|
|
349
|
+
[ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
|
|
350
|
+
mutate_unlock 2>/dev/null || true
|
|
351
|
+
}
|
|
352
|
+
|
|
335
353
|
cmd_import() {
|
|
336
354
|
require_manifest
|
|
337
355
|
local email="${1:-}"
|
|
@@ -393,6 +411,70 @@ cmd_import() {
|
|
|
393
411
|
[ "$no_sync" = "1" ] || auto_sync
|
|
394
412
|
}
|
|
395
413
|
|
|
414
|
+
# Prints duplicate account ids to REMOVE, one per line: for every email that appears
|
|
415
|
+
# more than once, keep exactly one (prefer an account that has auth on this machine,
|
|
416
|
+
# then the lowest id) and list the rest. Empty output => pool is already clean.
|
|
417
|
+
dup_ids_to_remove() {
|
|
418
|
+
[ -f "$MANIFEST" ] || return 0
|
|
419
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
|
|
420
|
+
import json, os, re, sys
|
|
421
|
+
manifest, root = sys.argv[1], sys.argv[2]
|
|
422
|
+
accts = [a for a in json.load(open(manifest)).get('accounts', [])
|
|
423
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
424
|
+
def has_auth(aid):
|
|
425
|
+
d = os.path.join(root, aid)
|
|
426
|
+
c = os.path.join(d, '.credentials.json')
|
|
427
|
+
t = os.path.join(d, 'server.token')
|
|
428
|
+
return (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0)
|
|
429
|
+
by_email = {}
|
|
430
|
+
for a in accts:
|
|
431
|
+
by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
432
|
+
for email, ids in by_email.items():
|
|
433
|
+
if len(ids) < 2:
|
|
434
|
+
continue
|
|
435
|
+
# keep: authed first, then lowest id
|
|
436
|
+
keep = sorted(ids, key=lambda i: (not has_auth(i), i))[0]
|
|
437
|
+
for i in ids:
|
|
438
|
+
if i != keep:
|
|
439
|
+
print(i)
|
|
440
|
+
PYEOF
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
cmd_dedupe() {
|
|
444
|
+
require_manifest
|
|
445
|
+
local yes=0
|
|
446
|
+
[ "${1:-}" = "--yes" ] && yes=1
|
|
447
|
+
local dups
|
|
448
|
+
dups="$(dup_ids_to_remove)"
|
|
449
|
+
if [ -z "$dups" ]; then
|
|
450
|
+
echo "No duplicate accounts — every email appears once."
|
|
451
|
+
return 0
|
|
452
|
+
fi
|
|
453
|
+
echo "Duplicate accounts (same email registered more than once):"
|
|
454
|
+
local id email
|
|
455
|
+
for id in $dups; do
|
|
456
|
+
email="$("$PYBIN" - "$MANIFEST" "$id" <<'PYEOF'
|
|
457
|
+
import json, sys
|
|
458
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
459
|
+
if a.get('id') == sys.argv[2]: print(a.get('email', '')); break
|
|
460
|
+
PYEOF
|
|
461
|
+
)"
|
|
462
|
+
echo " will remove $id ($email)"
|
|
463
|
+
done
|
|
464
|
+
if [ "$yes" != "1" ]; then
|
|
465
|
+
printf 'Remove these duplicates (keeps one per email)? [y/N] '
|
|
466
|
+
read -r ans
|
|
467
|
+
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
468
|
+
fi
|
|
469
|
+
for id in $dups; do
|
|
470
|
+
if [ -L "$ACC_ROOT/$id" ]; then rm -f "${ACC_ROOT:?}/${id:?}"; else rm -rf "${ACC_ROOT:?}/${id:?}"; fi
|
|
471
|
+
manifest_del_account "$id"
|
|
472
|
+
log_to ops.log "dedupe removed $id"
|
|
473
|
+
done
|
|
474
|
+
echo "Removed $(printf '%s\n' "$dups" | grep -c .) duplicate account(s)."
|
|
475
|
+
auto_sync
|
|
476
|
+
}
|
|
477
|
+
|
|
396
478
|
cmd_adopt() {
|
|
397
479
|
# Make <acct-NN> THIS machine's existing default login (~/.claude) without
|
|
398
480
|
# forking its OAuth grant: the account dir becomes a symlink to ~/.claude, so
|
|
@@ -661,24 +743,9 @@ cmd_limits() {
|
|
|
661
743
|
# shellcheck disable=SC2064
|
|
662
744
|
trap "rm -rf '$lock'" EXIT
|
|
663
745
|
rotate_log limits.log
|
|
664
|
-
#
|
|
665
|
-
#
|
|
666
|
-
#
|
|
667
|
-
local real="" d
|
|
668
|
-
real="$(find_real_claude "$_self" 2>/dev/null)" || real=""
|
|
669
|
-
if [ -n "$real" ]; then
|
|
670
|
-
for d in "$ACC_ROOT"/acct-*; do
|
|
671
|
-
[ -d "$d" ] || continue
|
|
672
|
-
[ -f "$d/.credentials.json" ] || continue
|
|
673
|
-
[ -s "$d/server.token" ] && continue
|
|
674
|
-
if "$PYBIN" -c '
|
|
675
|
-
import json, sys, time
|
|
676
|
-
c = json.load(open(sys.argv[1])).get("claudeAiOauth", {})
|
|
677
|
-
sys.exit(0 if c.get("expiresAt", 0) / 1000.0 <= time.time() + 60 else 1)' "$d/.credentials.json" 2>/dev/null; then
|
|
678
|
-
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth status >/dev/null 2>&1 || true
|
|
679
|
-
fi
|
|
680
|
-
done
|
|
681
|
-
fi
|
|
746
|
+
# NB: expired OAuth access tokens are refreshed inside the Python below via the
|
|
747
|
+
# refresh-token grant. (`claude auth status` was tried for this and does NOT
|
|
748
|
+
# refresh credentials — it only reports the on-disk state.)
|
|
682
749
|
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
683
750
|
# never loosen it, or an account could sit at 95% and still be selected.
|
|
684
751
|
local threshold
|
|
@@ -694,6 +761,21 @@ now = time.time()
|
|
|
694
761
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
695
762
|
MIN_FETCH_INTERVAL = int(os.environ.get('CLAUDE_MULTIACC_MIN_FETCH', '45'))
|
|
696
763
|
|
|
764
|
+
# OAuth refresh-token grant — the same endpoint + public client id Claude Code
|
|
765
|
+
# itself uses to keep .credentials.json alive. An account that sits idle past its
|
|
766
|
+
# access-token TTL would otherwise drop out of telemetry forever (stale data ranks
|
|
767
|
+
# neutral, so truly-idle accounts lose selection to busy-but-fresh ones).
|
|
768
|
+
TOKEN_URL = os.environ.get('CLAUDE_MULTIACC_TOKEN_URL',
|
|
769
|
+
'https://console.anthropic.com/v1/oauth/token')
|
|
770
|
+
CLIENT_ID = os.environ.get('CLAUDE_MULTIACC_CLIENT_ID',
|
|
771
|
+
'9d1c250a-e61b-44d9-88ed-5944d1962f5e')
|
|
772
|
+
# Only refresh a token that has been expired for a while: a LIVE session refreshes
|
|
773
|
+
# its own credential within moments of expiry, so a long-expired one proves no
|
|
774
|
+
# other writer is active (refresh tokens rotate; two racing refreshers strand one).
|
|
775
|
+
REFRESH_MIN_EXPIRED = 300
|
|
776
|
+
REFRESH_FAIL_BACKOFF = 600 # transient (network/5xx/429): retry in 10 min
|
|
777
|
+
REFRESH_DENIED_BACKOFF = 21600 # 4xx = grant likely revoked: 6h; re-login needed anyway
|
|
778
|
+
|
|
697
779
|
def say(msg):
|
|
698
780
|
if not quiet:
|
|
699
781
|
print(msg)
|
|
@@ -714,34 +796,108 @@ try:
|
|
|
714
796
|
except Exception as e:
|
|
715
797
|
sys.exit(f'cannot read manifest: {e}')
|
|
716
798
|
|
|
799
|
+
def refresh_oauth(aid, d, cpath):
|
|
800
|
+
"""Refresh a long-expired OAuth access token via the refresh-token grant and
|
|
801
|
+
persist the ROTATED credential atomically (0600). Returns the new bearer, or
|
|
802
|
+
None (fail open: the on-disk credential is never touched on failure).
|
|
803
|
+
Failures back off via <dir>/.oauth-refresh.json — a side file, NOT limits.json,
|
|
804
|
+
because telemetry state must only ever reflect real usage fetches."""
|
|
805
|
+
spath = os.path.join(d, '.oauth-refresh.json')
|
|
806
|
+
try:
|
|
807
|
+
doc = json.load(open(cpath))
|
|
808
|
+
o = doc.get('claudeAiOauth', {})
|
|
809
|
+
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
810
|
+
# write) must degrade THIS account only, like every other malformed input.
|
|
811
|
+
if not isinstance(doc, dict) or not isinstance(o, dict):
|
|
812
|
+
return None
|
|
813
|
+
except Exception:
|
|
814
|
+
return None
|
|
815
|
+
if not o.get('refreshToken') or not o.get('accessToken'):
|
|
816
|
+
return None
|
|
817
|
+
if o.get('expiresAt', 0) / 1000.0 > now - REFRESH_MIN_EXPIRED:
|
|
818
|
+
return None # not expired long enough to prove no live session owns it
|
|
819
|
+
if o.get('refreshTokenExpiresAt', 0) / 1000.0 <= now:
|
|
820
|
+
say(f'{aid}: refresh token expired — re-login needed (claude-accounts login {aid})')
|
|
821
|
+
return None
|
|
822
|
+
if not force:
|
|
823
|
+
try:
|
|
824
|
+
if json.load(open(spath)).get('retry_after', 0) > now:
|
|
825
|
+
return None # earlier refresh failure still backing off
|
|
826
|
+
except Exception:
|
|
827
|
+
pass
|
|
828
|
+
|
|
829
|
+
def back_off(wait, why):
|
|
830
|
+
try:
|
|
831
|
+
with open(spath + '.tmp', 'w') as f:
|
|
832
|
+
json.dump({'retry_after': int(now + wait), 'error': why,
|
|
833
|
+
'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}, f)
|
|
834
|
+
os.replace(spath + '.tmp', spath)
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
838
|
+
|
|
839
|
+
body = json.dumps({'grant_type': 'refresh_token',
|
|
840
|
+
'refresh_token': o['refreshToken'],
|
|
841
|
+
'client_id': CLIENT_ID}).encode()
|
|
842
|
+
req = urllib.request.Request(TOKEN_URL, data=body, headers={
|
|
843
|
+
'Content-Type': 'application/json',
|
|
844
|
+
'User-Agent': 'claude-multiacc/1.0',
|
|
845
|
+
})
|
|
846
|
+
try:
|
|
847
|
+
data = json.loads(urllib.request.urlopen(req, timeout=30).read().decode())
|
|
848
|
+
except urllib.error.HTTPError as e:
|
|
849
|
+
if e.code in (400, 401, 403):
|
|
850
|
+
back_off(REFRESH_DENIED_BACKOFF,
|
|
851
|
+
f'HTTP {e.code} — refresh token may be revoked; re-login needed')
|
|
852
|
+
else:
|
|
853
|
+
back_off(REFRESH_FAIL_BACKOFF, f'HTTP {e.code}')
|
|
854
|
+
return None
|
|
855
|
+
except Exception as e:
|
|
856
|
+
back_off(REFRESH_FAIL_BACKOFF, str(e)[:200])
|
|
857
|
+
return None
|
|
858
|
+
tok = data.get('access_token') if isinstance(data, dict) else None
|
|
859
|
+
if not tok:
|
|
860
|
+
back_off(REFRESH_DENIED_BACKOFF, 'no access_token in response')
|
|
861
|
+
return None
|
|
862
|
+
o['accessToken'] = tok
|
|
863
|
+
# The grant ROTATES the refresh token: persist it (and both expiries) or the
|
|
864
|
+
# account is stranded — hence atomic write, and a loud message if it fails.
|
|
865
|
+
if data.get('refresh_token'):
|
|
866
|
+
o['refreshToken'] = data['refresh_token']
|
|
867
|
+
if data.get('expires_in'):
|
|
868
|
+
o['expiresAt'] = int((now + float(data['expires_in'])) * 1000)
|
|
869
|
+
else:
|
|
870
|
+
# No expires_in in the response: assume a conservative 1h. Leaving the old
|
|
871
|
+
# (past) expiresAt would make every later pass re-run the grant in a loop.
|
|
872
|
+
o['expiresAt'] = int((now + 3600) * 1000)
|
|
873
|
+
if data.get('refresh_token_expires_in'):
|
|
874
|
+
o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
|
|
875
|
+
doc['claudeAiOauth'] = o
|
|
876
|
+
try:
|
|
877
|
+
fd = os.open(cpath + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
878
|
+
with os.fdopen(fd, 'w') as f:
|
|
879
|
+
json.dump(doc, f)
|
|
880
|
+
os.replace(cpath + '.tmp', cpath)
|
|
881
|
+
except Exception as e:
|
|
882
|
+
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
883
|
+
return None
|
|
884
|
+
try:
|
|
885
|
+
os.remove(spath)
|
|
886
|
+
except OSError:
|
|
887
|
+
pass
|
|
888
|
+
say(f'{aid}: oauth access token refreshed via refresh-token grant')
|
|
889
|
+
return tok
|
|
890
|
+
|
|
717
891
|
for acct in manifest.get('accounts', []):
|
|
718
892
|
aid = acct['id']
|
|
719
893
|
d = os.path.join(root, aid)
|
|
720
894
|
if not os.path.isdir(d):
|
|
721
895
|
continue
|
|
722
|
-
bearer = None
|
|
723
|
-
source = None
|
|
724
|
-
cpath = os.path.join(d, '.credentials.json')
|
|
725
|
-
if os.path.isfile(cpath):
|
|
726
|
-
try:
|
|
727
|
-
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
728
|
-
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
729
|
-
bearer, source = c['accessToken'], 'oauth'
|
|
730
|
-
except Exception:
|
|
731
|
-
pass
|
|
732
|
-
tpath = os.path.join(d, 'server.token')
|
|
733
|
-
if not bearer and os.path.isfile(tpath):
|
|
734
|
-
t = open(tpath).read().strip()
|
|
735
|
-
if t:
|
|
736
|
-
bearer, source = t, 'token'
|
|
737
|
-
if not bearer:
|
|
738
|
-
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
739
|
-
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
740
|
-
continue
|
|
741
896
|
|
|
742
897
|
# The usage endpoint rate-limits per account. Several callers can fire at once
|
|
743
898
|
# (60s cron + the shim's opportunistic kick + a manual run), so skip a fetch when
|
|
744
899
|
# this account's data is already fresh, and honor any backoff a 429 set earlier.
|
|
900
|
+
# Checked FIRST so a skipped account never burns an oauth refresh for nothing.
|
|
745
901
|
lpath = os.path.join(d, 'limits.json')
|
|
746
902
|
prev = {}
|
|
747
903
|
if os.path.isfile(lpath):
|
|
@@ -758,6 +914,36 @@ for acct in manifest.get('accounts', []):
|
|
|
758
914
|
say(f'{aid}: backing off after 429 ({int(retry_at - now)}s left); limits left as-is')
|
|
759
915
|
continue
|
|
760
916
|
|
|
917
|
+
bearer = None
|
|
918
|
+
source = None
|
|
919
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
920
|
+
if os.path.isfile(cpath):
|
|
921
|
+
try:
|
|
922
|
+
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
923
|
+
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
924
|
+
bearer, source = c['accessToken'], 'oauth'
|
|
925
|
+
except Exception:
|
|
926
|
+
pass
|
|
927
|
+
tpath = os.path.join(d, 'server.token')
|
|
928
|
+
if not bearer and os.path.isfile(tpath):
|
|
929
|
+
t = open(tpath).read().strip()
|
|
930
|
+
if t:
|
|
931
|
+
bearer, source = t, 'token'
|
|
932
|
+
if not bearer and os.path.isfile(cpath):
|
|
933
|
+
# Hard fail-open guard: NOTHING a single account's refresh does may abort
|
|
934
|
+
# the loop — every account after it would silently starve of telemetry.
|
|
935
|
+
try:
|
|
936
|
+
tok = refresh_oauth(aid, d, cpath)
|
|
937
|
+
except Exception as e:
|
|
938
|
+
say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
|
|
939
|
+
tok = None
|
|
940
|
+
if tok:
|
|
941
|
+
bearer, source = tok, 'oauth'
|
|
942
|
+
if not bearer:
|
|
943
|
+
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
944
|
+
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
945
|
+
continue
|
|
946
|
+
|
|
761
947
|
req = urllib.request.Request(url, headers={
|
|
762
948
|
'Authorization': 'Bearer ' + bearer,
|
|
763
949
|
'anthropic-beta': 'oauth-2025-04-20',
|
|
@@ -1165,6 +1351,7 @@ case "${1:-help}" in
|
|
|
1165
1351
|
add) shift; cmd_add "$@" ;;
|
|
1166
1352
|
import) shift; cmd_import "$@" ;;
|
|
1167
1353
|
adopt) shift; cmd_adopt "$@" ;;
|
|
1354
|
+
dedupe) shift; cmd_dedupe "$@" ;;
|
|
1168
1355
|
remove) shift; cmd_remove "$@" ;;
|
|
1169
1356
|
mint) shift; cmd_mint "$@" ;;
|
|
1170
1357
|
login) shift; cmd_login "$@" ;;
|
package/lib/common.sh
CHANGED
|
@@ -188,6 +188,34 @@ PYEOF
|
|
|
188
188
|
fi
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
# Short-lived mutation lock: serialize only the brief critical sections (id reservation,
|
|
192
|
+
# manifest write), NOT long interactive work like a browser sign-in — so several
|
|
193
|
+
# `add`/`login` in different terminals run in parallel and only briefly wait on each other.
|
|
194
|
+
# Held for milliseconds; a lock older than 30s is assumed abandoned (holder died) and reclaimed.
|
|
195
|
+
MUTATE_LOCK_HELD=0
|
|
196
|
+
mutate_lock() {
|
|
197
|
+
local lock="$ACC_ROOT/.locks/mutate" tries=0
|
|
198
|
+
mkdir -p "$ACC_ROOT/.locks" 2>/dev/null
|
|
199
|
+
while ! mkdir "$lock" 2>/dev/null; do
|
|
200
|
+
if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 30 ]; then
|
|
201
|
+
rm -rf "$lock" 2>/dev/null
|
|
202
|
+
continue
|
|
203
|
+
fi
|
|
204
|
+
tries=$((tries + 1))
|
|
205
|
+
[ "$tries" -gt 600 ] && return 1 # ~60s ceiling; contention should clear in ms
|
|
206
|
+
sleep 0.1
|
|
207
|
+
done
|
|
208
|
+
MUTATE_LOCK_HELD=1
|
|
209
|
+
return 0
|
|
210
|
+
}
|
|
211
|
+
# Only removes the lock dir when THIS process holds it — so a normal release, or a
|
|
212
|
+
# cleanup trap firing after release, can never delete a lock a parallel process just took.
|
|
213
|
+
mutate_unlock() {
|
|
214
|
+
[ "${MUTATE_LOCK_HELD:-0}" = 1 ] || return 0
|
|
215
|
+
rm -rf "$ACC_ROOT/.locks/mutate" 2>/dev/null
|
|
216
|
+
MUTATE_LOCK_HELD=0
|
|
217
|
+
}
|
|
218
|
+
|
|
191
219
|
rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
|
|
192
220
|
local f="$ACC_ROOT/$1"
|
|
193
221
|
[ -f "$f" ] || return 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -75,6 +75,9 @@ export CLAUDE_MULTIACC_NO_SYNC=1
|
|
|
75
75
|
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
76
76
|
# is off by default here; the throttle test re-enables it explicitly.
|
|
77
77
|
export CLAUDE_MULTIACC_MIN_FETCH=0
|
|
78
|
+
# The oauth token endpoint must NEVER be hit for real from tests: default to a missing
|
|
79
|
+
# file:// fixture (refresh fails fast, offline); the refresh tests override per-case.
|
|
80
|
+
export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
78
81
|
|
|
79
82
|
now="$(date +%s)"
|
|
80
83
|
|
|
@@ -353,11 +356,13 @@ check "remove account" "Removed acct-04" "$out"
|
|
|
353
356
|
[ ! -d "$ACC/acct-04" ] && t_ok "remove deleted dir" || t_fail "remove deleted dir" "dir still there"
|
|
354
357
|
|
|
355
358
|
# ---- 15b. duplicate-email guards ------------------------------------------------
|
|
359
|
+
# add of a named, already-present email => graceful SKIP before any sign-in (exit 0)
|
|
356
360
|
out="$(claude-accounts add a@test 2>&1)"
|
|
357
361
|
rc=$?
|
|
358
|
-
check "add
|
|
359
|
-
[ "$rc"
|
|
362
|
+
check "add skips a duplicate email with a message" "already added as acct-01 — skipping" "$out"
|
|
363
|
+
[ "$rc" = "0" ] && t_ok "duplicate add exits 0 (graceful skip)" || t_fail "duplicate add rc" "rc=$rc"
|
|
360
364
|
[ ! -d "$ACC/acct-04" ] && t_ok "duplicate add created nothing" || t_fail "duplicate add" "dir created"
|
|
365
|
+
# import stays strict (it's the lower-level command): refuses a duplicate
|
|
361
366
|
out="$(claude-accounts import a@test --id acct-09 --no-sync 2>&1)"
|
|
362
367
|
rc=$?
|
|
363
368
|
check "import refuses duplicate email" "already registered as acct-01" "$out"
|
|
@@ -391,8 +396,8 @@ claude-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
|
391
396
|
# add with no email, but the signed-in email is already registered => refuse + clean up
|
|
392
397
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add 2>&1)"
|
|
393
398
|
rc=$?
|
|
394
|
-
check "email-less add
|
|
395
|
-
[ "$rc"
|
|
399
|
+
check "email-less add skips a duplicate signed-in email" "a@test is already added as acct-01 — skipping" "$out"
|
|
400
|
+
[ "$rc" = "0" ] && t_ok "email-less duplicate skip exits 0" || t_fail "email-less dup rc" "rc=$rc"
|
|
396
401
|
[ ! -d "$ACC/acct-04" ] && t_ok "email-less duplicate cleaned up" || t_fail "email-less dup cleanup" "dir left"
|
|
397
402
|
# add with no email but identity can't be read back => refuse (can't label the account)
|
|
398
403
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_AUTH_FAIL=1 claude-accounts add 2>&1)"
|
|
@@ -422,7 +427,8 @@ check "aborted --token add detected" "sign-in failed or aborted" "$out"
|
|
|
422
427
|
# ---- 15e. sign-in as an already-registered email is rejected + cleaned ---------------
|
|
423
428
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=a@test claude-accounts add brand@test 2>&1)"
|
|
424
429
|
rc=$?
|
|
425
|
-
check "wrong-account sign-in
|
|
430
|
+
check "wrong-account sign-in skips (already added)" "a@test is already added as acct-01 — skipping" "$out"
|
|
431
|
+
[ "$rc" = "0" ] && t_ok "wrong-account skip exits 0" || t_fail "wrong-account rc" "rc=$rc"
|
|
426
432
|
[ ! -d "$ACC/acct-04" ] && t_ok "wrong-account sign-in cleaned up" || t_fail "wrong-account cleanup" "dir left behind"
|
|
427
433
|
|
|
428
434
|
# ---- 15f. login command completes auth for an existing auth-less account -------------
|
|
@@ -440,6 +446,62 @@ check "login --token saves a portable token" "acct-08 token saved" "$out"
|
|
|
440
446
|
[ -s "$ACC/acct-08/server.token" ] && t_ok "login --token writes server.token" || t_fail "login token" "missing"
|
|
441
447
|
claude-accounts remove acct-08 --yes >/dev/null 2>&1
|
|
442
448
|
|
|
449
|
+
# ---- 15g. parallel adds (different terminals) get distinct ids, no lock-busy error ----
|
|
450
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par1@test claude-accounts add >"$WORK/p1.out" 2>&1 ) &
|
|
451
|
+
pA=$!
|
|
452
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=par2@test claude-accounts add >"$WORK/p2.out" 2>&1 ) &
|
|
453
|
+
pB=$!
|
|
454
|
+
wait "$pA"; wait "$pB"
|
|
455
|
+
both="$(cat "$WORK/p1.out" "$WORK/p2.out" 2>/dev/null)"
|
|
456
|
+
printf '%s' "$both" | grep -q "in progress" \
|
|
457
|
+
&& t_fail "parallel add: no lock-busy error" "got the 'another add in progress' error" \
|
|
458
|
+
|| t_ok "parallel add: neither reports 'another add in progress'"
|
|
459
|
+
regA="$(claude-accounts list 2>&1 | grep par1@test | awk '{print $1}')"
|
|
460
|
+
regB="$(claude-accounts list 2>&1 | grep par2@test | awk '{print $1}')"
|
|
461
|
+
{ [ -n "$regA" ] && [ -n "$regB" ] && [ "$regA" != "$regB" ]; } \
|
|
462
|
+
&& t_ok "parallel add: both registered on distinct ids ($regA, $regB)" \
|
|
463
|
+
|| t_fail "parallel add ids" "regA=$regA regB=$regB"
|
|
464
|
+
# and no duplicate/second entry crept in for either email
|
|
465
|
+
{ [ "$(claude-accounts list 2>&1 | grep -c par1@test)" = "1" ] && [ "$(claude-accounts list 2>&1 | grep -c par2@test)" = "1" ]; } \
|
|
466
|
+
&& t_ok "parallel add: exactly one entry per email" || t_fail "parallel add dup" "duplicate created"
|
|
467
|
+
[ -n "$regA" ] && claude-accounts remove "$regA" --yes >/dev/null 2>&1
|
|
468
|
+
[ -n "$regB" ] && claude-accounts remove "$regB" --yes >/dev/null 2>&1
|
|
469
|
+
|
|
470
|
+
# ---- 15h. two parallel adds signing into the SAME email: exactly one wins ------------
|
|
471
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s1.out" 2>&1 ) &
|
|
472
|
+
sA=$!
|
|
473
|
+
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s2.out" 2>&1 ) &
|
|
474
|
+
sB=$!
|
|
475
|
+
wait "$sA"; wait "$sB"
|
|
476
|
+
n="$(claude-accounts list 2>&1 | grep -c same@test)"
|
|
477
|
+
[ "$n" = "1" ] && t_ok "same-email parallel add: exactly one registered (other skipped)" \
|
|
478
|
+
|| t_fail "same-email parallel add" "count=$n (expected 1)"
|
|
479
|
+
cat "$WORK/s1.out" "$WORK/s2.out" 2>/dev/null | grep -q "already added as\|skipping" \
|
|
480
|
+
&& t_ok "same-email parallel add: loser skipped gracefully" || t_fail "same-email skip msg" "no skip message"
|
|
481
|
+
regS="$(claude-accounts list 2>&1 | grep same@test | awk '{print $1}')"
|
|
482
|
+
[ -n "$regS" ] && claude-accounts remove "$regS" --yes >/dev/null 2>&1
|
|
483
|
+
|
|
484
|
+
# ---- 15i. dedupe removes accounts registered twice (keeps one per email) --------------
|
|
485
|
+
out="$(claude-accounts dedupe 2>&1)"
|
|
486
|
+
check "dedupe on a clean pool is a no-op" "No duplicate accounts" "$out"
|
|
487
|
+
# manufacture a duplicate directly in the manifest (a pre-fix leftover) + a dir for it
|
|
488
|
+
claude-accounts import twin@test --id acct-06 --creds "$WORK/import-creds.json" --mode copy --no-sync >/dev/null 2>&1
|
|
489
|
+
python3 - "$ACC/accounts.json" <<'EOF'
|
|
490
|
+
import json, os, sys
|
|
491
|
+
d = json.load(open(sys.argv[1]))
|
|
492
|
+
d['accounts'].append({'id': 'acct-07', 'email': 'twin@test', 'home': 'mac'})
|
|
493
|
+
json.dump(d, open(sys.argv[1] + '.tmp', 'w'), indent=2); os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
494
|
+
EOF
|
|
495
|
+
mkdir -p "$ACC/acct-07"
|
|
496
|
+
out="$(claude-accounts list 2>&1)"
|
|
497
|
+
check "list warns about a duplicate email" "twin@test is registered 2x" "$out"
|
|
498
|
+
out="$(claude-accounts dedupe --yes 2>&1)"
|
|
499
|
+
check "dedupe reports removal" "Removed 1 duplicate" "$out"
|
|
500
|
+
[ "$(claude-accounts list 2>&1 | grep -c twin@test)" = "1" ] && t_ok "dedupe keeps exactly one per email" || t_fail "dedupe count" "not 1"
|
|
501
|
+
# it kept the authed one (acct-06 has creds), removed the bare acct-07
|
|
502
|
+
claude-accounts list 2>&1 | grep -q "acct-06.*twin@test" && t_ok "dedupe kept the authenticated account" || t_fail "dedupe keep-authed" "kept the wrong one"
|
|
503
|
+
claude-accounts remove acct-06 --yes >/dev/null 2>&1
|
|
504
|
+
|
|
443
505
|
# ---- 16. CLI: limits marking via fixture endpoint ------------------------------
|
|
444
506
|
cat > "$WORK/usage-high.json" <<'EOF'
|
|
445
507
|
{"limits":[
|
|
@@ -559,15 +621,107 @@ d = json.load(open('$ACC/acct-01/limits.json'))
|
|
|
559
621
|
sys.exit(0 if 'retry_after' not in d and 'backoff' not in d else 1)" \
|
|
560
622
|
&& t_ok "successful fetch clears backoff state" || t_fail "backoff cleared" "retry_after/backoff persisted"
|
|
561
623
|
|
|
562
|
-
# ---- 16b. expired-bearer account
|
|
624
|
+
# ---- 16b. expired-bearer account: oauth refresh attempted; fail-open when it fails ----
|
|
563
625
|
mkdir -p "$ACC/acct-05"
|
|
564
|
-
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"
|
|
626
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-oldrefresh","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
565
627
|
claude-accounts import e@test --id acct-05 --no-sync >/dev/null 2>&1
|
|
566
628
|
out="$(CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
567
629
|
rc=$?
|
|
630
|
+
check "failed oauth refresh logged with backoff" "acct-05: oauth refresh failed" "$out"
|
|
568
631
|
check "expired bearer logged, not fatal" "acct-05: no fresh bearer" "$out"
|
|
569
632
|
[ "$rc" = "0" ] && t_ok "limits exits 0 with expired-bearer account" || t_fail "limits exit code" "rc=$rc"
|
|
570
633
|
[ ! -f "$ACC/acct-05/limits.json" ] && t_ok "no limits.json fabricated for expired account" || t_fail "expired acct limits.json" "unexpectedly written"
|
|
634
|
+
[ -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "refresh failure recorded in .oauth-refresh.json" || t_fail "refresh backoff file" "missing"
|
|
635
|
+
|
|
636
|
+
# ---- 16b2. refresh backoff honored: even a now-working endpoint is not retried early --
|
|
637
|
+
cat > "$WORK/token-ok.json" <<'EOF'
|
|
638
|
+
{"access_token":"sk-ant-oat01-refreshednew","refresh_token":"sk-ant-ort01-rotatednew","expires_in":28800,"refresh_token_expires_in":2592000}
|
|
639
|
+
EOF
|
|
640
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
641
|
+
check "refresh backoff honored (no early retry)" "acct-05: no fresh bearer" "$out"
|
|
642
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
643
|
+
&& t_fail "backoff prevented refresh" "credentials rewritten inside the backoff window" \
|
|
644
|
+
|| t_ok "no refresh inside the backoff window"
|
|
645
|
+
|
|
646
|
+
# ---- 16b3. --force bypasses refresh backoff: rotated credential persisted + fetch ok --
|
|
647
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
648
|
+
check "--force refreshes the expired oauth token" "acct-05: oauth access token refreshed" "$out"
|
|
649
|
+
check "refreshed account fetches telemetry" "acct-05: ok" "$out"
|
|
650
|
+
python3 - "$ACC/acct-05/.credentials.json" <<'EOF'
|
|
651
|
+
import json, os, stat, sys, time
|
|
652
|
+
p = sys.argv[1]
|
|
653
|
+
o = json.load(open(p))['claudeAiOauth']
|
|
654
|
+
assert o['accessToken'] == 'sk-ant-oat01-refreshednew', o['accessToken']
|
|
655
|
+
assert o['refreshToken'] == 'sk-ant-ort01-rotatednew', 'refresh token was not rotated'
|
|
656
|
+
assert o['expiresAt'] / 1000.0 > time.time() + 3600, 'expiresAt not advanced'
|
|
657
|
+
assert o['refreshTokenExpiresAt'] / 1000.0 > time.time() + 86400, 'refreshTokenExpiresAt not advanced'
|
|
658
|
+
mode = stat.S_IMODE(os.stat(p).st_mode)
|
|
659
|
+
assert mode == 0o600, oct(mode)
|
|
660
|
+
EOF
|
|
661
|
+
[ $? -eq 0 ] && t_ok "rotated credential persisted with 0600" || t_fail "credential rotation" "see assertions above"
|
|
662
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "successful refresh clears the backoff file" || t_fail "refresh backoff clear" "file still present"
|
|
663
|
+
[ -f "$ACC/acct-05/limits.json" ] && t_ok "telemetry written right after refresh" || t_fail "limits.json after refresh" "missing"
|
|
664
|
+
grep -qE "sk-ant-ort01|sk-ant-oat01-refreshednew" "$ACC/limits.log" \
|
|
665
|
+
&& t_fail "limits.log leaks no tokens" "a token leaked into limits.log" \
|
|
666
|
+
|| t_ok "limits.log leaks no tokens"
|
|
667
|
+
|
|
668
|
+
# ---- 16b4. steady state: fresh data means no refresh and no fetch (quiet skip) --------
|
|
669
|
+
out="$(CLAUDE_MULTIACC_MIN_FETCH=45 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
670
|
+
printf '%s' "$out" | grep -q "acct-05" \
|
|
671
|
+
&& t_fail "fresh account skipped silently" "unexpected acct-05 output: $out" \
|
|
672
|
+
|| t_ok "fresh account skipped silently (no refresh, no fetch)"
|
|
673
|
+
|
|
674
|
+
# ---- 16b5. an EXPIRED refresh token is never sent: clear re-login message -------------
|
|
675
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-dead","expiresAt":1000,"refreshTokenExpiresAt":1000}}' > "$ACC/acct-05/.credentials.json"
|
|
676
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
677
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
678
|
+
check "expired refresh token => re-login message" "re-login needed" "$out"
|
|
679
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
680
|
+
&& t_fail "dead refresh token never used" "credentials rewritten from a dead refresh token" \
|
|
681
|
+
|| t_ok "dead refresh token never used"
|
|
682
|
+
|
|
683
|
+
# ---- 16b6. RECENTLY-expired token is left alone (a live session owns it) --------------
|
|
684
|
+
# The 5-min REFRESH_MIN_EXPIRED gate is the rotation-safety core: a token that expired
|
|
685
|
+
# moments ago may be mid-refresh by a live claude session; grants must not race it.
|
|
686
|
+
# Not even --force may bypass this.
|
|
687
|
+
recent_ms="$(python3 -c 'import time; print(int((time.time()-100)*1000))')"
|
|
688
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-recent","refreshToken":"sk-ant-ort01-live","expiresAt":%s,"refreshTokenExpiresAt":9999999999999}}' "$recent_ms" > "$ACC/acct-05/.credentials.json"
|
|
689
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
690
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
691
|
+
check "recently-expired token is not refreshed (even --force)" "acct-05: no fresh bearer" "$out"
|
|
692
|
+
grep -q "sk-ant-oat01-recent" "$ACC/acct-05/.credentials.json" \
|
|
693
|
+
&& t_ok "recently-expired credential left untouched" \
|
|
694
|
+
|| t_fail "REFRESH_MIN_EXPIRED gate" "credential was rewritten within the 5-min grace window"
|
|
695
|
+
[ ! -f "$ACC/acct-05/.oauth-refresh.json" ] && t_ok "no backoff recorded for a gated (skipped) refresh" \
|
|
696
|
+
|| t_fail "gated refresh backoff" ".oauth-refresh.json written despite the gate"
|
|
697
|
+
|
|
698
|
+
# ---- 16b7. server.token accounts are NEVER oauth-refreshed (token bearer wins) ---------
|
|
699
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-old","refreshToken":"sk-ant-ort01-x","expiresAt":1000,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-05/.credentials.json"
|
|
700
|
+
printf 'sk-ant-oat01-portable-token-05' > "$ACC/acct-05/server.token"
|
|
701
|
+
rm -f "$ACC/acct-05/limits.json" "$ACC/acct-05/.oauth-refresh.json"
|
|
702
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
703
|
+
check "token-bearer account fetches without refresh" "acct-05: ok" "$out"
|
|
704
|
+
grep -q "sk-ant-oat01-refreshednew" "$ACC/acct-05/.credentials.json" \
|
|
705
|
+
&& t_fail "server.token exempts oauth refresh" "oauth creds were rotated despite a portable token" \
|
|
706
|
+
|| t_ok "server.token account never oauth-refreshed (grant not run)"
|
|
707
|
+
python3 -c "import json,sys; sys.exit(0 if json.load(open('$ACC/acct-05/limits.json'))['source']=='token' else 1)" \
|
|
708
|
+
&& t_ok "telemetry fetched via the portable token" || t_fail "token bearer source" "source != token"
|
|
709
|
+
rm -f "$ACC/acct-05/server.token"
|
|
710
|
+
|
|
711
|
+
# ---- 16b8. malformed claudeAiOauth (null) degrades that account ONLY (fail open) -------
|
|
712
|
+
# {"claudeAiOauth": null} is valid JSON from an interrupted/reset credential write; it
|
|
713
|
+
# must not abort the refresher — accounts AFTER it in the manifest must still be fetched.
|
|
714
|
+
# acct-01 is first in the manifest, so corrupting it exercises the loop guarantee.
|
|
715
|
+
cp "$ACC/acct-01/.credentials.json" "$WORK/acct01-creds.bak"
|
|
716
|
+
printf '{"claudeAiOauth": null}' > "$ACC/acct-01/.credentials.json"
|
|
717
|
+
rm -f "$ACC/acct-01/limits.json" "$ACC/acct-01/.oauth-refresh.json" "$ACC/acct-02/limits.json"
|
|
718
|
+
out="$(CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
719
|
+
rc=$?
|
|
720
|
+
[ "$rc" = "0" ] && t_ok "null claudeAiOauth exits 0 (fail open)" || t_fail "null claudeAiOauth rc" "rc=$rc: $out"
|
|
721
|
+
check "null claudeAiOauth degrades only that account" "acct-01: no fresh bearer" "$out"
|
|
722
|
+
[ -f "$ACC/acct-02/limits.json" ] && t_ok "accounts after a malformed one still refresh" \
|
|
723
|
+
|| t_fail "fail-open loop guarantee" "acct-02 was starved by acct-01's malformed creds"
|
|
724
|
+
cp "$WORK/acct01-creds.bak" "$ACC/acct-01/.credentials.json"
|
|
571
725
|
claude-accounts remove acct-05 --yes >/dev/null 2>&1
|
|
572
726
|
|
|
573
727
|
# ---- 16c. codex-review: security hardening -----------------------------------------
|