claude-multiacc 1.0.4 → 1.0.6
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/README.md +99 -14
- package/bin/claude +165 -12
- package/bin/claude-accounts +487 -53
- package/lib/audit.py +239 -0
- package/lib/common.sh +72 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +508 -2
package/lib/audit.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Shared account-auth audit — the ONE place that decides whether an account's login
|
|
2
|
+
still works, so `expired`, `relogin`, `list` and `status` can never disagree with the
|
|
3
|
+
shim's selection rule (bin/claude: creds_dead / expired_marked / auth_dead).
|
|
4
|
+
|
|
5
|
+
States:
|
|
6
|
+
ok usable right now (live OAuth credential, or a portable server.token)
|
|
7
|
+
expired has auth material that CANNOT authenticate — needs `claude-accounts relogin`
|
|
8
|
+
blocked authenticates, but its organization has disabled Claude Code subscription
|
|
9
|
+
access — a re-login cannot fix it, so it needs an admin (or removal)
|
|
10
|
+
missing no auth on this machine, and this machine is supposed to own the grant
|
|
11
|
+
remote no auth here, but the manifest says another machine owns it (informational)
|
|
12
|
+
|
|
13
|
+
Only 'ok' accounts are selectable; everything else is excluded by the shim.
|
|
14
|
+
|
|
15
|
+
Run directly for a TSV dump: python3 lib/audit.py <acc-root> [mac|linux]
|
|
16
|
+
Columns: id, email, home, state, label, reason, fix
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
VALID_ID = re.compile(r'acct-\d{2}')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _mtime(path):
|
|
29
|
+
try:
|
|
30
|
+
return os.path.getmtime(path)
|
|
31
|
+
except OSError:
|
|
32
|
+
return 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _nonempty(path):
|
|
36
|
+
try:
|
|
37
|
+
return os.path.getsize(path) > 0
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def marker_reason(mpath):
|
|
43
|
+
"""Second line of a marker file, as a human string ('' when absent/unreadable)."""
|
|
44
|
+
try:
|
|
45
|
+
lines = open(mpath, errors='replace').read().splitlines()
|
|
46
|
+
except OSError:
|
|
47
|
+
return ''
|
|
48
|
+
return lines[1].strip() if len(lines) > 1 else ''
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def marker_slug(detail):
|
|
52
|
+
"""The reason=<slug> field of a marker line ('' when absent)."""
|
|
53
|
+
m = re.search(r'reason=([A-Za-z0-9._-]+)', detail or '')
|
|
54
|
+
return m.group(1) if m else ''
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def expired_marked(d, now=None):
|
|
58
|
+
"""True when <d>/.expired is still in force. Mirrors the shim exactly:
|
|
59
|
+
a CREDENTIAL-scoped park clears as soon as a newer credential lands (re-login, or a
|
|
60
|
+
refresh by another process); a POLICY park (org-blocked) does not, because a token
|
|
61
|
+
refresh says nothing about whether the org re-enabled Claude Code. Either kind also
|
|
62
|
+
expires at its own soft_until when the shim wrote it from a single failed run."""
|
|
63
|
+
now = time.time() if now is None else now
|
|
64
|
+
mpath = os.path.join(d, '.expired')
|
|
65
|
+
if not os.path.isfile(mpath):
|
|
66
|
+
return False
|
|
67
|
+
detail = marker_reason(mpath)
|
|
68
|
+
if marker_slug(detail) != 'org-blocked':
|
|
69
|
+
mt = _mtime(mpath)
|
|
70
|
+
for name in ('.credentials.json', 'server.token'):
|
|
71
|
+
p = os.path.join(d, name)
|
|
72
|
+
if os.path.isfile(p) and _mtime(p) > mt:
|
|
73
|
+
return False
|
|
74
|
+
m = re.search(r'soft_until=(\d+)', detail)
|
|
75
|
+
if m and int(m.group(1)) <= now:
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ms(value):
|
|
81
|
+
"""Milliseconds field -> seconds. Anything non-numeric reads as 0 (absent) rather
|
|
82
|
+
than raising: one weird credential must never take down the whole audit."""
|
|
83
|
+
try:
|
|
84
|
+
return float(value or 0) / 1000.0
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return 0.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _scrape(cpath):
|
|
90
|
+
"""The shim's rule for a credential JSON cannot parse: sed out the numbers and the
|
|
91
|
+
presence of a refresh token (bin/claude: cred_num / creds_dead). Used so a
|
|
92
|
+
half-written file is judged the same way on both sides — audit.py must never call
|
|
93
|
+
an account dead that the shim is happily selecting."""
|
|
94
|
+
try:
|
|
95
|
+
txt = open(cpath, errors='replace').read()
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
def num(key):
|
|
99
|
+
m = re.search(r'"%s"\s*:\s*(\d+)' % key, txt)
|
|
100
|
+
return float(m.group(1)) / 1000.0 if m else 0.0
|
|
101
|
+
return {'exp': num('expiresAt'), 'rexp': num('refreshTokenExpiresAt'),
|
|
102
|
+
'has_refresh': bool(re.search(r'"refreshToken"\s*:\s*"[^"]', txt))}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def creds_state(cpath, now):
|
|
106
|
+
"""(state, reason) for an on-disk .credentials.json."""
|
|
107
|
+
scraped = None
|
|
108
|
+
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'))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# Unparseable (truncated / mid-write): fall back to the shim's lenient scrape so
|
|
117
|
+
# both sides agree. Only if that finds nothing usable is the account called dead.
|
|
118
|
+
scraped = _scrape(cpath)
|
|
119
|
+
if not scraped:
|
|
120
|
+
return 'expired', f'credentials unreadable ({str(e)[:60]})'
|
|
121
|
+
exp, rexp, has_refresh = scraped['exp'], scraped['rexp'], scraped['has_refresh']
|
|
122
|
+
if exp <= now and not (has_refresh and (not rexp or rexp > now)):
|
|
123
|
+
return 'expired', f'credentials unreadable ({str(e)[:60]})'
|
|
124
|
+
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
|
+
|
|
135
|
+
|
|
136
|
+
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED',
|
|
137
|
+
'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
|
|
138
|
+
|
|
139
|
+
# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
|
|
140
|
+
# two vocabularies — normalize, or an un-authenticated account on the very machine that
|
|
141
|
+
# owns it would be filed as "lives elsewhere" and silently drop off the worklist.
|
|
142
|
+
_MACHINE_ALIASES = {'mac': 'mac', 'macos': 'mac', 'darwin': 'mac', 'osx': 'mac',
|
|
143
|
+
'server': 'linux', 'linux': 'linux', 'ubuntu': 'linux',
|
|
144
|
+
'debian': 'linux', 'remote': 'linux'}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _norm_machine(name):
|
|
148
|
+
return _MACHINE_ALIASES.get(str(name or '').strip().lower(), '')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _elsewhere(home, machine):
|
|
152
|
+
"""True only when the manifest names a DIFFERENT machine as the grant's owner.
|
|
153
|
+
An unknown/blank home is never treated as 'elsewhere' — that would hide a real gap."""
|
|
154
|
+
h, m = _norm_machine(home), _norm_machine(machine)
|
|
155
|
+
return bool(h) and bool(m) and h != m
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _fix_for(state, aid):
|
|
159
|
+
if state == 'blocked':
|
|
160
|
+
# A fresh sign-in re-issues the grant and in practice clears this, so it is
|
|
161
|
+
# handled like any other dead login. If it comes back BLOCKED after a re-login,
|
|
162
|
+
# the org really has Claude Code switched off and an admin has to enable it.
|
|
163
|
+
return (f'claude-accounts relogin {aid} '
|
|
164
|
+
f'(if it stays BLOCKED, an admin must enable Claude Code for it)')
|
|
165
|
+
if state in ('expired', 'missing'):
|
|
166
|
+
return f'claude-accounts relogin {aid}'
|
|
167
|
+
return ''
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def audit_account(root, acct, now=None, machine=None):
|
|
171
|
+
now = time.time() if now is None else now
|
|
172
|
+
aid = acct.get('id', '')
|
|
173
|
+
d = os.path.join(root, aid)
|
|
174
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
175
|
+
tpath = os.path.join(d, 'server.token')
|
|
176
|
+
has_creds = _nonempty(cpath)
|
|
177
|
+
has_token = _nonempty(tpath)
|
|
178
|
+
home = acct.get('home', '?')
|
|
179
|
+
row = {'id': aid, 'email': acct.get('email', ''), 'home': home,
|
|
180
|
+
'state': 'ok', 'reason': ''}
|
|
181
|
+
|
|
182
|
+
def done(r):
|
|
183
|
+
r['label'] = LABELS.get(r['state'], r['state'].upper())
|
|
184
|
+
r['fix'] = _fix_for(r['state'], aid)
|
|
185
|
+
return r
|
|
186
|
+
|
|
187
|
+
if expired_marked(d, now=now):
|
|
188
|
+
detail = marker_reason(os.path.join(d, '.expired'))
|
|
189
|
+
slug = marker_slug(detail)
|
|
190
|
+
if slug == 'org-blocked':
|
|
191
|
+
row['state'] = 'blocked'
|
|
192
|
+
row['reason'] = ('this account\'s organization has disabled Claude Code '
|
|
193
|
+
'subscription access (a re-login cannot fix it)')
|
|
194
|
+
else:
|
|
195
|
+
row['state'] = 'expired'
|
|
196
|
+
row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
|
|
197
|
+
return done(row)
|
|
198
|
+
if has_creds:
|
|
199
|
+
state, reason = creds_state(cpath, now)
|
|
200
|
+
if state == 'ok' or has_token:
|
|
201
|
+
# A portable token authenticates on its own, so a dead credential beside it
|
|
202
|
+
# is not fatal — same rule the shim applies.
|
|
203
|
+
row['state'] = 'ok'
|
|
204
|
+
row['reason'] = reason if state == 'ok' else f'{reason}; using server.token'
|
|
205
|
+
else:
|
|
206
|
+
row['state'], row['reason'] = 'expired', reason
|
|
207
|
+
return done(row)
|
|
208
|
+
if has_token:
|
|
209
|
+
age = int((now - _mtime(tpath)) / 86400)
|
|
210
|
+
row['reason'] = f'server.token present (minted ~{age}d ago)'
|
|
211
|
+
return done(row)
|
|
212
|
+
if machine and _elsewhere(home, machine):
|
|
213
|
+
row['state'] = 'remote'
|
|
214
|
+
row['reason'] = f'no auth here — the grant lives on the {home} machine'
|
|
215
|
+
else:
|
|
216
|
+
row['state'] = 'missing'
|
|
217
|
+
row['reason'] = 'no credentials on this machine'
|
|
218
|
+
return done(row)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def audit_all(root, machine=None, now=None):
|
|
222
|
+
try:
|
|
223
|
+
doc = json.load(open(os.path.join(root, 'accounts.json')))
|
|
224
|
+
except Exception:
|
|
225
|
+
return []
|
|
226
|
+
out = []
|
|
227
|
+
for a in doc.get('accounts', []):
|
|
228
|
+
if isinstance(a, dict) and VALID_ID.fullmatch(str(a.get('id', ''))):
|
|
229
|
+
out.append(audit_account(root, a, now=now, machine=machine))
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == '__main__':
|
|
234
|
+
root = sys.argv[1]
|
|
235
|
+
machine = sys.argv[2] if len(sys.argv) > 2 else None
|
|
236
|
+
for r in audit_all(root, machine=machine):
|
|
237
|
+
# Tabs are the field separator, so no field may contain one.
|
|
238
|
+
print('\t'.join(str(r[k]).replace('\t', ' ')
|
|
239
|
+
for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
|
package/lib/common.sh
CHANGED
|
@@ -11,6 +11,9 @@ DEFAULT_SERVER_ROOT="/root/.claude-accounts"
|
|
|
11
11
|
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
12
12
|
USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
|
|
13
13
|
|
|
14
|
+
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
|
|
15
|
+
AUDIT_PY="$LIB_DIR/audit.py"
|
|
16
|
+
|
|
14
17
|
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
15
18
|
epoch_now() { date +%s; }
|
|
16
19
|
|
|
@@ -136,6 +139,75 @@ has_local_auth() { # $1 = acct dir
|
|
|
136
139
|
[ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
# ---- expired-login bookkeeping -------------------------------------------------
|
|
143
|
+
# `.expired` is the persistent "this account cannot authenticate" marker the shim
|
|
144
|
+
# honors (bin/claude: expired_marked). Two lines: marked-at epoch, then details.
|
|
145
|
+
# It is deliberately NOT time-boxed like `.limited`: dead auth only heals through a
|
|
146
|
+
# re-login, a credential refresh, or a usage fetch that proves the bearer works.
|
|
147
|
+
mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
|
|
148
|
+
local d="$1" slug="$2" detail="${3:-}"
|
|
149
|
+
[ -d "$d" ] || return 0
|
|
150
|
+
{
|
|
151
|
+
epoch_now
|
|
152
|
+
printf 'reason=%s marked_at=%s detail=%s\n' "$slug" "$(ts_utc)" "$detail"
|
|
153
|
+
} > "$d/.expired.$$" 2>/dev/null \
|
|
154
|
+
&& mv -f "$d/.expired.$$" "$d/.expired" 2>/dev/null \
|
|
155
|
+
|| rm -f "$d/.expired.$$" 2>/dev/null || true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Called wherever fresh auth lands (login/add/mint, successful usage fetch): the
|
|
159
|
+
# account is provably alive again, so both the dead-auth marker and any refresh
|
|
160
|
+
# backoff must go, or it would stay parked until the next re-login.
|
|
161
|
+
clear_auth_markers() { # $1 = acct dir
|
|
162
|
+
rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# TSV rows: id, email, home, state (ok|expired|blocked|missing|remote), label, reason, fix.
|
|
166
|
+
# Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
|
|
167
|
+
# silently empty audit reads exactly like a perfectly healthy pool.
|
|
168
|
+
account_audit() {
|
|
169
|
+
local out rc err="$ACC_ROOT/tmp/audit.$$.err"
|
|
170
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
|
|
171
|
+
# stderr goes to a file, never into the rows — a stray warning must not become a
|
|
172
|
+
# bogus account line.
|
|
173
|
+
out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
|
|
174
|
+
rc=$?
|
|
175
|
+
if [ "$rc" -ne 0 ]; then
|
|
176
|
+
printf 'claude-accounts: cannot audit accounts (%s)\n' "$(tail -1 "$err" 2>/dev/null)" >&2
|
|
177
|
+
rm -f "$err" 2>/dev/null
|
|
178
|
+
return 1
|
|
179
|
+
fi
|
|
180
|
+
rm -f "$err" 2>/dev/null
|
|
181
|
+
[ -n "$out" ] && printf '%s\n' "$out"
|
|
182
|
+
return 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Ids the shim will NOT select — everything a human has to look at.
|
|
186
|
+
accounts_unusable() {
|
|
187
|
+
account_audit \
|
|
188
|
+
| awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Ids a re-login should be pointed at. Org-blocked accounts are included: a fresh
|
|
192
|
+
# sign-in re-issues the grant and in practice clears the block, so they are handled
|
|
193
|
+
# exactly like any other dead login rather than being left for someone to notice.
|
|
194
|
+
accounts_needing_login() {
|
|
195
|
+
account_audit \
|
|
196
|
+
| awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# True when <acct dir>'s credential can authenticate right now (audit.py's rule).
|
|
200
|
+
creds_alive() { # $1 = acct dir
|
|
201
|
+
[ -s "$1/.credentials.json" ] || return 1
|
|
202
|
+
"$PYBIN" - "$AUDIT_PY" "$1/.credentials.json" <<'PYEOF' 2>/dev/null
|
|
203
|
+
import importlib.util, sys, time
|
|
204
|
+
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
|
|
205
|
+
mod = importlib.util.module_from_spec(spec)
|
|
206
|
+
spec.loader.exec_module(mod)
|
|
207
|
+
sys.exit(0 if mod.creds_state(sys.argv[2], time.time())[0] == 'ok' else 1)
|
|
208
|
+
PYEOF
|
|
209
|
+
}
|
|
210
|
+
|
|
139
211
|
manifest_init() { # manifest_init [server-target]
|
|
140
212
|
mkdir -p "$ACC_ROOT/tmp"
|
|
141
213
|
chmod 700 "$ACC_ROOT"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|