Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
202 changes: 188 additions & 14 deletions Resources/bin/cmux-claude-wrapper
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Loading
Loading