claude-multiacc 2.0.42 → 2.0.44

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.
Files changed (42) hide show
  1. package/README.md +17 -1
  2. package/bin/claude +78 -18
  3. package/bin/claude-accounts +112 -22
  4. package/docs/ACCOUNT_OPERATIONS.md +5 -0
  5. package/docs/claude-token-identity.md +147 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  17. package/lib/audit.py +10 -0
  18. package/lib/claude_token_identity.py +233 -0
  19. package/lib/common.sh +27 -2
  20. package/lib/credential.py +9 -2
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  35. package/tests/fake_security.sh +90 -0
  36. package/tests/run-tests.sh +254 -66
  37. package/tests/test_claude_token_identity.py +259 -0
  38. package/tests/test_keychain.py +323 -50
  39. package/tests/test_token_identity_ceremony.py +167 -0
  40. package/tests/test_token_identity_integration.py +207 -0
  41. package/tests/test_token_identity_retry.py +95 -0
  42. package/tests/test_token_identity_sync.py +139 -0
@@ -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"
@@ -402,6 +367,9 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
402
367
  # (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
403
368
  # throttle keeps those background kicks from racing explicit limits runs.
404
369
  export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
370
+ # Organization checks must never send synthetic tokens to a real provider. The
371
+ # dedicated identity suite overrides this with its loopback HTTP contract server.
372
+ export CLAUDE_MULTIACC_TOKEN_IDENTITY_URL="file://$WORK/identity-endpoint-missing.json"
405
373
  : > "$ACC/.limits-kick"
406
374
  # Keychain lookups are OFF for the legacy sections (their pools are file-based and the
407
375
  # extra `security` process per account would only slow them down); section 17 turns
@@ -7707,12 +7675,12 @@ check "mint refuses an unregistered account dir" "unknown account: acct-77" "$ou
7707
7675
  || t_fail "mint orphan dir" "server.token was written to an unregistered slot"
7708
7676
  rm -rf "$ACC/acct-77"
7709
7677
 
7710
- # mint must NAME the account it is about to bind a token to (the only guard there is).
7678
+ # mint must NAME the account even when no saved organization is available to check.
7711
7679
  printf 'sk-ant-oat01-MINTNAMEDMINTNAMEDMINTNAMEDMINTNAMEDMINTNAMED\n' \
7712
7680
  | claude-accounts mint acct-01 --paste > "$WORK/mint-named.out" 2>&1
7713
7681
  out="$(cat "$WORK/mint-named.out")"
7714
7682
  check "mint --paste names the account in its prompt" "for acct-01" "$out"
7715
- check "mint warns that a setup token carries no identity" "carries no identity" "$out"
7683
+ check "mint reports an unavailable organization check" "organization could not be checked" "$out"
7716
7684
 
7717
7685
  # ---- 47. every known verb answers --help with exit 0 (app-robot probes with it) ----
7718
7686
  # app-robot's runner asks `<verb> --help` to decide whether a Mac's build has the verb;
@@ -7769,11 +7737,22 @@ cat > "$KCP/accounts.json" <<EOF
7769
7737
  EOF
7770
7738
  printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
7771
7739
  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")" \
7740
+ kc_svc_dir() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
7741
+ kc_put() { # kc_put <acct dir> <credential json> [account] — store it the way a client the
7742
+ # pool launches would: under its FA() name, $USER, which lib/common.sh and
7743
+ # bin/claude fill from `id -un` when the caller's env has none
7744
+ security add-generic-password -U -a "${3:-${USER:-$(id -un)}}" -s "Claude Code-credentials-$(kc_hash "$1")" \
7775
7745
  -X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
7776
7746
  }
7747
+ kc_items() { # kc_items <acct dir> -> the account names holding an item, one per line
7748
+ local i
7749
+ for i in "$(kc_svc_dir "$1")"/*/; do [ -d "$i" ] && basename "$i"; done
7750
+ return 0
7751
+ }
7752
+ kc_secret() { # kc_secret <acct dir> [account] -> the stored document
7753
+ cat "$(kc_svc_dir "$1")/${2:-${USER:-$(id -un)}}/secret" 2>/dev/null
7754
+ }
7755
+ KC_ME="$(id -un)" # the canonical name: what the CLI fills USER with when it is unset
7777
7756
  kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7778
7757
  export CLAUDE_MULTIACC_KEYCHAIN=1
7779
7758
 
@@ -7834,13 +7813,25 @@ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("sourc
7834
7813
 
7835
7814
  # 17e. refresh: an expired keychain credential refreshes via the grant and is written
7836
7815
  # 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}}'
7816
+ # Beside it: a copy of the grant the refresh starts from (spent once the grant rotates —
7817
+ # it goes) and ANOTHER live grant (the right account's login beside a wrong-account
7818
+ # sign-in's, say — only a verified sign-in may drop that one).
7819
+ KC_OLD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
7820
+ KC_OTHER='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kother","refreshToken":"sk-ant-ort01-other","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7821
+ kc_put "$KCP/acct-02" "$KC_OLD"
7822
+ kc_put "$KCP/acct-02" "$KC_OLD" unknown
7823
+ kc_put "$KCP/acct-02" "$KC_OTHER" claude-code-user
7838
7824
  rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
7839
7825
  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
7826
  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")" \
7827
+ kc_secret "$KCP/acct-02" | grep -q 'sk-ant-oat01-refreshednew' \
7842
7828
  && t_ok "rotated credential written back to the keychain" \
7843
7829
  || t_fail "keychain write-back" "item not updated"
7830
+ [ "$(kc_items "$KCP/acct-02" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" claude-code-user | sort | tr '\n' ' ')" ] \
7831
+ && [ "$(kc_secret "$KCP/acct-02" claude-code-user)" = "$KC_OTHER" ] \
7832
+ && t_ok "the write-back drops the spent copy of the grant and keeps another live grant" \
7833
+ || t_fail "keychain write-back siblings" "items: $(kc_items "$KCP/acct-02" | tr '\n' ' ')"
7834
+ security delete-generic-password -a claude-code-user -s "Claude Code-credentials-$(kc_hash "$KCP/acct-02")" >/dev/null 2>&1
7844
7835
  [ ! -f "$KCP/acct-02/.credentials.json" ] \
7845
7836
  && t_ok "refresh never copies the keychain credential to a file" \
7846
7837
  || t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
@@ -7854,14 +7845,30 @@ CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
7854
7845
  && t_ok ".expired self-heals on a newer keychain credential" \
7855
7846
  || t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
7856
7847
 
7857
- # 17g. a login ceremony that lands in the keychain registers instead of dying
7848
+ # 17g. a login ceremony that lands in the keychain registers instead of dying. The slot
7849
+ # it reserves (acct-03) can carry leftover items from an account once removed there; they
7850
+ # go only AFTER the identity read back and the registration committed — a sign-in as an
7851
+ # account the pool already has is skipped and must leave them alone.
7852
+ kc_put "$KCP/acct-03" "$KC_OTHER" unknown
7853
+ out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kchain@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add 2>&1)"
7854
+ check "an add that signs in as a registered account is skipped" "already added as acct-02" "$out"
7855
+ [ "$(kc_secret "$KCP/acct-03" unknown)" = "$KC_OTHER" ] \
7856
+ && t_ok "a skipped add prunes nothing" \
7857
+ || t_fail "skipped add prune" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
7858
+ grep -q 'keychain prune acct-03' "$KCP/ops.log" 2>/dev/null \
7859
+ && t_fail "skipped add prune" "a prune ran before the registration" \
7860
+ || t_ok "no prune ran for the skipped add"
7858
7861
  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
7862
  check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
7860
7863
  [ ! -f "$KCP/acct-03/.credentials.json" ] \
7861
7864
  && t_ok "keychain add leaves no plaintext credential" \
7862
7865
  || 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"
7866
+ [ "$(kc_items "$KCP/acct-03")" = "$KC_ME" ] && kc_secret "$KCP/acct-03" | grep -q 'sk-ant-oat01-login' \
7867
+ && t_ok "the registered sign-in is the one item left, under \$(id -un)" \
7868
+ || t_fail "keychain add item" "items: $(kc_items "$KCP/acct-03" | tr '\n' ' ')"
7869
+ grep -q "keychain prune acct-03: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
7870
+ && t_ok "the add's prune is logged" \
7871
+ || t_fail "add prune log" "no prune line for acct-03 in ops.log"
7865
7872
 
7866
7873
  # 17h. export refuses a keychain-held machine-local credential, naming the store
7867
7874
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
@@ -7871,7 +7878,7 @@ check "export names the keychain as the machine-local store" "macOS Keychain" "$
7871
7878
 
7872
7879
  # 17i. remove deletes the keychain item with the account
7873
7880
  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" \
7881
+ [ -z "$(kc_items "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
7875
7882
  || t_fail "keychain remove" "the item survived removal"
7876
7883
 
7877
7884
  # 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
@@ -7889,12 +7896,187 @@ mkdir -p "$KCP/acct-04" "$KCP/acct-05"
7889
7896
  kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7890
7897
  out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
7891
7898
  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" \
7899
+ [ -n "$(kc_items "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
7893
7900
  || t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
7894
7901
  grep -q '"acct-04"' "$KCP/accounts.json" \
7895
7902
  && t_fail "dedupe manifest" "acct-04 still registered" \
7896
7903
  || t_ok "the credential-less duplicate left the manifest"
7897
7904
 
7905
+ # 17k. several items per service (2026-09-24). Claude Code names its item after $USER and,
7906
+ # under Bun, a client with no USER calls the user "unknown": app-robot's panel ran
7907
+ # `claude-accounts login acct-02` from an env without USER, the fresh login landed under
7908
+ # "unknown" beside the dead item under the real name, and the check read the dead one
7909
+ # (`security -s` without -a answers with the OLDEST item) — "login failed … nothing
7910
+ # changed". Now the CLI fills USER, every lookup reads the canonical item (the one the
7911
+ # client reads), and a sign-in prunes the siblings only once its identity checked out.
7912
+ python3 - "$KCP/accounts.json" <<'EOF2'
7913
+ import json, sys
7914
+ doc = json.load(open(sys.argv[1]))
7915
+ doc['accounts'].append({'id': 'acct-06', 'email': 'kfix@test', 'home': 'mac',
7916
+ 'added_at': '2026-09-24T00:00:00Z'})
7917
+ json.dump(doc, open(sys.argv[1], 'w'))
7918
+ EOF2
7919
+ mkdir -p "$KCP/acct-06"
7920
+ KC_DEAD='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdead","refreshToken":"r","expiresAt":1000,"refreshTokenExpiresAt":1000}}'
7921
+ KC_UNK='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kunk","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
7922
+ FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7923
+ FAKE_KEYCHAIN_MDAT=20260924095747 kc_put "$KCP/acct-06" "$KC_UNK" unknown
7924
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
7925
+ python3 - "$out" <<'EOF2'
7926
+ import json, sys
7927
+ a6 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-06']
7928
+ assert a6['selectable'] is False and a6['needs_login'] is True, a6
7929
+ EOF2
7930
+ [ $? -eq 0 ] && t_ok "the pool judges the canonical (dead) item its client reads, not the newer sibling" \
7931
+ || t_fail "keychain canonical read" "see assertions above"
7932
+ # A sign-in as the WRONG account: the client itself overwrites the canonical item, but
7933
+ # nothing multiacc does may touch the rest — the live sibling can be the right account's.
7934
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=intruder@test \
7935
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7936
+ [ "$rc" != "0" ] && t_ok "a wrong-identity login fails" || t_fail "wrong-identity login" "rc=0"
7937
+ check "a wrong-identity login says nothing was saved" "nothing saved" "$out"
7938
+ [ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
7939
+ && [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_UNK" ] \
7940
+ && t_ok "a wrong-identity login leaves every sibling item intact" \
7941
+ || t_fail "wrong-identity keychain login" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7942
+ grep -q 'keychain prune acct-06' "$KCP/ops.log" 2>/dev/null \
7943
+ && t_fail "wrong-identity keychain login" "pruned before the identity check" \
7944
+ || t_ok "no prune ran for the wrong-identity login"
7945
+ FAKE_KEYCHAIN_MDAT=20260910173739 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7946
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kfix@test \
7947
+ FAKE_LOGIN_SAW="$WORK/kc-login-saw" CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7948
+ [ "$rc" = "0" ] && t_ok "a USER-less login over a dead canonical + live sibling succeeds" \
7949
+ || t_fail "USER-less keychain login" "rc=$rc: $(printf '%s' "$out" | tail -c 200)"
7950
+ check "the USER-less login reports the keychain store" "acct-06 login saved (macOS Keychain" "$out"
7951
+ [ "$(cat "$WORK/kc-login-saw" 2>/dev/null)" = "$KC_ME" ] \
7952
+ && t_ok "the client the ceremony starts gets USER=\$(id -un), not Bun's 'unknown'" \
7953
+ || t_fail "ceremony USER fill" "client saw USER=$(cat "$WORK/kc-login-saw" 2>/dev/null)"
7954
+ [ "$(kc_items "$KCP/acct-06")" = "$KC_ME" ] \
7955
+ && t_ok "the verified login leaves exactly ONE item, under \$(id -un)" \
7956
+ || t_fail "keychain prune" "items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7957
+ kc_secret "$KCP/acct-06" "$KC_ME" | grep -q 'sk-ant-oat01-login' \
7958
+ && t_ok "the surviving item is the fresh login" \
7959
+ || t_fail "keychain prune" "the canonical item does not hold the fresh login"
7960
+ grep -q "keychain prune acct-06: pruned: kept $KC_ME, removed unknown" "$KCP/ops.log" 2>/dev/null \
7961
+ && t_ok "the prune is logged (non-secret) to ops.log" \
7962
+ || t_fail "keychain prune log" "no prune line in ops.log"
7963
+ grep -q 'sk-ant' "$KCP/ops.log" 2>/dev/null \
7964
+ && t_fail "keychain prune log" "a credential reached ops.log" \
7965
+ || t_ok "ops.log carries no credential"
7966
+
7967
+ # 17l. a failed ceremony changes nothing — not even the stale items ("nothing changed")
7968
+ FAKE_KEYCHAIN_MDAT=20260912000000 kc_put "$KCP/acct-06" "$KC_DEAD" "$KC_ME"
7969
+ FAKE_KEYCHAIN_MDAT=20260913000000 kc_put "$KCP/acct-06" "$KC_DEAD" unknown
7970
+ out="$(env -u USER -u LOGNAME CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_FAIL=1 FAKE_EMAIL=kfix@test \
7971
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts login acct-06 2>&1)"; rc=$?
7972
+ [ "$rc" != "0" ] && [ "$(kc_items "$KCP/acct-06" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
7973
+ && [ "$(kc_secret "$KCP/acct-06" unknown)" = "$KC_DEAD" ] \
7974
+ && t_ok "an aborted login leaves every item as it was" \
7975
+ || t_fail "aborted keychain login" "rc=$rc items: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7976
+
7977
+ # 17m. remove drops EVERY item for the dir, whatever name it sits under
7978
+ FAKE_KEYCHAIN_MDAT=20260101000000 kc_put "$KCP/acct-06" "$KC_DEAD" claude-code-user
7979
+ FAKE_KEYCHAIN_MDAT=20260102000000 kc_put "$KCP/acct-06" "$KC_DEAD" renamed-user
7980
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-06 --yes >/dev/null 2>&1
7981
+ [ -z "$(kc_items "$KCP/acct-06")" ] && t_ok "remove leaves zero items when duplicates exist" \
7982
+ || t_fail "keychain remove duplicates" "left: $(kc_items "$KCP/acct-06" | tr '\n' ' ')"
7983
+
7984
+ # 17n. the shim judges exactly the item its client will read: -a <$USER, filled>. A
7985
+ # live login under any other name is invisible to that client, so it is no auth here.
7986
+ KSP="$WORK/kcshim"
7987
+ mkdir -p "$KSP/tmp" "$KSP/acct-01" "$KSP/acct-02"
7988
+ : > "$KSP/.limits-kick"
7989
+ cat > "$KSP/accounts.json" <<EOF2
7990
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
7991
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
7992
+ "accounts": [
7993
+ {"id": "acct-01", "email": "sfile@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"},
7994
+ {"id": "acct-02", "email": "schain@test", "home": "mac", "added_at": "2026-09-24T00:00:00Z"}
7995
+ ] }
7996
+ EOF2
7997
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-sfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KSP/acct-01/.credentials.json"
7998
+ printf '%s\nbucket=session percent=95 reason=limits\n' "$((now+3600))" > "$KSP/acct-01/.limited"
7999
+ KC_LIVE='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-slive","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
8000
+ kc_put "$KSP/acct-02" "$KC_LIVE" unknown
8001
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
8002
+ check "shim ignores a live item under a name its client never reads" "CFG=acct-01" "$out"
8003
+ FAKE_KEYCHAIN_MDAT=20260910000000 kc_put "$KSP/acct-02" "$KC_LIVE" "$KC_ME"
8004
+ FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KSP/acct-02" "$KC_DEAD" unknown
8005
+ out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_ACCOUNTS_DIR="$KSP" claude 2>&1)"
8006
+ check "shim reads the -a \$(id -un) item even when a newer sibling is dead" "CFG=acct-02" "$out"
8007
+ check "the client the shim starts inherits the filled USER/LOGNAME" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
8008
+ out="$(env -u USER -u LOGNAME FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
8009
+ check "a passthrough launch gets the filled USER too" "USER=$KC_ME LOGNAME=$KC_ME" "$out"
8010
+ out="$(env USER=other.user LOGNAME=keep FAKE_PRINT_USER=1 CLAUDE_CONFIG_DIR="$KSP/acct-02" claude 2>&1)"
8011
+ check "a caller's own USER/LOGNAME are never overridden" "USER=other.user LOGNAME=keep" "$out"
8012
+
8013
+ # 17o. an item under a name the client does not read is NO login to anything that judges
8014
+ # the account — list, audit, verify — just as it is none to the shim and the client. Only
8015
+ # the limits pass looks further, and moves it under the canonical name. my-mini's
8016
+ # acct-06/acct-21 shape (the only item under "unknown"), with and without a setup token,
8017
+ # plus the reverse order: a stale sibling OLDER than the canonical item.
8018
+ KLP="$WORK/kclegacy"
8019
+ # The accounts live on THIS machine (CI runs on Linux): a login-less account whose home
8020
+ # is another machine is 'remote', not one that needs a login here.
8021
+ KLP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || KLP_HOME=server
8022
+ mkdir -p "$KLP/tmp" "$KLP/acct-01" "$KLP/acct-02" "$KLP/acct-03"
8023
+ : > "$KLP/.limits-kick"
8024
+ cat > "$KLP/accounts.json" <<EOF2
8025
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
8026
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
8027
+ "accounts": [
8028
+ {"id": "acct-01", "email": "ltok@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
8029
+ {"id": "acct-02", "email": "lonly@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"},
8030
+ {"id": "acct-03", "email": "lorder@test", "home": "$KLP_HOME", "added_at": "2026-09-24T00:00:00Z"}
8031
+ ] }
8032
+ EOF2
8033
+ printf 'sk-ant-oat01-legacy-token-01' > "$KLP/acct-01/server.token"
8034
+ KC_L1='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l1","refreshToken":"sk-ant-ort01-l1","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
8035
+ KC_L2='{"claudeAiOauth":{"accessToken":"sk-ant-oat01-l2","refreshToken":"sk-ant-ort01-l2","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
8036
+ kc_put "$KLP/acct-01" "$KC_L1" unknown
8037
+ kc_put "$KLP/acct-02" "$KC_L2" unknown
8038
+ FAKE_KEYCHAIN_MDAT=20260901000000 kc_put "$KLP/acct-03" "$KC_DEAD" unknown
8039
+ FAKE_KEYCHAIN_MDAT=20260924000000 kc_put "$KLP/acct-03" "$KC_LIVE" "$KC_ME"
8040
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
8041
+ python3 - "$out" <<'EOF2'
8042
+ import json, sys
8043
+ rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
8044
+ a1, a2, a3 = rows['acct-01'], rows['acct-02'], rows['acct-03']
8045
+ assert a1['credentials']['oauth_store'] is None, a1['credentials']
8046
+ assert a2['selectable'] is False and a2['needs_login'] is True, a2
8047
+ assert a2['credentials']['oauth_store'] is None, a2['credentials']
8048
+ assert a3['selectable'] is True and a3['credentials']['keychain'] == 'readable', a3
8049
+ EOF2
8050
+ [ $? -eq 0 ] && t_ok "list --json counts only the item its client reads (none under another name; canonical over an older sibling)" \
8051
+ || t_fail "keychain legacy list" "see assertions above"
8052
+ out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
8053
+ 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"
8054
+ check "verify skips an account whose only login its client cannot read" "acct-02 lonly@test: SKIP" "$out"
8055
+ check "verify passes the canonical item beside an older stale sibling" "acct-03 lorder@test: PASS" "$out"
8056
+ [ ! -f "$KLP/acct-01/.expired" ] && [ -f "$KLP/acct-01/.server-token-verified" ] \
8057
+ && t_ok "verify neither parks it nor forgets its token proof" \
8058
+ || t_fail "keychain legacy verify" "acct-01: $(cat "$KLP/acct-01/.expired" 2>/dev/null | tail -1)"
8059
+ 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)"
8060
+ check "the limits pass moves a login filed under another name" "acct-02: Keychain login moved under $KC_ME" "$out"
8061
+ check "and reads usage with it" "acct-02: ok" "$out"
8062
+ [ "$(kc_items "$KLP/acct-01")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-01")" = "$KC_L1" ] \
8063
+ && [ "$(kc_items "$KLP/acct-02")" = "$KC_ME" ] && [ "$(kc_secret "$KLP/acct-02")" = "$KC_L2" ] \
8064
+ && t_ok "each moved login is the one item left, under \$(id -un), unchanged" \
8065
+ || t_fail "keychain legacy move" "acct-01: $(kc_items "$KLP/acct-01" | tr '\n' ' ') acct-02: $(kc_items "$KLP/acct-02" | tr '\n' ' ')"
8066
+ [ "$(kc_items "$KLP/acct-03" | sort | tr '\n' ' ')" = "$(printf '%s\n' "$KC_ME" unknown | sort | tr '\n' ' ')" ] \
8067
+ && t_ok "the pass leaves an account with a canonical item alone (no write, no prune)" \
8068
+ || t_fail "keychain legacy move" "acct-03: $(kc_items "$KLP/acct-03" | tr '\n' ' ')"
8069
+ out="$(env -u USER -u LOGNAME CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts list --json 2>&1)"
8070
+ python3 - "$out" <<'EOF2'
8071
+ import json, sys
8072
+ a2 = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}['acct-02']
8073
+ assert a2['selectable'] is True and a2['credentials']['keychain'] == 'readable', a2
8074
+ EOF2
8075
+ [ $? -eq 0 ] && t_ok "once moved, the login counts everywhere" \
8076
+ || t_fail "keychain legacy move list" "see assertions above"
8077
+ out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
8078
+ check "and its client can use it" "acct-02 lonly@test: PASS" "$out"
8079
+
7898
8080
  export CLAUDE_MULTIACC_KEYCHAIN=0
7899
8081
  unset FAKE_KEYCHAIN_DIR
7900
8082
 
@@ -8842,6 +9024,12 @@ else
8842
9024
  t_fail "auto-resume watcher unit suite" "see unittest output above"
8843
9025
  fi
8844
9026
 
9027
+ if python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_*token_identity*.py'; then
9028
+ t_ok "claude: portable token organization identity suite"
9029
+ else
9030
+ t_fail "claude portable token identity suite" "see unittest output above"
9031
+ fi
9032
+
8845
9033
  # ---- summary ---------------------------------------------------------------------
8846
9034
  echo
8847
9035
  echo "passed: $PASS failed: $FAIL"