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.
- package/README.md +60 -2
- package/bin/claude +414 -5
- package/bin/claude-accounts +36 -11
- package/bin/codex +406 -4
- package/docs/ACCOUNT_OPERATIONS.md +7 -1
- package/docs/AUTORESUME.md +321 -0
- package/docs/CODEX.md +12 -1
- package/docs/VERIFICATION.md +9 -1
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +2271 -0
- package/lib/common.sh +24 -1
- package/lib/keychain.py +274 -59
- package/package.json +1 -1
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/fake_security.sh +90 -0
- package/tests/run-tests.sh +1178 -67
- package/tests/test_autoresume.py +2565 -0
- 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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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,
|
|
263
|
+
arc, _acct, modified = _attributes(service, canonical)
|
|
132
264
|
if arc == 0:
|
|
133
|
-
result.update(state='locked', account=
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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)
|
|
142
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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',
|
|
313
|
+
rc, _out, _err = _run(['add-generic-password', '-U', '-a', canonical, '-s', service,
|
|
165
314
|
'-X', payload])
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|