claude-multiacc 2.0.3 → 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 +21 -6
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +24 -7
- package/docs/ACCOUNT_OPERATIONS.md +6 -2
- 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/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 +278 -0
- package/tests/test_keychain.py +204 -0
package/lib/audit.py
CHANGED
|
@@ -7,11 +7,20 @@ States:
|
|
|
7
7
|
expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
|
|
8
8
|
blocked authenticates, but its organization has disabled Claude Code subscription
|
|
9
9
|
access — a re-login cannot fix it, so it needs an admin (or removal)
|
|
10
|
+
locked the OAuth login is in the macOS Keychain and THIS session cannot open it
|
|
11
|
+
(ssh/tmux/background) — it works from the Mac's own GUI session, so it is
|
|
12
|
+
neither dead nor missing; just not usable from here
|
|
10
13
|
missing no auth on this machine, and this machine is supposed to own the grant
|
|
11
14
|
remote no auth here, but the manifest says another machine owns it (informational)
|
|
12
15
|
|
|
13
16
|
Only 'ok' accounts are selectable; everything else is excluded by the shim.
|
|
14
17
|
|
|
18
|
+
An OAuth login lives either in `<acct>/.credentials.json` or — on macOS, whenever the
|
|
19
|
+
client could open the login Keychain — in a Keychain item (lib/keychain.py). Both are
|
|
20
|
+
the same document and the same machine-local class; the file is simply gone once the
|
|
21
|
+
client has moved it into the Keychain, so a file-only reading calls a working login
|
|
22
|
+
"missing" (that is exactly what happened to a whole pool on 2026-08-28).
|
|
23
|
+
|
|
15
24
|
Run directly for a TSV dump: python3 lib/audit.py <acc-root> [mac|linux]
|
|
16
25
|
Columns: id, email, home, state, label, reason, fix
|
|
17
26
|
"""
|
|
@@ -22,6 +31,12 @@ import re
|
|
|
22
31
|
import sys
|
|
23
32
|
import time
|
|
24
33
|
|
|
34
|
+
# This module is loaded both as a package sibling (`from audit import …` with lib/ on
|
|
35
|
+
# sys.path) and by file path (importlib in lib/common.sh's creds_alive); the keychain
|
|
36
|
+
# helper sits beside it either way.
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
import keychain # noqa: E402
|
|
39
|
+
|
|
25
40
|
VALID_ID = re.compile(r'acct-\d{2}')
|
|
26
41
|
|
|
27
42
|
|
|
@@ -71,6 +86,12 @@ def expired_marked(d, now=None):
|
|
|
71
86
|
p = os.path.join(d, name)
|
|
72
87
|
if os.path.isfile(p) and _mtime(p) > mt:
|
|
73
88
|
return False
|
|
89
|
+
# A login the client keeps in the Keychain is "written after the marker" in
|
|
90
|
+
# exactly the same sense — its modification stamp is readable even when this
|
|
91
|
+
# session cannot open the secret.
|
|
92
|
+
if not os.path.isfile(os.path.join(d, '.credentials.json')) \
|
|
93
|
+
and keychain.item_mtime(d) > mt:
|
|
94
|
+
return False
|
|
74
95
|
m = re.search(r'soft_until=(\d+)', detail)
|
|
75
96
|
if m and int(m.group(1)) <= now:
|
|
76
97
|
return False
|
|
@@ -102,16 +123,31 @@ def _scrape(cpath):
|
|
|
102
123
|
'has_refresh': bool(re.search(r'"refreshToken"\s*:\s*"[^"]', txt))}
|
|
103
124
|
|
|
104
125
|
|
|
126
|
+
def creds_doc_state(doc, now):
|
|
127
|
+
"""(state, reason) for a parsed credential document ({"claudeAiOauth": …}) —
|
|
128
|
+
the one rule for a file AND a Keychain item, so the two stores can never be
|
|
129
|
+
judged differently."""
|
|
130
|
+
o = doc.get('claudeAiOauth', {}) if isinstance(doc, dict) else None
|
|
131
|
+
if not isinstance(o, dict):
|
|
132
|
+
raise ValueError('claudeAiOauth is not an object')
|
|
133
|
+
exp, rexp = _ms(o.get('expiresAt')), _ms(o.get('refreshTokenExpiresAt'))
|
|
134
|
+
has_refresh = bool(o.get('refreshToken'))
|
|
135
|
+
if exp > now:
|
|
136
|
+
return 'ok', 'oauth credential valid'
|
|
137
|
+
if not has_refresh:
|
|
138
|
+
return 'expired', 'access token expired and there is no refresh token'
|
|
139
|
+
if rexp and rexp <= now:
|
|
140
|
+
days = max(0, int((now - rexp) / 86400))
|
|
141
|
+
when = time.strftime('%Y-%m-%d', time.gmtime(rexp))
|
|
142
|
+
return 'expired', f'refresh token expired {when} ({days}d ago)'
|
|
143
|
+
return 'ok', 'access token stale but auto-refreshes'
|
|
144
|
+
|
|
145
|
+
|
|
105
146
|
def creds_state(cpath, now):
|
|
106
147
|
"""(state, reason) for an on-disk .credentials.json."""
|
|
107
148
|
scraped = None
|
|
108
149
|
try:
|
|
109
|
-
|
|
110
|
-
o = doc.get('claudeAiOauth', {})
|
|
111
|
-
if not isinstance(o, dict):
|
|
112
|
-
raise ValueError('claudeAiOauth is not an object')
|
|
113
|
-
exp, rexp = _ms(o.get('expiresAt')), _ms(o.get('refreshTokenExpiresAt'))
|
|
114
|
-
has_refresh = bool(o.get('refreshToken'))
|
|
150
|
+
return creds_doc_state(json.load(open(cpath)), now)
|
|
115
151
|
except Exception as e:
|
|
116
152
|
# Unparseable (truncated / mid-write): fall back to the shim's lenient scrape so
|
|
117
153
|
# both sides agree. Only if that finds nothing usable is the account called dead.
|
|
@@ -122,18 +158,40 @@ def creds_state(cpath, now):
|
|
|
122
158
|
if exp <= now and not (has_refresh and (not rexp or rexp > now)):
|
|
123
159
|
return 'expired', f'credentials unreadable ({str(e)[:60]})'
|
|
124
160
|
return 'ok', 'credentials unreadable but carry a live token — left in the pool'
|
|
125
|
-
if exp > now:
|
|
126
|
-
return 'ok', 'oauth credential valid'
|
|
127
|
-
if not has_refresh:
|
|
128
|
-
return 'expired', 'access token expired and there is no refresh token'
|
|
129
|
-
if rexp and rexp <= now:
|
|
130
|
-
days = max(0, int((now - rexp) / 86400))
|
|
131
|
-
when = time.strftime('%Y-%m-%d', time.gmtime(rexp))
|
|
132
|
-
return 'expired', f'refresh token expired {when} ({days}d ago)'
|
|
133
|
-
return 'ok', 'access token stale but auto-refreshes'
|
|
134
161
|
|
|
135
162
|
|
|
136
|
-
|
|
163
|
+
def oauth_login(d, now):
|
|
164
|
+
"""Where <d>'s OAuth login lives and whether it can authenticate.
|
|
165
|
+
|
|
166
|
+
Returns {'store': 'file'|'keychain'|None, 'state': 'ok'|'expired'|'locked'|None,
|
|
167
|
+
'reason': str, 'doc': dict|None}. The FILE wins when both exist: it is what
|
|
168
|
+
a session without keychain access (ssh) will use, and the client itself reads the
|
|
169
|
+
keychain first only when it can open it. 'locked' means the login exists in the
|
|
170
|
+
Keychain but this session cannot open it."""
|
|
171
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
172
|
+
if _nonempty(cpath):
|
|
173
|
+
state, reason = creds_state(cpath, now)
|
|
174
|
+
return {'store': 'file', 'state': state, 'reason': reason, 'doc': None}
|
|
175
|
+
kc = keychain.probe(d)
|
|
176
|
+
if kc['state'] == 'present':
|
|
177
|
+
try:
|
|
178
|
+
state, reason = creds_doc_state(kc['doc'], now)
|
|
179
|
+
except ValueError as e:
|
|
180
|
+
state, reason = 'expired', f'keychain credential unreadable ({str(e)[:60]})'
|
|
181
|
+
return {'store': 'keychain', 'state': state, 'reason': f'{reason} (macOS Keychain)',
|
|
182
|
+
'doc': kc['doc']}
|
|
183
|
+
if kc['state'] == 'corrupt':
|
|
184
|
+
return {'store': 'keychain', 'state': 'expired',
|
|
185
|
+
'reason': 'keychain credential is not a credential document', 'doc': None}
|
|
186
|
+
if kc['state'] == 'locked':
|
|
187
|
+
return {'store': 'keychain', 'state': 'locked',
|
|
188
|
+
'reason': ('OAuth login is in the macOS Keychain, which this session '
|
|
189
|
+
'cannot open (ssh/background) — it works from the Mac\'s own '
|
|
190
|
+
'session'), 'doc': None}
|
|
191
|
+
return {'store': None, 'state': None, 'reason': '', 'doc': None}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED', 'locked': 'KEYCHAIN LOCKED',
|
|
137
195
|
'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
|
|
138
196
|
|
|
139
197
|
# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
|
|
@@ -164,6 +222,11 @@ def _fix_for(state, aid):
|
|
|
164
222
|
f'(if it stays BLOCKED, an admin must enable Claude Code for it)')
|
|
165
223
|
if state in ('expired', 'missing'):
|
|
166
224
|
return f'claude-accounts relogin {aid}'
|
|
225
|
+
if state == 'locked':
|
|
226
|
+
# Not a dead login: the Mac's own session can use it as it is. What an operator
|
|
227
|
+
# stuck in an ssh session CAN do is mint the portable token from here — the
|
|
228
|
+
# setup-token ceremony is a fresh browser grant and never reads the keychain.
|
|
229
|
+
return f'claude-accounts mint {aid} (portable; or use it from the Mac\'s GUI session)'
|
|
167
230
|
return ''
|
|
168
231
|
|
|
169
232
|
|
|
@@ -171,13 +234,11 @@ def audit_account(root, acct, now=None, machine=None):
|
|
|
171
234
|
now = time.time() if now is None else now
|
|
172
235
|
aid = acct.get('id', '')
|
|
173
236
|
d = os.path.join(root, aid)
|
|
174
|
-
cpath = os.path.join(d, '.credentials.json')
|
|
175
237
|
tpath = os.path.join(d, 'server.token')
|
|
176
|
-
has_creds = _nonempty(cpath)
|
|
177
238
|
has_token = _nonempty(tpath)
|
|
178
239
|
home = acct.get('home', '?')
|
|
179
240
|
row = {'id': aid, 'email': acct.get('email', ''), 'home': home,
|
|
180
|
-
'state': 'ok', 'reason': ''}
|
|
241
|
+
'state': 'ok', 'reason': '', 'store': None}
|
|
181
242
|
|
|
182
243
|
def done(r):
|
|
183
244
|
r['label'] = LABELS.get(r['state'], r['state'].upper())
|
|
@@ -195,8 +256,10 @@ def audit_account(root, acct, now=None, machine=None):
|
|
|
195
256
|
row['state'] = 'expired'
|
|
196
257
|
row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
|
|
197
258
|
return done(row)
|
|
198
|
-
|
|
199
|
-
|
|
259
|
+
login = oauth_login(d, now)
|
|
260
|
+
row['store'] = login['store']
|
|
261
|
+
if login['state'] in ('ok', 'expired'):
|
|
262
|
+
state, reason = login['state'], login['reason']
|
|
200
263
|
if state == 'ok' or has_token:
|
|
201
264
|
# A portable token authenticates on its own, so a dead credential beside it
|
|
202
265
|
# is not fatal — same rule the shim applies.
|
|
@@ -209,6 +272,9 @@ def audit_account(root, acct, now=None, machine=None):
|
|
|
209
272
|
age = int((now - _mtime(tpath)) / 86400)
|
|
210
273
|
row['reason'] = f'server.token present (minted ~{age}d ago)'
|
|
211
274
|
return done(row)
|
|
275
|
+
if login['state'] == 'locked':
|
|
276
|
+
row['state'], row['reason'] = 'locked', login['reason']
|
|
277
|
+
return done(row)
|
|
212
278
|
if machine and _elsewhere(home, machine):
|
|
213
279
|
row['state'] = 'remote'
|
|
214
280
|
row['reason'] = f'no auth here — the grant lives on the {home} machine'
|
package/lib/common.sh
CHANGED
|
@@ -117,6 +117,31 @@ find_real_bin() {
|
|
|
117
117
|
find_real_claude() { find_real_bin claude "${1:-}"; }
|
|
118
118
|
find_real_codex() { find_real_bin codex "${1:-}"; }
|
|
119
119
|
|
|
120
|
+
# The version string of an installed copy (its package.json), '' when unreadable.
|
|
121
|
+
# Shared because self-update must VERIFY the tree it wrote, in both CLIs.
|
|
122
|
+
pkg_version_at() {
|
|
123
|
+
[ -f "$1/package.json" ] || return 0
|
|
124
|
+
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# The npm that owns THIS install, resolved without trusting the ambient PATH.
|
|
128
|
+
# A launchd agent (and a bare cron) runs with PATH=/usr/bin:/bin:/usr/sbin:/sbin —
|
|
129
|
+
# no homebrew, no nvm — so `command -v npm` fails there and the daily self-update
|
|
130
|
+
# logged "npm not found; skipping" every night while announcing nothing was wrong.
|
|
131
|
+
# Every Mac in the fleet sat frozen on a three-day-old build that way.
|
|
132
|
+
# Order matters: the npm inside the prefix that holds the RUNNING copy is the one
|
|
133
|
+
# whose global tree we must write (my-mini has two node installs, homebrew's and
|
|
134
|
+
# nvm's, and updating the wrong one is the older bug this pairs with).
|
|
135
|
+
find_npm() { # find_npm [prefix]; prints an npm path, or nothing
|
|
136
|
+
local prefix="${1:-}" cand
|
|
137
|
+
for cand in ${prefix:+"$prefix/bin/npm"} \
|
|
138
|
+
/opt/homebrew/bin/npm /usr/local/bin/npm /usr/bin/npm; do
|
|
139
|
+
[ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; }
|
|
140
|
+
done
|
|
141
|
+
cand="$(command -v npm 2>/dev/null)" && [ -n "$cand" ] && { printf '%s\n' "$cand"; return 0; }
|
|
142
|
+
return 1
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
manifest_get() { # manifest_get <dot.path> [default]
|
|
121
146
|
local val
|
|
122
147
|
if [ -f "$MANIFEST" ]; then
|
|
@@ -423,10 +448,30 @@ has_local_auth() { # $1 = acct dir
|
|
|
423
448
|
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
424
449
|
[ -s "$1/auth.json" ]
|
|
425
450
|
else
|
|
426
|
-
[ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
|
|
451
|
+
[ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ] || keychain_has_login "$1"
|
|
427
452
|
fi
|
|
428
453
|
}
|
|
429
454
|
|
|
455
|
+
# ---- macOS Keychain-held logins (claude only) ------------------------------------
|
|
456
|
+
# Claude Code on macOS moves a config dir's OAuth login into the login Keychain the
|
|
457
|
+
# first time a GUI-session process can write there, deleting .credentials.json as it
|
|
458
|
+
# goes (lib/keychain.py has the whole story). These wrap that module so bash callers
|
|
459
|
+
# never parse `security` output themselves. Each one is a no-op (absent) off macOS.
|
|
460
|
+
keychain_state() { # $1 = acct dir -> prints present|locked|absent|corrupt|unavailable
|
|
461
|
+
local state
|
|
462
|
+
[ "$MULTIACC_PROVIDER" = "codex" ] && { echo absent; return 0; }
|
|
463
|
+
state="$("$PYBIN" "$LIB_DIR/keychain.py" probe "$1" 2>/dev/null \
|
|
464
|
+
| LC_ALL=C sed -n 's/.*"state": *"\([a-z]*\)".*/\1/p' | head -1)"
|
|
465
|
+
printf '%s\n' "${state:-unavailable}"
|
|
466
|
+
}
|
|
467
|
+
keychain_has_login() { # $1 = acct dir: an item EXISTS here (readable or locked)
|
|
468
|
+
case "$(keychain_state "$1")" in present|locked|corrupt) return 0 ;; *) return 1 ;; esac
|
|
469
|
+
}
|
|
470
|
+
keychain_forget() { # $1 = acct dir: drop the item (a removed account must not leave a grant behind)
|
|
471
|
+
[ "$MULTIACC_PROVIDER" = "codex" ] && return 0
|
|
472
|
+
"$PYBIN" "$LIB_DIR/keychain.py" delete "$1" >/dev/null 2>&1 || true
|
|
473
|
+
}
|
|
474
|
+
|
|
430
475
|
# ---- expired-login bookkeeping -------------------------------------------------
|
|
431
476
|
# `.expired` is the persistent "this account cannot authenticate" marker the shim
|
|
432
477
|
# honors (bin/claude: expired_marked). Two lines: marked-at epoch, then details.
|
|
@@ -487,15 +532,27 @@ accounts_needing_login() {
|
|
|
487
532
|
# True when <acct dir>'s credential can authenticate right now (the audit module's
|
|
488
533
|
# rule — audit.py for claude, codex_audit.py for codex; both expose creds_state).
|
|
489
534
|
creds_alive() { # $1 = acct dir
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
"$PYBIN" - "$AUDIT_PY" "$cred" <<'PYEOF' 2>/dev/null
|
|
535
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
536
|
+
[ -s "$1/auth.json" ] || return 1
|
|
537
|
+
"$PYBIN" - "$AUDIT_PY" "$1/auth.json" <<'PYEOF' 2>/dev/null
|
|
494
538
|
import importlib.util, sys, time
|
|
495
539
|
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
|
|
496
540
|
mod = importlib.util.module_from_spec(spec)
|
|
497
541
|
spec.loader.exec_module(mod)
|
|
498
542
|
sys.exit(0 if mod.creds_state(sys.argv[2], time.time())[0] == 'ok' else 1)
|
|
543
|
+
PYEOF
|
|
544
|
+
return $?
|
|
545
|
+
fi
|
|
546
|
+
# claude: the login is wherever the client put it — .credentials.json, or the macOS
|
|
547
|
+
# Keychain when the sign-in ran in a session that could open it. A ceremony that
|
|
548
|
+
# succeeded into the Keychain used to be reported as "login failed" and its
|
|
549
|
+
# half-made account dir deleted, stranding a live grant under an orphaned service.
|
|
550
|
+
"$PYBIN" - "$AUDIT_PY" "$1" <<'PYEOF' 2>/dev/null
|
|
551
|
+
import importlib.util, sys, time
|
|
552
|
+
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
|
|
553
|
+
mod = importlib.util.module_from_spec(spec)
|
|
554
|
+
spec.loader.exec_module(mod)
|
|
555
|
+
sys.exit(0 if mod.oauth_login(sys.argv[2], time.time())['state'] == 'ok' else 1)
|
|
499
556
|
PYEOF
|
|
500
557
|
}
|
|
501
558
|
|
package/lib/credential.py
CHANGED
|
@@ -220,8 +220,16 @@ def cmd_export(argv):
|
|
|
220
220
|
cpath = os.path.join(d, '.credentials.json')
|
|
221
221
|
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
222
222
|
if not has_token:
|
|
223
|
+
where = None
|
|
223
224
|
if os.path.isfile(cpath) and os.path.getsize(cpath) > 0:
|
|
224
|
-
|
|
225
|
+
where = '.credentials.json'
|
|
226
|
+
else:
|
|
227
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
228
|
+
import keychain
|
|
229
|
+
if keychain.probe(d)['state'] in ('present', 'locked', 'corrupt'):
|
|
230
|
+
where = 'macOS Keychain'
|
|
231
|
+
if where:
|
|
232
|
+
die(f'{aid} only has a MACHINE-LOCAL credential ({where}) and cannot '
|
|
225
233
|
f'be exported.\n'
|
|
226
234
|
f' Why: the OAuth grant\'s refresh token ROTATES on every refresh; a second\n'
|
|
227
235
|
f' machine refreshing the same grant strands the first one.\n'
|
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',
|