diff --git a/.dockerignore b/.dockerignore index f6fbbc9f137..a5b50068f02 100644 --- a/.dockerignore +++ b/.dockerignore @@ -102,6 +102,3 @@ acp_registry/ .gitattributes .hadolint.yaml .mailmap - -# Top-level LICENSE (not matched by *.md); not needed inside the container -LICENSE diff --git a/setup.py b/setup.py index 8487f76e86f..6e3e8c4272e 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,68 @@ from __future__ import annotations from collections import defaultdict from pathlib import Path +import tempfile from setuptools import setup +from setuptools.command.build import build as _build +from setuptools.command.egg_info import egg_info as _egg_info REPO_ROOT = Path(__file__).parent.resolve() +def _source_tree_is_writable() -> bool: + probe = REPO_ROOT / ".setuptools-write-probe" + try: + with probe.open("w", encoding="utf-8") as handle: + handle.write("") + probe.unlink() + except OSError: + try: + probe.unlink(missing_ok=True) + except OSError: + pass + return False + return True + + +def _temporary_build_dir(kind: str) -> str: + return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-") + + +def _would_write_under_source(path_value: str | None) -> bool: + if path_value is None: + return True + path = Path(path_value) + if not path.is_absolute(): + path = REPO_ROOT / path + try: + path.resolve().relative_to(REPO_ROOT) + except ValueError: + return False + return True + + +class ReadOnlySourceBuild(_build): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.build_base) + ): + self.build_base = _temporary_build_dir("build") + super().finalize_options() + + +class ReadOnlySourceEggInfo(_egg_info): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.egg_base) + ): + self.egg_base = _temporary_build_dir("egg-info") + super().finalize_options() + + def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: root = REPO_ROOT / root_name grouped: defaultdict[str, list[str]] = defaultdict(list) @@ -21,6 +76,10 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: setup( + cmdclass={ + "build": ReadOnlySourceBuild, + "egg_info": ReadOnlySourceEggInfo, + }, data_files=[ *_data_file_tree("skills"), *_data_file_tree("optional-skills"), diff --git a/tests/test_docker_webui_install_surface.py b/tests/test_docker_webui_install_surface.py new file mode 100644 index 00000000000..413bfdaf071 --- /dev/null +++ b/tests/test_docker_webui_install_surface.py @@ -0,0 +1,87 @@ +"""Guards for the multi-container Hermes WebUI install surface.""" + +from __future__ import annotations + +from pathlib import Path +import runpy + +from setuptools import Distribution +import setuptools + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _is_under(path: str, root: Path) -> bool: + try: + Path(path).resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def test_docker_context_includes_license_file() -> None: + """PEP 639 license-files metadata must resolve inside the Docker image.""" + dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8") + active_lines = [ + line.strip() + for line in dockerignore.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + assert "LICENSE" not in active_lines + + +def test_setup_uses_temporary_outputs_when_source_tree_is_read_only( + monkeypatch, +) -> None: + """WebUI installs from read-only /opt/hermes must not write build metadata.""" + captured: dict[str, object] = {} + + def capture_setup(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setuptools, "setup", capture_setup) + namespace = runpy.run_path(str(REPO_ROOT / "setup.py")) + + cmdclass = captured["cmdclass"] + monkeypatch.setitem( + cmdclass["build"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + monkeypatch.setitem( + cmdclass["egg_info"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + + build_cmd = cmdclass["build"](Distribution()) + build_cmd.initialize_options() + build_cmd.finalize_options() + assert not _is_under(build_cmd.build_base, REPO_ROOT) + assert Path(build_cmd.build_base).name.startswith("hermes-agent-build") + + source_relative_build = cmdclass["build"](Distribution()) + source_relative_build.initialize_options() + source_relative_build.build_base = "nested/build" + source_relative_build.finalize_options() + assert not _is_under(source_relative_build.build_base, REPO_ROOT) + assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build") + + egg_info_cmd = cmdclass["egg_info"](Distribution()) + egg_info_cmd.initialize_options() + egg_info_cmd.finalize_options() + assert egg_info_cmd.egg_base is not None + assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT) + assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info") + + source_relative_egg_info = cmdclass["egg_info"](Distribution()) + source_relative_egg_info.initialize_options() + source_relative_egg_info.egg_base = "." + source_relative_egg_info.finalize_options() + assert source_relative_egg_info.egg_base is not None + assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT) + assert Path(source_relative_egg_info.egg_base).name.startswith( + "hermes-agent-egg-info" + )