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.
@@ -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
- security add-generic-password -U -a tester -s "Claude Code-credentials-$h" -X "$hex"
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): items live as files under
327
- # $FAKE_KEYCHAIN_DIR/<service> (content = the secret) + <service>.acct (account name).
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
- cat > "$FAKEBIN/security" <<'EOF'
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
- kc_svc_file() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
7773
- kc_put() { # kc_put <acct dir> <credential json> — store it the way the client would
7774
- security add-generic-password -U -a tester -s "Claude Code-credentials-$(kc_hash "$1")" \
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
- kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
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' "$(kc_svc_file "$KCP/acct-02")" \
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
- [ -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "the sign-in landed in the keychain" \
7864
- || t_fail "keychain add item" "no keychain item for acct-03"
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
- [ ! -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
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
- [ -f "$(kc_svc_file "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
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