Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,3 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap

# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
59 changes: 59 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,68 @@

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)
Expand All @@ -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"),
Expand Down
87 changes: 87 additions & 0 deletions tests/test_docker_webui_install_surface.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading