claude-multiacc 2.0.32 → 2.0.33
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 +11 -1
- package/bin/claude-accounts +24 -2
- package/docs/INSTALLATION.md +13 -4
- package/docs/VERIFICATION.md +13 -4
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.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/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/install_actions.sh +67 -13
- package/lib/shim_path.py +306 -0
- package/package.json +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +114 -0
- package/tests/test_packaged_commands.py +39 -3
- package/tests/test_shim_path.py +285 -0
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ MAC (source of truth) SERVER (mirror)
|
|
|
59
59
|
server.token / .credentials.json
|
|
60
60
|
(or the login lives in the macOS Keychain — see below)
|
|
61
61
|
selection.log sync.log health.log /usr/local/bin/claude -> repo shim
|
|
62
|
-
repo bin/ first on PATH (rc
|
|
62
|
+
repo bin/ first on PATH (rc block + hook) /root/claude-multiacc/ (addon repo)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
**Shim selection order** (identical file on both machines, `bin/claude`):
|
|
@@ -380,6 +380,16 @@ into its global bin directory, so account commands work in the current shell. Op
|
|
|
380
380
|
new shell for the `claude` and `codex` shims. If auto-setup was skipped, run
|
|
381
381
|
`claude-multiacc install` to initialize the pools and shell setup.
|
|
382
382
|
|
|
383
|
+
The shims shadow the real binaries by PATH order alone, so the installer's rc block
|
|
384
|
+
(`~/.zshenv`, `~/.zprofile`, `~/.zshrc`, `~/.profile` and the bash rc files when they
|
|
385
|
+
exist) ends with a prompt hook that puts the shim dir back in front before every
|
|
386
|
+
prompt: an rc file interrupted with Ctrl-C, or a tool that rewrites PATH, can no
|
|
387
|
+
longer leave `~/.local/bin/claude` first — that launch would run on the machine's own
|
|
388
|
+
`~/.claude` login and never reach the pool. `claude-accounts status` probes fresh
|
|
389
|
+
`zsh`/`bash`/`sh` login shells with your rc files and prints `BYPASSED -> <path>` for
|
|
390
|
+
any that still resolve something other than the shim; the weekly `health` check fails
|
|
391
|
+
on it.
|
|
392
|
+
|
|
383
393
|
See [installation, updates, removal, and command-not-found recovery](docs/INSTALLATION.md)
|
|
384
394
|
for npm, npx, git checkout, and server instructions.
|
|
385
395
|
|
package/bin/claude-accounts
CHANGED
|
@@ -23,7 +23,8 @@ claude-accounts — multi-account pool manager for claude-multiacc
|
|
|
23
23
|
|
|
24
24
|
USAGE
|
|
25
25
|
claude-accounts list [--json] brief account list (--json: machine-readable)
|
|
26
|
-
claude-accounts status [--json] full health: auth, per-bucket limits, markers
|
|
26
|
+
claude-accounts status [--json] full health: auth, per-bucket limits, markers,
|
|
27
|
+
and whether fresh login shells reach the shim
|
|
27
28
|
claude-accounts add [email] [--token] [--force]
|
|
28
29
|
login-FIRST: runs the full Claude Code login (`claude auth login` — the normal
|
|
29
30
|
browser sign-in, no long-lived-token step-up), then registers only after the
|
|
@@ -85,7 +86,7 @@ USAGE
|
|
|
85
86
|
persisted), so idle accounts keep fresh telemetry and stay selectable.
|
|
86
87
|
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
87
88
|
--force ignores all three.
|
|
88
|
-
claude-accounts health limits + full verify; logs to health.log
|
|
89
|
+
claude-accounts health limits + full verify + shim-on-PATH probe; logs to health.log
|
|
89
90
|
claude-accounts self-update update this npm/git install; logs to update.log
|
|
90
91
|
claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
91
92
|
|
|
@@ -255,6 +256,18 @@ if dups:
|
|
|
255
256
|
PYEOF
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
# A fresh login shell that never reaches the shim is the one failure the pool cannot
|
|
260
|
+
# see from the inside — the launch simply happens elsewhere (my-mini 2026-09-17: an
|
|
261
|
+
# interrupted ~/.zshrc, and every `bash -l`, resolved the real binary and opened on the
|
|
262
|
+
# un-pooled ~/.claude login). lib/shim_path.py starts each login shell with this user's
|
|
263
|
+
# rc files and asks. Off with CLAUDE_MULTIACC_PATH_PROBE=0 — the sandboxed suites run
|
|
264
|
+
# under the operator's real HOME and must not depend on its rc files.
|
|
265
|
+
shim_path_report() {
|
|
266
|
+
# The module honours the kill switch itself and says so in its output, so a status
|
|
267
|
+
# or health.log read with the probe off never looks like a probe that passed.
|
|
268
|
+
"$PYBIN" "$LIB_DIR/shim_path.py" "$REPO_DIR"
|
|
269
|
+
}
|
|
270
|
+
|
|
258
271
|
cmd_status() {
|
|
259
272
|
require_manifest
|
|
260
273
|
local json=0
|
|
@@ -482,6 +495,8 @@ elif verdict == 'degraded':
|
|
|
482
495
|
print(" fix : claude-accounts limits --force # then, if it still fails:")
|
|
483
496
|
print(" claude-accounts login <acct-NN> # per account, on THIS machine")
|
|
484
497
|
PYEOF
|
|
498
|
+
echo
|
|
499
|
+
shim_path_report || true
|
|
485
500
|
}
|
|
486
501
|
|
|
487
502
|
cmd_add() {
|
|
@@ -2956,6 +2971,13 @@ cmd_health() {
|
|
|
2956
2971
|
# verify first: its real claude runs refresh any expired OAuth creds, so the
|
|
2957
2972
|
# limits pass that follows always has fresh bearers.
|
|
2958
2973
|
out="$( { echo "== verify =="; cmd_verify; echo; echo "== limits =="; cmd_limits; } 2>&1 )" || rc=1
|
|
2974
|
+
# The shim being reachable at all is part of the pool's health: a login shell that
|
|
2975
|
+
# resolves the real binary hands every `claude` typed there to ~/.claude, whatever
|
|
2976
|
+
# the accounts above say. Reported (and failed) here, where the weekly check pages.
|
|
2977
|
+
local shim_out
|
|
2978
|
+
shim_out="$( { echo; echo "== shim =="; shim_path_report; } 2>&1 )" || rc=1
|
|
2979
|
+
out="$out
|
|
2980
|
+
$shim_out"
|
|
2959
2981
|
printf '%s\n' "$out"
|
|
2960
2982
|
printf '%s health rc=%s\n%s\n' "$(ts_utc)" "$rc" "$out" >> "$ACC_ROOT/health.log"
|
|
2961
2983
|
if [ "$rc" -ne 0 ] && [ "$(machine_kind)" = "mac" ]; then
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -78,14 +78,23 @@ one layout that can NOT self-update — don't ship the addon that way.
|
|
|
78
78
|
What install does (all reversible, nothing else):
|
|
79
79
|
|
|
80
80
|
- **macOS:** marked PATH block at the END of `~/.zshenv`, `~/.zprofile`, `~/.zshrc`
|
|
81
|
-
(+ bash rc files
|
|
82
|
-
re-prepend `~/.local/bin
|
|
81
|
+
(+ `~/.profile` and the bash rc files when they exist) — end-of-file placement
|
|
82
|
+
matters because those files re-prepend `~/.local/bin`. Every copy of the block also
|
|
83
|
+
registers a prompt hook (zsh `precmd` / bash `PROMPT_COMMAND`) that puts the shim
|
|
84
|
+
dir back in front before each prompt, so an rc file interrupted with Ctrl-C or a
|
|
85
|
+
tool that rewrites PATH (conda, nvm, a venv) cannot leave the real binary first at
|
|
86
|
+
the next prompt. `~/.profile` is what `bash -l` / `sh -l` read when there is no
|
|
87
|
+
`~/.bash_profile`, so a launcher that runs `bash -lc claude` reaches the pool too;
|
|
88
|
+
it is created when absent (a stock macOS account has none), and the installer never
|
|
89
|
+
creates `~/.bash_profile` (that would stop bash reading `~/.profile`). launchd agents `com.claude-multiacc.limits` +
|
|
83
90
|
`.codex-limits` (15m Claude / 5m Codex), `.health` + `.codex-health` (weekly Mon morning), and
|
|
84
91
|
`.update` (daily 04:07). Notes when this Mac keeps Claude Code logins in the
|
|
85
92
|
Keychain (the pool reads them; ssh sessions cannot — mint portable tokens for
|
|
86
93
|
accounts that must work from everywhere).
|
|
87
|
-
- **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh
|
|
88
|
-
|
|
94
|
+
- **Linux (root):** PATH block in `~/.bashrc` + `/etc/profile.d/claude-multiacc.sh`
|
|
95
|
+
(+ `~/.profile`, created when absent — Debian's own `~/.profile` prepends
|
|
96
|
+
`~/.local/bin` after `/etc/profile.d` has run, so the block has to end that file
|
|
97
|
+
too), shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
|
|
89
98
|
PATH order — on the systemd default PATH too; the original binaries are untouched),
|
|
90
99
|
cron entries for limits/health (both providers) + the daily auto-update.
|
|
91
100
|
- Both: `~/.claude-accounts/` and `~/.codex-accounts/`, each with an `accounts.json` manifest.
|
package/docs/VERIFICATION.md
CHANGED
|
@@ -11,7 +11,8 @@ claude-accounts verify --quick # auth presence/expiry only, no inference
|
|
|
11
11
|
codex-accounts verify # real codex exec call per account
|
|
12
12
|
codex-accounts verify --quick # local Codex auth check, no inference
|
|
13
13
|
claude-accounts limits # live per-bucket usage incl. the Fable bucket
|
|
14
|
-
claude-accounts health # limits + full verify, logs to health.log, notifies on failure (Mac)
|
|
14
|
+
claude-accounts health # limits + full verify + shim-on-PATH probe, logs to health.log, notifies on failure (Mac)
|
|
15
|
+
python3 lib/shim_path.py "$(npm root -g)/claude-multiacc" # just the probe; exit 1 on a bypass
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
Verified end-to-end on both machines (2026-07-13): full matrix PASS, 20-invocation shim
|
|
@@ -22,9 +23,17 @@ repointed via `CLAUDE_BIN=/usr/local/bin/claude` and restarted healthy.
|
|
|
22
23
|
|
|
23
24
|
## Troubleshooting
|
|
24
25
|
|
|
25
|
-
- **`claude` resolves to the real binary, not the shim** —
|
|
26
|
-
|
|
27
|
-
(
|
|
26
|
+
- **`claude` resolves to the real binary, not the shim** — the launch then never
|
|
27
|
+
reaches the pool: the real client starts on the machine's own `~/.claude` login, and
|
|
28
|
+
from ssh/tmux (where that login sits in a locked Keychain) it opens on
|
|
29
|
+
"Not logged in · Please run /login" (my-mini, 2026-09-17). `claude-accounts status`
|
|
30
|
+
and the weekly `health` probe every login shell mode (`zsh -li`/`-lc`, `bash -li`/`-lc`,
|
|
31
|
+
`sh -lc`) with your own rc files and report `BYPASSED -> <path>` for any that resolve
|
|
32
|
+
something other than the shim. Fix: `claude-multiacc install` rewrites the rc blocks
|
|
33
|
+
(`~/.zshenv`, `~/.zprofile`, `~/.zshrc`, `~/.profile`, bash rc files) — each carries
|
|
34
|
+
a prompt hook that re-asserts the shim dir before every prompt — then open a new
|
|
35
|
+
shell; an already-open shell keeps the PATH it has. To check one shell by hand:
|
|
36
|
+
`zsh -lic 'whence -p claude'` / `bash -lc 'type -P claude'`.
|
|
28
37
|
- **An account never gets picked** — `claude-accounts status`: no auth on this machine,
|
|
29
38
|
a dead login (`selectable: NO`, see `claude-accounts expired`), or an active `.limited`
|
|
30
39
|
marker (shows bucket + minutes to reset).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/install_actions.sh
CHANGED
|
@@ -33,14 +33,48 @@ path_block_body() {
|
|
|
33
33
|
printf 'export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
path_guard_body() {
|
|
37
|
+
# The two lines above win only while they are the LAST thing to touch PATH. A later
|
|
38
|
+
# rc line, a tool that rewrites PATH (conda, nvm, a venv) or an rc file that was
|
|
39
|
+
# INTERRUPTED before reaching them (a Ctrl-C at a slow `conda` hook, my-mini
|
|
40
|
+
# 2026-09-17) leaves ~/.local/bin in front again — and the next `claude` typed at
|
|
41
|
+
# that prompt runs the real binary on the machine's own ~/.claude login (or on no
|
|
42
|
+
# login at all), never reaching the pool. Nothing inside the shim can notice a launch
|
|
43
|
+
# that never reached it, so the rc block also registers a prompt hook (zsh precmd /
|
|
44
|
+
# bash PROMPT_COMMAND) that puts the shim dir back in front before EVERY prompt.
|
|
45
|
+
# Fork-free while PATH is already right, registered once, inert once the addon is
|
|
46
|
+
# gone. The array syntax is wrapped in eval so sh/dash — which read ~/.profile and
|
|
47
|
+
# /etc/profile.d as well — can parse the block.
|
|
48
|
+
printf '_claude_multiacc_path_guard() {\n'
|
|
49
|
+
printf ' [ -x "%s/bin/claude" ] || return 0\n' "$REPO_DIR"
|
|
50
|
+
printf ' case ":$PATH:" in ":%s/bin:"*) return 0 ;; esac\n' "$REPO_DIR"
|
|
51
|
+
printf ' PATH="$(printf %%s ":$PATH:" | sed '\''s|:%s/bin:|:|g; s|^:||; s|:$||'\'')"\n' "$REPO_DIR"
|
|
52
|
+
printf ' export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
|
|
53
|
+
printf '}\n'
|
|
54
|
+
# Registered LAST on purpose, in both shells: a hook that runs after this one and
|
|
55
|
+
# rewrites PATH (direnv's, a venv's) would otherwise have the final say at the
|
|
56
|
+
# prompt. zsh removes any earlier registration and re-appends, so the copy of this
|
|
57
|
+
# block at the END of ~/.zshrc outranks hooks that ~/.zshrc registered before it;
|
|
58
|
+
# the filter is IFS-independent. bash appends once — its rc block is the last thing
|
|
59
|
+
# sourced, so an append lands last.
|
|
60
|
+
printf 'if [ -n "${ZSH_VERSION:-}" ]; then\n'
|
|
61
|
+
printf ' eval '\''precmd_functions=(${precmd_functions:#_claude_multiacc_path_guard} _claude_multiacc_path_guard)'\''\n'
|
|
62
|
+
printf 'elif [ -n "${BASH_VERSION:-}" ]; then\n'
|
|
63
|
+
printf ' case ";${PROMPT_COMMAND:-};" in *";_claude_multiacc_path_guard;"*) ;; *) PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}_claude_multiacc_path_guard" ;; esac\n'
|
|
64
|
+
printf 'fi\n'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rc_block() { # the complete marked block, for every rc file and /etc/profile.d alike
|
|
68
|
+
printf '%s\n' "$MARK_BEGIN"
|
|
69
|
+
path_block_body
|
|
70
|
+
path_guard_body
|
|
71
|
+
printf '%s\n' "$MARK_END"
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
append_block() { # strip then append our PATH block to a file
|
|
37
75
|
local f="$1"
|
|
38
76
|
strip_block "$f" || return 1 # never create a second block next to a broken one
|
|
39
|
-
|
|
40
|
-
printf '%s\n' "$MARK_BEGIN"
|
|
41
|
-
path_block_body
|
|
42
|
-
printf '%s\n' "$MARK_END"
|
|
43
|
-
} >> "$f"
|
|
77
|
+
rc_block >> "$f"
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
do_uninstall() {
|
|
@@ -154,11 +188,33 @@ PYEOF
|
|
|
154
188
|
fi
|
|
155
189
|
}
|
|
156
190
|
|
|
191
|
+
# bash and sh read ~/.profile for a LOGIN shell when ~/.bash_profile and ~/.bash_login
|
|
192
|
+
# are absent — `bash -lc`, `sh -lc`, a tmux/ssh session whose login shell is bash. Left
|
|
193
|
+
# without a block, such a shell ends its startup with whatever /etc/profile and its own
|
|
194
|
+
# ~/.local/bin prepend put in front and never sees the shim (my-mini 2026-09-17: every
|
|
195
|
+
# `bash -l` resolved the real binary). The file is CREATED when absent — a stock macOS
|
|
196
|
+
# account has no ~/.profile at all, and without one the bash/sh modes stay bypassed and
|
|
197
|
+
# the shim probe fails `health` with a remedy that changes nothing — exactly as the
|
|
198
|
+
# installer already creates ~/.zshenv and ~/.zprofile. It never creates ~/.bash_profile:
|
|
199
|
+
# that file's mere existence stops bash reading ~/.profile.
|
|
200
|
+
install_profile_block() {
|
|
201
|
+
touch "$HOME/.profile" 2>/dev/null || {
|
|
202
|
+
echo " WARNING: cannot create $HOME/.profile — bash/sh login shells will not see the shim" >&2
|
|
203
|
+
return 0
|
|
204
|
+
}
|
|
205
|
+
if append_block "$HOME/.profile"; then
|
|
206
|
+
echo " PATH block: also at the end of ~/.profile (bash/sh login shells)"
|
|
207
|
+
else
|
|
208
|
+
echo " WARNING: ~/.profile was NOT updated (see above) — bash/sh login shells will not see the shim" >&2
|
|
209
|
+
fi
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
install_mac_paths() {
|
|
158
213
|
# .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
|
|
159
214
|
# over ~/.local/bin re-prepends done earlier in .zshrc/.zprofile.
|
|
160
215
|
# zsh reads: .zshenv always; .zprofile for login; .zshrc for interactive.
|
|
161
|
-
# The block must end each file that later re-prepends ~/.local/bin
|
|
216
|
+
# The block must end each file that later re-prepends ~/.local/bin, and every copy
|
|
217
|
+
# also carries the prompt hook (path_guard_body) for the rc-interrupted case.
|
|
162
218
|
if [ -z "$INSTANCE" ]; then
|
|
163
219
|
touch "$HOME/.zshenv"
|
|
164
220
|
append_block "$HOME/.zshenv"
|
|
@@ -168,7 +224,8 @@ install_mac_paths() {
|
|
|
168
224
|
append_block "$HOME/.zshrc"
|
|
169
225
|
[ -f "$HOME/.bash_profile" ] && append_block "$HOME/.bash_profile"
|
|
170
226
|
[ -f "$HOME/.bashrc" ] && append_block "$HOME/.bashrc"
|
|
171
|
-
echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present)"
|
|
227
|
+
echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present), with a prompt hook that re-asserts it"
|
|
228
|
+
install_profile_block
|
|
172
229
|
fi
|
|
173
230
|
}
|
|
174
231
|
|
|
@@ -177,13 +234,10 @@ install_linux_paths() {
|
|
|
177
234
|
touch "$HOME/.bashrc"
|
|
178
235
|
append_block "$HOME/.bashrc"
|
|
179
236
|
if [ -w /etc/profile.d ] 2>/dev/null || [ "$(id -u)" = "0" ]; then
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
path_block_body
|
|
183
|
-
printf '%s\n' "$MARK_END"
|
|
184
|
-
} > "$PROFILED"
|
|
185
|
-
echo " PATH block: ~/.bashrc + $PROFILED"
|
|
237
|
+
rc_block > "$PROFILED"
|
|
238
|
+
echo " PATH block: ~/.bashrc + $PROFILED (with a prompt hook that re-asserts it)"
|
|
186
239
|
fi
|
|
240
|
+
install_profile_block
|
|
187
241
|
fi
|
|
188
242
|
if [ -z "$INSTANCE" ] && [ "$(id -u)" = "0" ]; then
|
|
189
243
|
if [ -e /usr/local/bin/claude ] && [ ! -L /usr/local/bin/claude ]; then
|
package/lib/shim_path.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Does a FRESH shell resolve `claude` / `codex` to this addon's shims?
|
|
2
|
+
|
|
3
|
+
The shims shadow the real binaries by PATH ORDER, and the rc-file block that puts them
|
|
4
|
+
first is only as good as the shell startup that runs it. Three ways it silently is not:
|
|
5
|
+
a ~/.profile with no block (every `bash -l` / `sh -l` on my-mini resolved the real
|
|
6
|
+
binary, 2026-09-17), an rc file interrupted half-way (a Ctrl-C at a slow `conda` hook
|
|
7
|
+
left ~/.local/bin in front, and the next `claude` at that prompt opened on the
|
|
8
|
+
un-pooled ~/.claude login: "Not logged in · Please run /login"), or a later
|
|
9
|
+
`export PATH=` that re-prepends ~/.local/bin. Nothing inside the shim can notice a
|
|
10
|
+
launch that never reached it, so this is checked from the OUTSIDE: start each login
|
|
11
|
+
shell the way a terminal or a `bash -lc` launcher would — clean environment, the user's
|
|
12
|
+
own rc files — and ask what the command names resolve to.
|
|
13
|
+
|
|
14
|
+
Interactive modes (`-li`) are fed the probe on stdin, so the shell runs its prompt loop
|
|
15
|
+
and the rc block's prompt hook (lib/install_actions.sh path_guard_body) gets its turn,
|
|
16
|
+
exactly as it does for a person at a prompt. The `-lc` modes are what a launcher that
|
|
17
|
+
runs `bash -lc claude` gets: rc order alone, no prompt hook.
|
|
18
|
+
|
|
19
|
+
What is measured is the PATH search — `whence -p` / `type -P`, aliases and functions set
|
|
20
|
+
aside — because the incident was a PATH-order failure and an `alias claude='claude
|
|
21
|
+
--flags'` still resolves through PATH. A function or alias that names the real binary
|
|
22
|
+
outright is not detected here; it is also not something an installer can fix.
|
|
23
|
+
|
|
24
|
+
Every answer line carries a sentinel, the first answer per name wins, and a name the
|
|
25
|
+
shell never answered for is an ERROR on that row, not a bypass: an rc file that reads a
|
|
26
|
+
line of stdin (an update prompt, a `read`) can eat part of the fed script. Nothing
|
|
27
|
+
touches the operator's shell history — the script disables it first thing.
|
|
28
|
+
|
|
29
|
+
probe(repo_dir) -> [ {shell, mode, argv, results: {name: {resolved, ok}}, error} ... ]
|
|
30
|
+
ok is True for the shim, False for anything else (the real binary, or nothing on
|
|
31
|
+
PATH at all), None when the shell could not answer (timeout / no output).
|
|
32
|
+
bypassed(rows) -> the (mode, name, resolved) triples with ok False.
|
|
33
|
+
|
|
34
|
+
Run directly: python3 lib/shim_path.py <repo-dir> [--json] [--timeout=SECONDS]
|
|
35
|
+
Prints the report; exit 1 when any mode bypasses the shim, 2 on usage, else 0.
|
|
36
|
+
CLAUDE_MULTIACC_PATH_PROBE=0 makes probe() return [] (the sandboxed suites run with the
|
|
37
|
+
operator's real HOME and must not depend on its rc files).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import getpass
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import signal
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
50
|
+
|
|
51
|
+
NAMES = ('claude', 'codex')
|
|
52
|
+
# The marker every shim carries in its header; lib/common.sh is_shim_file greps the
|
|
53
|
+
# same word, so a /usr/local/bin/claude -> shim symlink (Linux) counts as the shim too.
|
|
54
|
+
SHIM_MARK = 'multiacc-shim'
|
|
55
|
+
DEFAULT_TIMEOUT = 25
|
|
56
|
+
# (shell, flags, interactive) — a terminal is a login AND interactive shell; a
|
|
57
|
+
# `bash -lc` launcher is login only; sh has no interactive rc worth probing.
|
|
58
|
+
MODES = (
|
|
59
|
+
('zsh', '-li', True), ('zsh', '-lc', False),
|
|
60
|
+
('bash', '-li', True), ('bash', '-lc', False),
|
|
61
|
+
('sh', '-lc', False),
|
|
62
|
+
)
|
|
63
|
+
# What resolves a NAME to the executable a shell would run, per shell, bypassing
|
|
64
|
+
# aliases and functions (an interactive zsh answers `command -v claude` with the
|
|
65
|
+
# user's alias text, which says nothing about the file). POSIX sh has only `command -v`,
|
|
66
|
+
# which reports an alias or a function by name, so the sh script drops those first.
|
|
67
|
+
_RESOLVERS = {'zsh': 'whence -p', 'bash': 'type -P', 'sh': 'command -v'}
|
|
68
|
+
SENTINEL = '__claude_multiacc_probe__'
|
|
69
|
+
# The PATH a login shell STARTS from before any rc file runs — what sshd/login/launchd
|
|
70
|
+
# hand it. /usr/local/bin matters on Linux: the root install's /usr/local/bin/claude
|
|
71
|
+
# symlink is how a shell with no rc block still reaches the shim, so leaving it out
|
|
72
|
+
# would call a working server login a bypass. macOS's path_helper rebuilds PATH from
|
|
73
|
+
# /etc/paths anyway.
|
|
74
|
+
_SEED_PATH = {'darwin': '/usr/bin:/bin:/usr/sbin:/sbin'}
|
|
75
|
+
_SEED_PATH_DEFAULT = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def enabled():
|
|
79
|
+
return os.environ.get('CLAUDE_MULTIACC_PATH_PROBE', '').strip().lower() \
|
|
80
|
+
not in ('0', 'false', 'no', 'off')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def shell_path(name):
|
|
84
|
+
"""The shell binary a terminal would start, or None when it is not installed."""
|
|
85
|
+
found = shutil.which(name, path='/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin')
|
|
86
|
+
if found:
|
|
87
|
+
return found
|
|
88
|
+
candidate = os.path.join('/bin', name)
|
|
89
|
+
return candidate if os.access(candidate, os.X_OK) else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _script(shell, names):
|
|
93
|
+
resolver = _RESOLVERS[shell]
|
|
94
|
+
# First thing, before any answer: no history. The interactive shells would otherwise
|
|
95
|
+
# save these lines into the operator's real ~/.zsh_history / ~/.bash_history on exit
|
|
96
|
+
# (macOS /etc/zshrc sets HISTFILE unconditionally, so an env var is not enough — this
|
|
97
|
+
# runs AFTER the rc files and wins). bash honours `unset HISTFILE`; zsh honours
|
|
98
|
+
# SAVEHIST=0. The stderr redirect swallows "no such option" from the other shell.
|
|
99
|
+
# `set +o history` is bash-only: zsh's `set` rejects the option, and a NON-interactive
|
|
100
|
+
# zsh exits on a special-builtin error — the whole `-lc` row would read as "exited
|
|
101
|
+
# without answering". zsh needs nothing beyond SAVEHIST=0.
|
|
102
|
+
lines = ['unset HISTFILE 2>/dev/null; HISTFILE=/dev/null; SAVEHIST=0; HISTSIZE=0; '
|
|
103
|
+
'[ -z "${BASH_VERSION:-}" ] || set +o history; true']
|
|
104
|
+
if shell == 'sh':
|
|
105
|
+
# `command -v` answers with alias text or a bare function name; the PATH search
|
|
106
|
+
# is the question, so those go first (the shell is a throwaway).
|
|
107
|
+
lines.append('unalias -a 2>/dev/null; unset -f ' + ' '.join(names) + ' 2>/dev/null; true')
|
|
108
|
+
lines += [f'printf \'%s %s=%s\\n\' {SENTINEL} {n} "$({resolver} {n} 2>/dev/null)"'
|
|
109
|
+
for n in names]
|
|
110
|
+
# The interactive shells read this on stdin; without the exit an rc file that
|
|
111
|
+
# left the shell in a state where EOF is ignored (IGNOREEOF) would hang to timeout.
|
|
112
|
+
lines.append('exit 0')
|
|
113
|
+
return '\n'.join(lines) + '\n'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _env(home, user, shell):
|
|
117
|
+
# A TERM a terminal would set: rc files commonly bail out early on TERM=dumb (the
|
|
118
|
+
# Emacs TRAMP guard), which would judge a branch no person at a prompt ever sees.
|
|
119
|
+
return {'HOME': home, 'USER': user, 'LOGNAME': user, 'SHELL': shell,
|
|
120
|
+
'TERM': 'xterm-256color',
|
|
121
|
+
'PATH': _SEED_PATH.get(sys.platform, _SEED_PATH_DEFAULT),
|
|
122
|
+
'LANG': os.environ.get('LANG', 'C.UTF-8'),
|
|
123
|
+
'CLAUDE_MULTIACC_PATH_PROBE': '1'}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_shim(path, repo_dir, name):
|
|
127
|
+
if not path:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
if os.path.realpath(path) == os.path.realpath(os.path.join(repo_dir, 'bin', name)):
|
|
131
|
+
return True
|
|
132
|
+
with open(path, 'rb') as f:
|
|
133
|
+
return SHIM_MARK.encode() in f.read(300)
|
|
134
|
+
except OSError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse(text, names):
|
|
139
|
+
"""First sentinel answer per name. Anything else the shell printed is ignored."""
|
|
140
|
+
answers = {}
|
|
141
|
+
for line in text.splitlines():
|
|
142
|
+
if not line.startswith(SENTINEL + ' '):
|
|
143
|
+
continue
|
|
144
|
+
body = line[len(SENTINEL) + 1:]
|
|
145
|
+
name, _sep, value = body.partition('=')
|
|
146
|
+
if name in names and name not in answers:
|
|
147
|
+
answers[name] = value.strip()
|
|
148
|
+
return answers
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _run_mode(shell, flags, interactive, names, repo_dir, home, user, timeout):
|
|
152
|
+
exe = shell_path(shell)
|
|
153
|
+
mode = f'{shell} {flags}'
|
|
154
|
+
row = {'shell': shell, 'mode': mode, 'interactive': interactive, 'argv': None,
|
|
155
|
+
'results': {}, 'error': None}
|
|
156
|
+
if not exe:
|
|
157
|
+
row['error'] = 'shell not installed'
|
|
158
|
+
return row
|
|
159
|
+
script = _script(shell, names)
|
|
160
|
+
argv = [exe, flags] if interactive else [exe, flags, script]
|
|
161
|
+
row['argv'] = argv
|
|
162
|
+
try:
|
|
163
|
+
text, timed_out, returncode = _run_shell(argv, script if interactive else '',
|
|
164
|
+
_env(home, user, exe), home, timeout)
|
|
165
|
+
except Exception as e: # noqa: BLE001 — one mode must never take down the report
|
|
166
|
+
row['error'] = f'probe could not run: {e!r}'[:200]
|
|
167
|
+
return row
|
|
168
|
+
answers = _parse(text, names)
|
|
169
|
+
missing = [n for n in names if n not in answers]
|
|
170
|
+
if timed_out and missing:
|
|
171
|
+
row['error'] = f'no answer within {timeout}s (an rc file is hanging or prompting)'
|
|
172
|
+
return row
|
|
173
|
+
if not answers:
|
|
174
|
+
row['error'] = (f'shell exited {returncode} without answering '
|
|
175
|
+
'(an rc file exec\'d something, exited early, or read the probe)')
|
|
176
|
+
return row
|
|
177
|
+
for n in names:
|
|
178
|
+
if n in answers:
|
|
179
|
+
resolved = answers[n] or None
|
|
180
|
+
row['results'][n] = {'resolved': resolved, 'ok': is_shim(resolved, repo_dir, n)}
|
|
181
|
+
else:
|
|
182
|
+
row['results'][n] = {'resolved': None, 'ok': None}
|
|
183
|
+
if missing:
|
|
184
|
+
row['error'] = (f'no answer for {", ".join(missing)} — an rc file consumed part of '
|
|
185
|
+
'the probe (a prompt or `read` on stdin?)')
|
|
186
|
+
return row
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_shell(argv, stdin_text, env, cwd, timeout):
|
|
190
|
+
"""(stdout text, timed_out, returncode). stdout goes to a temp FILE, not a pipe:
|
|
191
|
+
a pipe only closes when every process holding it is gone, so an rc line that
|
|
192
|
+
backgrounds something (`ssh-agent`, `tmux new -d`, `cmd &`) would keep a pipe open
|
|
193
|
+
long after the shell answered and exited, and the whole answer would be lost to
|
|
194
|
+
the timeout. The shell gets its own session so a timeout can kill everything it
|
|
195
|
+
started. Whatever was written before a timeout is still returned."""
|
|
196
|
+
with tempfile.TemporaryFile() as out:
|
|
197
|
+
p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, stderr=subprocess.DEVNULL,
|
|
198
|
+
env=env, cwd=cwd if os.path.isdir(cwd) else None,
|
|
199
|
+
start_new_session=True)
|
|
200
|
+
timed_out = False
|
|
201
|
+
try:
|
|
202
|
+
p.communicate(stdin_text.encode('utf-8'), timeout=timeout)
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
timed_out = True
|
|
205
|
+
try:
|
|
206
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
p.wait(timeout=5)
|
|
211
|
+
except subprocess.TimeoutExpired:
|
|
212
|
+
pass
|
|
213
|
+
out.seek(0)
|
|
214
|
+
text = out.read().decode('utf-8', errors='replace')
|
|
215
|
+
return text, timed_out, p.returncode
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def probe(repo_dir, home=None, user=None, names=NAMES, timeout=DEFAULT_TIMEOUT, modes=MODES):
|
|
219
|
+
if not enabled():
|
|
220
|
+
return []
|
|
221
|
+
home = home or os.path.expanduser('~')
|
|
222
|
+
try:
|
|
223
|
+
user = user or getpass.getuser()
|
|
224
|
+
except Exception: # noqa: BLE001 — no passwd entry; the name only seeds $USER
|
|
225
|
+
user = os.environ.get('USER') or 'user'
|
|
226
|
+
with ThreadPoolExecutor(max_workers=len(modes)) as pool:
|
|
227
|
+
return list(pool.map(
|
|
228
|
+
lambda m: _run_mode(m[0], m[1], m[2], names, repo_dir, home, user, timeout), modes))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def bypassed(rows):
|
|
232
|
+
out = []
|
|
233
|
+
for row in rows:
|
|
234
|
+
for name, r in row['results'].items():
|
|
235
|
+
if r['ok'] is False:
|
|
236
|
+
out.append((row['mode'], name, r['resolved']))
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render(rows):
|
|
241
|
+
"""Operator-facing lines. The verdict line is the one that matters; the per-mode
|
|
242
|
+
lines say WHICH shell to open to reproduce it."""
|
|
243
|
+
lines = ['shim on PATH (fresh login shells, this user\'s rc files):']
|
|
244
|
+
if not rows:
|
|
245
|
+
lines.append(' (probe disabled: CLAUDE_MULTIACC_PATH_PROBE=0)')
|
|
246
|
+
return lines
|
|
247
|
+
for row in rows:
|
|
248
|
+
cell = f' {row["mode"]:<8}'
|
|
249
|
+
if row['error']:
|
|
250
|
+
lines.append(f'{cell} ? {row["error"]}')
|
|
251
|
+
continue
|
|
252
|
+
parts = []
|
|
253
|
+
for name, r in row['results'].items():
|
|
254
|
+
if r['ok'] is None:
|
|
255
|
+
parts.append(f'{name}: ?')
|
|
256
|
+
elif r['ok']:
|
|
257
|
+
parts.append(f'{name}: OK')
|
|
258
|
+
elif r['resolved']:
|
|
259
|
+
parts.append(f'{name}: BYPASSED -> {r["resolved"]}')
|
|
260
|
+
else:
|
|
261
|
+
parts.append(f'{name}: NOT ON PATH')
|
|
262
|
+
lines.append(f'{cell} {" ".join(parts)}' + (f' ({row["error"]})' if row['error'] else ''))
|
|
263
|
+
bad = bypassed(rows)
|
|
264
|
+
if bad:
|
|
265
|
+
modes = sorted({m for m, _n, _r in bad})
|
|
266
|
+
lines.append(f' ** {len(modes)} shell mode(s) start `claude`/`codex` OUTSIDE the pool '
|
|
267
|
+
f'({", ".join(modes)}): such a session runs the real binary on the '
|
|
268
|
+
'machine\'s own ~/.claude login — or on no login at all ("Not logged '
|
|
269
|
+
'in · Please run /login"). **')
|
|
270
|
+
lines.append(' fix: claude-multiacc install # rewrites the rc blocks (~/.zshenv, '
|
|
271
|
+
'~/.zprofile, ~/.zshrc, ~/.profile, bash rc files) with the prompt hook')
|
|
272
|
+
lines.append(' then open a new shell (or run: exec "$SHELL" -l) — an already-open '
|
|
273
|
+
'shell keeps the PATH it has')
|
|
274
|
+
unanswered = [row['mode'] for row in rows
|
|
275
|
+
if row['error'] and row['error'] != 'shell not installed' and not row['results']]
|
|
276
|
+
if unanswered:
|
|
277
|
+
lines.append(f' note: {", ".join(unanswered)} could not be probed — open one by hand '
|
|
278
|
+
'and run: command -v claude')
|
|
279
|
+
return lines
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv):
|
|
283
|
+
args = [a for a in argv[1:] if not a.startswith('--')]
|
|
284
|
+
flags = [a for a in argv[1:] if a.startswith('--')]
|
|
285
|
+
if len(args) != 1 or any(f not in ('--json',) and not f.startswith('--timeout=') for f in flags):
|
|
286
|
+
print('usage: shim_path.py <repo-dir> [--json] [--timeout=SECONDS]', file=sys.stderr)
|
|
287
|
+
return 2
|
|
288
|
+
timeout = DEFAULT_TIMEOUT
|
|
289
|
+
for f in flags:
|
|
290
|
+
if f.startswith('--timeout='):
|
|
291
|
+
try:
|
|
292
|
+
timeout = max(1, int(f.split('=', 1)[1]))
|
|
293
|
+
except ValueError:
|
|
294
|
+
print('usage: --timeout takes an integer number of seconds', file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
rows = probe(args[0], timeout=timeout)
|
|
297
|
+
if '--json' in flags:
|
|
298
|
+
print(json.dumps({'repo_dir': args[0], 'enabled': enabled(), 'modes': rows,
|
|
299
|
+
'bypassed': [list(b) for b in bypassed(rows)]}, indent=2))
|
|
300
|
+
else:
|
|
301
|
+
print('\n'.join(render(rows)))
|
|
302
|
+
return 1 if bypassed(rows) else 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == '__main__':
|
|
306
|
+
sys.exit(main(sys.argv))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.33",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
|
-
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py",
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && python3 tests/test_selector.py && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:seeding": "python3 tests/test_account_seeding.py && npm run test:codex-settings",
|
|
61
61
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
62
62
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -292,6 +292,10 @@ export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
|
292
292
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
293
293
|
# them on explicitly against the fake `security` above.
|
|
294
294
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
295
|
+
# `status` probes fresh login shells with $HOME's rc files (lib/shim_path.py). This suite
|
|
296
|
+
# runs under the operator's real HOME, whose rc files are not under test — section 18
|
|
297
|
+
# turns the probe on against its own fake HOME.
|
|
298
|
+
export CLAUDE_MULTIACC_PATH_PROBE=0
|
|
295
299
|
|
|
296
300
|
now="$(date +%s)"
|
|
297
301
|
|
|
@@ -6897,6 +6901,116 @@ grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
|
6897
6901
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
6898
6902
|
unset FAKE_KEYCHAIN_DIR
|
|
6899
6903
|
|
|
6904
|
+
# ---- 18. the rc block shadows `claude` even when the rc file that carries it is cut short ----
|
|
6905
|
+
# 2026-09-17: a Ctrl-C at the slow conda hook in ~/.zshrc stopped the file before the
|
|
6906
|
+
# multiacc block, ~/.local/bin stayed in front, and the next `claude` at that prompt ran
|
|
6907
|
+
# the real binary on ~/.claude — "Not logged in · Please run /login". Every `bash -l` and
|
|
6908
|
+
# `sh -l` on that Mac bypassed the shim too: ~/.profile (read when there is no
|
|
6909
|
+
# ~/.bash_profile) carried no block at all. The block now ends with a prompt hook that
|
|
6910
|
+
# re-asserts the shim dir before every prompt, the installer appends to ~/.profile, and
|
|
6911
|
+
# `status` probes fresh login shells so a bypass is reported instead of guessed at.
|
|
6912
|
+
RCH="$WORK/rc-home"
|
|
6913
|
+
mkdir -p "$RCH/.local/bin"
|
|
6914
|
+
printf '#!/bin/sh\necho REAL\n' > "$RCH/.local/bin/claude"
|
|
6915
|
+
cp "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
6916
|
+
chmod +x "$RCH/.local/bin/claude" "$RCH/.local/bin/codex"
|
|
6917
|
+
rc_block_text() { # the block the installer writes, for THIS repo
|
|
6918
|
+
REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6919
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; rc_block'
|
|
6920
|
+
}
|
|
6921
|
+
rc_block_text > "$WORK/rc-block.sh"
|
|
6922
|
+
for rsh in sh bash zsh; do
|
|
6923
|
+
command -v "$rsh" >/dev/null 2>&1 || continue
|
|
6924
|
+
if "$rsh" -n "$WORK/rc-block.sh" 2>/dev/null; then t_ok "rc block parses under $rsh"
|
|
6925
|
+
else t_fail "rc block parses under $rsh" "$("$rsh" -n "$WORK/rc-block.sh" 2>&1 | head -2)"; fi
|
|
6926
|
+
done
|
|
6927
|
+
grep -q '_claude_multiacc_path_guard' "$WORK/rc-block.sh" \
|
|
6928
|
+
&& t_ok "rc block carries the prompt hook" || t_fail "rc block carries the prompt hook"
|
|
6929
|
+
# bash: the PROMPT_COMMAND hook undoes a later re-prepend, and registers itself once
|
|
6930
|
+
out="$(HOME="$RCH" bash -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; eval "$PROMPT_COMMAND"; printf "%s|%s" "${PATH%%:*}" "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6931
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
6932
|
+
&& t_ok "bash prompt hook re-asserts the shim dir (registered once)" \
|
|
6933
|
+
|| t_fail "bash prompt hook re-asserts the shim dir" "got: $out"
|
|
6934
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6935
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; . "$1"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; print -r -- "${PATH%%:*}|${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6936
|
+
[ "$out" = "$REPO_DIR/bin|_claude_multiacc_path_guard" ] \
|
|
6937
|
+
&& t_ok "zsh precmd hook re-asserts the shim dir (registered once)" \
|
|
6938
|
+
|| t_fail "zsh precmd hook re-asserts the shim dir" "got: $out"
|
|
6939
|
+
fi
|
|
6940
|
+
# ORDER: a hook registered before ours that rewrites PATH (direnv, a venv) must not have
|
|
6941
|
+
# the final word — bash appends after it, zsh moves itself behind hooks ~/.zshrc added.
|
|
6942
|
+
out="$(HOME="$RCH" bash -c 'PROMPT_COMMAND="other_hook"; . "$1"; printf %s "$PROMPT_COMMAND"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6943
|
+
[ "$out" = "other_hook;_claude_multiacc_path_guard" ] \
|
|
6944
|
+
&& t_ok "bash hook is appended after an existing PROMPT_COMMAND" \
|
|
6945
|
+
|| t_fail "bash hook is appended after an existing PROMPT_COMMAND" "got: $out"
|
|
6946
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6947
|
+
out="$(HOME="$RCH" zsh -c '. "$1"; precmd_functions+=(later_hook); . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6948
|
+
[ "$out" = "later_hook,_claude_multiacc_path_guard" ] \
|
|
6949
|
+
&& t_ok "zsh hook re-registers itself LAST when the block is sourced again" \
|
|
6950
|
+
|| t_fail "zsh hook re-registers itself LAST" "got: $out"
|
|
6951
|
+
out="$(HOME="$RCH" zsh -c 'IFS=":"; . "$1"; . "$1"; print -r -- "${(j:,:)precmd_functions}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6952
|
+
[ "$out" = "_claude_multiacc_path_guard" ] \
|
|
6953
|
+
&& t_ok "zsh hook registers once whatever IFS is" \
|
|
6954
|
+
|| t_fail "zsh hook registers once whatever IFS is" "got: $out"
|
|
6955
|
+
fi
|
|
6956
|
+
# the hook is inert once the shim dir is gone: no error, PATH left alone
|
|
6957
|
+
out="$(HOME="$RCH" bash -c 'sed "s|'"$REPO_DIR"'|/nonexistent/multiacc|g" "$1" > "$1.gone"; . "$1.gone"; PATH="$HOME/.local/bin:$PATH"; _claude_multiacc_path_guard; printf "rc=%s %s" "$?" "${PATH%%:*}"' _ "$WORK/rc-block.sh" 2>&1)"
|
|
6958
|
+
[ "$out" = "rc=0 $RCH/.local/bin" ] \
|
|
6959
|
+
&& t_ok "prompt hook is inert when the shim dir is gone" \
|
|
6960
|
+
|| t_fail "prompt hook is inert when the shim dir is gone" "got: $out"
|
|
6961
|
+
# ~/.profile: CREATED when absent (a stock Mac has none — without it every bash/sh login
|
|
6962
|
+
# shell stays bypassed and the probe would fail health with a remedy that does nothing),
|
|
6963
|
+
# appended to exactly once when present, and ~/.bash_profile is never created.
|
|
6964
|
+
rm -f "$RCH/.profile"
|
|
6965
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6966
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
6967
|
+
[ -f "$RCH/.profile" ] && [ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
6968
|
+
&& t_ok "~/.profile is created with the block when absent" \
|
|
6969
|
+
|| t_fail "~/.profile is created with the block when absent"
|
|
6970
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\n' > "$RCH/.profile"
|
|
6971
|
+
for _ in 1 2; do
|
|
6972
|
+
HOME="$RCH" REPO_DIR="$REPO_DIR" MARK_BEGIN='# >>> claude-multiacc >>>' MARK_END='# <<< claude-multiacc <<<' \
|
|
6973
|
+
bash -c '. "$REPO_DIR/lib/install_actions.sh"; install_profile_block' >/dev/null
|
|
6974
|
+
done
|
|
6975
|
+
[ "$(grep -c '# >>> claude-multiacc >>>' "$RCH/.profile")" = "1" ] \
|
|
6976
|
+
&& t_ok "~/.profile gets the block exactly once" || t_fail "~/.profile gets the block exactly once"
|
|
6977
|
+
[ ! -e "$RCH/.bash_profile" ] && t_ok "installer never creates ~/.bash_profile" \
|
|
6978
|
+
|| t_fail "installer never creates ~/.bash_profile" "it exists"
|
|
6979
|
+
for lsh in sh bash; do
|
|
6980
|
+
out="$(env -i HOME="$RCH" USER="${USER:-tester}" TERM=dumb PATH=/usr/bin:/bin "$lsh" -lc 'command -v claude' 2>/dev/null)"
|
|
6981
|
+
[ "$out" = "$REPO_DIR/bin/claude" ] && t_ok "$lsh -l resolves the shim through ~/.profile" \
|
|
6982
|
+
|| t_fail "$lsh -l resolves the shim through ~/.profile" "got: $out"
|
|
6983
|
+
done
|
|
6984
|
+
# the interrupted-rc incident, end to end, through the probe `status` prints
|
|
6985
|
+
if command -v zsh >/dev/null 2>&1; then
|
|
6986
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
6987
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
6988
|
+
printf 'export PATH="$HOME/.local/bin:$PATH"\nreturn 0 # the rest of this rc file was never reached\n' > "$RCH/.zshrc"
|
|
6989
|
+
cat "$WORK/rc-block.sh" >> "$RCH/.zshrc"
|
|
6990
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
6991
|
+
[ "$rc" = 0 ] && case "$out" in *"zsh -li"*"claude: OK"*) t_ok "interrupted ~/.zshrc: the prompt hook still hands claude to the shim" ;; *) t_fail "interrupted ~/.zshrc probe" "$out" ;; esac
|
|
6992
|
+
[ "$rc" = 0 ] || t_fail "interrupted ~/.zshrc probe exit" "rc=$rc: $out"
|
|
6993
|
+
# the same rc files with the OLD two-line block (no hook) are the incident itself
|
|
6994
|
+
for f in .zshenv .zprofile .zshrc; do
|
|
6995
|
+
awk '/^_claude_multiacc_path_guard\(\)/{skip=1} /^# <<< claude-multiacc <<</{skip=0} !skip' "$RCH/$f" > "$RCH/$f.old" && mv "$RCH/$f.old" "$RCH/$f"
|
|
6996
|
+
done
|
|
6997
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 python3 "$REPO_DIR/lib/shim_path.py" "$REPO_DIR" 2>&1)"; rc=$?
|
|
6998
|
+
case "$rc:$out" in 1:*"zsh -li"*"BYPASSED -> $RCH/.local/bin/claude"*) t_ok "the probe catches the pre-fix block (zsh -li bypassed)" ;;
|
|
6999
|
+
*) t_fail "the probe catches the pre-fix block" "rc=$rc: $out" ;; esac
|
|
7000
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7001
|
+
check "status reports the bypass" "BYPASSED -> $RCH/.local/bin/claude" "$out"
|
|
7002
|
+
check "status names the fix" "claude-multiacc install" "$out"
|
|
7003
|
+
# restore the hook: status is clean again
|
|
7004
|
+
cp "$WORK/rc-block.sh" "$RCH/.zshenv"
|
|
7005
|
+
{ printf 'export PATH="$HOME/.local/bin:$PATH"\n'; cat "$WORK/rc-block.sh"; } > "$RCH/.zprofile"
|
|
7006
|
+
out="$(HOME="$RCH" CLAUDE_MULTIACC_PATH_PROBE=1 claude-accounts status 2>&1)"
|
|
7007
|
+
case "$out" in *BYPASSED*|*"NOT ON PATH"*) t_fail "status is clean with the hooked block" "$out" ;;
|
|
7008
|
+
*"shim on PATH"*) t_ok "status is clean with the hooked block" ;;
|
|
7009
|
+
*) t_fail "status prints the shim probe" "$out" ;; esac
|
|
7010
|
+
fi
|
|
7011
|
+
out="$(HOME="$RCH" claude-accounts status 2>&1)"
|
|
7012
|
+
check "status honours CLAUDE_MULTIACC_PATH_PROBE=0" "probe disabled" "$out"
|
|
7013
|
+
|
|
6900
7014
|
# The reset-credit contract is easier to prove against a stateful local HTTP server
|
|
6901
7015
|
# than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
|
|
6902
7016
|
if CODEX_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_codex_reset*.py'; then
|
|
@@ -6,6 +6,7 @@ import plistlib
|
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
8
|
import shutil
|
|
9
|
+
import sys
|
|
9
10
|
import unittest
|
|
10
11
|
|
|
11
12
|
from packaged_command_support import ROOT, install_package, run, seed_pools
|
|
@@ -117,13 +118,46 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
117
118
|
identity.write_text('#!/usr/bin/env bash\necho 0\n')
|
|
118
119
|
uname.chmod(0o755)
|
|
119
120
|
identity.chmod(0o755)
|
|
121
|
+
# bash/sh login shells read ~/.profile when ~/.bash_profile is absent, and a
|
|
122
|
+
# ~/.bashrc exists on most machines: both must end with the block, ~/.bash_profile
|
|
123
|
+
# must NOT be created (its existence would stop bash reading ~/.profile).
|
|
124
|
+
prepend = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
125
|
+
for rc in (".profile", ".bashrc"):
|
|
126
|
+
(self.home / rc).write_text(prepend)
|
|
127
|
+
real_bin = self.home / ".local/bin"
|
|
128
|
+
real_bin.mkdir(parents=True)
|
|
129
|
+
for name in ("claude", "codex"):
|
|
130
|
+
(real_bin / name).write_text('#!/bin/sh\necho REAL\n')
|
|
131
|
+
(real_bin / name).chmod(0o755)
|
|
120
132
|
try:
|
|
121
133
|
for _ in range(2):
|
|
122
134
|
result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
|
|
123
135
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
124
136
|
self.assertNotIn("status |", result.stdout)
|
|
125
|
-
for rc in (".zshenv", ".zprofile", ".zshrc"):
|
|
126
|
-
|
|
137
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
138
|
+
text = (self.home / rc).read_text()
|
|
139
|
+
self.assertEqual(text.count("# >>> claude-multiacc >>>"), 1, rc)
|
|
140
|
+
self.assertEqual(text.count("_claude_multiacc_path_guard() {"), 1, rc)
|
|
141
|
+
self.assertTrue(text.rstrip().endswith("# <<< claude-multiacc <<<"), rc)
|
|
142
|
+
self.assertFalse((self.home / ".bash_profile").exists())
|
|
143
|
+
# ...and fresh login shells of every flavour now resolve the package's shims,
|
|
144
|
+
# the real binaries in ~/.local/bin notwithstanding (lib/shim_path.py is the
|
|
145
|
+
# same probe `claude-accounts status` and `health` print).
|
|
146
|
+
probe_env = {**self.env, "CLAUDE_MULTIACC_PATH_PROBE": "1", "USER": "tester"}
|
|
147
|
+
result = run([sys.executable, str(self.package / "lib/shim_path.py"), str(self.package), "--json"],
|
|
148
|
+
probe_env, self.work)
|
|
149
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
150
|
+
report = json.loads(result.stdout)
|
|
151
|
+
self.assertEqual(report["bypassed"], [])
|
|
152
|
+
answered = [m for m in report["modes"] if m["error"] != "shell not installed"]
|
|
153
|
+
self.assertGreaterEqual(len(answered), 2)
|
|
154
|
+
for mode in answered:
|
|
155
|
+
self.assertIsNone(mode["error"], mode)
|
|
156
|
+
for name in ("claude", "codex"):
|
|
157
|
+
self.assertTrue(mode["results"][name]["ok"], mode)
|
|
158
|
+
status = run(["claude-accounts", "status"], probe_env, self.work)
|
|
159
|
+
self.assertIn("shim on PATH", status.stdout)
|
|
160
|
+
self.assertNotIn("BYPASSED", status.stdout)
|
|
127
161
|
calls = (self.home / "tool-calls").read_text()
|
|
128
162
|
self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
|
|
129
163
|
script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
|
|
@@ -137,7 +171,9 @@ class PackagedCommandsTests(unittest.TestCase):
|
|
|
137
171
|
self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
|
|
138
172
|
result = run(["claude-multiacc", "uninstall"], self.env, self.work)
|
|
139
173
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
140
|
-
|
|
174
|
+
for rc in (".zshenv", ".zprofile", ".zshrc", ".profile", ".bashrc"):
|
|
175
|
+
self.assertNotIn("claude-multiacc", (self.home / rc).read_text(), rc)
|
|
176
|
+
self.assertEqual((self.home / ".profile").read_text(), prepend)
|
|
141
177
|
self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
|
|
142
178
|
uname.write_text('#!/usr/bin/env bash\necho Linux\n')
|
|
143
179
|
(self.home / "tool-calls").write_text("")
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lib/shim_path.py — does a fresh login shell reach the shim?
|
|
3
|
+
|
|
4
|
+
Everything runs against a temp HOME whose rc files this test writes, with a fake shim
|
|
5
|
+
(`repo/bin/claude`, carrying the multiacc-shim header) and a fake real binary in
|
|
6
|
+
`~/.local/bin`. Real shells are started (zsh/bash/sh, whichever are installed), the
|
|
7
|
+
way a terminal or a `bash -lc` launcher starts them; nothing touches the operator's
|
|
8
|
+
rc files, and nothing here needs a Claude Code install.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
|
|
21
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(REPO / 'lib'))
|
|
23
|
+
|
|
24
|
+
import shim_path # noqa: E402
|
|
25
|
+
|
|
26
|
+
MARK_BEGIN = '# >>> claude-multiacc >>>'
|
|
27
|
+
MARK_END = '# <<< claude-multiacc <<<'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def installed_block(repo_dir):
|
|
31
|
+
"""The exact block lib/install_actions.sh writes for repo_dir."""
|
|
32
|
+
env = {**os.environ, 'REPO_DIR': str(repo_dir), 'MARK_BEGIN': MARK_BEGIN, 'MARK_END': MARK_END}
|
|
33
|
+
return subprocess.check_output(
|
|
34
|
+
['bash', '-c', f'. "{REPO}/lib/install_actions.sh"; rc_block'], env=env, text=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def legacy_block(repo_dir):
|
|
38
|
+
"""The block as every install before the prompt hook wrote it."""
|
|
39
|
+
lines = installed_block(repo_dir).splitlines()
|
|
40
|
+
return '\n'.join(lines[:3] + [MARK_END]) + '\n'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ShimPathTests(unittest.TestCase):
|
|
44
|
+
def setUp(self):
|
|
45
|
+
self.tmp = Path(tempfile.mkdtemp(prefix='multiacc-shim-path-'))
|
|
46
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
47
|
+
self.home = self.tmp / 'home'
|
|
48
|
+
self.repo = self.tmp / 'repo'
|
|
49
|
+
(self.home / '.local' / 'bin').mkdir(parents=True)
|
|
50
|
+
(self.repo / 'bin').mkdir(parents=True)
|
|
51
|
+
for name in shim_path.NAMES:
|
|
52
|
+
shim = self.repo / 'bin' / name
|
|
53
|
+
shim.write_text('#!/bin/sh\n# claude-multiacc-shim (fake for tests)\necho SHIM\n')
|
|
54
|
+
shim.chmod(0o755)
|
|
55
|
+
real = self.home / '.local' / 'bin' / name
|
|
56
|
+
real.write_text('#!/bin/sh\necho REAL\n')
|
|
57
|
+
real.chmod(0o755)
|
|
58
|
+
self.shells = [s for s in ('zsh', 'bash', 'sh') if shim_path.shell_path(s)]
|
|
59
|
+
self.assertTrue(self.shells, 'no shell to test with')
|
|
60
|
+
os.environ.pop('CLAUDE_MULTIACC_PATH_PROBE', None)
|
|
61
|
+
|
|
62
|
+
def write(self, name, *parts):
|
|
63
|
+
(self.home / name).write_text(''.join(parts))
|
|
64
|
+
|
|
65
|
+
def probe(self):
|
|
66
|
+
return shim_path.probe(str(self.repo), home=str(self.home), user='tester', timeout=40)
|
|
67
|
+
|
|
68
|
+
def by_mode(self, rows):
|
|
69
|
+
return {row['mode']: row for row in rows}
|
|
70
|
+
|
|
71
|
+
def all_modes(self, rows, name, expect_ok):
|
|
72
|
+
for row in rows:
|
|
73
|
+
if row['error'] == 'shell not installed':
|
|
74
|
+
continue
|
|
75
|
+
with self.subTest(mode=row['mode'], name=name):
|
|
76
|
+
self.assertIsNone(row['error'], row)
|
|
77
|
+
self.assertIs(row['results'][name]['ok'], expect_ok, row)
|
|
78
|
+
|
|
79
|
+
def test_no_rc_files_means_this_repo_is_never_reached(self):
|
|
80
|
+
# The SYSTEM rc files still run (macOS path_helper adds /opt/homebrew/bin; a Linux
|
|
81
|
+
# root install's /etc/profile.d puts ITS shim first), so a real binary — or another
|
|
82
|
+
# install's shim — may be found. What must never be found is this repo's shim.
|
|
83
|
+
rows = self.probe()
|
|
84
|
+
for row in rows:
|
|
85
|
+
if row['error'] == 'shell not installed':
|
|
86
|
+
continue
|
|
87
|
+
self.assertIsNone(row['error'], row)
|
|
88
|
+
for name in shim_path.NAMES:
|
|
89
|
+
self.assertNotEqual(row['results'][name]['resolved'], str(self.repo / 'bin' / name))
|
|
90
|
+
text = '\n'.join(shim_path.render(rows))
|
|
91
|
+
if shim_path.bypassed(rows):
|
|
92
|
+
self.assertRegex(text, r'NOT ON PATH|BYPASSED')
|
|
93
|
+
self.assertIn('claude-multiacc install', text)
|
|
94
|
+
|
|
95
|
+
def test_installed_block_wins_in_every_shell(self):
|
|
96
|
+
block = installed_block(self.repo)
|
|
97
|
+
prepend = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
98
|
+
self.write('.zshenv', block)
|
|
99
|
+
self.write('.zprofile', prepend, block)
|
|
100
|
+
self.write('.zshrc', prepend, block)
|
|
101
|
+
self.write('.profile', prepend, block)
|
|
102
|
+
self.write('.bashrc', prepend, block)
|
|
103
|
+
rows = self.probe()
|
|
104
|
+
for name in shim_path.NAMES:
|
|
105
|
+
self.all_modes(rows, name, True)
|
|
106
|
+
self.assertEqual(shim_path.bypassed(rows), [])
|
|
107
|
+
text = '\n'.join(shim_path.render(rows))
|
|
108
|
+
self.assertNotIn('BYPASSED', text)
|
|
109
|
+
self.assertIn('claude: OK', text)
|
|
110
|
+
|
|
111
|
+
def test_real_binary_prepended_after_the_block_is_reported(self):
|
|
112
|
+
block = installed_block(self.repo)
|
|
113
|
+
late = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
114
|
+
self.write('.zshenv', block, late)
|
|
115
|
+
self.write('.zprofile', block, late)
|
|
116
|
+
self.write('.zshrc', block, late)
|
|
117
|
+
self.write('.profile', block, late)
|
|
118
|
+
rows = self.probe()
|
|
119
|
+
real = str(self.home / '.local' / 'bin' / 'claude')
|
|
120
|
+
# Non-interactive login shells see rc order alone: bypassed.
|
|
121
|
+
for mode, row in self.by_mode(rows).items():
|
|
122
|
+
if row['error'] == 'shell not installed':
|
|
123
|
+
continue
|
|
124
|
+
if mode.endswith('-lc'):
|
|
125
|
+
self.assertEqual(row['results']['claude']['resolved'], real, row)
|
|
126
|
+
self.assertFalse(row['results']['claude']['ok'])
|
|
127
|
+
else:
|
|
128
|
+
# Interactive shells reach a prompt: the hook puts the shim back first.
|
|
129
|
+
self.assertTrue(row['results']['claude']['ok'], row)
|
|
130
|
+
bad = shim_path.bypassed(rows)
|
|
131
|
+
self.assertTrue(bad)
|
|
132
|
+
self.assertTrue(all(mode.endswith('-lc') for mode, _n, _r in bad), bad)
|
|
133
|
+
text = '\n'.join(shim_path.render(rows))
|
|
134
|
+
self.assertIn(f'BYPASSED -> {real}', text)
|
|
135
|
+
self.assertIn('OUTSIDE the pool', text)
|
|
136
|
+
|
|
137
|
+
@unittest.skipUnless(shim_path.shell_path('zsh'), 'zsh required')
|
|
138
|
+
def test_interrupted_zshrc_is_the_incident_and_the_hook_fixes_it(self):
|
|
139
|
+
"""my-mini 2026-09-17: ~/.zshrc stopped (Ctrl-C at the conda hook) before the
|
|
140
|
+
multiacc block, ~/.local/bin stayed in front, `claude` at that prompt ran the
|
|
141
|
+
real binary. With the legacy block the probe must SEE that; with the hooked
|
|
142
|
+
block the prompt must hand `claude` to the shim again."""
|
|
143
|
+
prepend = 'export PATH="$HOME/.local/bin:$PATH"\n'
|
|
144
|
+
for block, expect_ok in ((legacy_block(self.repo), False), (installed_block(self.repo), True)):
|
|
145
|
+
self.write('.zshenv', block)
|
|
146
|
+
self.write('.zprofile', prepend, block)
|
|
147
|
+
self.write('.zshrc', prepend, 'return 0 # interrupted before the block\n', block)
|
|
148
|
+
rows = self.by_mode(self.probe())
|
|
149
|
+
with self.subTest(hooked=expect_ok):
|
|
150
|
+
self.assertIs(rows['zsh -li']['results']['claude']['ok'], expect_ok, rows['zsh -li'])
|
|
151
|
+
# `zsh -lc` never reads ~/.zshrc, so it is fine either way.
|
|
152
|
+
self.assertTrue(rows['zsh -lc']['results']['claude']['ok'], rows['zsh -lc'])
|
|
153
|
+
|
|
154
|
+
def test_a_symlink_to_the_shim_counts_as_the_shim(self):
|
|
155
|
+
link_dir = self.tmp / 'usr-local-bin'
|
|
156
|
+
link_dir.mkdir()
|
|
157
|
+
os.symlink(self.repo / 'bin' / 'claude', link_dir / 'claude')
|
|
158
|
+
self.assertTrue(shim_path.is_shim(str(link_dir / 'claude'), str(self.repo), 'claude'))
|
|
159
|
+
copy = self.tmp / 'copy'
|
|
160
|
+
copy.write_text('#!/bin/sh\n# claude-multiacc-shim copied elsewhere\n')
|
|
161
|
+
self.assertTrue(shim_path.is_shim(str(copy), str(self.repo), 'claude'))
|
|
162
|
+
self.assertFalse(shim_path.is_shim(str(self.home / '.local' / 'bin' / 'claude'),
|
|
163
|
+
str(self.repo), 'claude'))
|
|
164
|
+
self.assertFalse(shim_path.is_shim(None, str(self.repo), 'claude'))
|
|
165
|
+
self.assertFalse(shim_path.is_shim(str(self.tmp / 'missing'), str(self.repo), 'claude'))
|
|
166
|
+
|
|
167
|
+
def test_rc_file_that_hangs_or_exits_is_an_error_not_a_verdict(self):
|
|
168
|
+
self.write('.profile', 'exit 3\n')
|
|
169
|
+
self.write('.zprofile', 'exit 3\n')
|
|
170
|
+
self.write('.zshrc', 'exit 3\n')
|
|
171
|
+
rows = self.by_mode(self.probe())
|
|
172
|
+
for mode in ('bash -lc', 'sh -lc', 'zsh -lc'):
|
|
173
|
+
if mode in rows and rows[mode]['error'] != 'shell not installed':
|
|
174
|
+
self.assertIn('without answering', rows[mode]['error'], rows[mode])
|
|
175
|
+
self.assertEqual(rows[mode]['results'], {})
|
|
176
|
+
self.write('.profile', 'sleep 30\n')
|
|
177
|
+
row = shim_path._run_mode('bash', '-lc', False, shim_path.NAMES, str(self.repo),
|
|
178
|
+
str(self.home), 'tester', timeout=1)
|
|
179
|
+
if row['error'] != 'shell not installed':
|
|
180
|
+
self.assertIn('no answer within 1s', row['error'])
|
|
181
|
+
text = '\n'.join(shim_path.render([row]))
|
|
182
|
+
self.assertIn('could not be probed', text)
|
|
183
|
+
|
|
184
|
+
def test_kill_switch_and_cli_exit_codes(self):
|
|
185
|
+
os.environ['CLAUDE_MULTIACC_PATH_PROBE'] = '0'
|
|
186
|
+
try:
|
|
187
|
+
self.assertEqual(self.probe(), [])
|
|
188
|
+
self.assertIn('probe disabled', '\n'.join(shim_path.render([])))
|
|
189
|
+
finally:
|
|
190
|
+
del os.environ['CLAUDE_MULTIACC_PATH_PROBE']
|
|
191
|
+
env = {**os.environ, 'HOME': str(self.home), 'USER': 'tester'}
|
|
192
|
+
env.pop('CLAUDE_MULTIACC_PATH_PROBE', None)
|
|
193
|
+
cli = [sys.executable, str(REPO / 'lib' / 'shim_path.py')]
|
|
194
|
+
self.assertEqual(subprocess.run(cli, env=env, capture_output=True).returncode, 2)
|
|
195
|
+
self.assertEqual(subprocess.run(cli + [str(self.repo), '--timeout=x'], env=env,
|
|
196
|
+
capture_output=True).returncode, 2)
|
|
197
|
+
# nothing in HOME -> this repo's shim is not reached -> 1 (unless a system-wide
|
|
198
|
+
# rc file on this host puts some other install's shim first, see above)
|
|
199
|
+
r = subprocess.run(cli + [str(self.repo), '--json'], env=env, capture_output=True, text=True)
|
|
200
|
+
import json
|
|
201
|
+
doc = json.loads(r.stdout)
|
|
202
|
+
self.assertEqual(r.returncode, 1 if doc['bypassed'] else 0, r.stderr)
|
|
203
|
+
self.assertEqual(doc['repo_dir'], str(self.repo))
|
|
204
|
+
block = installed_block(self.repo)
|
|
205
|
+
for rc in ('.zshenv', '.zprofile', '.zshrc', '.profile'):
|
|
206
|
+
self.write(rc, block)
|
|
207
|
+
r = subprocess.run(cli + [str(self.repo)], env=env, capture_output=True, text=True)
|
|
208
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
209
|
+
self.assertIn('claude: OK', r.stdout)
|
|
210
|
+
|
|
211
|
+
def test_probe_leaves_shell_history_alone(self):
|
|
212
|
+
block = installed_block(self.repo)
|
|
213
|
+
for rc in ('.zshenv', '.zprofile', '.zshrc', '.profile'):
|
|
214
|
+
self.write(rc, block)
|
|
215
|
+
before = sorted(p.name for p in self.home.iterdir())
|
|
216
|
+
self.probe()
|
|
217
|
+
after = sorted(p.name for p in self.home.iterdir())
|
|
218
|
+
self.assertEqual(before, after)
|
|
219
|
+
for hist in ('.zsh_history', '.bash_history', '.sh_history'):
|
|
220
|
+
self.assertFalse((self.home / hist).exists(), hist)
|
|
221
|
+
|
|
222
|
+
def test_rc_that_reads_stdin_costs_an_answer_not_a_verdict(self):
|
|
223
|
+
"""A prompt or `read` in an rc file eats one line of the fed script. The lost
|
|
224
|
+
answer must show as a row error, never as NOT ON PATH — that would page."""
|
|
225
|
+
block = installed_block(self.repo)
|
|
226
|
+
eater = 'read -r _eaten\n'
|
|
227
|
+
self.write('.zshenv', block)
|
|
228
|
+
self.write('.zprofile', block)
|
|
229
|
+
self.write('.zshrc', block, eater)
|
|
230
|
+
self.write('.profile', block)
|
|
231
|
+
self.write('.bashrc', block, eater)
|
|
232
|
+
rows = self.by_mode(self.probe())
|
|
233
|
+
for mode in ('zsh -li', 'bash -li'):
|
|
234
|
+
row = rows.get(mode)
|
|
235
|
+
if not row or row['error'] == 'shell not installed':
|
|
236
|
+
continue
|
|
237
|
+
with self.subTest(mode=mode):
|
|
238
|
+
oks = [r['ok'] for r in row['results'].values()]
|
|
239
|
+
self.assertNotIn(False, oks, row)
|
|
240
|
+
# the eater swallowed the history line, the sentinel answers survived
|
|
241
|
+
self.assertTrue(row['results'], row)
|
|
242
|
+
self.assertEqual(shim_path.bypassed(list(rows.values())), [])
|
|
243
|
+
# ...and an rc that tries to eat EVERY line: depending on how the shell buffers a
|
|
244
|
+
# piped stdin the probe either still answers or reads as unanswered — either way
|
|
245
|
+
# every name is OK or unknown, never a bypass.
|
|
246
|
+
self.write('.zshrc', block, eater * 6)
|
|
247
|
+
self.write('.bashrc', block, eater * 6)
|
|
248
|
+
rows = self.by_mode(self.probe())
|
|
249
|
+
for mode in ('zsh -li', 'bash -li'):
|
|
250
|
+
row = rows.get(mode)
|
|
251
|
+
if not row or row['error'] == 'shell not installed':
|
|
252
|
+
continue
|
|
253
|
+
if row['results']:
|
|
254
|
+
self.assertNotIn(False, [r['ok'] for r in row['results'].values()], row)
|
|
255
|
+
else:
|
|
256
|
+
self.assertIn('without answering', row['error'], row)
|
|
257
|
+
self.assertEqual(shim_path.bypassed(list(rows.values())), [])
|
|
258
|
+
|
|
259
|
+
def test_rc_noise_does_not_break_the_report(self):
|
|
260
|
+
block = installed_block(self.repo)
|
|
261
|
+
noise = ("printf 'claude=/nowhere\\n'\n" # a bare line that looks like an answer
|
|
262
|
+
"printf '\\xff\\xfe not utf-8 \\xc3\\n'\n" # a latin-1 banner
|
|
263
|
+
"sleep 3 &\n") # a child holding stdout open
|
|
264
|
+
for rc in ('.zshenv', '.zprofile', '.zshrc', '.profile'):
|
|
265
|
+
self.write(rc, noise, block)
|
|
266
|
+
started = __import__('time').monotonic()
|
|
267
|
+
rows = shim_path.probe(str(self.repo), home=str(self.home), user='tester', timeout=20)
|
|
268
|
+
elapsed = __import__('time').monotonic() - started
|
|
269
|
+
for name in shim_path.NAMES:
|
|
270
|
+
self.all_modes(rows, name, True)
|
|
271
|
+
self.assertEqual(shim_path.bypassed(rows), [])
|
|
272
|
+
# the backgrounded child must not have cost the timeout
|
|
273
|
+
self.assertLess(elapsed, 15, elapsed)
|
|
274
|
+
|
|
275
|
+
def test_sh_alias_in_profile_is_not_a_bypass(self):
|
|
276
|
+
block = installed_block(self.repo)
|
|
277
|
+
self.write('.profile', block, "alias claude='claude --dangerously-skip-permissions'\n")
|
|
278
|
+
rows = self.by_mode(self.probe())
|
|
279
|
+
row = rows.get('sh -lc')
|
|
280
|
+
if row and row['error'] != 'shell not installed':
|
|
281
|
+
self.assertTrue(row['results']['claude']['ok'], row)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == '__main__':
|
|
285
|
+
unittest.main()
|