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/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
- doc = json.load(open(cpath))
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
- LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED',
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
- if has_creds:
199
- state, reason = creds_state(cpath, now)
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
- local cred
491
- if [ "$MULTIACC_PROVIDER" = "codex" ]; then cred="$1/auth.json"; else cred="$1/.credentials.json"; fi
492
- [ -s "$cred" ] || return 1
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
- die(f'{aid} only has a MACHINE-LOCAL credential (.credentials.json) and cannot '
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'
@@ -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 / codex auth.json rotating refresh grant;
32
- copying it makes two machines fight over one grant and breaks both
33
- none nothing on disk here
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
- if isinstance(o, dict):
167
- detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
168
- detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {