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
81 changes: 62 additions & 19 deletions Resources/bin/cmux-hermes-python-wrapper
Original file line number Diff line number Diff line change
Expand Up @@ -17,42 +17,85 @@ cmux_hermes_accept_python() {
CMUX_HERMES_REAL_PYTHON_RESULT="$candidate"
}

# Print an interpreter's major.minor version, or nothing when unknown.
cmux_hermes_launcher_python_version() {
local candidate="$1"
case "${candidate##*/}" in
python*|Python) ;;
*) return 0 ;;
esac
"$candidate" -I -S -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null
}

# Print a venv's major.minor version from pyvenv.cfg without running it.
cmux_hermes_venv_python_version() {
local key value
[[ -f "$1/pyvenv.cfg" ]] || return 0
while IFS='=' read -r key value; do
key="${key//[[:space:]]/}"
value="${value//[[:space:]]/}"
case "$key" in
version|version_info)
if [[ "$value" =~ ^([0-9]+\.[0-9]+) ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
;;
esac
done < "$1/pyvenv.cfg"
}

cmux_hermes_resolve_real_python() {
local candidate launcher_pid
local candidate launcher_pid launcher_python="" launcher_version="" venv venv_version

if cmux_hermes_accept_python "${CMUX_HERMES_TUI_REAL_PYTHON:-}"; then
return 0
fi

# Prefer Hermes's environment-local interpreter. On macOS, `ps comm`
# resolves symlinked venv Python launchers to the base interpreter, which
# does not include Hermes's site-packages.
if [[ -n "${HERMES_PYTHON_SRC_ROOT:-}" ]]; then
for candidate in \
"$HERMES_PYTHON_SRC_ROOT/venv/bin/python" \
"$HERMES_PYTHON_SRC_ROOT/venv/bin/python3" \
"$HERMES_PYTHON_SRC_ROOT/.venv/bin/python" \
"$HERMES_PYTHON_SRC_ROOT/.venv/bin/python3"; do
if cmux_hermes_accept_python "$candidate"; then
return 0
fi
done
fi

# The Node TUI is a direct child of Hermes's Python launcher. `comm` gives
# the launcher's exact interpreter without parsing a command line or
# assuming a particular Hermes installation layout. Keep this as a
# fallback for installations without a discoverable source root.
# assuming a particular Hermes installation layout.
launcher_pid="$(/bin/ps -p "$PPID" -o ppid= 2>/dev/null | /usr/bin/tr -d '[:space:]')"
if [[ "$launcher_pid" =~ ^[1-9][0-9]*$ ]]; then
candidate="$(/bin/ps -ww -p "$launcher_pid" -o comm= 2>/dev/null || true)"
candidate="${candidate#"${candidate%%[![:space:]]*}"}"
candidate="${candidate%"${candidate##*[![:space:]]}"}"
if cmux_hermes_accept_python "$candidate"; then
return 0
launcher_python="$CMUX_HERMES_REAL_PYTHON_RESULT"
fi
fi

# Prefer Hermes's environment-local interpreter. On macOS, `ps comm`
# resolves symlinked venv Python launchers to the base interpreter, which
# does not include Hermes's site-packages. A source checkout can also hold
# an unrelated test venv, so skip one whose Python version differs from
# the launcher's: Hermes's bootstrap adds site-packages built for the
# launcher's version.
if [[ -n "${HERMES_PYTHON_SRC_ROOT:-}" ]]; then
for venv in "$HERMES_PYTHON_SRC_ROOT/venv" "$HERMES_PYTHON_SRC_ROOT/.venv"; do
[[ -d "$venv" ]] || continue
venv_version="$(cmux_hermes_venv_python_version "$venv")"
if [[ -n "$venv_version" && -n "$launcher_python" ]]; then
if [[ -z "$launcher_version" ]]; then
launcher_version="$(cmux_hermes_launcher_python_version "$launcher_python")"
fi
if [[ -n "$launcher_version" && "$venv_version" != "$launcher_version" ]]; then
continue
fi
fi
for candidate in "$venv/bin/python" "$venv/bin/python3"; do
if cmux_hermes_accept_python "$candidate"; then
return 0
fi
done
done
fi

if [[ -n "$launcher_python" ]]; then
CMUX_HERMES_REAL_PYTHON_RESULT="$launcher_python"
return 0
fi

cmux_hermes_accept_python "python3"
}

Expand Down
67 changes: 67 additions & 0 deletions tests/test_hermes_wrapper_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,72 @@ def test_tui_python_wrapper_prefers_symlinked_venv_interpreter(failures: list[st
)


def run_tui_python_wrapper_under_python_launcher(
source_root: Path,
output: Path,
) -> subprocess.CompletedProcess[str]:
# Mirror Hermes's process tree: a Python launcher starts an intermediate
# process (the Node TUI), which spawns the gateway through the wrapper.
# The trailing `exit` keeps sh from exec'ing the wrapper in its place.
env = os.environ.copy()
env.pop("CMUX_HERMES_TUI_REAL_PYTHON", None)
env["HERMES_PYTHON_SRC_ROOT"] = str(source_root)
gateway = [
str(SOURCE_TUI_PYTHON_WRAPPER),
"-c",
"import sys; open(sys.argv[1], 'w').write('launcher')",
str(output),
]
launcher = (
"import subprocess, sys; "
"sys.exit(subprocess.run(['/bin/sh', '-c', '\"$0\" \"$@\"; exit $?'] + sys.argv[1:]).returncode)"
)
return subprocess.run(
[sys.executable, "-c", launcher, *gateway],
env=env,
capture_output=True,
text=True,
)


def make_fake_tui_venv(source_root: Path, version: str, output: Path) -> None:
venv = source_root / ".venv"
(venv / "bin").mkdir(parents=True)
(venv / "pyvenv.cfg").write_text(
f"home = /nonexistent/bin\nversion_info = {version}\n",
encoding="utf-8",
)
python = venv / "bin" / "python"
python.write_text(
"#!/bin/sh\n"
f"printf venv > '{output}'\n",
encoding="utf-8",
)
python.chmod(0o755)


def test_tui_python_wrapper_skips_venv_for_other_python_version(failures: list[str]) -> None:
launcher_version = "%d.%d" % sys.version_info[:2]
cases = [
("mismatched venv", "2.7.18", "launcher"),
("matching venv", f"{launcher_version}.0", "venv"),
]
for label, venv_version, expected in cases:
with tempfile.TemporaryDirectory(prefix="cmux-hermes-python-test-") as td:
tmp = Path(td)
source_root = tmp / "hermes"
output = tmp / "selected"
make_fake_tui_venv(source_root, venv_version, output)
result = run_tui_python_wrapper_under_python_launcher(source_root, output)
expect(result.returncode == 0, f"{label}: wrapper failed: {result.stderr}", failures)
selected = output.read_text(encoding="utf-8") if output.exists() else ""
expect(
selected == expected,
f"{label}: expected the {expected} interpreter, got {selected or 'nothing'}",
failures,
)


def test_tui_gateway_rejects_retired_cmux_python_wrapper(failures: list[str]) -> None:
expected_events = [
f"gateway:{event}:{turn}"
Expand Down Expand Up @@ -1050,6 +1116,7 @@ def main() -> int:
test_tui_bridge_fails_closed_on_untrusted_session_files(failures)
test_tui_gateway_registers_hooks_for_every_turn(failures)
test_tui_python_wrapper_prefers_symlinked_venv_interpreter(failures)
test_tui_python_wrapper_skips_venv_for_other_python_version(failures)
test_tui_gateway_rejects_retired_cmux_python_wrapper(failures)
test_bundled_wrappers_ignore_path_bash_shadow(failures)
test_explicit_classic_cli_skips_tui_watcher(failures)
Expand Down