diff --git a/loopx/claude_goal_mode/scripts/install.py b/loopx/claude_goal_mode/scripts/install.py index 928639944d..324f9048ed 100644 --- a/loopx/claude_goal_mode/scripts/install.py +++ b/loopx/claude_goal_mode/scripts/install.py @@ -34,10 +34,29 @@ def _p(*parts) -> str: return str(PLUGIN_ROOT.joinpath(*parts)).replace("\\", "/") +def _launches_python(py: str) -> bool: + """True when running `py` actually starts an interpreter. + + `shutil.which` only proves a file is executable, not that it runs Python. On + Windows it can return the 0-byte `WindowsApps` App Execution Alias, which + never starts Python: on some hosts it exits non-zero (9009), on others + launching it raises OSError, and it writes nothing either way. Anything that + bakes that path (the PreToolUse hook, the statusline, `/loopx`) then + silently does nothing.""" + try: + return subprocess.run([py, "-c", ""], capture_output=True, timeout=10).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + def _python_cmd() -> str: """Interpreter to bake into hook / statusline / command strings — robust where - only `python` (not `python3`) is registered.""" - return shutil.which("python3") or shutil.which("python") or sys.executable + only `python` (not `python3`) is registered, and where `python3` resolves to a + launcher that cannot run Python (the Windows `WindowsApps` alias).""" + for candidate in (shutil.which("python3"), shutil.which("python")): + if candidate and _launches_python(candidate): + return candidate + return sys.executable def deep_merge(base: dict, add: dict) -> dict: diff --git a/tests/test_claude_goal_install_python_launcher.py b/tests/test_claude_goal_install_python_launcher.py new file mode 100644 index 0000000000..7c65145709 --- /dev/null +++ b/tests/test_claude_goal_install_python_launcher.py @@ -0,0 +1,140 @@ +r"""The Claude adapter installer must bake a launcher that actually runs Python. + +`install.py --harden` writes a PreToolUse hook command and a statusline command +into `settings.json`, and `command_md()` writes the `/loopx` command with the +same interpreter. All of them bake one PATH lookup of `python3` - the first +interpreter name found, without ever running it. + +On Windows, `shutil.which("python3")` can resolve to the 0-byte `WindowsApps` +App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe`). That +file exists and passes `shutil.which`, but it never starts Python - on some +hosts executing it exits non-zero (9009), on others launching it raises OSError, +and it writes nothing either way. An installer that bakes that path writes hook / +statusline / `/loopx` commands that silently do nothing: +the `--harden` should_run gate fails OPEN (Claude Code treats the non-zero, +decision-less hook exit as a non-blocking error and runs the tool anyway), the +statusline renders nothing, and `/loopx` never runs. + +The installer must only bake an interpreter it has confirmed can launch Python. +PR #6166 named this residual as its out-of-scope successor. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.claude_goal_mode.scripts import install as claude_install + + +def _dead_python3_alias(tmp_path: Path) -> Path: + """A stand-in for the 0-byte `WindowsApps` App Execution Alias. + + Executing it raises OSError - WinError 193 ("not a valid Win32 application") + on Windows, "Permission denied" on POSIX - so it has the same + `shutil.which`-passes / cannot-run shape as the real alias, on any host. + """ + alias = tmp_path / "WindowsApps" / "python3.EXE" + alias.parent.mkdir(parents=True, exist_ok=True) + alias.write_bytes(b"") + return alias + + +@pytest.fixture() +def alias_host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A PATH where `python3` is the dead alias and `python` is a real interpreter.""" + alias = _dead_python3_alias(tmp_path) + + def which(name: str) -> str | None: + if name == "python3": + return str(alias) + if name == "python": + return sys.executable + return None + + monkeypatch.setattr(claude_install.shutil, "which", which) + return alias + + +def test_python_cmd_skips_a_python3_alias_that_cannot_run(alias_host: Path) -> None: + assert claude_install._python_cmd() == sys.executable + + +def test_python_cmd_falls_back_to_sys_executable_when_no_candidate_runs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + alias = _dead_python3_alias(tmp_path) + monkeypatch.setattr(claude_install.shutil, "which", lambda name: str(alias)) + assert claude_install._python_cmd() == sys.executable + + +def test_baked_commands_do_not_carry_the_dead_alias(alias_host: Path) -> None: + alias = str(alias_host) + hardening = claude_install.hardening_block() + hook_cmd = hardening["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + status_cmd = hardening["statusLine"]["command"] + command = claude_install.command_md() + + assert sys.executable in hook_cmd and alias not in hook_cmd + assert sys.executable in status_cmd and alias not in status_cmd + assert f"allowed-tools: Bash({sys.executable}:*)" in command + assert alias not in command + + +def test_installed_gate_command_actually_runs_python(alias_host: Path, tmp_path: Path) -> None: + """Run exactly the hook command `--harden` installs, the way Claude Code does. + + Claude Code executes the command through a shell with the event JSON on + stdin. With the dead alias baked in, the shell finds a file that exits 9009 + and prints nothing; the event never reaches the gate. + """ + settings = tmp_path / "claude" / "settings.json" + claude_install.add_hardening(dry=False, settings_path=settings) + command = json.loads(settings.read_text(encoding="utf-8"))["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + + project = tmp_path / "project" + project.mkdir() + event = json.dumps({"cwd": str(project), "tool_name": "Read", "tool_input": {}}) + result = subprocess.run(command, shell=True, input=event.encode("utf-8"), capture_output=True) + + assert result.returncode == 0, (result.returncode, result.stdout, result.stderr) + assert json.loads(result.stdout.decode("utf-8")) == {} + + +def test_python_cmd_keeps_the_python3_preference_when_it_runs(monkeypatch: pytest.MonkeyPatch) -> None: + tried: list[str] = [] + + def launches(py: str) -> bool: + tried.append(py) + return True + + monkeypatch.setattr(claude_install.shutil, "which", lambda name: f"/fake/{name}") + monkeypatch.setattr(claude_install, "_launches_python", launches) + assert claude_install._python_cmd() == "/fake/python3" + assert tried == ["/fake/python3"] + + +def test_python_cmd_falls_through_python3_to_python(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(claude_install.shutil, "which", lambda name: f"/fake/{name}") + monkeypatch.setattr(claude_install, "_launches_python", lambda py: py.endswith("python")) + assert claude_install._python_cmd() == "/fake/python" + + +def test_launches_python_rejects_a_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + returncode = 9009 + + monkeypatch.setattr(claude_install.subprocess, "run", lambda *args, **kwargs: Result()) + assert claude_install._launches_python("python3") is False + + +def test_launches_python_accepts_a_clean_run(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + returncode = 0 + + monkeypatch.setattr(claude_install.subprocess, "run", lambda *args, **kwargs: Result()) + assert claude_install._launches_python("python3") is True