claude-multiacc 1.0.7 → 1.0.8
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 +78 -8
- package/bin/cli.mjs +22 -6
- package/bin/codex +453 -0
- package/bin/codex-accounts +1663 -0
- package/install.sh +90 -12
- package/lib/codex_audit.py +238 -0
- package/lib/common.sh +70 -24
- package/package.json +6 -2
- package/tests/run-tests.sh +791 -2
package/install.sh
CHANGED
|
@@ -11,6 +11,9 @@ set -u
|
|
|
11
11
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
12
12
|
# shellcheck source=lib/common.sh
|
|
13
13
|
. "$REPO_DIR/lib/common.sh"
|
|
14
|
+
# common.sh is sourced in claude mode (ACC_ROOT = the claude pool); the codex pool
|
|
15
|
+
# root is needed here too for uninstall/purge messaging.
|
|
16
|
+
CODEX_ACC_ROOT="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
|
|
14
17
|
|
|
15
18
|
MARK_BEGIN="# >>> claude-multiacc >>>"
|
|
16
19
|
MARK_END="# <<< claude-multiacc <<<"
|
|
@@ -18,6 +21,8 @@ CRON_TAG="# claude-multiacc"
|
|
|
18
21
|
PLIST_LIMITS="$HOME/Library/LaunchAgents/com.claude-multiacc.limits.plist"
|
|
19
22
|
PLIST_HEALTH="$HOME/Library/LaunchAgents/com.claude-multiacc.health.plist"
|
|
20
23
|
PLIST_UPDATE="$HOME/Library/LaunchAgents/com.claude-multiacc.update.plist"
|
|
24
|
+
PLIST_CODEX_LIMITS="$HOME/Library/LaunchAgents/com.claude-multiacc.codex-limits.plist"
|
|
25
|
+
PLIST_CODEX_HEALTH="$HOME/Library/LaunchAgents/com.claude-multiacc.codex-health.plist"
|
|
21
26
|
PROFILED="/etc/profile.d/claude-multiacc.sh"
|
|
22
27
|
|
|
23
28
|
UNINSTALL=0
|
|
@@ -122,21 +127,63 @@ EOF
|
|
|
122
127
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
123
128
|
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
124
129
|
</dict></plist>
|
|
130
|
+
EOF
|
|
131
|
+
# Codex pool: its own limits/health agents, so either provider's telemetry can
|
|
132
|
+
# fail or be removed without touching the other's.
|
|
133
|
+
cat > "$PLIST_CODEX_LIMITS" <<EOF
|
|
134
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
135
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
136
|
+
<plist version="1.0"><dict>
|
|
137
|
+
<key>Label</key><string>com.claude-multiacc.codex-limits</string>
|
|
138
|
+
<key>ProgramArguments</key><array>
|
|
139
|
+
<string>$REPO_DIR/bin/codex-accounts</string>
|
|
140
|
+
<string>limits</string>
|
|
141
|
+
<string>--quiet</string>
|
|
142
|
+
</array>
|
|
143
|
+
<key>StartInterval</key><integer>60</integer>
|
|
144
|
+
<key>RunAtLoad</key><true/>
|
|
145
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
146
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
147
|
+
</dict></plist>
|
|
148
|
+
EOF
|
|
149
|
+
cat > "$PLIST_CODEX_HEALTH" <<EOF
|
|
150
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
151
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
152
|
+
<plist version="1.0"><dict>
|
|
153
|
+
<key>Label</key><string>com.claude-multiacc.codex-health</string>
|
|
154
|
+
<key>ProgramArguments</key><array>
|
|
155
|
+
<string>$REPO_DIR/bin/codex-accounts</string>
|
|
156
|
+
<string>health</string>
|
|
157
|
+
</array>
|
|
158
|
+
<key>StartCalendarInterval</key><dict>
|
|
159
|
+
<key>Weekday</key><integer>1</integer>
|
|
160
|
+
<key>Hour</key><integer>9</integer>
|
|
161
|
+
<key>Minute</key><integer>37</integer>
|
|
162
|
+
</dict>
|
|
163
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
164
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
165
|
+
</dict></plist>
|
|
125
166
|
EOF
|
|
126
167
|
launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
|
|
127
168
|
launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
|
|
128
169
|
launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
|
|
170
|
+
launchctl unload "$PLIST_CODEX_LIMITS" 2>/dev/null || true
|
|
171
|
+
launchctl unload "$PLIST_CODEX_HEALTH" 2>/dev/null || true
|
|
129
172
|
launchctl load -w "$PLIST_LIMITS" 2>/dev/null || echo " (launchctl load limits agent failed — limits refresh will rely on the shim's opportunistic kick)"
|
|
130
173
|
launchctl load -w "$PLIST_HEALTH" 2>/dev/null || true
|
|
174
|
+
launchctl load -w "$PLIST_CODEX_LIMITS" 2>/dev/null || true
|
|
175
|
+
launchctl load -w "$PLIST_CODEX_HEALTH" 2>/dev/null || true
|
|
131
176
|
[ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && launchctl load -w "$PLIST_UPDATE" 2>/dev/null || true
|
|
132
|
-
echo " launchd: limits refresh every 60s + weekly health
|
|
177
|
+
echo " launchd: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
|
|
133
178
|
}
|
|
134
179
|
|
|
135
180
|
mac_schedule_remove() {
|
|
136
181
|
launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
|
|
137
182
|
launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
|
|
138
183
|
launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
|
|
139
|
-
|
|
184
|
+
launchctl unload "$PLIST_CODEX_LIMITS" 2>/dev/null || true
|
|
185
|
+
launchctl unload "$PLIST_CODEX_HEALTH" 2>/dev/null || true
|
|
186
|
+
rm -f "$PLIST_LIMITS" "$PLIST_HEALTH" "$PLIST_UPDATE" "$PLIST_CODEX_LIMITS" "$PLIST_CODEX_HEALTH"
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
linux_schedule_install() {
|
|
@@ -145,13 +192,15 @@ linux_schedule_install() {
|
|
|
145
192
|
{ crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true; } > "$tmp"
|
|
146
193
|
{
|
|
147
194
|
echo "* * * * * $REPO_DIR/bin/claude-accounts limits --quiet >/dev/null 2>&1 $CRON_TAG"
|
|
195
|
+
echo "* * * * * $REPO_DIR/bin/codex-accounts limits --quiet >/dev/null 2>&1 $CRON_TAG"
|
|
148
196
|
echo "17 9 * * 1 $REPO_DIR/bin/claude-accounts health >/dev/null 2>&1 $CRON_TAG"
|
|
197
|
+
echo "37 9 * * 1 $REPO_DIR/bin/codex-accounts health >/dev/null 2>&1 $CRON_TAG"
|
|
149
198
|
[ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && \
|
|
150
199
|
echo "7 4 * * 0 $REPO_DIR/bin/claude-accounts self-update --quiet >/dev/null 2>&1 $CRON_TAG"
|
|
151
200
|
} >> "$tmp"
|
|
152
201
|
crontab "$tmp"
|
|
153
202
|
rm -f "$tmp"
|
|
154
|
-
echo " cron: limits refresh every 60s + weekly health
|
|
203
|
+
echo " cron: limits refresh every 60s (claude + codex) + weekly health checks + weekly auto-update"
|
|
155
204
|
}
|
|
156
205
|
|
|
157
206
|
linux_schedule_remove() {
|
|
@@ -179,14 +228,22 @@ do_uninstall() {
|
|
|
179
228
|
rm -f /usr/local/bin/claude
|
|
180
229
|
echo " removed /usr/local/bin/claude shim symlink"
|
|
181
230
|
fi
|
|
231
|
+
if [ -L /usr/local/bin/codex ] && [ "$(canon_path /usr/local/bin/codex)" = "$(canon_path "$REPO_DIR/bin/codex")" ]; then
|
|
232
|
+
rm -f /usr/local/bin/codex
|
|
233
|
+
echo " removed /usr/local/bin/codex shim symlink"
|
|
234
|
+
fi
|
|
182
235
|
fi
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
236
|
+
local root
|
|
237
|
+
for root in "$ACC_ROOT" "$CODEX_ACC_ROOT"; do
|
|
238
|
+
if [ "$PURGE" = "1" ]; then
|
|
239
|
+
case "$root" in
|
|
240
|
+
*/.claude-accounts|*/.codex-accounts) rm -rf "$root"; echo " purged $root" ;;
|
|
241
|
+
*) echo " refusing to purge unusual accounts root: $root" >&2 ;;
|
|
242
|
+
esac
|
|
243
|
+
fi
|
|
244
|
+
done
|
|
245
|
+
if [ "$PURGE" != "1" ]; then
|
|
246
|
+
echo " account data kept at $ACC_ROOT and $CODEX_ACC_ROOT (use --purge-data to remove)"
|
|
190
247
|
fi
|
|
191
248
|
echo "uninstall complete — stock claude behavior restored"
|
|
192
249
|
}
|
|
@@ -198,6 +255,8 @@ do_install() {
|
|
|
198
255
|
|
|
199
256
|
[ -x "$REPO_DIR/bin/claude" ] || chmod +x "$REPO_DIR/bin/claude" 2>/dev/null || true
|
|
200
257
|
[ -x "$REPO_DIR/bin/claude-accounts" ] || chmod +x "$REPO_DIR/bin/claude-accounts" 2>/dev/null || true
|
|
258
|
+
[ -x "$REPO_DIR/bin/codex" ] || chmod +x "$REPO_DIR/bin/codex" 2>/dev/null || true
|
|
259
|
+
[ -x "$REPO_DIR/bin/codex-accounts" ] || chmod +x "$REPO_DIR/bin/codex-accounts" 2>/dev/null || true
|
|
201
260
|
|
|
202
261
|
local real
|
|
203
262
|
if real="$(find_real_claude "$REPO_DIR/bin/claude")"; then
|
|
@@ -205,6 +264,12 @@ do_install() {
|
|
|
205
264
|
else
|
|
206
265
|
echo " WARNING: no real claude binary found yet — install Claude Code first (https://claude.com/claude-code)" >&2
|
|
207
266
|
fi
|
|
267
|
+
local real_codex
|
|
268
|
+
if real_codex="$(find_real_codex "$REPO_DIR/bin/codex")"; then
|
|
269
|
+
echo " real codex binary: $real_codex ($("$real_codex" --version 2>/dev/null | head -1 || echo 'version unknown'))"
|
|
270
|
+
else
|
|
271
|
+
echo " note: no codex binary found — the codex pool stays idle until Codex CLI is installed (npm i -g @openai/codex)"
|
|
272
|
+
fi
|
|
208
273
|
|
|
209
274
|
# Keychain-mode detection: per-dir /login isolation needs file-based credentials.
|
|
210
275
|
if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
|
|
@@ -226,6 +291,12 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
|
226
291
|
PYEOF
|
|
227
292
|
fi
|
|
228
293
|
echo " account pool: $ACC_ROOT (manifest ready)"
|
|
294
|
+
# The codex pool gets its own skeleton + manifest (same schema, separate root).
|
|
295
|
+
if ! "$REPO_DIR/bin/codex-accounts" init-pool "${SERVER_OVERRIDE:-}" >/dev/null 2>&1; then
|
|
296
|
+
echo " WARNING: codex pool init failed (codex-accounts init-pool)" >&2
|
|
297
|
+
else
|
|
298
|
+
echo " codex account pool: $CODEX_ACC_ROOT (manifest ready)"
|
|
299
|
+
fi
|
|
229
300
|
|
|
230
301
|
if [ "$kind" = "mac" ]; then
|
|
231
302
|
# .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
|
|
@@ -260,14 +331,21 @@ PYEOF
|
|
|
260
331
|
ln -sfn "$REPO_DIR/bin/claude" /usr/local/bin/claude
|
|
261
332
|
echo " shim: /usr/local/bin/claude -> $REPO_DIR/bin/claude (systemd-PATH compatible)"
|
|
262
333
|
fi
|
|
334
|
+
if [ -e /usr/local/bin/codex ] && [ ! -L /usr/local/bin/codex ]; then
|
|
335
|
+
echo " WARNING: /usr/local/bin/codex exists and is a real file — NOT overwriting." >&2
|
|
336
|
+
else
|
|
337
|
+
ln -sfn "$REPO_DIR/bin/codex" /usr/local/bin/codex
|
|
338
|
+
echo " shim: /usr/local/bin/codex -> $REPO_DIR/bin/codex (systemd-PATH compatible)"
|
|
339
|
+
fi
|
|
263
340
|
fi
|
|
264
341
|
[ "$NO_SCHEDULE" = "1" ] || linux_schedule_install
|
|
265
342
|
fi
|
|
266
343
|
|
|
267
344
|
echo
|
|
268
345
|
echo "install complete. Open a new shell (or 'export PATH=\"$REPO_DIR/bin:\$PATH\"'), then:"
|
|
269
|
-
echo " claude-accounts list #
|
|
270
|
-
echo "
|
|
346
|
+
echo " claude-accounts list # the Claude Code pool"
|
|
347
|
+
echo " codex-accounts list # the Codex pool"
|
|
348
|
+
echo " claude-accounts status | codex-accounts status # auth + limits detail"
|
|
271
349
|
}
|
|
272
350
|
|
|
273
351
|
if [ "$UNINSTALL" = "1" ]; then
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Shared codex account-auth audit — the ONE place that decides whether a codex
|
|
2
|
+
account's login still works, so `expired`, `relogin`, `list` and `status` can never
|
|
3
|
+
disagree with the shim's selection rule (bin/codex: expired_marked / auth_dead).
|
|
4
|
+
|
|
5
|
+
Codex auth material is a single $CODEX_HOME/auth.json written by `codex login`:
|
|
6
|
+
{"auth_mode": "chatgpt", "OPENAI_API_KEY": null,
|
|
7
|
+
"tokens": {"id_token": JWT, "access_token": JWT, "refresh_token": str,
|
|
8
|
+
"account_id": str},
|
|
9
|
+
"last_refresh": ISO}
|
|
10
|
+
The access token is a JWT whose `exp` claim is readable offline; the refresh token
|
|
11
|
+
is opaque (no readable expiry), so a dead refresh grant is only ever known from a
|
|
12
|
+
marker written by a failed refresh/verify — exactly like the shim assumes.
|
|
13
|
+
|
|
14
|
+
States:
|
|
15
|
+
ok usable right now (live access token, or expired-but-refreshable)
|
|
16
|
+
expired has auth material that CANNOT authenticate — needs `codex-accounts relogin`
|
|
17
|
+
blocked authenticates, but Codex access is disabled for it by a workspace admin —
|
|
18
|
+
a re-login usually re-issues the grant; if it stays blocked, an admin must act
|
|
19
|
+
missing no auth on this machine, and this machine is supposed to own the grant
|
|
20
|
+
remote no auth here, but the manifest says another machine owns it (informational)
|
|
21
|
+
|
|
22
|
+
Only 'ok' accounts are selectable; everything else is excluded by the shim.
|
|
23
|
+
|
|
24
|
+
Run directly for a TSV dump: python3 lib/codex_audit.py <acc-root> [mac|linux]
|
|
25
|
+
Columns: id, email, home, state, label, reason, fix
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
VALID_ID = re.compile(r'acct-\d{2}')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mtime(path):
|
|
39
|
+
try:
|
|
40
|
+
return os.path.getmtime(path)
|
|
41
|
+
except OSError:
|
|
42
|
+
return 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _nonempty(path):
|
|
46
|
+
try:
|
|
47
|
+
return os.path.getsize(path) > 0
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def marker_reason(mpath):
|
|
53
|
+
"""Second line of a marker file, as a human string ('' when absent/unreadable)."""
|
|
54
|
+
try:
|
|
55
|
+
lines = open(mpath, errors='replace').read().splitlines()
|
|
56
|
+
except OSError:
|
|
57
|
+
return ''
|
|
58
|
+
return lines[1].strip() if len(lines) > 1 else ''
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def marker_slug(detail):
|
|
62
|
+
"""The reason=<slug> field of a marker line ('' when absent)."""
|
|
63
|
+
m = re.search(r'reason=([A-Za-z0-9._-]+)', detail or '')
|
|
64
|
+
return m.group(1) if m else ''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def expired_marked(d, now=None):
|
|
68
|
+
"""True when <d>/.expired is still in force. Mirrors the shim exactly:
|
|
69
|
+
a CREDENTIAL-scoped park clears as soon as a newer auth.json lands (re-login, or a
|
|
70
|
+
refresh by another process); a POLICY park (org-blocked) does not, because a token
|
|
71
|
+
refresh says nothing about whether the workspace re-enabled Codex. Either kind also
|
|
72
|
+
expires at its own soft_until when the shim wrote it from a single failed run."""
|
|
73
|
+
now = time.time() if now is None else now
|
|
74
|
+
mpath = os.path.join(d, '.expired')
|
|
75
|
+
if not os.path.isfile(mpath):
|
|
76
|
+
return False
|
|
77
|
+
detail = marker_reason(mpath)
|
|
78
|
+
if marker_slug(detail) != 'org-blocked':
|
|
79
|
+
mt = _mtime(mpath)
|
|
80
|
+
p = os.path.join(d, 'auth.json')
|
|
81
|
+
if os.path.isfile(p) and _mtime(p) > mt:
|
|
82
|
+
return False
|
|
83
|
+
m = re.search(r'soft_until=(\d+)', detail)
|
|
84
|
+
if m and int(m.group(1)) <= now:
|
|
85
|
+
return False
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def jwt_claims(token):
|
|
90
|
+
"""Decode a JWT payload without verification (identity/expiry live client-side
|
|
91
|
+
anyway). Returns {} for anything that is not a well-formed JWT."""
|
|
92
|
+
try:
|
|
93
|
+
payload = str(token).split('.')[1]
|
|
94
|
+
payload += '=' * (-len(payload) % 4)
|
|
95
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
96
|
+
return claims if isinstance(claims, dict) else {}
|
|
97
|
+
except Exception:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def auth_email(cpath):
|
|
102
|
+
"""The signed-in identity, read offline from the id_token ('' when unknown)."""
|
|
103
|
+
try:
|
|
104
|
+
doc = json.load(open(cpath))
|
|
105
|
+
tokens = doc.get('tokens') or {}
|
|
106
|
+
return str(jwt_claims(tokens.get('id_token')).get('email') or '')
|
|
107
|
+
except Exception:
|
|
108
|
+
return ''
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def creds_state(cpath, now):
|
|
112
|
+
"""(state, reason) for an on-disk auth.json."""
|
|
113
|
+
try:
|
|
114
|
+
doc = json.load(open(cpath))
|
|
115
|
+
if not isinstance(doc, dict):
|
|
116
|
+
raise ValueError('auth.json is not an object')
|
|
117
|
+
except Exception as e:
|
|
118
|
+
# Unparseable (truncated / mid-write): the shim still selects this account
|
|
119
|
+
# (it only checks for a non-empty access_token string), so fall back to the
|
|
120
|
+
# same lenient rule — a scrape-able access token keeps it in the pool.
|
|
121
|
+
try:
|
|
122
|
+
txt = open(cpath, errors='replace').read()
|
|
123
|
+
except OSError:
|
|
124
|
+
return 'expired', f'auth.json unreadable ({str(e)[:60]})'
|
|
125
|
+
if re.search(r'"access_token"\s*:\s*"[^"]', txt):
|
|
126
|
+
return 'ok', 'auth.json unreadable but carries a token — left in the pool'
|
|
127
|
+
return 'expired', f'auth.json unreadable ({str(e)[:60]})'
|
|
128
|
+
tokens = doc.get('tokens')
|
|
129
|
+
if not isinstance(tokens, dict) or not tokens.get('access_token'):
|
|
130
|
+
if doc.get('OPENAI_API_KEY'):
|
|
131
|
+
# Subscription-only by design (same rule as the claude pool: no API keys).
|
|
132
|
+
return 'expired', ('signed in with an API key — not supported; '
|
|
133
|
+
'use the ChatGPT subscription login')
|
|
134
|
+
return 'expired', 'auth.json has no ChatGPT tokens'
|
|
135
|
+
exp = jwt_claims(tokens['access_token']).get('exp')
|
|
136
|
+
try:
|
|
137
|
+
exp = float(exp)
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
exp = 0.0
|
|
140
|
+
if exp > now:
|
|
141
|
+
return 'ok', 'chatgpt access token valid'
|
|
142
|
+
if not tokens.get('refresh_token'):
|
|
143
|
+
return 'expired', 'access token expired and there is no refresh token'
|
|
144
|
+
# The refresh token is opaque (no readable expiry): assume refreshable. A dead
|
|
145
|
+
# grant is detected by the limits refresher / verify, which write `.expired`.
|
|
146
|
+
return 'ok', 'access token stale but auto-refreshes'
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
LABELS = {'ok': 'OK', 'expired': 'EXPIRED', 'blocked': 'BLOCKED',
|
|
150
|
+
'missing': 'NO LOGIN', 'remote': 'ELSEWHERE'}
|
|
151
|
+
|
|
152
|
+
# `import --home` speaks mac|server; machine_kind() speaks mac|linux. Same two boxes,
|
|
153
|
+
# two vocabularies — normalize, or an un-authenticated account on the very machine that
|
|
154
|
+
# owns it would be filed as "lives elsewhere" and silently drop off the worklist.
|
|
155
|
+
_MACHINE_ALIASES = {'mac': 'mac', 'macos': 'mac', 'darwin': 'mac', 'osx': 'mac',
|
|
156
|
+
'server': 'linux', 'linux': 'linux', 'ubuntu': 'linux',
|
|
157
|
+
'debian': 'linux', 'remote': 'linux'}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _norm_machine(name):
|
|
161
|
+
return _MACHINE_ALIASES.get(str(name or '').strip().lower(), '')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _elsewhere(home, machine):
|
|
165
|
+
"""True only when the manifest names a DIFFERENT machine as the grant's owner.
|
|
166
|
+
An unknown/blank home is never treated as 'elsewhere' — that would hide a real gap."""
|
|
167
|
+
h, m = _norm_machine(home), _norm_machine(machine)
|
|
168
|
+
return bool(h) and bool(m) and h != m
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fix_for(state, aid):
|
|
172
|
+
if state == 'blocked':
|
|
173
|
+
return (f'codex-accounts relogin {aid} '
|
|
174
|
+
f'(if it stays BLOCKED, a workspace admin must enable Codex for it)')
|
|
175
|
+
if state in ('expired', 'missing'):
|
|
176
|
+
return f'codex-accounts relogin {aid}'
|
|
177
|
+
return ''
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def audit_account(root, acct, now=None, machine=None):
|
|
181
|
+
now = time.time() if now is None else now
|
|
182
|
+
aid = acct.get('id', '')
|
|
183
|
+
d = os.path.join(root, aid)
|
|
184
|
+
cpath = os.path.join(d, 'auth.json')
|
|
185
|
+
has_creds = _nonempty(cpath)
|
|
186
|
+
home = acct.get('home', '?')
|
|
187
|
+
row = {'id': aid, 'email': acct.get('email', ''), 'home': home,
|
|
188
|
+
'state': 'ok', 'reason': ''}
|
|
189
|
+
|
|
190
|
+
def done(r):
|
|
191
|
+
r['label'] = LABELS.get(r['state'], r['state'].upper())
|
|
192
|
+
r['fix'] = _fix_for(r['state'], aid)
|
|
193
|
+
return r
|
|
194
|
+
|
|
195
|
+
if expired_marked(d, now=now):
|
|
196
|
+
detail = marker_reason(os.path.join(d, '.expired'))
|
|
197
|
+
slug = marker_slug(detail)
|
|
198
|
+
if slug == 'org-blocked':
|
|
199
|
+
row['state'] = 'blocked'
|
|
200
|
+
row['reason'] = ('Codex access is disabled for this account by a '
|
|
201
|
+
'workspace admin')
|
|
202
|
+
else:
|
|
203
|
+
row['state'] = 'expired'
|
|
204
|
+
row['reason'] = f'marked dead by the pool ({detail or "authentication failed"})'
|
|
205
|
+
return done(row)
|
|
206
|
+
if has_creds:
|
|
207
|
+
state, reason = creds_state(cpath, now)
|
|
208
|
+
row['state'] = state
|
|
209
|
+
row['reason'] = reason
|
|
210
|
+
return done(row)
|
|
211
|
+
if machine and _elsewhere(home, machine):
|
|
212
|
+
row['state'] = 'remote'
|
|
213
|
+
row['reason'] = f'no auth here — the grant lives on the {home} machine'
|
|
214
|
+
else:
|
|
215
|
+
row['state'] = 'missing'
|
|
216
|
+
row['reason'] = 'no credentials on this machine'
|
|
217
|
+
return done(row)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def audit_all(root, machine=None, now=None):
|
|
221
|
+
try:
|
|
222
|
+
doc = json.load(open(os.path.join(root, 'accounts.json')))
|
|
223
|
+
except Exception:
|
|
224
|
+
return []
|
|
225
|
+
out = []
|
|
226
|
+
for a in doc.get('accounts', []):
|
|
227
|
+
if isinstance(a, dict) and VALID_ID.fullmatch(str(a.get('id', ''))):
|
|
228
|
+
out.append(audit_account(root, a, now=now, machine=machine))
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == '__main__':
|
|
233
|
+
root = sys.argv[1]
|
|
234
|
+
machine = sys.argv[2] if len(sys.argv) > 2 else None
|
|
235
|
+
for r in audit_all(root, machine=machine):
|
|
236
|
+
# Tabs are the field separator, so no field may contain one.
|
|
237
|
+
print('\t'.join(str(r[k]).replace('\t', ' ')
|
|
238
|
+
for k in ('id', 'email', 'home', 'state', 'label', 'reason', 'fix')))
|
package/lib/common.sh
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# lib/common.sh — claude-multiacc shared helpers.
|
|
3
|
-
# Sourced by bin/claude-accounts and install.sh.
|
|
3
|
+
# Sourced by bin/claude-accounts, bin/codex-accounts and install.sh.
|
|
4
|
+
# Bash 3.2 compatible (macOS stock).
|
|
5
|
+
#
|
|
6
|
+
# PROVIDER SUPPORT: the same pool machinery serves two independent providers —
|
|
7
|
+
# claude (Claude Code, the default) and codex (OpenAI Codex CLI). A caller opts
|
|
8
|
+
# into codex by exporting MULTIACC_PROVIDER=codex BEFORE sourcing this file; every
|
|
9
|
+
# path/endpoint global below then points at the codex pool. The two pools are
|
|
10
|
+
# completely separate on disk (~/.claude-accounts vs ~/.codex-accounts), so
|
|
11
|
+
# nothing an operation does on one provider can ever touch the other's accounts.
|
|
4
12
|
# shellcheck disable=SC2034
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
MANIFEST="$ACC_ROOT/accounts.json"
|
|
14
|
+
MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
|
|
8
15
|
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
9
16
|
DEFAULT_SERVER="root@138.197.36.107"
|
|
10
|
-
DEFAULT_SERVER_ROOT="/root/.claude-accounts"
|
|
11
17
|
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
12
|
-
USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
|
|
13
18
|
|
|
14
19
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
|
|
15
|
-
|
|
20
|
+
|
|
21
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
22
|
+
ACC_ROOT="${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}"
|
|
23
|
+
DEFAULT_SERVER_ROOT="/root/.codex-accounts"
|
|
24
|
+
USAGE_URL="${CODEX_MULTIACC_USAGE_URL:-https://chatgpt.com/backend-api/codex/usage}"
|
|
25
|
+
AUDIT_PY="$LIB_DIR/codex_audit.py"
|
|
26
|
+
PROVIDER_CLI="codex-accounts"
|
|
27
|
+
else
|
|
28
|
+
ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}"
|
|
29
|
+
DEFAULT_SERVER_ROOT="/root/.claude-accounts"
|
|
30
|
+
USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
|
|
31
|
+
AUDIT_PY="$LIB_DIR/audit.py"
|
|
32
|
+
PROVIDER_CLI="claude-accounts"
|
|
33
|
+
fi
|
|
34
|
+
MANIFEST="$ACC_ROOT/accounts.json"
|
|
16
35
|
|
|
17
36
|
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
18
37
|
epoch_now() { date +%s; }
|
|
@@ -23,8 +42,8 @@ log_to() { # log_to <file-in-acc-root> <msg...>
|
|
|
23
42
|
printf '%s %s\n' "$(ts_utc)" "$*" >> "$ACC_ROOT/$f" 2>/dev/null || true
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
die() { printf '
|
|
27
|
-
warn() { printf '
|
|
45
|
+
die() { printf '%s: error: %s\n' "$PROVIDER_CLI" "$*" >&2; exit 1; }
|
|
46
|
+
warn() { printf '%s: warning: %s\n' "$PROVIDER_CLI" "$*" >&2; }
|
|
28
47
|
|
|
29
48
|
machine_kind() { case "$(uname -s)" in Darwin) echo mac ;; *) echo linux ;; esac; }
|
|
30
49
|
|
|
@@ -47,14 +66,16 @@ canon_path() { # resolve symlinks (loop-guarded) and normalize; always prints so
|
|
|
47
66
|
if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
|
|
69
|
+
# Matches both shims: bin/claude ("claude-multiacc-shim") and bin/codex
|
|
70
|
+
# ("codex-multiacc-shim") — find_real_bin must skip either, whatever it scans for.
|
|
71
|
+
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }
|
|
51
72
|
|
|
52
|
-
# Prints an INVOCABLE path to the real
|
|
73
|
+
# Prints an INVOCABLE path to the real <name> binary (kept as the symlink path,
|
|
53
74
|
# not the canonical target, so process cmdlines keep matching monitors like
|
|
54
|
-
# `pgrep -f '.local/bin/claude'`). $1 =
|
|
55
|
-
|
|
56
|
-
local self_c cand c d
|
|
57
|
-
self_c="$(canon_path "${
|
|
75
|
+
# `pgrep -f '.local/bin/claude'`). $1 = binary name, $2 = caller path to exclude.
|
|
76
|
+
find_real_bin() {
|
|
77
|
+
local name="$1" self_c cand c d
|
|
78
|
+
self_c="$(canon_path "${2:-${BASH_SOURCE[0]}}")"
|
|
58
79
|
local oldifs="$IFS"
|
|
59
80
|
IFS=':'; set -f
|
|
60
81
|
# shellcheck disable=SC2086
|
|
@@ -62,7 +83,7 @@ find_real_claude() {
|
|
|
62
83
|
IFS="$oldifs"; set +f
|
|
63
84
|
for d in "$@"; do
|
|
64
85
|
[ -n "$d" ] || continue
|
|
65
|
-
cand="$d
|
|
86
|
+
cand="$d/$name"
|
|
66
87
|
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
67
88
|
c="$(canon_path "$cand")"
|
|
68
89
|
[ "$c" = "$self_c" ] && continue
|
|
@@ -70,7 +91,7 @@ find_real_claude() {
|
|
|
70
91
|
is_shim_file "$c" && continue
|
|
71
92
|
printf '%s\n' "$cand"; return 0
|
|
72
93
|
done
|
|
73
|
-
for cand in "$HOME/.local/bin
|
|
94
|
+
for cand in "$HOME/.local/bin/$name" "/usr/local/bin/$name" "/opt/homebrew/bin/$name" "/usr/bin/$name"; do
|
|
74
95
|
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
75
96
|
c="$(canon_path "$cand")"
|
|
76
97
|
[ "$c" = "$self_c" ] && continue
|
|
@@ -80,6 +101,9 @@ find_real_claude() {
|
|
|
80
101
|
return 1
|
|
81
102
|
}
|
|
82
103
|
|
|
104
|
+
find_real_claude() { find_real_bin claude "${1:-}"; }
|
|
105
|
+
find_real_codex() { find_real_bin codex "${1:-}"; }
|
|
106
|
+
|
|
83
107
|
manifest_get() { # manifest_get <dot.path> [default]
|
|
84
108
|
local val
|
|
85
109
|
if [ -f "$MANIFEST" ]; then
|
|
@@ -136,7 +160,11 @@ valid_ssh_target() {
|
|
|
136
160
|
acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }
|
|
137
161
|
|
|
138
162
|
has_local_auth() { # $1 = acct dir
|
|
139
|
-
[
|
|
163
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
164
|
+
[ -s "$1/auth.json" ]
|
|
165
|
+
else
|
|
166
|
+
[ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
|
|
167
|
+
fi
|
|
140
168
|
}
|
|
141
169
|
|
|
142
170
|
# ---- expired-login bookkeeping -------------------------------------------------
|
|
@@ -173,7 +201,7 @@ account_audit() {
|
|
|
173
201
|
out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" 2>"$err")"
|
|
174
202
|
rc=$?
|
|
175
203
|
if [ "$rc" -ne 0 ]; then
|
|
176
|
-
printf '
|
|
204
|
+
printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
|
|
177
205
|
rm -f "$err" 2>/dev/null
|
|
178
206
|
return 1
|
|
179
207
|
fi
|
|
@@ -196,10 +224,13 @@ accounts_needing_login() {
|
|
|
196
224
|
| awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
|
|
197
225
|
}
|
|
198
226
|
|
|
199
|
-
# True when <acct dir>'s credential can authenticate right now (audit
|
|
227
|
+
# True when <acct dir>'s credential can authenticate right now (the audit module's
|
|
228
|
+
# rule — audit.py for claude, codex_audit.py for codex; both expose creds_state).
|
|
200
229
|
creds_alive() { # $1 = acct dir
|
|
201
|
-
|
|
202
|
-
"$
|
|
230
|
+
local cred
|
|
231
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then cred="$1/auth.json"; else cred="$1/.credentials.json"; fi
|
|
232
|
+
[ -s "$cred" ] || return 1
|
|
233
|
+
"$PYBIN" - "$AUDIT_PY" "$cred" <<'PYEOF' 2>/dev/null
|
|
203
234
|
import importlib.util, sys, time
|
|
204
235
|
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
|
|
205
236
|
mod = importlib.util.module_from_spec(spec)
|
|
@@ -230,13 +261,28 @@ os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
|
230
261
|
PYEOF
|
|
231
262
|
}
|
|
232
263
|
|
|
233
|
-
# Seed an account config dir so headless runs never prompt
|
|
234
|
-
# copied settings.json, shared projects/ symlink
|
|
235
|
-
# work regardless of
|
|
264
|
+
# Seed an account config dir so headless runs never prompt.
|
|
265
|
+
# claude: stripped .claude.json, copied settings.json, shared projects/ symlink
|
|
266
|
+
# (shared history => --continue/--resume work regardless of picked account).
|
|
267
|
+
# codex: copied config.toml (carries project trust + settings, no identity),
|
|
268
|
+
# shared sessions/ symlink (=> `codex resume` finds every session), and a
|
|
269
|
+
# shared AGENTS.md symlink so global instructions apply under any account.
|
|
236
270
|
seed_account_dir() { # $1 = acct dir
|
|
237
271
|
local d="$1"
|
|
238
272
|
mkdir -p "$d"
|
|
239
273
|
chmod 700 "$d"
|
|
274
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
275
|
+
if [ ! -f "$d/config.toml" ] && [ -f "$HOME/.codex/config.toml" ]; then
|
|
276
|
+
cp "$HOME/.codex/config.toml" "$d/config.toml" 2>/dev/null || true
|
|
277
|
+
fi
|
|
278
|
+
if [ ! -e "$d/sessions" ] && [ -d "$HOME/.codex/sessions" ]; then
|
|
279
|
+
ln -s "$HOME/.codex/sessions" "$d/sessions" 2>/dev/null || true
|
|
280
|
+
fi
|
|
281
|
+
if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
|
|
282
|
+
ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
|
|
283
|
+
fi
|
|
284
|
+
return 0
|
|
285
|
+
fi
|
|
240
286
|
if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
|
241
287
|
"$PYBIN" - "$HOME/.claude.json" "$d/.claude.json" <<'PYEOF' 2>/dev/null || true
|
|
242
288
|
import json, sys
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
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.",
|
|
3
|
+
"version": "1.0.8",
|
|
4
|
+
"description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec 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": {
|
|
7
7
|
"claude-multiacc": "bin/cli.mjs"
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"keywords": [
|
|
19
19
|
"claude",
|
|
20
20
|
"claude-code",
|
|
21
|
+
"codex",
|
|
22
|
+
"codex-cli",
|
|
23
|
+
"openai",
|
|
24
|
+
"chatgpt",
|
|
21
25
|
"anthropic",
|
|
22
26
|
"multi-account",
|
|
23
27
|
"subscription",
|