claude-multiacc 2.0.31 → 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.
@@ -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()