claude-multiacc 2.0.2 → 2.0.4
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/CLAUDE_ACCS_TASK.md +7 -0
- package/README.md +28 -7
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +47 -11
- package/docs/ACCOUNT_OPERATIONS.md +7 -3
- package/install.sh +24 -8
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.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/audit.py +87 -21
- package/lib/codex_reset.py +170 -0
- package/lib/common.sh +62 -5
- package/lib/credential.py +9 -1
- package/lib/keychain.py +210 -0
- package/lib/report.py +33 -8
- package/package.json +1 -1
- package/tests/run-tests.sh +287 -0
- package/tests/test_codex_reset.py +170 -0
- package/tests/test_keychain.py +204 -0
package/lib/keychain.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""macOS Keychain-held Claude Code OAuth logins.
|
|
2
|
+
|
|
3
|
+
Claude Code on macOS keeps a config dir's OAuth credential in the login Keychain —
|
|
4
|
+
service ``Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[:8]>``, account = the
|
|
5
|
+
macOS user name — whenever the keychain is writable, and DELETES the plaintext
|
|
6
|
+
``.credentials.json`` the moment a keychain write lands (its store is "keychain with
|
|
7
|
+
a plaintext fallback": the fallback file is removed once the primary holds the
|
|
8
|
+
credential). Only sessions that cannot open the keychain — ssh, tmux, launchd
|
|
9
|
+
background jobs, where ``security`` exits 36 "user interaction is not allowed" — keep
|
|
10
|
+
using the file.
|
|
11
|
+
|
|
12
|
+
So a per-dir login that was file-based when it was added (an ``add`` run over ssh)
|
|
13
|
+
migrates into the Keychain the first time a GUI-session process refreshes its token —
|
|
14
|
+
a launchd agent, a Terminal window, a runner-spawned ``claude`` — and from then on the
|
|
15
|
+
file is gone. Reading only ``.credentials.json`` then reports a perfectly working
|
|
16
|
+
login as "no credentials on this machine": the shim stops selecting it, telemetry
|
|
17
|
+
goes dark, and every consumer of ``list --json`` shows it missing.
|
|
18
|
+
|
|
19
|
+
This module is the one place that knows the keychain layout. Everything else asks
|
|
20
|
+
:func:`probe`, which never raises and never prints the secret.
|
|
21
|
+
|
|
22
|
+
States returned by :func:`probe`:
|
|
23
|
+
present the item exists and this process could read it (``doc`` is the parsed
|
|
24
|
+
``{"claudeAiOauth": …}`` document, identical in shape to the file)
|
|
25
|
+
locked the item exists but this session cannot open the keychain (exit 36) —
|
|
26
|
+
a GUI-session process on this Mac CAN use it; an ssh session cannot
|
|
27
|
+
absent no such item
|
|
28
|
+
corrupt the item exists and was readable, but is not a credential document
|
|
29
|
+
unavailable not macOS, no ``security`` tool, or it failed in a way that says
|
|
30
|
+
nothing about the item (timeout, crash)
|
|
31
|
+
|
|
32
|
+
Override: ``CLAUDE_MULTIACC_KEYCHAIN=0`` turns the whole lookup off (every dir reads
|
|
33
|
+
as ``absent``), ``=1`` forces it on (the test suite runs the real code path on Linux
|
|
34
|
+
against a fake ``security``). Unset: on when running on macOS with ``security``
|
|
35
|
+
available.
|
|
36
|
+
|
|
37
|
+
Run directly: ``python3 lib/keychain.py <probe|service|mtime|delete> <config-dir>``.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import getpass
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import shutil
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import time
|
|
50
|
+
|
|
51
|
+
SERVICE_PREFIX = 'Claude Code-credentials'
|
|
52
|
+
# `security` maps OSStatus to an exit code modulo 256.
|
|
53
|
+
EXIT_NOT_FOUND = 44 # errSecItemNotFound (-25300)
|
|
54
|
+
EXIT_NO_INTERACTION = 36 # errSecInteractionNotAllowed (-25308): keychain locked here
|
|
55
|
+
TIMEOUT_S = 8
|
|
56
|
+
|
|
57
|
+
_ATTR_ACCT = re.compile(r'"acct"<blob>="((?:[^"\\]|\\.)*)"')
|
|
58
|
+
_ATTR_MDAT = re.compile(r'"mdat"<timedate>=0x[0-9A-Fa-f]+\s+"(\d{14})Z')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def enabled():
|
|
62
|
+
"""Whether keychain lookups run at all in this process (see module doc)."""
|
|
63
|
+
override = os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '').strip().lower()
|
|
64
|
+
if override in ('0', 'false', 'no', 'off'):
|
|
65
|
+
return False
|
|
66
|
+
if override in ('1', 'true', 'yes', 'on'):
|
|
67
|
+
return shutil.which('security') is not None
|
|
68
|
+
return sys.platform == 'darwin' and shutil.which('security') is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def service_name(config_dir):
|
|
72
|
+
"""The keychain service Claude Code uses for a CLAUDE_CONFIG_DIR — hashed from the
|
|
73
|
+
path string exactly as the client was given it (no realpath, no trailing slash
|
|
74
|
+
games), which is what the shim passes: ``$ACC_ROOT/acct-NN``."""
|
|
75
|
+
digest = hashlib.sha256(str(config_dir).encode('utf-8')).hexdigest()[:8]
|
|
76
|
+
return f'{SERVICE_PREFIX}-{digest}'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run(args, timeout=TIMEOUT_S):
|
|
80
|
+
"""(returncode, stdout, stderr); returncode None when the tool could not run."""
|
|
81
|
+
try:
|
|
82
|
+
p = subprocess.run(['security'] + list(args), capture_output=True, text=True,
|
|
83
|
+
timeout=timeout, stdin=subprocess.DEVNULL)
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return None, '', ''
|
|
86
|
+
return p.returncode, p.stdout or '', p.stderr or ''
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _attributes(service):
|
|
90
|
+
"""(rc, account, modified_epoch). Attribute reads work on a LOCKED keychain — only
|
|
91
|
+
the secret needs it open — which is what tells 'locked' from 'absent'."""
|
|
92
|
+
rc, out, _err = _run(['find-generic-password', '-s', service])
|
|
93
|
+
if rc != 0:
|
|
94
|
+
return rc, None, None
|
|
95
|
+
acct = _ATTR_ACCT.search(out)
|
|
96
|
+
mdat = _ATTR_MDAT.search(out)
|
|
97
|
+
modified = None
|
|
98
|
+
if mdat:
|
|
99
|
+
try:
|
|
100
|
+
import calendar
|
|
101
|
+
modified = calendar.timegm(time.strptime(mdat.group(1), '%Y%m%d%H%M%S'))
|
|
102
|
+
except (ValueError, OverflowError):
|
|
103
|
+
modified = None
|
|
104
|
+
return rc, (acct.group(1) if acct else None), modified
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def probe(config_dir):
|
|
108
|
+
"""Never raises, never logs the secret. See the module doc for the states."""
|
|
109
|
+
service = service_name(config_dir)
|
|
110
|
+
result = {'state': 'unavailable', 'service': service, 'doc': None,
|
|
111
|
+
'account': None, 'modified': None}
|
|
112
|
+
if not enabled():
|
|
113
|
+
result['state'] = 'absent' if os.environ.get('CLAUDE_MULTIACC_KEYCHAIN', '') \
|
|
114
|
+
.strip().lower() in ('0', 'false', 'no', 'off') else 'unavailable'
|
|
115
|
+
return result
|
|
116
|
+
rc, out, _err = _run(['find-generic-password', '-s', service, '-w'])
|
|
117
|
+
if rc == 0:
|
|
118
|
+
try:
|
|
119
|
+
doc = json.loads(out.strip())
|
|
120
|
+
except ValueError:
|
|
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'
|
|
129
|
+
return result
|
|
130
|
+
if rc == EXIT_NO_INTERACTION:
|
|
131
|
+
arc, acct, modified = _attributes(service)
|
|
132
|
+
if arc == 0:
|
|
133
|
+
result.update(state='locked', account=acct, modified=modified)
|
|
134
|
+
elif arc == EXIT_NOT_FOUND:
|
|
135
|
+
result['state'] = 'absent'
|
|
136
|
+
return result
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def item_mtime(config_dir):
|
|
141
|
+
"""Epoch of the item's last modification (0 when absent/unknown). Readable even
|
|
142
|
+
when the keychain is locked, so a marker can still self-heal on a newer login."""
|
|
143
|
+
if not enabled():
|
|
144
|
+
return 0
|
|
145
|
+
rc, _acct, modified = _attributes(service_name(config_dir))
|
|
146
|
+
if rc != 0:
|
|
147
|
+
return 0
|
|
148
|
+
return modified or 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def write(config_dir, doc, account=None):
|
|
152
|
+
"""Store ``doc`` the way Claude Code does (``add-generic-password -U``, hex
|
|
153
|
+
payload, same account name as the existing item so no duplicate is created).
|
|
154
|
+
True on success. The document is passed on argv — the same choice the client
|
|
155
|
+
makes when its payload does not fit `security -i`'s line limit; ``ps`` exposure
|
|
156
|
+
lasts milliseconds and is the same window `add-generic-password -w` always had."""
|
|
157
|
+
if not enabled():
|
|
158
|
+
return False
|
|
159
|
+
service = service_name(config_dir)
|
|
160
|
+
if not account:
|
|
161
|
+
_rc, existing, _m = _attributes(service)
|
|
162
|
+
account = existing or _current_user()
|
|
163
|
+
payload = json.dumps(doc, separators=(',', ':')).encode('utf-8').hex()
|
|
164
|
+
rc, _out, _err = _run(['add-generic-password', '-U', '-a', account, '-s', service,
|
|
165
|
+
'-X', payload])
|
|
166
|
+
return rc == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def delete(config_dir):
|
|
170
|
+
"""Remove the item. True when it is gone (including when it never existed)."""
|
|
171
|
+
if not enabled():
|
|
172
|
+
return True
|
|
173
|
+
rc, _out, _err = _run(['delete-generic-password', '-s', service_name(config_dir)])
|
|
174
|
+
return rc in (0, EXIT_NOT_FOUND)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _current_user():
|
|
178
|
+
try:
|
|
179
|
+
return getpass.getuser()
|
|
180
|
+
except Exception: # noqa: BLE001 — no passwd entry; the keychain item name is cosmetic
|
|
181
|
+
return os.environ.get('USER') or 'claude'
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv):
|
|
185
|
+
if len(argv) != 3 or argv[1] not in ('probe', 'service', 'mtime', 'delete'):
|
|
186
|
+
print('usage: keychain.py <probe|service|mtime|delete> <config-dir>', file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
verb, config_dir = argv[1], argv[2]
|
|
189
|
+
if verb == 'service':
|
|
190
|
+
print(service_name(config_dir))
|
|
191
|
+
return 0
|
|
192
|
+
if verb == 'mtime':
|
|
193
|
+
print(item_mtime(config_dir))
|
|
194
|
+
return 0
|
|
195
|
+
if verb == 'delete':
|
|
196
|
+
return 0 if delete(config_dir) else 1
|
|
197
|
+
res = probe(config_dir)
|
|
198
|
+
# The secret itself never leaves this process on stdout: callers that need the
|
|
199
|
+
# document import the module. The CLI answers with the state and non-secret facts.
|
|
200
|
+
o = (res.get('doc') or {}).get('claudeAiOauth') or {}
|
|
201
|
+
print(json.dumps({'state': res['state'], 'service': res['service'],
|
|
202
|
+
'account': res['account'], 'modified': res['modified'],
|
|
203
|
+
'expires_at': o.get('expiresAt'),
|
|
204
|
+
'refresh_token_expires_at': o.get('refreshTokenExpiresAt'),
|
|
205
|
+
'has_refresh_token': bool(o.get('refreshToken'))}))
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == '__main__':
|
|
210
|
+
sys.exit(main(sys.argv))
|
package/lib/report.py
CHANGED
|
@@ -23,14 +23,18 @@ Account status vocabulary (the panel's contract):
|
|
|
23
23
|
limited authenticates, but a >=threshold bucket is parked until limit_reset_at
|
|
24
24
|
expired has auth material that cannot authenticate — needs an interactive login
|
|
25
25
|
blocked authenticates, but the org/workspace disabled the CLI for it
|
|
26
|
+
locked claude only: the OAuth login is in the macOS Keychain and the process
|
|
27
|
+
that built this report could not open it (ssh/background session) — the
|
|
28
|
+
Mac's own GUI session uses it fine; never reported by a launchd runner
|
|
26
29
|
missing no auth material on this machine (and this machine should own it)
|
|
27
30
|
remote no auth here on purpose — the manifest says another machine owns it
|
|
28
31
|
|
|
29
32
|
Credential classes (what may be COPIED between machines):
|
|
30
33
|
portable claude setup-token (server.token) — safe to export/import anywhere
|
|
31
|
-
machine-local claude .credentials.json
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
machine-local claude .credentials.json OR a macOS Keychain item (credentials.oauth_store
|
|
35
|
+
says which) / codex auth.json — rotating refresh grant; copying it makes
|
|
36
|
+
two machines fight over one grant and breaks both
|
|
37
|
+
none nothing on disk (or in the Keychain) here
|
|
34
38
|
"""
|
|
35
39
|
|
|
36
40
|
import json
|
|
@@ -40,6 +44,9 @@ import socket
|
|
|
40
44
|
import sys
|
|
41
45
|
import time
|
|
42
46
|
|
|
47
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
48
|
+
import keychain # noqa: E402
|
|
49
|
+
|
|
43
50
|
SCHEMA = 'claude-multiacc/pool.v1'
|
|
44
51
|
|
|
45
52
|
# Must match the SHIM's ranking window, or this document calls data "fresh" that
|
|
@@ -152,20 +159,36 @@ def _sync_block(root, manifest):
|
|
|
152
159
|
|
|
153
160
|
|
|
154
161
|
def _claude_credentials(d):
|
|
155
|
-
"""(credential_class, credentials-detail) for a claude account dir.
|
|
162
|
+
"""(credential_class, credentials-detail) for a claude account dir.
|
|
163
|
+
|
|
164
|
+
The OAuth login is machine-local whether it sits in `.credentials.json` or in the
|
|
165
|
+
macOS Keychain (`oauth_store` says which; `keychain` says whether THIS process could
|
|
166
|
+
open it). A Keychain login this session cannot open is still a login on this Mac —
|
|
167
|
+
reporting it as `none` is what made a whole pool of working accounts read as
|
|
168
|
+
missing — so the class stays machine-local and the row's status says `locked`."""
|
|
156
169
|
cpath = os.path.join(d, '.credentials.json')
|
|
157
170
|
tpath = os.path.join(d, 'server.token')
|
|
158
171
|
has_oauth = _size(cpath) > 0
|
|
159
172
|
has_token = _size(tpath) > 0
|
|
160
|
-
detail = {'oauth': has_oauth, 'token': has_token,
|
|
173
|
+
detail = {'oauth': has_oauth, 'token': has_token, 'oauth_store': None, 'keychain': None,
|
|
161
174
|
'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
|
|
162
175
|
'token_minted_at': None, 'token_age_days': None}
|
|
176
|
+
o = None
|
|
163
177
|
if has_oauth:
|
|
178
|
+
detail['oauth_store'] = 'file'
|
|
164
179
|
doc = _load_json(cpath) or {}
|
|
165
180
|
o = doc.get('claudeAiOauth')
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
else:
|
|
182
|
+
kc = keychain.probe(d)
|
|
183
|
+
if kc['state'] in ('present', 'corrupt', 'locked'):
|
|
184
|
+
has_oauth = True
|
|
185
|
+
detail['oauth'] = True
|
|
186
|
+
detail['oauth_store'] = 'keychain'
|
|
187
|
+
detail['keychain'] = 'locked' if kc['state'] == 'locked' else 'readable'
|
|
188
|
+
o = (kc['doc'] or {}).get('claudeAiOauth')
|
|
189
|
+
if isinstance(o, dict):
|
|
190
|
+
detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
|
|
191
|
+
detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
|
|
169
192
|
if has_token:
|
|
170
193
|
mt = _mtime(tpath)
|
|
171
194
|
detail['token_minted_at'] = _iso(mt)
|
|
@@ -318,6 +341,8 @@ def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind
|
|
|
318
341
|
'reason': st['reason'],
|
|
319
342
|
'fix': st['fix'],
|
|
320
343
|
'selectable': st['state'] == 'ok' and not limited,
|
|
344
|
+
# 'locked' is deliberately NOT a login worklist item: the login exists and works
|
|
345
|
+
# from the Mac's own session; only the reporting process could not open it.
|
|
321
346
|
'needs_login': st['state'] in ('expired', 'blocked', 'missing'),
|
|
322
347
|
'credential_class': cclass,
|
|
323
348
|
'portable': cclass == 'portable',
|
package/package.json
CHANGED
package/tests/run-tests.sh
CHANGED
|
@@ -56,6 +56,16 @@ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
|
|
|
56
56
|
fi
|
|
57
57
|
if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
|
|
58
58
|
[ -n "${FAKE_LOGIN_FAIL:-}" ] && { echo "login aborted" >&2; exit 1; }
|
|
59
|
+
if [ -n "${FAKE_LOGIN_KEYCHAIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
60
|
+
# simulate the macOS client in a keychain-capable session: the login lands in the
|
|
61
|
+
# keychain (via the fake `security` on PATH) and NO .credentials.json is written
|
|
62
|
+
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
63
|
+
hex="$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' \
|
|
64
|
+
| python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
65
|
+
security add-generic-password -U -a tester -s "Claude Code-credentials-$h" -X "$hex"
|
|
66
|
+
echo "Logged in."
|
|
67
|
+
exit 0
|
|
68
|
+
fi
|
|
59
69
|
# simulate a completed full-scope login: write auto-refreshing creds to the config dir
|
|
60
70
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "${CLAUDE_CONFIG_DIR:-/dev/null}/.credentials.json"
|
|
61
71
|
echo "Logged in."
|
|
@@ -134,6 +144,65 @@ echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
|
|
|
134
144
|
EOF
|
|
135
145
|
chmod +x "$FAKEBIN/claude"
|
|
136
146
|
|
|
147
|
+
# Fake macOS `security` (generic-password verbs only): items live as files under
|
|
148
|
+
# $FAKE_KEYCHAIN_DIR/<service> (content = the secret) + <service>.acct (account name).
|
|
149
|
+
# FAKE_KEYCHAIN_LOCKED=1 simulates a session that cannot open the keychain — secret
|
|
150
|
+
# reads and every write exit 36 while ATTRIBUTE reads still answer, exactly the split
|
|
151
|
+
# the real tool exhibits over ssh. Shadows any real /usr/bin/security via PATH, so a
|
|
152
|
+
# macOS dev run of this suite can never touch the developer's actual keychain.
|
|
153
|
+
cat > "$FAKEBIN/security" <<'EOF'
|
|
154
|
+
#!/usr/bin/env bash
|
|
155
|
+
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
156
|
+
cmd="${1:-}"; shift || true
|
|
157
|
+
svc=""; acct=""; want_pw=0; hexdata=""
|
|
158
|
+
while [ $# -gt 0 ]; do
|
|
159
|
+
case "$1" in
|
|
160
|
+
-s) svc="$2"; shift 2 ;;
|
|
161
|
+
-a) acct="$2"; shift 2 ;;
|
|
162
|
+
-w) want_pw=1; shift ;;
|
|
163
|
+
-X) hexdata="$2"; shift 2 ;;
|
|
164
|
+
*) shift ;;
|
|
165
|
+
esac
|
|
166
|
+
done
|
|
167
|
+
# The modification stamp is RECORDED AT WRITE TIME in a sidecar rather than derived
|
|
168
|
+
# from the item file's mtime: `stat`/`date` flags differ between BSD and GNU (on Linux
|
|
169
|
+
# `stat -f %m` even "succeeds" with garbage), and this fake must behave identically on
|
|
170
|
+
# a developer's Mac and on Linux CI.
|
|
171
|
+
item_mdat() { cat "$KC/$svc.mdat" 2>/dev/null || echo 00000000000000; }
|
|
172
|
+
case "$cmd" in
|
|
173
|
+
show-keychain-info)
|
|
174
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
175
|
+
exit 0 ;;
|
|
176
|
+
find-generic-password)
|
|
177
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
178
|
+
if [ "$want_pw" = "1" ]; then
|
|
179
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
180
|
+
cat "$KC/$svc"
|
|
181
|
+
exit 0
|
|
182
|
+
fi
|
|
183
|
+
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
184
|
+
printf 'keychain: "login.keychain-db"\nclass: "genp"\nattributes:\n'
|
|
185
|
+
printf ' "acct"<blob>="%s"\n' "$a"
|
|
186
|
+
printf ' "mdat"<timedate>=0x00 "%sZ\\000"\n' "$(item_mdat)"
|
|
187
|
+
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
188
|
+
exit 0 ;;
|
|
189
|
+
add-generic-password)
|
|
190
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
191
|
+
mkdir -p "$KC"
|
|
192
|
+
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
193
|
+
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
194
|
+
date -u +%Y%m%d%H%M%S > "$KC/$svc.mdat"
|
|
195
|
+
exit 0 ;;
|
|
196
|
+
delete-generic-password)
|
|
197
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
198
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
199
|
+
rm -f "$KC/$svc" "$KC/$svc.acct" "$KC/$svc.mdat"
|
|
200
|
+
exit 0 ;;
|
|
201
|
+
*) exit 1 ;;
|
|
202
|
+
esac
|
|
203
|
+
EOF
|
|
204
|
+
chmod +x "$FAKEBIN/security"
|
|
205
|
+
|
|
137
206
|
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
138
207
|
export FAKE_CTL="$WORK/ctl"
|
|
139
208
|
# Neutralize any ambient state from the invoking environment.
|
|
@@ -155,6 +224,10 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
|
155
224
|
# throttle keeps those background kicks from racing explicit limits runs.
|
|
156
225
|
export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
157
226
|
: > "$ACC/.limits-kick"
|
|
227
|
+
# Keychain lookups are OFF for the legacy sections (their pools are file-based and the
|
|
228
|
+
# extra `security` process per account would only slow them down); section 17 turns
|
|
229
|
+
# them on explicitly against the fake `security` above.
|
|
230
|
+
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
158
231
|
|
|
159
232
|
now="$(date +%s)"
|
|
160
233
|
|
|
@@ -2366,6 +2439,61 @@ if command -v node >/dev/null 2>&1; then
|
|
|
2366
2439
|
esac
|
|
2367
2440
|
case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
|
|
2368
2441
|
*) t_fail "self-update message" "unexpected: $out" ;; esac
|
|
2442
|
+
|
|
2443
|
+
# ---- 19a. an npm install updates itself with NO npm on PATH -----------------------
|
|
2444
|
+
# launchd hands an agent PATH=/usr/bin:/bin:/usr/sbin:/sbin, so `command -v npm` finds
|
|
2445
|
+
# nothing and the nightly self-update logged "npm not found; skipping" for days while
|
|
2446
|
+
# every Mac's copy silently froze. npm must be resolved by path — and specifically the
|
|
2447
|
+
# one inside the prefix that owns the RUNNING copy, never whatever a stray PATH offers.
|
|
2448
|
+
for _cli in claude-accounts codex-accounts; do
|
|
2449
|
+
PFX="$WORK/pfx-$_cli"
|
|
2450
|
+
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
2451
|
+
mkdir -p "$NPMROOT" "$PFX/bin"
|
|
2452
|
+
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
2453
|
+
# A fake npm that records how it was called and "installs" by bumping package.json.
|
|
2454
|
+
cat > "$PFX/bin/npm" <<NPMEOF
|
|
2455
|
+
#!/usr/bin/env bash
|
|
2456
|
+
echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
|
|
2457
|
+
case "\${1:-}" in
|
|
2458
|
+
view) echo 9.9.9 ;;
|
|
2459
|
+
install) python3 - "$NPMROOT/package.json" <<'PY'
|
|
2460
|
+
import json, sys
|
|
2461
|
+
p = sys.argv[1]
|
|
2462
|
+
doc = json.load(open(p)); doc['version'] = '9.9.9'
|
|
2463
|
+
json.dump(doc, open(p, 'w'))
|
|
2464
|
+
PY
|
|
2465
|
+
;;
|
|
2466
|
+
esac
|
|
2467
|
+
exit 0
|
|
2468
|
+
NPMEOF
|
|
2469
|
+
chmod +x "$PFX/bin/npm"
|
|
2470
|
+
# A DIFFERENT npm earlier on PATH must not win: the running copy's prefix owns it.
|
|
2471
|
+
mkdir -p "$WORK/wrongbin"
|
|
2472
|
+
printf '#!/usr/bin/env bash\necho "WRONG-NPM \$*" >> "%s"\nexit 0\n' "$WORK/npm-calls-$_cli.log" > "$WORK/wrongbin/npm"
|
|
2473
|
+
chmod +x "$WORK/wrongbin/npm"
|
|
2474
|
+
: > "$WORK/npm-calls-$_cli.log"
|
|
2475
|
+
out="$(env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
2476
|
+
CLAUDE_ACCOUNTS_DIR="$ACC" CODEX_ACCOUNTS_DIR="$WORK/codex-accounts" \
|
|
2477
|
+
"$NPMROOT/bin/$_cli" self-update 2>&1)"
|
|
2478
|
+
rc=$?
|
|
2479
|
+
[ "$rc" = "0" ] && t_ok "$_cli: self-update exits 0 with no npm on PATH" \
|
|
2480
|
+
|| t_fail "$_cli self-update rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
2481
|
+
printf '%s' "$out" | grep -q "npm not found" \
|
|
2482
|
+
&& t_fail "$_cli self-update npm lookup" "still reports 'npm not found' with an npm in its own prefix" \
|
|
2483
|
+
|| t_ok "$_cli: npm resolved without PATH"
|
|
2484
|
+
grep -q "^npm install " "$WORK/npm-calls-$_cli.log" \
|
|
2485
|
+
&& t_ok "$_cli: the prefix's own npm performed the install" \
|
|
2486
|
+
|| t_fail "$_cli npm install" "calls: $(cat "$WORK/npm-calls-$_cli.log" | head -3)"
|
|
2487
|
+
grep -q "WRONG-NPM" "$WORK/npm-calls-$_cli.log" \
|
|
2488
|
+
&& t_fail "$_cli npm choice" "used the npm from PATH instead of the running prefix's" \
|
|
2489
|
+
|| t_ok "$_cli: a stray npm on PATH never wins over the running prefix's"
|
|
2490
|
+
check "$_cli: self-update verifies the tree it wrote" "npm update ok (9.9.9)" "$out"
|
|
2491
|
+
done
|
|
2492
|
+
# The agents install.sh writes must carry a PATH for the same reason (a future tool
|
|
2493
|
+
# that is not resolved by absolute path would hit exactly this again).
|
|
2494
|
+
grep -q '<key>PATH</key>' "$REPO_DIR/install.sh" \
|
|
2495
|
+
&& t_ok "install.sh gives its launchd agents a PATH" \
|
|
2496
|
+
|| t_fail "agent PATH" "plist_env_block writes no PATH — launchd agents get /usr/bin:/bin only"
|
|
2369
2497
|
# postinstall must SKIP for a non-global install and never fail
|
|
2370
2498
|
out="$(node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
|
|
2371
2499
|
rc=$?
|
|
@@ -2399,6 +2527,7 @@ unset CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
|
|
|
2399
2527
|
export CODEX_MULTIACC_NO_SYNC=1
|
|
2400
2528
|
export CODEX_MULTIACC_MIN_FETCH=0
|
|
2401
2529
|
export CODEX_MULTIACC_CLIENT_SCAN_TTL=0
|
|
2530
|
+
export CODEX_MULTIACC_AUTO_RESET=0
|
|
2402
2531
|
export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
|
|
2403
2532
|
# Default usage URL is an offline missing fixture: the SHIM's opportunistic
|
|
2404
2533
|
# background `limits --quiet` kick must never reach a real endpoint from tests
|
|
@@ -4071,6 +4200,164 @@ for _bin in claude-accounts codex-accounts; do
|
|
|
4071
4200
|
|| t_fail "$_bin unknown verb --help" "exited 0 — the probe would accept a verb that does not exist"
|
|
4072
4201
|
done
|
|
4073
4202
|
|
|
4203
|
+
# ---- 17. macOS Keychain-held logins --------------------------------------------------
|
|
4204
|
+
# Claude Code on macOS moves a per-dir OAuth login into the login Keychain (service
|
|
4205
|
+
# "Claude Code-credentials-<sha256(dir)[:8]>") from any keychain-capable session and
|
|
4206
|
+
# deletes .credentials.json — the 2026-08-28 incident read five working accounts as
|
|
4207
|
+
# "missing". The fake `security` above serves items from $FAKE_KEYCHAIN_DIR so the real
|
|
4208
|
+
# lookup code runs on Linux too. Own pool root: the main pool's manifest has been
|
|
4209
|
+
# through a dozen mutations by this point in the suite.
|
|
4210
|
+
KCP="$WORK/kcpool"
|
|
4211
|
+
export FAKE_KEYCHAIN_DIR="$WORK/keychain"
|
|
4212
|
+
mkdir -p "$KCP/tmp" "$KCP/acct-01" "$KCP/acct-02" "$FAKE_KEYCHAIN_DIR"
|
|
4213
|
+
: > "$KCP/.limits-kick"
|
|
4214
|
+
cat > "$KCP/accounts.json" <<EOF
|
|
4215
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
4216
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
4217
|
+
"accounts": [
|
|
4218
|
+
{"id": "acct-01", "email": "kfile@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"},
|
|
4219
|
+
{"id": "acct-02", "email": "kchain@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"}
|
|
4220
|
+
] }
|
|
4221
|
+
EOF
|
|
4222
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
|
|
4223
|
+
kc_hash() { printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8; }
|
|
4224
|
+
kc_svc_file() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
|
|
4225
|
+
kc_put() { # kc_put <acct dir> <credential json> — store it the way the client would
|
|
4226
|
+
security add-generic-password -U -a tester -s "Claude Code-credentials-$(kc_hash "$1")" \
|
|
4227
|
+
-X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
4228
|
+
}
|
|
4229
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4230
|
+
export CLAUDE_MULTIACC_KEYCHAIN=1
|
|
4231
|
+
|
|
4232
|
+
# 17a. the keychain login reads as a working machine-local OAuth login
|
|
4233
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
|
|
4234
|
+
python3 - "$out" <<'EOF'
|
|
4235
|
+
import json, sys
|
|
4236
|
+
doc = json.loads(sys.argv[1])
|
|
4237
|
+
rows = {a['id']: a for a in doc['accounts']}
|
|
4238
|
+
a2 = rows['acct-02']
|
|
4239
|
+
assert a2['status'] == 'active', a2['status']
|
|
4240
|
+
assert a2['credential_class'] == 'machine-local', a2['credential_class']
|
|
4241
|
+
assert a2['credentials']['oauth_store'] == 'keychain', a2['credentials']
|
|
4242
|
+
assert a2['credentials']['keychain'] == 'readable', a2['credentials']
|
|
4243
|
+
assert a2['selectable'] is True, a2
|
|
4244
|
+
assert rows['acct-01']['credentials']['oauth_store'] == 'file', rows['acct-01']['credentials']
|
|
4245
|
+
assert doc['summary']['selectable'] == 2, doc['summary']
|
|
4246
|
+
EOF
|
|
4247
|
+
[ $? -eq 0 ] && t_ok "keychain login is active machine-local in list --json" \
|
|
4248
|
+
|| t_fail "keychain list --json" "see assertions above"
|
|
4249
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list 2>&1)"
|
|
4250
|
+
check "keychain login shown in the plain list" "auth=keychain" "$out"
|
|
4251
|
+
|
|
4252
|
+
# 17b. the shim runs under a keychain-only account (file account parked by a limit)
|
|
4253
|
+
printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
|
|
4254
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
|
|
4255
|
+
check "shim selects the keychain-only account" "CFG=acct-02" "$out"
|
|
4256
|
+
|
|
4257
|
+
# 17c. locked keychain (ssh session): 'locked', never 'missing'; excluded HERE only
|
|
4258
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
|
|
4259
|
+
python3 - "$out" <<'EOF'
|
|
4260
|
+
import json, sys
|
|
4261
|
+
doc = json.loads(sys.argv[1])
|
|
4262
|
+
a2 = {a['id']: a for a in doc['accounts']}['acct-02']
|
|
4263
|
+
assert a2['status'] == 'locked', a2['status']
|
|
4264
|
+
assert a2['credential_class'] == 'machine-local', a2['credential_class']
|
|
4265
|
+
assert a2['credentials']['keychain'] == 'locked', a2['credentials']
|
|
4266
|
+
assert a2['selectable'] is False, a2
|
|
4267
|
+
assert a2['needs_login'] is False, 'locked must not join the re-login worklist'
|
|
4268
|
+
EOF
|
|
4269
|
+
[ $? -eq 0 ] && t_ok "locked keychain reads as 'locked' machine-local, not missing" \
|
|
4270
|
+
|| t_fail "keychain locked state" "see assertions above"
|
|
4271
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts expired --quiet 2>&1)"; rc=$?
|
|
4272
|
+
[ "$rc" = "0" ] && t_ok "a locked keychain login is not a relogin worklist item" \
|
|
4273
|
+
|| t_fail "locked vs expired" "rc=$rc out=$out"
|
|
4274
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
|
|
4275
|
+
check "shim never selects a keychain login it cannot read" "CFG=acct-01" "$out"
|
|
4276
|
+
rm -f "$KCP/acct-01/.limited"
|
|
4277
|
+
|
|
4278
|
+
# 17d. limits: the probe takes its bearer from the keychain
|
|
4279
|
+
rm -f "$KCP/acct-02/limits.json"
|
|
4280
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
4281
|
+
check "limits fetches with a keychain bearer" "acct-02: ok" "$out"
|
|
4282
|
+
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("source")=="oauth", d' \
|
|
4283
|
+
"$KCP/acct-02/limits.json" 2>/dev/null \
|
|
4284
|
+
&& t_ok "keychain-backed telemetry records source=oauth" \
|
|
4285
|
+
|| t_fail "keychain limits source" "limits.json missing or wrong source"
|
|
4286
|
+
|
|
4287
|
+
# 17e. refresh: an expired keychain credential refreshes via the grant and is written
|
|
4288
|
+
# BACK to the keychain — never copied out into a .credentials.json
|
|
4289
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
|
|
4290
|
+
rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
|
|
4291
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
4292
|
+
check "keychain credential refreshes via the grant" "acct-02: oauth access token refreshed" "$out"
|
|
4293
|
+
grep -q 'sk-ant-oat01-refreshednew' "$(kc_svc_file "$KCP/acct-02")" \
|
|
4294
|
+
&& t_ok "rotated credential written back to the keychain" \
|
|
4295
|
+
|| t_fail "keychain write-back" "item not updated"
|
|
4296
|
+
[ ! -f "$KCP/acct-02/.credentials.json" ] \
|
|
4297
|
+
&& t_ok "refresh never copies the keychain credential to a file" \
|
|
4298
|
+
|| t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
|
|
4299
|
+
|
|
4300
|
+
# 17f. .expired self-heals on a keychain login written after the marker
|
|
4301
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$KCP/acct-02/.expired"
|
|
4302
|
+
touch -t 202001010000 "$KCP/acct-02/.expired"
|
|
4303
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-knew","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4304
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
|
|
4305
|
+
[ ! -f "$KCP/acct-02/.expired" ] \
|
|
4306
|
+
&& t_ok ".expired self-heals on a newer keychain credential" \
|
|
4307
|
+
|| t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
|
|
4308
|
+
|
|
4309
|
+
# 17g. a login ceremony that lands in the keychain registers instead of dying
|
|
4310
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kc3@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add kc3@test 2>&1)"
|
|
4311
|
+
check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
|
|
4312
|
+
[ ! -f "$KCP/acct-03/.credentials.json" ] \
|
|
4313
|
+
&& t_ok "keychain add leaves no plaintext credential" \
|
|
4314
|
+
|| t_fail "keychain add" "unexpected .credentials.json"
|
|
4315
|
+
[ -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "the sign-in landed in the keychain" \
|
|
4316
|
+
|| t_fail "keychain add item" "no keychain item for acct-03"
|
|
4317
|
+
|
|
4318
|
+
# 17h. export refuses a keychain-held machine-local credential, naming the store
|
|
4319
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
|
|
4320
|
+
[ "$rc" = "3" ] && t_ok "export-credential refuses a keychain login (exit 3)" \
|
|
4321
|
+
|| t_fail "keychain export rc" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
4322
|
+
check "export names the keychain as the machine-local store" "macOS Keychain" "$out"
|
|
4323
|
+
|
|
4324
|
+
# 17i. remove deletes the keychain item with the account
|
|
4325
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-03 --yes >/dev/null 2>&1
|
|
4326
|
+
[ ! -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
|
|
4327
|
+
|| t_fail "keychain remove" "the item survived removal"
|
|
4328
|
+
|
|
4329
|
+
# 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
|
|
4330
|
+
# keychain holder gets the HIGHER id on purpose: with keychain auth invisible, the
|
|
4331
|
+
# lowest-id tiebreak would keep credential-less acct-04 and throw the grant away.
|
|
4332
|
+
python3 - "$KCP/accounts.json" <<'EOF'
|
|
4333
|
+
import json, sys
|
|
4334
|
+
doc = json.load(open(sys.argv[1]))
|
|
4335
|
+
doc['accounts'] += [
|
|
4336
|
+
{'id': 'acct-04', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'},
|
|
4337
|
+
{'id': 'acct-05', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'}]
|
|
4338
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
4339
|
+
EOF
|
|
4340
|
+
mkdir -p "$KCP/acct-04" "$KCP/acct-05"
|
|
4341
|
+
kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4342
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
|
|
4343
|
+
check "dedupe removes the credential-less duplicate, not the keychain one" "will remove acct-04 (kdup@test)" "$out"
|
|
4344
|
+
[ -f "$(kc_svc_file "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
|
|
4345
|
+
|| t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
|
|
4346
|
+
grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
4347
|
+
&& t_fail "dedupe manifest" "acct-04 still registered" \
|
|
4348
|
+
|| t_ok "the credential-less duplicate left the manifest"
|
|
4349
|
+
|
|
4350
|
+
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
4351
|
+
unset FAKE_KEYCHAIN_DIR
|
|
4352
|
+
|
|
4353
|
+
# The reset-credit contract is easier to prove against a stateful local HTTP server
|
|
4354
|
+
# than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
|
|
4355
|
+
if CODEX_MULTIACC_AUTO_RESET=1 python3 "$REPO_DIR/tests/test_codex_reset.py"; then
|
|
4356
|
+
t_ok "codex: automatic earned-reset integration suite"
|
|
4357
|
+
else
|
|
4358
|
+
t_fail "codex automatic reset suite" "see unittest output above"
|
|
4359
|
+
fi
|
|
4360
|
+
|
|
4074
4361
|
# ---- summary ---------------------------------------------------------------------
|
|
4075
4362
|
echo
|
|
4076
4363
|
echo "passed: $PASS failed: $FAIL"
|