From dc80ba8778a4a0e73f4aef19264e7f5f65c105c1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 16 Aug 2026 15:15:24 -0700 Subject: [PATCH 1/7] test: reproduce Claude wrapper settings re-entry loop --- tests/test_claude_wrapper_hooks.py | 64 +++++++- tests/test_claude_wrapper_mutual_shim_loop.py | 141 ++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index ed53f75cbd15..b08574c0e20d 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -38,7 +38,10 @@ def parse_settings_arg(argv: list[str]) -> dict: index = argv.index("--settings") if index + 1 >= len(argv): return {} - return json.loads(argv[index + 1]) + value = argv[index + 1] + if value.lstrip().startswith(("{", "[")): + return json.loads(value) + return json.loads(Path(value).read_text(encoding="utf-8")) def run_wrapper( @@ -48,6 +51,7 @@ def run_wrapper( node_options: str | None = None, tmpdir: str | None = None, hooks_disabled: bool = False, + process_timeout: float | None = None, ) -> tuple[int, list[str], list[str], str, str, str, str, str, str, str]: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-test-") as td: tmp = Path(td) @@ -192,6 +196,7 @@ def run_wrapper( if node_options is not None: env["NODE_OPTIONS"] = node_options + timed_out = False try: proc = subprocess.run( [str(wrapper), *argv], @@ -200,6 +205,15 @@ def run_wrapper( capture_output=True, text=True, check=False, + timeout=process_timeout, + ) + except subprocess.TimeoutExpired as exc: + timed_out = True + proc = subprocess.CompletedProcess( + [str(wrapper), *argv], + 124, + stdout=exc.stdout or "", + stderr=exc.stderr or "", ) finally: if test_socket is not None: @@ -217,11 +231,14 @@ def run_wrapper( child_node_options_value = child_node_options_lines[0] if child_node_options_lines else "" hook_cmux_bin_value = hook_cmux_bin_lines[0] if hook_cmux_bin_lines else "" launch_argv_b64_value = launch_argv_b64_lines[0] if launch_argv_b64_lines else "" + stderr = proc.stderr.strip() + if timed_out: + stderr = f"timed out after {process_timeout}s: {stderr}".strip() return ( proc.returncode, read_lines(real_args_log), read_lines(cmux_log), - proc.stderr.strip(), + stderr, claudecode_value, node_options_value, runtime_node_options_value, @@ -623,6 +640,32 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: ) +def test_live_socket_handoff_uses_a_settings_file(failures: list[str]) -> None: + code, real_argv, _cmux_log, stderr, *_ = run_wrapper( + socket_state="live", + argv=["hello"], + ) + expect(code == 0, f"file handoff: wrapper exited {code}: {stderr}", failures) + if "--settings" not in real_argv: + failures.append(f"file handoff: missing --settings in args: {real_argv}") + return + settings_value = real_argv[real_argv.index("--settings") + 1] + expect( + not settings_value.lstrip().startswith(("{", "[")), + f"file handoff: expected a path rather than inline JSON, got {settings_value[:80]!r}", + failures, + ) + try: + settings_path_exists = Path(settings_value).is_file() + except OSError: + settings_path_exists = False + expect( + settings_path_exists, + f"file handoff: settings path was not readable: {settings_value!r}", + failures, + ) + + def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> None: code, real_argv, _cmux_log, stderr, *_ = run_wrapper( socket_state="live", @@ -837,6 +880,21 @@ def test_live_socket_empty_settings_warns_instead_of_silent_drop(failures: list[ ) +def test_large_settings_argument_is_rejected_without_hanging(failures: list[str]) -> None: + large_settings = '{"large":"' + ("x" * 300_000) + '"}' + code, _real_argv, _cmux_log, stderr, *_ = run_wrapper( + socket_state="live", + argv=["--settings", large_settings, "hello"], + process_timeout=2, + ) + expect(code != 124, f"large settings: wrapper pinned the test process: {stderr!r}", failures) + expect( + "argument" in stderr.lower() and "large" in stderr.lower(), + f"large settings: expected a clear argument-size error, got {stderr!r}", + failures, + ) + + def test_plain_claude_launch_argv_has_no_empty_argument(failures: list[str]) -> None: code, _, _, stderr, _, _, _, _, _, launch_argv_b64 = run_wrapper( socket_state="live", @@ -1960,6 +2018,7 @@ def main() -> int: return 0 failures: list[str] = [] test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures) + test_live_socket_handoff_uses_a_settings_file(failures) test_live_socket_merges_user_settings_into_hooks(failures) test_live_socket_merges_inline_settings_form(failures) test_live_socket_repeated_settings_user_value_wins_conflict(failures) @@ -1967,6 +2026,7 @@ def main() -> int: test_live_socket_invalid_settings_warns_and_falls_back(failures) test_live_socket_merges_settings_file_form(failures) test_live_socket_empty_settings_warns_instead_of_silent_drop(failures) + test_large_settings_argument_is_rejected_without_hanging(failures) test_plain_claude_launch_argv_has_no_empty_argument(failures) test_command_like_invocations_bypass_hook_injection(failures) test_hidden_attach_subcommand_bypasses_hook_injection(failures) diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py index 134bfd638459..c91e409e6511 100644 --- a/tests/test_claude_wrapper_mutual_shim_loop.py +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -947,6 +947,146 @@ def test_interactive_spawning_shim_chain_refreshes_claude_pid(failures: list[str failures.append(f"expected one hook injection after spawning shim chain, got: {settings!r}") +def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str]) -> None: + """A launcher that re-enters the cmux shim once must not duplicate hooks.""" + node_path = ensure_node_on_path() + if node_path is None: + failures.append("issue #10230 re-entry requires a Node runtime") + return + with tempfile.TemporaryDirectory(prefix="cmux-claude-issue-10230-reentry-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-10230" + launcher_dir = root / "launcher" + custom_dir = root / "custom" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, launcher_dir, custom_dir, real_dir): + directory.mkdir(parents=True, exist_ok=True) + + cmux_shim = cmux_shim_dir / "claude" + shutil.copy2(WRAPPER, cmux_shim) + cmux_shim.chmod(0o755) + + cmux_bin = cmux_shim_dir / "cmux" + write_executable( + cmux_bin, + """#!/usr/bin/env bash +if [[ "${1:-}" == "--socket" ]]; then + shift 2 +fi +if [[ "${1:-}" == "ping" ]]; then + exit 0 +fi +exit 0 +""", + ) + + launch_count = root / "launcher-count" + managed_path = ( + f"{cmux_shim_dir}:{launcher_dir}:{real_dir}:" + f"{Path(node_path).parent}:/usr/bin:/bin" + ) + write_executable( + launcher_dir / "resolve-claude", + f"""#!/usr/bin/env node +const fs = require("node:fs"); +const {{ spawnSync }} = require("node:child_process"); +const countPath = {json.dumps(str(launch_count))}; +const firstHop = !fs.existsSync(countPath); +if (firstHop) fs.writeFileSync(countPath, "1"); +// The first lookup intentionally restores the managed path to reproduce a +// downstream launcher that does not know about cmux's shim directory. +process.env.PATH = firstHop + ? {json.dumps(managed_path)} + : process.env.PATH.split(":").filter((entry) => !entry.includes("/cmux-cli-shims/")).join(":"); +const entries = (process.env.PATH || "").split(":"); +const target = entries.map((entry) => `${{entry}}/claude`).find((candidate) => + fs.existsSync(candidate) && fs.statSync(candidate).isFile() +); +if (!target) process.exit(127); +const child = spawnSync(target, process.argv.slice(2), {{ env: process.env, encoding: "utf8" }}); +process.stdout.write(child.stdout || ""); +process.stderr.write(child.stderr || ""); +process.exit(child.status ?? 1); +""", + ) + + custom_path = custom_dir / "claude-launcher" + write_executable( + custom_path, + f"""#!/usr/bin/env bash +exec {json.dumps(str(launcher_dir / "resolve-claude"))} claude "$@" +""", + ) + + settings_output = root / "settings-output.json" + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +set -euo pipefail +settings_path="" +while (( $# > 0 )); do + if [[ "$1" == "--settings" && $# -gt 1 ]]; then + settings_path="$2" + shift 2 + continue + fi + shift +done +[[ -n "$settings_path" ]] && cp "$settings_path" "$FAKE_SETTINGS_OUTPUT" +""", + ) + + socket_path = root / "cmux.sock" + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + test_socket.bind(str(socket_path)) + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{launcher_dir}:{real_dir}:{inherited_path}", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + "CMUX_CUSTOM_CLAUDE_PATH": str(custom_path), + "CMUX_SURFACE_ID": "surface-10230", + "CMUX_SOCKET_PATH": str(socket_path), + "CMUX_BUNDLED_CLI_PATH": str(cmux_bin), + "FAKE_SETTINGS_OUTPUT": str(settings_output), + } + result = subprocess.run( + [str(cmux_shim), "hello"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append("issue #10230 re-entry timed out instead of converging") + return + finally: + test_socket.close() + + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"issue #10230 re-entry failed with {result.returncode}: {combined_output!r}") + return + if not settings_output.is_file(): + failures.append(f"issue #10230 re-entry never reached the real Claude binary: {combined_output!r}") + return + try: + settings = json.loads(settings_output.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + failures.append(f"issue #10230 emitted invalid settings JSON: {exc}") + return + hooks = settings.get("hooks", {}) + if len(hooks.get("SessionStart", [])) != 1 or len(hooks.get("Stop", [])) != 3: + failures.append( + "issue #10230 re-entry should converge to one cmux hook block, " + f"got SessionStart={len(hooks.get('SessionStart', []))} " + f"Stop={len(hooks.get('Stop', []))}: {settings!r}" + ) + + def main() -> int: if ensure_node_on_path() is None: print("SKIP: node runtime not found; shim fakes exec node") @@ -964,6 +1104,7 @@ def main() -> int: test_custom_shell_wrapper_execing_real_claude_allows_child_claude(failures) test_interactive_finite_shim_chain_does_not_duplicate_hooks(failures) test_interactive_spawning_shim_chain_refreshes_claude_pid(failures) + test_custom_path_reentry_converges_to_one_settings_block(failures) if failures: print("FAIL: claude wrapper mutual shim loop checks failed") for failure in failures: From 6b140ed7bc63bd3c51d035d4200337fad80e34d1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 15:32:43 -0700 Subject: [PATCH 2/7] fix: make Claude wrapper settings re-entry bounded --- Resources/bin/cmux-claude-wrapper | 441 +++++++++++++++--- tests/test_claude_wrapper_hooks.py | 53 +++ tests/test_claude_wrapper_mutual_shim_loop.py | 26 +- 3 files changed, 440 insertions(+), 80 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 1e96f93826c5..28845264e828 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -39,6 +39,110 @@ cmux_claude_wrapper_reexec_hops=0 # legally contain ':' even though PATH entries cannot. cmux_claude_wrapper_reexec_target_history="" +# Keep argument inspection bounded. Apart from protecting the shell's pattern +# matcher, this prevents a caller from turning the wrapper's own launch metadata +# into another ARG_MAX-sized environment value. +cmux_claude_wrapper_max_argument_bytes=262144 + +cmux_claude_wrapper_validate_arguments() { + local arg + for arg in "$@"; do + # Bash's character count is sufficient for the ASCII/JSON command-line + # values handled here and avoids invoking a subprocess for every arg. + if (( ${#arg} > cmux_claude_wrapper_max_argument_bytes )); then + printf 'cmux: argument too large (maximum %s bytes)\n' \ + "$cmux_claude_wrapper_max_argument_bytes" >&2 + return 2 + fi + done + return 0 +} + +cmux_claude_wrapper_settings_value_is_managed() { + local value="$1" + local trimmed="${value#"${value%%[![:space:]]*}"}" + if [[ "$trimmed" == \{* || "$trimmed" == \[* ]]; then + case "$trimmed" in + *'"__cmux":{"managed":"claude-hooks"'*|*'hooks feed --source claude'*) + return 0 + ;; + esac + return 1 + fi + [[ -f "$trimmed" ]] || return 1 + grep -Fq '"__cmux":{"managed":"claude-hooks"' "$trimmed" 2>/dev/null && return 0 + grep -Fq 'hooks feed --source claude' "$trimmed" 2>/dev/null +} + +cmux_claude_wrapper_args_have_managed_settings() { + local collect=false + local arg + for arg in "$@"; do + if [[ "$collect" == true ]]; then + cmux_claude_wrapper_settings_value_is_managed "$arg" && return 0 + collect=false + continue + fi + if [[ "$arg" == "--settings" ]]; then + collect=true + elif [[ "${arg:0:11}" == "--settings=" ]]; then + cmux_claude_wrapper_settings_value_is_managed "${arg#--settings=}" && return 0 + fi + done + return 1 +} + +cmux_claude_wrapper_path_without_shims() { + local current_path="${PATH:-}" + local shim_root="${CMUX_CLAUDE_WRAPPER_SHIM_ROOT:-}" + local old_ifs="$IFS" + local entry + local result="" + local globbing_was_disabled=0 + + case "$-" in + *f*) globbing_was_disabled=1 ;; + *) set -f ;; + esac + IFS=: + for entry in ${current_path:-}; do + if [[ "$entry" == "$shim_root" || + "$entry" == */cmux-cli-shims/* || + "$entry" == */cmux-cli-shims ]]; then + continue + fi + if [[ -z "$result" ]]; then + result="$entry" + else + result="$result:$entry" + fi + done + IFS="$old_ifs" + if (( globbing_was_disabled == 0 )); then + set +f + fi + printf '%s' "$result" +} + +cmux_claude_wrapper_target_is_custom_path() { + local target="$1" + local custom="${CMUX_CUSTOM_CLAUDE_PATH:-}" + custom="${custom#"${custom%%[![:space:]]*}"}" + custom="${custom%"${custom##*[![:space:]]}"}" + [[ -n "$custom" && -n "$target" ]] || return 1 + [[ "$target" == "$custom" ]] && return 0 + [[ -e "$target" && -e "$custom" && "$target" -ef "$custom" ]] +} + +cmux_claude_wrapper_prepare_custom_path_environment() { + local target="$1" + [[ "${IN_CMUX:-0}" == "1" ]] || return 0 + cmux_claude_wrapper_target_is_custom_path "$target" || return 0 + local cleaned_path + cleaned_path="$(cmux_claude_wrapper_path_without_shims)" + export PATH="$cleaned_path" +} + cmux_claude_wrapper_is_nonnegative_integer() { case "$1" in ''|*[!0-9]*) @@ -88,6 +192,25 @@ cmux_claude_wrapper_reexec_target_seen() { return 1 } +cmux_claude_wrapper_custom_target_is_direct_absolute_claude() { + local snippet="$1" + local line rest token + while IFS= read -r line; do + case "$line" in + *exec\ *) + rest="${line#*exec }" + rest="${rest#\"}" + rest="${rest#\'}" + token="${rest%%[[:space:]]*}" + token="${token%\"}" + token="${token%\'}" + [[ "$token" == */claude ]] && return 0 + ;; + esac + done <<< "$snippet" + return 1 +} + cmux_claude_wrapper_target_looks_like_reexec_shim() { local target="$1" cmux_claude_wrapper_is_self_or_shim "$target" && return 0 @@ -140,29 +263,90 @@ cmux_claude_wrapper_target_looks_like_reexec_shim() { return 1 ;; esac - case "$first_line" in - *node*|*Node*) - return 1 - ;; - esac + if ! cmux_claude_wrapper_target_is_custom_path "$target"; then + case "$first_line" in + *node*|*Node*) + return 1 + ;; + esac + fi + + # A configured launcher can delegate through another executable (for + # example `exec some-launcher claude "$@"`) without matching the finite + # command forms above. Treat a bare Claude token in that launcher as a + # possible re-entry, but keep direct absolute `/.../claude` launchers on + # the real-binary side of the boundary. + if [[ "${IN_CMUX:-0}" == "1" ]] && cmux_claude_wrapper_target_is_custom_path "$target"; then + cmux_claude_wrapper_custom_target_is_direct_absolute_claude "$snippet" && return 1 + local launcher_line + while IFS= read -r launcher_line; do + if [[ "$launcher_line" == *'exec "'*'/claude" '* || + "$launcher_line" == *'exec '*/claude' '* ]]; then + continue + fi + case "$launcher_line" in + *'exec '*claude*|*'spawn'*claude*|*'which'*claude*|*'command -v '*claude*) + return 0 + ;; + esac + done <<< "$snippet" + + # A configured script is a launcher boundary unless it is an explicit + # absolute Claude executable. Keep the re-entry counter across generic + # launcher syntax (`exec "$LAUNCHER"`, `env`, etc.) so an indirect PATH + # lookup cannot reset it between wrapper passes. + case "$snippet" in + *'exec '*|*'command '*|*'spawn'*|*'which '*) + return 0 + ;; + esac + fi return 1 } +cmux_claude_wrapper_exec_real_after_managed_reentry() { + local previous_custom_path="${CMUX_CUSTOM_CLAUDE_PATH:-}" + unset CMUX_CUSTOM_CLAUDE_PATH + local fallback + fallback="$(find_real_claude)" + if [[ -n "$previous_custom_path" ]]; then + export CMUX_CUSTOM_CLAUDE_PATH="$previous_custom_path" + fi + [[ -n "$fallback" ]] || cmux_claude_wrapper_fail_reexec_guard + cmux_claude_wrapper_target_looks_like_reexec_shim "$fallback" && + cmux_claude_wrapper_fail_reexec_guard + unset CMUX_AGENT_RESTORE_LAUNCH + unset CMUX_CLAUDE_TEAMS_WRAPPER_LAUNCH + unset cmux_claude_wrapper_reexec_guard + unset cmux_claude_wrapper_reexec_targets + exec "$fallback" "$@" +} + cmux_claude_wrapper_exec_resolved_claude() { local target="$1" shift if ! cmux_claude_wrapper_target_looks_like_reexec_shim "$target"; then # A non-shim target is the boundary where a real Claude process starts. - # Clear the shim-bounce guard so real Claude children that invoke - # `claude` later begin a fresh wrapper resolution. + # Custom launcher scripts that can delegate to Claude are classified as + # re-entry shims above, so this reset is only reached for a proven real + # boundary. Real Claude children that invoke `claude` later can therefore + # begin a fresh wrapper resolution without inheriting the hop counter. unset CMUX_AGENT_RESTORE_LAUNCH unset CMUX_CLAUDE_TEAMS_WRAPPER_LAUNCH unset cmux_claude_wrapper_reexec_guard unset cmux_claude_wrapper_reexec_targets + # A configured Claude path is often a launcher script rather than the + # Claude executable itself. It must not inherit cmux's PATH shim: a + # downstream `which`/execvp/command -v lookup would otherwise route back + # into this wrapper after the guard was cleared. + cmux_claude_wrapper_prepare_custom_path_environment "$target" exec "$target" "$@" fi if cmux_claude_wrapper_reexec_target_seen "$target"; then + if cmux_claude_wrapper_args_have_managed_settings "$@"; then + cmux_claude_wrapper_exec_real_after_managed_reentry "$@" + fi cmux_claude_wrapper_fail_reexec_guard "$target" fi if (( cmux_claude_wrapper_reexec_hops >= cmux_claude_wrapper_reexec_guard_limit )); then @@ -190,6 +374,7 @@ cmux_claude_wrapper_exec_resolved_claude() { # hops. The final real-Claude boundary above consumes it. export CMUX_CLAUDE_TEAMS_WRAPPER_LAUNCH=1 fi + cmux_claude_wrapper_prepare_custom_path_environment "$target" exec "$target" "$@" } @@ -610,6 +795,10 @@ if [[ -n "$CMUX_SURFACE_ID" ]]; then IN_CMUX=1 fi +if [[ "$IN_CMUX" == "1" ]]; then + cmux_claude_wrapper_validate_arguments "$@" || exit $? +fi + # CMUX_AGENT_RESTORE_LAUNCH is a provider/session-bound one-shot token attached # only to app-generated restore input. Consume it before every eventual exec so # it can authorize this launch without leaking to Claude or hooks. @@ -794,6 +983,31 @@ encode_launch_argv() { } | base64 | tr -d '\n' } +cmux_claude_wrapper_create_settings_file() { + local contents="$1" + local temp_dir="${TMPDIR:-/tmp}" + local settings_path + [[ -d "$temp_dir" ]] || temp_dir="/tmp" + settings_path="$(mktemp "${temp_dir%/}/cmux-claude-settings.XXXXXX")" || return 1 + if ! printf '%s' "$contents" >"$settings_path"; then + rm -f -- "$settings_path" + return 1 + fi + printf '%s' "$settings_path" +} + +cmux_claude_wrapper_create_settings_values_file() { + local temp_dir="${TMPDIR:-/tmp}" + local settings_path + [[ -d "$temp_dir" ]] || temp_dir="/tmp" + settings_path="$(mktemp "${temp_dir%/}/cmux-claude-settings-inputs.XXXXXX")" || return 1 + if ! printf '%s\0' "$@" >"$settings_path"; then + rm -f -- "$settings_path" + return 1 + fi + printf '%s' "$settings_path" +} + claude_interactive_entry_flag() { case "$1" in --print|--print=*|-p|--resume|--resume=*|-r|--continue|-c|\ @@ -884,6 +1098,10 @@ if (( cmux_claude_wrapper_reexec_hops > 0 )); then export CMUX_CLAUDE_PID=$$ export CMUX_CLAUDE_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then + # Do not carry a pre-file-handoff launch capture from an older wrapper + # pass; it may contain the oversized inline settings payload we are + # replacing here. + unset CMUX_AGENT_LAUNCH_ARGV_B64 export CMUX_AGENT_LAUNCH_KIND="claude" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CLAUDE" export CMUX_AGENT_LAUNCH_CWD="$PWD" @@ -928,7 +1146,6 @@ export CMUX_CLAUDE_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then export CMUX_AGENT_LAUNCH_KIND="claude" export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CLAUDE" - export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" export CMUX_AGENT_LAUNCH_CWD="$PWD" fi install_cmux_node_options @@ -965,7 +1182,7 @@ install_cmux_node_options # timeout because it may run a summarization subprocess; it gates itself # on the workspaceAutoNaming setting via a socket probe, so it is a # no-op when the feature is disabled. -HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' +HOOKS_JSON='{"__cmux":{"managed":"claude-hooks","version":1},"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' # Fold any user-provided --settings into HOOKS_JSON before launching. # @@ -976,12 +1193,13 @@ HOOKS_JSON='{"preferredNotifChannel":"notifications_disabled","hooks":{"SessionS # first-wins on <=2.1.168 (the user's flag was dropped: the original #2816 # symptom), last-wins on >=2.1.169 (the user's flag wins but cmux's hook # --settings is dropped, silently breaking cmux notifications/status). Either -# way one side is lost. Deep-merge the user's settings into ours and pass a -# SINGLE combined --settings so Claude Code never sees multiple --settings: hook -# arrays are concatenated (so both cmux and user hooks run) and the user's other -# keys win. This is precedence-agnostic -- it does not rely on the CLI's -# multi-flag behavior. Requires `node`; if node is missing or the merge fails we -# fall back to the previous behavior (no worse than before). +# way one side is lost. Deep-merge the user's settings into ours, write the +# combined document to a private temporary file, and pass one path-valued +# --settings flag. Hook arrays from a genuine user document are retained, while +# cmux's marked block is replaced on re-entry. This is precedence-agnostic -- it +# does not rely on the CLI's multi-flag behavior. Requires `node`; if node is +# missing or the merge fails we fall back to the previous behavior. +unset CMUX_USER_SETTINGS_B64 CMUX_FILTERED_ARGS=() CMUX_USER_SETTINGS=() cmux_collect_settings_value=false @@ -1002,83 +1220,156 @@ for arg in "$@"; do cmux_pending_option_value=false continue fi - case "$arg" in - --) - cmux_positional_only=true - CMUX_FILTERED_ARGS+=("$arg") - ;; - --settings) - cmux_collect_settings_value=true - ;; - --settings=*) - CMUX_USER_SETTINGS+=("${arg#--settings=}") - ;; - -*) - CMUX_FILTERED_ARGS+=("$arg") - if [[ "$arg" != *=* ]] && claude_option_consumes_value "$arg"; then - cmux_pending_option_value=true - fi - ;; - *) - CMUX_FILTERED_ARGS+=("$arg") - ;; - esac + if [[ "$arg" == "--" ]]; then + cmux_positional_only=true + CMUX_FILTERED_ARGS+=("$arg") + elif [[ "$arg" == "--settings" ]]; then + # The fixed-width prefix checks above avoid a wildcard match against a + # potentially very large JSON value. + cmux_collect_settings_value=true + elif [[ "${arg:0:11}" == "--settings=" ]]; then + CMUX_USER_SETTINGS+=("${arg#--settings=}") + elif [[ "${arg:0:1}" == "-" ]]; then + CMUX_FILTERED_ARGS+=("$arg") + if [[ "${arg%%=*}" == "$arg" ]] && claude_option_consumes_value "$arg"; then + cmux_pending_option_value=true + fi + else + CMUX_FILTERED_ARGS+=("$arg") + fi done if [[ "$cmux_collect_settings_value" == true ]]; then # Dangling --settings with no value; keep it so the real CLI reports the error. CMUX_FILTERED_ARGS+=("--settings") fi -if (( ${#CMUX_USER_SETTINGS[@]} > 0 )) && command -v node >/dev/null 2>&1; then - CMUX_MERGED_SETTINGS="$( - CMUX_BASE_SETTINGS="$HOOKS_JSON" \ - CMUX_USER_SETTINGS_B64="$(printf '%s\0' "${CMUX_USER_SETTINGS[@]}" | base64 | tr -d '\n')" \ +CMUX_SETTINGS_PATH="" +CMUX_SETTINGS_BASE_PATH="" +CMUX_SETTINGS_INPUTS_PATH="" +CMUX_MERGED_SETTINGS_PATH="" +CMUX_SETTINGS_MERGE_SUCCEEDED=false + +# The settings artifact is file-backed so re-entry carries a bounded identity +# instead of copying an ever-growing JSON value through argv and the environment. +if CMUX_SETTINGS_BASE_PATH="$(cmux_claude_wrapper_create_settings_file "$HOOKS_JSON")"; then + CMUX_SETTINGS_PATH="$CMUX_SETTINGS_BASE_PATH" +else + printf 'cmux: warning: unable to create hook settings file; hooks were not injected\n' >&2 +fi + +if (( ${#CMUX_USER_SETTINGS[@]} > 0 )) && [[ -n "$CMUX_SETTINGS_BASE_PATH" ]] && command -v node >/dev/null 2>&1; then + if CMUX_SETTINGS_INPUTS_PATH="$(cmux_claude_wrapper_create_settings_values_file "${CMUX_USER_SETTINGS[@]}")" && + CMUX_MERGED_SETTINGS_PATH="$(cmux_claude_wrapper_create_settings_file "")"; then + CMUX_BASE_SETTINGS_PATH="$CMUX_SETTINGS_BASE_PATH" \ + CMUX_USER_SETTINGS_PATH="$CMUX_SETTINGS_INPUTS_PATH" \ + CMUX_MERGED_SETTINGS_PATH="$CMUX_MERGED_SETTINGS_PATH" \ node -e ' const fs=require("fs"),os=require("os"),path=require("path"); const isObj=x=>x&&typeof x==="object"&&!Array.isArray(x); +const clone=x=>JSON.parse(JSON.stringify(x)); +const canonical=x=>JSON.stringify(x,(key,value)=>{ + if(!isObj(value)) return value; + return Object.keys(value).sort().reduce((out,k)=>{out[k]=value[k];return out;},{}); +}); +const marker=x=>isObj(x)&&isObj(x.__cmux)&&x.__cmux.managed==="claude-hooks"; +const expand=p=>p.indexOf("~/")===0?path.join(os.homedir(),p.slice(2)):p; +const load=value=>{ + const text=String(value).trim(); + if(text==="") throw new Error("empty --settings value"); + return (text[0]==="{"||text[0]==="[")?JSON.parse(text):JSON.parse(fs.readFileSync(expand(text),"utf8")); +}; const merge=(a,b)=>{ - if(Array.isArray(a)&&Array.isArray(b))return a.concat(b); - if(isObj(a)&&isObj(b)){const o=Object.assign({},a);for(const k of Object.keys(b))o[k]=(k in a)?merge(a[k],b[k]):b[k];return o;} - // a is the cmux/base side; on a type mismatch keep the cmux container so a - // non-object/array user value (e.g. hooks:null or hooks:[]) cannot wipe the - // cmux hook object/event arrays. Scalars still let the user value (b) win. - if(isObj(a)||Array.isArray(a))return a; + if(Array.isArray(a)&&Array.isArray(b)) return a.concat(b); + if(isObj(a)&&isObj(b)){ + const out=Object.assign({},a); + for(const key of Object.keys(b)) out[key]=(key in a)?merge(a[key],b[key]):clone(b[key]); + return out; + } + if(isObj(a)||Array.isArray(a)) return a; return b; }; -const expand=p=>p.indexOf("~/")===0?path.join(os.homedir(),p.slice(2)):p; -const load=v=>{const t=String(v).trim();return (t[0]==="{"||t[0]==="[")?JSON.parse(t):JSON.parse(fs.readFileSync(expand(t),"utf8"));}; -let acc=JSON.parse(process.env.CMUX_BASE_SETTINGS); -const raw=Buffer.from(process.env.CMUX_USER_SETTINGS_B64||"","base64").toString("utf8"); -// split() leaves one trailing "" from printf terminating NUL; slice it off -// rather than filtering all empties, so an explicit empty --settings= reaches -// load("") -> JSON.parse throws -> merge fails loudly (warning + fallback) -// instead of being silently dropped. The wrapper emits exactly one --settings, -// so Claude Code never sees multiples; its multi-flag precedence (first-wins -// <=2.1.168, last-wins >=2.1.169, undocumented) is irrelevant here. Among -// repeated USER --settings, reverse() makes the earliest-listed value win the -// scalar conflict (issue #2816). -try{for(const it of raw.split("\0").slice(0,-1).reverse())acc=merge(acc,load(it));} -catch(e){process.stderr.write("cmux: --settings merge error: "+((e&&e.message)||e)+"\n");process.exit(1);} -process.stdout.write(JSON.stringify(acc)); +const stripManaged=(settings,base,hasMarker)=>{ + if(!isObj(settings)||!isObj(settings.hooks)) return settings; + const out=clone(settings); + const fingerprints=new Set( + hasMarker&&isObj(settings.__cmux)&&Array.isArray(settings.__cmux.hookFingerprints) + ? settings.__cmux.hookFingerprints + : [] + ); + for(const event of Object.keys(out.hooks)){ + const values=out.hooks[event]; + if(!Array.isArray(values)) continue; + const baseValues=isObj(base.hooks)&&Array.isArray(base.hooks[event])?base.hooks[event]:[]; + out.hooks[event]=values.filter(value=>{ + if(baseValues.some(baseValue=>canonical(baseValue)===canonical(value))) return false; + return !fingerprints.has(canonical(value)); + }); + } + return out; +}; +const base=load(process.env.CMUX_BASE_SETTINGS_PATH); +const raw=fs.readFileSync(process.env.CMUX_USER_SETTINGS_PATH,"utf8"); +const values=raw.split("\0").slice(0,-1); +let acc=clone(base); +try{ + for(const value of values.reverse()){ + const loaded=load(value); + const hasBaseHook=()=>isObj(loaded)&&isObj(loaded.hooks)&&Object.keys(loaded.hooks).some(event=>{ + const values=loaded.hooks[event]; + const baseValues=isObj(base.hooks)&&Array.isArray(base.hooks[event])?base.hooks[event]:[]; + return Array.isArray(values)&&values.some(value=>baseValues.some(baseValue=>canonical(baseValue)===canonical(value))); + }); + const managed=marker(loaded)||hasBaseHook(); + acc=merge(acc,managed?stripManaged(loaded,base,marker(loaded)):loaded); + } +}catch(error){ + process.stderr.write("cmux: --settings merge error: "+((error&&error.message)||error)+"\n"); + process.exit(1); +} +const baseMarker=clone(base.__cmux||{managed:"claude-hooks",version:1}); +const hookFingerprints=[]; +for(const event of Object.keys(base.hooks||{})){ + const groups=Array.isArray(base.hooks[event])?base.hooks[event]:[]; + for(const group of groups) hookFingerprints.push(canonical(group)); +} +baseMarker.hookFingerprints=hookFingerprints; +acc.__cmux=baseMarker; +fs.writeFileSync(process.env.CMUX_MERGED_SETTINGS_PATH,JSON.stringify(acc),{encoding:"utf8",mode:0o600}); ' - )" - cmux_merge_status=$? - if [[ $cmux_merge_status -eq 0 && -n "$CMUX_MERGED_SETTINGS" ]]; then - HOOKS_JSON="$CMUX_MERGED_SETTINGS" - set -- "${CMUX_FILTERED_ARGS[@]}" - else - # Merge failed: malformed JSON, an unreadable --settings file, or a node - # error. node has already written the specific cause to stderr; add a - # one-line summary so the user knows their --settings was dropped instead - # of silently falling back to the dual-flag behavior that #2816 fixes. - printf 'cmux: warning: --settings merge failed; your --settings was ignored\n' >&2 + cmux_merge_status=$? + if [[ $cmux_merge_status -eq 0 && -s "$CMUX_MERGED_SETTINGS_PATH" ]]; then + CMUX_SETTINGS_PATH="$CMUX_MERGED_SETTINGS_PATH" + CMUX_SETTINGS_MERGE_SUCCEEDED=true + set -- "${CMUX_FILTERED_ARGS[@]}" + else + rm -f -- "$CMUX_MERGED_SETTINGS_PATH" + fi fi - unset CMUX_MERGED_SETTINGS cmux_merge_status +fi + +if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && ${#CMUX_USER_SETTINGS[@]} -gt 0 ]]; then + printf 'cmux: warning: --settings merge failed; your --settings was ignored\n' >&2 +fi +if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && -n "$CMUX_MERGED_SETTINGS_PATH" ]]; then + rm -f -- "$CMUX_MERGED_SETTINGS_PATH" +fi +if [[ -n "$CMUX_SETTINGS_INPUTS_PATH" ]]; then + rm -f -- "$CMUX_SETTINGS_INPUTS_PATH" +fi +if [[ -n "$CMUX_SETTINGS_BASE_PATH" && "$CMUX_SETTINGS_PATH" != "$CMUX_SETTINGS_BASE_PATH" ]]; then + rm -f -- "$CMUX_SETTINGS_BASE_PATH" +fi +unset CMUX_MERGED_SETTINGS_PATH cmux_merge_status CMUX_SETTINGS_INPUTS_PATH + +# Capture only the compact post-merge argv. The previous implementation encoded +# the inline JSON here as a second ARG_MAX multiplier. +if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then + export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" fi if [[ "$SKIP_SESSION_ID" == true ]]; then - cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --settings "$CMUX_SETTINGS_PATH" "$@" else SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" - cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$CMUX_SETTINGS_PATH" "$@" fi diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index b08574c0e20d..b05875c19136 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -811,6 +811,25 @@ def test_live_socket_user_nonobject_hooks_does_not_drop_cmux_hooks(failures: lis ) +def test_live_socket_preserves_genuine_user_hook_command(failures: list[str]) -> None: + user_hook = { + "matcher": "UserPromptSubmit", + "hooks": [{"type": "command", "command": "cmux hooks claude user-owned"}], + } + code, real_argv, _cmux_log, stderr, *_ = run_wrapper( + socket_state="live", + argv=["--settings", json.dumps({"hooks": {"UserPromptSubmit": [user_hook]}}), "hi"], + ) + expect(code == 0, f"user hook preservation: wrapper exited {code}: {stderr}", failures) + settings = parse_settings_arg(real_argv) + user_hooks = settings.get("hooks", {}).get("UserPromptSubmit", []) + expect( + user_hook in user_hooks, + f"user hook preservation: genuine user hook was dropped, got {user_hooks!r}", + failures, + ) + + def test_live_socket_invalid_settings_warns_and_falls_back(failures: list[str]) -> None: # A malformed --settings must not be dropped in silence: the wrapper surfaces # a stderr warning instead of quietly reverting to the dual --settings @@ -895,6 +914,38 @@ def test_large_settings_argument_is_rejected_without_hanging(failures: list[str] ) +def test_large_settings_file_is_merged_without_argv_growth(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-large-settings-file-") as td: + settings_path = Path(td) / "large-settings.json" + large_value = "x" * 200_000 + settings_path.write_text(json.dumps({"largeUserValue": large_value}), encoding="utf-8") + code, real_argv, _cmux_log, stderr, *_ = run_wrapper( + socket_state="live", + argv=["--settings", str(settings_path), "hello"], + process_timeout=5, + ) + expect(code == 0, f"large settings file: wrapper exited {code}: {stderr}", failures) + if "--settings" not in real_argv: + failures.append(f"large settings file: missing merged settings path: {real_argv}") + return + merged_path = real_argv[real_argv.index("--settings") + 1] + expect( + len(merged_path) < 4096, + f"large settings file: merged argv path grew unexpectedly: {len(merged_path)} bytes", + failures, + ) + try: + merged = json.loads(Path(merged_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + failures.append(f"large settings file: merged settings could not be read: {exc}") + return + expect( + merged.get("largeUserValue") == large_value, + "large settings file: genuine user value was not preserved", + failures, + ) + + def test_plain_claude_launch_argv_has_no_empty_argument(failures: list[str]) -> None: code, _, _, stderr, _, _, _, _, _, launch_argv_b64 = run_wrapper( socket_state="live", @@ -2023,10 +2074,12 @@ def main() -> int: test_live_socket_merges_inline_settings_form(failures) test_live_socket_repeated_settings_user_value_wins_conflict(failures) test_live_socket_user_nonobject_hooks_does_not_drop_cmux_hooks(failures) + test_live_socket_preserves_genuine_user_hook_command(failures) test_live_socket_invalid_settings_warns_and_falls_back(failures) test_live_socket_merges_settings_file_form(failures) test_live_socket_empty_settings_warns_instead_of_silent_drop(failures) test_large_settings_argument_is_rejected_without_hanging(failures) + test_large_settings_file_is_merged_without_argv_growth(failures) test_plain_claude_launch_argv_has_no_empty_argument(failures) test_command_like_invocations_bypass_hook_injection(failures) test_hidden_attach_subcommand_bypasses_hook_injection(failures) diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py index c91e409e6511..9767865c1633 100644 --- a/tests/test_claude_wrapper_mutual_shim_loop.py +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -21,6 +21,12 @@ def write_executable(path: Path, contents: str) -> None: path.chmod(0o755) +def load_settings_value(value: str) -> dict: + if value.lstrip().startswith(("{", "[")): + return json.loads(value) + return json.loads(Path(value).read_text(encoding="utf-8")) + + def build_mutual_shim_tree(root: Path) -> tuple[Path, dict[str, str]]: cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-loop" delimit_primary_dir = root / "home" / ".delimit" / "shims" @@ -821,7 +827,7 @@ def test_interactive_finite_shim_chain_does_not_duplicate_hooks(failures: list[s failures.append(f"expected --settings value after finite shim chain, got: {args!r}") return try: - settings = json.loads(args[settings_index + 1]) + settings = load_settings_value(args[settings_index + 1]) except Exception as exc: # noqa: BLE001 - simple test harness failures.append(f"expected JSON --settings value, got {args[settings_index + 1]!r}: {exc}") return @@ -941,7 +947,7 @@ def test_interactive_spawning_shim_chain_refreshes_claude_pid(failures: list[str if settings_index + 1 >= len(args): failures.append(f"expected --settings value after spawning shim chain, got: {combined_output!r}") return - settings = json.loads(args[settings_index + 1]) + settings = load_settings_value(args[settings_index + 1]) hooks = settings.get("hooks", {}) if len(hooks.get("SessionStart", [])) != 1 or len(hooks.get("Stop", [])) != 3: failures.append(f"expected one hook injection after spawning shim chain, got: {settings!r}") @@ -981,6 +987,7 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] ) launch_count = root / "launcher-count" + inherited_path_log = root / "launcher-inherited-path.log" managed_path = ( f"{cmux_shim_dir}:{launcher_dir}:{real_dir}:" f"{Path(node_path).parent}:/usr/bin:/bin" @@ -991,6 +998,8 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] const fs = require("node:fs"); const {{ spawnSync }} = require("node:child_process"); const countPath = {json.dumps(str(launch_count))}; +const inheritedPathLog = {json.dumps(str(inherited_path_log))}; +fs.appendFileSync(inheritedPathLog, `${{(process.env.PATH || "").includes("/cmux-cli-shims/")}}\n`); const firstHop = !fs.existsSync(countPath); if (firstHop) fs.writeFileSync(countPath, "1"); // The first lookup intentionally restores the managed path to reproduce a @@ -1010,11 +1019,11 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] """, ) - custom_path = custom_dir / "claude-launcher" + custom_path = custom_dir / "agent-entry" write_executable( custom_path, - f"""#!/usr/bin/env bash -exec {json.dumps(str(launcher_dir / "resolve-claude"))} claude "$@" + """#!/usr/bin/env bash +exec "$CMUX_LAUNCHER" "$@" """, ) @@ -1047,6 +1056,7 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), "CMUX_CUSTOM_CLAUDE_PATH": str(custom_path), + "CMUX_LAUNCHER": str(launcher_dir / "resolve-claude"), "CMUX_SURFACE_ID": "surface-10230", "CMUX_SOCKET_PATH": str(socket_path), "CMUX_BUNDLED_CLI_PATH": str(cmux_bin), @@ -1073,6 +1083,12 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] if not settings_output.is_file(): failures.append(f"issue #10230 re-entry never reached the real Claude binary: {combined_output!r}") return + inherited_path_values = inherited_path_log.read_text(encoding="utf-8").splitlines() + if any(value == "true" for value in inherited_path_values): + failures.append( + "issue #10230 custom launcher inherited cmux's shim directory on PATH: " + f"{inherited_path_values!r}" + ) try: settings = json.loads(settings_output.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: From 9d798d0e445cd4475f0653e248d4d9c51a1b0292 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 15:48:28 -0700 Subject: [PATCH 3/7] fix: harden wrapper fallback and re-entry tests --- Resources/bin/cmux-claude-wrapper | 34 +++++++++++++++---- tests/test_claude_wrapper_hooks.py | 12 +++++-- tests/test_claude_wrapper_mutual_shim_loop.py | 7 +++- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 28845264e828..2c10577df8ae 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -139,8 +139,9 @@ cmux_claude_wrapper_prepare_custom_path_environment() { [[ "${IN_CMUX:-0}" == "1" ]] || return 0 cmux_claude_wrapper_target_is_custom_path "$target" || return 0 local cleaned_path - cleaned_path="$(cmux_claude_wrapper_path_without_shims)" - export PATH="$cleaned_path" + if cleaned_path="$(cmux_claude_wrapper_path_without_shims)" && [[ -n "$cleaned_path" ]]; then + export PATH="$cleaned_path" + fi } cmux_claude_wrapper_is_nonnegative_integer() { @@ -204,7 +205,11 @@ cmux_claude_wrapper_custom_target_is_direct_absolute_claude() { token="${rest%%[[:space:]]*}" token="${token%\"}" token="${token%\'}" - [[ "$token" == */claude ]] && return 0 + if [[ "$token" == /*/claude || "$token" == /claude ]] && + [[ -e "$token" && -x "$token" ]] && + ! cmux_claude_wrapper_is_self_or_shim "$token"; then + return 0 + fi ;; esac done <<< "$snippet" @@ -263,7 +268,11 @@ cmux_claude_wrapper_target_looks_like_reexec_shim() { return 1 ;; esac - if ! cmux_claude_wrapper_target_is_custom_path "$target"; then + local target_is_custom=false + if [[ "${IN_CMUX:-0}" == "1" ]] && cmux_claude_wrapper_target_is_custom_path "$target"; then + target_is_custom=true + fi + if [[ "$target_is_custom" != true ]]; then case "$first_line" in *node*|*Node*) return 1 @@ -276,7 +285,7 @@ cmux_claude_wrapper_target_looks_like_reexec_shim() { # command forms above. Treat a bare Claude token in that launcher as a # possible re-entry, but keep direct absolute `/.../claude` launchers on # the real-binary side of the boundary. - if [[ "${IN_CMUX:-0}" == "1" ]] && cmux_claude_wrapper_target_is_custom_path "$target"; then + if [[ "$target_is_custom" == true ]]; then cmux_claude_wrapper_custom_target_is_direct_absolute_claude "$snippet" && return 1 local launcher_line while IFS= read -r launcher_line; do @@ -1182,6 +1191,9 @@ install_cmux_node_options # timeout because it may run a summarization subprocess; it gates itself # on the workspaceAutoNaming setting via a socket probe, so it is a # no-op when the feature is disabled. +# `__cmux` is private merge metadata. Claude Code's settings schema permits +# additional properties; keeping the marker in the file is what lets a later +# wrapper pass distinguish cmux-owned hook groups from genuine user hooks. HOOKS_JSON='{"__cmux":{"managed":"claude-hooks","version":1},"preferredNotifChannel":"notifications_disabled","hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude stop","timeout":10}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude auto-name","timeout":120,"async":true}]}],"SubagentStop":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":10,"async":true}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"CronCreate","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude cron-create-guard","timeout":5}]},{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude pre-tool-use","timeout":5,"async":true}]}],"PostToolUse":[{"matcher":"PushNotification","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks claude push-notification","timeout":10,"async":true}]}],"PermissionRequest":[{"matcher":"","hooks":[{"type":"command","command":"\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\" hooks feed --source claude","timeout":125}]}]}}' # Fold any user-provided --settings into HOOKS_JSON before launching. @@ -1368,8 +1380,16 @@ if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then fi if [[ "$SKIP_SESSION_ID" == true ]]; then - cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --settings "$CMUX_SETTINGS_PATH" "$@" + if [[ -n "$CMUX_SETTINGS_PATH" ]]; then + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --settings "$CMUX_SETTINGS_PATH" "$@" + else + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" "$@" + fi else SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" - cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$CMUX_SETTINGS_PATH" "$@" + if [[ -n "$CMUX_SETTINGS_PATH" ]]; then + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$CMUX_SETTINGS_PATH" "$@" + else + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" "$@" + fi fi diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index b05875c19136..1662b03f2074 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -209,11 +209,13 @@ def run_wrapper( ) except subprocess.TimeoutExpired as exc: timed_out = True + stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") proc = subprocess.CompletedProcess( [str(wrapper), *argv], 124, - stdout=exc.stdout or "", - stderr=exc.stderr or "", + stdout=stdout, + stderr=stderr, ) finally: if test_socket is not None: @@ -664,6 +666,12 @@ def test_live_socket_handoff_uses_a_settings_file(failures: list[str]) -> None: f"file handoff: settings path was not readable: {settings_value!r}", failures, ) + if settings_path_exists: + expect( + Path(settings_value).stat().st_mode & 0o777 == 0o600, + f"file handoff: settings file permissions were not private: {oct(Path(settings_value).stat().st_mode & 0o777)}", + failures, + ) def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> None: diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py index 9767865c1633..ef675c35f9d4 100644 --- a/tests/test_claude_wrapper_mutual_shim_loop.py +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -1060,6 +1060,7 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] "CMUX_SURFACE_ID": "surface-10230", "CMUX_SOCKET_PATH": str(socket_path), "CMUX_BUNDLED_CLI_PATH": str(cmux_bin), + "TMPDIR": str(root / "tmp"), "FAKE_SETTINGS_OUTPUT": str(settings_output), } result = subprocess.run( @@ -1083,7 +1084,11 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] if not settings_output.is_file(): failures.append(f"issue #10230 re-entry never reached the real Claude binary: {combined_output!r}") return - inherited_path_values = inherited_path_log.read_text(encoding="utf-8").splitlines() + if not inherited_path_log.is_file(): + failures.append("issue #10230 custom launcher did not record its inherited PATH") + inherited_path_values: list[str] = [] + else: + inherited_path_values = inherited_path_log.read_text(encoding="utf-8").splitlines() if any(value == "true" for value in inherited_path_values): failures.append( "issue #10230 custom launcher inherited cmux's shim directory on PATH: " From 4c884e5ee1d075648f5bd1acc36c64dc9c1e90fd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 15:52:07 -0700 Subject: [PATCH 4/7] fix: sanitize wrapper merge failures --- Resources/bin/cmux-claude-wrapper | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 2c10577df8ae..b750ca8b8610 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -997,6 +997,8 @@ cmux_claude_wrapper_create_settings_file() { local temp_dir="${TMPDIR:-/tmp}" local settings_path [[ -d "$temp_dir" ]] || temp_dir="/tmp" + # mktemp gives each launch a private name; do not sweep matching files here, + # because another surface may still be handing one to Claude. settings_path="$(mktemp "${temp_dir%/}/cmux-claude-settings.XXXXXX")" || return 1 if ! printf '%s' "$contents" >"$settings_path"; then rm -f -- "$settings_path" @@ -1210,7 +1212,8 @@ HOOKS_JSON='{"__cmux":{"managed":"claude-hooks","version":1},"preferredNotifChan # --settings flag. Hook arrays from a genuine user document are retained, while # cmux's marked block is replaced on re-entry. This is precedence-agnostic -- it # does not rely on the CLI's multi-flag behavior. Requires `node`; if node is -# missing or the merge fails we fall back to the previous behavior. +# missing or the merge fails, preserve the user's original settings and disable +# cmux hooks for that launch rather than passing an ambiguous pair of flags. unset CMUX_USER_SETTINGS_B64 CMUX_FILTERED_ARGS=() CMUX_USER_SETTINGS=() @@ -1335,7 +1338,7 @@ try{ acc=merge(acc,managed?stripManaged(loaded,base,marker(loaded)):loaded); } }catch(error){ - process.stderr.write("cmux: --settings merge error: "+((error&&error.message)||error)+"\n"); + process.stderr.write("cmux: settings merge failed; using user settings unchanged\n"); process.exit(1); } const baseMarker=clone(base.__cmux||{managed:"claude-hooks",version:1}); @@ -1360,7 +1363,8 @@ fs.writeFileSync(process.env.CMUX_MERGED_SETTINGS_PATH,JSON.stringify(acc),{enco fi if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && ${#CMUX_USER_SETTINGS[@]} -gt 0 ]]; then - printf 'cmux: warning: --settings merge failed; your --settings was ignored\n' >&2 + printf 'cmux: warning: settings merge failed; cmux hooks are disabled for this launch and your settings were preserved unchanged\n' >&2 + CMUX_SETTINGS_PATH="" fi if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && -n "$CMUX_MERGED_SETTINGS_PATH" ]]; then rm -f -- "$CMUX_MERGED_SETTINGS_PATH" @@ -1376,7 +1380,9 @@ unset CMUX_MERGED_SETTINGS_PATH cmux_merge_status CMUX_SETTINGS_INPUTS_PATH # Capture only the compact post-merge argv. The previous implementation encoded # the inline JSON here as a second ARG_MAX multiplier. if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then - export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" + # Keep the telemetry capture compact even when user settings could not be + # merged; the real Claude argv still receives those original settings. + export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "${CMUX_FILTERED_ARGS[@]}")" fi if [[ "$SKIP_SESSION_ID" == true ]]; then From 4655dc64dc64a23309f01c2f3aed84e2bcc5fba4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 15:56:06 -0700 Subject: [PATCH 5/7] fix: clarify disabled-hook merge warning --- Resources/bin/cmux-claude-wrapper | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index b750ca8b8610..4b77b9e23003 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -1363,7 +1363,7 @@ fs.writeFileSync(process.env.CMUX_MERGED_SETTINGS_PATH,JSON.stringify(acc),{enco fi if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && ${#CMUX_USER_SETTINGS[@]} -gt 0 ]]; then - printf 'cmux: warning: settings merge failed; cmux hooks are disabled for this launch and your settings were preserved unchanged\n' >&2 + printf 'cmux: warning: settings merge failed; cmux hooks are disabled for this launch and your settings were preserved unchanged; install Node or correct the settings file before retrying\n' >&2 CMUX_SETTINGS_PATH="" fi if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" != true && -n "$CMUX_MERGED_SETTINGS_PATH" ]]; then From 1c0724bdde41a1352794fb28b7e75ae7bce187b5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 16:06:28 -0700 Subject: [PATCH 6/7] fix: make wrapper diagnostics and limits portable --- Resources/bin/cmux-claude-wrapper | 4 +- tests/test_claude_wrapper_hooks.py | 2 +- tests/test_claude_wrapper_mutual_shim_loop.py | 67 +++++++++++-------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 4b77b9e23003..97413b8a61fc 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -42,7 +42,9 @@ cmux_claude_wrapper_reexec_target_history="" # Keep argument inspection bounded. Apart from protecting the shell's pattern # matcher, this prevents a caller from turning the wrapper's own launch metadata # into another ARG_MAX-sized environment value. -cmux_claude_wrapper_max_argument_bytes=262144 +# Stay below Linux's 128 KiB per-argument exec limit so the same guard can be +# exercised portably; larger settings should be supplied by file path. +cmux_claude_wrapper_max_argument_bytes=122880 cmux_claude_wrapper_validate_arguments() { local arg diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 1662b03f2074..07ab0e493df9 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -908,7 +908,7 @@ def test_live_socket_empty_settings_warns_instead_of_silent_drop(failures: list[ def test_large_settings_argument_is_rejected_without_hanging(failures: list[str]) -> None: - large_settings = '{"large":"' + ("x" * 300_000) + '"}' + large_settings = '{"large":"' + ("x" * 125_000) + '"}' code, _real_argv, _cmux_log, stderr, *_ = run_wrapper( socket_state="live", argv=["--settings", large_settings, "hello"], diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py index ef675c35f9d4..0aa5c7448b04 100644 --- a/tests/test_claude_wrapper_mutual_shim_loop.py +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -953,6 +953,43 @@ def test_interactive_spawning_shim_chain_refreshes_claude_pid(failures: list[str failures.append(f"expected one hook injection after spawning shim chain, got: {settings!r}") +def verify_custom_path_reentry_result( + result: subprocess.CompletedProcess[str], + settings_output: Path, + inherited_path_log: Path, + failures: list[str], +) -> None: + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"issue #10230 re-entry failed with {result.returncode}: {combined_output!r}") + return + if not settings_output.is_file(): + failures.append(f"issue #10230 re-entry never reached the real Claude binary: {combined_output!r}") + return + if not inherited_path_log.is_file(): + failures.append("issue #10230 custom launcher did not record its inherited PATH") + inherited_path_values: list[str] = [] + else: + inherited_path_values = inherited_path_log.read_text(encoding="utf-8").splitlines() + if any(value == "true" for value in inherited_path_values): + failures.append( + "issue #10230 custom launcher inherited cmux's shim directory on PATH: " + f"{inherited_path_values!r}" + ) + try: + settings = json.loads(settings_output.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + failures.append(f"issue #10230 emitted invalid settings JSON: {exc}") + return + hooks = settings.get("hooks", {}) + if len(hooks.get("SessionStart", [])) != 1 or len(hooks.get("Stop", [])) != 3: + failures.append( + "issue #10230 re-entry should converge to one cmux hook block, " + f"got SessionStart={len(hooks.get('SessionStart', []))} " + f"Stop={len(hooks.get('Stop', []))}: {settings!r}" + ) + + def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str]) -> None: """A launcher that re-enters the cmux shim once must not duplicate hooks.""" node_path = ensure_node_on_path() @@ -1077,35 +1114,7 @@ def test_custom_path_reentry_converges_to_one_settings_block(failures: list[str] finally: test_socket.close() - combined_output = result.stdout + result.stderr - if result.returncode != 0: - failures.append(f"issue #10230 re-entry failed with {result.returncode}: {combined_output!r}") - return - if not settings_output.is_file(): - failures.append(f"issue #10230 re-entry never reached the real Claude binary: {combined_output!r}") - return - if not inherited_path_log.is_file(): - failures.append("issue #10230 custom launcher did not record its inherited PATH") - inherited_path_values: list[str] = [] - else: - inherited_path_values = inherited_path_log.read_text(encoding="utf-8").splitlines() - if any(value == "true" for value in inherited_path_values): - failures.append( - "issue #10230 custom launcher inherited cmux's shim directory on PATH: " - f"{inherited_path_values!r}" - ) - try: - settings = json.loads(settings_output.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - failures.append(f"issue #10230 emitted invalid settings JSON: {exc}") - return - hooks = settings.get("hooks", {}) - if len(hooks.get("SessionStart", [])) != 1 or len(hooks.get("Stop", [])) != 3: - failures.append( - "issue #10230 re-entry should converge to one cmux hook block, " - f"got SessionStart={len(hooks.get('SessionStart', []))} " - f"Stop={len(hooks.get('Stop', []))}: {settings!r}" - ) + verify_custom_path_reentry_result(result, settings_output, inherited_path_log, failures) def main() -> int: From 505ba07ac455e99d5803748ab61afdf0226a9351 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 17 Aug 2026 16:25:59 -0700 Subject: [PATCH 7/7] fix: preserve bounded launch metadata --- Resources/bin/cmux-claude-wrapper | 40 ++++++++++++++----- tests/test_claude_wrapper_hooks.py | 20 ++++++++++ tests/test_claude_wrapper_mutual_shim_loop.py | 15 +++++-- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 97413b8a61fc..da2fb62154da 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -48,15 +48,31 @@ cmux_claude_wrapper_max_argument_bytes=122880 cmux_claude_wrapper_validate_arguments() { local arg + local byte_length + local had_lc_all=0 + local previous_lc_all="${LC_ALL-}" + local too_large=0 + if [[ ${LC_ALL+x} ]]; then + had_lc_all=1 + fi + export LC_ALL=C for arg in "$@"; do - # Bash's character count is sufficient for the ASCII/JSON command-line - # values handled here and avoids invoking a subprocess for every arg. - if (( ${#arg} > cmux_claude_wrapper_max_argument_bytes )); then - printf 'cmux: argument too large (maximum %s bytes)\n' \ - "$cmux_claude_wrapper_max_argument_bytes" >&2 - return 2 + byte_length=${#arg} + if (( byte_length > cmux_claude_wrapper_max_argument_bytes )); then + too_large=1 + break fi done + if (( had_lc_all == 1 )); then + export LC_ALL="$previous_lc_all" + else + unset LC_ALL + fi + if (( too_large == 1 )); then + printf 'cmux: argument too large (maximum %s bytes)\n' \ + "$cmux_claude_wrapper_max_argument_bytes" >&2 + return 2 + fi return 0 } @@ -1382,9 +1398,15 @@ unset CMUX_MERGED_SETTINGS_PATH cmux_merge_status CMUX_SETTINGS_INPUTS_PATH # Capture only the compact post-merge argv. The previous implementation encoded # the inline JSON here as a second ARG_MAX multiplier. if [[ "$cmux_claude_teams_wrapper_launch" != "1" ]]; then - # Keep the telemetry capture compact even when user settings could not be - # merged; the real Claude argv still receives those original settings. - export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "${CMUX_FILTERED_ARGS[@]}")" + if [[ "$CMUX_SETTINGS_MERGE_SUCCEEDED" == true ]]; then + # A successful merge has replaced settings with the private file path, + # so the filtered argv is the authoritative compact launch snapshot. + export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "${CMUX_FILTERED_ARGS[@]}")" + else + # Preserve the caller's original settings in restore metadata when the + # merge is unavailable; the argument-size guard keeps this bounded. + export CMUX_AGENT_LAUNCH_ARGV_B64="$(encode_launch_argv "$@")" + fi fi if [[ "$SKIP_SESSION_ID" == true ]]; then diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 07ab0e493df9..600f12d593ee 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -915,6 +915,7 @@ def test_large_settings_argument_is_rejected_without_hanging(failures: list[str] process_timeout=2, ) expect(code != 124, f"large settings: wrapper pinned the test process: {stderr!r}", failures) + expect(code != 0, f"large settings: expected a rejection status, got {code}", failures) expect( "argument" in stderr.lower() and "large" in stderr.lower(), f"large settings: expected a clear argument-size error, got {stderr!r}", @@ -922,6 +923,24 @@ def test_large_settings_argument_is_rejected_without_hanging(failures: list[str] ) +def test_multibyte_settings_argument_uses_byte_limit(failures: list[str]) -> None: + # 62,000 two-byte characters stay below Linux's per-argument exec ceiling + # while exceeding the wrapper's 120 KiB byte limit. + large_settings = '{"large":"' + ("é" * 62_000) + '"}' + code, _real_argv, _cmux_log, stderr, *_ = run_wrapper( + socket_state="live", + argv=["--settings", large_settings, "hello"], + process_timeout=2, + ) + expect(code != 124, f"multibyte settings: wrapper pinned the test process: {stderr!r}", failures) + expect(code != 0, f"multibyte settings: expected a rejection status, got {code}", failures) + expect( + "argument too large" in stderr.lower(), + f"multibyte settings: expected a byte-size error, got {stderr!r}", + failures, + ) + + def test_large_settings_file_is_merged_without_argv_growth(failures: list[str]) -> None: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-large-settings-file-") as td: settings_path = Path(td) / "large-settings.json" @@ -2087,6 +2106,7 @@ def main() -> int: test_live_socket_merges_settings_file_form(failures) test_live_socket_empty_settings_warns_instead_of_silent_drop(failures) test_large_settings_argument_is_rejected_without_hanging(failures) + test_multibyte_settings_argument_uses_byte_limit(failures) test_large_settings_file_is_merged_without_argv_growth(failures) test_plain_claude_launch_argv_has_no_empty_argument(failures) test_command_like_invocations_bypass_hook_injection(failures) diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py index 0aa5c7448b04..eafdd9cff599 100644 --- a/tests/test_claude_wrapper_mutual_shim_loop.py +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -981,12 +981,19 @@ def verify_custom_path_reentry_result( except json.JSONDecodeError as exc: failures.append(f"issue #10230 emitted invalid settings JSON: {exc}") return - hooks = settings.get("hooks", {}) - if len(hooks.get("SessionStart", [])) != 1 or len(hooks.get("Stop", [])) != 3: + hooks = settings.get("hooks") + if ( + not isinstance(hooks, dict) + or not isinstance(hooks.get("SessionStart"), list) + or not isinstance(hooks.get("Stop"), list) + ): + failures.append(f"issue #10230 emitted malformed hooks structure: {hooks!r}") + return + if len(hooks["SessionStart"]) != 1 or len(hooks["Stop"]) != 3: failures.append( "issue #10230 re-entry should converge to one cmux hook block, " - f"got SessionStart={len(hooks.get('SessionStart', []))} " - f"Stop={len(hooks.get('Stop', []))}: {settings!r}" + f"got SessionStart={len(hooks['SessionStart'])} " + f"Stop={len(hooks['Stop'])}: {settings!r}" )