claude-multiacc 2.0.3 → 2.0.4
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/CLAUDE_ACCS_TASK.md +7 -0
- package/README.md +21 -6
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +24 -7
- package/docs/ACCOUNT_OPERATIONS.md +6 -2
- package/install.sh +24 -8
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +87 -21
- package/lib/common.sh +62 -5
- package/lib/credential.py +9 -1
- package/lib/keychain.py +210 -0
- package/lib/report.py +33 -8
- package/package.json +1 -1
- package/tests/run-tests.sh +278 -0
- package/tests/test_keychain.py +204 -0
package/CLAUDE_ACCS_TASK.md
CHANGED
|
@@ -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** (
|
|
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
|
|
181
|
-
`.credentials.json
|
|
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).
|
|
376
|
-
(
|
|
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
|
-
|
|
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 <
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
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:]]*"[^"]'
|
|
587
|
-
rexp="$(cred_num "$
|
|
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" ] && {
|
|
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
|
}
|