From c49be269a18446779cd8a048caaa7f0ba3a3b48b Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Sat, 8 Aug 2026 12:01:40 +0530 Subject: [PATCH] fix(wrap): stop the launch cwd from shadowing the installed package in the proxy subprocess (#2843) ## Description `headroom wrap` starts the proxy via `_start_proxy`, which builds `cmd = [sys.executable, "-m", "headroom.cli", "proxy", ...]`. A `python -m ` invocation prepends the launch cwd to `sys.path`. So when `wrap` is run from a directory that contains a `headroom/` folder (most commonly a clone of this very repo, whose package lives at `/headroom/`), that raw source tree shadows the installed wheel in site-packages. The source tree has no compiled `headroom._core` (the maturin extension only exists in the built wheel), so the proxy dies with: ```text Error: Proxy dependencies not installed. Run: pip install headroom-ai[proxy] Details: No module named 'headroom._core' ``` `wrap` then falls back to launching the client unwrapped, and the "not installed" hint is misleading: the dependency is installed, it is being shadowed by cwd. The fix sets `PYTHONSAFEPATH=1` in the proxy subprocess env. That disables the cwd/script-dir prepend to `sys.path` (Python 3.11+, and a harmless no-op on 3.10, so it never breaks the supported floor), which is exactly what the issue reporter confirmed resolves it: ```console $ PYTHONSAFEPATH=1 python -c "import headroom._core; print('OK')" # -> OK ``` The proxy is still launched as `-m headroom.cli`, so nothing about the invocation changes except that it now always resolves the installed package. Fixes #2793 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/wrap.py` (`_start_proxy`): set `proxy_env["PYTHONSAFEPATH"] = "1"` alongside the existing `PYTHONIOENCODING`, with a comment explaining the cwd-shadow failure mode. - `tests/test_cli/test_wrap_claude_vertex_proxy_env.py`: added `test_start_proxy_sets_pythonsafepath_to_avoid_cwd_shadow`, which drives `_start_proxy` with a faked `subprocess.Popen` and asserts the subprocess env carries `PYTHONSAFEPATH=1` while still launching `-m headroom.cli proxy`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Fail-before (source fix stashed, new test kept): tests/test_cli/test_wrap_claude_vertex_proxy_env.py::test_start_proxy_sets_pythonsafepath_to_avoid_cwd_shadow FAILED assert captured["kwargs"]["env"]["PYTHONSAFEPATH"] == "1" KeyError: 'PYTHONSAFEPATH' # Pass-after (fix applied): tests/test_cli/test_wrap_claude_vertex_proxy_env.py 18 passed # Broader wrap suites: tests/test_cli/test_wrap_claude_vertex_proxy_env.py tests/test_cli_proxy_env.py tests/test_cli/test_wrap_persistent.py 121 passed, 1 skipped # uvx ruff@0.15.17 check -> All checks passed! # uvx mypy@1.20.2 headroom/cli/wrap.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.17 and mypy 1.20.2 via uvx. - Exact command / steps: confirmed `_start_proxy` builds `[sys.executable, "-m", "headroom.cli", "proxy", ...]` and constructs the subprocess env as `proxy_env`, reproduced the shadowing behaviour in the reporter's terms (`python -m` prepends cwd; a cwd `headroom/` without `_core` shadows the wheel), fail-before with `git stash push headroom/cli/wrap.py` and `python -m pytest ... -k pythonsafepath` (the env lacks the key), then pass-after with `git stash pop` and rerunning the file (18 passed) plus the broader wrap suites (121 passed, 1 skipped). - Observed result: the proxy subprocess env now carries `PYTHONSAFEPATH=1`, which disables the cwd prepend, so `import headroom._core` resolves the installed wheel instead of a shadowing local `headroom/` source tree. The proxy command is unchanged otherwise. - Not tested: an end-to-end `cd && headroom wrap claude` against a real installed wheel (this environment is a source checkout without a separate installed wheel to shadow). The behaviour is verified through the spawn env the subprocess inherits, and `PYTHONSAFEPATH` is the documented, reporter-confirmed switch for this exact failure mode. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes Scoped to the proxy launch, which is the reported, high-impact path (its failure makes `wrap` fall back to unwrapped). `wrap` spawns one other `python -m headroom.*` subprocess (the memory-sync helper in the Claude flow) that shares the same root cause; it is a lower-severity, unreported path and is left for a follow-up rather than widening this diff. The misleading "pip install headroom-ai[proxy]" message the reporter also flagged is a separate error-text concern and is likewise out of scope here. --- headroom/cli/wrap.py | 9 ++++++ .../test_wrap_claude_vertex_proxy_env.py | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index af22dc45c..8911c883f 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -629,6 +629,15 @@ def _start_proxy( proxy_env = os.environ.copy() _scrub_copilot_proxy_seed_env(proxy_env) proxy_env["PYTHONIOENCODING"] = "utf-8" + # `python -m headroom.cli` prepends the launch cwd to sys.path, so running + # `wrap` from a directory that contains a `headroom/` folder (most commonly a + # clone of this repo, whose package lives at /headroom/) shadows the + # installed wheel with the raw source tree, which has no compiled + # `headroom._core`. The proxy then dies with "No module named 'headroom._core'" + # and wrap silently falls back to launching the client unwrapped (#2793). + # PYTHONSAFEPATH disables that cwd prepend (Python 3.11+; a harmless no-op on + # 3.10) so the subprocess always resolves the installed package. + proxy_env["PYTHONSAFEPATH"] = "1" # Vertex AI RST_STREAMs HTTP/2 connections (error_code:2). Force HTTP/1.1 # when wrapping a Vertex-mode client so upstream requests succeed. if os.environ.get("CLAUDE_CODE_USE_VERTEX") or os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID"): diff --git a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py index 4d888ba61..546b31cd8 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -370,6 +370,36 @@ def test_start_proxy_clears_inherited_vertex_target_env( assert "VERTEX_TARGET_API_URL" not in proxy_env +def test_start_proxy_sets_pythonsafepath_to_avoid_cwd_shadow( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """`python -m headroom.cli` prepends the launch cwd to sys.path, so running + wrap from a directory that contains a `headroom/` folder (a clone of this + repo) shadows the installed wheel with the raw source tree, which has no + compiled `headroom._core`, and the proxy dies importing it (#2793). The + subprocess env must set PYTHONSAFEPATH=1 to disable that cwd prepend.""" + fake_proc = _FakeProxyProcess() + captured: dict[str, Any] = {} + + monkeypatch.setattr(wrap_mod, "_get_log_path", lambda: tmp_path / "proxy.log") + monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: True) + monkeypatch.setattr(wrap_mod.time, "sleep", lambda _seconds: None) + + def fake_popen(cmd: list[str], **kwargs: object) -> _FakeProxyProcess: + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return fake_proc + + monkeypatch.setattr(wrap_mod.subprocess, "Popen", fake_popen) + + proc = wrap_mod._start_proxy(8787, agent_type="claude") + + assert proc is fake_proc + assert captured["kwargs"]["env"]["PYTHONSAFEPATH"] == "1" + # Still launched as a module of the installed package. + assert captured["cmd"][:4] == [wrap_mod.sys.executable, "-m", "headroom.cli", "proxy"] + + def test_ensure_proxy_restarts_idle_proxy_for_vertex_api_url_mismatch( monkeypatch: pytest.MonkeyPatch, ) -> None: