55
66from __future__ import annotations
77
8+ import base64
89import json
910import os
1011import shutil
@@ -44,7 +45,7 @@ def run_wrapper(
4445 argv : list [str ],
4546 node_options : str | None = None ,
4647 tmpdir : str | None = None ,
47- ) -> tuple [int , list [str ], list [str ], str , str , str , str , str , str ]:
48+ ) -> tuple [int , list [str ], list [str ], str , str , str , str , str , str , str ]:
4849 with tempfile .TemporaryDirectory (prefix = "cmux-claude-wrapper-test-" ) as td :
4950 tmp = Path (td )
5051 wrapper_dir = tmp / "wrapper-bin"
@@ -63,6 +64,7 @@ def run_wrapper(
6364 real_node_options_log = tmp / "real-node-options.log"
6465 real_runtime_node_options_log = tmp / "real-runtime-node-options.log"
6566 real_child_node_options_log = tmp / "real-child-node-options.log"
67+ real_launch_argv_b64_log = tmp / "real-launch-argv-b64.log"
6668 hook_cmux_bin_log = tmp / "hook-cmux-bin.log"
6769 cmux_log = tmp / "cmux.log"
6870 socket_path = str (tmp / "cmux.sock" )
@@ -74,6 +76,7 @@ def run_wrapper(
7476: > "$FAKE_REAL_ARGS_LOG"
7577printf '%s\\ n' "${CLAUDECODE-__UNSET__}" > "$FAKE_REAL_CLAUDECODE_LOG"
7678printf '%s\\ n' "${NODE_OPTIONS-__UNSET__}" > "$FAKE_REAL_NODE_OPTIONS_LOG"
79+ printf '%s\\ n' "${CMUX_AGENT_LAUNCH_ARGV_B64-__UNSET__}" > "$FAKE_REAL_LAUNCH_ARGV_B64_LOG"
7780printf '%s\\ n' "${CMUX_CLAUDE_HOOK_CMUX_BIN-__UNSET__}" > "$FAKE_HOOK_CMUX_BIN_LOG"
7881for arg in "$@"; do
7982 printf '%s\\ n' "$arg" >> "$FAKE_REAL_ARGS_LOG"
@@ -155,6 +158,7 @@ def run_wrapper(
155158 env ["FAKE_REAL_NODE_OPTIONS_LOG" ] = str (real_node_options_log )
156159 env ["FAKE_REAL_RUNTIME_NODE_OPTIONS_LOG" ] = str (real_runtime_node_options_log )
157160 env ["FAKE_REAL_CHILD_NODE_OPTIONS_LOG" ] = str (real_child_node_options_log )
161+ env ["FAKE_REAL_LAUNCH_ARGV_B64_LOG" ] = str (real_launch_argv_b64_log )
158162 env ["FAKE_REAL_NODE_SCRIPT" ] = str (real_dir / "claude-real.js" )
159163 env ["FAKE_HOOK_CMUX_BIN_LOG" ] = str (hook_cmux_bin_log )
160164 env ["FAKE_CMUX_LOG" ] = str (cmux_log )
@@ -182,6 +186,7 @@ def run_wrapper(
182186
183187 claudecode_lines = read_lines (real_claudecode_log )
184188 hook_cmux_bin_lines = read_lines (hook_cmux_bin_log )
189+ launch_argv_b64_lines = read_lines (real_launch_argv_b64_log )
185190 claudecode_value = claudecode_lines [0 ] if claudecode_lines else ""
186191 node_options_lines = read_lines (real_node_options_log )
187192 node_options_value = node_options_lines [0 ] if node_options_lines else ""
@@ -190,6 +195,7 @@ def run_wrapper(
190195 child_node_options_lines = read_lines (real_child_node_options_log )
191196 child_node_options_value = child_node_options_lines [0 ] if child_node_options_lines else ""
192197 hook_cmux_bin_value = hook_cmux_bin_lines [0 ] if hook_cmux_bin_lines else ""
198+ launch_argv_b64_value = launch_argv_b64_lines [0 ] if launch_argv_b64_lines else ""
193199 return (
194200 proc .returncode ,
195201 read_lines (real_args_log ),
@@ -200,6 +206,7 @@ def run_wrapper(
200206 runtime_node_options_value ,
201207 child_node_options_value ,
202208 hook_cmux_bin_value ,
209+ launch_argv_b64_value ,
203210 )
204211
205212
@@ -208,8 +215,16 @@ def expect(condition: bool, message: str, failures: list[str]) -> None:
208215 failures .append (message )
209216
210217
218+ def decode_nul_argv (encoded : str ) -> list [str ]:
219+ raw = base64 .b64decode (encoded )
220+ parts = raw .split (b"\0 " )
221+ if parts and parts [- 1 ] == b"" :
222+ parts = parts [:- 1 ]
223+ return [part .decode ("utf-8" ) for part in parts ]
224+
225+
211226def test_live_socket_injects_supported_hooks (failures : list [str ]) -> None :
212- code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin = run_wrapper (
227+ code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin , _ = run_wrapper (
213228 socket_state = "live" ,
214229 argv = ["hello" ],
215230 )
@@ -273,10 +288,21 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None:
273288 )
274289
275290
291+ def test_plain_claude_launch_argv_has_no_empty_argument (failures : list [str ]) -> None :
292+ code , _ , _ , stderr , _ , _ , _ , _ , _ , launch_argv_b64 = run_wrapper (
293+ socket_state = "live" ,
294+ argv = [],
295+ )
296+ expect (code == 0 , f"plain claude: wrapper exited { code } : { stderr } " , failures )
297+ argv = decode_nul_argv (launch_argv_b64 )
298+ expect (len (argv ) == 1 , f"plain claude: expected only executable in encoded launch argv, got { argv } " , failures )
299+ expect (argv [0 ].endswith ("/real-bin/claude" ), f"plain claude: expected real claude executable, got { argv } " , failures )
300+
301+
276302def test_live_socket_enforces_heap_cap_for_space_separated_flag (failures : list [str ]) -> None :
277303 existing = "--max-old-space-size 2048 --trace-warnings"
278304 restored = "--max-old-space-size=2048 --trace-warnings"
279- code , _ , _ , stderr , _ , node_options , runtime_node_options , child_node_options , _ = run_wrapper (
305+ code , _ , _ , stderr , _ , node_options , runtime_node_options , child_node_options , _ , _ = run_wrapper (
280306 socket_state = "live" ,
281307 argv = ["hello" ],
282308 node_options = existing ,
@@ -302,7 +328,7 @@ def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[
302328 with tempfile .TemporaryDirectory (prefix = "cmux-claude-wrapper-bad-tmp-" ) as td :
303329 bad_tmpdir = Path (td ) / "not-a-directory"
304330 bad_tmpdir .write_text ("occupied" , encoding = "utf-8" )
305- code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , _ = run_wrapper (
331+ code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , _ , _ = run_wrapper (
306332 socket_state = "live" ,
307333 argv = ["hello" ],
308334 tmpdir = str (bad_tmpdir ),
@@ -323,7 +349,7 @@ def test_live_socket_stale_mktemp_literal_does_not_warn(failures: list[str]) ->
323349 guard_dir = tmpdir / "cmux-claude-node-options"
324350 guard_dir .mkdir (parents = True , exist_ok = True )
325351 (guard_dir / "restore-node-options.XXXXXX.cjs" ).write_text ("stale" , encoding = "utf-8" )
326- code , _ , _ , stderr , _ , node_options , runtime_node_options , child_node_options , _ = run_wrapper (
352+ code , _ , _ , stderr , _ , node_options , runtime_node_options , child_node_options , _ , _ = run_wrapper (
327353 socket_state = "live" ,
328354 argv = ["hello" ],
329355 tmpdir = str (tmpdir ),
@@ -346,7 +372,7 @@ def test_live_socket_stale_mktemp_literal_does_not_warn(failures: list[str]) ->
346372
347373
348374def test_missing_socket_skips_hook_injection (failures : list [str ]) -> None :
349- code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin = run_wrapper (
375+ code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin , _ = run_wrapper (
350376 socket_state = "missing" ,
351377 argv = ["hello" ],
352378 )
@@ -361,7 +387,7 @@ def test_missing_socket_skips_hook_injection(failures: list[str]) -> None:
361387
362388
363389def test_stale_socket_skips_hook_injection (failures : list [str ]) -> None :
364- code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin = run_wrapper (
390+ code , real_argv , cmux_log , stderr , claudecode , node_options , runtime_node_options , child_node_options , hook_cmux_bin , _ = run_wrapper (
365391 socket_state = "stale" ,
366392 argv = ["hello" ],
367393 )
@@ -383,6 +409,7 @@ def test_stale_socket_skips_hook_injection(failures: list[str]) -> None:
383409def main () -> int :
384410 failures : list [str ] = []
385411 test_live_socket_injects_supported_hooks (failures )
412+ test_plain_claude_launch_argv_has_no_empty_argument (failures )
386413 test_live_socket_enforces_heap_cap_for_space_separated_flag (failures )
387414 test_live_socket_tmpdir_failure_skips_node_options_injection (failures )
388415 test_live_socket_stale_mktemp_literal_does_not_warn (failures )
0 commit comments