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.
- package/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- 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_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.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/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/lib/install_actions.sh
CHANGED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
name) and ``dedupe`` (which must not
|
|
46
|
-
costs a second call
|
|
47
|
-
- :func:`write` always targets the canonical name
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
246
|
-
canonical item
|
|
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=
|
|
260
|
-
|
|
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`).
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
361
|
-
|
|
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 {
|
|
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 {
|
|
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))
|