claude-multiacc 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.sh CHANGED
@@ -103,13 +103,23 @@ PLIST_CODEX_LIMITS="$HOME/Library/LaunchAgents/$LABEL.codex-limits.plist"
103
103
  PLIST_CODEX_HEALTH="$HOME/Library/LaunchAgents/$LABEL.codex-health.plist"
104
104
  PROFILED="/etc/profile.d/claude-multiacc.sh"
105
105
 
106
- # Pool roots (and any sync override) for a scheduled agent, so it refreshes THIS
107
- # instance's pool. Empty for a default install nothing changes there.
106
+ # Environment for a scheduled agent: a usable PATH always, plus this instance's pool
107
+ # roots when the install is instance-scoped.
108
+ #
109
+ # launchd hands an agent PATH=/usr/bin:/bin:/usr/sbin:/sbin — no /opt/homebrew, no
110
+ # nvm — so `npm`, `node` and a homebrew `git` are all absent from a plain agent run.
111
+ # The daily self-update agent logged "npm not found; skipping" every night for days
112
+ # while reporting nothing wrong, and every Mac's npm copy silently froze. The code
113
+ # now resolves npm by absolute path too (lib/common.sh find_npm), but an agent whose
114
+ # environment cannot even find node is a trap waiting for the next tool we add.
108
115
  plist_env_block() {
109
- [ -n "$INSTANCE" ] || return 0
110
116
  printf ' <key>EnvironmentVariables</key><dict>\n'
111
- printf ' <key>CLAUDE_ACCOUNTS_ROOT</key><string>%s</string>\n' "$ACC_ROOT"
112
- printf ' <key>CODEX_ACCOUNTS_ROOT</key><string>%s</string>\n' "$CODEX_ACC_ROOT"
117
+ printf ' <key>PATH</key><string>%s</string>\n' \
118
+ "/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
119
+ if [ -n "$INSTANCE" ]; then
120
+ printf ' <key>CLAUDE_ACCOUNTS_ROOT</key><string>%s</string>\n' "$ACC_ROOT"
121
+ printf ' <key>CODEX_ACCOUNTS_ROOT</key><string>%s</string>\n' "$CODEX_ACC_ROOT"
122
+ fi
113
123
  printf ' </dict>\n'
114
124
  }
115
125
  cron_env_prefix() {
@@ -380,11 +390,17 @@ do_install() {
380
390
  echo " note: no codex binary found — the codex pool stays idle until Codex CLI is installed (npm i -g @openai/codex)"
381
391
  fi
382
392
 
383
- # Keychain-mode detection: per-dir /login isolation needs file-based credentials.
393
+ # Keychain-mode note: Claude Code keeps per-config-dir logins in the login Keychain
394
+ # (one item per dir) whenever the session can open it, and only sessions without
395
+ # keychain access (ssh, launchd background jobs) fall back to .credentials.json. The
396
+ # pool reads both (lib/keychain.py); what an operator has to know is that a login
397
+ # made from a GUI session is invisible to their ssh sessions — mint a portable
398
+ # token for anything that must work from everywhere.
384
399
  if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
385
400
  && security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
386
- echo " WARNING: this Mac stores Claude Code credentials in the Keychain, not files." >&2
387
- echo " Per-directory logins may not isolate; use token-based accounts (claude-accounts mint/import --token-file)." >&2
401
+ echo " note: this Mac keeps Claude Code logins in the Keychain. Per-dir logins made here"
402
+ echo " are read from it; ssh/background sessions cannot open it (they see 'locked')"
403
+ echo " use 'claude-accounts mint <acct-NN>' for accounts that must work from everywhere."
388
404
  fi
389
405
 
390
406
  manifest_init "${SERVER_OVERRIDE:-$DEFAULT_SERVER}"
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'
@@ -0,0 +1,170 @@
1
+ """Automatic redemption of earned Codex usage-limit reset credits.
2
+
3
+ The Codex CLI exposes reset credits through the same authenticated backend as
4
+ usage telemetry. This module is called only after a fresh usage response says
5
+ an account has 5% or less remaining. A pending idempotency key is written
6
+ before the mutation so a lost response can be retried without spending twice.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+
19
+ RESET_AT_USED_PERCENT = 95
20
+ REDEEM_COOLDOWN_SECONDS = 900
21
+ STATE_FILE = ".usage-reset.json"
22
+
23
+
24
+ def auto_reset_enabled() -> bool:
25
+ """Return whether automatic redemption is enabled (on by default)."""
26
+ return os.environ.get("CODEX_MULTIACC_AUTO_RESET", "1").strip().lower() \
27
+ not in {"0", "false", "no", "off"}
28
+
29
+
30
+ def _load(path: str) -> dict:
31
+ try:
32
+ value = json.load(open(path, encoding="utf-8"))
33
+ return value if isinstance(value, dict) else {}
34
+ except Exception:
35
+ return {}
36
+
37
+
38
+ def _write(path: str, value: dict) -> None:
39
+ temp = f"{path}.tmp.{os.getpid()}"
40
+ descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
42
+ json.dump(value, handle, indent=2, sort_keys=True)
43
+ handle.write("\n")
44
+ os.replace(temp, path)
45
+
46
+
47
+ def _reset_urls(usage_url: str) -> tuple[str, str]:
48
+ explicit_list = os.environ.get("CODEX_MULTIACC_RESET_CREDITS_URL", "").strip()
49
+ explicit_consume = os.environ.get("CODEX_MULTIACC_RESET_CONSUME_URL", "").strip()
50
+ if explicit_list and explicit_consume:
51
+ return explicit_list, explicit_consume
52
+ parsed = urllib.parse.urlsplit(usage_url)
53
+ path = parsed.path
54
+ if path.endswith("/wham/usage"):
55
+ prefix = path[: -len("/wham/usage")] + "/wham"
56
+ elif path.endswith("/api/codex/usage"):
57
+ prefix = path[: -len("/usage")]
58
+ elif "/backend-api/" in path:
59
+ prefix = path.split("/backend-api/", 1)[0] + "/backend-api/wham"
60
+ else:
61
+ prefix = path.rsplit("/usage", 1)[0]
62
+ base = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, prefix, "", ""))
63
+ return (explicit_list or f"{base}/rate-limit-reset-credits",
64
+ explicit_consume or f"{base}/rate-limit-reset-credits/consume")
65
+
66
+
67
+ def _request(url: str, headers: dict, payload: dict | None = None) -> dict:
68
+ data = json.dumps(payload).encode() if payload is not None else None
69
+ request = urllib.request.Request(url, data=data, headers=headers,
70
+ method="POST" if data is not None else "GET")
71
+ response = urllib.request.urlopen(request, timeout=15)
72
+ value = json.loads(response.read().decode())
73
+ if not isinstance(value, dict):
74
+ raise ValueError("reset endpoint returned a non-object")
75
+ return value
76
+
77
+
78
+ def _credit_to_redeem(details: dict) -> tuple[int, str | None]:
79
+ try:
80
+ available_count = max(0, int(details.get("available_count") or 0))
81
+ except (TypeError, ValueError):
82
+ available_count = 0
83
+ available = [item for item in details.get("credits", [])
84
+ if isinstance(item, dict) and item.get("status") == "available"]
85
+ available.sort(key=lambda item: item.get("expires_at") or "9999-12-31T23:59:59Z")
86
+ credit_id = str(available[0].get("id") or "") if available else ""
87
+ return available_count, credit_id or None
88
+
89
+
90
+ def _pending_request(account_dir: str, now: int) -> tuple[dict, str]:
91
+ state_path = os.path.join(account_dir, STATE_FILE)
92
+ state = _load(state_path)
93
+ if state.get("state") == "complete" and int(state.get("suppress_until") or 0) > now:
94
+ return state, "cooldown"
95
+ if state.get("state") == "pending" and state.get("redeem_request_id"):
96
+ return state, "pending"
97
+ return {}, "new"
98
+
99
+
100
+ def _redeem_request_id(account_id: str, usage: dict, headers: dict, now: int) -> str:
101
+ """Return one fleet-stable UUID for this account's current limit windows."""
102
+ subject = str(headers.get("chatgpt-account-id") or account_id)
103
+ resets = []
104
+ stack = [usage]
105
+ while stack:
106
+ node = stack.pop()
107
+ if isinstance(node, dict):
108
+ value = node.get("reset_at")
109
+ if value is not None:
110
+ try:
111
+ resets.append(int(float(value)))
112
+ except (TypeError, ValueError):
113
+ pass
114
+ stack.extend(node.values())
115
+ elif isinstance(node, list):
116
+ stack.extend(node)
117
+ cycle = sorted(set(resets)) or [f"hour-{now // 3600}"]
118
+ seed = json.dumps({"account": subject, "resets": cycle}, sort_keys=True)
119
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
120
+
121
+
122
+ def try_auto_redeem(account_dir: str, account_id: str, used_percent: int,
123
+ usage: dict, usage_url: str, headers: dict, say,
124
+ now: int | None = None) -> dict:
125
+ """Redeem one available reset and return a non-secret outcome document."""
126
+ current = int(now if now is not None else time.time())
127
+ if not auto_reset_enabled() or used_percent < RESET_AT_USED_PERCENT:
128
+ return {"status": "not_eligible"}
129
+ state, disposition = _pending_request(account_dir, current)
130
+ if disposition == "cooldown":
131
+ return {"status": "cooldown", "outcome": state.get("outcome")}
132
+ list_url, consume_url = _reset_urls(usage_url)
133
+ if disposition == "new":
134
+ summary = usage.get("rate_limit_reset_credits")
135
+ if isinstance(summary, dict) and int(summary.get("available_count") or 0) <= 0:
136
+ return {"status": "no_credit"}
137
+ try:
138
+ available_count, credit_id = _credit_to_redeem(_request(list_url, headers))
139
+ except Exception as error:
140
+ say(f"{account_id}: usage reset availability failed ({type(error).__name__}); failing open")
141
+ return {"status": "error"}
142
+ if available_count <= 0:
143
+ return {"status": "no_credit"}
144
+ request_id = _redeem_request_id(account_id, usage, headers, current)
145
+ state = {"schema": 1, "state": "pending", "redeem_request_id": request_id,
146
+ "credit_id": credit_id, "started_at": current}
147
+ _write(os.path.join(account_dir, STATE_FILE), state)
148
+ payload = {"redeem_request_id": state["redeem_request_id"]}
149
+ if state.get("credit_id"):
150
+ payload["credit_id"] = state["credit_id"]
151
+ try:
152
+ response = _request(consume_url, headers, payload)
153
+ except Exception as error:
154
+ say(f"{account_id}: usage reset redeem failed ({type(error).__name__}); retry is idempotent")
155
+ return {"status": "pending"}
156
+ outcome = str(response.get("code") or "unknown")
157
+ if outcome in {"reset", "already_redeemed"}:
158
+ complete = {"schema": 1, "state": "complete", "outcome": outcome,
159
+ "redeem_request_id": state["redeem_request_id"],
160
+ "redeemed_at": current, "suppress_until": current + REDEEM_COOLDOWN_SECONDS,
161
+ "windows_reset": int(response.get("windows_reset") or 0)}
162
+ _write(os.path.join(account_dir, STATE_FILE), complete)
163
+ say(f"{account_id}: usage reset redeemed automatically at {used_percent}% used")
164
+ return {"status": "redeemed", "outcome": outcome,
165
+ "windows_reset": complete["windows_reset"], "redeemed_at": current}
166
+ try:
167
+ os.remove(os.path.join(account_dir, STATE_FILE))
168
+ except OSError:
169
+ pass
170
+ return {"status": outcome}
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'