claude-multiacc 2.0.42 → 2.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/claude +20 -3
- package/bin/claude-accounts +36 -11
- package/docs/ACCOUNT_OPERATIONS.md +5 -0
- 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/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 +243 -64
- package/tests/test_keychain.py +323 -50
package/tests/test_keychain.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import re
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
import subprocess
|
|
15
16
|
import sys
|
|
@@ -19,49 +20,20 @@ import unittest
|
|
|
19
20
|
REPO = Path(__file__).resolve().parents[1]
|
|
20
21
|
sys.path.insert(0, str(REPO / 'lib'))
|
|
21
22
|
|
|
22
|
-
FAKE_SECURITY =
|
|
23
|
-
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
24
|
-
cmd="${1:-}"; shift || true
|
|
25
|
-
svc=""; acct=""; want_pw=0; hexdata=""
|
|
26
|
-
while [ $# -gt 0 ]; do
|
|
27
|
-
case "$1" in
|
|
28
|
-
-s) svc="$2"; shift 2 ;;
|
|
29
|
-
-a) acct="$2"; shift 2 ;;
|
|
30
|
-
-w) want_pw=1; shift ;;
|
|
31
|
-
-X) hexdata="$2"; shift 2 ;;
|
|
32
|
-
*) shift ;;
|
|
33
|
-
esac
|
|
34
|
-
done
|
|
35
|
-
case "$cmd" in
|
|
36
|
-
find-generic-password)
|
|
37
|
-
[ -f "$KC/$svc" ] || exit 44
|
|
38
|
-
if [ "$want_pw" = "1" ]; then
|
|
39
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
40
|
-
cat "$KC/$svc"; exit 0
|
|
41
|
-
fi
|
|
42
|
-
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
43
|
-
printf 'attributes:\n "acct"<blob>="%s"\n' "$a"
|
|
44
|
-
printf ' "mdat"<timedate>=0x00 "20260828043137Z\\000"\n'
|
|
45
|
-
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
46
|
-
exit 0 ;;
|
|
47
|
-
add-generic-password)
|
|
48
|
-
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
49
|
-
mkdir -p "$KC"
|
|
50
|
-
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
51
|
-
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
52
|
-
exit 0 ;;
|
|
53
|
-
delete-generic-password)
|
|
54
|
-
[ -f "$KC/$svc" ] || exit 44
|
|
55
|
-
rm -f "$KC/$svc" "$KC/$svc.acct"; exit 0 ;;
|
|
56
|
-
*) exit 1 ;;
|
|
57
|
-
esac
|
|
58
|
-
'''
|
|
23
|
+
FAKE_SECURITY = (REPO / 'tests' / 'fake_security.sh').read_text()
|
|
59
24
|
|
|
60
25
|
DOC = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-x', 'refreshToken': 'r',
|
|
61
26
|
'expiresAt': 9999999999999,
|
|
62
27
|
'refreshTokenExpiresAt': 9999999999999}}
|
|
63
28
|
|
|
64
29
|
|
|
30
|
+
DEAD = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-dead', 'refreshToken': 'r',
|
|
31
|
+
'expiresAt': 1000, 'refreshTokenExpiresAt': 1000}}
|
|
32
|
+
LIVE = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-live', 'refreshToken': 'r',
|
|
33
|
+
'expiresAt': 9999999999999,
|
|
34
|
+
'refreshTokenExpiresAt': 9999999999999}}
|
|
35
|
+
|
|
36
|
+
|
|
65
37
|
class KeychainTests(unittest.TestCase):
|
|
66
38
|
def setUp(self):
|
|
67
39
|
self.work = tempfile.TemporaryDirectory()
|
|
@@ -74,11 +46,17 @@ class KeychainTests(unittest.TestCase):
|
|
|
74
46
|
self.kcdir = work / 'keychain'
|
|
75
47
|
self.saved = {k: os.environ.get(k) for k in
|
|
76
48
|
('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
|
|
77
|
-
'FAKE_KEYCHAIN_LOCKED'
|
|
49
|
+
'FAKE_KEYCHAIN_LOCKED', 'FAKE_KEYCHAIN_MDAT', 'FAKE_SECURITY_LOG',
|
|
50
|
+
'USER')}
|
|
78
51
|
os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
|
|
79
52
|
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
80
53
|
os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
|
|
81
54
|
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
55
|
+
os.environ.pop('FAKE_KEYCHAIN_MDAT', None)
|
|
56
|
+
os.environ.pop('FAKE_SECURITY_LOG', None)
|
|
57
|
+
self.calllog = work / 'security.log'
|
|
58
|
+
# The canonical name follows USER; pin it so the layout below is deterministic.
|
|
59
|
+
os.environ['USER'] = 'gas'
|
|
82
60
|
# (re)load with the fake in place
|
|
83
61
|
import importlib
|
|
84
62
|
import keychain
|
|
@@ -86,6 +64,7 @@ class KeychainTests(unittest.TestCase):
|
|
|
86
64
|
self.root = work / 'accounts'
|
|
87
65
|
(self.root / 'acct-01').mkdir(parents=True)
|
|
88
66
|
(self.root / 'acct-01' / 'tmp').mkdir(exist_ok=True)
|
|
67
|
+
self.d = str(self.root / 'acct-01')
|
|
89
68
|
|
|
90
69
|
def tearDown(self):
|
|
91
70
|
for key, value in self.saved.items():
|
|
@@ -95,6 +74,34 @@ class KeychainTests(unittest.TestCase):
|
|
|
95
74
|
os.environ[key] = value
|
|
96
75
|
self.work.cleanup()
|
|
97
76
|
|
|
77
|
+
# -- helpers: items placed the way a client would, read straight off the fake --
|
|
78
|
+
def put(self, text, account, mdat, d=None):
|
|
79
|
+
"""An item under <account>, as a client whose FA() said <account> writes it."""
|
|
80
|
+
if not isinstance(text, str):
|
|
81
|
+
text = json.dumps(text)
|
|
82
|
+
subprocess.run(['security', 'add-generic-password', '-U', '-a', account, '-s',
|
|
83
|
+
self.keychain.service_name(d or self.d), '-X',
|
|
84
|
+
text.encode('utf-8').hex()],
|
|
85
|
+
check=True, env=dict(os.environ, FAKE_KEYCHAIN_MDAT=mdat))
|
|
86
|
+
|
|
87
|
+
def items(self, d=None):
|
|
88
|
+
svc = self.kcdir / self.keychain.service_name(d or self.d)
|
|
89
|
+
return sorted(p.name for p in svc.iterdir()) if svc.is_dir() else []
|
|
90
|
+
|
|
91
|
+
def secret(self, account, d=None):
|
|
92
|
+
svc = self.kcdir / self.keychain.service_name(d or self.d)
|
|
93
|
+
return json.loads((svc / account / 'secret').read_text())
|
|
94
|
+
|
|
95
|
+
def calls(self, fn, *args):
|
|
96
|
+
"""(result, [one line per `security` call]) — verb, -a name, secret asked."""
|
|
97
|
+
self.calllog.write_text('')
|
|
98
|
+
os.environ['FAKE_SECURITY_LOG'] = str(self.calllog)
|
|
99
|
+
try:
|
|
100
|
+
result = fn(*args)
|
|
101
|
+
finally:
|
|
102
|
+
os.environ.pop('FAKE_SECURITY_LOG', None)
|
|
103
|
+
return result, self.calllog.read_text().splitlines()
|
|
104
|
+
|
|
98
105
|
# -- the layout facts this whole feature stands on ------------------------------
|
|
99
106
|
def test_service_name_matches_the_client(self):
|
|
100
107
|
# Observed live 2026-08-28 (Claude Code 2.1.250, my-mini): this config dir's
|
|
@@ -104,49 +111,308 @@ class KeychainTests(unittest.TestCase):
|
|
|
104
111
|
self.keychain.service_name('/Users/gas/.claude-accounts/acct-16'),
|
|
105
112
|
'Claude Code-credentials-4219e2b1')
|
|
106
113
|
|
|
114
|
+
def test_fake_models_the_real_tool(self):
|
|
115
|
+
# What every rule below stands on (verified live on my-mini, 2026-09-24): no -a
|
|
116
|
+
# answers with the OLDEST item, -a picks its own, add -U updates in place.
|
|
117
|
+
self.put(DEAD, 'gas', '20260910173739')
|
|
118
|
+
self.put(LIVE, 'unknown', '20260924095747')
|
|
119
|
+
svc = self.keychain.service_name(self.d)
|
|
120
|
+
out = subprocess.run(['security', 'find-generic-password', '-s', svc, '-w'],
|
|
121
|
+
capture_output=True, text=True).stdout
|
|
122
|
+
self.assertEqual(json.loads(out), DEAD)
|
|
123
|
+
out = subprocess.run(['security', 'find-generic-password', '-a', 'unknown',
|
|
124
|
+
'-s', svc, '-w'], capture_output=True, text=True).stdout
|
|
125
|
+
self.assertEqual(json.loads(out), LIVE)
|
|
126
|
+
rc = subprocess.run(['security', 'add-generic-password', '-a', 'gas', '-s', svc,
|
|
127
|
+
'-X', '00']).returncode
|
|
128
|
+
self.assertEqual(rc, 45)
|
|
129
|
+
|
|
107
130
|
def test_absent_then_present_roundtrip(self):
|
|
108
|
-
d =
|
|
131
|
+
d = self.d
|
|
109
132
|
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
110
|
-
self.assertTrue(self.keychain.write(d, DOC, account='
|
|
133
|
+
self.assertTrue(self.keychain.write(d, DOC, account='someone-else'))
|
|
111
134
|
res = self.keychain.probe(d)
|
|
112
135
|
self.assertEqual(res['state'], 'present')
|
|
113
136
|
self.assertEqual(res['doc'], DOC)
|
|
114
|
-
#
|
|
137
|
+
# the account argument is ignored: writes land under the canonical name
|
|
138
|
+
self.assertEqual((res['account'], res['canonical']), ('gas', 'gas'))
|
|
139
|
+
# -U update path keeps a single item
|
|
115
140
|
doc2 = {'claudeAiOauth': dict(DOC['claudeAiOauth'], accessToken='sk-ant-oat01-y')}
|
|
116
141
|
self.assertTrue(self.keychain.write(d, doc2))
|
|
117
142
|
again = self.keychain.probe(d)
|
|
118
143
|
self.assertEqual(again['doc']['claudeAiOauth']['accessToken'], 'sk-ant-oat01-y')
|
|
144
|
+
self.assertEqual(self.items(), ['gas'])
|
|
119
145
|
self.assertTrue(self.keychain.delete(d))
|
|
120
146
|
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
121
147
|
self.assertTrue(self.keychain.delete(d)) # deleting a missing item is fine
|
|
122
148
|
|
|
123
149
|
def test_locked_is_distinguished_from_absent(self):
|
|
124
|
-
d =
|
|
125
|
-
self.keychain.write(d, DOC
|
|
150
|
+
d = self.d
|
|
151
|
+
self.keychain.write(d, DOC)
|
|
126
152
|
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
127
153
|
res = self.keychain.probe(d)
|
|
128
154
|
self.assertEqual(res['state'], 'locked')
|
|
129
155
|
self.assertIsNone(res['doc'])
|
|
130
|
-
self.assertEqual(res['account'], '
|
|
156
|
+
self.assertEqual(res['account'], 'gas')
|
|
131
157
|
self.assertGreater(self.keychain.item_mtime(d), 0)
|
|
132
158
|
# an id with NO item is still absent, not locked
|
|
133
159
|
self.assertEqual(self.keychain.probe(d + '-other')['state'], 'absent')
|
|
134
160
|
|
|
135
161
|
def test_corrupt_item(self):
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
self.kcdir.mkdir(exist_ok=True)
|
|
139
|
-
(self.kcdir / svc).write_text('not json at all')
|
|
140
|
-
self.assertEqual(self.keychain.probe(d)['state'], 'corrupt')
|
|
162
|
+
self.put('not json at all', 'gas', '20260924000000')
|
|
163
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'corrupt')
|
|
141
164
|
|
|
142
165
|
def test_kill_switch(self):
|
|
143
|
-
d =
|
|
166
|
+
d = self.d
|
|
144
167
|
self.keychain.write(d, DOC)
|
|
145
168
|
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '0'
|
|
146
169
|
self.assertFalse(self.keychain.enabled())
|
|
147
170
|
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
148
171
|
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
149
172
|
|
|
173
|
+
# -- several items per service: the 2026-09-24 incident ----------------------------
|
|
174
|
+
def test_the_canonical_item_answers_even_beside_a_newer_live_sibling(self):
|
|
175
|
+
# acct-02 on my-mini: the old login under the real name, and a fresh sign-in a
|
|
176
|
+
# USER-less client filed under Bun's "unknown". A client multiacc launches reads
|
|
177
|
+
# -a <canonical> and nothing else, so that is the item the pool must judge —
|
|
178
|
+
# reading the newer sibling would call an account alive that its client is not.
|
|
179
|
+
self.put(DEAD, 'gas', '20260910173739')
|
|
180
|
+
self.put(LIVE, 'unknown', '20260924095747')
|
|
181
|
+
res = self.keychain.probe(self.d)
|
|
182
|
+
self.assertEqual((res['state'], res['account'], res['canonical'], res['doc']),
|
|
183
|
+
('present', 'gas', 'gas', DEAD))
|
|
184
|
+
import calendar
|
|
185
|
+
self.assertEqual(self.keychain.item_mtime(self.d),
|
|
186
|
+
calendar.timegm((2026, 9, 10, 17, 37, 39, 0, 0, 0)))
|
|
187
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
188
|
+
res = self.keychain.probe(self.d)
|
|
189
|
+
self.assertEqual((res['state'], res['account']), ('locked', 'gas'))
|
|
190
|
+
self.assertEqual(res['modified'], calendar.timegm((2026, 9, 10, 17, 37, 39, 0, 0, 0)))
|
|
191
|
+
|
|
192
|
+
def test_the_canonical_item_answers_even_when_a_sibling_is_older(self):
|
|
193
|
+
# The reverse order: the stale sibling is the OLDEST item, so the old no -a
|
|
194
|
+
# lookup would have read it. The canonical item is what the client reads.
|
|
195
|
+
import calendar
|
|
196
|
+
self.put(DEAD, 'unknown', '20260901000000')
|
|
197
|
+
self.put(LIVE, 'gas', '20260924095747')
|
|
198
|
+
for legacy in (False, True):
|
|
199
|
+
res = self.keychain.probe(self.d, legacy=legacy)
|
|
200
|
+
self.assertEqual((res['state'], res['account'], res['doc']),
|
|
201
|
+
('present', 'gas', LIVE))
|
|
202
|
+
self.assertEqual(self.keychain.item_mtime(self.d),
|
|
203
|
+
calendar.timegm((2026, 9, 24, 9, 57, 47, 0, 0, 0)))
|
|
204
|
+
|
|
205
|
+
def test_an_item_under_another_name_is_absent_unless_legacy_is_asked(self):
|
|
206
|
+
# acct-06/acct-21 on my-mini: the only item sits under "unknown". The client
|
|
207
|
+
# multiacc launches reads -a <canonical> and cannot see it, so every lookup that
|
|
208
|
+
# judges the account says absent; only the limits pass (which moves it) and
|
|
209
|
+
# dedupe ask for the old no -a first match.
|
|
210
|
+
self.put(LIVE, 'unknown', '20260924095747')
|
|
211
|
+
res = self.keychain.probe(self.d)
|
|
212
|
+
self.assertEqual((res['state'], res['doc'], res['account']), ('absent', None, None))
|
|
213
|
+
self.assertEqual(self.keychain.item_mtime(self.d), 0)
|
|
214
|
+
res = self.keychain.probe(self.d, legacy=True)
|
|
215
|
+
self.assertEqual((res['state'], res['doc'], res['canonical']), ('present', LIVE, 'gas'))
|
|
216
|
+
self.assertNotEqual(res['account'], res['canonical'])
|
|
217
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
218
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'absent')
|
|
219
|
+
res = self.keychain.probe(self.d, legacy=True)
|
|
220
|
+
self.assertEqual((res['state'], res['account']), ('locked', 'unknown'))
|
|
221
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
222
|
+
self.put('garbage', 'olduser', '20260101000000', d=self.d + '-x')
|
|
223
|
+
self.assertEqual(self.keychain.probe(self.d + '-x')['state'], 'absent')
|
|
224
|
+
self.assertEqual(self.keychain.probe(self.d + '-x', legacy=True)['state'], 'corrupt')
|
|
225
|
+
|
|
226
|
+
def test_a_lookup_costs_one_security_call(self):
|
|
227
|
+
# Operator rule: a fix must not add time. The canonical item answers in ONE
|
|
228
|
+
# call, exactly what the old no -a lookup cost; enumeration is for writes only.
|
|
229
|
+
self.put(LIVE, 'gas', '20260924095747')
|
|
230
|
+
self.put(DEAD, 'unknown', '20260924095748')
|
|
231
|
+
res, log = self.calls(self.keychain.probe, self.d)
|
|
232
|
+
self.assertEqual(res['state'], 'present')
|
|
233
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1'])
|
|
234
|
+
res, log = self.calls(self.keychain.item_mtime, self.d)
|
|
235
|
+
self.assertGreater(res, 0)
|
|
236
|
+
self.assertEqual(log, ['find-generic-password a=gas w=0'])
|
|
237
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
238
|
+
res, log = self.calls(self.keychain.probe, self.d)
|
|
239
|
+
self.assertEqual(res['state'], 'locked')
|
|
240
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1',
|
|
241
|
+
'find-generic-password a=gas w=0'])
|
|
242
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
243
|
+
# No canonical item — a token-only or file-only account, or one whose item sits
|
|
244
|
+
# under another name: still ONE call, for the probe and the stamp alike. These
|
|
245
|
+
# run for every account on every list/status the panel's heartbeat asks for.
|
|
246
|
+
other = self.d + '-legacy'
|
|
247
|
+
self.put(LIVE, 'unknown', '20260924095747', d=other)
|
|
248
|
+
for d in (self.d + '-none', other):
|
|
249
|
+
res, log = self.calls(self.keychain.probe, d)
|
|
250
|
+
self.assertEqual(res['state'], 'absent')
|
|
251
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1'])
|
|
252
|
+
res, log = self.calls(self.keychain.item_mtime, d)
|
|
253
|
+
self.assertEqual(res, 0)
|
|
254
|
+
self.assertEqual(log, ['find-generic-password a=gas w=0'])
|
|
255
|
+
# Only the migration read pays a second call, and only without a canonical item.
|
|
256
|
+
res, log = self.calls(self.keychain.probe, other, True)
|
|
257
|
+
self.assertEqual(res['state'], 'present')
|
|
258
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1',
|
|
259
|
+
'find-generic-password a=- w=1'])
|
|
260
|
+
res, log = self.calls(self.keychain.probe, self.d, True)
|
|
261
|
+
self.assertEqual(log, ['find-generic-password a=gas w=1'])
|
|
262
|
+
|
|
263
|
+
def test_write_lands_on_canonical_and_removes_spent_siblings(self):
|
|
264
|
+
self.put(DEAD, 'olduser', '20260101000000') # the oldest, any name
|
|
265
|
+
self.put(DEAD, 'unknown', '20260910173739')
|
|
266
|
+
self.put('not a credential', 'claude-code-user', '20260911000000')
|
|
267
|
+
self.assertTrue(self.keychain.write(self.d, LIVE))
|
|
268
|
+
self.assertEqual(self.items(), ['gas'])
|
|
269
|
+
self.assertEqual(self.secret('gas'), LIVE)
|
|
270
|
+
|
|
271
|
+
def test_write_keeps_a_live_sibling_holding_another_grant(self):
|
|
272
|
+
# After a sign-in as the WRONG account the client itself put the intruder's
|
|
273
|
+
# grant under the canonical name, and the right account's live login may sit
|
|
274
|
+
# beside it (under "unknown", from a USER-less sign-in). Refreshing the intruder
|
|
275
|
+
# must not destroy it; the sibling still holding the grant the refresh started
|
|
276
|
+
# from is spent (the refresh rotated it) and goes.
|
|
277
|
+
intruder = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-intr', 'refreshToken': 'ri',
|
|
278
|
+
'expiresAt': 1000, 'refreshTokenExpiresAt': 9999999999999}}
|
|
279
|
+
right = {'claudeAiOauth': dict(LIVE['claudeAiOauth'], refreshToken='rr')}
|
|
280
|
+
self.put(intruder, 'gas', '20260924100000')
|
|
281
|
+
self.put(right, 'unknown', '20260924095747')
|
|
282
|
+
self.put(intruder, 'claude-code-user', '20260924090000')
|
|
283
|
+
rotated = {'claudeAiOauth': dict(intruder['claudeAiOauth'], accessToken='sk-ant-oat01-i2',
|
|
284
|
+
refreshToken='ri2', expiresAt=9999999999999)}
|
|
285
|
+
self.assertTrue(self.keychain.write(self.d, rotated, supersedes='ri'))
|
|
286
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
287
|
+
self.assertEqual(self.secret('unknown'), right)
|
|
288
|
+
self.assertEqual(self.secret('gas'), rotated)
|
|
289
|
+
|
|
290
|
+
def test_refresh_write_back_migrates_a_legacy_item(self):
|
|
291
|
+
# The limits pass reads a legacy "unknown" item, refreshes it and writes it back:
|
|
292
|
+
# it lands under the canonical name and, after the read-back, the sibling the
|
|
293
|
+
# refresh came from goes (its grant was rotated away).
|
|
294
|
+
self.put(LIVE, 'unknown', '20260924095747')
|
|
295
|
+
doc = self.keychain.probe(self.d, legacy=True)['doc']
|
|
296
|
+
rotated = {'claudeAiOauth': dict(doc['claudeAiOauth'], accessToken='sk-ant-oat01-rot',
|
|
297
|
+
refreshToken='r2')}
|
|
298
|
+
self.assertTrue(self.keychain.write(self.d, rotated, supersedes='r'))
|
|
299
|
+
self.assertEqual(self.items(), ['gas'])
|
|
300
|
+
self.assertEqual(self.secret('gas'), rotated)
|
|
301
|
+
self.assertEqual(self.keychain.probe(self.d)['account'], 'gas')
|
|
302
|
+
# moving it unchanged (no refresh) retires the legacy copy the same way
|
|
303
|
+
other = self.d + '-move'
|
|
304
|
+
self.put(LIVE, 'unknown', '20260924095747', d=other)
|
|
305
|
+
self.assertTrue(self.keychain.write(other, self.keychain.probe(other, legacy=True)['doc']))
|
|
306
|
+
self.assertEqual(self.items(other), ['gas'])
|
|
307
|
+
self.assertEqual(self.secret('gas', d=other), LIVE)
|
|
308
|
+
|
|
309
|
+
def test_write_keeps_siblings_without_a_good_readback(self):
|
|
310
|
+
self.put(DEAD, 'unknown', '20260910173739')
|
|
311
|
+
real_run = self.keychain._run
|
|
312
|
+
|
|
313
|
+
def lying_run(args, timeout=None):
|
|
314
|
+
# the canonical item reads back as something else (a client refreshed it
|
|
315
|
+
# in between, or the read failed): nothing may be deleted on that evidence
|
|
316
|
+
if args[:1] == ['find-generic-password'] and '-w' in args \
|
|
317
|
+
and args[args.index('-a') + 1] == 'gas':
|
|
318
|
+
return 0, json.dumps(DEAD), ''
|
|
319
|
+
return real_run(args)
|
|
320
|
+
|
|
321
|
+
self.keychain._run = lying_run
|
|
322
|
+
try:
|
|
323
|
+
self.assertTrue(self.keychain.write(self.d, LIVE))
|
|
324
|
+
finally:
|
|
325
|
+
self.keychain._run = real_run
|
|
326
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
327
|
+
|
|
328
|
+
def test_delete_removes_every_duplicate(self):
|
|
329
|
+
self.put(DEAD, 'renamed-a', '20260101000000') # oldest: the no -a match
|
|
330
|
+
self.put(DEAD, 'renamed-b', '20260102000000') # a name nobody enumerates
|
|
331
|
+
self.put(LIVE, 'gas', '20260924000000')
|
|
332
|
+
self.put(DEAD, 'unknown', '20260923000000')
|
|
333
|
+
self.put(DEAD, 'claude-code-user', '20260922000000')
|
|
334
|
+
self.assertTrue(self.keychain.delete(self.d))
|
|
335
|
+
self.assertEqual(self.items(), [])
|
|
336
|
+
self.assertEqual(self.keychain.probe(self.d)['state'], 'absent')
|
|
337
|
+
|
|
338
|
+
def test_delete_reports_a_locked_keychain(self):
|
|
339
|
+
self.put(LIVE, 'gas', '20260924000000')
|
|
340
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
341
|
+
self.assertFalse(self.keychain.delete(self.d))
|
|
342
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
343
|
+
self.assertEqual(self.items(), ['gas'])
|
|
344
|
+
|
|
345
|
+
def test_canonical_account_mirrors_the_client(self):
|
|
346
|
+
import pwd
|
|
347
|
+
self.assertEqual(self.keychain.canonical_account(), 'gas')
|
|
348
|
+
# USER unset: the passwd name — never Bun's "unknown"
|
|
349
|
+
os.environ.pop('USER')
|
|
350
|
+
name = pwd.getpwuid(os.getuid()).pw_name
|
|
351
|
+
expect = name if re.fullmatch(r'[A-Za-z0-9._-]+', name) else 'claude-code-user'
|
|
352
|
+
self.assertEqual(self.keychain.canonical_account(), expect)
|
|
353
|
+
os.environ['USER'] = ''
|
|
354
|
+
self.assertEqual(self.keychain.canonical_account(), expect)
|
|
355
|
+
for bad in ('two words', 'ümlaut', 'a/b'):
|
|
356
|
+
os.environ['USER'] = bad
|
|
357
|
+
self.assertEqual(self.keychain.canonical_account(), 'claude-code-user')
|
|
358
|
+
|
|
359
|
+
def test_prune_keeps_a_live_canonical_item_and_drops_the_rest(self):
|
|
360
|
+
self.put(DEAD, 'olduser', '20260101000000')
|
|
361
|
+
self.put(LIVE, 'gas', '20260924095747')
|
|
362
|
+
self.put(DEAD, 'unknown', '20260924095748')
|
|
363
|
+
self.put(DEAD, 'claude-code-user', '20260924095749')
|
|
364
|
+
(ok, line), log = self.calls(self.keychain.prune, self.d)
|
|
365
|
+
self.assertTrue(ok, line)
|
|
366
|
+
self.assertEqual(line, 'pruned: kept gas, removed olduser,unknown,claude-code-user')
|
|
367
|
+
self.assertEqual(self.items(), ['gas'])
|
|
368
|
+
self.assertEqual(self.secret('gas'), LIVE)
|
|
369
|
+
# it never writes (no secret on argv) and never deletes the canonical item
|
|
370
|
+
self.assertFalse([c for c in log if c.startswith('add-generic-password')], log)
|
|
371
|
+
self.assertNotIn('delete-generic-password a=gas w=0', log)
|
|
372
|
+
self.assertEqual(self.keychain.prune(self.d), (True, 'ok: one item, under gas'))
|
|
373
|
+
|
|
374
|
+
def test_prune_refuses_unless_the_canonical_item_is_a_live_login(self):
|
|
375
|
+
# dead canonical: the live sibling may be the only working grant left
|
|
376
|
+
self.put(DEAD, 'gas', '20260910173739')
|
|
377
|
+
self.put(LIVE, 'unknown', '20260924095747')
|
|
378
|
+
ok, line = self.keychain.prune(self.d)
|
|
379
|
+
self.assertTrue(ok)
|
|
380
|
+
self.assertTrue(line.startswith('skip: the login under gas is expired'), line)
|
|
381
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
382
|
+
self.assertNotIn('sk-ant', line)
|
|
383
|
+
# locked: the canonical item cannot be judged from here
|
|
384
|
+
self.put(LIVE, 'gas', '20260924100000')
|
|
385
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
386
|
+
self.assertEqual(self.keychain.prune(self.d),
|
|
387
|
+
(True, 'skip: keychain locked for this session'))
|
|
388
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED')
|
|
389
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
390
|
+
# corrupt canonical
|
|
391
|
+
self.put('garbage', 'gas', '20260924110000')
|
|
392
|
+
self.assertEqual(self.keychain.prune(self.d),
|
|
393
|
+
(True, 'skip: the item under gas is not a credential'))
|
|
394
|
+
self.assertEqual(self.items(), ['gas', 'unknown'])
|
|
395
|
+
# absent canonical (the unknown-only shape): nothing is removed
|
|
396
|
+
other = self.d + '-u'
|
|
397
|
+
self.put(LIVE, 'unknown', '20260924095747', d=other)
|
|
398
|
+
self.assertEqual(self.keychain.prune(other), (True, 'skip: no item under gas'))
|
|
399
|
+
self.assertEqual(self.items(other), ['unknown'])
|
|
400
|
+
|
|
401
|
+
def test_cli_probe_and_prune_stay_non_secret(self):
|
|
402
|
+
self.put(LIVE, 'gas', '20260924095747')
|
|
403
|
+
self.put(DEAD, 'unknown', '20260910173739')
|
|
404
|
+
cli = [sys.executable, str(REPO / 'lib' / 'keychain.py')]
|
|
405
|
+
out = subprocess.run(cli + ['probe', self.d], capture_output=True, text=True,
|
|
406
|
+
check=True).stdout
|
|
407
|
+
self.assertNotIn('sk-ant', out)
|
|
408
|
+
doc = json.loads(out)
|
|
409
|
+
self.assertEqual((doc['state'], doc['account'], doc['canonical']),
|
|
410
|
+
('present', 'gas', 'gas'))
|
|
411
|
+
out = subprocess.run(cli + ['prune', self.d], capture_output=True, text=True)
|
|
412
|
+
self.assertEqual(out.returncode, 0, out.stderr)
|
|
413
|
+
self.assertEqual(out.stdout.strip(), 'pruned: kept gas, removed unknown')
|
|
414
|
+
self.assertEqual(self.items(), ['gas'])
|
|
415
|
+
|
|
150
416
|
# -- audit + report integration --------------------------------------------------
|
|
151
417
|
def _manifest(self):
|
|
152
418
|
(self.root / 'accounts.json').write_text(json.dumps({
|
|
@@ -174,6 +440,13 @@ class KeychainTests(unittest.TestCase):
|
|
|
174
440
|
self.assertEqual(row['state'], 'locked')
|
|
175
441
|
self.assertEqual(row['label'], 'KEYCHAIN LOCKED')
|
|
176
442
|
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
443
|
+
# a live login under a name the client does not read is no login here, exactly
|
|
444
|
+
# as the shim sees it (bin/claude kc_lookup -a "$KC_ACCOUNT")
|
|
445
|
+
self.keychain.delete(d)
|
|
446
|
+
self.put(LIVE, 'unknown', '20260924095747', d=d)
|
|
447
|
+
row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
448
|
+
'home': 'mac'}, machine='mac')
|
|
449
|
+
self.assertEqual(row['state'], 'missing')
|
|
177
450
|
|
|
178
451
|
def test_report_class_and_status(self):
|
|
179
452
|
self._manifest()
|