claude-multiacc 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,11 @@ See README.md (tested reality). Deltas from plan, decided during implementation:
40
40
  Original plan below. Verified facts from planning:
41
41
  - This Mac stores Claude Code credentials per-config-dir in files (`.credentials.json`),
42
42
  NOT in macOS Keychain (tested 2026-07-13, Claude Code 2.1.207) → per-dir logins coexist.
43
+ **Superseded 2026-08-28:** that observation was an artefact of testing over ssh. Claude
44
+ Code (verified on 2.1.250) writes each config dir's login to the login Keychain
45
+ (`Claude Code-credentials-<sha256(dir)[:8]>`) from any session that can open it and
46
+ deletes the file; ssh/background sessions fall back to the file. The pool now reads
47
+ both (`lib/keychain.py`); per-dir logins still coexist (one Keychain item per dir).
43
48
  - `CLAUDE_CONFIG_DIR` isolation confirmed working on this Mac (isolated dir = "Not logged in").
44
49
  - Real binary: `~/.local/bin/claude`. Interactive `claude` is a zsh alias adding
45
50
  `--dangerously-skip-permissions` (kept; it composes with the shim via PATH).
@@ -231,6 +236,8 @@ Per account, in ONE incognito browser window signed into that claude.ai account:
231
236
  - `setup-token` tokens are inference-only (no Remote Control) — acceptable for `-p` usage.
232
237
  - macOS installs that store creds in Keychain (not this Mac) would break per-dir `/login`
233
238
  isolation; the addon must detect this at install time and warn (token-based fallback).
239
+ (2026-08-28: wrong on both counts — the Keychain item is per config dir, so isolation
240
+ holds, and every Mac does this from a GUI session; the addon reads the Keychain now.)
234
241
  - The usage/limits endpoint is internal (what `/usage` consumes) and may change without
235
242
  notice; limit tracking must fail OPEN (account treated available, retry logic as backstop)
236
243
  so limits telemetry can never break the 100% `-p` guarantee.
package/README.md CHANGED
@@ -56,6 +56,7 @@ MAC (source of truth) SERVER (mirror)
56
56
  acct-01 -> ~/.claude ("adopted") acct-01/server.token (synced, 600, once minted)
57
57
  acct-02/ (registered) acct-02 -> /root/.claude ("adopted")
58
58
  server.token / .credentials.json
59
+ (or the login lives in the macOS Keychain — see below)
59
60
  selection.log sync.log health.log /usr/local/bin/claude -> repo shim
60
61
  repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
61
62
  ```
@@ -68,7 +69,8 @@ repo bin/ first on PATH (rc-file block) /root/claude-multiacc/ (addon repo)
68
69
  token, revoked grant, a run that failed to authenticate) or an **org-blocked** one
69
70
  (Claude Code disabled for that account). Those can only fail, so they never enter
70
71
  selection.
71
- 4. Among the accounts that remain valid **on this machine** (`.credentials.json` or
72
+ 4. Among the accounts that remain valid **on this machine** (an OAuth login —
73
+ `.credentials.json`, or a macOS Keychain item this session can open — or
72
74
  `server.token` present) and not limit-excluded, pick the one with the **most remaining
73
75
  headroom** (see below). On equal headroom the pool **rotates away from the account it
74
76
  just handed out** and samples the rest at random — so quitting a session and starting
@@ -177,8 +179,20 @@ Only when even that is unavailable does selection rank the account last, logged
177
179
  If an account's OAuth access token has been expired for a while (idle account,
178
180
  nothing ran claude under it for hours), the refresher renews it directly via the OAuth
179
181
  **refresh-token grant** — the same endpoint and public client id Claude Code itself uses —
180
- and atomically persists the rotated credential (0600) back to that account's
181
- `.credentials.json`. This is what keeps idle accounts' telemetry fresh so they win
182
+ and atomically persists the rotated credential back to wherever it came from: that
183
+ account's `.credentials.json` (0600), or its macOS Keychain item.
184
+
185
+ > **Where a claude OAuth login actually lives (macOS).** Claude Code writes each config
186
+ > dir's login to the **login Keychain** (service `Claude Code-credentials-<sha256(dir)[:8]>`)
187
+ > from any session that can open it — a Terminal window, a launchd agent — and then
188
+ > deletes `.credentials.json`. Sessions that cannot open the keychain (**ssh, tmux from
189
+ > ssh, background jobs**) fall back to the plaintext file, and they also cannot READ a
190
+ > Keychain-held login: those accounts show `KEYCHAIN LOCKED` there and are excluded from
191
+ > that session's selection, while working normally from the Mac's own session. So a
192
+ > login made over ssh migrates into the Keychain the first time a GUI-session process
193
+ > refreshes its token — the pool reads both places (`lib/keychain.py`), and an account
194
+ > that must work from everywhere should carry a portable token (`claude-accounts mint`).
195
+ > Override: `CLAUDE_MULTIACC_KEYCHAIN=0` disables the lookup. This is what keeps idle accounts' telemetry fresh so they win
182
196
  selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
183
197
  account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
184
198
  `<acct>/.oauth-refresh.json` (10 min transient, 6 h when the grant looks revoked — the log
@@ -260,7 +274,7 @@ above about the claude pool holds for the codex pool with these translations:
260
274
  | `bin/claude` shim, `claude-accounts` CLI | `bin/codex` shim, `codex-accounts` CLI |
261
275
  | pool `~/.claude-accounts` | pool `~/.codex-accounts` |
262
276
  | `CLAUDE_CONFIG_DIR` per-account dirs | `CODEX_HOME` per-account dirs |
263
- | `.credentials.json` (OAuth, machine-local) | `auth.json` (ChatGPT OAuth, machine-local) |
277
+ | `.credentials.json` / macOS Keychain item (OAuth, machine-local) | `auth.json` (ChatGPT OAuth, machine-local) |
264
278
  | `claude -p` auto-retry | `codex exec` auto-retry |
265
279
  | Anthropic OAuth usage and Fable buckets | Codex usage endpoint and per-model buckets |
266
280
  | `CLAUDE_*` pool controls | equivalent `CODEX_*` controls |
@@ -372,8 +386,9 @@ What install does (all reversible, nothing else):
372
386
  (+ bash rc files if present) — end-of-file placement matters because those files
373
387
  re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
374
388
  `.codex-limits` (5m), `.health` + `.codex-health` (weekly Mon morning), and
375
- `.update` (daily 04:07). Warns if this Mac stores credentials in the Keychain
376
- (file-based credentials are required for per-dir logins).
389
+ `.update` (daily 04:07). Notes when this Mac keeps Claude Code logins in the
390
+ Keychain (the pool reads them; ssh sessions cannot — mint portable tokens for
391
+ accounts that must work from everywhere).
377
392
  - **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`,
378
393
  shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
379
394
  PATH order — on the systemd default PATH too; the original binaries are untouched),
package/bin/claude CHANGED
@@ -96,6 +96,58 @@ else
96
96
  file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
97
97
  fi
98
98
 
99
+ # ---- macOS Keychain-held logins ----------------------------------------------------
100
+ # Claude Code on macOS moves a config dir's OAuth login into the login Keychain
101
+ # (service "Claude Code-credentials-<sha256(dir)[:8]>") the first time a GUI-session
102
+ # process can write there, deleting .credentials.json as it does. A session that
103
+ # cannot open the keychain (ssh/tmux/background: `security` exits 36) neither sees
104
+ # such a login nor can the real client it launches use it — so here "readable" is
105
+ # the only state that counts as auth, and a locked item counts as none FOR THIS
106
+ # SESSION (the audit reports it as 'locked' rather than missing). lib/keychain.py is
107
+ # the reference; this is the same lookup without a python start-up on the hot path.
108
+ case "${CLAUDE_MULTIACC_KEYCHAIN:-}" in
109
+ 0|false|no|off) KC_ON=0 ;;
110
+ 1|true|yes|on) KC_ON=1 ;;
111
+ *) if [ "$(uname -s)" = "Darwin" ]; then KC_ON=1; else KC_ON=0; fi ;;
112
+ esac
113
+ if [ "$KC_ON" = "1" ] && ! command -v security >/dev/null 2>&1; then KC_ON=0; fi
114
+ kc_service() { # $1 = acct dir -> the keychain service name the client uses for it
115
+ local h=""
116
+ if command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
117
+ elif command -v sha256sum >/dev/null 2>&1; then h="$(printf '%s' "$1" | sha256sum 2>/dev/null)"; fi
118
+ printf 'Claude Code-credentials-%s' "$(printf '%s' "$h" | cut -c1-8)"
119
+ }
120
+ # One lookup per dir per run: has_auth, auth_dead and acct_token all ask about the
121
+ # same dir in a row, and every `security` call is a process. Memoised in variables
122
+ # (not a $(...) result) so the memo survives — callers read KC_JSON after kc_lookup.
123
+ KC_MEMO_DIR=""; KC_MEMO_RC=1; KC_JSON=""
124
+ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but locked here
125
+ [ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
126
+ if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
127
+ local out rc
128
+ out="$(security find-generic-password -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
129
+ KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
130
+ case "$rc" in
131
+ 0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
132
+ 36) KC_MEMO_RC=2 ;;
133
+ esac
134
+ return "$KC_MEMO_RC"
135
+ }
136
+ kc_mtime() { # $1 = acct dir -> epoch of the item's last write (0 when none); attributes
137
+ # are readable even when the secret is locked, so a marker still self-heals
138
+ [ "$KC_ON" = "1" ] || { echo 0; return 0; }
139
+ local stamp
140
+ stamp="$(security find-generic-password -s "$(kc_service "$1")" 2>/dev/null \
141
+ | LC_ALL=C sed -n 's/.*"mdat"<timedate>=0x[0-9A-Fa-f]* *"\([0-9]\{14\}\)Z.*/\1/p' | head -1)"
142
+ case "$stamp" in
143
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9])
144
+ date -j -u -f '%Y%m%d%H%M%S' "$stamp" +%s 2>/dev/null \
145
+ || date -u -d "${stamp:0:8} ${stamp:8:2}:${stamp:10:2}:${stamp:12:2}" +%s 2>/dev/null \
146
+ || echo 0 ;;
147
+ *) echo 0 ;;
148
+ esac
149
+ }
150
+
99
151
 
100
152
  # A number this shim will do ARITHMETIC on: digits only, and short enough that bash
101
153
  # cannot go out of range. An over-range value makes `[ x -lt y ]` print
@@ -563,28 +615,43 @@ sel_capture_session() { # $1 acct dir
563
615
 
564
616
  # An empty credentials file is NOT auth (an interrupted write must not make a
565
617
  # dead account selectable and turn a working stock run into an auth failure).
566
- has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ]; }
618
+ # A Keychain-held login counts only when THIS session can read it (kc_lookup rc 0).
619
+ has_auth() { [ -s "$1/.credentials.json" ] || [ -s "$1/server.token" ] || kc_lookup "$1"; }
620
+ # The OAuth login itself, wherever the client keeps it (file or readable Keychain item).
621
+ has_oauth() { [ -f "$1/.credentials.json" ] || kc_lookup "$1"; }
567
622
 
568
- cred_num() { # cred_num <file> <json key> -> integer (epoch ms) or empty
623
+ cred_num() { # cred_num <json text> <json key> -> integer (epoch ms) or empty
569
624
  # The leading quote makes "expiresAt" unambiguous: "refreshTokenExpiresAt" cannot match it.
570
- LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" "$1" 2>/dev/null | head -1
625
+ printf '%s' "$1" | LC_ALL=C sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p" 2>/dev/null | head -1
571
626
  }
572
627
 
573
- # TRUE when <dir>/.credentials.json exists but can no longer authenticate: the access
628
+ # The credential document for <dir>: .credentials.json when present (even unreadable
629
+ # or truncated — the scrape below is deliberately lenient), else the Keychain item
630
+ # this session can read. Empty when there is neither.
631
+ cred_text() { # $1 = acct dir
632
+ if [ -s "$1/.credentials.json" ]; then
633
+ cat "$1/.credentials.json" 2>/dev/null
634
+ elif kc_lookup "$1"; then
635
+ printf '%s' "$KC_JSON"
636
+ fi
637
+ }
638
+
639
+ # TRUE when the dir's OAuth login exists but can no longer authenticate: the access
574
640
  # token is expired AND nothing can renew it (no refresh token, or the refresh token is
575
641
  # itself expired). Such an account fails EVERY run with "OAuth session expired and
576
642
  # could not be refreshed", so selecting it is strictly worse than not having it.
577
643
  # Anything unparseable fails OPEN (treated as alive) — telemetry-free guesswork must
578
644
  # never take a working account out of the pool.
579
645
  creds_dead() { # $1 = acct dir
580
- local f="$1/.credentials.json" exp rexp
581
- [ -s "$f" ] || return 1
582
- exp="$(cred_num "$f" expiresAt)"
646
+ local src exp rexp
647
+ has_oauth "$1" || return 1
648
+ src="$(cred_text "$1")"
649
+ exp="$(cred_num "$src" expiresAt)"
583
650
  case "$exp" in ''|*[!0-9]*) exp=0 ;; esac
584
651
  [ $((exp / 1000)) -gt "$now" ] && return 1 # access token still valid
585
652
  # Access token expired: only a live refresh token can save it.
586
- grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' "$f" 2>/dev/null || return 0
587
- rexp="$(cred_num "$f" refreshTokenExpiresAt)"
653
+ printf '%s' "$src" | grep -q '"refreshToken"[[:space:]]*:[[:space:]]*"[^"]' 2>/dev/null || return 0
654
+ rexp="$(cred_num "$src" refreshTokenExpiresAt)"
588
655
  case "$rexp" in ''|*[!0-9]*) return 1 ;; esac # unknown expiry => fail open
589
656
  [ $((rexp / 1000)) -le "$now" ]
590
657
  }
@@ -617,6 +684,12 @@ expired_marked() { # $1 = acct dir
617
684
  return 1
618
685
  fi
619
686
  done
687
+ # A login the client keeps in the Keychain is "written after the marker" in the
688
+ # same sense; its stamp is readable even from a session that cannot open it.
689
+ if [ ! -f "$1/.credentials.json" ] && [ "$(kc_mtime "$1")" -gt "$mt" ]; then
690
+ rm -f "$m" 2>/dev/null
691
+ return 1
692
+ fi
620
693
  fi
621
694
  soft="$(LC_ALL=C sed -n 's/.*soft_until=\([0-9][0-9]*\).*/\1/p' "$m" 2>/dev/null | head -1)"
622
695
  case "$soft" in
@@ -639,7 +712,7 @@ auth_dead() { # $1 = acct dir
639
712
  acct_token() { # $1 = acct dir; prints token if the dir must authenticate by token
640
713
  # Token-auth dirs (no local creds), and dirs whose OAuth credential is dead but which
641
714
  # still carry a portable setup-token — the token is the only thing that can work there.
642
- if [ -s "$1/server.token" ] && { [ ! -f "$1/.credentials.json" ] || creds_dead "$1"; }; then
715
+ if [ -s "$1/server.token" ] && { ! has_oauth "$1" || creds_dead "$1"; }; then
643
716
  tr -d '[:space:]' < "$1/server.token"
644
717
  fi
645
718
  }