claude-multiacc 2.0.41 → 2.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +60 -2
  2. package/bin/claude +414 -5
  3. package/bin/claude-accounts +36 -11
  4. package/bin/codex +406 -4
  5. package/docs/ACCOUNT_OPERATIONS.md +7 -1
  6. package/docs/AUTORESUME.md +321 -0
  7. package/docs/CODEX.md +12 -1
  8. package/docs/VERIFICATION.md +9 -1
  9. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  19. package/lib/autoresume.py +2271 -0
  20. package/lib/common.sh +24 -1
  21. package/lib/keychain.py +274 -59
  22. package/package.json +1 -1
  23. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  24. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  25. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  26. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  27. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  28. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  30. package/tests/fake_security.sh +90 -0
  31. package/tests/run-tests.sh +1178 -67
  32. package/tests/test_autoresume.py +2565 -0
  33. package/tests/test_keychain.py +323 -50
@@ -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 = r'''#!/usr/bin/env bash
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 = str(self.root / 'acct-01')
131
+ d = self.d
109
132
  self.assertEqual(self.keychain.probe(d)['state'], 'absent')
110
- self.assertTrue(self.keychain.write(d, DOC, account='gas'))
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
- # -U update path keeps a single item and the account name it was made with
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 = str(self.root / 'acct-01')
125
- self.keychain.write(d, DOC, account='tester')
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'], 'tester')
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
- d = str(self.root / 'acct-01')
137
- svc = self.keychain.service_name(d)
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 = str(self.root / 'acct-01')
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()