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/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.4",
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": {