claude-multiacc 2.0.3 → 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 +21 -6
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +24 -7
- package/docs/ACCOUNT_OPERATIONS.md +6 -2
- 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/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 +278 -0
- package/tests/test_keychain.py +204 -0
package/bin/claude-accounts
CHANGED
|
@@ -28,7 +28,8 @@ USAGE
|
|
|
28
28
|
login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
|
|
29
29
|
browser sign-in, no long-lived-token step-up), then registers only after the
|
|
30
30
|
signed-in email is read back. Email is OPTIONAL (derived from the sign-in).
|
|
31
|
-
Stores auto-refreshing
|
|
31
|
+
Stores an auto-refreshing login valid on THIS machine (.credentials.json, or
|
|
32
|
+
the macOS Keychain when the session can open it). Duplicates refused.
|
|
32
33
|
--token instead mints a portable setup-token (works on Mac AND server; needs a
|
|
33
34
|
recent sign-in — use it when you want the account usable on the server too).
|
|
34
35
|
claude-accounts login <acct-NN> [--token] [--force]
|
|
@@ -215,12 +216,14 @@ if not accounts:
|
|
|
215
216
|
for a in accounts:
|
|
216
217
|
d = os.path.join(root, a['id'])
|
|
217
218
|
auth = []
|
|
218
|
-
|
|
219
|
+
st = audit_account(root, a, machine=machine)
|
|
220
|
+
if st.get('store') == 'file':
|
|
219
221
|
auth.append('oauth')
|
|
222
|
+
elif st.get('store') == 'keychain':
|
|
223
|
+
auth.append('keychain' if st['state'] != 'locked' else 'keychain(locked)')
|
|
220
224
|
if os.path.getsize(os.path.join(d, 'server.token')) > 0 if os.path.isfile(os.path.join(d, 'server.token')) else False:
|
|
221
225
|
auth.append('token')
|
|
222
226
|
limited = os.path.isfile(os.path.join(d, '.limited'))
|
|
223
|
-
st = audit_account(root, a, machine=machine)
|
|
224
227
|
flags = []
|
|
225
228
|
if st['state'] == 'expired':
|
|
226
229
|
flags.append('EXPIRED-LOGIN')
|
|
@@ -228,6 +231,8 @@ for a in accounts:
|
|
|
228
231
|
flags.append('ORG-BLOCKED')
|
|
229
232
|
elif st['state'] == 'missing':
|
|
230
233
|
flags.append('NO-LOGIN')
|
|
234
|
+
elif st['state'] == 'locked':
|
|
235
|
+
flags.append('KEYCHAIN-LOCKED-HERE')
|
|
231
236
|
if limited:
|
|
232
237
|
flags.append('LIMITED')
|
|
233
238
|
print(f"{a['id']} {a['email']:<28} home={a.get('home','?'):<7} "
|
|
@@ -264,7 +269,7 @@ import json, os, sys, time
|
|
|
264
269
|
doc = json.load(open(sys.argv[1]))
|
|
265
270
|
root = sys.argv[2]
|
|
266
271
|
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
267
|
-
from audit import audit_account # noqa: E402 (shared with the shim's rule)
|
|
272
|
+
from audit import audit_account, oauth_login # noqa: E402 (shared with the shim's rule)
|
|
268
273
|
machine = sys.argv[4]
|
|
269
274
|
now = time.time()
|
|
270
275
|
# Must match the shim's window (bin/claude), or status would call data "fresh" that
|
|
@@ -320,20 +325,25 @@ for a in doc.get('accounts', []):
|
|
|
320
325
|
'missing': ' ** NO LOGIN — claude-accounts relogin %s **' % aid,
|
|
321
326
|
'expired': ' ** LOGIN EXPIRED — claude-accounts relogin %s **' % aid,
|
|
322
327
|
'blocked': ' ** ORG BLOCKED — Claude Code disabled for this account **',
|
|
328
|
+
'locked': ' [login is in the macOS Keychain — locked for THIS session, fine from the Mac itself]',
|
|
323
329
|
}[st['state']]
|
|
324
330
|
print(f"{aid} {a['email']} [home={a.get('home','?')}]{banner}")
|
|
325
331
|
print(f" selectable : {'yes' if st['state'] == 'ok' else 'NO — ' + st['reason']}")
|
|
326
332
|
cpath = os.path.join(d, '.credentials.json')
|
|
327
|
-
|
|
333
|
+
login = oauth_login(d, now)
|
|
334
|
+
if login['store'] == 'file' or login['doc'] is not None:
|
|
328
335
|
try:
|
|
329
|
-
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
336
|
+
c = (login['doc'] or json.load(open(cpath))).get('claudeAiOauth', {})
|
|
330
337
|
exp = c.get('expiresAt', 0) / 1000.0
|
|
331
338
|
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
332
339
|
state = 'fresh' if exp > now else 'stale (auto-refreshes on use)'
|
|
333
340
|
rstate = 'ok' if rexp > now else 'EXPIRED — re-login needed'
|
|
334
|
-
|
|
341
|
+
where = 'macOS Keychain' if login['store'] == 'keychain' else '.credentials.json'
|
|
342
|
+
print(f" oauth creds : {state}; refresh token {rstate} (until {time.strftime('%Y-%m-%d', time.gmtime(rexp)) if rexp else '?'}) [{where}]")
|
|
335
343
|
except Exception as e:
|
|
336
344
|
print(f" oauth creds : unreadable ({e})")
|
|
345
|
+
elif login['store'] == 'keychain':
|
|
346
|
+
print(f" oauth creds : in the macOS Keychain — {login['reason']}")
|
|
337
347
|
else:
|
|
338
348
|
print(" oauth creds : none on this machine")
|
|
339
349
|
tpath = os.path.join(d, 'server.token')
|
|
@@ -499,7 +509,8 @@ cmd_add() {
|
|
|
499
509
|
got="$(token_email "$CEREMONY_TOKEN")"
|
|
500
510
|
else
|
|
501
511
|
# Default: full Claude Code login (full scopes, no long-lived-token step-up).
|
|
502
|
-
#
|
|
512
|
+
# Leaves an auto-refreshing login valid on THIS machine — .credentials.json, or
|
|
513
|
+
# the macOS Keychain when this session can open it (see lib/keychain.py).
|
|
503
514
|
echo "Preparing $id for $elabel — NOTHING is registered until login completes."
|
|
504
515
|
run_login_ceremony "$d" "$email" || die "login failed or aborted — nothing was created"
|
|
505
516
|
got="$(config_dir_email "$d")"
|
|
@@ -627,16 +638,25 @@ cmd_import() {
|
|
|
627
638
|
# then the lowest id) and list the rest. Empty output => pool is already clean.
|
|
628
639
|
dup_ids_to_remove() {
|
|
629
640
|
[ -f "$MANIFEST" ] || return 0
|
|
630
|
-
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" <<'PYEOF' 2>/dev/null
|
|
641
|
+
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" <<'PYEOF' 2>/dev/null
|
|
631
642
|
import json, os, re, sys
|
|
632
643
|
manifest, root = sys.argv[1], sys.argv[2]
|
|
644
|
+
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
645
|
+
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
633
646
|
accts = [a for a in json.load(open(manifest)).get('accounts', [])
|
|
634
647
|
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
635
648
|
def has_auth(aid):
|
|
636
649
|
d = os.path.join(root, aid)
|
|
637
650
|
c = os.path.join(d, '.credentials.json')
|
|
638
651
|
t = os.path.join(d, 'server.token')
|
|
639
|
-
|
|
652
|
+
if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
|
|
653
|
+
return True
|
|
654
|
+
# A login the client moved into the macOS Keychain still counts — deduping must
|
|
655
|
+
# not throw away the one duplicate that actually holds the grant.
|
|
656
|
+
try:
|
|
657
|
+
return keychain.probe(d)['state'] in ('present', 'locked', 'corrupt')
|
|
658
|
+
except Exception:
|
|
659
|
+
return False
|
|
640
660
|
by_email = {}
|
|
641
661
|
for a in accts:
|
|
642
662
|
by_email.setdefault(a.get('email', '').lower(), []).append(a['id'])
|
|
@@ -678,7 +698,13 @@ PYEOF
|
|
|
678
698
|
case "$ans" in y|Y|yes) ;; *) echo "aborted"; return 1 ;; esac
|
|
679
699
|
fi
|
|
680
700
|
for id in $dups; do
|
|
681
|
-
if [ -L "$ACC_ROOT/$id" ]; then
|
|
701
|
+
if [ -L "$ACC_ROOT/$id" ]; then
|
|
702
|
+
rm -f "${ACC_ROOT:?}/${id:?}"
|
|
703
|
+
else
|
|
704
|
+
# Same rule as cmd_remove: a removed dir's Keychain login must go with it.
|
|
705
|
+
keychain_forget "$ACC_ROOT/$id"
|
|
706
|
+
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
707
|
+
fi
|
|
682
708
|
manifest_del_account "$id"
|
|
683
709
|
log_to ops.log "dedupe removed $id"
|
|
684
710
|
done
|
|
@@ -738,6 +764,10 @@ cmd_remove() {
|
|
|
738
764
|
if [ -L "$ACC_ROOT/$id" ]; then
|
|
739
765
|
rm -f "${ACC_ROOT:?}/${id:?}" # adopted account: remove the symlink, never the target
|
|
740
766
|
else
|
|
767
|
+
# The client may hold this dir's login in the macOS Keychain (keyed by the dir
|
|
768
|
+
# path): drop it with the dir, or a removed account leaves a live grant behind
|
|
769
|
+
# that the next account to reuse this id would silently inherit.
|
|
770
|
+
keychain_forget "$ACC_ROOT/$id"
|
|
741
771
|
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
742
772
|
fi
|
|
743
773
|
manifest_del_account "$id"
|
|
@@ -847,8 +877,10 @@ except Exception:
|
|
|
847
877
|
|
|
848
878
|
# Full subscription login: `claude auth login --claudeai`, the SAME flow the
|
|
849
879
|
# interactive app uses (claude.com/cai/oauth/authorize, full Claude Code scopes,
|
|
850
|
-
# normal sign-in — no long-lived-token step-up wall).
|
|
851
|
-
#
|
|
880
|
+
# normal sign-in — no long-lived-token step-up wall). Leaves an auto-refreshing login
|
|
881
|
+
# in <dir>/.credentials.json — or, on macOS from a session that can open the login
|
|
882
|
+
# Keychain, in a Keychain item (lib/keychain.py). Returns success iff a credential
|
|
883
|
+
# that can authenticate landed in either place. $2 = optional email hint.
|
|
852
884
|
run_login_ceremony() { # $1 = config dir, $2 = email hint
|
|
853
885
|
local d="$1" hint="${2:-}" real
|
|
854
886
|
real="$(find_real_claude "$_self")" || { warn "real claude binary not found"; return 1; }
|
|
@@ -859,19 +891,21 @@ paste it after the "Paste code here" prompt and press Enter. This is the normal
|
|
|
859
891
|
login — it draws on the subscription, no API keys, and refreshes itself over time.
|
|
860
892
|
TIP
|
|
861
893
|
# Run claude auth login DIRECTLY on the user's terminal (no `script` PTY wrapper):
|
|
862
|
-
# we capture nothing here (the
|
|
863
|
-
#
|
|
894
|
+
# we capture nothing here (the client stores the credential itself), and a PTY
|
|
895
|
+
# layer can suppress the echo of the pasted code. Direct invocation behaves
|
|
864
896
|
# exactly like running `claude auth login` yourself, so the code you paste is shown.
|
|
865
897
|
if [ -n "$hint" ]; then
|
|
866
898
|
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai --email "$hint" || true
|
|
867
899
|
else
|
|
868
900
|
CLAUDE_CONFIG_DIR="$d" CLAUDE_SHIM_ACTIVE=1 "$real" auth login --claudeai || true
|
|
869
901
|
fi
|
|
870
|
-
# Success is a credential that can AUTHENTICATE — not merely
|
|
871
|
-
# Re-login targets already have a (dead)
|
|
872
|
-
#
|
|
873
|
-
#
|
|
874
|
-
|
|
902
|
+
# Success is a credential that can AUTHENTICATE — not merely one that exists.
|
|
903
|
+
# Re-login targets already have a (dead) credential in place, so a bare existence
|
|
904
|
+
# test would call an aborted sign-in a success, clear the dead-auth marker, and
|
|
905
|
+
# hand the account straight back to the pool. creds_alive looks in both places
|
|
906
|
+
# the client writes to (.credentials.json, macOS Keychain): demanding the FILE
|
|
907
|
+
# here turned every Keychain-backed sign-in into "login failed", deleted the
|
|
908
|
+
# reserved dir, and left the fresh grant orphaned in the Keychain.
|
|
875
909
|
creds_alive "$d"
|
|
876
910
|
}
|
|
877
911
|
|
|
@@ -930,8 +964,8 @@ cmd_mint() {
|
|
|
930
964
|
|
|
931
965
|
cmd_login() {
|
|
932
966
|
# Complete (or refresh) auth for an EXISTING account. Default: full Claude Code login
|
|
933
|
-
# (auto-refreshing .credentials.json
|
|
934
|
-
# (Mac + server). Verifies the signed-in email matches the manifest.
|
|
967
|
+
# (auto-refreshing, this machine — .credentials.json or the macOS Keychain). --token:
|
|
968
|
+
# portable setup-token (Mac + server). Verifies the signed-in email matches the manifest.
|
|
935
969
|
require_manifest
|
|
936
970
|
local id="" force=0 token=0
|
|
937
971
|
while [ $# -gt 0 ]; do
|
|
@@ -971,7 +1005,11 @@ cmd_login() {
|
|
|
971
1005
|
die "signed in, but the account identity could not be read back — refusing to call $id fixed (retry, or pass --force)"
|
|
972
1006
|
fi
|
|
973
1007
|
clear_auth_markers "$d"
|
|
974
|
-
|
|
1008
|
+
if [ -s "$d/.credentials.json" ]; then
|
|
1009
|
+
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
1010
|
+
else
|
|
1011
|
+
echo "$id login saved (macOS Keychain, this machine, auto-refreshing — sessions without keychain access, e.g. ssh, cannot use it)."
|
|
1012
|
+
fi
|
|
975
1013
|
fi
|
|
976
1014
|
log_to ops.log "login $id verified=${got:-unverified} mode=$([ "$token" = 1 ] && echo token || echo login)"
|
|
977
1015
|
auto_sync
|
|
@@ -1231,8 +1269,10 @@ cmd_limits() {
|
|
|
1231
1269
|
case "$threshold" in ''|*[!0-9]*) threshold=90 ;; esac
|
|
1232
1270
|
[ "$threshold" -gt 90 ] && threshold=90
|
|
1233
1271
|
[ "$threshold" -lt 1 ] && threshold=90
|
|
1234
|
-
"$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
|
|
1272
|
+
"$PYBIN" - "$ACC_ROOT" "$threshold" "$quiet" "$USAGE_URL" "$force" "$LIB_DIR" <<'PYEOF' 2>>"$ACC_ROOT/limits.log"
|
|
1235
1273
|
import hashlib, json, os, sys, time, urllib.request
|
|
1274
|
+
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1275
|
+
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1236
1276
|
|
|
1237
1277
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
1238
1278
|
now = time.time()
|
|
@@ -1354,15 +1394,72 @@ def park_dead_grant(aid, d, slug, detail):
|
|
|
1354
1394
|
say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')
|
|
1355
1395
|
|
|
1356
1396
|
|
|
1357
|
-
|
|
1397
|
+
class FileStore:
|
|
1398
|
+
"""The OAuth credential as `.credentials.json`: atomic 0600 writes."""
|
|
1399
|
+
kind = 'file'
|
|
1400
|
+
|
|
1401
|
+
def __init__(self, path):
|
|
1402
|
+
self.path = path
|
|
1403
|
+
|
|
1404
|
+
def read(self):
|
|
1405
|
+
return json.load(open(self.path))
|
|
1406
|
+
|
|
1407
|
+
def write(self, doc):
|
|
1408
|
+
fd = os.open(self.path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1409
|
+
with os.fdopen(fd, 'w') as f:
|
|
1410
|
+
json.dump(doc, f)
|
|
1411
|
+
f.flush()
|
|
1412
|
+
os.fsync(f.fileno())
|
|
1413
|
+
os.replace(self.path + '.tmp', self.path)
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
class KeychainStore:
|
|
1417
|
+
"""The same credential, held in the macOS Keychain by the client itself (see
|
|
1418
|
+
lib/keychain.py). Read and written in place — NEVER copied out to a file: a file
|
|
1419
|
+
beside a Keychain item is a second copy of a ROTATING refresh grant, and the
|
|
1420
|
+
client reads the Keychain first, so the two would drift apart and strand one."""
|
|
1421
|
+
kind = 'keychain'
|
|
1422
|
+
|
|
1423
|
+
def __init__(self, d, probe):
|
|
1424
|
+
self.d = d
|
|
1425
|
+
self.account = probe.get('account')
|
|
1426
|
+
|
|
1427
|
+
def read(self):
|
|
1428
|
+
p = keychain.probe(self.d)
|
|
1429
|
+
if p['state'] != 'present':
|
|
1430
|
+
raise ValueError(f'keychain credential {p["state"]}')
|
|
1431
|
+
return p['doc']
|
|
1432
|
+
|
|
1433
|
+
def write(self, doc):
|
|
1434
|
+
if not keychain.write(self.d, doc, account=self.account):
|
|
1435
|
+
raise OSError('keychain write refused')
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def oauth_store(d):
|
|
1439
|
+
"""Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
|
|
1440
|
+
the string 'locked' (Keychain item exists but this session cannot open it), or
|
|
1441
|
+
None. The file wins when both exist — same rule as lib/audit.oauth_login."""
|
|
1442
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
1443
|
+
if os.path.isfile(cpath):
|
|
1444
|
+
return FileStore(cpath)
|
|
1445
|
+
p = keychain.probe(d)
|
|
1446
|
+
if p['state'] == 'present':
|
|
1447
|
+
return KeychainStore(d, p)
|
|
1448
|
+
if p['state'] == 'locked':
|
|
1449
|
+
return 'locked'
|
|
1450
|
+
return None
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def refresh_oauth(aid, d, store):
|
|
1358
1454
|
"""Refresh a long-expired OAuth access token via the refresh-token grant and
|
|
1359
|
-
persist the ROTATED credential atomically (0600
|
|
1360
|
-
None (fail open: the
|
|
1361
|
-
Failures back off via <dir>/.oauth-refresh.json —
|
|
1362
|
-
because telemetry state must only ever reflect
|
|
1455
|
+
persist the ROTATED credential atomically (0600 file, or the Keychain item it
|
|
1456
|
+
came from). Returns the new bearer, or None (fail open: the stored credential
|
|
1457
|
+
is never touched on failure). Failures back off via <dir>/.oauth-refresh.json —
|
|
1458
|
+
a side file, NOT limits.json, because telemetry state must only ever reflect
|
|
1459
|
+
real usage fetches."""
|
|
1363
1460
|
spath = os.path.join(d, '.oauth-refresh.json')
|
|
1364
1461
|
try:
|
|
1365
|
-
doc =
|
|
1462
|
+
doc = store.read()
|
|
1366
1463
|
o = doc.get('claudeAiOauth', {})
|
|
1367
1464
|
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
1368
1465
|
# write) must degrade THIS account only, like every other malformed input.
|
|
@@ -1472,21 +1569,15 @@ def refresh_oauth(aid, d, cpath):
|
|
|
1472
1569
|
# on-disk refresh token is no longer the one this grant was issued against, the
|
|
1473
1570
|
# other writer won: keep its result, discard ours.
|
|
1474
1571
|
try:
|
|
1475
|
-
|
|
1476
|
-
disk = json.load(f).get('claudeAiOauth', {})
|
|
1572
|
+
disk = store.read().get('claudeAiOauth', {})
|
|
1477
1573
|
if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
|
|
1478
1574
|
say(f'{aid}: credential was refreshed by something else mid-flight — '
|
|
1479
|
-
f'keeping the newer one
|
|
1575
|
+
f'keeping the newer one in the {store.kind}')
|
|
1480
1576
|
return None
|
|
1481
1577
|
except Exception:
|
|
1482
1578
|
pass
|
|
1483
1579
|
try:
|
|
1484
|
-
|
|
1485
|
-
with os.fdopen(fd, 'w') as f:
|
|
1486
|
-
json.dump(doc, f)
|
|
1487
|
-
f.flush()
|
|
1488
|
-
os.fsync(f.fileno())
|
|
1489
|
-
os.replace(cpath + '.tmp', cpath)
|
|
1580
|
+
store.write(doc)
|
|
1490
1581
|
except Exception as e:
|
|
1491
1582
|
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
1492
1583
|
return None
|
|
@@ -1552,20 +1643,30 @@ for acct in manifest.get('accounts', []):
|
|
|
1552
1643
|
# it. OAuth first, refresh second, token only as a genuine last resort.
|
|
1553
1644
|
bearer = None
|
|
1554
1645
|
source = None
|
|
1555
|
-
cpath = os.path.join(d, '.credentials.json')
|
|
1556
1646
|
tpath = os.path.join(d, 'server.token')
|
|
1557
|
-
|
|
1647
|
+
# The login may sit in .credentials.json or in the macOS Keychain (the client
|
|
1648
|
+
# moves it there from any session that can open the keychain — a launchd agent
|
|
1649
|
+
# like the one running this probe can, an ssh session cannot).
|
|
1650
|
+
try:
|
|
1651
|
+
store = oauth_store(d)
|
|
1652
|
+
except Exception as e:
|
|
1653
|
+
say(f'{aid}: could not locate the oauth credential ({str(e)[:120]}); failing open')
|
|
1654
|
+
store = None
|
|
1655
|
+
locked = store == 'locked'
|
|
1656
|
+
if locked:
|
|
1657
|
+
store = None
|
|
1658
|
+
if store is not None:
|
|
1558
1659
|
try:
|
|
1559
|
-
c =
|
|
1660
|
+
c = store.read().get('claudeAiOauth', {})
|
|
1560
1661
|
if c.get('accessToken') and c.get('expiresAt', 0) / 1000.0 > now + 60:
|
|
1561
1662
|
bearer, source = c['accessToken'], 'oauth'
|
|
1562
1663
|
except Exception:
|
|
1563
1664
|
pass
|
|
1564
|
-
if not bearer and
|
|
1665
|
+
if not bearer and store is not None:
|
|
1565
1666
|
# Hard fail-open guard: NOTHING a single account's refresh does may abort
|
|
1566
1667
|
# the loop — every account after it would silently starve of telemetry.
|
|
1567
1668
|
try:
|
|
1568
|
-
tok = refresh_oauth(aid, d,
|
|
1669
|
+
tok = refresh_oauth(aid, d, store)
|
|
1569
1670
|
except Exception as e:
|
|
1570
1671
|
say(f'{aid}: oauth refresh failed unexpectedly ({str(e)[:200]}); failing open')
|
|
1571
1672
|
tok = None
|
|
@@ -1592,7 +1693,12 @@ for acct in manifest.get('accounts', []):
|
|
|
1592
1693
|
bearer, source = t, 'token'
|
|
1593
1694
|
if not bearer:
|
|
1594
1695
|
# Fail OPEN: no usable bearer => leave existing state; never block work on telemetry.
|
|
1595
|
-
|
|
1696
|
+
if locked:
|
|
1697
|
+
say(f'{aid}: oauth login is in the macOS Keychain, which this session cannot '
|
|
1698
|
+
f'open (ssh/background) — its telemetry comes from the Mac\'s own launchd '
|
|
1699
|
+
f'probe; limits left as-is')
|
|
1700
|
+
else:
|
|
1701
|
+
say(f'{aid}: no fresh bearer (expired oauth and/or no token); limits left as-is')
|
|
1596
1702
|
continue
|
|
1597
1703
|
|
|
1598
1704
|
req = urllib.request.Request(url, headers={
|
|
@@ -1870,7 +1976,8 @@ import json, os, re, subprocess, sys, time
|
|
|
1870
1976
|
|
|
1871
1977
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
1872
1978
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
1873
|
-
from audit import audit_account, creds_state # noqa: E402 (shared with the shim's rule)
|
|
1979
|
+
from audit import audit_account, creds_state, creds_doc_state # noqa: E402 (shared with the shim's rule)
|
|
1980
|
+
import keychain # noqa: E402
|
|
1874
1981
|
machine = sys.argv[5]
|
|
1875
1982
|
now = time.time()
|
|
1876
1983
|
manifest = json.load(open(os.path.join(root, 'accounts.json')))
|
|
@@ -1914,13 +2021,28 @@ for acct in manifest.get('accounts', []):
|
|
|
1914
2021
|
tpath = os.path.join(d, 'server.token')
|
|
1915
2022
|
has_creds = os.path.isfile(cpath)
|
|
1916
2023
|
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
2024
|
+
cred_doc = None
|
|
2025
|
+
cred_state = None
|
|
2026
|
+
if not has_creds:
|
|
2027
|
+
# The login may be in the macOS Keychain (lib/keychain.py). A Keychain this
|
|
2028
|
+
# session cannot open is not a failure of the ACCOUNT: the real call below
|
|
2029
|
+
# would fail for the session, not the grant, so it is a skip with a reason.
|
|
2030
|
+
kc = keychain.probe(d)
|
|
2031
|
+
if kc['state'] == 'present':
|
|
2032
|
+
has_creds, cred_doc = True, kc['doc']
|
|
2033
|
+
elif kc['state'] == 'locked' and not has_token:
|
|
2034
|
+
print(f'{aid} {acct["email"]}: SKIP (login is in the macOS Keychain, locked '
|
|
2035
|
+
f'for this session — run verify from the Mac\'s own session)')
|
|
2036
|
+
continue
|
|
1917
2037
|
if not has_creds and not has_token:
|
|
1918
2038
|
print(f'{aid} {acct["email"]}: SKIP (no auth on this machine)')
|
|
1919
2039
|
continue
|
|
1920
2040
|
tested += 1
|
|
1921
2041
|
if has_creds:
|
|
1922
2042
|
try:
|
|
1923
|
-
c = json.load(open(cpath)).get('claudeAiOauth', {})
|
|
2043
|
+
c = (cred_doc or json.load(open(cpath))).get('claudeAiOauth', {})
|
|
2044
|
+
cred_state = creds_doc_state(cred_doc, now)[0] if cred_doc is not None \
|
|
2045
|
+
else creds_state(cpath, now)[0]
|
|
1924
2046
|
rexp = c.get('refreshTokenExpiresAt', 0) / 1000.0
|
|
1925
2047
|
if rexp and rexp < now and not has_token:
|
|
1926
2048
|
mark_expired(d, 'refresh-token-expired',
|
|
@@ -1954,7 +2076,7 @@ for acct in manifest.get('accounts', []):
|
|
|
1954
2076
|
# Mirror the shim's acct_token(): a portable token is what actually authenticates
|
|
1955
2077
|
# whenever there is no credential OR the credential beside it is dead. Testing such
|
|
1956
2078
|
# an account with the dead credential would fail it — and park a healthy account.
|
|
1957
|
-
if has_token and (not has_creds or
|
|
2079
|
+
if has_token and (not has_creds or cred_state != 'ok'):
|
|
1958
2080
|
env['CLAUDE_CODE_OAUTH_TOKEN'] = open(tpath).read().strip()
|
|
1959
2081
|
t0 = time.time()
|
|
1960
2082
|
try:
|
|
@@ -2208,23 +2330,27 @@ cmd_post_sync() {
|
|
|
2208
2330
|
|
|
2209
2331
|
# Version of the copy at $1, read from its package.json on disk — the only answer
|
|
2210
2332
|
# that describes what will actually execute.
|
|
2211
|
-
pkg_version_at() {
|
|
2212
|
-
[ -f "$1/package.json" ] || return 0
|
|
2213
|
-
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
2333
|
cmd_self_update() {
|
|
2217
2334
|
# Update the addon in place. npm global install => npm i -g @latest (its postinstall
|
|
2218
2335
|
# re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
|
|
2219
2336
|
# no-op with a hint. Best-effort and fully logged; never disrupts a running claude.
|
|
2220
2337
|
local quiet=0
|
|
2221
2338
|
[ "${1:-}" = "--quiet" ] && quiet=1
|
|
2339
|
+
# The update log is appended to with a plain redirect below, so the pool root has to
|
|
2340
|
+
# exist: on a machine where this provider's pool was never initialised the redirect
|
|
2341
|
+
# itself failed, and the update was reported as "npm update FAILED" with nothing in
|
|
2342
|
+
# the log to say why.
|
|
2343
|
+
mkdir -p "$ACC_ROOT" 2>/dev/null || true
|
|
2222
2344
|
rotate_log update.log
|
|
2223
2345
|
ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
|
|
2224
2346
|
case "$REPO_DIR" in
|
|
2225
2347
|
*/node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
|
|
2226
|
-
|
|
2227
|
-
|
|
2348
|
+
local cur lat prefix after npm_bin
|
|
2349
|
+
prefix="${REPO_DIR%/lib/node_modules/*}"
|
|
2350
|
+
npm_bin="$(find_npm "$prefix")" || {
|
|
2351
|
+
ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
|
|
2352
|
+
return 0
|
|
2353
|
+
}
|
|
2228
2354
|
# Update THE COPY THAT IS RUNNING, not whichever one the ambient npm prefix
|
|
2229
2355
|
# happens to point at. my-mini had two global installs — homebrew's on PATH and
|
|
2230
2356
|
# nvm's under `npm root -g` — and self-update kept upgrading the nvm one and
|
|
@@ -2232,15 +2358,14 @@ cmd_self_update() {
|
|
|
2232
2358
|
# It sat eleven versions behind for weeks and said "already latest" throughout.
|
|
2233
2359
|
# A deploy nobody runs is not a deploy, and one that announces success is worse
|
|
2234
2360
|
# than one that fails.
|
|
2235
|
-
prefix="${REPO_DIR%/lib/node_modules/claude-multiacc*}"
|
|
2236
2361
|
cur="$(pkg_version_at "$REPO_DIR")"
|
|
2237
|
-
lat="$(
|
|
2362
|
+
lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
2238
2363
|
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
2239
2364
|
ulog "self-update: already latest ($cur)"
|
|
2240
2365
|
return 0
|
|
2241
2366
|
fi
|
|
2242
|
-
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix)"
|
|
2243
|
-
if
|
|
2367
|
+
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
|
|
2368
|
+
if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
2244
2369
|
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
2245
2370
|
# Verify by re-reading the file on disk. npm reporting success says nothing
|
|
2246
2371
|
# about which tree it wrote to.
|
package/bin/codex-accounts
CHANGED
|
@@ -1882,21 +1882,38 @@ cmd_self_update() {
|
|
|
1882
1882
|
# no-op with a hint. Best-effort and fully logged; never disrupts a running codex.
|
|
1883
1883
|
local quiet=0
|
|
1884
1884
|
[ "${1:-}" = "--quiet" ] && quiet=1
|
|
1885
|
+
# The update log is appended to with a plain redirect below, so the pool root has to
|
|
1886
|
+
# exist: on a machine where this provider's pool was never initialised the redirect
|
|
1887
|
+
# itself failed, and the update was reported as "npm update FAILED" with nothing in
|
|
1888
|
+
# the log to say why.
|
|
1889
|
+
mkdir -p "$ACC_ROOT" 2>/dev/null || true
|
|
1885
1890
|
rotate_log update.log
|
|
1886
1891
|
ulog() { log_to update.log "$*"; [ "$quiet" = "1" ] || echo "$*"; }
|
|
1887
1892
|
case "$REPO_DIR" in
|
|
1888
1893
|
*/node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1894
|
+
local cur lat prefix npm_bin after
|
|
1895
|
+
# Same resolution and same prefix discipline as claude-accounts: a launchd/cron
|
|
1896
|
+
# PATH has no npm, and the npm that must run is the one owning the RUNNING copy.
|
|
1897
|
+
prefix="${REPO_DIR%/lib/node_modules/*}"
|
|
1898
|
+
npm_bin="$(find_npm "$prefix")" || {
|
|
1899
|
+
ulog "self-update: no npm found (looked in $prefix/bin, /opt/homebrew/bin, /usr/local/bin, /usr/bin, PATH); skipping"
|
|
1900
|
+
return 0
|
|
1901
|
+
}
|
|
1902
|
+
cur="$(pkg_version_at "$REPO_DIR")"
|
|
1903
|
+
lat="$("$npm_bin" view claude-multiacc version 2>/dev/null)"
|
|
1893
1904
|
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
1894
1905
|
ulog "self-update: already latest ($cur)"
|
|
1895
1906
|
return 0
|
|
1896
1907
|
fi
|
|
1897
|
-
ulog "self-update: npm $cur -> ${lat:-latest}"
|
|
1898
|
-
if
|
|
1899
|
-
|
|
1908
|
+
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
|
|
1909
|
+
if "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
1910
|
+
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
1911
|
+
after="$(pkg_version_at "$REPO_DIR")"
|
|
1912
|
+
if [ -n "$lat" ] && [ "$after" != "$lat" ]; then
|
|
1913
|
+
ulog "self-update: npm reported success but $REPO_DIR is still $after, not $lat — this install is NOT being updated"
|
|
1914
|
+
return 1
|
|
1915
|
+
fi
|
|
1916
|
+
ulog "self-update: npm update ok ($after)"
|
|
1900
1917
|
else
|
|
1901
1918
|
ulog "self-update: npm update FAILED (see update.log)"
|
|
1902
1919
|
return 1
|
|
@@ -69,7 +69,11 @@ subscription — never API keys.
|
|
|
69
69
|
|
|
70
70
|
Using the full login on purpose: it does **not** hit the *"Sign in again to continue"*
|
|
71
71
|
step-up wall that the narrow long-lived-token grant triggers, and it stores an
|
|
72
|
-
auto-refreshing `.credentials.json
|
|
72
|
+
auto-refreshing login — `.credentials.json`, or on macOS the login Keychain whenever
|
|
73
|
+
the session can open it (Claude Code migrates the file into the Keychain on the first
|
|
74
|
+
refresh from a keychain-capable session; ssh sessions then see the account as
|
|
75
|
+
`KEYCHAIN LOCKED` and cannot run it, while the Mac's own session uses it normally).
|
|
76
|
+
That credential is **machine-local** (never synced),
|
|
73
77
|
which is what keeps two machines from invalidating each other's refresh token — so an
|
|
74
78
|
account you `add` on the Mac runs on the Mac, and you `add` it on the server (over SSH) if
|
|
75
79
|
you want it there too. The account is registered **only after the signed-in email is read
|
|
@@ -244,7 +248,7 @@ whose reset time has passed does not count, an unreadable one does.
|
|
|
244
248
|
| Credential | Where it lives | Class | Can it be copied to another machine? |
|
|
245
249
|
| --- | --- | --- | --- |
|
|
246
250
|
| claude setup-token | `<acct>/server.token` | **portable** | **Yes**; the fleet-distributed credential |
|
|
247
|
-
| claude OAuth login | `<acct>/.credentials.json
|
|
251
|
+
| claude OAuth login | `<acct>/.credentials.json`, or a macOS Keychain item | machine-local | **No**; refresh tokens rotate |
|
|
248
252
|
| codex ChatGPT login | `<acct>/auth.json` | machine-local | **No**; Codex has no portable credential |
|
|
249
253
|
|
|
250
254
|
Non-portable accounts are not a dead end: they show up as `credential_class:
|
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
|