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 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])
@@ -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 and written in place — NEVER copied out to a file: a file
1863
- beside a Keychain item is a second copy of a ROTATING refresh grant, and the
1864
- client reads the Keychain first, so the two would drift apart and strand one."""
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.account = probe.get('account')
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, account=self.account):
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
- return KeychainStore(d, p)
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
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 the item (a removed account must not leave a grant behind)
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