From 66d3ea43e7895ef4a39803655cd614953a0e2f86 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sun, 11 Oct 2026 18:26:57 +0800 Subject: [PATCH 1/2] fix(claude-goal-mode): bake a launcher that actually runs Python into the Claude adapter The Claude Code adapter baked a bare `python3` PATH lookup into the PreToolUse hook, the statusline and the `/loopx` command. On Windows that name can resolve to the 0-byte `WindowsApps` App Execution Alias, which exits 9009 writing nothing, so the optional `--harden` gate silently failed OPEN, the statusline rendered nothing, and `/loopx` never ran. PR #6166 named this residual. `_python_cmd()` now bakes only an interpreter it has confirmed can launch Python: the preference order (`python3` -> `python` -> `sys.executable`) and POSIX behavior are unchanged. Verified on a zh-CN Windows host where `shutil.which("python3")` is that alias (0 bytes, rc 9009): the exact installed hook command now exits 0 with its `{}` no-op decision, where the base commit exited 9009 with empty stdout. The new tests fail 4/4 before the change and pass after. Signed-off-by: JasonBuildAI --- loopx/claude_goal_mode/scripts/install.py | 21 +++- ...est_claude_goal_install_python_launcher.py | 104 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/test_claude_goal_install_python_launcher.py diff --git a/loopx/claude_goal_mode/scripts/install.py b/loopx/claude_goal_mode/scripts/install.py index 928639944d..1ba6ca952e 100644 --- a/loopx/claude_goal_mode/scripts/install.py +++ b/loopx/claude_goal_mode/scripts/install.py @@ -34,10 +34,27 @@ 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 + exits 9009 writing nothing, so anything that bakes that path (the PreToolUse + hook, the statusline, `/loopx`) silently does nothing.""" + try: + return subprocess.run([py, "-c", ""], capture_output=True).returncode == 0 + except OSError: + 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..a4dee6789d --- /dev/null +++ b/tests/test_claude_goal_install_python_launcher.py @@ -0,0 +1,104 @@ +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 executing it exits 9009 without +starting Python and without writing to stdout or stderr. 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")) == {} From 5673f99e92a3b778dce9aa447d0334e6ce0dafef Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sun, 11 Oct 2026 19:14:15 +0800 Subject: [PATCH 2/2] test(claude-goal-mode): cover the launcher preference order and nonzero exits Follow-up to the independent review of this PR: the tests now pin the `python3` -> `python` preference and both rejection branches of the interpreter probe (non-zero exit and launch failure), and the probe bounds a candidate launch with a timeout so a hanging launcher falls through instead of stalling the installer. Docstrings no longer assume a single host shape - the Windows alias exits 9009 on some hosts and raises OSError on others. Signed-off-by: JasonBuildAI --- loopx/claude_goal_mode/scripts/install.py | 10 +++-- ...est_claude_goal_install_python_launcher.py | 42 +++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/loopx/claude_goal_mode/scripts/install.py b/loopx/claude_goal_mode/scripts/install.py index 1ba6ca952e..324f9048ed 100644 --- a/loopx/claude_goal_mode/scripts/install.py +++ b/loopx/claude_goal_mode/scripts/install.py @@ -39,11 +39,13 @@ def _launches_python(py: str) -> bool: `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 - exits 9009 writing nothing, so anything that bakes that path (the PreToolUse - hook, the statusline, `/loopx`) silently does nothing.""" + 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).returncode == 0 - except OSError: + return subprocess.run([py, "-c", ""], capture_output=True, timeout=10).returncode == 0 + except (OSError, subprocess.SubprocessError): return False diff --git a/tests/test_claude_goal_install_python_launcher.py b/tests/test_claude_goal_install_python_launcher.py index a4dee6789d..7c65145709 100644 --- a/tests/test_claude_goal_install_python_launcher.py +++ b/tests/test_claude_goal_install_python_launcher.py @@ -7,9 +7,10 @@ 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 executing it exits 9009 without -starting Python and without writing to stdout or stderr. An installer that bakes -that path writes hook / statusline / `/loopx` commands that silently do nothing: +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. @@ -102,3 +103,38 @@ def test_installed_gate_command_actually_runs_python(alias_host: Path, tmp_path: 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