claude-multiacc 2.0.42 → 2.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/claude +20 -3
- package/bin/claude-accounts +36 -11
- package/docs/ACCOUNT_OPERATIONS.md +5 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/common.sh +24 -1
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/fake_security.sh +90 -0
- package/tests/run-tests.sh +243 -64
- package/tests/test_keychain.py +323 -50
package/README.md
CHANGED
|
@@ -274,6 +274,17 @@ account's `.credentials.json` (0600), or its macOS Keychain item.
|
|
|
274
274
|
> login made over ssh migrates into the Keychain the first time a GUI-session process
|
|
275
275
|
> refreshes its token — the pool reads both places (`lib/keychain.py`), and an account
|
|
276
276
|
> that must work from everywhere should carry a portable token (`claude-accounts mint`).
|
|
277
|
+
> The item's **account name** is the client's `$USER` — and a client started without USER
|
|
278
|
+
> files it under `unknown`, beside the older item, where the next client never looks. So
|
|
279
|
+
> `claude-accounts`/`codex-accounts` and the shim fill `USER`/`LOGNAME` from `id -un` when
|
|
280
|
+
> the caller's env has none, and the pool judges only the item under that canonical name —
|
|
281
|
+
> the one the client reads (one `security` call per lookup). A login found only under
|
|
282
|
+
> another name counts nowhere until the scheduled limits pass moves it under the canonical
|
|
283
|
+
> name. A token refresh writes back under the canonical name and drops the siblings it
|
|
284
|
+
> made stale (the same grant, a dead one) but keeps another live grant; a sign-in whose
|
|
285
|
+
> identity checked out prunes every sibling; `remove` deletes every item.
|
|
286
|
+
> `python3 lib/keychain.py prune <acct dir>` prunes by hand (only when the canonical item
|
|
287
|
+
> is a live login; it never writes).
|
|
277
288
|
> Override: `CLAUDE_MULTIACC_KEYCHAIN=0` disables the lookup. This is what keeps idle accounts' telemetry fresh so they win
|
|
278
289
|
selection over busy accounts; without it, stale telemetry ranks neutral and a truly-idle
|
|
279
290
|
account would lose to a busy-but-fresh one. Refresh failures fail open and back off via
|
package/bin/claude
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
set -u
|
|
14
14
|
|
|
15
|
+
# Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
|
|
16
|
+
# USER calls the user "unknown" — so a caller whose env lacks it (app-robot's panel
|
|
17
|
+
# ceremony, 2026-09-24) gets a login filed under a name the next client never reads
|
|
18
|
+
# (lib/keychain.py). Every client this shim starts, passthrough included, inherits a
|
|
19
|
+
# real name; lib/common.sh does the same for claude-accounts.
|
|
20
|
+
if [ -z "${USER:-}" ]; then
|
|
21
|
+
USER="$(id -un 2>/dev/null || true)"
|
|
22
|
+
if [ -n "$USER" ]; then export USER; else unset USER; fi
|
|
23
|
+
fi
|
|
24
|
+
if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
|
|
25
|
+
|
|
15
26
|
# ${HOME:-} guards: with HOME stripped (env -i, some cron/systemd units) the shim
|
|
16
27
|
# must still fail OPEN into plain passthrough, never abort on an unbound variable.
|
|
17
28
|
# CLAUDE_ACCOUNTS_ROOT scopes the pool to one app-robot instance; CLAUDE_ACCOUNTS_DIR
|
|
@@ -177,7 +188,8 @@ mcp_nested_mirror() {
|
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
# Fast passthrough: caller pinned a config dir or token, addon disabled, recursion
|
|
180
|
-
# guard, or no account data yet. Byte-identical behavior to stock claude
|
|
191
|
+
# guard, or no account data yet. Byte-identical behavior to stock claude (a USER the
|
|
192
|
+
# caller left out is filled at the top — see there).
|
|
181
193
|
if [ -n "${CLAUDE_CONFIG_DIR:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
|
|
182
194
|
|| [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] || [ -n "${CLAUDE_SHIM_ACTIVE:-}" ] \
|
|
183
195
|
|| [ ! -f "$MANIFEST" ]; then
|
|
@@ -216,6 +228,11 @@ case "${CLAUDE_MULTIACC_KEYCHAIN:-}" in
|
|
|
216
228
|
*) if [ "$(uname -s)" = "Darwin" ]; then KC_ON=1; else KC_ON=0; fi ;;
|
|
217
229
|
esac
|
|
218
230
|
if [ "$KC_ON" = "1" ] && ! command -v security >/dev/null 2>&1; then KC_ON=0; fi
|
|
231
|
+
# The client reads ONLY the item under its own account name — $USER (filled above),
|
|
232
|
+
# anything outside [A-Za-z0-9._-] becoming "claude-code-user" — so that one item is what
|
|
233
|
+
# this shim judges: a sibling under another name is invisible to the client it launches.
|
|
234
|
+
KC_ACCOUNT="${USER:-}"
|
|
235
|
+
case "$KC_ACCOUNT" in ''|*[!A-Za-z0-9._-]*) KC_ACCOUNT=claude-code-user ;; esac
|
|
219
236
|
kc_service() { # $1 = acct dir -> the keychain service name the client uses for it
|
|
220
237
|
local h=""
|
|
221
238
|
if command -v shasum >/dev/null 2>&1; then h="$(printf '%s' "$1" | shasum -a 256 2>/dev/null)"
|
|
@@ -230,7 +247,7 @@ kc_lookup() { # $1 = acct dir; sets KC_JSON; rc 0 readable, 1 none, 2 exists but
|
|
|
230
247
|
[ "$KC_ON" = "1" ] || { KC_JSON=""; return 1; }
|
|
231
248
|
if [ "$KC_MEMO_DIR" = "$1" ]; then return "$KC_MEMO_RC"; fi
|
|
232
249
|
local out rc
|
|
233
|
-
out="$(security find-generic-password -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
|
|
250
|
+
out="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" -w 2>/dev/null)"; rc=$?
|
|
234
251
|
KC_MEMO_DIR="$1"; KC_JSON=""; KC_MEMO_RC=1
|
|
235
252
|
case "$rc" in
|
|
236
253
|
0) case "$out" in *claudeAiOauth*) KC_JSON="$out"; KC_MEMO_RC=0 ;; esac ;;
|
|
@@ -242,7 +259,7 @@ kc_mtime() { # $1 = acct dir -> epoch of the item's last write (0 when none); at
|
|
|
242
259
|
# are readable even when the secret is locked, so a marker still self-heals
|
|
243
260
|
[ "$KC_ON" = "1" ] || { echo 0; return 0; }
|
|
244
261
|
local stamp
|
|
245
|
-
stamp="$(security find-generic-password -s "$(kc_service "$1")" 2>/dev/null \
|
|
262
|
+
stamp="$(security find-generic-password -a "$KC_ACCOUNT" -s "$(kc_service "$1")" 2>/dev/null \
|
|
246
263
|
| LC_ALL=C sed -n 's/.*"mdat"<timedate>=0x[0-9A-Fa-f]* *"\([0-9]\{14\}\)Z.*/\1/p' | head -1)"
|
|
247
264
|
case "$stamp" in
|
|
248
265
|
[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])
|
package/bin/claude-accounts
CHANGED
|
@@ -678,6 +678,8 @@ cmd_add() {
|
|
|
678
678
|
mutate_unlock
|
|
679
679
|
RESERVED_DIR="" # committed — the trap must not delete it now
|
|
680
680
|
trap - EXIT INT TERM
|
|
681
|
+
# Registered under the identity it read back: leave one Keychain item for the dir.
|
|
682
|
+
[ "$token" = "1" ] || keychain_prune "$d"
|
|
681
683
|
# Only the full-login path actually READ the identity back. Saying "verified" for a
|
|
682
684
|
# setup token would relaunder the very assumption this flow just warned about.
|
|
683
685
|
local verdict="sign-in verified"
|
|
@@ -784,9 +786,10 @@ def has_auth(aid):
|
|
|
784
786
|
if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
|
|
785
787
|
return True
|
|
786
788
|
# A login the client moved into the macOS Keychain still counts — deduping must
|
|
787
|
-
# not throw away the one duplicate that actually holds the grant
|
|
789
|
+
# not throw away the one duplicate that actually holds the grant, even one filed
|
|
790
|
+
# under a name its client no longer reads (the limits pass moves those back).
|
|
788
791
|
try:
|
|
789
|
-
return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
|
|
792
|
+
return keychain.probe(d, legacy=True)['state'] in ('present', 'locked', 'corrupt')
|
|
790
793
|
except Exception:
|
|
791
794
|
return False
|
|
792
795
|
by_email = {}
|
|
@@ -1323,6 +1326,9 @@ cmd_login() {
|
|
|
1323
1326
|
if [ -z "$got" ] && [ "$force" != "1" ]; then
|
|
1324
1327
|
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
1325
1328
|
fi
|
|
1329
|
+
# Only now, with the identity confirmed, may the stale Keychain items go: a sign-in
|
|
1330
|
+
# as the wrong account (died above) must leave every item as it was.
|
|
1331
|
+
keychain_prune "$d"
|
|
1326
1332
|
clear_auth_markers "$d" keep-token-park
|
|
1327
1333
|
if [ -s "$d/.credentials.json" ]; then
|
|
1328
1334
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
@@ -1859,36 +1865,49 @@ class FileStore:
|
|
|
1859
1865
|
|
|
1860
1866
|
class KeychainStore:
|
|
1861
1867
|
"""The same credential, held in the macOS Keychain by the client itself (see
|
|
1862
|
-
lib/keychain.py). Read
|
|
1863
|
-
|
|
1864
|
-
|
|
1868
|
+
lib/keychain.py). Read from the item the client reads (the canonical name; this
|
|
1869
|
+
pass alone also accepts the legacy first match, which oauth_store moves) and written
|
|
1870
|
+
back under the canonical name, which also drops the siblings holding the grant the
|
|
1871
|
+
refresh started from — NEVER copied out to a file: a file beside a Keychain item is
|
|
1872
|
+
a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
|
|
1873
|
+
so the two would drift apart and strand one."""
|
|
1865
1874
|
kind = 'keychain'
|
|
1866
1875
|
|
|
1867
1876
|
def __init__(self, d, probe):
|
|
1868
1877
|
self.d = d
|
|
1869
|
-
self.
|
|
1878
|
+
self.grant = None
|
|
1879
|
+
self.moved = False
|
|
1870
1880
|
|
|
1871
1881
|
def read(self):
|
|
1872
|
-
p = keychain.probe(self.d)
|
|
1882
|
+
p = keychain.probe(self.d, legacy=True)
|
|
1873
1883
|
if p['state'] != 'present':
|
|
1874
1884
|
raise ValueError(f'keychain credential {p["state"]}')
|
|
1885
|
+
grant = p['doc']['claudeAiOauth'].get('refreshToken')
|
|
1886
|
+
self.grant = grant if isinstance(grant, str) else None
|
|
1875
1887
|
return p['doc']
|
|
1876
1888
|
|
|
1877
1889
|
def write(self, doc):
|
|
1878
|
-
if not keychain.write(self.d, doc,
|
|
1890
|
+
if not keychain.write(self.d, doc, supersedes=self.grant):
|
|
1879
1891
|
raise OSError('keychain write refused')
|
|
1880
1892
|
|
|
1881
1893
|
|
|
1882
1894
|
def oauth_store(d):
|
|
1883
1895
|
"""Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
|
|
1884
1896
|
the string 'locked' (Keychain item exists but this session cannot open it), or
|
|
1885
|
-
None. The file wins when both exist — same rule as lib/audit.oauth_login.
|
|
1897
|
+
None. The file wins when both exist — same rule as lib/audit.oauth_login.
|
|
1898
|
+
|
|
1899
|
+
A Keychain login found only under a name its client no longer reads (a USER-less
|
|
1900
|
+
sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
|
|
1901
|
+
until then the client, the shim and the audit all see no login."""
|
|
1886
1902
|
cpath = os.path.join(d, '.credentials.json')
|
|
1887
1903
|
if os.path.isfile(cpath):
|
|
1888
1904
|
return FileStore(cpath)
|
|
1889
|
-
p = keychain.probe(d)
|
|
1905
|
+
p = keychain.probe(d, legacy=True)
|
|
1890
1906
|
if p['state'] == 'present':
|
|
1891
|
-
|
|
1907
|
+
store = KeychainStore(d, p)
|
|
1908
|
+
if p['account'] != p['canonical']:
|
|
1909
|
+
store.moved = keychain.write(d, p['doc'])
|
|
1910
|
+
return store
|
|
1892
1911
|
if p['state'] == 'locked':
|
|
1893
1912
|
return 'locked'
|
|
1894
1913
|
return None
|
|
@@ -2096,6 +2115,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2096
2115
|
except Exception as e:
|
|
2097
2116
|
say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
|
|
2098
2117
|
store = None
|
|
2118
|
+
if getattr(store, 'moved', False):
|
|
2119
|
+
say(f'{aid}: Keychain login moved under {keychain.canonical_account()}, '
|
|
2120
|
+
f'the name its client reads')
|
|
2099
2121
|
locked = store == 'locked'
|
|
2100
2122
|
if locked:
|
|
2101
2123
|
store = None
|
|
@@ -2784,6 +2806,9 @@ for acct in manifest.get('accounts', []):
|
|
|
2784
2806
|
# The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
|
|
2785
2807
|
# session cannot open is not a failure of the ACCOUNT: the real call below
|
|
2786
2808
|
# would fail for the session, not the grant, so it is a skip with a reason.
|
|
2809
|
+
# Only the canonical item counts — the one the client below reads; testing an
|
|
2810
|
+
# item under another name would run the client on a login it cannot see and
|
|
2811
|
+
# park an account its token serves.
|
|
2787
2812
|
kc = keychain.probe(d)
|
|
2788
2813
|
if kc['state'] == 'present':
|
|
2789
2814
|
has_creds, cred_doc = True, kc['doc']
|
|
@@ -73,6 +73,11 @@ auto-refreshing login — `.credentials.json`, or on macOS the login Keychain wh
|
|
|
73
73
|
the session can open it (Claude Code migrates the file into the Keychain on the first
|
|
74
74
|
refresh from a keychain-capable session; ssh sessions then see the account as
|
|
75
75
|
`KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
|
|
76
|
+
The Keychain item is named after `$USER`; the CLI fills it from `id -un` when the caller's
|
|
77
|
+
env has none (a USER-less client files its login under `unknown`, where the next client
|
|
78
|
+
never reads it). The pool judges only the item under that name, as the client does; a
|
|
79
|
+
login found only under another name is moved there by the next limits pass, and a
|
|
80
|
+
sign-in whose identity matched leaves exactly one item, under that name.
|
|
76
81
|
That credential is **machine-local** (never synced),
|
|
77
82
|
which is what keeps two machines from invalidating each other's refresh token — so an
|
|
78
83
|
account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/common.sh
CHANGED
|
@@ -12,6 +12,19 @@
|
|
|
12
12
|
# shellcheck disable=SC2034
|
|
13
13
|
|
|
14
14
|
MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
|
|
15
|
+
|
|
16
|
+
# Claude Code names its macOS Keychain item after $USER, and under Bun a client with no
|
|
17
|
+
# USER calls the user "unknown": `claude-accounts login` run from an env without it
|
|
18
|
+
# (app-robot's panel, 2026-09-24) filed the fresh login beside the old item, under a
|
|
19
|
+
# name the next client never reads (lib/keychain.py). So every client these CLIs start
|
|
20
|
+
# inherits a real name, whatever env the caller passed; bin/claude does the same.
|
|
21
|
+
# Harmless for codex and on Linux.
|
|
22
|
+
if [ -z "${USER:-}" ]; then
|
|
23
|
+
USER="$(id -un 2>/dev/null || true)"
|
|
24
|
+
if [ -n "$USER" ]; then export USER; else unset USER; fi
|
|
25
|
+
fi
|
|
26
|
+
if [ -z "${LOGNAME:-}" ] && [ -n "${USER:-}" ]; then export LOGNAME="$USER"; fi
|
|
27
|
+
|
|
15
28
|
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
16
29
|
DEFAULT_SERVER="root@138.197.36.107"
|
|
17
30
|
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
@@ -469,10 +482,20 @@ keychain_state() { # $1 = acct dir -> prints present|locked|absent|corrupt|unava
|
|
|
469
482
|
keychain_has_login() { # $1 = acct dir: an item EXISTS here (readable or locked)
|
|
470
483
|
case "$(keychain_state "$1")" in present|locked|corrupt) return 0 ;; *) return 1 ;; esac
|
|
471
484
|
}
|
|
472
|
-
keychain_forget() { # $1 = acct dir: drop
|
|
485
|
+
keychain_forget() { # $1 = acct dir: drop every item (a removed account must not leave a grant behind)
|
|
473
486
|
[ "$MULTIACC_PROVIDER" = "codex" ] && return 0
|
|
474
487
|
"$PYBIN" "$LIB_DIR/keychain.py" delete "$1" >/dev/null 2>&1 || true
|
|
475
488
|
}
|
|
489
|
+
keychain_prune() { # $1 = acct dir: drop the non-canonical items once the canonical one is a
|
|
490
|
+
# live login (lib/keychain.py prune). Quiet, never fails; logs only a change.
|
|
491
|
+
[ "$MULTIACC_PROVIDER" = "codex" ] && return 0
|
|
492
|
+
local line
|
|
493
|
+
line="$("$PYBIN" "$LIB_DIR/keychain.py" prune "$1" 2>/dev/null)" || true
|
|
494
|
+
case "$line" in
|
|
495
|
+
pruned:*|failed:*) log_to ops.log "keychain prune $(basename "$1"): $line" ;;
|
|
496
|
+
esac
|
|
497
|
+
return 0
|
|
498
|
+
}
|
|
476
499
|
|
|
477
500
|
# ---- expired-login bookkeeping -------------------------------------------------
|
|
478
501
|
# `.expired` is the persistent "this account cannot authenticate" marker the shim
|