claude-multiacc 2.0.9 → 2.0.11
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/bin/claude +37 -15
- package/bin/claude-accounts +180 -14
- package/docs/ACCOUNT_OPERATIONS.md +2 -1
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +15 -2
- package/lib/common.sh +4 -2
- package/lib/report.py +8 -1
- package/package.json +1 -1
- package/tests/run-tests.sh +144 -1
package/bin/claude
CHANGED
|
@@ -559,17 +559,26 @@ client_auth_scan() { # $1 acct dir
|
|
|
559
559
|
return 1
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail
|
|
563
|
-
local marked
|
|
562
|
+
mark_proven_auth_dead() { # $1 acct dir, $2 fixed safe detail, $3 optional reason slug
|
|
563
|
+
local marked slug="${3:-auth-error}"
|
|
564
564
|
marked="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
565
|
-
{ echo "$now"; echo "reason
|
|
565
|
+
{ echo "$now"; echo "reason=$slug marked_at=$marked detail=$2"; } \
|
|
566
566
|
2>/dev/null > "$1/.expired.$$" && mv -f "$1/.expired.$$" "$1/.expired" 2>/dev/null \
|
|
567
567
|
|| rm -f "$1/.expired.$$" 2>/dev/null || true
|
|
568
568
|
}
|
|
569
569
|
|
|
570
570
|
mark_client_auth_dead() { # $1 acct dir
|
|
571
|
-
|
|
572
|
-
|
|
571
|
+
local tok
|
|
572
|
+
tok="$(acct_token "$1")"
|
|
573
|
+
if [ -n "$tok" ]; then
|
|
574
|
+
rm -f "$1/.server-token-verified" 2>/dev/null || true
|
|
575
|
+
mark_proven_auth_dead "$1" "client rejected the portable OAuth token" \
|
|
576
|
+
"setup-token-invalid"
|
|
577
|
+
sel_log "$(basename "$1") parked (setup-token-invalid) — client-reported"
|
|
578
|
+
else
|
|
579
|
+
mark_proven_auth_dead "$1" "client session failed to authenticate"
|
|
580
|
+
sel_log "$(basename "$1") parked (auth-error) — client-reported"
|
|
581
|
+
fi
|
|
573
582
|
}
|
|
574
583
|
|
|
575
584
|
mark_client_limit() { # $1 acct dir, $2 reset epoch, $3 rate limit type
|
|
@@ -750,8 +759,9 @@ token_preflight() { # $1 acct dir, $2 setup-token; rc 1 only for proven invalid
|
|
|
750
759
|
if [ "$rc" -ne 0 ] \
|
|
751
760
|
&& grep -qiE "$token_auth_pat" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
752
761
|
rm -f "$marker" 2>/dev/null || true
|
|
753
|
-
mark_proven_auth_dead "$d" "portable OAuth token rejected by inference preflight"
|
|
754
|
-
|
|
762
|
+
mark_proven_auth_dead "$d" "portable OAuth token rejected by inference preflight" \
|
|
763
|
+
"setup-token-invalid"
|
|
764
|
+
sel_log "$(basename "$d") parked (setup-token-invalid) — preflight returned 401"
|
|
755
765
|
rm -rf "$tmpd"
|
|
756
766
|
return 1
|
|
757
767
|
fi
|
|
@@ -1244,9 +1254,14 @@ while :; do
|
|
|
1244
1254
|
park_detail="the account's organization has disabled Claude Code subscription access"
|
|
1245
1255
|
park_soft=$((now + PARK_SOFT_ORG))
|
|
1246
1256
|
elif grep -qiE "$PARK_AUTH" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1257
|
+
if [ -n "$tok" ]; then
|
|
1258
|
+
park_reason="setup-token-invalid"
|
|
1259
|
+
park_detail="run rejected the portable OAuth token"
|
|
1260
|
+
else
|
|
1261
|
+
park_reason="auth-error"
|
|
1262
|
+
park_detail="run failed to authenticate"
|
|
1263
|
+
park_soft=$((now + PARK_SOFT_AUTH))
|
|
1264
|
+
fi
|
|
1250
1265
|
fi
|
|
1251
1266
|
# An account that ran out of ONE model has not run out. Cooling it down would
|
|
1252
1267
|
# take a perfectly usable account out of the pool for every other model too —
|
|
@@ -1254,13 +1269,20 @@ while :; do
|
|
|
1254
1269
|
model_scoped=0
|
|
1255
1270
|
grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null && model_scoped=1
|
|
1256
1271
|
if [ -n "$park_reason" ]; then
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1272
|
+
if [ "$park_reason" = "setup-token-invalid" ]; then
|
|
1273
|
+
rm -f "$cur/.server-token-verified" 2>/dev/null || true
|
|
1274
|
+
park_line="reason=$park_reason marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
1275
|
+
else
|
|
1276
|
+
park_line="reason=$park_reason soft_until=$park_soft marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) detail=$park_detail"
|
|
1277
|
+
fi
|
|
1278
|
+
{ echo "$now"; echo "$park_line"; } 2>/dev/null > "$cur/.expired.$$" \
|
|
1261
1279
|
&& mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
|
|
1262
1280
|
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
1263
|
-
|
|
1281
|
+
if [ "$park_reason" = "setup-token-invalid" ]; then
|
|
1282
|
+
sel_log "$(basename "$cur") parked ($park_reason, durable) — see: claude-accounts expired"
|
|
1283
|
+
else
|
|
1284
|
+
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
|
|
1285
|
+
fi
|
|
1264
1286
|
elif [ "$model_scoped" = 0 ]; then
|
|
1265
1287
|
{
|
|
1266
1288
|
echo $((now + 600))
|
package/bin/claude-accounts
CHANGED
|
@@ -227,6 +227,8 @@ for a in accounts:
|
|
|
227
227
|
flags = []
|
|
228
228
|
if st['state'] == 'expired':
|
|
229
229
|
flags.append('EXPIRED-LOGIN')
|
|
230
|
+
elif st['state'] == 'token-invalid':
|
|
231
|
+
flags.append('INVALID-TOKEN')
|
|
230
232
|
elif st['state'] == 'blocked':
|
|
231
233
|
flags.append('ORG-BLOCKED')
|
|
232
234
|
elif st['state'] == 'missing':
|
|
@@ -239,7 +241,7 @@ for a in accounts:
|
|
|
239
241
|
f"auth={'+'.join(auth) or 'NONE':<11} {' '.join(flags)}")
|
|
240
242
|
bad = [a for a in accounts
|
|
241
243
|
if audit_account(root, a, machine=machine)['state']
|
|
242
|
-
in ('expired', 'blocked', 'missing')]
|
|
244
|
+
in ('expired', 'token-invalid', 'blocked', 'missing')]
|
|
243
245
|
if bad:
|
|
244
246
|
print()
|
|
245
247
|
print(f"{len(bad)} account(s) are NOT usable — details: claude-accounts expired")
|
|
@@ -317,13 +319,15 @@ for a in doc.get('accounts', []):
|
|
|
317
319
|
aid = a['id']
|
|
318
320
|
d = os.path.join(root, aid)
|
|
319
321
|
st = audit_account(root, a, machine=machine)
|
|
320
|
-
if st['state'] in ('expired', 'blocked', 'missing'):
|
|
322
|
+
if st['state'] in ('expired', 'token-invalid', 'blocked', 'missing'):
|
|
321
323
|
needs_login.append(aid)
|
|
322
324
|
else:
|
|
323
325
|
selectable_ids.append(aid)
|
|
324
326
|
banner = {'ok': '', 'remote': ' [not logged in here — grant lives elsewhere]',
|
|
325
327
|
'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
|
|
326
328
|
'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
|
|
329
|
+
'token-invalid': (' ** SETUP-TOKEN INVALID — '
|
|
330
|
+
'claude-accounts login %s --token **' % aid),
|
|
327
331
|
'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
|
|
328
332
|
'locked': ' [login is in the macOS Keychain — locked for THIS session, fine from the Mac itself]',
|
|
329
333
|
}[st['state']]
|
|
@@ -504,8 +508,7 @@ cmd_add() {
|
|
|
504
508
|
# Portable setup-token variant (works on Mac AND server; needs a recent sign-in).
|
|
505
509
|
echo "Preparing $id for $elabel (portable token) — NOTHING is registered until sign-in completes."
|
|
506
510
|
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing was created"
|
|
507
|
-
|
|
508
|
-
chmod 600 "$d/server.token"
|
|
511
|
+
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
|
|
509
512
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
510
513
|
else
|
|
511
514
|
# Default: full Claude Code login (full scopes, no long-lived-token step-up).
|
|
@@ -567,7 +570,9 @@ EOF
|
|
|
567
570
|
|
|
568
571
|
add_cleanup_reserved() {
|
|
569
572
|
# EXIT trap for cmd_add: remove a reserved-but-uncommitted account dir (and, in case
|
|
570
|
-
# we died mid-critical-section, release the lock).
|
|
573
|
+
# we died mid-critical-section, release the lock). A ceremony interrupted mid-way
|
|
574
|
+
# must also hand the terminal back at its own width.
|
|
575
|
+
restore_ceremony_tty 2>/dev/null || true
|
|
571
576
|
[ -n "${RESERVED_DIR:-}" ] && rm -rf "$RESERVED_DIR" 2>/dev/null
|
|
572
577
|
mutate_unlock 2>/dev/null || true
|
|
573
578
|
}
|
|
@@ -790,6 +795,137 @@ valid_subscription_token() {
|
|
|
790
795
|
esac
|
|
791
796
|
}
|
|
792
797
|
|
|
798
|
+
# A complete subscription setup-token is 108 characters (sk-ant-oat01- + 95). The
|
|
799
|
+
# ceremony scrapes it from a terminal transcript, and the client's TUI HARD-WRAPS at
|
|
800
|
+
# the pty's width — an unsized pty (the panel's) renders as 80 columns. Every
|
|
801
|
+
# panel-driven mint of 2026-08-28 therefore saved the first 79 characters of a
|
|
802
|
+
# 108-character token; the fleet rejected all seven with 401 while the minting Mac,
|
|
803
|
+
# still holding its OAuth login, looked healthy. Two guards, belt and braces: the
|
|
804
|
+
# terminal is widened for the ceremony, and the capture must pass a real inference
|
|
805
|
+
# before it is saved anywhere.
|
|
806
|
+
SETUP_TOKEN_FULL_LEN=108
|
|
807
|
+
CEREMONY_TTY_MIN_COLS=160
|
|
808
|
+
CEREMONY_TTY_COLS=400
|
|
809
|
+
CEREMONY_TTY_ORIG=""
|
|
810
|
+
|
|
811
|
+
widen_ceremony_tty() {
|
|
812
|
+
CEREMONY_TTY_ORIG=""
|
|
813
|
+
[ -t 0 ] || return 0
|
|
814
|
+
local size rows cols
|
|
815
|
+
size="$(stty size 2>/dev/null)" || return 0
|
|
816
|
+
rows="${size%% *}"; cols="${size##* }"
|
|
817
|
+
case "$cols" in ''|*[!0-9]*) return 0 ;; esac
|
|
818
|
+
case "$rows" in ''|*[!0-9]*) rows=0 ;; esac
|
|
819
|
+
[ "$cols" -ge "$CEREMONY_TTY_MIN_COLS" ] && return 0
|
|
820
|
+
CEREMONY_TTY_ORIG="$rows $cols"
|
|
821
|
+
# `script` copies stdin's window size to the pty it opens for the client, so
|
|
822
|
+
# widening here is what the client sees. A zero row count is an unsized pty too.
|
|
823
|
+
stty cols "$CEREMONY_TTY_COLS" rows "$([ "$rows" -gt 0 ] && echo "$rows" || echo 50)" 2>/dev/null \
|
|
824
|
+
|| CEREMONY_TTY_ORIG=""
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
restore_ceremony_tty() {
|
|
828
|
+
[ -n "$CEREMONY_TTY_ORIG" ] || return 0
|
|
829
|
+
stty rows "${CEREMONY_TTY_ORIG%% *}" cols "${CEREMONY_TTY_ORIG##* }" 2>/dev/null || true
|
|
830
|
+
CEREMONY_TTY_ORIG=""
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
token_digest() { # $1 token; prints a non-secret sha256 digest (same as the shim's)
|
|
834
|
+
local h=""
|
|
835
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
836
|
+
h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
|
|
837
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
838
|
+
h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"
|
|
839
|
+
fi
|
|
840
|
+
printf '%s' "$h" | cut -d ' ' -f1
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
record_token_verified() { # $1 acct dir, $2 token — the shim's own proof marker (token_preflight)
|
|
844
|
+
local digest
|
|
845
|
+
digest="$(token_digest "$2")"
|
|
846
|
+
[ -n "$digest" ] || return 0
|
|
847
|
+
{ umask 077; printf '%s\n' "$digest" > "$1/.server-token-verified.$$"; } 2>/dev/null \
|
|
848
|
+
&& mv -f "$1/.server-token-verified.$$" "$1/.server-token-verified" 2>/dev/null \
|
|
849
|
+
|| rm -f "$1/.server-token-verified.$$" 2>/dev/null || true
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
# Prove a captured token with ONE real inference before it is saved. rc 0 = Claude
|
|
853
|
+
# answered with it; rc 1 = Claude REJECTED it (401: revoked, wrong account's grant,
|
|
854
|
+
# or captured incomplete); rc 2 = inconclusive (network, 429, timeout). The probe runs
|
|
855
|
+
# in an EMPTY config dir: the account dir may hold an OAuth login the client would
|
|
856
|
+
# silently prefer, and this must exercise the captured token and nothing else. The
|
|
857
|
+
# token travels by environment, never argv.
|
|
858
|
+
CEREMONY_CHECK_DETAIL=""
|
|
859
|
+
ceremony_probe() { # $1 = real claude, $2 = empty config dir; token in CEREMONY_TOKEN_UNDER_TEST
|
|
860
|
+
"$PYBIN" - "$1" "$2" "$ACC_ROOT" <<'PYEOF'
|
|
861
|
+
import os, re, subprocess, sys
|
|
862
|
+
real, cfg, root = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
863
|
+
env = {k: v for k, v in os.environ.items()
|
|
864
|
+
if k not in ('ANTHROPIC_API_KEY', 'CLAUDE_ACCOUNT', 'CEREMONY_TOKEN_UNDER_TEST')}
|
|
865
|
+
env['CLAUDE_CONFIG_DIR'] = cfg
|
|
866
|
+
env['CLAUDE_CODE_OAUTH_TOKEN'] = os.environ['CEREMONY_TOKEN_UNDER_TEST']
|
|
867
|
+
env['CLAUDE_SHIM_ACTIVE'] = '1'
|
|
868
|
+
# The shim's own vocabulary for a rejected token (bin/claude token_preflight).
|
|
869
|
+
AUTH = re.compile(r'failed to authenticate|oauth (access )?token is invalid|oauth session expired'
|
|
870
|
+
r'|please run /login|invalid bearer token|authentication_error|\b401\b', re.I)
|
|
871
|
+
try:
|
|
872
|
+
r = subprocess.run([real, '-p', '--output-format', 'text', '--max-turns', '1'],
|
|
873
|
+
env=env, capture_output=True, text=True, timeout=180,
|
|
874
|
+
input='Reply with exactly: OK\n', cwd=root)
|
|
875
|
+
except subprocess.TimeoutExpired:
|
|
876
|
+
print('inconclusive\ttimed out after 180s'); sys.exit(0)
|
|
877
|
+
except OSError as exc:
|
|
878
|
+
print(f'inconclusive\t{exc}'); sys.exit(0)
|
|
879
|
+
out, err = (r.stdout or '').strip(), (r.stderr or '').strip()
|
|
880
|
+
if r.returncode == 0 and 'ok' in out.lower():
|
|
881
|
+
print('ok\t'); sys.exit(0)
|
|
882
|
+
if AUTH.search(out) or AUTH.search(err):
|
|
883
|
+
print('rejected\t' + (err or out)[:160].replace('\n', ' ')); sys.exit(0)
|
|
884
|
+
print('inconclusive\t' + f'rc={r.returncode} ' + (err or out)[:160].replace('\n', ' '))
|
|
885
|
+
PYEOF
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
ceremony_token_check() { # $1 = token
|
|
889
|
+
local tok="$1" real tmpd verdict
|
|
890
|
+
CEREMONY_CHECK_DETAIL=""
|
|
891
|
+
real="$(find_real_claude "$_self")" || { CEREMONY_CHECK_DETAIL="real claude binary not found"; return 2; }
|
|
892
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
893
|
+
tmpd="$(mktemp -d "$ACC_ROOT/tmp/mint-check.XXXXXX" 2>/dev/null)" \
|
|
894
|
+
|| { CEREMONY_CHECK_DETAIL="cannot create a scratch config dir"; return 2; }
|
|
895
|
+
chmod 700 "$tmpd" 2>/dev/null || true
|
|
896
|
+
verdict="$(CEREMONY_TOKEN_UNDER_TEST="$tok" ceremony_probe "$real" "$tmpd")"
|
|
897
|
+
rm -rf "$tmpd" 2>/dev/null || true
|
|
898
|
+
case "$verdict" in
|
|
899
|
+
ok*) return 0 ;;
|
|
900
|
+
rejected*) CEREMONY_CHECK_DETAIL="${verdict#rejected }"; return 1 ;;
|
|
901
|
+
inconclusive*) CEREMONY_CHECK_DETAIL="${verdict#inconclusive }"; return 2 ;;
|
|
902
|
+
*) CEREMONY_CHECK_DETAIL="no verdict"; return 2 ;;
|
|
903
|
+
esac
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
# The ONE place a ceremony's token is written: only after ceremony_token_check, so a
|
|
907
|
+
# token Claude rejects is never saved, uploaded, or distributed. $1 = acct dir,
|
|
908
|
+
# $2 = token, $3 = acct id (messages only).
|
|
909
|
+
commit_ceremony_token() {
|
|
910
|
+
local d="$1" tok="$2" id="$3" rc=0
|
|
911
|
+
ceremony_token_check "$tok" || rc=$?
|
|
912
|
+
if [ "$rc" -eq 1 ]; then
|
|
913
|
+
if [ "${#tok}" -lt "$SETUP_TOKEN_FULL_LEN" ]; then
|
|
914
|
+
die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — it is ${#tok} characters and a complete setup-token is $SETUP_TOKEN_FULL_LEN: the sign-in terminal wrapped it and only its first line was captured. Nothing saved for $id. Mint again from a terminal at least $CEREMONY_TTY_MIN_COLS columns wide, or from an updated panel."
|
|
915
|
+
fi
|
|
916
|
+
die "Claude rejected the captured token (${CEREMONY_CHECK_DETAIL:-401}) — nothing saved for $id; sign in again as the right account and retry"
|
|
917
|
+
fi
|
|
918
|
+
( umask 077; printf '%s' "$tok" > "$d/server.token" )
|
|
919
|
+
chmod 600 "$d/server.token"
|
|
920
|
+
if [ "$rc" -eq 0 ]; then
|
|
921
|
+
record_token_verified "$d" "$tok"
|
|
922
|
+
echo "Token verified by a real inference."
|
|
923
|
+
else
|
|
924
|
+
rm -f "$d/.server-token-verified" 2>/dev/null || true
|
|
925
|
+
warn "the token could not be verified right now (${CEREMONY_CHECK_DETAIL:-no answer}) — saved as UNVERIFIED; the shim proves it on first use, or run: claude-accounts verify"
|
|
926
|
+
fi
|
|
927
|
+
}
|
|
928
|
+
|
|
793
929
|
CEREMONY_TOKEN=""
|
|
794
930
|
run_token_ceremony() { # $1 = config dir
|
|
795
931
|
CEREMONY_TOKEN=""
|
|
@@ -813,11 +949,16 @@ If you do see "Sign in again to continue", that is Claude's security step, not a
|
|
|
813
949
|
error — just sign in to that account and approve; the code still appears.
|
|
814
950
|
TIP
|
|
815
951
|
if [ -t 0 ]; then
|
|
952
|
+
# The client's TUI hard-wraps at the pty's width, and the token is scraped from
|
|
953
|
+
# the transcript below — see SETUP_TOKEN_FULL_LEN for the 79-character tokens
|
|
954
|
+
# this produced. `script` copies the (widened) window size to the client's pty.
|
|
955
|
+
widen_ceremony_tty
|
|
816
956
|
if [ "$(machine_kind)" = "mac" ]; then
|
|
817
957
|
script -q "$cap" env CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" setup-token
|
|
818
958
|
else
|
|
819
959
|
script -q -c "CLAUDE_CONFIG_DIR='$d' CLAUDE_SHIM_ACTIVE=1 '$real' setup-token" "$cap"
|
|
820
960
|
fi
|
|
961
|
+
restore_ceremony_tty
|
|
821
962
|
else
|
|
822
963
|
# Headless (tests / piped code): capture into the 0600 file only. Never tee the
|
|
823
964
|
# raw token to stdout — a redirected run would write the secret to a plain log.
|
|
@@ -825,7 +966,10 @@ TIP
|
|
|
825
966
|
sed -E 's/sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]*/sk-ant-oat**-<redacted>/g' "$cap"
|
|
826
967
|
fi
|
|
827
968
|
umask "$old_umask"
|
|
828
|
-
|
|
969
|
+
# The LONGEST match, not the last: a TUI repaints its frame many times, and a
|
|
970
|
+
# frame rendered while the terminal was still narrow holds a wrapped fragment.
|
|
971
|
+
CEREMONY_TOKEN="$(grep -aoE 'sk-ant-oat[0-9]{2}-[A-Za-z0-9_-]{40,}' "$cap" \
|
|
972
|
+
| awk '{ if (length($0) > length(best)) best = $0 } END { if (best != "") print best }')"
|
|
829
973
|
rm -f "$cap"
|
|
830
974
|
[ -n "$CEREMONY_TOKEN" ] || return 1
|
|
831
975
|
valid_subscription_token "$CEREMONY_TOKEN" || {
|
|
@@ -937,6 +1081,8 @@ cmd_mint() {
|
|
|
937
1081
|
local email tok="" got
|
|
938
1082
|
email="$(account_email "$id")"
|
|
939
1083
|
[ -n "$email" ] || die "$id has no email in the manifest — refusing to mint a token nobody could attribute"
|
|
1084
|
+
trap 'restore_ceremony_tty; exit 130' INT TERM
|
|
1085
|
+
trap 'restore_ceremony_tty' EXIT
|
|
940
1086
|
if [ "$paste" = "1" ]; then
|
|
941
1087
|
printf 'Paste the sk-ant-oat... token for %s (%s): ' "$id" "${email:-unknown email}"
|
|
942
1088
|
read -r tok
|
|
@@ -953,8 +1099,7 @@ cmd_mint() {
|
|
|
953
1099
|
if [ -n "$got" ] && [ -n "$email" ] && [ "$got" != "$email" ]; then
|
|
954
1100
|
die "that token authenticates as $got but $id is $email — nothing saved"
|
|
955
1101
|
fi
|
|
956
|
-
|
|
957
|
-
chmod 600 "$d/server.token"
|
|
1102
|
+
commit_ceremony_token "$d" "$tok" "$id"
|
|
958
1103
|
clear_auth_markers "$d"
|
|
959
1104
|
log_to ops.log "mint $id"
|
|
960
1105
|
echo "Token saved to $d/server.token"
|
|
@@ -984,13 +1129,14 @@ cmd_login() {
|
|
|
984
1129
|
email="$(account_email "$id")"
|
|
985
1130
|
echo "Sign in as $email for $id."
|
|
986
1131
|
if [ "$token" = "1" ]; then
|
|
1132
|
+
trap 'restore_ceremony_tty; exit 130' INT TERM
|
|
1133
|
+
trap 'restore_ceremony_tty' EXIT
|
|
987
1134
|
run_token_ceremony "$d" || die "sign-in failed or aborted — nothing changed"
|
|
988
1135
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
989
1136
|
if [ -n "$got" ] && [ "$got" != "$email" ] && [ "$force" != "1" ]; then
|
|
990
1137
|
die "you signed in as $got but $id is $email — nothing saved (use --force to override)"
|
|
991
1138
|
fi
|
|
992
|
-
|
|
993
|
-
chmod 600 "$d/server.token"
|
|
1139
|
+
commit_ceremony_token "$d" "$CEREMONY_TOKEN" "$id"
|
|
994
1140
|
clear_auth_markers "$d"
|
|
995
1141
|
[ -n "$got" ] || warn "a setup token carries no identity — $id is trusted to hold $email because that is who you approved as"
|
|
996
1142
|
echo "$id token saved (portable — works on Mac and server)."
|
|
@@ -1040,14 +1186,17 @@ cmd_expired() {
|
|
|
1040
1186
|
die "the manifest lists accounts but none could be audited — check $MANIFEST"
|
|
1041
1187
|
fi
|
|
1042
1188
|
bad="$(printf '%s\n' "$rows" \
|
|
1043
|
-
| awk -F'\t' '
|
|
1189
|
+
| awk -F'\t' '
|
|
1190
|
+
NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing|unverified)$/ {
|
|
1191
|
+
print $1
|
|
1192
|
+
}')"
|
|
1044
1193
|
if [ "$quiet" = "1" ]; then
|
|
1045
1194
|
[ -n "$bad" ] || return 0
|
|
1046
1195
|
printf '%s\n' "$bad"
|
|
1047
1196
|
return 1
|
|
1048
1197
|
fi
|
|
1049
1198
|
printf '%s\n' "$rows" | awk -F'\t' '
|
|
1050
|
-
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; unverified = 0 }
|
|
1199
|
+
BEGIN { bad = 0; ok = 0; remote = 0; relogin = 0; retoken = 0; unverified = 0 }
|
|
1051
1200
|
NF < 4 || $1 == "" { next } # never invent an account from a blank line
|
|
1052
1201
|
$4 == "ok" { ok++; next }
|
|
1053
1202
|
$4 == "remote" { remote++; rem = rem sprintf(" %-9s %-28s %s\n", $1, $2, $6); next }
|
|
@@ -1058,6 +1207,13 @@ cmd_expired() {
|
|
|
1058
1207
|
printf " %-9s %-28s %-9s fix: %s\n", "", "", "", $7
|
|
1059
1208
|
next
|
|
1060
1209
|
}
|
|
1210
|
+
$4 == "token-invalid" {
|
|
1211
|
+
bad++
|
|
1212
|
+
retoken++
|
|
1213
|
+
printf " %-9s %-28s %-13s %s\n", $1, $2, $5, $6
|
|
1214
|
+
printf " %-9s %-28s %-13s fix: %s\n", "", "", "", $7
|
|
1215
|
+
next
|
|
1216
|
+
}
|
|
1061
1217
|
{
|
|
1062
1218
|
bad++
|
|
1063
1219
|
relogin++
|
|
@@ -1075,6 +1231,10 @@ cmd_expired() {
|
|
|
1075
1231
|
printf "\nVerify portable tokens:\n"
|
|
1076
1232
|
printf " claude-accounts verify\n"
|
|
1077
1233
|
}
|
|
1234
|
+
if (retoken > 0) {
|
|
1235
|
+
printf "\nReplace invalid portable setup-tokens:\n"
|
|
1236
|
+
printf " claude-accounts login acct-NN --token\n"
|
|
1237
|
+
}
|
|
1078
1238
|
if (relogin > 0) {
|
|
1079
1239
|
printf "\nRe-authenticate them:\n"
|
|
1080
1240
|
printf " claude-accounts relogin # every account that needs it\n"
|
|
@@ -2175,8 +2335,14 @@ for acct in manifest.get('accounts', []):
|
|
|
2175
2335
|
os.remove(os.path.join(d, '.server-token-verified'))
|
|
2176
2336
|
except OSError:
|
|
2177
2337
|
pass
|
|
2178
|
-
|
|
2179
|
-
|
|
2338
|
+
if uses_token:
|
|
2339
|
+
mark_expired(d, 'setup-token-invalid',
|
|
2340
|
+
'portable setup-token failed a real inference')
|
|
2341
|
+
hint = (f' — portable setup-token is invalid, run: '
|
|
2342
|
+
f'claude-accounts login {aid} --token')
|
|
2343
|
+
else:
|
|
2344
|
+
mark_expired(d, 'auth-error', 'a real call came back not-authenticated')
|
|
2345
|
+
hint = f' — login is dead, run: claude-accounts relogin {aid}'
|
|
2180
2346
|
print(f'{aid} {acct["email"]}: FAIL rc={r.returncode} '
|
|
2181
2347
|
f'out={out[:120]!r} err={err!r}{hint}')
|
|
2182
2348
|
failures += 1
|
|
@@ -215,7 +215,8 @@ providers (`lib/report.py`), so a consumer writes one parser:
|
|
|
215
215
|
"accounts": [{
|
|
216
216
|
"id": "acct-05", "email": "…", "home": "mac", "added_at": "…Z",
|
|
217
217
|
"home_dir": "/Users/gas/.claude-accounts/acct-05", "adopted": false,
|
|
218
|
-
"status": "active", // active|limited|expired|blocked
|
|
218
|
+
"status": "active", // active|limited|expired|token-invalid|blocked
|
|
219
|
+
// missing|remote
|
|
219
220
|
"state": "ok", "label": "OK", "reason": "…", "fix": "…",
|
|
220
221
|
"selectable": true, "needs_login": false,
|
|
221
222
|
"credential_class": "portable", // portable|machine-local|none
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/audit.py
CHANGED
|
@@ -5,6 +5,7 @@ shim's selection rule (bin/claude: creds_dead / expired_marked / auth_dead).
|
|
|
5
5
|
States:
|
|
6
6
|
ok usable right now (live OAuth credential, or a portable server.token)
|
|
7
7
|
expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
|
|
8
|
+
token-invalid a portable setup-token was rejected — needs a new setup-token, not a login
|
|
8
9
|
blocked authenticates, but its organization has disabled Claude Code subscription
|
|
9
10
|
access — a re-login cannot fix it, so it needs an admin (or removal)
|
|
10
11
|
locked the OAuth login is in the macOS Keychain and THIS session cannot open it
|
|
@@ -193,8 +194,9 @@ def oauth_login(d, now):
|
|
|
193
194
|
return {'store': None, 'state': None, 'reason': '', 'doc': None}
|
|
194
195
|
|
|
195
196
|
|
|
196
|
-
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', '
|
|
197
|
-
'
|
|
197
|
+
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'token-invalid': 'TOKEN INVALID',
|
|
198
|
+
'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED', 'missing': 'NO LOGIN',
|
|
199
|
+
'remote': 'ELSEWHERE', 'unverified': 'UNVERIFIED'}
|
|
198
200
|
|
|
199
201
|
# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
|
|
200
202
|
# two vocabularies — normalize, or an un-authenticated account on the very machine that
|
|
@@ -216,6 +218,8 @@ def _elsewhere(home, machine):
|
|
|
216
218
|
|
|
217
219
|
|
|
218
220
|
def _fix_for(state, aid):
|
|
221
|
+
if state == 'token-invalid':
|
|
222
|
+
return f'claude-accounts login {aid} --token'
|
|
219
223
|
if state == 'blocked':
|
|
220
224
|
# A fresh sign-in re-issues the grant and in practice clears this, so it is
|
|
221
225
|
# handled like any other dead login. If it comes back BLOCKED after a re-login,
|
|
@@ -244,6 +248,12 @@ def _token_verified(d, tpath):
|
|
|
244
248
|
return False
|
|
245
249
|
|
|
246
250
|
|
|
251
|
+
# The report (`list --json`) tells the panel the same thing per Mac: a Mac reads
|
|
252
|
+
# "active" for any token FILE it can see, and only this says whether a real call
|
|
253
|
+
# ever succeeded with it here.
|
|
254
|
+
token_verified = _token_verified
|
|
255
|
+
|
|
256
|
+
|
|
247
257
|
def audit_account(root, acct, now=None, machine=None, require_verified_token=False):
|
|
248
258
|
now = time.time() if now is None else now
|
|
249
259
|
aid = acct.get('id', '')
|
|
@@ -266,6 +276,9 @@ def audit_account(root, acct, now=None, machine=None, require_verified_token=Fal
|
|
|
266
276
|
row['state'] = 'blocked'
|
|
267
277
|
row['reason'] = ('this account\'s organization has disabled Claude Code '
|
|
268
278
|
'subscription access')
|
|
279
|
+
elif slug == 'setup-token-invalid':
|
|
280
|
+
row['state'] = 'token-invalid'
|
|
281
|
+
row['reason'] = 'portable setup-token was rejected by a real inference'
|
|
269
282
|
else:
|
|
270
283
|
row['state'] = 'expired'
|
|
271
284
|
row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
|
package/lib/common.sh
CHANGED
|
@@ -495,7 +495,8 @@ clear_auth_markers() { # $1 = acct dir
|
|
|
495
495
|
rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
# TSV rows: id, email, home, state
|
|
498
|
+
# TSV rows: id, email, home, state
|
|
499
|
+
# (ok|expired|token-invalid|blocked|missing|remote|locked|unverified), label, reason, fix.
|
|
499
500
|
# Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
|
|
500
501
|
# silently empty audit reads exactly like a perfectly healthy pool.
|
|
501
502
|
account_audit() {
|
|
@@ -518,7 +519,8 @@ account_audit() {
|
|
|
518
519
|
# Ids the shim will NOT select — everything a human has to look at.
|
|
519
520
|
accounts_unusable() {
|
|
520
521
|
account_audit \
|
|
521
|
-
| awk -F'\t'
|
|
522
|
+
| awk -F'\t' \
|
|
523
|
+
'NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing)$/ { print $1 }'
|
|
522
524
|
}
|
|
523
525
|
|
|
524
526
|
# Ids a re-login should be pointed at. Org-blocked accounts are included: a fresh
|
package/lib/report.py
CHANGED
|
@@ -46,6 +46,7 @@ import time
|
|
|
46
46
|
|
|
47
47
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
48
48
|
import keychain # noqa: E402
|
|
49
|
+
from audit import token_verified # noqa: E402
|
|
49
50
|
|
|
50
51
|
SCHEMA = 'claude-multiacc/pool.v1'
|
|
51
52
|
|
|
@@ -172,7 +173,10 @@ def _claude_credentials(d):
|
|
|
172
173
|
has_token = _size(tpath) > 0
|
|
173
174
|
detail = {'oauth': has_oauth, 'token': has_token, 'oauth_store': None, 'keychain': None,
|
|
174
175
|
'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
|
|
175
|
-
'token_minted_at': None, 'token_age_days': None
|
|
176
|
+
'token_minted_at': None, 'token_age_days': None,
|
|
177
|
+
# True once THIS exact token passed a real inference on this machine
|
|
178
|
+
# (.server-token-verified); False = present but never proven here.
|
|
179
|
+
'token_verified': token_verified(d, tpath) if has_token else None}
|
|
176
180
|
o = None
|
|
177
181
|
if has_oauth:
|
|
178
182
|
detail['oauth_store'] = 'file'
|
|
@@ -347,6 +351,9 @@ def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind
|
|
|
347
351
|
'credential_class': cclass,
|
|
348
352
|
'portable': cclass == 'portable',
|
|
349
353
|
'credentials': cdetail,
|
|
354
|
+
# Flat copy for heartbeat consumers: a portable token this Mac has proven
|
|
355
|
+
# (True), holds unproven (False), or does not hold (None).
|
|
356
|
+
'token_verified': cdetail.get('token_verified') if provider == 'claude' else None,
|
|
350
357
|
'limited': bool(limited),
|
|
351
358
|
'limit_reset_at': _iso(reset_epoch) if limited and reset_epoch else None,
|
|
352
359
|
'limit_reset_epoch': reset_epoch if limited and reset_epoch else None,
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -74,6 +74,19 @@ fi
|
|
|
74
74
|
if [ "${1:-}" = "setup-token" ]; then
|
|
75
75
|
echo "Open this sign-in link: https://claude.ai/oauth/authorize?fake=1"
|
|
76
76
|
[ -n "${FAKE_TOKEN_FAIL:-}" ] && { echo "sign-in aborted" >&2; exit 1; }
|
|
77
|
+
if [ -n "${FAKE_TOKEN_FULL:-}" ]; then
|
|
78
|
+
# The real client's shape: a 108-character token that the TUI HARD-WRAPS at the
|
|
79
|
+
# terminal's width — an unsized pty (0 columns) renders as 80, and a 79-character
|
|
80
|
+
# first line is exactly what every panel mint of 2026-08-28 saved.
|
|
81
|
+
tok="sk-ant-oat01-$(printf '%66s' '' | tr ' ' W)$(printf '%23s' '' | tr ' ' T)TAILOK"
|
|
82
|
+
cols="$(stty size 2>/dev/null | awk '{print $2}')"
|
|
83
|
+
if [ -n "${FAKE_TOKEN_WRAP:-}" ] || { [ -n "$cols" ] && [ "$cols" -lt 108 ]; }; then
|
|
84
|
+
printf ' %s\n%s\n' "${tok:0:79}" "${tok:79}"
|
|
85
|
+
else
|
|
86
|
+
printf ' %s\n' "$tok"
|
|
87
|
+
fi
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
77
90
|
echo "Your token: sk-ant-oat01-FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"
|
|
78
91
|
exit 0
|
|
79
92
|
fi
|
|
@@ -88,6 +101,13 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
|
88
101
|
*REVOKED*)
|
|
89
102
|
echo "Please run /login · API Error: 401 OAuth access token is invalid." >&2
|
|
90
103
|
exit 1 ;;
|
|
104
|
+
sk-ant-oat01-WWW*)
|
|
105
|
+
# The wrapped fixture above: only the COMPLETE token (with its tail) authenticates;
|
|
106
|
+
# its 79-character first line is what Claude answered 401 to, fleet-wide.
|
|
107
|
+
case "$CLAUDE_CODE_OAUTH_TOKEN" in
|
|
108
|
+
*TAILOK) : ;;
|
|
109
|
+
*) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
|
|
110
|
+
esac ;;
|
|
91
111
|
esac
|
|
92
112
|
case " $* " in
|
|
93
113
|
*" -p --output-format text --max-turns 1 "*) echo "OK"; exit 0 ;;
|
|
@@ -515,7 +535,7 @@ case "$out" in
|
|
|
515
535
|
*"API Error: 401"*) t_fail "revoked setup-token hides preflight 401" "401 reached caller" ;;
|
|
516
536
|
*) t_ok "revoked setup-token hides preflight 401" ;;
|
|
517
537
|
esac
|
|
518
|
-
grep -q 'reason=
|
|
538
|
+
grep -q 'reason=setup-token-invalid' "$ACC/acct-01/.expired" 2>/dev/null \
|
|
519
539
|
&& t_ok "revoked setup-token writes .expired" \
|
|
520
540
|
|| t_fail "revoked setup-token marker" ".expired missing"
|
|
521
541
|
if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
|
|
@@ -523,6 +543,37 @@ if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
|
|
|
523
543
|
else
|
|
524
544
|
t_ok "proven token rejection is durable"
|
|
525
545
|
fi
|
|
546
|
+
out="$(claude-accounts expired 2>&1)"
|
|
547
|
+
check "revoked setup-token is not mislabeled as a dead login" "TOKEN INVALID" "$out"
|
|
548
|
+
check "revoked setup-token gets the token replacement fix" \
|
|
549
|
+
"claude-accounts login acct-01 --token" "$out"
|
|
550
|
+
case "$out" in
|
|
551
|
+
*"claude-accounts relogin acct-01"*)
|
|
552
|
+
t_fail "revoked setup-token does not recommend re-login" "wrong recovery shown" ;;
|
|
553
|
+
*) t_ok "revoked setup-token does not recommend re-login" ;;
|
|
554
|
+
esac
|
|
555
|
+
# A token can be revoked after it passed preflight. The captured -p path must retire
|
|
556
|
+
# that exact verified token too, rather than giving it a one-hour generic login park.
|
|
557
|
+
rm -f "$ACC/acct-01/.expired"
|
|
558
|
+
token_digest="$(printf '%s' 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' \
|
|
559
|
+
| { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)"
|
|
560
|
+
printf '%s\n' "$token_digest" > "$ACC/acct-01/.server-token-verified"
|
|
561
|
+
out="$(claude -p 'Reply OK' < /dev/null 2>&1)"
|
|
562
|
+
case "$out" in
|
|
563
|
+
*"API Error: 401"*) t_fail "revoked verified token hides runtime 401" "401 reached caller" ;;
|
|
564
|
+
*) t_ok "revoked verified token hides runtime 401" ;;
|
|
565
|
+
esac
|
|
566
|
+
grep -q 'reason=setup-token-invalid' "$ACC/acct-01/.expired" 2>/dev/null \
|
|
567
|
+
&& t_ok "revoked verified token keeps token classification" \
|
|
568
|
+
|| t_fail "revoked verified token marker" ".expired missing"
|
|
569
|
+
[ ! -f "$ACC/acct-01/.server-token-verified" ] \
|
|
570
|
+
&& t_ok "revoked verified token clears its proof" \
|
|
571
|
+
|| t_fail "revoked verified token proof" "verification marker survived"
|
|
572
|
+
if grep -q 'soft_until=' "$ACC/acct-01/.expired" 2>/dev/null; then
|
|
573
|
+
t_fail "revoked verified token is durably quarantined" "marker has a soft expiry"
|
|
574
|
+
else
|
|
575
|
+
t_ok "revoked verified token is durably quarantined"
|
|
576
|
+
fi
|
|
526
577
|
out="$(CLAUDE_ACCOUNT=acct-01 claude --resume d6ccbac0-6643-4780-a99e-3afa1683478e 2>&1)"
|
|
527
578
|
rc=$?
|
|
528
579
|
check "pinned revoked setup-token is classified before --resume" \
|
|
@@ -1232,6 +1283,86 @@ esac
|
|
|
1232
1283
|
[ -s "$ACC/acct-04/server.token" ] && t_ok "add --token writes server.token" || t_fail "add --token" "missing"
|
|
1233
1284
|
out="$(CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
1234
1285
|
check "token account exports CLAUDE_CODE_OAUTH_TOKEN" "TOK=sk-ant-oat01-FAKE" "$out"
|
|
1286
|
+
|
|
1287
|
+
# ---- 15c1a. a ceremony's token is PROVEN before it is saved --------------------------
|
|
1288
|
+
# add --token above ran the inference probe: the exact token is on record as verified
|
|
1289
|
+
# on this machine, so the audit never calls a fresh mint UNVERIFIED — and the panel
|
|
1290
|
+
# can tell a proven token from one a Mac merely holds.
|
|
1291
|
+
[ -f "$ACC/acct-04/.server-token-verified" ] \
|
|
1292
|
+
&& t_ok "add --token proves the token with a real inference before saving" \
|
|
1293
|
+
|| t_fail "add --token verification marker" ".server-token-verified missing"
|
|
1294
|
+
out="$(claude-accounts list --json 2>/dev/null | python3 -c '
|
|
1295
|
+
import json, sys
|
|
1296
|
+
rows = {a["id"]: a for a in json.load(sys.stdin)["accounts"]}
|
|
1297
|
+
print(rows["acct-04"].get("token_verified"))')"
|
|
1298
|
+
[ "$out" = "True" ] && t_ok "list --json reports the token as verified here" \
|
|
1299
|
+
|| t_fail "list --json token_verified" "expected True, got: $out"
|
|
1300
|
+
# The 2026-08-28 shape: the client's TUI wrapped the 108-character token at 80 columns
|
|
1301
|
+
# and only its 79-character first line was captured. Claude rejects the fragment; the
|
|
1302
|
+
# mint must refuse to save it and say WHY (seven fleet-wide 401s were the old answer).
|
|
1303
|
+
orig="$(cat "$ACC/acct-04/server.token")"
|
|
1304
|
+
out="$(FAKE_TOKEN_FULL=1 FAKE_TOKEN_WRAP=1 claude-accounts mint acct-04 2>&1 </dev/null)"
|
|
1305
|
+
rc=$?
|
|
1306
|
+
[ "$rc" != "0" ] && t_ok "a wrapped (79-char) token capture is refused" || t_fail "wrapped token rc" "rc=0"
|
|
1307
|
+
check "the refusal names the truncation" "79 characters" "$out"
|
|
1308
|
+
check "the refusal names the cause" "terminal wrapped it" "$out"
|
|
1309
|
+
[ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a refused capture leaves the old token in place" \
|
|
1310
|
+
|| t_fail "refused capture" "server.token was overwritten"
|
|
1311
|
+
# A pasted token gets the same proof: a revoked one is refused, not saved.
|
|
1312
|
+
out="$(printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' | claude-accounts mint acct-04 --paste 2>&1)"
|
|
1313
|
+
rc=$?
|
|
1314
|
+
[ "$rc" != "0" ] && t_ok "mint --paste refuses a token Claude rejects" || t_fail "paste rejected rc" "rc=0"
|
|
1315
|
+
check "the paste refusal says Claude rejected it" "Claude rejected the captured token" "$out"
|
|
1316
|
+
[ "$(cat "$ACC/acct-04/server.token")" = "$orig" ] && t_ok "a rejected paste saves nothing" \
|
|
1317
|
+
|| t_fail "rejected paste" "server.token was overwritten"
|
|
1318
|
+
|
|
1319
|
+
# ---- 15c1b. the ceremony widens an unsized pty, so the whole token is captured ------
|
|
1320
|
+
# The panel drives the ceremony over a pty it never sized (0x0 -> the TUI renders 80
|
|
1321
|
+
# columns wide). Run the mint under exactly such a pty: the fake wraps its token
|
|
1322
|
+
# whenever the terminal is narrower than the token, so only a widened terminal yields
|
|
1323
|
+
# all 108 characters — and the capture must then pass the inference probe.
|
|
1324
|
+
cat > "$WORK/pty-run.py" <<'EOF'
|
|
1325
|
+
import os, pty, select, sys
|
|
1326
|
+
pid, fd = pty.fork() # a fresh pty: 0 rows, 0 columns, like the panel's
|
|
1327
|
+
if pid == 0:
|
|
1328
|
+
os.execvp(sys.argv[1], sys.argv[1:])
|
|
1329
|
+
buf = b''
|
|
1330
|
+
while True:
|
|
1331
|
+
try:
|
|
1332
|
+
ready, _, _ = select.select([fd], [], [], 60)
|
|
1333
|
+
if not ready:
|
|
1334
|
+
break
|
|
1335
|
+
chunk = os.read(fd, 4096)
|
|
1336
|
+
except OSError:
|
|
1337
|
+
break
|
|
1338
|
+
if not chunk:
|
|
1339
|
+
break
|
|
1340
|
+
buf += chunk
|
|
1341
|
+
_, status = os.waitpid(pid, 0)
|
|
1342
|
+
sys.stdout.write(buf.decode('utf-8', 'ignore'))
|
|
1343
|
+
sys.exit(os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1)
|
|
1344
|
+
EOF
|
|
1345
|
+
size="$(python3 "$WORK/pty-run.py" stty size | tr -d '\r' | tail -1)"
|
|
1346
|
+
[ "$size" = "0 0" ] && t_ok "the test pty really is unsized (the panel's shape)" \
|
|
1347
|
+
|| t_fail "test pty size" "expected '0 0', got '$size'"
|
|
1348
|
+
if command -v script >/dev/null 2>&1; then
|
|
1349
|
+
out="$(FAKE_TOKEN_FULL=1 python3 "$WORK/pty-run.py" claude-accounts mint acct-04 2>&1)"
|
|
1350
|
+
rc=$?
|
|
1351
|
+
[ "$rc" = "0" ] && t_ok "mint under an unsized pty succeeds" \
|
|
1352
|
+
|| t_fail "unsized-pty mint rc" "rc=$rc: $(printf '%s' "$out" | tail -c 300)"
|
|
1353
|
+
n="$(tr -d '[:space:]' < "$ACC/acct-04/server.token" | wc -c | tr -d ' ')"
|
|
1354
|
+
[ "$n" = "108" ] && t_ok "the unsized pty is widened: all 108 characters captured" \
|
|
1355
|
+
|| t_fail "unsized-pty capture" "saved $n characters"
|
|
1356
|
+
check "the widened-pty mint verifies its token" "Token verified by a real inference" "$out"
|
|
1357
|
+
case "$(tr -d '[:space:]' < "$ACC/acct-04/server.token")" in
|
|
1358
|
+
*TAILOK) t_ok "the saved token is the complete one" ;;
|
|
1359
|
+
*) t_fail "saved token" "tail missing" ;;
|
|
1360
|
+
esac
|
|
1361
|
+
[ -f "$ACC/acct-04/.server-token-verified" ] && t_ok "the widened-pty mint records its proof" \
|
|
1362
|
+
|| t_fail "widened-pty proof" "marker missing"
|
|
1363
|
+
else
|
|
1364
|
+
printf 'skip unsized-pty mint (no script(1) here)\n'
|
|
1365
|
+
fi
|
|
1235
1366
|
claude-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
1236
1367
|
|
|
1237
1368
|
# ---- 15c0. add with NO email: derives it from the verified sign-in -------------------
|
|
@@ -2480,7 +2611,19 @@ state="$(python3 "$REPO_DIR/lib/audit.py" "$ACC" "$(uname -s | tr '[:upper:]' '[
|
|
|
2480
2611
|
strict-tokens | awk -F'\t' '$1=="acct-01"{print $4}')"
|
|
2481
2612
|
[ "$state" = "ok" ] && t_ok "verified setup-token is no longer false-unknown" \
|
|
2482
2613
|
|| t_fail "verified setup-token audit" "expected ok, got: $state"
|
|
2614
|
+
printf 'sk-ant-oat01-REVOKEDREVOKEDREVOKEDREVOKEDREVOKEDREVOKED' \
|
|
2615
|
+
> "$ACC/acct-01/server.token"
|
|
2616
|
+
rm -f "$ACC/acct-01/.server-token-verified"
|
|
2617
|
+
out="$(claude-accounts verify 2>&1)"
|
|
2618
|
+
check "verify calls a rejected setup-token what it is" "portable setup-token is invalid" "$out"
|
|
2619
|
+
check "verify recommends replacing a rejected setup-token" \
|
|
2620
|
+
"claude-accounts login acct-01 --token" "$out"
|
|
2621
|
+
out="$(claude-accounts expired 2>&1)"
|
|
2622
|
+
check "verify marker retains setup-token classification" "TOKEN INVALID" "$out"
|
|
2623
|
+
check "verify marker retains setup-token recovery" \
|
|
2624
|
+
"claude-accounts login acct-01 --token" "$out"
|
|
2483
2625
|
rm -f "$ACC/acct-01/server.token"
|
|
2626
|
+
rm -f "$ACC/acct-01/.expired" "$ACC/acct-01/.server-token-verified"
|
|
2484
2627
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test01","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$ACC/acct-01/.credentials.json"
|
|
2485
2628
|
|
|
2486
2629
|
# ---- 17c. audit: machine vocabulary (home=server vs machine_kind=linux) ---------------
|