hermes-agent/tests/docker/test_docker_exec_privilege_drop.py
ethernet e4458ee7d9 perf(tests): split boot-heavy docker test files for parallel boots
The docker suite's wall time was max(whale files): four files each
serialized 2-3 ~110s container boots internally while 21 fast files
finished in seconds (P50 9.9s vs max 341s on the ARC runners). The
per-file parallel runner can only overlap what lives in separate files.

- test_dashboard.py -> 3 files (one boot each); shared _http_probe
  helper moves to conftest
- test_container_restart.py -> 2 files (restart_container fixture
  travels via the shared header; per-file container isolation is the
  point of the split)
- test_docker_exec_privilege_drop.py -> boot-heavy e2e login test split
  out; the two fast tests stay together
- test_config_migration.py: single test, unchanged

53 tests before and after, zero assertions changed — pure file
reorganization. Local (-j4, same cap as CI): 374.6s -> 58.0s wall,
slowest file 341.7s -> 18.6s.
2026-07-31 14:15:26 -04:00

168 lines
7.1 KiB
Python

"""Regression tests for the docker-exec privilege-drop shim.
The shim (docker/hermes-exec-shim.sh, installed at /opt/hermes/bin/hermes)
exists to prevent the auth.json ownership-mismatch bug where
`docker exec <c> hermes login` would write /opt/data/auth.json as
root:root mode 0600, leaving the supervised gateway (UID 10000) unable
to read its own credentials and returning "Provider authentication
failed: Hermes is not logged into Nous Portal" on every message.
These tests verify:
1. ``docker exec <c> hermes …`` (defaulting to root) gets dropped to the
hermes user before the real binary runs.
2. ``docker exec --user hermes <c> hermes …`` (already non-root) short-
circuits and doesn't try to drop again.
3. Files written under $HERMES_HOME from a ``docker exec`` session land
as hermes:hermes — the actual user-visible invariant.
4. The HERMES_DOCKER_EXEC_AS_ROOT opt-out lets diagnostic sessions keep
running as root deliberately.
5. The main CMD path (``docker run <image> …``) is unaffected by the
PATH-shim ordering — no recursion, no behavior change.
"""
from __future__ import annotations
from tests.docker.conftest import docker_exec
import subprocess
import time
from collections.abc import Iterator
import pytest
# How long to give a `docker run -d` container before declaring it not ready.
# Generous because under arm64 QEMU emulation cont-init (a Python config
# migration + chowns) runs several times slower than on native amd64.
_RUN_READY_TIMEOUT_S = 60
def _wait_for_cont_init(container: str) -> None:
"""Block until s6 cont-init has fully finished, not merely until
``docker exec`` is responsive.
The earlier ``_wait_for_init`` only polled ``docker exec <c> true``,
which succeeds almost immediately on s6-overlay — long before the
``01-hermes-setup`` cont-init hook (docker/stage2-hook.sh) has
finished seeding + ``chown hermes:hermes`` config.yaml and running the
Python config migration. A test that wipes config.yaml and then writes
it as root would then race that boot-time chown: on native amd64
stage2-hook wins in a blink and the test always passed, but under arm64
QEMU emulation the slow Python migration was still in flight and
clobbered the root-written file's ownership back to hermes:hermes,
failing ``test_shim_opt_out_keeps_root`` non-deterministically.
The reliable "cont-init is done" signal is
``$HERMES_HOME/logs/container-boot.log``: it is written by
``02-reconcile-profiles`` (hermes_cli.container_boot), which s6 runs
*strictly after* ``01-hermes-setup`` in lexicographic order. The
reconciler always logs at least one ``profile=default`` line even for a
bare ``sleep infinity`` container, so once that marker appears every
stage2-hook side effect (seed, chown, migrate) is guaranteed complete.
Mirrors the readiness pattern in test_container_restart.py.
"""
deadline = time.monotonic() + _RUN_READY_TIMEOUT_S
last = ""
while time.monotonic() < deadline:
r = subprocess.run(
["docker", "exec", container,
"cat", "/opt/data/logs/container-boot.log"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
last = r.stdout
if "profile=default" in last:
return
time.sleep(0.2)
pytest.fail(
f"container {container} did not finish cont-init within "
f"{_RUN_READY_TIMEOUT_S}s (container-boot.log so far: {last!r})"
)
@pytest.fixture
def sleep_container(built_image: str, container_name: str) -> Iterator[str]:
"""Long-lived container running `sleep infinity` so we can docker exec into it."""
subprocess.run(
["docker", "rm", "-f", container_name],
capture_output=True, check=False,
)
r = subprocess.run(
["docker", "run", "-d", "--name", container_name, built_image,
"sleep", "infinity"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"docker run failed: {r.stderr}"
try:
_wait_for_cont_init(container_name)
yield container_name
finally:
subprocess.run(
["docker", "rm", "-f", container_name],
capture_output=True, check=False,
)
def test_shim_drops_root_to_hermes_uid(sleep_container: str) -> None:
"""docker exec defaults to root; the shim should drop to uid 10000.
We invoke `hermes` with a Python-style `-c` shim equivalent — there's no
pure-hermes "print my uid" command, so we use the venv's python directly
via the shim's PATH lookup: `python -c 'print(os.getuid())'` is resolved
through the venv. But that bypasses the shim. Instead, we exploit the
fact that the venv's `hermes` is a console_scripts entry — under the
hood it's a tiny Python wrapper. We can't easily inject "print my uid"
into it without forking subcommands. Simplest approach: have `hermes`
do anything that writes to disk, then check the file's owner.
Use `hermes config set` which writes config.yaml under HERMES_HOME.
The resulting file ownership tells us what UID the shim ended up at.
"""
# Wipe any prior state.
subprocess.run(
["docker", "exec", "--user", "root", sleep_container,
"rm", "-f", "/opt/data/config.yaml"],
capture_output=True, check=False,
)
# Default docker exec (root) — should be dropped by the shim.
r = subprocess.run(
["docker", "exec", sleep_container,
"hermes", "config", "set", "_test.shim_marker", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"config set failed: stdout={r.stdout!r} stderr={r.stderr!r}"
# The written file must be owned by hermes, not root.
r = subprocess.run(
["docker", "exec", sleep_container,
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
capture_output=True, text=True, timeout=10,
)
assert r.returncode == 0, f"stat failed: {r.stderr}"
assert r.stdout.strip() == "hermes:hermes", (
f"config.yaml owned by {r.stdout.strip()!r}, expected hermes:hermes. "
"The shim did not drop privileges before invoking hermes."
)
def test_main_cmd_path_unaffected(built_image: str) -> None:
"""The CMD path (docker run <image> <args>) must still work.
The shim sits at /opt/hermes/bin earliest on PATH; main-wrapper.sh
invokes `s6-setuidgid hermes hermes <args>` which resolves `hermes`
through PATH. With the shim in the way, this could regress if the
shim recurses or interferes with TTY/exit-code propagation.
`chat --help` is cheap and exercises the full subcommand
passthrough path. The duplicate of test_main_invocation's
pre-existing test is intentional — that one would have passed
pre-shim too; this one specifically guards against shim regressions
in the CMD-as-main-program codepath.
"""
r = subprocess.run(
["docker", "run", "--rm", built_image, "chat", "--help"],
capture_output=True, text=True, timeout=60,
)
assert r.returncode == 0, f"CMD path broken by shim: stderr={r.stderr!r}"
assert "Traceback" not in r.stderr