headroom/tests/test_mcp_registry/test_install.py
Abhay Singh d7b25ae3bb
fix(wrap/serena): install Serena from the serena-agent PyPI wheel, not the git source
## Description

`headroom/mcp_registry/install.py` (`build_serena_spec`) and the
wrap-time Serena pre-index in `headroom/cli/wrap.py` both ran:

```
uvx --from git+https://github.com/oraios/serena serena ...
```

The git source forces a from-source build. On proot-based filesystems
(Termux + proot-distro on Android, some restricted Linux) `uv` cannot
hardlink build dependencies into a fresh build venv, so the build fails
immediately and Serena's MCP server fails to start on every `headroom
wrap codex` launch:

```
× Failed to download and build `serena-agent @ git+https://github.com/oraios/serena@<commit>`
╰─▶ failed to hardlink file ... Operation not permitted (os error 1)
```

Setting `UV_LINK_MODE=copy` fixes it in an interactive shell, but Codex
strips most env vars from the MCP subprocesses it spawns, so that
workaround does not reliably reach Serena's launch.

Serena publishes the official `serena-agent` package to PyPI with
prebuilt wheels, and it exposes the same `serena` console script
(`serena = "serena.cli:top_level"` in the project's `pyproject.toml`),
so `uvx --from serena-agent serena ...` runs the identical command
without a build step. On platforms where the git build already worked
there is no functional difference.

Fixes #2871

## 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/mcp_registry/install.py` (`build_serena_spec`): `--from
git+https://github.com/oraios/serena` -> `--from serena-agent`.
- `headroom/cli/wrap.py` (Serena `project index` pre-warm): same swap.
- `tests/test_mcp_registry/test_install.py`: updated the spec assertion
and added `test_build_serena_spec_uses_pypi_not_git_source` (asserts
`serena-agent` is used and no `git+` source remains).
- `tests/test_cli/test_wrap_serena_boost.py`: the pre-index test now
asserts `serena-agent` is in the command and the git source is not.

## 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 swap stashed, updated tests kept):
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_agent_context FAILED
tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_pypi_not_git_source FAILED
tests/test_cli/test_wrap_serena_boost.py::test_preindex_runs_serena_in_cwd FAILED

# Pass-after:
tests/test_mcp_registry/ tests/test_cli/test_wrap_serena_boost.py
tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py   135 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/mcp_registry/install.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 `serena-agent` exists on PyPI
(v1.6.1, homepage github.com/oraios/serena) and that its
`pyproject.toml` declares `[project.scripts] serena =
"serena.cli:top_level"`, so the `serena start-mcp-server ...` invocation
is unchanged. Swapped both `--from` sources, then fail-before with `git
stash push headroom/mcp_registry/install.py headroom/cli/wrap.py` (the
two production-asserting tests fail on the old git source) and
pass-after with `git stash pop` (135 serena-suite tests pass). Verified
no `git+https://github.com/oraios/serena` references remain in
`headroom/`.
- Observed result: `build_serena_spec` and the pre-index command now
install Serena from the `serena-agent` PyPI wheel, so a proot
environment gets the prebuilt wheel instead of a from-source build that
cannot hardlink. The migration/ledger tests, which use the old git spec
as a deliberately-stale fixture, are unaffected.
- Not tested: a live `headroom wrap codex` on a real proot/Termux device
(not available here). The change is a package-source swap verified
against Serena's own published package metadata and the existing
spec/command tests.

## 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

The git source was unpinned (tracked the repo default branch), so
switching to `serena-agent` from PyPI does not lose a version pin; if
anything it is more reproducible. The issue reporter also noted that
`headroom wrap codex` force-rewrites the Serena block in
`~/.codex/config.toml` from this template on every launch, which is why
the fix has to live in the package source rather than a user config edit
-- this PR puts it there.
2026-08-11 09:10:32 -07:00

194 lines
6.5 KiB
Python

"""Tests for the install_everywhere orchestrator."""
from __future__ import annotations
from headroom.mcp_registry.base import (
MCPRegistrar,
RegisterResult,
RegisterStatus,
ServerSpec,
)
from headroom.mcp_registry.install import (
DEFAULT_PROXY_URL,
build_headroom_spec,
build_serena_spec,
install_everywhere,
)
class _FakeRegistrar(MCPRegistrar):
"""Minimal registrar for orchestrator tests."""
def __init__(
self,
name: str,
*,
detected: bool = True,
register_result: RegisterResult | None = None,
) -> None:
self.name = name
self.display_name = name.title()
self._detected = detected
self._register_result = register_result or RegisterResult(RegisterStatus.REGISTERED, "ok")
self.calls: list[ServerSpec] = []
def detect(self) -> bool:
return self._detected
def get_server(self, server_name: str) -> ServerSpec | None:
return None
def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult:
self.calls.append(spec)
return self._register_result
def unregister_server(self, server_name: str) -> bool:
return True
# ----------------------------------------------------------------------
# build_headroom_spec
# ----------------------------------------------------------------------
def test_build_spec_default_proxy_no_env(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.mcp_registry.install.resolve_headroom_command",
lambda: ["/opt/headroom/bin/headroom"],
)
spec = build_headroom_spec()
assert spec.name == "headroom"
assert spec.command == "/opt/headroom/bin/headroom"
assert spec.args == ("mcp", "serve")
assert spec.env == {}
def test_build_spec_custom_proxy_sets_env() -> None:
spec = build_headroom_spec("http://127.0.0.1:9999")
assert spec.env == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9999"}
def test_build_spec_default_url_omits_env() -> None:
spec = build_headroom_spec(DEFAULT_PROXY_URL)
assert spec.env == {}
def test_build_spec_falls_back_to_python_module_when_no_binary(monkeypatch) -> None:
monkeypatch.setattr("headroom.install.runtime.shutil.which", lambda name: None)
monkeypatch.setattr("headroom.install.runtime.sys.executable", "/usr/bin/python")
spec = build_headroom_spec()
assert spec.command == "/usr/bin/python"
assert spec.args == ("-m", "headroom.cli", "mcp", "serve")
assert spec.env == {}
def test_build_serena_spec_uses_agent_context() -> None:
spec = build_serena_spec("codex")
assert spec.name == "serena"
assert spec.command == "uvx"
assert spec.args == (
# PyPI package with prebuilt wheels, not the git source (#2871).
"--from",
"serena-agent",
"serena",
"start-mcp-server",
"--project-from-cwd",
"--context",
"codex",
"--open-web-dashboard",
"False",
)
assert spec.env == {}
def test_build_serena_spec_uses_pypi_not_git_source() -> None:
"""Serena is installed from the PyPI package (prebuilt wheels), not the git
source, which forces a from-source build that fails under proot-based
filesystems where uv cannot hardlink into a build venv (#2871)."""
spec = build_serena_spec("codex")
assert "serena-agent" in spec.args
assert not any("git+" in arg for arg in spec.args)
def test_build_serena_spec_disables_dashboard_popup_by_default() -> None:
# Headroom installs Serena by default; the dashboard browser tab must not
# auto-open. The flag overrides the user's serena_config.yml at startup,
# so this holds even when the user never created a Serena config.
for context in ("codex", "claude-code"):
spec = build_serena_spec(context)
idx = spec.args.index("--open-web-dashboard")
assert spec.args[idx + 1] == "False"
# ----------------------------------------------------------------------
# install_everywhere
# ----------------------------------------------------------------------
def test_install_everywhere_calls_each_detected_registrar() -> None:
a = _FakeRegistrar("a")
b = _FakeRegistrar("b")
results = install_everywhere(registrars=[a, b])
assert set(results) == {"a", "b"}
assert results["a"].status == RegisterStatus.REGISTERED
assert results["b"].status == RegisterStatus.REGISTERED
assert len(a.calls) == 1
assert len(b.calls) == 1
def test_install_everywhere_skips_undetected() -> None:
detected = _FakeRegistrar("a", detected=True)
missing = _FakeRegistrar("b", detected=False)
results = install_everywhere(registrars=[detected, missing])
assert results["a"].status == RegisterStatus.REGISTERED
assert results["b"].status == RegisterStatus.NOT_DETECTED
assert len(detected.calls) == 1
assert len(missing.calls) == 0
def test_install_everywhere_filters_by_agents() -> None:
a = _FakeRegistrar("a")
b = _FakeRegistrar("b")
c = _FakeRegistrar("c")
results = install_everywhere(registrars=[a, b, c], agents=["a", "c"])
assert set(results) == {"a", "c"}
assert "b" not in results
def test_install_everywhere_passes_proxy_url_into_spec() -> None:
captured: list[ServerSpec] = []
class CapturingRegistrar(_FakeRegistrar):
def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult:
captured.append(spec)
return RegisterResult(RegisterStatus.REGISTERED, "ok")
reg = CapturingRegistrar("x")
install_everywhere(proxy_url="http://localhost:9000", registrars=[reg])
assert len(captured) == 1
assert captured[0].env == {"HEADROOM_PROXY_URL": "http://localhost:9000"}
def test_install_everywhere_passes_force_flag() -> None:
captured: list[bool] = []
class CapturingRegistrar(_FakeRegistrar):
def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult:
captured.append(force)
return RegisterResult(RegisterStatus.REGISTERED, "ok")
reg = CapturingRegistrar("x")
install_everywhere(registrars=[reg], force=True)
assert captured == [True]
def test_install_everywhere_returns_mismatch_results() -> None:
mismatched = _FakeRegistrar(
"a",
register_result=RegisterResult(RegisterStatus.MISMATCH, "env differs"),
)
results = install_everywhere(registrars=[mismatched])
assert results["a"].status == RegisterStatus.MISMATCH
assert results["a"].ok is False # mismatch is NOT a success