Skip to content

Commit b9efe4c

Browse files
committed
fix: avoid empty claude launch argv entries
1 parent 0c2c257 commit b9efe4c

2 files changed

Lines changed: 40 additions & 8 deletions

File tree

Resources/bin/claude

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,12 @@ normalize_node_options_for_restore() {
191191
}
192192

193193
encode_launch_argv() {
194-
{ printf '%s\0' "$REAL_CLAUDE"; printf '%s\0' "$@"; } | base64 | tr -d '\n'
194+
{
195+
printf '%s\0' "$REAL_CLAUDE"
196+
if (( $# > 0 )); then
197+
printf '%s\0' "$@"
198+
fi
199+
} | base64 | tr -d '\n'
195200
}
196201

197202
# Pass through subcommands that don't support session/hook flags.

tests/test_claude_wrapper_hooks.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import base64
89
import json
910
import os
1011
import 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"
7577
printf '%s\\n' "${CLAUDECODE-__UNSET__}" > "$FAKE_REAL_CLAUDECODE_LOG"
7678
printf '%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"
7780
printf '%s\\n' "${CMUX_CLAUDE_HOOK_CMUX_BIN-__UNSET__}" > "$FAKE_HOOK_CMUX_BIN_LOG"
7881
for 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+
211226
def 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+
276302
def 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

348374
def 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

363389
def 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:
383409
def 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

Comments
 (0)