claude-multiacc 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE_ACCS_TASK.md +7 -0
- package/README.md +28 -7
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +47 -11
- package/docs/ACCOUNT_OPERATIONS.md +7 -3
- package/install.sh +24 -8
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +87 -21
- package/lib/codex_reset.py +170 -0
- package/lib/common.sh +62 -5
- package/lib/credential.py +9 -1
- package/lib/keychain.py +210 -0
- package/lib/report.py +33 -8
- package/package.json +1 -1
- package/tests/run-tests.sh +287 -0
- package/tests/test_codex_reset.py +170 -0
- package/tests/test_keychain.py +204 -0
package/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
|
-
#
|
|
107
|
-
#
|
|
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>
|
|
112
|
-
|
|
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
|
|
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 "
|
|
387
|
-
echo "
|
|
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}"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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'
|
|
@@ -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
|
-
|
|
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'
|