claude-multiacc 2.0.57 → 2.0.59

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 (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_packaged_commands.py +41 -0
@@ -188,6 +188,24 @@ PYEOF
188
188
  fi
189
189
  }
190
190
 
191
+ # The model/effort catalog auto-resume relaunches sessions with (lib/model_catalog.py), into
192
+ # both pools on EVERY install and update: npm's postinstall and the git self-update both run
193
+ # this installer. It runs before the scheduler step, because reloading the update agent can
194
+ # end the very job that is running this install. Best-effort: a catalog that cannot be
195
+ # written never fails an install, and the limits passes retry it within the hour.
196
+ install_model_catalog() {
197
+ if ! model_catalog_on claude && ! model_catalog_on codex; then
198
+ echo " model catalog: refresh switched off ($MODEL_CATALOG_OFF_BY)"
199
+ return 0
200
+ fi
201
+ if "$REPO_DIR/bin/claude-accounts" model-catalog refresh --quiet </dev/null >/dev/null 2>&1; then
202
+ echo " model catalog: refreshed (auto-resume model and effort matching)"
203
+ else
204
+ echo " note: the model catalog could not be refreshed; auto-resume uses the shipped copy" >&2
205
+ fi
206
+ return 0
207
+ }
208
+
191
209
  # bash and sh read ~/.profile for a LOGIN shell when ~/.bash_profile and ~/.bash_login
192
210
  # are absent — `bash -lc`, `sh -lc`, a tmux/ssh session whose login shell is bash. Left
193
211
  # without a block, such a shell ends its startup with whatever /etc/profile and its own
@@ -265,6 +283,7 @@ do_install() {
265
283
 
266
284
  install_binaries
267
285
  install_pools
286
+ install_model_catalog
268
287
 
269
288
  # An INSTANCE install never rewrites the shell rc blocks: one interactive PATH
270
289
  # cannot serve two pools, and clobbering the default install's block would point
package/lib/keychain.py CHANGED
@@ -40,33 +40,44 @@ Hence the rules here:
40
40
  report, verify and the marker checks must not count it (a fallback that did made
41
41
  ``list --json`` advertise accounts the shim never picks, and made ``verify`` test a
42
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;
43
+ - ``probe(d, legacy=True)`` adds the old no ``-a`` first match when the canonical item
44
+ holds no login (none exists, or it holds MCP state only). Only the limits pass (which
45
+ then moves such a login under the canonical name) and ``dedupe`` (which must not
46
+ throw away a stranded grant) ask for it; it costs a second call in that case only;
47
+ - :func:`write` always targets the canonical name, and lands INTO the document the
48
+ item holds: the keys it is given replace theirs, every other top-level key (the
49
+ client's MCP OAuth state) stays. Once the canonical item reads back with the access
50
+ token just written, it deletes each other item that is provably spent: the same
51
+ grant (the written refresh token, or the ``supersedes`` one a refresh started from),
52
+ a dead login, or not a credential at all. A LIVE other grant stays — after a sign-in
53
+ as the wrong account the client itself put the intruder's grant under the canonical
54
+ name, and the right account's login may be that sibling;
53
55
  - :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
56
+ - :func:`prune` deletes the non-canonical items, and ONLY when the canonical item is
55
57
  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
58
+ writes and never handles the secret beyond those reads. The sign-in commands run it
57
59
  once the ceremony's identity check has passed — never before, so a sign-in as the
58
60
  wrong account cannot cost the right account its login.
59
61
 
62
+ The document is the client's whole credential store, not only the login: beside
63
+ ``claudeAiOauth`` (the Claude login) it keeps its MCP servers' OAuth state (``mcpOAuth``,
64
+ ``mcpOAuthClientConfig``; 2.1.303). An item is a LOGIN only when it carries a
65
+ ``claudeAiOauth`` object; one holding only the rest is MCP state — no login for any
66
+ decision (``nologin``), and never deleted as "not a credential" by write or prune.
67
+
60
68
  This module is the one place that knows the keychain layout. Everything else asks
61
69
  :func:`probe`, which never raises and never prints the secret.
62
70
 
63
71
  States returned by :func:`probe`:
64
72
  present the item exists and this process could read it (``doc`` is the parsed
65
73
  ``{"claudeAiOauth": …}`` document, identical in shape to the file)
74
+ nologin the item exists and was readable, a JSON object WITHOUT a login (the
75
+ client's MCP OAuth state alone); ``doc`` is that document. No login here,
76
+ exactly as if there were no item; ``legacy=True`` then looks further
66
77
  locked the item exists but this session cannot open the keychain (exit 36) —
67
78
  a GUI-session process on this Mac CAN use it; an ssh session cannot
68
79
  absent no such item
69
- corrupt the item exists and was readable, but is not a credential document
80
+ corrupt the item exists and was readable, but is not a JSON object at all
70
81
  unavailable not macOS, no ``security`` tool, or it failed in a way that says
71
82
  nothing about the item (timeout, crash)
72
83
  ``account`` is the name of the item that answered: the canonical one, or with
@@ -196,16 +207,34 @@ def _names(service, skip=()):
196
207
  return True, names
197
208
 
198
209
 
199
- def _credential(text):
210
+ def _credential(text, any_object=False):
211
+ """The parsed item when it carries a Claude login (a ``claudeAiOauth`` object), else
212
+ None. ``any_object=True`` returns every JSON object — MCP state with no login among
213
+ them — and None only for a secret that is no JSON object at all."""
200
214
  try:
201
215
  doc = json.loads(text.strip())
202
216
  except ValueError:
203
217
  return None
204
- if isinstance(doc, dict) and isinstance(doc.get('claudeAiOauth'), dict):
218
+ if not isinstance(doc, dict):
219
+ return None
220
+ if any_object or isinstance(doc.get('claudeAiOauth'), dict):
205
221
  return doc
206
222
  return None
207
223
 
208
224
 
225
+ def _item_state(doc):
226
+ """probe's verdict on a secret parsed by ``_credential(text, any_object=True)``."""
227
+ if doc is None:
228
+ return 'corrupt'
229
+ return 'present' if isinstance(doc.get('claudeAiOauth'), dict) else 'nologin'
230
+
231
+
232
+ def _keeps_other_state(doc):
233
+ """True when an item holds something besides the login — the client's MCP OAuth
234
+ state, which no multiacc sweep may throw away with a spent or missing login."""
235
+ return isinstance(doc, dict) and any(key != 'claudeAiOauth' for key in doc)
236
+
237
+
209
238
  def _doc_state(doc):
210
239
  """audit.creds_doc_state's verdict ('ok' / 'expired') on a parsed credential — the
211
240
  one rule for "can this login work", imported lazily because audit imports this
@@ -222,11 +251,11 @@ def _doc_state(doc):
222
251
 
223
252
  def _legacy_probe(service, result):
224
253
  """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."""
254
+ ``probe(legacy=True)`` asks for it, and only when the canonical item holds no login."""
226
255
  rc, out, _err = _run(['find-generic-password', '-s', service, '-w'])
227
256
  if rc == 0:
228
- doc = _credential(out)
229
- result.update(state='present' if doc is not None else 'corrupt', doc=doc)
257
+ doc = _credential(out, any_object=True)
258
+ result.update(state=_item_state(doc), doc=doc)
230
259
  elif rc == EXIT_NOT_FOUND:
231
260
  result['state'] = 'absent'
232
261
  elif rc == EXIT_NO_INTERACTION:
@@ -242,9 +271,9 @@ def probe(config_dir, legacy=False):
242
271
  """Never raises, never logs the secret. See the module doc for the states. Reads
243
272
  the item under :func:`canonical_account` — the one a multiacc-launched client reads
244
273
  — 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."""
274
+ absent item). ``legacy=True`` falls back to the old no ``-a`` first match when the
275
+ canonical item holds no login — none exists, or it holds MCP state only (a second
276
+ call); only a caller that migrates such an item or must not discard it asks for that."""
248
277
  service = service_name(config_dir)
249
278
  canonical = canonical_account()
250
279
  result = {'state': 'unavailable', 'service': service, 'doc': None,
@@ -255,9 +284,15 @@ def probe(config_dir, legacy=False):
255
284
  return result
256
285
  rc, out, _err = _run(['find-generic-password', '-a', canonical, '-s', service, '-w'])
257
286
  if rc == 0:
258
- doc = _credential(out)
259
- result.update(state='present' if doc is not None else 'corrupt', doc=doc,
260
- account=canonical)
287
+ doc = _credential(out, any_object=True)
288
+ result.update(state=_item_state(doc), doc=doc, account=canonical)
289
+ if result['state'] == 'nologin' and legacy:
290
+ # MCP state alone is no login — exactly as if the item were absent — so the
291
+ # migration read looks for one stranded under another name, as it would then.
292
+ found = _legacy_probe(service, dict(result, state='unavailable', doc=None,
293
+ account=None))
294
+ if found['state'] == 'present':
295
+ return found
261
296
  return result
262
297
  if rc == EXIT_NO_INTERACTION:
263
298
  arc, _acct, modified = _attributes(service, canonical)
@@ -292,6 +327,13 @@ def write(config_dir, doc, account=None, supersedes=None):
292
327
  and IGNORED: the canonical name is the one item a multiacc-launched client reads.
293
328
  True when the add succeeded.
294
329
 
330
+ ``doc`` lands INTO the document the item holds now: its top-level keys replace the
331
+ item's and every other key stays. The client keeps its MCP servers' OAuth state
332
+ (``mcpOAuth``, ``mcpOAuthClientConfig``) in that same item, and a login write that
333
+ replaced the document would sign every one of them out — so a caller passes only the
334
+ keys it owns (a refresh writes ``{"claudeAiOauth": …}``). An item that cannot be
335
+ read first (locked, a failing tool) is never written blind.
336
+
295
337
  Once the canonical item reads back with the access token just written, each other
296
338
  item for the service is read and deleted when it is provably spent: it holds the
297
339
  same grant (the written refresh token, or ``supersedes`` — the refresh token a
@@ -299,7 +341,8 @@ def write(config_dir, doc, account=None, supersedes=None):
299
341
  login, or no credential at all. A live OTHER grant is kept: a sign-in as the wrong
300
342
  account puts the intruder's grant under the canonical name, and a refresh of it must
301
343
  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.
344
+ checked out may (:func:`prune`). An item carrying MCP state is kept whatever its
345
+ login. Those reads cost calls on a write only.
303
346
 
304
347
  The document is passed on argv — the same choice the client makes when its payload
305
348
  does not fit `security -i`'s line limit; ``ps`` exposure lasts milliseconds and is
@@ -309,7 +352,15 @@ def write(config_dir, doc, account=None, supersedes=None):
309
352
  return False
310
353
  service = service_name(config_dir)
311
354
  canonical = canonical_account()
312
- payload = json.dumps(doc, separators=(',', ':')).encode('utf-8').hex()
355
+ rc, out, _err = _run(['find-generic-password', '-a', canonical, '-s', service, '-w'])
356
+ if rc == 0:
357
+ merged = dict(_credential(out, any_object=True) or {})
358
+ elif rc == EXIT_NOT_FOUND:
359
+ merged = {}
360
+ else:
361
+ return False
362
+ merged.update(doc if isinstance(doc, dict) else {})
363
+ payload = json.dumps(merged, separators=(',', ':')).encode('utf-8').hex()
313
364
  rc, _out, _err = _run(['add-generic-password', '-U', '-a', canonical, '-s', service,
314
365
  '-X', payload])
315
366
  if rc != 0:
@@ -326,10 +377,13 @@ def write(config_dir, doc, account=None, supersedes=None):
326
377
  rc, out, _err = _run(['find-generic-password', '-a', name, '-s', service, '-w'])
327
378
  if rc != 0:
328
379
  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':
380
+ other = _credential(out, any_object=True)
381
+ if _keeps_other_state(other):
382
+ continue # the client's MCP state rides on it: never ours
383
+ login = other if _item_state(other) == 'present' else None
384
+ grant = login['claudeAiOauth'].get('refreshToken') if login is not None else None
385
+ if login is not None and not (isinstance(grant, str) and grant in spent) \
386
+ and _doc_state(login)[0] == 'ok':
333
387
  continue # a live grant of its own
334
388
  _run(['delete-generic-password', '-a', name, '-s', service])
335
389
  return True
@@ -357,8 +411,9 @@ def delete(config_dir):
357
411
  def prune(config_dir):
358
412
  """Delete the items for the dir that sit under any name but the canonical one —
359
413
  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."""
414
+ audit's own rule — except an item carrying the client's MCP state, which is never
415
+ pruned. Never writes; a no-op in every other case. Returns (ok, one-line status);
416
+ the status names accounts and states only, never the secret."""
362
417
  if not enabled():
363
418
  return True, 'skip: keychain lookups are off'
364
419
  service = service_name(config_dir)
@@ -370,23 +425,32 @@ def prune(config_dir):
370
425
  return True, 'skip: keychain locked for this session'
371
426
  if rc != 0:
372
427
  return True, 'skip: keychain unavailable'
373
- doc = _credential(out)
428
+ doc = _credential(out, any_object=True)
374
429
  if doc is None:
375
430
  return True, f'skip: the item under {canonical} is not a credential'
431
+ if _item_state(doc) == 'nologin':
432
+ return True, f'skip: the item under {canonical} holds no login (MCP state only)'
376
433
  state, reason = _doc_state(doc)
377
434
  if state != 'ok':
378
435
  return True, f'skip: the login under {canonical} is {state} ({reason})'
379
436
  ok, siblings = _names(service, skip=(canonical,))
380
- removed, left = [], []
437
+ removed, left, mcp = [], [], []
381
438
  for name in siblings:
439
+ rc, out, _err = _run(['find-generic-password', '-a', name, '-s', service, '-w'])
440
+ if rc == 0 and _keeps_other_state(_credential(out, any_object=True)):
441
+ mcp.append(name) # the client's MCP state rides on it: never pruned
442
+ continue
382
443
  rc, _out, _err = _run(['delete-generic-password', '-a', name, '-s', service])
383
444
  (removed if rc in (0, EXIT_NOT_FOUND) else left).append(name)
445
+ kept = canonical + (f" and MCP state under {','.join(mcp)}" if mcp else '')
384
446
  if left or not ok:
385
- return False, (f"failed: kept {canonical}, removed {','.join(removed) or '-'}, "
447
+ return False, (f"failed: kept {kept}, removed {','.join(removed) or '-'}, "
386
448
  f"left {','.join(left) or '-'}"
387
449
  + ('' if ok else ' (could not list every item)'))
388
450
  if removed:
389
- return True, f"pruned: kept {canonical}, removed {','.join(removed)}"
451
+ return True, f"pruned: kept {kept}, removed {','.join(removed)}"
452
+ if mcp:
453
+ return True, f'ok: the login under {kept}'
390
454
  return True, f'ok: one item, under {canonical}'
391
455
 
392
456
 
@@ -421,5 +485,85 @@ def main(argv):
421
485
  return 0
422
486
 
423
487
 
488
+ # ---------------------------------------------------------------- the raw document
489
+ # The client keeps ONE JSON document per config dir: the login under `claudeAiOauth`
490
+ # beside other state — `mcpOAuth`, MCP servers' OAuth logins (lib/mcp_oauth.py). Every
491
+ # function above judges the login; these two read and write the document whatever it
492
+ # holds, for the one caller that merges a key into it and leaves the rest alone.
493
+
494
+ #: `security -i` reads each command line into a 4096-byte buffer; Claude Code keeps 64
495
+ #: bytes of headroom and passes a longer command on argv instead (2.1.303).
496
+ SECURITY_STDIN_LIMIT = 4096 - 64
497
+
498
+
499
+ def _run_input(args, data, timeout=TIMEOUT_S):
500
+ """:func:`_run` with ``data`` on stdin."""
501
+ try:
502
+ p = subprocess.run(['security'] + list(args), input=data, capture_output=True,
503
+ text=True, timeout=timeout)
504
+ except (OSError, subprocess.SubprocessError):
505
+ return None, '', ''
506
+ return p.returncode, p.stdout or '', p.stderr or ''
507
+
508
+
509
+ def _json_object(text):
510
+ """The JSON object `find-generic-password -w` printed, or None. The tool prints a
511
+ secret it cannot show as text in hex, so that spelling is accepted too."""
512
+ text = (text or '').strip()
513
+ try:
514
+ doc = json.loads(text)
515
+ except ValueError:
516
+ if not re.fullmatch(r'(?:[0-9A-Fa-f]{2})+', text):
517
+ return None
518
+ try:
519
+ doc = json.loads(bytes.fromhex(text).decode('utf-8'))
520
+ except ValueError:
521
+ return None
522
+ return doc if isinstance(doc, dict) else None
523
+
524
+
525
+ def read_doc(config_dir, timeout=TIMEOUT_S):
526
+ """(state, doc) of the canonical item — the one a multiacc-launched client reads —
527
+ whatever the document holds: 'present' with the parsed JSON object, 'absent' (exit
528
+ 44; also when lookups are off), 'locked' (exit 36: the item exists and this session
529
+ cannot open the keychain), 'corrupt' (readable, not a JSON object) or 'unavailable'
530
+ (no keychain here, or the tool failed). ONE `security` call; never raises, never
531
+ prints the secret."""
532
+ if not enabled():
533
+ off = os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '').strip().lower() \
534
+ in ('0', 'false', 'no', 'off')
535
+ return ('absent' if off else 'unavailable'), None
536
+ rc, out, _err = _run(['find-generic-password', '-a', canonical_account(), '-s',
537
+ service_name(config_dir), '-w'], timeout=timeout)
538
+ if rc == 0:
539
+ doc = _json_object(out)
540
+ return ('present', doc) if doc is not None else ('corrupt', None)
541
+ if rc == EXIT_NOT_FOUND:
542
+ return 'absent', None
543
+ if rc == EXIT_NO_INTERACTION:
544
+ return 'locked', None
545
+ return 'unavailable', None
546
+
547
+
548
+ def write_doc(config_dir, doc, timeout=TIMEOUT_S):
549
+ """Store ``doc`` as the canonical item the way the client's own update() does:
550
+ ``add-generic-password -U -a <name> -s <svc> -X <hex>`` typed into `security -i` on
551
+ stdin while the command fits the tool's line buffer — the secret never reaches argv —
552
+ and passed on argv only beyond :data:`SECURITY_STDIN_LIMIT`. ``-U`` CREATES a missing
553
+ item, so a caller writes only an item it has just read. True when `security` took
554
+ the write; the caller reads it back to know what landed. Never prints the secret."""
555
+ if not enabled():
556
+ return False
557
+ canonical, service = canonical_account(), service_name(config_dir)
558
+ payload = json.dumps(doc, separators=(',', ':')).encode('utf-8').hex()
559
+ command = f'add-generic-password -U -a "{canonical}" -s "{service}" -X "{payload}"\n'
560
+ if len(command) <= SECURITY_STDIN_LIMIT:
561
+ rc, _out, _err = _run_input(['-i'], command, timeout=timeout)
562
+ else:
563
+ rc, _out, _err = _run(['add-generic-password', '-U', '-a', canonical, '-s', service,
564
+ '-X', payload], timeout=timeout)
565
+ return rc == 0
566
+
567
+
424
568
  if __name__ == '__main__':
425
569
  sys.exit(main(sys.argv))