claude-multiacc 2.0.42 → 2.0.43
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 +11 -0
- package/bin/claude +20 -3
- package/bin/claude-accounts +36 -11
- package/docs/ACCOUNT_OPERATIONS.md +5 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/common.sh +24 -1
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/fake_security.sh +90 -0
- package/tests/run-tests.sh +243 -64
- package/tests/test_keychain.py +323 -50
package/tests/run-tests.sh
CHANGED
|
@@ -125,7 +125,9 @@ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
|
|
|
125
125
|
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
126
126
|
hex="$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' \
|
|
127
127
|
| python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
128
|
-
|
|
128
|
+
# The real client's account name is FA(): $USER, and Bun's "unknown" without it.
|
|
129
|
+
[ -n "${FAKE_LOGIN_SAW:-}" ] && printf '%s\n' "${USER:-<unset>}" > "$FAKE_LOGIN_SAW"
|
|
130
|
+
security add-generic-password -U -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -X "$hex"
|
|
129
131
|
echo "Logged in."
|
|
130
132
|
exit 0
|
|
131
133
|
fi
|
|
@@ -249,6 +251,17 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
|
249
251
|
*) echo "Failed to authenticate. API Error: 401 OAuth access token is invalid." >&2; exit 1 ;;
|
|
250
252
|
esac ;;
|
|
251
253
|
esac
|
|
254
|
+
if [ -n "${FAKE_KEYCHAIN_CLIENT:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] \
|
|
255
|
+
&& [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
|
256
|
+
# The real macOS client's login lookup: the Keychain item under its FA() name ONLY
|
|
257
|
+
# ($USER; Bun's "unknown" without it), then .credentials.json — nothing else.
|
|
258
|
+
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
259
|
+
if ! security find-generic-password -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -w >/dev/null 2>&1 \
|
|
260
|
+
&& [ ! -f "$CLAUDE_CONFIG_DIR/.credentials.json" ]; then
|
|
261
|
+
echo "Not logged in · Please run /login" >&2
|
|
262
|
+
exit 1
|
|
263
|
+
fi
|
|
264
|
+
fi
|
|
252
265
|
if [ -f "$ctl" ] && grep -qx "brokencli:$acct" "$ctl" 2>/dev/null; then
|
|
253
266
|
# An infrastructure fault, not a login verdict: the CLI never reached the API.
|
|
254
267
|
# Verbatim from my-mini, 2026-09-10. Note it carries no auth vocabulary at all
|
|
@@ -319,67 +332,19 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
319
332
|
fi
|
|
320
333
|
# what an interactive client would inherit from the shim (auto-resume must leak nothing)
|
|
321
334
|
[ -n "${FAKE_PRINT_ENV:-}" ] && echo "AR_ENV=$(env | grep -c '^CLAUDE_MULTIACC_AR')"
|
|
335
|
+
[ -n "${FAKE_PRINT_USER:-}" ] && echo "USER=${USER:-<unset>} LOGNAME=${LOGNAME:-<unset>}"
|
|
322
336
|
echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
|
|
323
337
|
EOF
|
|
324
338
|
chmod +x "$FAKEBIN/claude"
|
|
325
339
|
|
|
326
|
-
# Fake macOS `security` (generic-password verbs only)
|
|
327
|
-
#
|
|
340
|
+
# Fake macOS `security` (generic-password verbs only), shared with tests/test_keychain.py:
|
|
341
|
+
# items keyed by (service, account) under $FAKE_KEYCHAIN_DIR, -a selecting its own and
|
|
342
|
+
# no -a the OLDEST, exactly as the real tool behaves (see the file's header).
|
|
328
343
|
# FAKE_KEYCHAIN_LOCKED=1 simulates a session that cannot open the keychain — secret
|
|
329
344
|
# reads and every write exit 36 while ATTRIBUTE reads still answer, exactly the split
|
|
330
345
|
# the real tool exhibits over ssh. Shadows any real /usr/bin/security via PATH, so a
|
|
331
346
|
# macOS dev run of this suite can never touch the developer's actual keychain.
|
|
332
|
-
|
|
333
|
-
#!/usr/bin/env bash
|
|
334
|
-
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
335
|
-
cmd="${1:-}"; shift || true
|
|
336
|
-
svc=""; acct=""; want_pw=0; hexdata=""
|
|
337
|
-
while [ $# -gt 0 ]; do
|
|
338
|
-
case "$1" in
|
|
339
|
-
-s) svc="$2"; shift 2 ;;
|
|
340
|
-
-a) acct="$2"; shift 2 ;;
|
|
341
|
-
-w) want_pw=1; shift ;;
|
|
342
|
-
-X) hexdata="$2"; shift 2 ;;
|
|
343
|
-
*) shift ;;
|
|
344
|
-
esac
|
|
345
|
-
done
|
|
346
|
-
# The modification stamp is RECORDED AT WRITE TIME in a sidecar rather than derived
|
|
347
|
-
# from the item file's mtime: `stat`/`date` flags differ between BSD and GNU (on Linux
|
|
348
|
-
# `stat -f %m` even "succeeds" with garbage), and this fake must behave identically on
|
|
349
|
-
# a developer's Mac and on Linux CI.
|
|
350
|
-
item_mdat() { cat "$KC/$svc.mdat" 2>/dev/null || echo 00000000000000; }
|
|
351
|
-
case "$cmd" in
|
|
352
|
-
show-keychain-info)
|
|
353
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
354
|
-
exit 0 ;;
|
|
355
|
-
find-generic-password)
|
|
356
|
-
[ -f "$KC/$svc" ] || exit 44
|
|
357
|
-
if [ "$want_pw" = "1" ]; then
|
|
358
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
359
|
-
cat "$KC/$svc"
|
|
360
|
-
exit 0
|
|
361
|
-
fi
|
|
362
|
-
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
363
|
-
printf 'keychain: "login.keychain-db"\nclass: "genp"\nattributes:\n'
|
|
364
|
-
printf ' "acct"<blob>="%s"\n' "$a"
|
|
365
|
-
printf ' "mdat"<timedate>=0x00 "%sZ\\000"\n' "$(item_mdat)"
|
|
366
|
-
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
367
|
-
exit 0 ;;
|
|
368
|
-
add-generic-password)
|
|
369
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
370
|
-
mkdir -p "$KC"
|
|
371
|
-
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
372
|
-
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
373
|
-
date -u +%Y%m%d%H%M%S > "$KC/$svc.mdat"
|
|
374
|
-
exit 0 ;;
|
|
375
|
-
delete-generic-password)
|
|
376
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
377
|
-
[ -f "$KC/$svc" ] || exit 44
|
|
378
|
-
rm -f "$KC/$svc" "$KC/$svc.acct" "$KC/$svc.mdat"
|
|
379
|
-
exit 0 ;;
|
|
380
|
-
*) exit 1 ;;
|
|
381
|
-
esac
|
|
382
|
-
EOF
|
|
347
|
+
cp "$REPO_DIR/tests/fake_security.sh" "$FAKEBIN/security"
|
|
383
348
|
chmod +x "$FAKEBIN/security"
|
|
384
349
|
|
|
385
350
|
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
@@ -7769,11 +7734,22 @@ cat > "$KCP/accounts.json" <<EOF
|
|
|
7769
7734
|
EOF
|
|
7770
7735
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
|
|
7771
7736
|
kc_hash() { printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8; }
|
|
7772
|
-
|
|
7773
|
-
kc_put() { # kc_put <acct dir> <credential json> — store it the way
|
|
7774
|
-
|
|
7737
|
+
kc_svc_dir() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
|
|
7738
|
+
kc_put() { # kc_put <acct dir> <credential json> [account] — store it the way a client the
|
|
7739
|
+
# pool launches would: under its FA() name, $USER, which lib/common.sh and
|
|
7740
|
+
# bin/claude fill from `id -un` when the caller's env has none
|
|
7741
|
+
security add-generic-password -U -a "${3:-${USER:-$(id -un)}}" -s "Claude Code-credentials-$(kc_hash "$1")" \
|
|
7775
7742
|
-X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
7776
7743
|
}
|
|
7744
|
+
kc_items() { # kc_items <acct dir> -> the account names holding an item, one per line
|
|
7745
|
+
local i
|
|
7746
|
+
for i in "$(kc_svc_dir "$1")"/*/; do [ -d "$i" ] && basename "$i"; done
|
|
7747
|
+
return 0
|
|
7748
|
+
}
|
|
7749
|
+
kc_secret() { # kc_secret <acct dir> [account] -> the stored document
|
|
7750
|
+
cat "$(kc_svc_dir "$1")/${2:-${USER:-$(id -un)}}/secret" 2>/dev/null
|
|
7751
|
+
}
|
|
7752
|
+
KC_ME="$(id -un)" # the canonical name: what the CLI fills USER with when it is unset
|
|
7777
7753
|
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
7778
7754
|
export CLAUDE_MULTIACC_KEYCHAIN=1
|
|
7779
7755
|
|
|
@@ -7834,13 +7810,25 @@ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("sourc
|
|
|
7834
7810
|
|
|
7835
7811
|
# 17e. refresh: an expired keychain credential refreshes via the grant and is written
|
|
7836
7812
|
# BACK to the keychain — never copied out into a .credentials.json
|
|
7837
|
-
|
|
7813
|
+
# Beside it: a copy of the grant the refresh starts from (spent once the grant rotates —
|
|
7814
|
+
# it goes) and ANOTHER live grant (the right account's login beside a wrong-account
|
|
7815
|
+
# sign-in's, say — only a verified sign-in may drop that one).
|
|
7816
|
+
KC_OLD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
|
|
7817
|
+
KC_OTHER='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kother","refreshToken":"sk-ant-ort01-other","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
7818
|
+
kc_put "$KCP/acct-02" "$KC_OLD"
|
|
7819
|
+
kc_put "$KCP/acct-02" "$KC_OLD" unknown
|
|
7820
|
+
kc_put "$KCP/acct-02" "$KC_OTHER" claude-code-user
|
|
7838
7821
|
rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
|
|
7839
7822
|
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
7840
7823
|
check "keychain credential refreshes via the grant" "acct-02: oauth access token refreshed" "$out"
|
|
7841
|
-
grep -q 'sk-ant-oat01-refreshednew'
|
|
7824
|
+
kc_secret "$KCP/acct-02" | grep -q 'sk-ant-oat01-refreshednew' \
|
|
7842
7825
|
&& t_ok "rotated credential written back to the keychain" \
|
|
7843
7826
|
|| t_fail "keychain write-back" "item not updated"
|
|
7827
|
+
[ "$(kc_items "$KCP/acct-02" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" claude-code-user | sort | tr '\n' ' ')" ] \
|
|
7828
|
+
&& [ "$(kc_secret "$KCP/acct-02" claude-code-user)" = "$KC_OTHER" ] \
|
|
7829
|
+
&& t_ok "the write-back drops the spent copy of the grant and keeps another live grant" \
|
|
7830
|
+
|| t_fail "keychain write-back siblings" "items: $(kc_items "$KCP/acct-02" | tr '\n' ' ')"
|
|
7831
|
+
security delete-generic-password -a claude-code-user -s "Claude Code-credentials-$(kc_hash "$KCP/acct-02")" >/dev/null 2>&1
|
|
7844
7832
|
[ ! -f "$KCP/acct-02/.credentials.json" ] \
|
|
7845
7833
|
&& t_ok "refresh never copies the keychain credential to a file" \
|
|
7846
7834
|
|| t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
|
|
@@ -7854,14 +7842,30 @@ CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
|
|
|
7854
7842
|
&& t_ok ".expired self-heals on a newer keychain credential" \
|
|
7855
7843
|
|| t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
|
|
7856
7844
|
|
|
7857
|
-
# 17g. a login ceremony that lands in the keychain registers instead of dying
|
|
7845
|
+
# 17g. a login ceremony that lands in the keychain registers instead of dying. The slot
|
|
7846
|
+
# it reserves (acct-03) can carry leftover items from an account once removed there; they
|
|
7847
|
+
# go only AFTER the identity read back and the registration committed — a sign-in as an
|
|
7848
|
+
# account the pool already has is skipped and must leave them alone.
|
|
7849
|
+
kc_put "$KCP/acct-03" "$KC_OTHER" unknown
|
|
7850
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kchain@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add 2>&1)"
|
|
7851
|
+
check "an add that signs in as a registered account is skipped" "already added as acct-02" "$out"
|
|
7852
|
+
[ "$(kc_secret "$KCP/acct-03" unknown)" = "$KC_OTHER" ] \
|
|
7853
|
+
&& t_ok "a skipped add prunes nothing" \
|
|
7854
|
+
|| t_fail "skipped add prune" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
|
|
7855
|
+
grep -q 'keychain prune acct-03' "$KCP/ops.log" 2>/dev/null \
|
|
7856
|
+
&& t_fail "skipped add prune" "a prune ran before the registration" \
|
|
7857
|
+
|| t_ok "no prune ran for the skipped add"
|
|
7858
7858
|
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kc3@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add kc3@test 2>&1)"
|
|
7859
7859
|
check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
|
|
7860
7860
|
[ ! -f "$KCP/acct-03/.credentials.json" ] \
|
|
7861
7861
|
&& t_ok "keychain add leaves no plaintext credential" \
|
|
7862
7862
|
|| t_fail "keychain add" "unexpected .credentials.json"
|
|
7863
|
-
[
|
|
7864
|
-
|
|
7863
|
+
[ "$(kc_items "$KCP/acct-03")" = "$KC_ME" ] && kc_secret "$KCP/acct-03" | grep -q 'sk-ant-oat01-login' \
|
|
7864
|
+
&& t_ok "the registered sign-in is the one item left, under \$(id -un)" \
|
|
7865
|
+
|| t_fail "keychain add item" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
|
|
7866
|
+
grep -q "keychain prune acct-03: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
|
|
7867
|
+
&& t_ok "the add's prune is logged" \
|
|
7868
|
+
|| t_fail "add prune log" "no prune line for acct-03 in ops.log"
|
|
7865
7869
|
|
|
7866
7870
|
# 17h. export refuses a keychain-held machine-local credential, naming the store
|
|
7867
7871
|
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
|
|
@@ -7871,7 +7875,7 @@ check "export names the keychain as the machine-local store" "macOS Keychain" "$
|
|
|
7871
7875
|
|
|
7872
7876
|
# 17i. remove deletes the keychain item with the account
|
|
7873
7877
|
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-03 --yes >/dev/null 2>&1
|
|
7874
|
-
[
|
|
7878
|
+
[ -z "$(kc_items "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
|
|
7875
7879
|
|| t_fail "keychain remove" "the item survived removal"
|
|
7876
7880
|
|
|
7877
7881
|
# 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
|
|
@@ -7889,12 +7893,187 @@ mkdir -p "$KCP/acct-04" "$KCP/acct-05"
|
|
|
7889
7893
|
kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
7890
7894
|
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
|
|
7891
7895
|
check "dedupe removes the credential-less duplicate, not the keychain one" "will remove acct-04 (kdup@test)" "$out"
|
|
7892
|
-
[ -
|
|
7896
|
+
[ -n "$(kc_items "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
|
|
7893
7897
|
|| t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
|
|
7894
7898
|
grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
7895
7899
|
&& t_fail "dedupe manifest" "acct-04 still registered" \
|
|
7896
7900
|
|| t_ok "the credential-less duplicate left the manifest"
|
|
7897
7901
|
|
|
7902
|
+
# 17k. several items per service (2026-09-24). Claude Code names its item after $USER and,
|
|
7903
|
+
# under Bun, a client with no USER calls the user "unknown": app-robot's panel ran
|
|
7904
|
+
# `claude-accounts login acct-02` from an env without USER, the fresh login landed under
|
|
7905
|
+
# "unknown" beside the dead item under the real name, and the check read the dead one
|
|
7906
|
+
# (`security -s` without -a answers with the OLDEST item) — "login failed … nothing
|
|
7907
|
+
# changed". Now the CLI fills USER, every lookup reads the canonical item (the one the
|
|
7908
|
+
# client reads), and a sign-in prunes the siblings only once its identity checked out.
|
|
7909
|
+
python3 - "$KCP/accounts.json" <<'EOF2'
|
|
7910
|
+
import json, sys
|
|
7911
|
+
doc = json.load(open(sys.argv[1]))
|
|
7912
|
+
doc['accounts'].append({'id': 'acct-06', 'email': 'kfix@test', 'home': 'mac',
|
|
7913
|
+
'added_at': '2026-09-24T00:00:00Z'})
|
|
7914
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
7915
|
+
EOF2
|
|
7916
|
+
mkdir -p "$KCP/acct-06"
|
|
7917
|
+
KC_DEAD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdead","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":1000}}'
|
|
7918
|
+
KC_UNK='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kunk","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
7919
|
+
FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
|
|
7920
|
+
FAKE_KEYCHAIN_MDAT=20260924095747 kc_put "$KCP/acct-06" "$KC_UNK" unknown
|
|
7921
|
+
out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
|
|
7922
|
+
python3 - "$out" <<'EOF2'
|
|
7923
|
+
import json, sys
|
|
7924
|
+
a6 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-06']
|
|
7925
|
+
assert a6['selectable'] is False and a6['needs_login'] is True, a6
|
|
7926
|
+
EOF2
|
|
7927
|
+
[ $? -eq 0 ] && t_ok "the pool judges the canonical (dead) item its client reads, not the newer sibling" \
|
|
7928
|
+
|| t_fail "keychain canonical read" "see assertions above"
|
|
7929
|
+
# A sign-in as the WRONG account: the client itself overwrites the canonical item, but
|
|
7930
|
+
# nothing multiacc does may touch the rest — the live sibling can be the right account's.
|
|
7931
|
+
out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=intruder@test \
|
|
7932
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
|
|
7933
|
+
[ "$rc" != "0" ] && t_ok "a wrong-identity login fails" || t_fail "wrong-identity login" "rc=0"
|
|
7934
|
+
check "a wrong-identity login says nothing was saved" "nothing saved" "$out"
|
|
7935
|
+
[ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
|
|
7936
|
+
&& [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_UNK" ] \
|
|
7937
|
+
&& t_ok "a wrong-identity login leaves every sibling item intact" \
|
|
7938
|
+
|| t_fail "wrong-identity keychain login" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
|
|
7939
|
+
grep -q 'keychain prune acct-06' "$KCP/ops.log" 2>/dev/null \
|
|
7940
|
+
&& t_fail "wrong-identity keychain login" "pruned before the identity check" \
|
|
7941
|
+
|| t_ok "no prune ran for the wrong-identity login"
|
|
7942
|
+
FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
|
|
7943
|
+
out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kfix@test \
|
|
7944
|
+
FAKE_LOGIN_SAW="$WORK/kc-login-saw" CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
|
|
7945
|
+
[ "$rc" = "0" ] && t_ok "a USER-less login over a dead canonical + live sibling succeeds" \
|
|
7946
|
+
|| t_fail "USER-less keychain login" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
|
|
7947
|
+
check "the USER-less login reports the keychain store" "acct-06 login saved (macOS Keychain" "$out"
|
|
7948
|
+
[ "$(cat "$WORK/kc-login-saw" 2>/dev/null)" = "$KC_ME" ] \
|
|
7949
|
+
&& t_ok "the client the ceremony starts gets USER=\$(id -un), not Bun's 'unknown'" \
|
|
7950
|
+
|| t_fail "ceremony USER fill" "client saw USER=$(cat "$WORK/kc-login-saw" 2>/dev/null)"
|
|
7951
|
+
[ "$(kc_items "$KCP/acct-06")" = "$KC_ME" ] \
|
|
7952
|
+
&& t_ok "the verified login leaves exactly ONE item, under \$(id -un)" \
|
|
7953
|
+
|| t_fail "keychain prune" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
|
|
7954
|
+
kc_secret "$KCP/acct-06" "$KC_ME" | grep -q 'sk-ant-oat01-login' \
|
|
7955
|
+
&& t_ok "the surviving item is the fresh login" \
|
|
7956
|
+
|| t_fail "keychain prune" "the canonical item does not hold the fresh login"
|
|
7957
|
+
grep -q "keychain prune acct-06: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
|
|
7958
|
+
&& t_ok "the prune is logged (non-secret) to ops.log" \
|
|
7959
|
+
|| t_fail "keychain prune log" "no prune line in ops.log"
|
|
7960
|
+
grep -q 'sk-ant' "$KCP/ops.log" 2>/dev/null \
|
|
7961
|
+
&& t_fail "keychain prune log" "a credential reached ops.log" \
|
|
7962
|
+
|| t_ok "ops.log carries no credential"
|
|
7963
|
+
|
|
7964
|
+
# 17l. a failed ceremony changes nothing — not even the stale items ("nothing changed")
|
|
7965
|
+
FAKE_KEYCHAIN_MDAT=20260912000000 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
|
|
7966
|
+
FAKE_KEYCHAIN_MDAT=20260913000000 kc_put "$KCP/acct-06" "$KC_DEAD" unknown
|
|
7967
|
+
out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 FAKE_EMAIL=kfix@test \
|
|
7968
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
|
|
7969
|
+
[ "$rc" != "0" ] && [ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
|
|
7970
|
+
&& [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_DEAD" ] \
|
|
7971
|
+
&& t_ok "an aborted login leaves every item as it was" \
|
|
7972
|
+
|| t_fail "aborted keychain login" "rc=$rc items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
|
|
7973
|
+
|
|
7974
|
+
# 17m. remove drops EVERY item for the dir, whatever name it sits under
|
|
7975
|
+
FAKE_KEYCHAIN_MDAT=20260101000000 kc_put "$KCP/acct-06" "$KC_DEAD" claude-code-user
|
|
7976
|
+
FAKE_KEYCHAIN_MDAT=20260102000000 kc_put "$KCP/acct-06" "$KC_DEAD" renamed-user
|
|
7977
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-06 --yes >/dev/null 2>&1
|
|
7978
|
+
[ -z "$(kc_items "$KCP/acct-06")" ] && t_ok "remove leaves zero items when duplicates exist" \
|
|
7979
|
+
|| t_fail "keychain remove duplicates" "left: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
|
|
7980
|
+
|
|
7981
|
+
# 17n. the shim judges exactly the item its client will read: -a <$USER, filled>. A
|
|
7982
|
+
# live login under any other name is invisible to that client, so it is no auth here.
|
|
7983
|
+
KSP="$WORK/kcshim"
|
|
7984
|
+
mkdir -p "$KSP/tmp" "$KSP/acct-01" "$KSP/acct-02"
|
|
7985
|
+
: > "$KSP/.limits-kick"
|
|
7986
|
+
cat > "$KSP/accounts.json" <<EOF2
|
|
7987
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
7988
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
7989
|
+
"accounts": [
|
|
7990
|
+
{"id": "acct-01", "email": "sfile@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"},
|
|
7991
|
+
{"id": "acct-02", "email": "schain@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"}
|
|
7992
|
+
] }
|
|
7993
|
+
EOF2
|
|
7994
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-sfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KSP/acct-01/.credentials.json"
|
|
7995
|
+
printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$KSP/acct-01/.limited"
|
|
7996
|
+
KC_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-slive","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
7997
|
+
kc_put "$KSP/acct-02" "$KC_LIVE" unknown
|
|
7998
|
+
out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
|
|
7999
|
+
check "shim ignores a live item under a name its client never reads" "CFG=acct-01" "$out"
|
|
8000
|
+
FAKE_KEYCHAIN_MDAT=20260910000000 kc_put "$KSP/acct-02" "$KC_LIVE" "$KC_ME"
|
|
8001
|
+
FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KSP/acct-02" "$KC_DEAD" unknown
|
|
8002
|
+
out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
|
|
8003
|
+
check "shim reads the -a \$(id -un) item even when a newer sibling is dead" "CFG=acct-02" "$out"
|
|
8004
|
+
check "the client the shim starts inherits the filled USER/LOGNAME" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
|
|
8005
|
+
out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
|
|
8006
|
+
check "a passthrough launch gets the filled USER too" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
|
|
8007
|
+
out="$(env USER=other.user LOGNAME=keep FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
|
|
8008
|
+
check "a caller's own USER/LOGNAME are never overridden" "USER=other.user LOGNAME=keep" "$out"
|
|
8009
|
+
|
|
8010
|
+
# 17o. an item under a name the client does not read is NO login to anything that judges
|
|
8011
|
+
# the account — list, audit, verify — just as it is none to the shim and the client. Only
|
|
8012
|
+
# the limits pass looks further, and moves it under the canonical name. my-mini's
|
|
8013
|
+
# acct-06/acct-21 shape (the only item under "unknown"), with and without a setup token,
|
|
8014
|
+
# plus the reverse order: a stale sibling OLDER than the canonical item.
|
|
8015
|
+
KLP="$WORK/kclegacy"
|
|
8016
|
+
# The accounts live on THIS machine (CI runs on Linux): a login-less account whose home
|
|
8017
|
+
# is another machine is 'remote', not one that needs a login here.
|
|
8018
|
+
KLP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || KLP_HOME=server
|
|
8019
|
+
mkdir -p "$KLP/tmp" "$KLP/acct-01" "$KLP/acct-02" "$KLP/acct-03"
|
|
8020
|
+
: > "$KLP/.limits-kick"
|
|
8021
|
+
cat > "$KLP/accounts.json" <<EOF2
|
|
8022
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
8023
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
8024
|
+
"accounts": [
|
|
8025
|
+
{"id": "acct-01", "email": "ltok@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
|
|
8026
|
+
{"id": "acct-02", "email": "lonly@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
|
|
8027
|
+
{"id": "acct-03", "email": "lorder@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"}
|
|
8028
|
+
] }
|
|
8029
|
+
EOF2
|
|
8030
|
+
printf 'sk-ant-oat01-legacy-token-01' > "$KLP/acct-01/server.token"
|
|
8031
|
+
KC_L1='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l1","refreshToken":"sk-ant-ort01-l1","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
8032
|
+
KC_L2='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l2","refreshToken":"sk-ant-ort01-l2","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
8033
|
+
kc_put "$KLP/acct-01" "$KC_L1" unknown
|
|
8034
|
+
kc_put "$KLP/acct-02" "$KC_L2" unknown
|
|
8035
|
+
FAKE_KEYCHAIN_MDAT=20260901000000 kc_put "$KLP/acct-03" "$KC_DEAD" unknown
|
|
8036
|
+
FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KLP/acct-03" "$KC_LIVE" "$KC_ME"
|
|
8037
|
+
out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
|
|
8038
|
+
python3 - "$out" <<'EOF2'
|
|
8039
|
+
import json, sys
|
|
8040
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
8041
|
+
a1, a2, a3 = rows['acct-01'], rows['acct-02'], rows['acct-03']
|
|
8042
|
+
assert a1['credentials']['oauth_store'] is None, a1['credentials']
|
|
8043
|
+
assert a2['selectable'] is False and a2['needs_login'] is True, a2
|
|
8044
|
+
assert a2['credentials']['oauth_store'] is None, a2['credentials']
|
|
8045
|
+
assert a3['selectable'] is True and a3['credentials']['keychain'] == 'readable', a3
|
|
8046
|
+
EOF2
|
|
8047
|
+
[ $? -eq 0 ] && t_ok "list --json counts only the item its client reads (none under another name; canonical over an older sibling)" \
|
|
8048
|
+
|| t_fail "keychain legacy list" "see assertions above"
|
|
8049
|
+
out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
|
|
8050
|
+
check "verify runs a token-backed account on its token when its login is out of the client's reach" "acct-01 ltok@test: PASS" "$out"
|
|
8051
|
+
check "verify skips an account whose only login its client cannot read" "acct-02 lonly@test: SKIP" "$out"
|
|
8052
|
+
check "verify passes the canonical item beside an older stale sibling" "acct-03 lorder@test: PASS" "$out"
|
|
8053
|
+
[ ! -f "$KLP/acct-01/.expired" ] && [ -f "$KLP/acct-01/.server-token-verified" ] \
|
|
8054
|
+
&& t_ok "verify neither parks it nor forgets its token proof" \
|
|
8055
|
+
|| t_fail "keychain legacy verify" "acct-01: $(cat "$KLP/acct-01/.expired" 2>/dev/null | tail -1)"
|
|
8056
|
+
out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
8057
|
+
check "the limits pass moves a login filed under another name" "acct-02: Keychain login moved under $KC_ME" "$out"
|
|
8058
|
+
check "and reads usage with it" "acct-02: ok" "$out"
|
|
8059
|
+
[ "$(kc_items "$KLP/acct-01")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-01")" = "$KC_L1" ] \
|
|
8060
|
+
&& [ "$(kc_items "$KLP/acct-02")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-02")" = "$KC_L2" ] \
|
|
8061
|
+
&& t_ok "each moved login is the one item left, under \$(id -un), unchanged" \
|
|
8062
|
+
|| t_fail "keychain legacy move" "acct-01: $(kc_items "$KLP/acct-01" | tr '\n' ' ') acct-02: $(kc_items "$KLP/acct-02" | tr '\n' ' ')"
|
|
8063
|
+
[ "$(kc_items "$KLP/acct-03" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
|
|
8064
|
+
&& t_ok "the pass leaves an account with a canonical item alone (no write, no prune)" \
|
|
8065
|
+
|| t_fail "keychain legacy move" "acct-03: $(kc_items "$KLP/acct-03" | tr '\n' ' ')"
|
|
8066
|
+
out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
|
|
8067
|
+
python3 - "$out" <<'EOF2'
|
|
8068
|
+
import json, sys
|
|
8069
|
+
a2 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-02']
|
|
8070
|
+
assert a2['selectable'] is True and a2['credentials']['keychain'] == 'readable', a2
|
|
8071
|
+
EOF2
|
|
8072
|
+
[ $? -eq 0 ] && t_ok "once moved, the login counts everywhere" \
|
|
8073
|
+
|| t_fail "keychain legacy move list" "see assertions above"
|
|
8074
|
+
out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
|
|
8075
|
+
check "and its client can use it" "acct-02 lonly@test: PASS" "$out"
|
|
8076
|
+
|
|
7898
8077
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
7899
8078
|
unset FAKE_KEYCHAIN_DIR
|
|
7900
8079
|
|