diff --git a/Dockerfile b/Dockerfile index 3e94040..3e2ba43 100755 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,10 @@ FROM python:3.12-slim WORKDIR /app -# Node.js (LTS) is needed to run npx-based MCP providers +# Node.js (LTS) is needed to run npx-based MCP providers. +# git is needed by repository providers (clone + build before spawn). RUN apt-get update && apt-get install -y --no-install-recommends \ - curl ca-certificates \ + curl ca-certificates git \ && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* diff --git a/frontend/app.py b/frontend/app.py index 44a488b..b699d6f 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -544,6 +544,8 @@ async def clone_and_build(request: Request) -> dict: workdir = _repository_workdir(name, explicit_workdir) try: + from server import _ensure_git_installed + _ensure_git_installed() Path(workdir).parent.mkdir(parents=True, exist_ok=True) if (Path(workdir) / ".git").exists(): subprocess.run(["git", "-C", workdir, "pull", "--ff-only"], check=True) diff --git a/server.py b/server.py index 4ebc24e..b158822 100755 --- a/server.py +++ b/server.py @@ -310,6 +310,33 @@ def repository_workdir(provider_name: str, spec: dict[str, Any]) -> str | None: return str(REPOS_DIR / safe) +def _ensure_git_installed() -> None: + """Verify ``git`` is on PATH; attempt apt-get install if missing. + + Repository providers need ``git``. The Dockerfile installs it during the + image build, but older images (or non-Docker environments) may not have + it — try a best-effort apt-get install before bailing out. Runs at most + once per process. + """ + import shutil + if shutil.which("git"): + return + print("git not found on PATH — attempting apt-get install...") + try: + subprocess.run(["apt-get", "update"], check=True) + subprocess.run( + ["apt-get", "install", "-y", "--no-install-recommends", "git"], + check=True, + ) + except Exception as exc: + raise RuntimeError( + "git is required for repository providers but is not installed, " + f"and automatic apt-get install failed: {exc}" + ) from exc + if not shutil.which("git"): + raise RuntimeError("apt-get install succeeded but git is still not on PATH") + + def materialize_repository(spec: dict[str, Any]) -> None: """Clone the repo (if absent) and run build_commands. Idempotent. @@ -332,6 +359,7 @@ def materialize_repository(spec: dict[str, Any]) -> None: build_commands = list(repo.get("build_commands") or []) try: + _ensure_git_installed() wd_path = Path(workdir) wd_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_server.py b/tests/test_server.py index 3b002d0..bb842c2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -770,6 +770,35 @@ def test_default_workdir_uses_provider_name(self): assert wd.endswith("linkedin") +class TestEnsureGitInstalled: + def test_noop_when_git_present(self): + from server import _ensure_git_installed + with patch("shutil.which", return_value="/usr/bin/git"), \ + patch("server.subprocess.run") as mock_run: + _ensure_git_installed() + mock_run.assert_not_called() + + def test_runs_apt_get_when_missing(self): + from server import _ensure_git_installed + # First which() returns None, second (after install) returns the path + which_calls = iter([None, "/usr/bin/git"]) + with patch("shutil.which", side_effect=lambda _: next(which_calls)), \ + patch("server.subprocess.run") as mock_run: + _ensure_git_installed() + cmds = [c[0][0] for c in mock_run.call_args_list] + assert cmds[0] == ["apt-get", "update"] + assert cmds[1][:3] == ["apt-get", "install", "-y"] + assert "git" in cmds[1] + + def test_raises_when_install_fails(self): + from server import _ensure_git_installed + import subprocess as sp + with patch("shutil.which", return_value=None), \ + patch("server.subprocess.run", side_effect=sp.CalledProcessError(1, "apt-get")): + with pytest.raises(RuntimeError, match="git is required"): + _ensure_git_installed() + + class TestMaterializeRepository: def _spec(self, tmp_path: Path, **overrides): repo = {