mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
The Photon iMessage sidecar needs node_modules under plugins/platforms/photon/sidecar/, but hosted/managed images keep the whole install tree under an immutable /opt/hermes — every install and self-heal path (setup CLI, stale-deps reinstall, cold install) died on EROFS, and hosted users have no shell to work around it. Three-layer fix, mirroring the WhatsApp bridge resolver pattern: 1. Bake the deps into the image. The Dockerfile now runs npm ci for the sidecar in the layer-cached dependency stage (deterministic installs from the committed lockfile; the postinstall spectrum-ts patch runs at build time). Hosted happy path needs no runtime install at all. 2. New sidecar_paths.resolve_sidecar_dir() decides where the sidecar runs from: PHOTON_SIDECAR_DIR override > writable source dir (dev installs, unchanged) > read-only dir with baked fresh deps (managed image) > mirror to $HERMES_HOME/photon/sidecar (writable data volume) when deps are missing or stale in a read-only tree. The mirror refreshes changed source files on image updates while keeping node_modules, so the existing lockfile-staleness self-heal works there. 3. connect() can now cold-install: _start_sidecar() runs the bounded npm ci bootstrap when node_modules is missing instead of raising immediately, and check_requirements() reports available when a self-install is possible (npm present + writable resolved dir) so the gateway actually creates the adapter on hosted instances. A failed bootstrap still raises the actionable error, which connect() surfaces as the retryable SIDECAR_FAILED fatal state on the dashboard. Tests: resolver decision table (env override, in-place, mirror, refresh, fail-open), cold-install lifecycle paths, and a Dockerfile contract test guarding the baked-deps + no-chown invariants. Fixes NS-606.
133 lines
5.4 KiB
Python
133 lines
5.4 KiB
Python
"""Contract tests for the Docker image's immutable /opt/hermes install tree."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DOCKERFILE = REPO_ROOT / "Dockerfile"
|
|
|
|
|
|
def _dockerfile_text() -> str:
|
|
return DOCKERFILE.read_text()
|
|
|
|
|
|
def test_dockerfile_makes_opt_hermes_readonly_for_hermes_user() -> None:
|
|
text = _dockerfile_text()
|
|
|
|
# --chmod on the source COPY bakes read-only perms at copy time instead
|
|
# of a separate chmod -R pass (which walked ~30k files — #49113).
|
|
assert "COPY --link --chmod=a+rX,go-w . ." in text
|
|
# The old tree-walking passes must not be present.
|
|
assert "chown -R root:root /opt/hermes" not in text
|
|
assert "chmod -R a+rX /opt/hermes" not in text
|
|
assert "chmod -R a-w /opt/hermes" not in text
|
|
|
|
|
|
def test_dockerfile_keeps_mutable_state_under_opt_data() -> None:
|
|
text = _dockerfile_text()
|
|
|
|
assert "ENV HERMES_HOME=/opt/data" in text
|
|
assert "ENV HERMES_WRITE_SAFE_ROOT=/opt/data" in text
|
|
assert 'VOLUME [ "/opt/data" ]' in text
|
|
|
|
|
|
def test_dockerfile_disables_runtime_install_mutations() -> None:
|
|
text = _dockerfile_text()
|
|
|
|
assert "ENV PYTHONDONTWRITEBYTECODE=1" in text
|
|
assert "ENV HERMES_DISABLE_LAZY_INSTALLS=1" in text
|
|
assert "HERMES_TUI_DIR=/opt/hermes/ui-tui" in text
|
|
|
|
|
|
def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None:
|
|
text = _dockerfile_text()
|
|
forbidden_patterns = (
|
|
r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/\.venv",
|
|
r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/ui-tui",
|
|
r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/gateway",
|
|
r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/node_modules",
|
|
)
|
|
for pattern in forbidden_patterns:
|
|
assert not re.search(pattern, text), (
|
|
"runtime install trees under /opt/hermes must stay immutable; "
|
|
f"found forbidden pattern {pattern!r}"
|
|
)
|
|
|
|
|
|
def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None:
|
|
"""The 'docker' install-method stamp is baked next to the code.
|
|
|
|
detect_install_method() reads the code-scoped stamp
|
|
(/opt/hermes/.install_method) first; baking it at build time keeps the
|
|
published image self-identifying as 'docker' WITHOUT writing into the
|
|
shared $HERMES_HOME data volume (which a host install may also use).
|
|
The stamp is created by root in the shim-wiring RUN block; the hermes
|
|
user can't modify it (go-w from the --chmod on the source COPY).
|
|
"""
|
|
text = _dockerfile_text()
|
|
assert "printf 'docker\\n' > /opt/hermes/.install_method" in text
|
|
|
|
# The stamp must be in the RUN block that wires the exec shim.
|
|
shim_block = re.search(
|
|
r"RUN mkdir -p /opt/hermes/bin && \\\n"
|
|
r"(?:.*\\\n)+?"
|
|
r"\s+printf 'docker\\n' > /opt/hermes/\.install_method",
|
|
text,
|
|
)
|
|
assert shim_block, "install-method stamp must be in the shim-wiring RUN block"
|
|
|
|
|
|
def test_dockerfile_redirects_lazy_installs_to_durable_target() -> None:
|
|
"""Immutable image seals the venv but redirects lazy installs to the
|
|
writable data volume, so opt-in backends still install at first use
|
|
without being able to break the sealed core.
|
|
|
|
Guards the contract between the Dockerfile env var, the stage2-hook
|
|
seeding, and tools/lazy_deps.py — these three must agree on the path.
|
|
"""
|
|
text = _dockerfile_text()
|
|
target = "/opt/data/lazy-packages"
|
|
|
|
# The redirect target must be set AND must live under the data volume,
|
|
# never under the immutable /opt/hermes tree.
|
|
assert f"ENV HERMES_LAZY_INSTALL_TARGET={target}" in text
|
|
assert target.startswith("/opt/data/"), "target must be on the durable volume"
|
|
assert "ENV HERMES_LAZY_INSTALL_TARGET=/opt/hermes" not in text
|
|
|
|
# The seal flag must still be present — the redirect rides on top of it,
|
|
# it does not replace it.
|
|
assert "ENV HERMES_DISABLE_LAZY_INSTALLS=1" in text
|
|
|
|
# stage2-hook must seed + chown the target dir so first-use installs
|
|
# succeed as the unprivileged hermes runtime user.
|
|
stage2 = (REPO_ROOT / "docker" / "stage2-hook.sh").read_text()
|
|
assert '"$HERMES_HOME/lazy-packages"' in stage2, (
|
|
"stage2-hook.sh must create the lazy-packages dir on the data volume"
|
|
)
|
|
assert "lazy-packages" in stage2.split("for sub in", 1)[1].split(";", 1)[0], (
|
|
"lazy-packages must be in the per-boot chown subdir list so it stays "
|
|
"hermes-owned"
|
|
)
|
|
|
|
|
|
def test_dockerfile_bakes_photon_sidecar_deps() -> None:
|
|
"""The Photon sidecar's node_modules must be baked at build time (NS-606).
|
|
|
|
The install tree is immutable at runtime, so a lazy `npm ci` on first
|
|
connect would hit EROFS. Baking the deps (from the committed lockfile,
|
|
which also runs the spectrum-ts postinstall patch) makes the hosted
|
|
happy path install-free. Guards the contract between the Dockerfile
|
|
and plugins/platforms/photon/sidecar_paths.resolve_sidecar_dir, which
|
|
runs in place only when the baked deps exist and match the lockfile.
|
|
"""
|
|
text = _dockerfile_text()
|
|
|
|
assert "plugins/platforms/photon/sidecar/package-lock.json" in text
|
|
assert re.search(
|
|
r"RUN cd plugins/platforms/photon/sidecar && \\\n\s+npm ci", text
|
|
), "sidecar deps must be installed with `npm ci` (deterministic, runs postinstall patch)"
|
|
# Immutability contract: never chown the sidecar tree to the runtime user.
|
|
assert not re.search(
|
|
r"chown\s+-R\s+hermes:hermes\s+/opt/hermes/plugins", text
|
|
)
|