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 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-file block) /root/claude-multiacc/ (addon repo)
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
 
@@ -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
@@ -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 if present) — end-of-file placement matters because those files
82
- re-prepend `~/.local/bin`; launchd agents `com.claude-multiacc.limits` +
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
- shim symlinks at `/usr/local/bin/claude` and `/usr/local/bin/codex` (shadow via
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.
@@ -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** — open a new shell, or check
26
- that the marked block is the LAST PATH manipulation in your rc file
27
- (`grep -A2 'claude-multiacc >>>' ~/.zshrc`).
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).
@@ -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
- printf '%s\n' "$MARK_BEGIN"
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
@@ -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.32",
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"
@@ -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
- self.assertEqual((self.home / rc).read_text().count("# >>> claude-multiacc >>>"), 1)
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
- self.assertNotIn("claude-multiacc", (self.home / ".zshrc").read_text())
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()