mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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.
194 lines
6.5 KiB
Python
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
|