Skip to content

Ensure shell integrations always dispatch claude through the bundled wrapper - #2465

Merged
austinywang merged 5 commits into
mainfrom
issue-2448-cmux-claude-wrapper
Apr 1, 2026
Merged

austinywang merged 5 commits into
mainfrom
issue-2448-cmux-claude-wrapper

Conversation

@austinywang

@austinywang austinywang commented Apr 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2448.

Summary

  • add a regression test that boots zsh and bash shell integration and verifies claude still resolves to the bundled wrapper when GHOSTTY_BIN_DIR is unset and PATH later prefers another binary
  • install a claude shell function in the zsh and bash integrations using the wrapper path derived from CMUX_SHELL_INTEGRATION_DIR
  • keep the regression in a separate first commit so CI can show the test fails without the fix

Verification

  • ./scripts/setup.sh
  • ./scripts/reload.sh --tag issue-2448
  • zsh -n Resources/shell-integration/cmux-zsh-integration.zsh
  • bash -n Resources/shell-integration/cmux-bash-integration.bash
  • python3 -m py_compile tests/test_issue_2448_shell_claude_wrapper_dispatch.py

Notes

  • repo policy says not to run the local test suite, so I limited verification to the tagged app build and static checks above

Summary by cubic

Ensure zsh routes claude through the bundled wrapper and bash does unless a user alias/function exists; also pin Claude hooks to the bundled cmux so PATH changes or unset GHOSTTY_BIN_DIR can't break dispatch. Fixes #2448.

  • Bug Fixes
    • Install a claude shell function from CMUX_SHELL_INTEGRATION_DIR that stores _CMUX_CLAUDE_WRAPPER and stays ahead of later PATH changes; zsh unaliases and installs, bash preserves existing claude aliases/functions.
    • Pin hook commands in Resources/bin/claude by exporting CMUX_CLAUDE_HOOK_CMUX_BIN (from CMUX_BUNDLED_CLI_PATH, wrapper dir, or PATH) and referencing it in the hooks JSON; add tests for hook pinning and zsh/bash alias/function cases.

Written for commit adfb7ab. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Improved bash and zsh shell integration so the bundled Claude wrapper is used reliably (overrides existing aliases and forwards arguments).
  • Tests

    • Added a regression test that verifies Claude command dispatch across interactive bash/zsh scenarios, including alias cases.

@vercel

vercel Bot commented Apr 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 1, 2026 8:15am

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

@coderabbitai

coderabbitai Bot commented Apr 1, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Defines a shell-level Claude dispatch in bash and zsh integration: locate the bundled Resources/bin/claude, remove any claude alias, and install a claude() shell function that forwards arguments to the bundled wrapper. Adds a regression test exercising both shells and alias preconditions.

Changes

Cohort / File(s) Summary
Shell Integration (bash)
Resources/shell-integration/cmux-bash-integration.bash
Add _CMUX_CLAUDE_WRAPPER variable and _cmux_install_claude_wrapper(); on init detect bundled bin/claude, unalias any claude, and eval-install claude() to forward args to the wrapper.
Shell Integration (zsh)
Resources/shell-integration/cmux-zsh-integration.zsh
Add _CMUX_CLAUDE_WRAPPER and _cmux_install_claude_wrapper()-style logic: compute absolute wrapper path, verify executable, remove claude alias, and define claude() that forwards arguments to the bundled wrapper before PATH changes.
Regression Test
tests/test_issue_2448_shell_claude_wrapper_dispatch.py
New end-to-end test creating a temp bundle with mock wrapper and real claude binaries; runs interactive zsh and bash (with and without pre-defined alias) and asserts the bundled wrapper is invoked in all scenarios.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant UserShell as "User Shell"
    participant ShellFunc as "claude() (shell function)"
    participant Bundled as "Resources/bin/claude (wrapper)"
    participant RealBin as "real claude (PATH)"

    UserShell->>ShellFunc: User runs `claude arg1 arg2`
    ShellFunc->>Bundled: exec "$_CMUX_CLAUDE_WRAPPER" "$@"
    Bundled->>RealBin: (may forward to real binary or handle)
    RealBin-->>Bundled: exit/result
    Bundled-->>ShellFunc: exit/result
    ShellFunc-->>UserShell: exit/result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I found a wrapper snug and neat,
A function raced ahead of PATH's long street.
No alias wins, the bundle steers the way,
Zsh and bash both cheer today —
Hop, run, dispatch, hooray! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: ensuring shell integrations dispatch claude through the bundled wrapper.
Linked Issues check ✅ Passed All coding requirements from issue #2448 are met: claude shell functions added to both zsh and bash integrations, regression test added, bundled wrapper dispatch ensured regardless of PATH changes.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue: two shell integration files modified and one regression test added, with no unrelated modifications present.
Description check ✅ Passed The pull request description includes a comprehensive summary of changes, testing verification steps, and notes explaining the limitations and approach taken.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2448-cmux-claude-wrapper

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…-2448-cmux-claude-wrapper

# Conflicts:
#	Resources/shell-integration/cmux-bash-integration.bash
@greptile-apps

greptile-apps Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes issue #2448 by installing a claude() shell function in both the zsh and bash integrations that pins dispatch to the bundled wrapper binary regardless of how PATH is later mutated. Previously, the only guard was _cmux_fix_path, which is a no-op when GHOSTTY_BIN_DIR is unset, leaving the session exposed to PATH shadowing. The fix derives the wrapper path from CMUX_SHELL_INTEGRATION_DIR (stripping the /shell-integration suffix to find Resources/bin/claude) and defines a claude() function that executes it directly, bypassing PATH entirely for the interactive shell.

Key points:

  • The path-derivation logic (${integration_dir%/shell-integration}) is correct and gracefully bails out (returns 0) if the expected layout isn't found.
  • Both zsh and bash implementations are symmetric, with appropriate typeset -g / builtin unalias idioms for each shell.
  • A self-contained regression test boots real zsh and bash subprocesses, injects a fake bundled wrapper and a competing real-bin, and asserts that only the wrapper is invoked after PATH is mutated — correctly verifying the runtime behavior.
  • The two-commit structure (test-first, then fix) follows the repo's regression test commit policy.
  • Minor: the bash claude function is not exported (export -f) and _CMUX_CLAUDE_WRAPPER is not exported, so bash subshells won't inherit the fix — but this gap pre-existed for the GHOSTTY_BIN_DIR-unset scenario, so it is not a regression.
  • The test's subprocess.run(..., timeout=8) calls don't catch subprocess.TimeoutExpired, which would surface as an unhandled exception on a slow CI runner rather than a clean FAIL.

Confidence Score: 5/5

Safe to merge — the fix is correct, well-tested, and all remaining findings are P2 style suggestions.

All identified issues are P2: the bash subshell export gap is a pre-existing limitation (not a regression), and the unhandled TimeoutExpired in the test is a minor robustness concern. The core fix logic — installing a shell function that bypasses PATH — is sound and correctly verified by the new regression test.

No files require special attention.

Important Files Changed

Filename Overview
Resources/shell-integration/cmux-bash-integration.bash Adds _cmux_install_claude_wrapper that defines a claude() shell function pointing at the bundled binary; correctly derives the wrapper path from CMUX_SHELL_INTEGRATION_DIR and guards with an executable check before installing.
Resources/shell-integration/cmux-zsh-integration.zsh Mirrors the bash change using typeset -g and builtin unalias; logic is identical and correct for zsh.
tests/test_issue_2448_shell_claude_wrapper_dispatch.py Standalone regression test that spawns real zsh/bash subprocesses and asserts the wrapper binary (not the PATH binary) is called; test structure and path derivation are correct, but subprocess.TimeoutExpired is not caught, which would surface as an unhandled exception rather than a clean FAIL on slow CI.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Shell starts (zsh / bash)"] --> B["Integration sourced\n.zshenv → cmux-zsh-integration.zsh\n--norc + source cmux-bash-integration.bash"]
    B --> C["_cmux_install_claude_wrapper()"]
    C --> D{"CMUX_SHELL_INTEGRATION_DIR\nset?"}
    D -- No --> E["return 0 (no-op)"]
    D -- Yes --> F["Derive bundle_dir\n= integration_dir%/shell-integration"]
    F --> G{"bundle_dir/bin/claude\nexecutable?"}
    G -- No --> E
    G -- Yes --> H["Set _CMUX_CLAUDE_WRAPPER\n= bundle_dir/bin/claude"]
    H --> I["unalias claude\ndefine claude() function"]
    I --> J["User later mutates PATH\ne.g. prepends real-bin/"]
    J --> K["User types: claude ..."]
    K --> L["Shell function intercepts"]
    L --> M["Executes _CMUX_CLAUDE_WRAPPER\n= bundled wrapper ✓"]
    J --> N["Without this fix:\ncommand claude → PATH lookup"]
    N --> O["Finds real-bin/claude ✗"]
Loading

Reviews (1): Last reviewed commit: "Merge branch 'main' of https://github.co..." | Re-trigger Greptile

Comment on lines +65 to +72
result = subprocess.run(
["zsh", "-d", "-i", "-c", 'PATH="$CMUX_TEST_REAL_BIN:$PATH"; claude zsh-case'],
env=env,
capture_output=True,
text=True,
timeout=8,
check=False,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TimeoutExpired not caught — surfaces as stack trace instead of clean FAIL

subprocess.run(..., timeout=8) raises subprocess.TimeoutExpired if the shell hangs. This exception is not caught anywhere in run_zsh or run_bash, so a timeout would crash the test script with an unhandled exception rather than appending a message to failures and returning 1. The same applies to run_bash at line 85–98.

Suggested change
result = subprocess.run(
["zsh", "-d", "-i", "-c", 'PATH="$CMUX_TEST_REAL_BIN:$PATH"; claude zsh-case'],
env=env,
capture_output=True,
text=True,
timeout=8,
check=False,
)
try:
result = subprocess.run(
["zsh", "-d", "-i", "-c", 'PATH="$CMUX_TEST_REAL_BIN:$PATH"; claude zsh-case'],
env=env,
capture_output=True,
text=True,
timeout=8,
check=False,
)
except subprocess.TimeoutExpired:
return 1, "zsh timed out", []

Comment on lines +57 to +62
_CMUX_CLAUDE_WRAPPER="$wrapper_path"
unalias claude >/dev/null 2>&1 || true
claude() {
"$_CMUX_CLAUDE_WRAPPER" "$@"
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bash claude function and _CMUX_CLAUDE_WRAPPER not exported to subshells

The claude() function reads $_CMUX_CLAUDE_WRAPPER dynamically at call time, but neither the variable nor the function itself is exported. Any bash subshell launched from the interactive session (bash -c 'claude ...', inline shell scripts, etc.) will lose the wrapper and fall back to PATH lookup. The same gap exists in the zsh integration (zsh has no export -f equivalent, so nothing can be done there).

For bash, adding both exports after the function definition would extend the fix to subshells:

_CMUX_CLAUDE_WRAPPER="$wrapper_path"
unalias claude >/dev/null 2>&1 || true
claude() {
    "$_CMUX_CLAUDE_WRAPPER" "$@"
}
export _CMUX_CLAUDE_WRAPPER
export -f claude

Note: this was also broken before this PR for the GHOSTTY_BIN_DIR-unset case (since _cmux_fix_path was a no-op), so this is not a regression introduced here — it's a pre-existing gap that this fix partially addresses.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Resources/shell-integration/cmux-zsh-integration.zsh`:
- Around line 69-72: The current function definition uses the shorthand
"claude() { ... }" which allows zsh alias expansion at parse time and can
accidentally expand a pre-existing "alias claude" before the "builtin unalias
claude >/dev/null 2>&1 || true" runs; change the declaration to the explicit
"function claude { ... }" form to suppress alias expansion during sourcing,
keeping the body calling "$_CMUX_CLAUDE_WRAPPER" "$@" unchanged so the function
still delegates to the wrapper.

In `@tests/test_issue_2448_shell_claude_wrapper_dispatch.py`:
- Around line 78-83: The test's env inherits os.environ so bash can still source
BASH_ENV; modify the env dict used by run_bash (the local variable env created
in the snippet) to remove/clear the "BASH_ENV" key before launching bash (e.g.
env.pop("BASH_ENV", None) or set env["BASH_ENV"] = ""), ensuring run_bash
invocation will not leak host shell configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8bb79833-85aa-4bd4-9da4-2e2d9e285708

📥 Commits

Reviewing files that changed from the base of the PR and between f1acbcb and de5b1e9.

📒 Files selected for processing (3)
  • Resources/shell-integration/cmux-bash-integration.bash
  • Resources/shell-integration/cmux-zsh-integration.zsh
  • tests/test_issue_2448_shell_claude_wrapper_dispatch.py

Comment thread Resources/shell-integration/cmux-zsh-integration.zsh Outdated
Comment on lines +78 to +83
env = dict(os.environ)
env["CMUX_SHELL_INTEGRATION_DIR"] = str(shell_dir)
env["CMUX_TEST_LOG"] = str(log_path)
env["CMUX_TEST_REAL_BIN"] = str(real_bin)
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
env.pop("GHOSTTY_BIN_DIR", None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
env BASH_ENV=/dev/fd/3 bash --noprofile --norc -c 'true' 3<<<'printf "BASH_ENV_RAN\n"'

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

cat -n tests/test_issue_2448_shell_claude_wrapper_dispatch.py | sed -n '70,100p'

Repository: manaflow-ai/cmux

Length of output: 1302


🏁 Script executed:

#!/bin/bash
# Test if BASH_ENV is sourced with --noprofile --norc -c
# Create a temp file and set BASH_ENV to it
tmpfile=$(mktemp)
printf 'export BASH_ENV_SOURCED=yes\n' > "$tmpfile"

# Test 1: with BASH_ENV set
output=$(env BASH_ENV="$tmpfile" bash --noprofile --norc -c 'echo "${BASH_ENV_SOURCED:-no}"')
echo "Test 1 (BASH_ENV set): $output"

# Test 2: without BASH_ENV
output=$(bash --noprofile --norc -c 'echo "${BASH_ENV_SOURCED:-no}"')
echo "Test 2 (BASH_ENV unset): $output"

# Test 3: check if os.environ typically includes BASH_ENV
python3 << 'EOF'
import os
print("BASH_ENV in os.environ:", "BASH_ENV" in os.environ)
if "BASH_ENV" in os.environ:
    print("  Value:", os.environ["BASH_ENV"])
EOF

rm -f "$tmpfile"

Repository: manaflow-ai/cmux

Length of output: 144


🏁 Script executed:

grep -n "subprocess.run" tests/test_issue_2448_shell_claude_wrapper_dispatch.py | head -20

Repository: manaflow-ai/cmux

Length of output: 123


🏁 Script executed:

cat -n tests/test_issue_2448_shell_claude_wrapper_dispatch.py | sed -n '50,75p'

Repository: manaflow-ai/cmux

Length of output: 1166


🏁 Script executed:

cat -n tests/test_issue_2448_shell_claude_wrapper_dispatch.py | sed -n '44,100p'

Repository: manaflow-ai/cmux

Length of output: 2521


🏁 Script executed:

grep -n "^def " tests/test_issue_2448_shell_claude_wrapper_dispatch.py

Repository: manaflow-ai/cmux

Length of output: 432


🏁 Script executed:

grep -r "bash.*--noprofile.*--norc" tests/ --include="*.py" -n

Repository: manaflow-ai/cmux

Length of output: 231


🏁 Script executed:

cat -n tests/test_issue_1138_sidebar_pr_polling.py | sed -n '415,435p'

Repository: manaflow-ai/cmux

Length of output: 884


🏁 Script executed:

# Check if there are other functions in the codebase that also copy os.environ and run bash
rg "subprocess\.run.*bash.*--noprofile" -A 5 -B 5 tests/ --type py | head -60

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg "subprocess\.run" tests/ --type py -A 3 | grep -A 3 "bash"

Repository: manaflow-ai/cmux

Length of output: 541


Clear BASH_ENV before launching bash.

run_bash() inherits os.environ, and bash --noprofile --norc -c still sources the file named by BASH_ENV. This can leak host configuration into the test and make it flaky. Clear it defensively:

     env["CMUX_TEST_REAL_BIN"] = str(real_bin)
     env["PATH"] = f"{real_bin}:/usr/bin:/bin"
     env.pop("GHOSTTY_BIN_DIR", None)
+    env.pop("BASH_ENV", None)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
env = dict(os.environ)
env["CMUX_SHELL_INTEGRATION_DIR"] = str(shell_dir)
env["CMUX_TEST_LOG"] = str(log_path)
env["CMUX_TEST_REAL_BIN"] = str(real_bin)
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
env.pop("GHOSTTY_BIN_DIR", None)
env = dict(os.environ)
env["CMUX_SHELL_INTEGRATION_DIR"] = str(shell_dir)
env["CMUX_TEST_LOG"] = str(log_path)
env["CMUX_TEST_REAL_BIN"] = str(real_bin)
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
env.pop("GHOSTTY_BIN_DIR", None)
env.pop("BASH_ENV", None)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_issue_2448_shell_claude_wrapper_dispatch.py` around lines 78 - 83,
The test's env inherits os.environ so bash can still source BASH_ENV; modify the
env dict used by run_bash (the local variable env created in the snippet) to
remove/clear the "BASH_ENV" key before launching bash (e.g. env.pop("BASH_ENV",
None) or set env["BASH_ENV"] = ""), ensuring run_bash invocation will not leak
host shell configuration.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Resources/shell-integration/cmux-bash-integration.bash">

<violation number="1" location="Resources/shell-integration/cmux-bash-integration.bash:57">
P2: This early return skips installing the bundled `claude` wrapper whenever a user alias/function exists, which defeats the goal of forcing wrapper dispatch. Remove the guard so the wrapper is always installed (and the existing alias is unaliased).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

local wrapper_path="$bundle_dir/bin/claude"
[[ -x "$wrapper_path" ]] || return 0

existing_type="$(type -t claude 2>/dev/null || true)"

@cubic-dev-ai cubic-dev-ai Bot Apr 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This early return skips installing the bundled claude wrapper whenever a user alias/function exists, which defeats the goal of forcing wrapper dispatch. Remove the guard so the wrapper is always installed (and the existing alias is unaliased).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Resources/shell-integration/cmux-bash-integration.bash, line 57:

<comment>This early return skips installing the bundled `claude` wrapper whenever a user alias/function exists, which defeats the goal of forcing wrapper dispatch. Remove the guard so the wrapper is always installed (and the existing alias is unaliased).</comment>

<file context>
@@ -46,13 +46,21 @@ _cmux_restore_scrollback_once
     local wrapper_path="$bundle_dir/bin/claude"
     [[ -x "$wrapper_path" ]] || return 0
 
+    existing_type="$(type -t claude 2>/dev/null || true)"
+    case "$existing_type" in
+        alias|function)
</file context>
Fix with Cubic

This branch was successfully deployed

1 active deployment
Preview — adfb7abe Deployed Apr 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cmux claude wrapper not always used inside cmux terminals

1 participant