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.
@@ -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.2",
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": {
@@ -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"