claude-multiacc 2.0.41 → 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.
Files changed (33) hide show
  1. package/README.md +60 -2
  2. package/bin/claude +414 -5
  3. package/bin/claude-accounts +36 -11
  4. package/bin/codex +406 -4
  5. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  6. package/docs/AUTORESUME.md +321 -0
  7. package/docs/CODEX.md +12 -1
  8. package/docs/VERIFICATION.md +9 -1
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/autoresume.py +2271 -0
  20. package/lib/common.sh +24 -1
  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_codex_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  30. package/tests/fake_security.sh +90 -0
  31. package/tests/run-tests.sh +1178 -67
  32. package/tests/test_autoresume.py +2565 -0
  33. package/tests/test_keychain.py +323 -50
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
package/lib/keychain.py CHANGED
@@ -1,13 +1,11 @@
1
1
  """macOS Keychain-held Claude Code OAuth logins.
2
2
 
3
- Claude Code on macOS keeps a config dir's OAuth credential in the login Keychain —
4
- service ``Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[:8]>``, account = the
5
- macOS user name — whenever the keychain is writable, and DELETES the plaintext
6
- ``.credentials.json`` the moment a keychain write lands (its store is "keychain with
7
- a plaintext fallback": the fallback file is removed once the primary holds the
8
- credential). Only sessions that cannot open the keychain — ssh, tmux, launchd
9
- background jobs, where ``security`` exits 36 "user interaction is not allowed" — keep
10
- using the file.
3
+ Claude Code on macOS keeps a config dir's OAuth credential in the login Keychain
4
+ whenever the keychain is writable, and DELETES the plaintext ``.credentials.json``
5
+ the moment a keychain write lands (its store is "keychain with a plaintext fallback":
6
+ the fallback file is removed once the primary holds the credential). Only sessions
7
+ that cannot open the keychain — ssh, tmux, launchd background jobs, where ``security``
8
+ exits 36 "user interaction is not allowed" — keep using the file.
11
9
 
12
10
  So a per-dir login that was file-based when it was added (an ``add`` run over ssh)
13
11
  migrates into the Keychain the first time a GUI-session process refreshes its token —
@@ -16,6 +14,49 @@ file is gone. Reading only ``.credentials.json`` then reports a perfectly workin
16
14
  login as "no credentials on this machine": the shim stops selecting it, telemetry
17
15
  goes dark, and every consumer of ``list --json`` shows it missing.
18
16
 
17
+ The layout (read out of the Bun-compiled client, 2.1.281):
18
+ service ``Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[:8]>``
19
+ account the client's own name for the user: ``$USER``, else
20
+ ``os.userInfo().username``; a name outside ``[A-Za-z0-9._-]+`` (or a
21
+ lookup that throws) becomes ``claude-code-user``
22
+ read ``find-generic-password -a <name> -s <svc> -w`` — ONLY its own name; exit
23
+ 44 falls back to ``<config>/.credentials.json``
24
+ write ``add-generic-password -U -a <name> -s <svc> -X <hex>``
25
+ logout deletes ``-a <name>`` only
26
+
27
+ Under Bun, ``os.userInfo().username`` is ``"unknown"`` when USER is unset. So a
28
+ client started without USER — app-robot's panel ran ``claude-accounts login`` with an
29
+ env that had none (2026-09-24) — files its fresh login under ``unknown`` BESIDE an
30
+ older item under the real name, and ``security find-generic-password -s <svc>``
31
+ without ``-a`` answers with the OLDEST item: the dead one. That is how a sign-in that
32
+ worked read back as "no working credential landed". lib/common.sh and bin/claude now
33
+ fill USER from ``id -un`` when the caller's env has none, so every client multiacc
34
+ starts files and reads its login under :func:`canonical_account`.
35
+
36
+ Hence the rules here:
37
+ - a lookup reads exactly the item a multiacc-launched client reads: ``-a <canonical>``,
38
+ ONE ``security`` call, the cost of the old no ``-a`` lookup. Exit 44 is "absent",
39
+ even when an item sits under another name: its client cannot read it, so audit,
40
+ report, verify and the marker checks must not count it (a fallback that did made
41
+ ``list --json`` advertise accounts the shim never picks, and made ``verify`` test a
42
+ token-backed account on a login its client could not see, then park it);
43
+ - ``probe(d, legacy=True)`` adds the old no ``-a`` first match when no canonical item
44
+ exists. Only the limits pass (which then moves such a login under the canonical
45
+ name) and ``dedupe`` (which must not throw away a stranded grant) ask for it; it
46
+ costs a second call when no canonical item exists;
47
+ - :func:`write` always targets the canonical name. Once the canonical item reads back
48
+ with the access token just written, it deletes each other item that is provably
49
+ spent: the same grant (the written refresh token, or the ``supersedes`` one a
50
+ refresh started from), a dead login, or not a credential at all. A LIVE other grant
51
+ stays — after a sign-in as the wrong account the client itself put the intruder's
52
+ grant under the canonical name, and the right account's login may be that sibling;
53
+ - :func:`delete` removes every item for the service, whatever its name;
54
+ - :func:`prune` deletes all non-canonical items, and ONLY when the canonical item is
55
+ present, readable and a live credential (audit.creds_doc_state 'ok'). It never
56
+ writes and never handles the secret beyond that read. The sign-in commands run it
57
+ once the ceremony's identity check has passed — never before, so a sign-in as the
58
+ wrong account cannot cost the right account its login.
59
+
19
60
  This module is the one place that knows the keychain layout. Everything else asks
20
61
  :func:`probe`, which never raises and never prints the secret.
21
62
 
@@ -28,20 +69,25 @@ States returned by :func:`probe`:
28
69
  corrupt the item exists and was readable, but is not a credential document
29
70
  unavailable not macOS, no ``security`` tool, or it failed in a way that says
30
71
  nothing about the item (timeout, crash)
72
+ ``account`` is the name of the item that answered: the canonical one, or with
73
+ ``legacy=True`` whatever the fallback could name (None when it read a secret without
74
+ the extra call it would take to name its item). ``canonical`` is the name a write
75
+ targets.
31
76
 
32
77
  Override: ``CLAUDE_MULTIACC_KEYCHAIN=0`` turns the whole lookup off (every dir reads
33
78
  as ``absent``), ``=1`` forces it on (the test suite runs the real code path on Linux
34
79
  against a fake ``security``). Unset: on when running on macOS with ``security``
35
80
  available.
36
81
 
37
- Run directly: ``python3 lib/keychain.py <probe|service|mtime|delete> <config-dir>``.
82
+ Run directly: ``python3 lib/keychain.py <probe|service|mtime|delete|prune> <config-dir>``.
38
83
  """
39
84
  from __future__ import annotations
40
85
 
41
- import getpass
86
+ import calendar
42
87
  import hashlib
43
88
  import json
44
89
  import os
90
+ import pwd
45
91
  import re
46
92
  import shutil
47
93
  import subprocess
@@ -53,9 +99,15 @@ SERVICE_PREFIX = 'Claude Code-credentials'
53
99
  EXIT_NOT_FOUND = 44 # errSecItemNotFound (-25300)
54
100
  EXIT_NO_INTERACTION = 36 # errSecInteractionNotAllowed (-25308): keychain locked here
55
101
  TIMEOUT_S = 8
102
+ # The client's fallback name, and the one Bun's os.userInfo() reports without USER.
103
+ FALLBACK_ACCOUNT = 'claude-code-user'
104
+ BUN_UNSET_ACCOUNT = 'unknown'
105
+ # `delete-generic-password -s` without -a removes one (the oldest) item per call.
106
+ DELETE_SWEEP_CAP = 16
56
107
 
57
108
  _ATTR_ACCT = re.compile(r'"acct"<blob>="((?:[^"\\]|\\.)*)"')
58
109
  _ATTR_MDAT = re.compile(r'"mdat"<timedate>=0x[0-9A-Fa-f]+\s+"(\d{14})Z')
110
+ _VALID_ACCOUNT = re.compile(r'[A-Za-z0-9._-]+')
59
111
 
60
112
 
61
113
  def enabled():
@@ -76,6 +128,17 @@ def service_name(config_dir):
76
128
  return f'{SERVICE_PREFIX}-{digest}'
77
129
 
78
130
 
131
+ def canonical_account():
132
+ """The account name a client launched by multiacc files its login under: the
133
+ client's own rule (USER, else the user database) with the passwd entry standing in
134
+ for Bun's ``"unknown"`` — the name lib/common.sh and bin/claude fill USER with."""
135
+ try:
136
+ name = os.environ.get('USER') or pwd.getpwuid(os.getuid()).pw_name
137
+ except (KeyError, OSError):
138
+ return FALLBACK_ACCOUNT
139
+ return name if _VALID_ACCOUNT.fullmatch(name or '') else FALLBACK_ACCOUNT
140
+
141
+
79
142
  def _run(args, timeout=TIMEOUT_S):
80
143
  """(returncode, stdout, stderr); returncode None when the tool could not run."""
81
144
  try:
@@ -86,10 +149,16 @@ def _run(args, timeout=TIMEOUT_S):
86
149
  return p.returncode, p.stdout or '', p.stderr or ''
87
150
 
88
151
 
89
- def _attributes(service):
90
- """(rc, account, modified_epoch). Attribute reads work on a LOCKED keychain — only
91
- the secret needs it open — which is what tells 'locked' from 'absent'."""
92
- rc, out, _err = _run(['find-generic-password', '-s', service])
152
+ def _target(service, account):
153
+ """``-a <account> -s <service>``, or the no ``-a`` first (oldest) match."""
154
+ return (['-a', account] if account is not None else []) + ['-s', service]
155
+
156
+
157
+ def _attributes(service, account=None):
158
+ """(rc, account, modified_epoch) of one item — the oldest when ``account`` is None.
159
+ Attribute reads work on a LOCKED keychain — only the secret needs it open — which
160
+ is what tells 'locked' from 'absent'."""
161
+ rc, out, _err = _run(['find-generic-password'] + _target(service, account))
93
162
  if rc != 0:
94
163
  return rc, None, None
95
164
  acct = _ATTR_ACCT.search(out)
@@ -97,93 +166,234 @@ def _attributes(service):
97
166
  modified = None
98
167
  if mdat:
99
168
  try:
100
- import calendar
101
169
  modified = calendar.timegm(time.strptime(mdat.group(1), '%Y%m%d%H%M%S'))
102
170
  except (ValueError, OverflowError):
103
171
  modified = None
104
- return rc, (acct.group(1) if acct else None), modified
172
+ return rc, (acct.group(1) if acct else account), modified
173
+
105
174
 
175
+ def _names(service, skip=()):
176
+ """(ok, names) — the account names holding an item for ``service``: the oldest
177
+ item's (whatever it is called) plus each name the client can produce, minus
178
+ ``skip``. Several ``security`` calls, so only write/delete/prune enumerate — never
179
+ a lookup. ``ok`` is False when the tool failed in a way that says nothing about the
180
+ items (the list is then what was found before the failure)."""
181
+ rc, first, _m = _attributes(service)
182
+ if rc == EXIT_NOT_FOUND:
183
+ return True, []
184
+ if rc != 0:
185
+ return False, []
186
+ names = [first] if first is not None and first not in skip else []
187
+ for name in (canonical_account(), BUN_UNSET_ACCOUNT, FALLBACK_ACCOUNT):
188
+ if name == first or name in skip or name in names:
189
+ continue
190
+ rc, _acct, _m = _attributes(service, name)
191
+ if rc == EXIT_NOT_FOUND:
192
+ continue
193
+ if rc != 0:
194
+ return False, names
195
+ names.append(name)
196
+ return True, names
197
+
198
+
199
+ def _credential(text):
200
+ try:
201
+ doc = json.loads(text.strip())
202
+ except ValueError:
203
+ return None
204
+ if isinstance(doc, dict) and isinstance(doc.get('claudeAiOauth'), dict):
205
+ return doc
206
+ return None
106
207
 
107
- def probe(config_dir):
108
- """Never raises, never logs the secret. See the module doc for the states."""
208
+
209
+ def _doc_state(doc):
210
+ """audit.creds_doc_state's verdict ('ok' / 'expired') on a parsed credential — the
211
+ one rule for "can this login work", imported lazily because audit imports this
212
+ module."""
213
+ here = os.path.dirname(os.path.abspath(__file__))
214
+ if here not in sys.path:
215
+ sys.path.insert(0, here)
216
+ from audit import creds_doc_state
217
+ try:
218
+ return creds_doc_state(doc, time.time())
219
+ except ValueError as e:
220
+ return 'expired', str(e)[:60]
221
+
222
+
223
+ def _legacy_probe(service, result):
224
+ """The lookup as it was before canonical names: the no ``-a`` first match. Only
225
+ ``probe(legacy=True)`` asks for it, and only when no canonical item exists."""
226
+ rc, out, _err = _run(['find-generic-password', '-s', service, '-w'])
227
+ if rc == 0:
228
+ doc = _credential(out)
229
+ result.update(state='present' if doc is not None else 'corrupt', doc=doc)
230
+ elif rc == EXIT_NOT_FOUND:
231
+ result['state'] = 'absent'
232
+ elif rc == EXIT_NO_INTERACTION:
233
+ arc, acct, modified = _attributes(service)
234
+ if arc == 0:
235
+ result.update(state='locked', account=acct, modified=modified)
236
+ elif arc == EXIT_NOT_FOUND:
237
+ result['state'] = 'absent'
238
+ return result
239
+
240
+
241
+ def probe(config_dir, legacy=False):
242
+ """Never raises, never logs the secret. See the module doc for the states. Reads
243
+ the item under :func:`canonical_account` — the one a multiacc-launched client reads
244
+ — in ONE ``security`` call (two only for a locked keychain, to tell it from an
245
+ absent item). ``legacy=True`` falls back to the old no ``-a`` first match when no
246
+ canonical item exists (a second call); only a caller that migrates such an item or
247
+ must not discard it asks for that."""
109
248
  service = service_name(config_dir)
249
+ canonical = canonical_account()
110
250
  result = {'state': 'unavailable', 'service': service, 'doc': None,
111
- 'account': None, 'modified': None}
251
+ 'account': None, 'modified': None, 'canonical': canonical}
112
252
  if not enabled():
113
253
  result['state'] = 'absent' if os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '') \
114
254
  .strip().lower() in ('0', 'false', 'no', 'off') else 'unavailable'
115
255
  return result
116
- rc, out, _err = _run(['find-generic-password', '-s', service, '-w'])
256
+ rc, out, _err = _run(['find-generic-password', '-a', canonical, '-s', service, '-w'])
117
257
  if rc == 0:
118
- try:
119
- doc = json.loads(out.strip())
120
- except ValueError:
121
- doc = None
122
- if isinstance(doc, dict) and isinstance(doc.get('claudeAiOauth'), dict):
123
- result.update(state='present', doc=doc)
124
- else:
125
- result['state'] = 'corrupt'
126
- return result
127
- if rc == EXIT_NOT_FOUND:
128
- result['state'] = 'absent'
258
+ doc = _credential(out)
259
+ result.update(state='present' if doc is not None else 'corrupt', doc=doc,
260
+ account=canonical)
129
261
  return result
130
262
  if rc == EXIT_NO_INTERACTION:
131
- arc, acct, modified = _attributes(service)
263
+ arc, _acct, modified = _attributes(service, canonical)
132
264
  if arc == 0:
133
- result.update(state='locked', account=acct, modified=modified)
134
- elif arc == EXIT_NOT_FOUND:
135
- result['state'] = 'absent'
136
- return result
265
+ result.update(state='locked', account=canonical, modified=modified)
266
+ return result
267
+ if arc != EXIT_NOT_FOUND:
268
+ return result
269
+ rc = EXIT_NOT_FOUND # no canonical item: the secret read said nothing
270
+ if rc == EXIT_NOT_FOUND:
271
+ if legacy:
272
+ return _legacy_probe(service, result)
273
+ result['state'] = 'absent'
137
274
  return result
138
275
 
139
276
 
140
277
  def item_mtime(config_dir):
141
- """Epoch of the item's last modification (0 when absent/unknown). Readable even
142
- when the keychain is locked, so a marker can still self-heal on a newer login."""
278
+ """Epoch of the canonical item's last modification (0 when absent/unknown) — the
279
+ item the client reads, as the shim's kc_mtime reads it. Readable even when the
280
+ keychain is locked, so a marker can still self-heal on a newer login. One call."""
143
281
  if not enabled():
144
282
  return 0
145
- rc, _acct, modified = _attributes(service_name(config_dir))
283
+ rc, _acct, modified = _attributes(service_name(config_dir), canonical_account())
146
284
  if rc != 0:
147
285
  return 0
148
286
  return modified or 0
149
287
 
150
288
 
151
- def write(config_dir, doc, account=None):
289
+ def write(config_dir, doc, account=None, supersedes=None):
152
290
  """Store ``doc`` the way Claude Code does (``add-generic-password -U``, hex
153
- payload, same account name as the existing item so no duplicate is created).
154
- True on success. The document is passed on argv — the same choice the client
155
- makes when its payload does not fit `security -i`'s line limit; ``ps`` exposure
156
- lasts milliseconds and is the same window `add-generic-password -w` always had."""
291
+ payload) under :func:`canonical_account` — ``account`` is accepted for old callers
292
+ and IGNORED: the canonical name is the one item a multiacc-launched client reads.
293
+ True when the add succeeded.
294
+
295
+ Once the canonical item reads back with the access token just written, each other
296
+ item for the service is read and deleted when it is provably spent: it holds the
297
+ same grant (the written refresh token, or ``supersedes`` — the refresh token a
298
+ refresh started from, so a legacy item the refresh came from goes with it), a dead
299
+ login, or no credential at all. A live OTHER grant is kept: a sign-in as the wrong
300
+ account puts the intruder's grant under the canonical name, and a refresh of it must
301
+ not destroy the right account's login beside it — only a sign-in whose identity
302
+ checked out may (:func:`prune`). Those reads cost calls on a write only.
303
+
304
+ The document is passed on argv — the same choice the client makes when its payload
305
+ does not fit `security -i`'s line limit; ``ps`` exposure lasts milliseconds and is
306
+ the same window `add-generic-password -w` always had."""
307
+ del account
157
308
  if not enabled():
158
309
  return False
159
310
  service = service_name(config_dir)
160
- if not account:
161
- _rc, existing, _m = _attributes(service)
162
- account = existing or _current_user()
311
+ canonical = canonical_account()
163
312
  payload = json.dumps(doc, separators=(',', ':')).encode('utf-8').hex()
164
- rc, _out, _err = _run(['add-generic-password', '-U', '-a', account, '-s', service,
313
+ rc, _out, _err = _run(['add-generic-password', '-U', '-a', canonical, '-s', service,
165
314
  '-X', payload])
166
- return rc == 0
315
+ if rc != 0:
316
+ return False
317
+ rc, out, _err = _run(['find-generic-password', '-a', canonical, '-s', service, '-w'])
318
+ back = _credential(out) if rc == 0 else None
319
+ wrote = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
320
+ if back is None or not isinstance(wrote, dict) \
321
+ or back['claudeAiOauth'].get('accessToken') != wrote.get('accessToken'):
322
+ return True
323
+ spent = {t for t in (wrote.get('refreshToken'), supersedes) if isinstance(t, str) and t}
324
+ _ok, siblings = _names(service, skip=(canonical,))
325
+ for name in siblings:
326
+ rc, out, _err = _run(['find-generic-password', '-a', name, '-s', service, '-w'])
327
+ if rc != 0:
328
+ continue # gone, or cannot be judged: leave it
329
+ other = _credential(out)
330
+ grant = other['claudeAiOauth'].get('refreshToken') if other is not None else None
331
+ if other is not None and not (isinstance(grant, str) and grant in spent) \
332
+ and _doc_state(other)[0] == 'ok':
333
+ continue # a live grant of its own
334
+ _run(['delete-generic-password', '-a', name, '-s', service])
335
+ return True
167
336
 
168
337
 
169
338
  def delete(config_dir):
170
- """Remove the item. True when it is gone (including when it never existed)."""
339
+ """Remove every item for the dir, whatever name it sits under. True when none is
340
+ left (including when none ever existed)."""
171
341
  if not enabled():
172
342
  return True
173
- rc, _out, _err = _run(['delete-generic-password', '-s', service_name(config_dir)])
174
- return rc in (0, EXIT_NOT_FOUND)
343
+ service = service_name(config_dir)
344
+ _ok, names = _names(service)
345
+ for name in names:
346
+ _run(['delete-generic-password', '-a', name, '-s', service])
347
+ # Names this module does not enumerate (a user renamed since): each call without
348
+ # -a removes the oldest remaining item.
349
+ for _ in range(DELETE_SWEEP_CAP):
350
+ rc, _out, _err = _run(['delete-generic-password', '-s', service])
351
+ if rc != 0:
352
+ break
353
+ rc, _out, _err = _run(['find-generic-password', '-s', service])
354
+ return rc == EXIT_NOT_FOUND
175
355
 
176
356
 
177
- def _current_user():
178
- try:
179
- return getpass.getuser()
180
- except Exception: # noqa: BLE001 — no passwd entry; the keychain item name is cosmetic
181
- return os.environ.get('USER') or 'claude'
357
+ def prune(config_dir):
358
+ """Delete the items for the dir that sit under any name but the canonical one —
359
+ only when the canonical item is present, readable and a live credential by the
360
+ audit's own rule. Never writes; a no-op in every other case. Returns (ok, one-line
361
+ status); the status names accounts and states only, never the secret."""
362
+ if not enabled():
363
+ return True, 'skip: keychain lookups are off'
364
+ service = service_name(config_dir)
365
+ canonical = canonical_account()
366
+ rc, out, _err = _run(['find-generic-password', '-a', canonical, '-s', service, '-w'])
367
+ if rc == EXIT_NOT_FOUND:
368
+ return True, f'skip: no item under {canonical}'
369
+ if rc == EXIT_NO_INTERACTION:
370
+ return True, 'skip: keychain locked for this session'
371
+ if rc != 0:
372
+ return True, 'skip: keychain unavailable'
373
+ doc = _credential(out)
374
+ if doc is None:
375
+ return True, f'skip: the item under {canonical} is not a credential'
376
+ state, reason = _doc_state(doc)
377
+ if state != 'ok':
378
+ return True, f'skip: the login under {canonical} is {state} ({reason})'
379
+ ok, siblings = _names(service, skip=(canonical,))
380
+ removed, left = [], []
381
+ for name in siblings:
382
+ rc, _out, _err = _run(['delete-generic-password', '-a', name, '-s', service])
383
+ (removed if rc in (0, EXIT_NOT_FOUND) else left).append(name)
384
+ if left or not ok:
385
+ return False, (f"failed: kept {canonical}, removed {','.join(removed) or '-'}, "
386
+ f"left {','.join(left) or '-'}"
387
+ + ('' if ok else ' (could not list every item)'))
388
+ if removed:
389
+ return True, f"pruned: kept {canonical}, removed {','.join(removed)}"
390
+ return True, f'ok: one item, under {canonical}'
182
391
 
183
392
 
184
393
  def main(argv):
185
- if len(argv) != 3 or argv[1] not in ('probe', 'service', 'mtime', 'delete'):
186
- print('usage: keychain.py <probe|service|mtime|delete> <config-dir>', file=sys.stderr)
394
+ verbs = ('probe', 'service', 'mtime', 'delete', 'prune')
395
+ if len(argv) != 3 or argv[1] not in verbs:
396
+ print(f"usage: keychain.py <{'|'.join(verbs)}> <config-dir>", file=sys.stderr)
187
397
  return 2
188
398
  verb, config_dir = argv[1], argv[2]
189
399
  if verb == 'service':
@@ -194,12 +404,17 @@ def main(argv):
194
404
  return 0
195
405
  if verb == 'delete':
196
406
  return 0 if delete(config_dir) else 1
407
+ if verb == 'prune':
408
+ ok, line = prune(config_dir)
409
+ print(line)
410
+ return 0 if ok else 1
197
411
  res = probe(config_dir)
198
412
  # The secret itself never leaves this process on stdout: callers that need the
199
413
  # document import the module. The CLI answers with the state and non-secret facts.
200
414
  o = (res.get('doc') or {}).get('claudeAiOauth') or {}
201
415
  print(json.dumps({'state': res['state'], 'service': res['service'],
202
416
  'account': res['account'], 'modified': res['modified'],
417
+ 'canonical': res['canonical'],
203
418
  'expires_at': o.get('expiresAt'),
204
419
  'refresh_token_expires_at': o.get('refreshTokenExpiresAt'),
205
420
  'has_refresh_token': bool(o.get('refreshToken'))}))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.41",
3
+ "version": "2.0.43",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # Fake macOS `security` (generic-password verbs only), shared by tests/run-tests.sh and
3
+ # tests/test_keychain.py. Installed on PATH as `security`, it shadows any real
4
+ # /usr/bin/security, so a macOS dev run can never touch the developer's keychain.
5
+ #
6
+ # It models what the real tool does with SEVERAL items per service (verified on
7
+ # my-mini, 2026-09-24), which is what lib/keychain.py and bin/claude stand on:
8
+ # - an item is keyed by (service, account); `-a` selects exactly that item;
9
+ # - without `-a`, find/delete act on the OLDEST-created item for the service;
10
+ # - `add -U` updates the (service, account) item in place or creates it; `add`
11
+ # without -U on an existing item exits 45 (errSecDuplicateItem);
12
+ # - attribute reads answer on a locked keychain; secret reads and writes exit 36.
13
+ #
14
+ # Items live as $FAKE_KEYCHAIN_DIR/<service>/<account>/{secret,mdat,seq}: `seq` is the
15
+ # creation order, `mdat` the 14-digit UTC modification stamp — recorded at write time
16
+ # (FAKE_KEYCHAIN_MDAT overrides it) rather than derived from a file mtime, because
17
+ # `stat`/`date` flags differ between BSD and GNU and this fake must behave identically
18
+ # on a developer's Mac and on Linux CI. FAKE_KEYCHAIN_LOCKED=1 simulates a session that
19
+ # cannot open the keychain (ssh/tmux/background). FAKE_SECURITY_LOG=<file> appends one
20
+ # line per call — verb, -a name ("-" without -a), whether the secret was asked for —
21
+ # so a test can count calls; the secret and the -X payload are never logged.
22
+ KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
23
+ cmd="${1:-}"; shift || true
24
+ svc=""; acct=""; have_acct=0; want_pw=0; update=0; hexdata=""
25
+ while [ $# -gt 0 ]; do
26
+ case "$1" in
27
+ -s) svc="${2:-}"; shift 2 ;;
28
+ -a) acct="${2:-}"; have_acct=1; shift 2 ;;
29
+ -w) want_pw=1; shift ;;
30
+ -U) update=1; shift ;;
31
+ -X) hexdata="${2:-}"; shift 2 ;;
32
+ *) shift ;;
33
+ esac
34
+ done
35
+ [ -n "${FAKE_SECURITY_LOG:-}" ] && printf '%s a=%s w=%s\n' "$cmd" \
36
+ "$([ "$have_acct" = "1" ] && printf '%s' "$acct" || printf -- '-')" "$want_pw" >> "$FAKE_SECURITY_LOG"
37
+ locked() { [ -n "${FAKE_KEYCHAIN_LOCKED:-}" ]; }
38
+ item_dir() { # the item a lookup names: -a picks its own, no -a picks the oldest
39
+ local d best="" bestseq="" s
40
+ if [ "$have_acct" = "1" ]; then
41
+ [ -n "$acct" ] && [ -d "$KC/$svc/$acct" ] && { printf '%s\n' "$KC/$svc/$acct"; return 0; }
42
+ return 1
43
+ fi
44
+ for d in "$KC/$svc"/*/; do
45
+ [ -d "$d" ] || continue
46
+ s="$(cat "$d/seq" 2>/dev/null || echo 0)"
47
+ if [ -z "$bestseq" ] || [ "$s" -lt "$bestseq" ]; then best="${d%/}"; bestseq="$s"; fi
48
+ done
49
+ [ -n "$best" ] && { printf '%s\n' "$best"; return 0; }
50
+ return 1
51
+ }
52
+ case "$cmd" in
53
+ show-keychain-info)
54
+ locked && exit 36
55
+ exit 0 ;;
56
+ find-generic-password)
57
+ it="$(item_dir)" || exit 44
58
+ if [ "$want_pw" = "1" ]; then
59
+ locked && exit 36
60
+ cat "$it/secret"; echo
61
+ exit 0
62
+ fi
63
+ printf 'keychain: "login.keychain-db"\nclass: "genp"\nattributes:\n'
64
+ printf ' "acct"<blob>="%s"\n' "$(basename "$it")"
65
+ printf ' "mdat"<timedate>=0x00 "%sZ\\000"\n' "$(cat "$it/mdat" 2>/dev/null || echo 00000000000000)"
66
+ printf ' "svce"<blob>="%s"\n' "$svc"
67
+ exit 0 ;;
68
+ add-generic-password)
69
+ locked && exit 36
70
+ [ "$have_acct" = "1" ] && [ -n "$acct" ] && [ -n "$svc" ] || exit 2
71
+ it="$KC/$svc/$acct"
72
+ if [ -d "$it" ]; then
73
+ [ "$update" = "1" ] || exit 45
74
+ else
75
+ mkdir -p "$it" || exit 1
76
+ n="$(cat "$KC/.seq" 2>/dev/null || echo 0)"; n=$((n + 1))
77
+ printf '%s\n' "$n" > "$KC/.seq"
78
+ printf '%s\n' "$n" > "$it/seq"
79
+ fi
80
+ printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$it/secret"
81
+ printf '%s\n' "${FAKE_KEYCHAIN_MDAT:-$(date -u +%Y%m%d%H%M%S)}" > "$it/mdat"
82
+ exit 0 ;;
83
+ delete-generic-password)
84
+ locked && exit 36
85
+ it="$(item_dir)" || exit 44
86
+ rm -rf "$it"
87
+ rmdir "$KC/$svc" 2>/dev/null
88
+ exit 0 ;;
89
+ *) exit 1 ;;
90
+ esac