diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0287fc6a11bd..b9981b18dd4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -636,6 +636,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_openagent_plugin_migration.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py python3 tests/test_claude_wrapper_hooks.py + python3 tests/test_claude_wrapper_mutual_shim_loop.py python3 tests/test_claude_wrapper_user_binary_resolution.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 63b7ec09d531..1c144c482298 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -33,6 +33,156 @@ cmux_claude_wrapper_is_self_or_shim() { return 1 } +cmux_claude_wrapper_reexec_guard_limit=16 +cmux_claude_wrapper_reexec_hops=0 +# Newline-delimited, not PATH/colon-delimited: custom executable paths can +# legally contain ':' even though PATH entries cannot. +cmux_claude_wrapper_reexec_target_history="" + +cmux_claude_wrapper_is_nonnegative_integer() { + case "$1" in + ''|*[!0-9]*) + return 1 + ;; + esac + return 0 +} + +cmux_claude_wrapper_fail_reexec_guard() { + cat >&2 <<'EOF' +cmux: stopped a possible infinite claude shim loop. +cmux's per-surface claude shim re-entered before the real binary started. +This is usually caused by a conflicting `claude` shim from another tool on PATH. +Set CMUX_CUSTOM_CLAUDE_PATH, or set Automation > Claude Binary Path to the real +claude binary, then start a new surface. +EOF + exit 126 +} + +cmux_claude_wrapper_init_reexec_guard() { + local guard="${cmux_claude_wrapper_reexec_guard:-}" + local hops + + case "$guard" in + *[!0-9]*|'') + unset cmux_claude_wrapper_reexec_guard + unset cmux_claude_wrapper_reexec_targets + cmux_claude_wrapper_reexec_hops=0 + cmux_claude_wrapper_reexec_target_history="" + ;; + *) + hops="$guard" + cmux_claude_wrapper_is_nonnegative_integer "$hops" || hops=0 + cmux_claude_wrapper_reexec_hops="$hops" + cmux_claude_wrapper_reexec_target_history="${cmux_claude_wrapper_reexec_targets:-}" + ;; + esac +} + +cmux_claude_wrapper_reexec_target_seen() { + local target="$1" + local seen + while IFS= read -r seen; do + [[ "$seen" == "$target" ]] && return 0 + done <<< "$cmux_claude_wrapper_reexec_target_history" + return 1 +} + +cmux_claude_wrapper_target_looks_like_reexec_shim() { + local target="$1" + cmux_claude_wrapper_is_self_or_shim "$target" && return 0 + [[ -f "$target" && -r "$target" ]] || return 1 + + case "$target" in + */.asdf/shims/claude|*/.mise/shims/claude|*/.local/share/mise/shims/claude|*/.volta/bin/claude) + return 1 + ;; + esac + + local magic + magic="$(od -An -N2 -tx1 "$target" 2>/dev/null | tr -d '[:space:]')" + [[ "$magic" == "2321" ]] || return 1 + + local first_line snippet + IFS= read -r first_line < "$target" || first_line="" + snippet="$(sed -n '1,160p' "$target" 2>/dev/null || true)" + + case "$snippet" in + *"exec claude"*|*"exec \"claude\""*|*"exec 'claude'"*|\ + *"exec env claude"*|*"exec /usr/bin/env claude"*|\ + *"env claude"*|*"env \"claude\""*|*"env 'claude'"*|\ + *"command claude"*|*"command \"claude\""*|*"command 'claude'"*|\ + *"spawn(\"claude\""*|*"spawn('claude'"*|\ + *"spawnSync(\"claude\""*|*"spawnSync('claude'"*|\ + *"execFile(\"claude\""*|*"execFile('claude'"*|\ + *"execFileSync(\"claude\""*|*"execFileSync('claude'"*|\ + *"execSync(\"claude"*|*"execSync('claude"*|\ + *"os.execvp(\"claude\""*|*"os.execvp('claude'"*|\ + *"os.execlp(\"claude\""*|*"os.execlp('claude'"*|\ + *"subprocess.run([\"claude\""*|*"subprocess.run(['claude'"*|\ + *"subprocess.call([\"claude\""*|*"subprocess.call(['claude'"*|\ + *"subprocess.Popen([\"claude\""*|*"subprocess.Popen(['claude'"*) + return 0 + ;; + esac + case "$snippet" in + *'=claude'*|*'="claude"'*|*"='claude'"*|\ + *':-claude}'*|*':-"claude"}'*|*":-'claude'}"*) + case "$snippet" in + *'exec "$'*|*'exec ${'*) + return 0 + ;; + esac + ;; + esac + case "$snippet" in + *'@anthropic-ai/claude-code'*|*'claude-code/cli.js'*) + return 1 + ;; + esac + case "$first_line" in + *node*|*Node*) + return 1 + ;; + esac + return 1 +} + +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. + unset cmux_claude_wrapper_reexec_guard + unset cmux_claude_wrapper_reexec_targets + exec "$target" "$@" + fi + if cmux_claude_wrapper_reexec_target_seen "$target"; then + cmux_claude_wrapper_fail_reexec_guard "$target" + fi + if (( cmux_claude_wrapper_reexec_hops >= cmux_claude_wrapper_reexec_guard_limit )); then + cmux_claude_wrapper_fail_reexec_guard + fi + + local hops="${cmux_claude_wrapper_reexec_hops:-0}" + cmux_claude_wrapper_is_nonnegative_integer "$hops" || hops=0 + hops=$((hops + 1)) + export cmux_claude_wrapper_reexec_guard="$hops" + if [[ -n "$cmux_claude_wrapper_reexec_target_history" ]]; then + cmux_claude_wrapper_reexec_target_history="$cmux_claude_wrapper_reexec_target_history"$'\n'"$target" + else + cmux_claude_wrapper_reexec_target_history="$target" + fi + cmux_claude_wrapper_reexec_targets="$cmux_claude_wrapper_reexec_target_history" + export cmux_claude_wrapper_reexec_targets + exec "$target" "$@" +} + +cmux_claude_wrapper_init_reexec_guard + # Find the real claude binary, skipping cmux's wrapper and temp shell shims. find_real_claude() { # Honor custom path from Settings > Automation > Claude Code > Claude Binary Path. @@ -395,7 +545,7 @@ exec_real_claude_passthrough() { done unset TERMINFO fi - exec "$REAL_CLAUDE" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" "$@" } if [[ "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then @@ -408,7 +558,7 @@ if [[ "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then reconcile_claude_config_dir_for_resume "$(extract_claude_resume_session_id "$@")" fi REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } - exec "$REAL_CLAUDE" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" "$@" fi if [[ "$IN_CMUX" == "0" ]] || ! cmux_socket_available; then @@ -527,6 +677,23 @@ normalize_node_options_for_restore() { fi } +install_cmux_node_options() { + local guard_path + if guard_path="$(ensure_node_options_restore_module)"; then + if [[ ${NODE_OPTIONS+x} && "${NODE_OPTIONS:-}" == *"--require=$guard_path"* ]]; then + return 0 + fi + if [[ ${NODE_OPTIONS+x} ]]; then + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 + export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" + else + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 + unset CMUX_ORIGINAL_NODE_OPTIONS + fi + export NODE_OPTIONS="$(merge_node_options "$guard_path")" + fi +} + encode_launch_argv() { { printf '%s\0' "$REAL_CLAUDE" @@ -616,6 +783,22 @@ should_inject_claude_hooks() { return 0 } +if (( cmux_claude_wrapper_reexec_hops > 0 )); then + # A previous wrapper pass already made the cmux launch decision and may have + # injected --session-id/--settings. While resolving a finite shim chain, + # refresh per-process hook metadata, then forward argv unchanged so hooks are + # not merged again on re-entry. + unset CLAUDECODE + clear_inherited_claude_auth_selection_env + export CMUX_CLAUDE_PID=$$ + export CMUX_CLAUDE_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" + export CMUX_AGENT_LAUNCH_KIND="claude" + export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CLAUDE" + export CMUX_AGENT_LAUNCH_CWD="$PWD" + install_cmux_node_options + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" "$@" +fi + # Only inject hooks for Claude session entrypoints. Command-like invocations # pass through so Claude subcommands do not receive session/hook flags. if ! should_inject_claude_hooks "$@"; then @@ -648,16 +831,7 @@ 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" -if GUARD_PATH="$(ensure_node_options_restore_module)"; then - if [[ ${NODE_OPTIONS+x} ]]; then - export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 - export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" - else - export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 - unset CMUX_ORIGINAL_NODE_OPTIONS - fi - export NODE_OPTIONS="$(merge_node_options "$GUARD_PATH")" -fi +install_cmux_node_options # Build Claude settings JSON. # Claude Code merges --settings additively with the user's own settings.json. @@ -796,8 +970,8 @@ process.stdout.write(JSON.stringify(acc)); fi if [[ "$SKIP_SESSION_ID" == true ]]; then - exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@" else SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" - exec "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" + cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" --session-id "$SESSION_ID" --settings "$HOOKS_JSON" "$@" fi diff --git a/tests/test_claude_wrapper_mutual_shim_loop.py b/tests/test_claude_wrapper_mutual_shim_loop.py new file mode 100644 index 000000000000..49168c21ccb2 --- /dev/null +++ b/tests/test_claude_wrapper_mutual_shim_loop.py @@ -0,0 +1,890 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WRAPPER = ROOT / "Resources" / "bin" / "cmux-claude-wrapper" + + +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +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" + delimit_secondary_dir = root / "home" / ".delimit" / "managed-shims" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, delimit_primary_dir, delimit_secondary_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) + + shim_template = """#!/usr/bin/env bash +printf 'delimit shim hop: %s\\n' "$0" >&2 +next_path="" +old_ifs="$IFS" +IFS=: +for entry in ${DELIMIT_MANAGED_PATH:-${PATH:-}}; do + if [[ "$entry" == "__SHIM_DIR__" ]]; then + continue + fi + if [[ -z "$next_path" ]]; then + next_path="$entry" + else + next_path="$next_path:$entry" + fi +done +IFS="$old_ifs" +export PATH="$next_path" +exec claude "$@" +""" + for shim_dir in (delimit_primary_dir, delimit_secondary_dir): + write_executable( + shim_dir / "claude", + shim_template.replace("__SHIM_DIR__", str(shim_dir)), + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real claude %s\\n' "$*" +""", + ) + + managed_path = f"{cmux_shim_dir}:{delimit_primary_dir}:{delimit_secondary_dir}:{real_dir}:/usr/bin:/bin" + env = { + "HOME": str(root / "home"), + "PATH": managed_path, + "DELIMIT_MANAGED_PATH": managed_path, + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + return cmux_shim, env + + +def test_wrapper_stops_mutual_foreign_shim_loop(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-mutual-shim-loop-") as td: + claude, env = build_mutual_shim_tree(Path(td)) + try: + result = subprocess.run( + [str(claude), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append("mutual shim repro timed out instead of terminating") + return + + combined_output = result.stdout + result.stderr + if result.returncode == 0: + failures.append(f"expected non-zero exit from mutual shim guard, got output: {combined_output!r}") + if "conflicting `claude` shim" not in combined_output: + failures.append(f"expected actionable conflicting-shim error, got: {combined_output!r}") + if "CMUX_CUSTOM_CLAUDE_PATH" not in combined_output: + failures.append(f"expected CMUX_CUSTOM_CLAUDE_PATH remedy, got: {combined_output!r}") + + +def test_wrapper_stops_node_based_foreign_shim_loop(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-node-mutual-shim-loop-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-node-loop" + node_primary_dir = root / "home" / ".node-shim" / "primary" + node_secondary_dir = root / "home" / ".node-shim" / "secondary" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, node_primary_dir, node_secondary_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) + + node_shim_template = """#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +process.stderr.write(`node shim hop: ${process.argv[1]}\\n`); +const managedPath = process.env.NODE_SHIM_MANAGED_PATH || process.env.PATH || ""; +process.env.PATH = managedPath + .split(":") + .filter((entry) => entry !== "__SHIM_DIR__") + .join(":"); +const child = spawnSync("claude", process.argv.slice(2), { + env: process.env, + stdio: "inherit", +}); +process.exit(child.status ?? 1); +""" + for shim_dir in (node_primary_dir, node_secondary_dir): + write_executable( + shim_dir / "claude", + node_shim_template.replace("__SHIM_DIR__", str(shim_dir)), + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real claude %s\\n' "$*" +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + managed_path = f"{cmux_shim_dir}:{node_primary_dir}:{node_secondary_dir}:{real_dir}:{inherited_path}" + env = { + "HOME": str(root / "home"), + "PATH": managed_path, + "NODE_SHIM_MANAGED_PATH": managed_path, + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + try: + result = subprocess.run( + [str(cmux_shim), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append("node mutual shim repro timed out instead of terminating") + return + + combined_output = result.stdout + result.stderr + if result.returncode == 0: + failures.append(f"expected non-zero exit from node mutual shim guard, got output: {combined_output!r}") + if "conflicting `claude` shim" not in combined_output: + failures.append(f"expected actionable node conflicting-shim error, got: {combined_output!r}") + + +def test_wrapper_stops_indirect_shell_foreign_shim_loop(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-indirect-mutual-shim-loop-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-indirect-loop" + shell_primary_dir = root / "home" / ".indirect-shim" / "primary" + shell_secondary_dir = root / "home" / ".indirect-shim" / "secondary" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, shell_primary_dir, shell_secondary_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) + + shell_shim_template = """#!/usr/bin/env bash +next_path="" +old_ifs="$IFS" +IFS=: +for entry in ${INDIRECT_SHIM_MANAGED_PATH:-${PATH:-}}; do + if [[ "$entry" == "__SHIM_DIR__" ]]; then + continue + fi + if [[ -z "$next_path" ]]; then + next_path="$entry" + else + next_path="$next_path:$entry" + fi +done +IFS="$old_ifs" +export PATH="$next_path" +cmd=${CLAUDE_BIN:-claude} +exec "$cmd" "$@" +""" + for shim_dir in (shell_primary_dir, shell_secondary_dir): + write_executable( + shim_dir / "claude", + shell_shim_template.replace("__SHIM_DIR__", str(shim_dir)), + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real claude %s\\n' "$*" +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + managed_path = f"{cmux_shim_dir}:{shell_primary_dir}:{shell_secondary_dir}:{real_dir}:{inherited_path}" + env = { + "HOME": str(root / "home"), + "PATH": managed_path, + "INDIRECT_SHIM_MANAGED_PATH": managed_path, + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + try: + result = subprocess.run( + [str(cmux_shim), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append("indirect shell mutual shim repro timed out instead of terminating") + return + + combined_output = result.stdout + result.stderr + if result.returncode == 0: + failures.append(f"expected non-zero exit from indirect shell shim guard, got output: {combined_output!r}") + if "conflicting `claude` shim" not in combined_output: + failures.append(f"expected actionable indirect-shell conflicting-shim error, got: {combined_output!r}") + + +def test_wrapper_guard_allows_child_claude_process(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-child-reentry-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-child" + real_dir = root / "real-bin" + for directory in (cmux_shim_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) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +if (process.argv[2] === "child") { + process.stdout.write("child claude ok\\n"); + process.exit(0); +} +const command = ["cl", "aude"].join(""); +const child = spawnSync(command, ["child"], { + env: process.env, + encoding: "utf8", +}); +if (child.error) { + process.stderr.write(`${child.error.message}\\n`); + process.exit(1); +} +process.stdout.write(child.stdout || ""); +process.stderr.write(child.stderr || ""); +process.exit(child.status ?? 1); +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{real_dir}:{inherited_path}", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + result = subprocess.run( + [str(cmux_shim)], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"child claude launch failed with {result.returncode}: {combined_output!r}") + if result.stdout.strip() != "child claude ok": + failures.append(f"expected child claude to run, got: {combined_output!r}") + if "possible infinite claude shim loop" in combined_output: + failures.append(f"guard fired for legitimate child claude launch: {combined_output!r}") + + +def test_wrapper_allows_finite_layered_foreign_shims(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-finite-shim-chain-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-finite" + shim_a_dir = root / "foreign-a" + shim_b_dir = root / "foreign-b" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, shim_a_dir, shim_b_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) + + shim_template = """#!/usr/bin/env bash +next_path="" +old_ifs="$IFS" +IFS=: +for entry in ${PATH:-}; do + if [[ "$entry" == "__SHIM_DIR__" ]]; then + continue + fi + if [[ -z "$next_path" ]]; then + next_path="$entry" + else + next_path="$next_path:$entry" + fi +done +IFS="$old_ifs" +export PATH="$next_path" +exec claude "$@" +""" + for shim_dir in (shim_a_dir, shim_b_dir): + write_executable( + shim_dir / "claude", + shim_template.replace("__SHIM_DIR__", str(shim_dir)), + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real claude reached %s\\n' "$*" +""", + ) + + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{shim_a_dir}:{shim_b_dir}:{real_dir}:/usr/bin:/bin", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + result = subprocess.run( + [str(cmux_shim), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"finite shim chain failed with {result.returncode}: {combined_output!r}") + if result.stdout.strip() != "real claude reached --version": + failures.append(f"expected finite shim chain to reach real claude, got: {combined_output!r}") + if "possible infinite claude shim loop" in combined_output: + failures.append(f"guard fired for finite shim chain: {combined_output!r}") + + +def test_passthrough_real_node_claude_does_not_receive_guard_env(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-passthrough-guard-env-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-passthrough" + foreign_shim_dir = root / "foreign-shim" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, foreign_shim_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) + + write_executable( + foreign_shim_dir / "claude", + f"""#!/usr/bin/env bash +next_path="" +old_ifs="$IFS" +IFS=: +for entry in ${{PATH:-}}; do + if [[ "$entry" == "{foreign_shim_dir}" ]]; then + continue + fi + if [[ -z "$next_path" ]]; then + next_path="$entry" + else + next_path="$next_path:$entry" + fi +done +IFS="$old_ifs" +export PATH="$next_path" +exec claude "$@" +""", + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env node +const upperGuard = process.env.CMUX_CLAUDE_WRAPPER_REEXEC_GUARD ?? "__unset__"; +const upperTargets = process.env.CMUX_CLAUDE_WRAPPER_REEXEC_TARGETS ?? "__unset__"; +const lowerGuard = process.env.cmux_claude_wrapper_reexec_guard ?? "__unset__"; +const lowerTargets = process.env.cmux_claude_wrapper_reexec_targets ?? "__unset__"; +const surface = process.env.CMUX_SURFACE_ID ?? "__unset__"; +process.stdout.write( + `upperGuard=${upperGuard}\\n` + + `upperTargets=${upperTargets}\\n` + + `lowerGuard=${lowerGuard}\\n` + + `lowerTargets=${lowerTargets}\\n` + + `surface=${surface}\\n` +); +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{foreign_shim_dir}:{real_dir}:{inherited_path}", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + "CMUX_SOCKET_PATH": str(root / "missing.sock"), + "CMUX_SURFACE_ID": "surface-passthrough", + } + result = subprocess.run( + [str(cmux_shim), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + expected = "\n".join( + [ + "upperGuard=__unset__", + "upperTargets=__unset__", + "lowerGuard=__unset__", + "lowerTargets=__unset__", + "surface=__unset__", + ] + ) + output = result.stdout.strip() + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"passthrough real node claude failed with {result.returncode}: {combined_output!r}") + if output != expected: + failures.append(f"expected passthrough cleanup output {expected!r}, got: {combined_output!r}") + + +def test_custom_shim_path_with_colon_is_tracked_as_one_target(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-custom-shim-target-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-custom-colon" + custom_shim_dir = root / "custom:shim" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, custom_shim_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) + + custom_shim = custom_shim_dir / "claude" + write_executable( + custom_shim, + f"""#!/usr/bin/env bash +printf 'custom colon shim hop\\n' >&2 +export PATH="{cmux_shim_dir}:{real_dir}:/usr/bin:/bin" +exec claude "$@" +""", + ) + + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real claude %s\\n' "$*" +""", + ) + + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{real_dir}:/usr/bin:/bin", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + "CMUX_CUSTOM_CLAUDE_PATH": str(custom_shim), + } + try: + result = subprocess.run( + [str(cmux_shim), "--version"], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append("custom shim path with colon timed out instead of terminating") + return + + combined_output = result.stdout + result.stderr + if result.returncode == 0: + failures.append(f"expected non-zero exit from custom shim loop guard, got: {combined_output!r}") + if combined_output.count("custom colon shim hop") != 1: + failures.append(f"expected repeated custom shim target to be detected after one hop, got: {combined_output!r}") + if "conflicting `claude` shim" not in combined_output: + failures.append(f"expected actionable custom-shim error, got: {combined_output!r}") + + +def test_real_shell_claude_launcher_allows_child_claude_process(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-real-shell-launcher-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-shell-launcher" + real_dir = root / "real-bin" + package_dir = real_dir / "node_modules" / "@anthropic-ai" / "claude-code" + for directory in (cmux_shim_dir, package_dir): + directory.mkdir(parents=True, exist_ok=True) + + cmux_shim = cmux_shim_dir / "claude" + shutil.copy2(WRAPPER, cmux_shim) + cmux_shim.chmod(0o755) + + write_executable( + real_dir / "claude", + f"""#!/usr/bin/env bash +exec node "{package_dir}/cli.js" "$@" +""", + ) + write_executable( + package_dir / "cli.js", + """#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +if (process.argv[2] === "child") { + const guard = process.env.cmux_claude_wrapper_reexec_guard ?? "__unset__"; + const targets = process.env.cmux_claude_wrapper_reexec_targets ?? "__unset__"; + process.stdout.write(`child shell launcher ok guard=${guard} targets=${targets}\\n`); + process.exit(0); +} +const command = ["cl", "aude"].join(""); +const child = spawnSync(command, ["child"], { + env: process.env, + encoding: "utf8", +}); +if (child.error) { + process.stderr.write(`${child.error.message}\\n`); + process.exit(1); +} +process.stdout.write(child.stdout || ""); +process.stderr.write(child.stderr || ""); +process.exit(child.status ?? 1); +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{real_dir}:{inherited_path}", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + } + result = subprocess.run( + [str(cmux_shim)], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"real shell claude launcher failed with {result.returncode}: {combined_output!r}") + expected = "child shell launcher ok guard=__unset__ targets=__unset__" + if result.stdout.strip() != expected: + failures.append(f"expected real shell launcher child output {expected!r}, got: {combined_output!r}") + if "possible infinite claude shim loop" in combined_output: + failures.append(f"guard fired for real shell claude launcher child: {combined_output!r}") + + +def test_custom_shell_wrapper_execing_real_claude_allows_child_claude(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-custom-real-wrapper-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-custom-real" + custom_dir = root / "custom-bin" + real_dir = root / "real-bin" + for directory in (cmux_shim_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) + + custom_wrapper = custom_dir / "claude-wrapper" + write_executable( + custom_wrapper, + f"""#!/usr/bin/env bash +export CLAUDE_CUSTOM_WRAPPER_SEEN=1 +exec "{real_dir}/claude" "$@" +""", + ) + write_executable( + real_dir / "claude", + """#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +if (process.argv[2] === "child") { + const guard = process.env.cmux_claude_wrapper_reexec_guard ?? "__unset__"; + const targets = process.env.cmux_claude_wrapper_reexec_targets ?? "__unset__"; + const wrapperSeen = process.env.CLAUDE_CUSTOM_WRAPPER_SEEN ?? "__unset__"; + process.stdout.write(`custom child ok guard=${guard} targets=${targets} wrapper=${wrapperSeen}\\n`); + process.exit(0); +} +const command = ["cl", "aude"].join(""); +const child = spawnSync(command, ["child"], { + env: process.env, + encoding: "utf8", +}); +if (child.error) { + process.stderr.write(`${child.error.message}\\n`); + process.exit(1); +} +process.stdout.write(child.stdout || ""); +process.stderr.write(child.stderr || ""); +process.exit(child.status ?? 1); +""", + ) + + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_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_wrapper), + } + result = subprocess.run( + [str(cmux_shim)], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"custom shell wrapper failed with {result.returncode}: {combined_output!r}") + expected = "custom child ok guard=__unset__ targets=__unset__ wrapper=1" + if result.stdout.strip() != expected: + failures.append(f"expected custom shell wrapper child output {expected!r}, got: {combined_output!r}") + if "possible infinite claude shim loop" in combined_output: + failures.append(f"guard fired for custom shell wrapper child: {combined_output!r}") + + +def test_interactive_finite_shim_chain_does_not_duplicate_hooks(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-interactive-finite-shim-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-interactive" + foreign_shim_dir = root / "foreign-shim" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, foreign_shim_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) + + write_executable( + cmux_shim_dir / "cmux", + """#!/usr/bin/env bash +if [[ "${1:-}" == "--socket" ]]; then + shift 2 +fi +if [[ "${1:-}" == "ping" ]]; then + exit 0 +fi +exit 0 +""", + ) + write_executable( + foreign_shim_dir / "claude", + f"""#!/usr/bin/env bash +export PATH="{cmux_shim_dir}:{real_dir}:/usr/bin:/bin" +exec claude "$@" +""", + ) + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +for arg in "$@"; do + printf '%s\\n' "$arg" +done +""", + ) + + socket_path = str(root / "cmux.sock") + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + test_socket.bind(socket_path) + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{foreign_shim_dir}:{real_dir}:/usr/bin:/bin", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + "CMUX_SURFACE_ID": "surface-interactive", + "CMUX_SOCKET_PATH": socket_path, + } + result = subprocess.run( + [str(cmux_shim)], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + finally: + test_socket.close() + + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"interactive finite shim chain failed with {result.returncode}: {combined_output!r}") + return + + args = result.stdout.splitlines() + settings_indexes = [index for index, arg in enumerate(args) if arg == "--settings"] + if len(settings_indexes) != 1: + failures.append(f"expected one --settings arg after finite shim chain, got: {args!r}") + return + settings_index = settings_indexes[0] + if settings_index + 1 >= len(args): + failures.append(f"expected --settings value after finite shim chain, got: {args!r}") + return + try: + settings = json.loads(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 + + hooks = settings.get("hooks", {}) + session_start = hooks.get("SessionStart", []) + stop = hooks.get("Stop", []) + if len(session_start) != 1 or len(stop) != 3: + failures.append( + "expected one cmux hook injection after finite shim chain, " + f"got SessionStart={len(session_start)} Stop={len(stop)} settings={settings!r}" + ) + + +def test_interactive_spawning_shim_chain_refreshes_claude_pid(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-interactive-spawn-shim-") as td: + root = Path(td) + cmux_shim_dir = root / "tmp" / "cmux-cli-shims" / "surface-spawn" + foreign_shim_dir = root / "node-shim" + real_dir = root / "real-bin" + for directory in (cmux_shim_dir, foreign_shim_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) + + write_executable( + cmux_shim_dir / "cmux", + """#!/usr/bin/env bash +if [[ "${1:-}" == "--socket" ]]; then + shift 2 +fi +if [[ "${1:-}" == "ping" ]]; then + exit 0 +fi +exit 0 +""", + ) + inherited_path = os.environ.get("PATH", "/usr/bin:/bin") + write_executable( + foreign_shim_dir / "claude", + f"""#!/usr/bin/env node +const {{ spawnSync }} = require("node:child_process"); +process.env.PATH = "{cmux_shim_dir}:{real_dir}:{inherited_path}"; +const child = spawnSync("claude", process.argv.slice(2), {{ + env: process.env, + encoding: "utf8", +}}); +process.stdout.write(child.stdout || ""); +process.stderr.write(child.stderr || ""); +process.exit(child.status ?? 1); +""", + ) + write_executable( + real_dir / "claude", + """#!/usr/bin/env bash +printf 'real_pid=%s\\n' "$$" +printf 'cmux_pid=%s\\n' "${CMUX_CLAUDE_PID:-__unset__}" +printf 'node_options=%s\\n' "${NODE_OPTIONS:-__unset__}" +for arg in "$@"; do + printf 'arg=%s\\n' "$arg" +done +""", + ) + + socket_path = str(root / "cmux.sock") + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + test_socket.bind(socket_path) + env = { + "HOME": str(root / "home"), + "PATH": f"{cmux_shim_dir}:{foreign_shim_dir}:{real_dir}:{inherited_path}", + "CMUX_CLAUDE_WRAPPER_SHIM": str(cmux_shim), + "CMUX_CLAUDE_WRAPPER_SHIM_ROOT": str(cmux_shim_dir), + "CMUX_SURFACE_ID": "surface-spawn", + "CMUX_SOCKET_PATH": socket_path, + } + result = subprocess.run( + [str(cmux_shim)], + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + finally: + test_socket.close() + + combined_output = result.stdout + result.stderr + if result.returncode != 0: + failures.append(f"interactive spawning shim chain failed with {result.returncode}: {combined_output!r}") + return + + lines = result.stdout.splitlines() + values: dict[str, str] = {} + args: list[str] = [] + for line in lines: + if line.startswith("arg="): + args.append(line.removeprefix("arg=")) + elif "=" in line: + key, value = line.split("=", 1) + values[key] = value + if values.get("real_pid") != values.get("cmux_pid"): + failures.append(f"expected CMUX_CLAUDE_PID to match real process pid, got: {combined_output!r}") + return + node_options = values.get("node_options", "") + if "--require=" not in node_options or "--max-old-space-size=4096" not in node_options: + failures.append(f"expected reentry to reinstall cmux NODE_OPTIONS, got: {combined_output!r}") + return + + settings_indexes = [index for index, arg in enumerate(args) if arg == "--settings"] + if len(settings_indexes) != 1: + failures.append(f"expected one --settings arg after spawning shim chain, got: {combined_output!r}") + return + settings_index = settings_indexes[0] + 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]) + 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}") + + +def main() -> int: + failures: list[str] = [] + test_wrapper_stops_mutual_foreign_shim_loop(failures) + test_wrapper_stops_node_based_foreign_shim_loop(failures) + test_wrapper_stops_indirect_shell_foreign_shim_loop(failures) + test_wrapper_guard_allows_child_claude_process(failures) + test_wrapper_allows_finite_layered_foreign_shims(failures) + test_passthrough_real_node_claude_does_not_receive_guard_env(failures) + test_custom_shim_path_with_colon_is_tracked_as_one_target(failures) + test_real_shell_claude_launcher_allows_child_claude_process(failures) + 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) + if failures: + print("FAIL: claude wrapper mutual shim loop checks failed") + for failure in failures: + print(f"- {failure}") + return 1 + print("PASS: claude wrapper stops mutual shim loops") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())