chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""Shared fixtures for docker-image integration tests.
|
||||
|
||||
Tests in this directory build the image with the current ``Dockerfile``
|
||||
and exercise it via ``docker run``. They skip when Docker is unavailable
|
||||
(e.g. on developer laptops without a daemon).
|
||||
|
||||
Override the image with ``HERMES_TEST_IMAGE`` env var to point at a pre-built
|
||||
image (faster local iteration); otherwise the ``built_image`` fixture builds
|
||||
the repo's Dockerfile once per session.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
IMAGE_TAG = os.environ.get("HERMES_TEST_IMAGE", "hermes-agent-harness:latest")
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
"""Return True iff a docker CLI is on PATH and the daemon answers."""
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "info"], capture_output=True, timeout=5,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items): # noqa: D401 - pytest hook
|
||||
"""Apply docker-suite policy: timeout bump + skip on missing docker."""
|
||||
docker_ok = _docker_available()
|
||||
skip_docker = pytest.mark.skip(
|
||||
reason="Docker not available or daemon not running",
|
||||
)
|
||||
for item in items:
|
||||
if "tests/docker/" not in str(item.fspath).replace(os.sep, "/"):
|
||||
continue
|
||||
if not docker_ok:
|
||||
item.add_marker(skip_docker)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def built_image() -> str:
|
||||
"""Build the image once per test session.
|
||||
|
||||
Override with ``HERMES_TEST_IMAGE`` env var to point at a pre-built
|
||||
image (faster local iteration).
|
||||
"""
|
||||
if os.environ.get("HERMES_TEST_IMAGE"):
|
||||
return IMAGE_TAG
|
||||
repo_root = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", ".."),
|
||||
)
|
||||
result = subprocess.run(
|
||||
["docker", "build", "-t", IMAGE_TAG, repo_root],
|
||||
capture_output=True, text=True, timeout=1200,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"docker build failed:\n{result.stderr[-2000:]}"
|
||||
)
|
||||
return IMAGE_TAG
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def container_name(request) -> Iterator[str]:
|
||||
"""Generate a unique container name and ensure cleanup on test exit."""
|
||||
safe = request.node.name.replace("[", "_").replace("]", "_")
|
||||
name = f"hermes-test-{safe}"
|
||||
yield name
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# docker_exec — default to the unprivileged hermes user
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Background: every Hermes runtime path inside the container drops to UID
|
||||
# 10000 (the ``hermes`` user) via ``s6-setuidgid hermes``. ``docker exec``
|
||||
# without ``-u`` runs as root, which is **not** representative of how
|
||||
# production code executes. PR #30136 review caught a real regression
|
||||
# this way — ``Path('/proc/1/exe').resolve()`` works as root and silently
|
||||
# fails (PermissionError swallowed) for hermes, so a test that ran as root
|
||||
# couldn't catch a feature that was inert for the actual runtime user.
|
||||
#
|
||||
# Tests in this directory MUST exercise the realistic user context. The
|
||||
# helpers below run every probe under ``-u hermes`` unless a specific
|
||||
# test explicitly opts into ``user="root"`` (rare — e.g. inspecting
|
||||
# /proc/1/exe itself, chowning a volume).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def docker_exec(
|
||||
container: str,
|
||||
*args: str,
|
||||
user: str = "hermes",
|
||||
timeout: int = 30,
|
||||
extra_docker_args: tuple[str, ...] = (),
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command inside ``container`` as ``user`` (default: hermes).
|
||||
|
||||
Returns the CompletedProcess with text=True, capture_output=True.
|
||||
|
||||
Pass ``user="root"`` only when the test specifically needs root
|
||||
capabilities (e.g. reading /proc/1/exe, manipulating ownership).
|
||||
Most tests should use the default.
|
||||
"""
|
||||
cmd = ["docker", "exec", "-u", user, *extra_docker_args, container, *args]
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def docker_exec_sh(
|
||||
container: str,
|
||||
command: str,
|
||||
*,
|
||||
user: str = "hermes",
|
||||
timeout: int = 30,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run ``sh -c <command>`` inside the container as ``user``."""
|
||||
return docker_exec(
|
||||
container, "sh", "-c", command, user=user, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_container_ready(
|
||||
container: str,
|
||||
*,
|
||||
deadline_s: float = 30.0,
|
||||
interval_s: float = 0.25,
|
||||
) -> None:
|
||||
"""Poll until the container has finished s6 cont-init (stage2 + reconcile).
|
||||
|
||||
The readiness signal is ``profile=default`` appearing in
|
||||
``/opt/data/logs/container-boot.log``, which the 02-reconcile-profiles
|
||||
cont-init script writes on every boot. That log entry fires AFTER
|
||||
stage2-hook.sh completes, so by the time it appears the full
|
||||
cont-init chain (UID remap, chown, config seeding, skills sync,
|
||||
browser discovery, config migration) has run.
|
||||
|
||||
Raises ``TimeoutError`` if the container never becomes ready — much
|
||||
better than a fixed ``time.sleep()`` that either wastes time on fast
|
||||
machines or flakes on slow ones.
|
||||
"""
|
||||
end = time.monotonic() + deadline_s
|
||||
while time.monotonic() < end:
|
||||
r = docker_exec(
|
||||
container,
|
||||
"sh", "-c",
|
||||
"cat /opt/data/logs/container-boot.log 2>/dev/null",
|
||||
timeout=5,
|
||||
)
|
||||
if r.returncode == 0 and "profile=default" in r.stdout:
|
||||
return
|
||||
time.sleep(interval_s)
|
||||
raise TimeoutError(
|
||||
f"container {container} did not finish cont-init within {deadline_s}s"
|
||||
)
|
||||
|
||||
|
||||
def start_container(
|
||||
image: str,
|
||||
name: str,
|
||||
*env: str,
|
||||
cmd: str = "sleep infinity",
|
||||
timeout: int = 60,
|
||||
) -> str:
|
||||
"""Start a detached container and wait for cont-init to finish.
|
||||
|
||||
Args:
|
||||
image: Docker image to run.
|
||||
name: Container name (cleanup is the caller's responsibility —
|
||||
typically handled by the ``container_name`` fixture).
|
||||
env: Env vars as ``KEY=VALUE`` strings, each passed via ``-e``.
|
||||
cmd: Container CMD (default ``sleep infinity``).
|
||||
timeout: ``docker run`` subprocess timeout.
|
||||
|
||||
Returns the container name. Raises on ``docker run`` failure or if
|
||||
the container never finishes cont-init within 30s.
|
||||
"""
|
||||
args = ["docker", "run", "-d", "--name", name]
|
||||
for e in env:
|
||||
args.extend(["-e", e])
|
||||
args.extend([image, *cmd.split()])
|
||||
subprocess.run(args, check=True, capture_output=True, timeout=timeout)
|
||||
wait_for_container_ready(name)
|
||||
return name
|
||||
|
||||
|
||||
def restart_container(container: str, timeout: int = 60) -> None:
|
||||
"""Restart a container and wait for cont-init to finish.
|
||||
|
||||
Equivalent to ``docker restart <container>`` followed by
|
||||
:func:`wait_for_container_ready`.
|
||||
|
||||
The readiness signal (``profile=default`` in
|
||||
``/opt/data/logs/container-boot.log``) is append-only and persists
|
||||
across restarts, so we truncate it BEFORE restarting — otherwise
|
||||
``wait_for_container_ready`` would match the stale line from the
|
||||
previous boot and return before cont-init runs on the new boot.
|
||||
"""
|
||||
docker_exec(container, "sh", "-c",
|
||||
"truncate -s 0 /opt/data/logs/container-boot.log 2>/dev/null || true",
|
||||
user="root", timeout=5)
|
||||
subprocess.run(
|
||||
["docker", "restart", container],
|
||||
check=True, capture_output=True, timeout=timeout,
|
||||
)
|
||||
wait_for_container_ready(container)
|
||||
|
||||
|
||||
def poll_container(
|
||||
container: str,
|
||||
probe: str,
|
||||
*,
|
||||
deadline_s: float = 30.0,
|
||||
interval_s: float = 0.5,
|
||||
user: str = "hermes",
|
||||
) -> tuple[bool, str]:
|
||||
"""Repeatedly run ``probe`` inside the container until it exits 0 or
|
||||
``deadline_s`` elapses.
|
||||
|
||||
Returns ``(success, last_stdout)``. Useful for waiting on a process
|
||||
to appear, a port to open, a file to contain a string, etc.
|
||||
"""
|
||||
end = time.monotonic() + deadline_s
|
||||
last = ""
|
||||
while time.monotonic() < end:
|
||||
r = docker_exec_sh(container, probe, user=user, timeout=10)
|
||||
last = r.stdout
|
||||
if r.returncode == 0:
|
||||
return True, last
|
||||
time.sleep(interval_s)
|
||||
return False, last
|
||||
|
||||
|
||||
def wait_for_path(
|
||||
container: str,
|
||||
path: str,
|
||||
*,
|
||||
kind: str = "f",
|
||||
deadline_s: float = 30.0,
|
||||
interval_s: float = 0.25,
|
||||
) -> bool:
|
||||
"""Poll ``test -<kind> <path>`` inside the container until success or timeout.
|
||||
|
||||
``kind`` is the ``test`` flag: ``'f'`` for file, ``'d'`` for directory,
|
||||
``'e'`` for existence. Returns ``True`` on success, ``False`` on timeout.
|
||||
"""
|
||||
return poll_container(
|
||||
container, f"test -{kind} {path}",
|
||||
deadline_s=deadline_s, interval_s=interval_s,
|
||||
)[0]
|
||||
|
||||
|
||||
def wait_for_log(
|
||||
container: str,
|
||||
log_path: str,
|
||||
needle: str,
|
||||
*,
|
||||
deadline_s: float = 30.0,
|
||||
interval_s: float = 0.25,
|
||||
) -> str:
|
||||
"""Poll until a log file inside the container contains ``needle``.
|
||||
|
||||
Returns the full log on success.
|
||||
"""
|
||||
end = time.monotonic() + deadline_s
|
||||
last = ""
|
||||
while time.monotonic() < end:
|
||||
r = docker_exec_sh(
|
||||
container, f"cat {log_path} 2>/dev/null", timeout=5,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
last = r.stdout
|
||||
if needle in last:
|
||||
return last
|
||||
time.sleep(interval_s)
|
||||
raise AssertionError(f"Didn't see `{needle}` in {log_path} within {deadline_s} in container {container}")
|
||||
|
||||
|
||||
|
||||
def wait_for_docker_logs(
|
||||
container: str, needle: str, *, deadline_s: float = 30.0, interval_s: float = 0.5,
|
||||
) -> str:
|
||||
"""Poll ``docker logs`` until ``needle`` appears or deadline expires.
|
||||
|
||||
Returns the full docker logs on success.
|
||||
"""
|
||||
end = time.monotonic() + deadline_s
|
||||
last = ""
|
||||
while time.monotonic() < end:
|
||||
r = subprocess.run(
|
||||
["docker", "logs", container],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
last = r.stdout + r.stderr
|
||||
if needle in last:
|
||||
return last
|
||||
time.sleep(interval_s)
|
||||
raise AssertionError(f"Didn't see `{needle}` in docker logs within {deadline_s} in container {container}")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Runtime smoke test for Docker config-schema migration on boot.
|
||||
|
||||
Build the real image and verify: a config.yaml present in $HERMES_HOME
|
||||
is migrated by docker_config_migrate.py on boot, running as the hermes
|
||||
user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import docker_exec, docker_exec_sh, start_container
|
||||
|
||||
|
||||
def test_config_migration_runs_on_boot(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A config.yaml in $HERMES_HOME must be migrated on boot by
|
||||
docker_config_migrate.py, running as the hermes user."""
|
||||
# Start container
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Verify config.yaml exists (should be seeded by stage2 if not present)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -f /opt/data/config.yaml && echo EXISTS || echo MISSING",
|
||||
timeout=10,
|
||||
)
|
||||
assert "EXISTS" in r.stdout, (
|
||||
f"config.yaml not found in $HERMES_HOME: {r.stdout}"
|
||||
)
|
||||
|
||||
# Verify the migration script exists in the image
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -f /opt/hermes/scripts/docker_config_migrate.py && "
|
||||
"echo SCRIPT_EXISTS || echo SCRIPT_MISSING",
|
||||
timeout=10,
|
||||
)
|
||||
assert "SCRIPT_EXISTS" in r.stdout, (
|
||||
f"docker_config_migrate.py not found in image: {r.stdout}"
|
||||
)
|
||||
|
||||
# Verify config.yaml is owned by hermes (migration ran as hermes)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'stat -c "%U" /opt/data/config.yaml',
|
||||
timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "hermes", (
|
||||
f"config.yaml not owned by hermes (migration may have run as root): "
|
||||
f"{r.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_config_migration_opt_out_env_var_respected(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""HERMES_SKIP_CONFIG_MIGRATION=1 must skip the migration."""
|
||||
start_container(
|
||||
built_image, container_name, "HERMES_SKIP_CONFIG_MIGRATION=1",
|
||||
)
|
||||
|
||||
# config.yaml should still be seeded (seeding is separate from migration)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -f /opt/data/config.yaml && echo EXISTS || echo MISSING",
|
||||
timeout=10,
|
||||
)
|
||||
assert "EXISTS" in r.stdout, (
|
||||
f"config.yaml should be seeded even with migration skipped: {r.stdout}"
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Container-restart survives per-profile gateway registrations.
|
||||
|
||||
The s6 dynamic scandir at /run/service/ lives on tmpfs and is wiped
|
||||
on every container restart. Phase 4 Task 4.0's container_boot module
|
||||
+ cont-init.d/02-reconcile-profiles regenerate the service slots from
|
||||
$HERMES_HOME/profiles/<name>/gateway_state.json on every boot and
|
||||
auto-start only those whose last state was `running`.
|
||||
|
||||
These tests stand up a container with a named volume, create profiles
|
||||
inside it in various gateway states, restart the container, and
|
||||
assert the reconciler did the right thing.
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec` / :func:`docker_exec_sh` in conftest); see
|
||||
the conftest module docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.docker.conftest import docker_exec, docker_exec_sh, wait_for_path, wait_for_log, wait_for_docker_logs, poll_container
|
||||
|
||||
|
||||
def _docker(*args: str, **kw) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["docker", *args],
|
||||
capture_output=True, text=True, timeout=kw.pop("timeout", 60),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _wait_for_reconcile_log_mention(
|
||||
container: str,
|
||||
profile: str,
|
||||
*,
|
||||
deadline_s: float = 30.0,
|
||||
interval_s: float = 0.25,
|
||||
) -> str:
|
||||
"""Poll until /opt/data/logs/container-boot.log mentions `profile`.
|
||||
"""
|
||||
return wait_for_log(container, "/opt/data/logs/container-boot.log", f"profile={profile}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restart_container(request, built_image: str):
|
||||
"""A long-running container with a named volume so docker restart
|
||||
preserves $HERMES_HOME/profiles/."""
|
||||
safe = request.node.name.replace("[", "_").replace("]", "_")
|
||||
name = f"hermes-restart-{safe}"
|
||||
volume = f"hermes-restart-vol-{safe}"
|
||||
_docker("rm", "-f", name)
|
||||
_docker("volume", "rm", "-f", volume)
|
||||
_docker("volume", "create", volume, timeout=10).check_returncode()
|
||||
r = _docker(
|
||||
"run", "-d", "--name", name,
|
||||
"-v", f"{volume}:/opt/data",
|
||||
built_image, "sleep", "infinity",
|
||||
timeout=30,
|
||||
)
|
||||
r.check_returncode()
|
||||
# Wait for s6 + stage2 + 02-reconcile to publish the boot log so
|
||||
# the test can rely on the default slot being registered before
|
||||
# it starts issuing commands. The reconciler always writes one
|
||||
# 'default' line on every boot (PR #30136 item I1) — that's our
|
||||
# readiness signal.
|
||||
wait_for_log(name, "/opt/data/logs/container-boot.log", "profile=default")
|
||||
yield name
|
||||
_docker("rm", "-f", name)
|
||||
_docker("volume", "rm", "-f", volume)
|
||||
|
||||
|
||||
def test_running_gateway_survives_container_restart(restart_container: str) -> None:
|
||||
container = restart_container
|
||||
|
||||
# Create the profile + start its gateway. The Phase 4 hooks
|
||||
# register the s6 service slot during create and the dispatch
|
||||
# path brings it up via s6-svc -u.
|
||||
r = docker_exec(container, "hermes", "profile", "create", "coder")
|
||||
assert r.returncode == 0, f"profile create failed: {r.stderr}"
|
||||
|
||||
r = docker_exec(container, "hermes", "-p", "coder", "gateway", "start", timeout=60)
|
||||
assert r.returncode == 0, f"gateway start failed: {r.stderr}"
|
||||
|
||||
# Give the service time to actually come up under supervision.
|
||||
poll_container(container, "/command/s6-svstat /run/service/gateway-coder | grep -q 'up '")
|
||||
|
||||
# Persist state so the reconciler will treat the slot as 'running'
|
||||
# post-restart. The gateway process itself writes gateway_state.json
|
||||
# via gateway/status.py — but we don't want to wait for or assert
|
||||
# against the live process here; just stamp the file directly to
|
||||
# exercise the reconciler's contract.
|
||||
write_state = (
|
||||
"import json, pathlib; "
|
||||
"p = pathlib.Path('/opt/data/profiles/coder/gateway_state.json'); "
|
||||
"p.write_text(json.dumps({'gateway_state': 'running', 'timestamp': 1}))"
|
||||
)
|
||||
docker_exec(container, "python3", "-c", write_state, timeout=10).check_returncode()
|
||||
|
||||
# Restart. After this, /run/service/ is empty until cont-init.d
|
||||
# runs the reconciler. We need to wait long enough for the
|
||||
# reconciler to write coder's entry to the boot log AND for
|
||||
# s6-svscan to spin up the service supervise tree from the
|
||||
# restored slot. Polling the boot log gives us the first signal.
|
||||
_docker("restart", container, timeout=60).check_returncode()
|
||||
log = _wait_for_reconcile_log_mention(container, "coder", deadline_s=30.0)
|
||||
assert "action=started" in log
|
||||
|
||||
# Service slot exists.
|
||||
assert wait_for_path(
|
||||
container, "/run/service/gateway-coder", kind="d", deadline_s=10.0,
|
||||
), "slot not recreated after restart"
|
||||
|
||||
# No `down` marker — we asked for auto-start.
|
||||
r = docker_exec_sh(container, "test -f /run/service/gateway-coder/down")
|
||||
assert r.returncode != 0, "down marker present despite prior_state=running"
|
||||
|
||||
|
||||
def test_stopped_gateway_stays_stopped_after_restart(restart_container: str) -> None:
|
||||
container = restart_container
|
||||
|
||||
docker_exec(container, "hermes", "profile", "create", "writer").check_returncode()
|
||||
|
||||
# Write 'stopped' directly so we don't have to race against the
|
||||
# gateway's own state writes.
|
||||
write_state = (
|
||||
"import json, pathlib; "
|
||||
"p = pathlib.Path('/opt/data/profiles/writer/gateway_state.json'); "
|
||||
"p.write_text(json.dumps({'gateway_state': 'stopped', 'timestamp': 1}))"
|
||||
)
|
||||
docker_exec(container, "python3", "-c", write_state, timeout=10).check_returncode()
|
||||
|
||||
_docker("restart", container, timeout=60).check_returncode()
|
||||
_wait_for_reconcile_log_mention(container, "writer", deadline_s=30.0)
|
||||
|
||||
# Slot exists.
|
||||
assert wait_for_path(
|
||||
container, "/run/service/gateway-writer", kind="d", deadline_s=10.0,
|
||||
)
|
||||
|
||||
# Down marker present.
|
||||
r = docker_exec_sh(container, "test -f /run/service/gateway-writer/down")
|
||||
assert r.returncode == 0, "down marker missing despite prior_state=stopped"
|
||||
|
||||
|
||||
def test_stale_gateway_pid_cleaned_up_on_restart(restart_container: str) -> None:
|
||||
"""A dead container's gateway.pid + processes.json must NOT
|
||||
survive the restart — a numerically-equal live PID in the new
|
||||
container is a different process and would confuse the gateway
|
||||
process-mismatch checks."""
|
||||
container = restart_container
|
||||
|
||||
docker_exec(container, "hermes", "profile", "create", "ghost").check_returncode()
|
||||
|
||||
# Stamp stale runtime files alongside a 'running' state so the
|
||||
# reconciler walks this profile.
|
||||
stamp = (
|
||||
"import json, pathlib; "
|
||||
"p = pathlib.Path('/opt/data/profiles/ghost'); "
|
||||
"(p / 'gateway_state.json').write_text(json.dumps({'gateway_state': 'stopped', 'timestamp': 1})); "
|
||||
"(p / 'gateway.pid').write_text(json.dumps({'pid': 99999, 'host': 'old'})); "
|
||||
"(p / 'processes.json').write_text('[]')"
|
||||
)
|
||||
docker_exec(container, "python3", "-c", stamp, timeout=10).check_returncode()
|
||||
|
||||
_docker("restart", container, timeout=60).check_returncode()
|
||||
_wait_for_reconcile_log_mention(container, "ghost", deadline_s=30.0)
|
||||
|
||||
# Stale runtime files swept.
|
||||
r = docker_exec_sh(container, "test -f /opt/data/profiles/ghost/gateway.pid")
|
||||
assert r.returncode != 0, "stale gateway.pid survived restart"
|
||||
r = docker_exec_sh(container, "test -f /opt/data/profiles/ghost/processes.json")
|
||||
assert r.returncode != 0, "stale processes.json survived restart"
|
||||
|
||||
|
||||
def test_live_gateway_autostarts_after_real_restart_without_manual_state_stamp(
|
||||
restart_container: str,
|
||||
) -> None:
|
||||
"""End-to-end guard for issue #42675.
|
||||
|
||||
The other tests in this module stamp gateway_state.json directly to
|
||||
exercise the reconciler's READ side. This one exercises the WRITE
|
||||
side: a real, live gateway is killed by the container/s6 SIGTERM that
|
||||
`docker restart` sends — no manual state stamp — and must come back up
|
||||
on the next boot.
|
||||
|
||||
Before the fix, the shutdown handler unconditionally persisted
|
||||
gateway_state=stopped on that SIGTERM, so the reconciler saw 'stopped'
|
||||
and registered the slot DOWN — the gateway silently stayed dark after
|
||||
every container restart. The fix classifies an unmarked SIGTERM as
|
||||
signal-initiated and persists 'running' instead, so auto-start works.
|
||||
"""
|
||||
container = restart_container
|
||||
|
||||
docker_exec(container, "hermes", "profile", "create", "live").check_returncode()
|
||||
r = docker_exec(container, "hermes", "-p", "live", "gateway", "start", timeout=60)
|
||||
assert r.returncode == 0, f"gateway start failed: {r.stderr}"
|
||||
|
||||
# Wait for the gateway to actually come up under supervision AND write
|
||||
# its own gateway_state=running (we do NOT stamp it ourselves).
|
||||
poll_container(container, "/command/s6-svstat /run/service/gateway-live | grep -q 'up '")
|
||||
|
||||
# Confirm the gateway persisted its own 'running' state. The gateway has
|
||||
# to boot Python, discover ~50 plugins, construct GatewayRunner, and
|
||||
# reach write_runtime_status("running") at run.py start() — on a loaded
|
||||
# CI runner with parallel docker test containers competing for CPU, this
|
||||
# can take a while.
|
||||
wait_for_log(container, "/opt/data/profiles/live/gateway_state.json", '"running"', deadline_s=45, interval_s=1)
|
||||
|
||||
# Real restart — Docker sends SIGTERM to PID 1; s6 propagates it to the
|
||||
# supervised gateway. No planned-stop marker is written (this is not an
|
||||
# operator `hermes gateway stop`), so the shutdown is signal-initiated.
|
||||
_docker("restart", container, timeout=60).check_returncode()
|
||||
|
||||
log = _wait_for_reconcile_log_mention(container, "live", deadline_s=30.0)
|
||||
# The crux: the reconciler must AUTO-START it, not register it down.
|
||||
assert "action=started" in log, (
|
||||
f"gateway did NOT auto-start after a real restart (issue #42675 "
|
||||
f"regression): {log!r}"
|
||||
)
|
||||
|
||||
# Slot recreated, and NO down marker (we expect auto-start).
|
||||
assert wait_for_path(
|
||||
container, "/run/service/gateway-live", kind="d", deadline_s=10.0,
|
||||
), "slot not recreated after restart"
|
||||
r = docker_exec_sh(container, "test -f /run/service/gateway-live/down")
|
||||
assert r.returncode != 0, (
|
||||
"down marker present despite a live gateway being restarted — "
|
||||
"the signal-initiated shutdown wrongly persisted 'stopped' (#42675)"
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Harness: dashboard opt-in via HERMES_DASHBOARD.
|
||||
|
||||
Today (tini): dashboard starts once when HERMES_DASHBOARD=1; if it crashes
|
||||
it stays dead. After Phase 2 (s6): dashboard starts once; if it crashes
|
||||
it is restarted under supervision. The restart-after-crash test lives in
|
||||
Phase 2 Task 2.5; this file only locks the opt-in surface (which must
|
||||
not change between tini and s6).
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec`/:func:`docker_exec_sh` in conftest), matching
|
||||
the realistic runtime context. See the conftest module docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from tests.docker.conftest import docker_exec, docker_exec_sh, start_container, poll_container
|
||||
|
||||
|
||||
def test_dashboard_not_running_by_default(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Without HERMES_DASHBOARD, no dashboard process should be running."""
|
||||
start_container(built_image, container_name, cmd="sleep 60")
|
||||
r = docker_exec(container_name, "pgrep", "-f", "hermes dashboard")
|
||||
# pgrep exits non-zero when no match found
|
||||
assert r.returncode != 0, (
|
||||
"Dashboard should not be running without HERMES_DASHBOARD"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_slot_reports_down_when_disabled(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Without HERMES_DASHBOARD, s6-svstat should report the dashboard
|
||||
slot as DOWN (not up-with-sleep-infinity, which would
|
||||
false-positive `hermes doctor` and any other health check).
|
||||
|
||||
Locks the PR #30136 review item I3 fix: cont-init.d/03-dashboard-toggle
|
||||
writes a `down` marker file in the live service-dir when
|
||||
HERMES_DASHBOARD is unset, so the slot reflects reality.
|
||||
"""
|
||||
start_container(built_image, container_name, cmd="sleep 60")
|
||||
# /command/ isn't on PATH for docker-exec sessions, so call by
|
||||
# absolute path.
|
||||
r = docker_exec(
|
||||
container_name, "/command/s6-svstat", "/run/service/dashboard",
|
||||
)
|
||||
assert r.returncode == 0, f"s6-svstat failed: {r.stderr!r} / {r.stdout!r}"
|
||||
assert "down" in r.stdout, (
|
||||
f"Dashboard slot should be 'down' without HERMES_DASHBOARD; "
|
||||
f"svstat reports: {r.stdout!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_slot_reports_up_when_enabled(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Symmetry: with HERMES_DASHBOARD=1, s6-svstat reports the slot as up."""
|
||||
# The default dashboard host is 0.0.0.0, which now engages the
|
||||
# OAuth auth gate. Without a provider registered (no
|
||||
# HERMES_DASHBOARD_OAUTH_CLIENT_ID in this test env), start_server
|
||||
# would fail closed and the slot would never come up. Pin the
|
||||
# explicit insecure opt-in to keep this test focused on the s6
|
||||
# supervision contract, not the auth gate.
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=test-dashboard-pw",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
# uvicorn takes a moment to bind; poll svstat.
|
||||
poll_container(container_name, "/command/s6-svstat /run/service/dashboard | grep -q 'up '")
|
||||
|
||||
|
||||
def test_dashboard_opt_in_starts(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""With HERMES_DASHBOARD=1, a dashboard process should be visible."""
|
||||
# Default bind is 0.0.0.0, which engages the auth gate. Register the
|
||||
# bundled basic password provider so the gate has a provider and the
|
||||
# dashboard binds (vs fail-closed). Keeps the test focused on s6
|
||||
# supervision, not auth.
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=test-dashboard-pw",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
# Poll for the dashboard subprocess to appear — the entrypoint
|
||||
# backgrounds it and bootstrap (skills sync etc.) can take a few
|
||||
# seconds before the python process actually launches.
|
||||
ok, _ = poll_container(
|
||||
container_name, "pgrep -f 'hermes dashboard'", deadline_s=30.0,
|
||||
)
|
||||
assert ok, "Dashboard should be running with HERMES_DASHBOARD=1"
|
||||
|
||||
|
||||
def test_dashboard_port_override(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""HERMES_DASHBOARD_PORT changes the dashboard's listen port."""
|
||||
# Default bind is 0.0.0.0; register the basic password provider so
|
||||
# the auth gate has a provider and the dashboard binds. See
|
||||
# test_dashboard_slot_reports_up_when_enabled for the full rationale.
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_PORT=9120",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=test-dashboard-pw",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
# The dashboard process appearing in pgrep doesn't mean it's bound
|
||||
# to the port yet — uvicorn takes another second or two to come up.
|
||||
# The image doesn't ship ss/netstat, so probe /proc/net/tcp directly:
|
||||
# port 9120 = 0x23A0, state 0A = LISTEN.
|
||||
ok, stdout = poll_container(
|
||||
container_name,
|
||||
"grep -E ' 0+:23A0 .* 0A ' /proc/net/tcp /proc/net/tcp6 "
|
||||
"2>/dev/null",
|
||||
deadline_s=60.0,
|
||||
)
|
||||
assert ok, f"Dashboard not listening on port 9120: stdout={stdout!r}"
|
||||
|
||||
|
||||
def test_dashboard_restarts_after_crash(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Phase 2 invariant: under s6 supervision, killing the dashboard
|
||||
process should be recovered automatically.
|
||||
|
||||
Pre-s6 (tini) behavior was "stays dead" — the test wouldn't have
|
||||
passed against that image. After the s6-overlay migration the
|
||||
dashboard runs as a longrun s6-rc service and s6-supervise restarts
|
||||
it after a ~1s backoff (the default).
|
||||
"""
|
||||
# Default bind is 0.0.0.0; register the basic password provider so
|
||||
# the auth gate has a provider and the supervised dashboard binds.
|
||||
# See test_dashboard_slot_reports_up_when_enabled for the full
|
||||
# rationale.
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=test-dashboard-pw",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
# Wait for the first dashboard to come up.
|
||||
ok, _ = poll_container(
|
||||
container_name, "pgrep -f 'hermes dashboard'", deadline_s=30.0,
|
||||
)
|
||||
assert ok, "Dashboard never started initially"
|
||||
|
||||
# Grab the initial PID. s6 may briefly transition through restart
|
||||
# state between our poll-success and the follow-up pgrep, so retry
|
||||
# a couple of times before giving up.
|
||||
first_pid: str | None = None
|
||||
for _attempt in range(10):
|
||||
first_pid_result = docker_exec(
|
||||
container_name, "pgrep", "-f", "hermes dashboard",
|
||||
)
|
||||
first_pids = first_pid_result.stdout.strip().split()
|
||||
if first_pids:
|
||||
first_pid = first_pids[0]
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert first_pid is not None, "Could not capture initial dashboard PID"
|
||||
|
||||
# Kill the dashboard. The dashboard process runs as hermes, so the
|
||||
# hermes user can kill it (same UID).
|
||||
docker_exec(container_name, "kill", "-9", first_pid)
|
||||
|
||||
# s6 backs off ~1s before restart; allow up to 15s for the new
|
||||
# process to appear with a different PID.
|
||||
deadline = time.monotonic() + 15.0
|
||||
while time.monotonic() < deadline:
|
||||
r = docker_exec(container_name, "pgrep", "-f", "hermes dashboard")
|
||||
pids = r.stdout.strip().split() if r.returncode == 0 else []
|
||||
if pids and pids[0] != first_pid:
|
||||
return # success
|
||||
time.sleep(0.5)
|
||||
|
||||
raise AssertionError(
|
||||
f"Dashboard not restarted after kill (first_pid={first_pid})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth auth-gate behaviour — regression guard for the dashboard-insecure
|
||||
# auto-injection bug. Pre-fix, the s6 run script appended `--insecure`
|
||||
# whenever `HERMES_DASHBOARD_HOST` was non-loopback, silently disabling
|
||||
# the OAuth gate on every container-deployed dashboard. The matching
|
||||
# static-text guard lives in tests/test_docker_home_override_scripts.py;
|
||||
# this is the behavioural end-to-end check.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _http_probe(
|
||||
container: str,
|
||||
path: str,
|
||||
*,
|
||||
deadline_s: float = 60.0,
|
||||
) -> tuple[int, str]:
|
||||
"""Poll ``http://127.0.0.1:9119<path>`` from inside the container.
|
||||
|
||||
Returns ``(status_code, body)`` as soon as the dashboard answers any
|
||||
HTTP response — 200, 401, 503, anything. The image doesn't ship
|
||||
``curl`` but the venv's stdlib ``urllib`` is good enough; we use a
|
||||
proper ``try``/``except`` to intercept ``HTTPError`` because
|
||||
``urlopen`` raises on 4xx/5xx, and we treat those as legitimate
|
||||
responses (the OAuth gate's 401 IS the success signal for the
|
||||
gate-engaged test).
|
||||
|
||||
Connection errors (uvicorn still starting, fail-closed exited) keep
|
||||
the poll loop running until ``deadline_s`` elapses.
|
||||
|
||||
The probe Python program is fed over stdin (``python -``) rather
|
||||
than ``python -c`` so we can use proper multi-line syntax with
|
||||
``try``/``except`` blocks without escaping hell.
|
||||
|
||||
Raises ``AssertionError`` on timeout.
|
||||
"""
|
||||
py_program = f"""\
|
||||
import urllib.request, urllib.error
|
||||
req = urllib.request.Request("http://127.0.0.1:9119{path}")
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=5)
|
||||
print(r.status)
|
||||
print(r.read().decode(), end="")
|
||||
except urllib.error.HTTPError as h:
|
||||
print(h.code)
|
||||
print(h.read().decode(), end="")
|
||||
"""
|
||||
# Feed the program over stdin via a heredoc so docker_exec_sh's
|
||||
# single bash string stays clean. The 'PY' delimiter is quoted to
|
||||
# disable shell expansion inside the heredoc body.
|
||||
probe = (
|
||||
"/opt/hermes/.venv/bin/python - <<'PY'\n"
|
||||
f"{py_program}"
|
||||
"PY"
|
||||
)
|
||||
end = time.monotonic() + deadline_s
|
||||
last_err = ""
|
||||
while time.monotonic() < end:
|
||||
r = docker_exec_sh(container, probe, timeout=10)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
lines = r.stdout.split("\n", 1)
|
||||
try:
|
||||
status = int(lines[0].strip())
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
return status, body
|
||||
except (ValueError, IndexError) as exc:
|
||||
last_err = f"parse: {exc!r} / stdout={r.stdout!r}"
|
||||
else:
|
||||
last_err = f"rc={r.returncode} stderr={r.stderr!r}"
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(
|
||||
f"Probe of {path} never returned HTTP within {deadline_s}s; "
|
||||
f"last error: {last_err}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_oauth_gate_engages_on_non_loopback_bind(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The s6 dashboard run script must NOT auto-add ``--insecure`` when the
|
||||
dashboard binds to ``0.0.0.0``. The OAuth auth gate engages on its own
|
||||
when a ``DashboardAuthProvider`` is registered (the bundled nous
|
||||
provider activates whenever ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` is
|
||||
set).
|
||||
|
||||
Regression guard for the wildcard-subdomain rollout where every
|
||||
portal-provisioned agent binds ``0.0.0.0`` and relies on the OAuth
|
||||
gate to authenticate browser callers. Before this fix, the run script
|
||||
flipped ``--insecure`` on for any non-loopback bind, which routed
|
||||
``start_server`` straight back into the legacy ``allow_public=True``
|
||||
branch and disabled the gate every time.
|
||||
|
||||
We verify two independent observable consequences of the gate being
|
||||
on:
|
||||
|
||||
1. ``/api/auth/providers`` (publicly reachable through the gate so
|
||||
the login page can bootstrap) returns 200 with ``nous`` in the
|
||||
provider list — proves the bundled provider registered.
|
||||
2. ``/api/sessions`` (a gated route under both the legacy
|
||||
``_SESSION_TOKEN`` middleware and the OAuth gate) returns 401
|
||||
to an unauthenticated caller — proves the OAuth gate is actively
|
||||
intercepting browser traffic. We deliberately probe a gated route
|
||||
here rather than ``/api/status``: status sits in the shared
|
||||
``PUBLIC_API_PATHS`` allowlist (portal liveness probe target) and
|
||||
responds 200 without a cookie under both gates, so it cannot
|
||||
distinguish "gate on" from "gate off".
|
||||
"""
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_HOST=0.0.0.0",
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID=agent:test-instance",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
|
||||
# (1) Provider registry visible via the public bootstrap endpoint.
|
||||
status_code, body = _http_probe(container_name, "/api/auth/providers")
|
||||
assert status_code == 200, (
|
||||
f"/api/auth/providers should return 200 when a provider is "
|
||||
f"registered; got {status_code} body={body!r}"
|
||||
)
|
||||
payload = json.loads(body)
|
||||
provider_names = [p.get("name") for p in payload.get("providers", [])]
|
||||
assert "nous" in provider_names, (
|
||||
"Bundled dashboard_auth/nous provider should register when "
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID is set. Got: {payload!r}"
|
||||
)
|
||||
|
||||
# (2) A gated route (``/api/sessions``) returns 401 to an
|
||||
# unauthenticated caller — the OAuth gate is intercepting.
|
||||
status_code, body = _http_probe(container_name, "/api/sessions")
|
||||
assert status_code == 401, (
|
||||
"OAuth gate must intercept gated /api/* routes on 0.0.0.0 bind "
|
||||
"when a provider is registered and HERMES_DASHBOARD_INSECURE "
|
||||
f"is unset. Got: status={status_code} body={body!r}"
|
||||
)
|
||||
|
||||
# (3) ``/api/status`` remains 200 under the gate — it's in the shared
|
||||
# ``PUBLIC_API_PATHS`` allowlist so NAS's wildcard-subdomain
|
||||
# liveness probe (``fly-provider.ts`` ``getInstanceRuntimeStatus``)
|
||||
# can reach it without a cookie. Regression guard: this allowlist
|
||||
# drifted once already and surfaced every healthy agent as
|
||||
# STARTING/down in the portal UI.
|
||||
status_code, body = _http_probe(container_name, "/api/status")
|
||||
assert status_code == 200, (
|
||||
"/api/status must remain publicly reachable under the OAuth gate "
|
||||
"— the portal uses it as the wildcard-subdomain liveness probe. "
|
||||
f"Got: status={status_code} body={body!r}"
|
||||
)
|
||||
status = json.loads(body)
|
||||
assert status.get("auth_required") is True, (
|
||||
"/api/status must report auth_required=True when the OAuth gate "
|
||||
f"is engaged so the SPA/portal can distinguish modes. Got: {status!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_insecure_env_var_no_longer_bypasses_gate(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""``HERMES_DASHBOARD_INSECURE=1`` NO LONGER disables the auth gate
|
||||
(June 2026 hardening). With insecure set on a 0.0.0.0 bind and NO auth
|
||||
provider registered, start_server fails closed — the dashboard never
|
||||
binds, so ``/api/status`` is unreachable. This proves the unauthenticated
|
||||
public-dashboard escape hatch is gone: there is no env that serves the
|
||||
dashboard on a public bind without an auth provider.
|
||||
"""
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
"HERMES_DASHBOARD_HOST=0.0.0.0",
|
||||
"HERMES_DASHBOARD_INSECURE=1",
|
||||
cmd="sleep 120",
|
||||
)
|
||||
# Fail-closed: the dashboard process must NOT successfully serve. Probe
|
||||
# for a few seconds; /api/status should never become reachable because
|
||||
# start_server raised SystemExit before binding.
|
||||
ok, _ = poll_container(
|
||||
container_name,
|
||||
"curl -fsS -m 2 http://127.0.0.1:9119/api/status >/dev/null 2>&1",
|
||||
deadline_s=12.0,
|
||||
)
|
||||
assert not ok, (
|
||||
"Dashboard must NOT serve on a public bind with --insecure and no "
|
||||
"auth provider — the gate fails closed. /api/status became reachable, "
|
||||
"meaning the unauthenticated escape hatch is still open."
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""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_shim_short_circuits_for_non_root_exec(sleep_container: str) -> None:
|
||||
"""docker exec --user hermes already runs as 10000; shim should be a no-op.
|
||||
|
||||
Verified indirectly: the command must still succeed end-to-end. If the
|
||||
shim incorrectly tried to drop privileges a second time (e.g. by
|
||||
invoking s6-setuidgid which requires root), it would fail with
|
||||
EPERM. A clean success proves the short-circuit fired.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "--user", "hermes", sleep_container,
|
||||
"hermes", "config", "set", "_test.shim_short_circuit", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"docker exec --user hermes failed: {r.stderr!r} stdout={r.stdout!r}. "
|
||||
"If the shim mis-handled the non-root path, this would fail with EPERM."
|
||||
)
|
||||
|
||||
# File still ends up hermes:hermes — orthogonally confirms uid.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "hermes:hermes"
|
||||
|
||||
|
||||
def test_shim_opt_out_keeps_root(sleep_container: str) -> None:
|
||||
"""HERMES_DOCKER_EXEC_AS_ROOT=1 should suppress the privilege drop.
|
||||
|
||||
Reserved for diagnostic sessions where the operator deliberately
|
||||
wants root semantics. Verified by writing a file and checking its
|
||||
owner.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec",
|
||||
"-e", "HERMES_DOCKER_EXEC_AS_ROOT=1",
|
||||
sleep_container,
|
||||
"hermes", "config", "set", "_test.opt_out", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"opt-out invocation failed: {r.stderr}"
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "root:root", (
|
||||
f"With HERMES_DOCKER_EXEC_AS_ROOT=1, expected root:root, "
|
||||
f"got {r.stdout.strip()!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("falsy_value", ["0", "false", "no", "", "garbage", "2"])
|
||||
def test_shim_opt_out_strict_truthiness(
|
||||
sleep_container: str, falsy_value: str,
|
||||
) -> None:
|
||||
"""Anything other than 1/true/yes (case-insensitive) does NOT opt out.
|
||||
|
||||
Strict truthiness so a typo (``HERMES_DOCKER_EXEC_AS_ROOT=0``) doesn't
|
||||
silently keep the user as root. Mirrors the policy used by
|
||||
``HERMES_GATEWAY_NO_SUPERVISE`` in #33583.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "exec", "--user", "root", sleep_container,
|
||||
"rm", "-f", "/opt/data/config.yaml"],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec",
|
||||
"-e", f"HERMES_DOCKER_EXEC_AS_ROOT={falsy_value}",
|
||||
sleep_container,
|
||||
"hermes", "config", "set", "_test.falsy", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"falsy value {falsy_value!r} caused failure: {r.stderr}"
|
||||
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "hermes:hermes", (
|
||||
f"falsy opt-out value {falsy_value!r} unexpectedly suppressed the drop; "
|
||||
f"file owner is {r.stdout.strip()!r}, expected hermes: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
|
||||
|
||||
|
||||
def test_e2e_login_then_supervised_gateway_can_read_auth(
|
||||
sleep_container: str,
|
||||
) -> None:
|
||||
"""End-to-end regression for the original bug.
|
||||
|
||||
Pre-shim: ``docker exec <c> hermes login`` (root) wrote
|
||||
/opt/data/auth.json as root:root 0600. The supervised gateway (UID
|
||||
10000) couldn't read it, _load_auth_store swallowed PermissionError
|
||||
as a parse failure, and resolve_nous_runtime_credentials raised
|
||||
"Hermes is not logged into Nous Portal" on every message.
|
||||
|
||||
We can't do a real OAuth login in a unit test, but we can stand in
|
||||
for it by writing the same file shape via `hermes config set`-style
|
||||
writes — what matters is the *file ownership invariant* downstream
|
||||
of `_save_auth_store`. If the shim works, every file the
|
||||
`docker exec` path produces is hermes-readable.
|
||||
|
||||
Specifically: pretend the operator ran `hermes login` (writes
|
||||
auth.json) and verify (a) the file exists and (b) it's readable by
|
||||
the hermes UID. We use `hermes auth list` since that touches the
|
||||
auth store on the read side and would fail with the same
|
||||
'not logged in' shape if the file was unreadable to uid 10000.
|
||||
"""
|
||||
# Have the shim-protected `docker exec` write the auth store.
|
||||
# `hermes auth list` is read-only but still exercises _load_auth_store
|
||||
# under the shim's UID. We invoke `hermes config set` first to
|
||||
# provoke a write into HERMES_HOME so we have something concrete to
|
||||
# owner-check.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", sleep_container,
|
||||
"hermes", "config", "set", "_test.e2e_marker", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"config set failed: {r.stderr}"
|
||||
|
||||
# The supervised UID (10000) must be able to read everything under
|
||||
# HERMES_HOME that docker exec just wrote.
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "--user", "hermes", sleep_container,
|
||||
"find", "/opt/data", "-maxdepth", "2", "-type", "f",
|
||||
"!", "-readable", "-print"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
assert r.returncode == 0, f"find failed: {r.stderr}"
|
||||
unreadable = [ln for ln in r.stdout.splitlines() if ln.strip()]
|
||||
assert not unreadable, (
|
||||
"Files written by `docker exec` are unreadable to the hermes user "
|
||||
f"(supervised gateway UID): {unreadable}. The shim failed to drop "
|
||||
"privileges before the write."
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Regression test: ``hermes dump`` reports a real git SHA inside the container.
|
||||
|
||||
Background: ``.dockerignore`` excludes ``.git``, so ``git rev-parse HEAD``
|
||||
fails inside the published image and ``hermes dump`` used to report
|
||||
``version: ... [(unknown)]``. The Dockerfile now writes the build-time
|
||||
``$HERMES_GIT_SHA`` build-arg to ``/opt/hermes/.hermes_build_sha`` and
|
||||
``hermes_cli/build_info.py`` reads it as a fallback.
|
||||
|
||||
CI (``.github/workflows/docker.yml``) always sets the build-arg
|
||||
to ``${{ github.sha }}``. Local ``docker build`` (the ``built_image``
|
||||
fixture in ``tests/docker/conftest.py``) does NOT — so locally the file
|
||||
is absent and ``hermes dump`` correctly falls back to ``(unknown)``.
|
||||
|
||||
This test handles both cases:
|
||||
|
||||
* If ``/opt/hermes/.hermes_build_sha`` exists in the image, assert that
|
||||
``hermes dump`` surfaces its content as the version SHA (not
|
||||
``(unknown)``).
|
||||
* If the file is absent, assert the legacy behaviour (``(unknown)``)
|
||||
still holds — defensive guard against the helper accidentally
|
||||
reporting bogus data from somewhere else.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
_VERSION_LINE = re.compile(r"^version:\s+(?P<rest>.+)$", re.MULTILINE)
|
||||
_SHA_BRACKET = re.compile(r"\[(?P<sha>[^\]]+)\]\s*$")
|
||||
|
||||
|
||||
def _run_dump(image: str) -> str:
|
||||
"""Return the stdout of ``docker run <image> dump``.
|
||||
|
||||
Relies on Docker's anonymous VOLUME for ``/opt/data`` (declared by the
|
||||
Dockerfile) so the container's hermes user (UID 10000) can bootstrap
|
||||
its config. Anonymous volumes are auto-cleaned by ``--rm``, so unlike
|
||||
a host bind-mount we don't have to chown anything to UID 10000 (which
|
||||
would break cleanup on non-root hosts).
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", image, "dump"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"hermes dump exited {r.returncode}: "
|
||||
f"stderr={r.stderr[-1000:]!r}\nstdout={r.stdout[-1000:]!r}"
|
||||
)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _read_baked_sha_from_image(image: str) -> str | None:
|
||||
"""Return the ``/opt/hermes/.hermes_build_sha`` content, or None if absent."""
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "run", "--rm", "--entrypoint", "cat", image,
|
||||
"/opt/hermes/.hermes_build_sha",
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return r.stdout.strip() or None
|
||||
|
||||
|
||||
def test_dump_reports_baked_sha_when_present(built_image: str) -> None:
|
||||
"""When the image was built with ``HERMES_GIT_SHA``, dump must surface it.
|
||||
|
||||
Together with the smoke-test action (which exercises ``--help``), this
|
||||
closes the regression loop for the missing-sha bug: any future change
|
||||
that breaks the baked-file -> dump pipeline will fail CI here.
|
||||
"""
|
||||
baked = _read_baked_sha_from_image(built_image)
|
||||
stdout = _run_dump(built_image)
|
||||
|
||||
match = _VERSION_LINE.search(stdout)
|
||||
assert match, f"no `version:` line in dump output:\n{stdout[:2000]}"
|
||||
sha_match = _SHA_BRACKET.search(match.group("rest"))
|
||||
assert sha_match, (
|
||||
f"`version:` line missing [<sha>] bracket: {match.group('rest')!r}"
|
||||
)
|
||||
reported = sha_match.group("sha")
|
||||
|
||||
if baked is None:
|
||||
# Local-build path: no build-arg was passed. Verify the legacy
|
||||
# fallback ``(unknown)`` is intact — guards against the helper
|
||||
# ever inventing a SHA from thin air.
|
||||
assert reported == "(unknown)", (
|
||||
f"expected '(unknown)' when no SHA baked, got {reported!r}"
|
||||
)
|
||||
return
|
||||
|
||||
# CI path: build-arg was set, baked file exists. ``hermes dump``
|
||||
# truncates to 8 chars via ``git rev-parse --short=8`` semantics.
|
||||
assert reported != "(unknown)", (
|
||||
"baked SHA file present in image but dump still reported "
|
||||
f"'(unknown)' — the build-info fallback is broken. "
|
||||
f"Baked file content: {baked!r}"
|
||||
)
|
||||
assert reported == baked[:8], (
|
||||
f"dump reported {reported!r} but baked file contained {baked!r} "
|
||||
f"(expected first 8 chars: {baked[:8]!r})"
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Runtime smoke tests for Docker gateway_state.json bootstrap seeding.
|
||||
|
||||
Build the real image and verify the actual runtime behavior:
|
||||
|
||||
1. HERMES_GATEWAY_BOOTSTRAP_STATE=running on a fresh volume seeds
|
||||
gateway_state.json with running state
|
||||
2. An existing gateway_state.json is never clobbered (first-boot-only)
|
||||
3. No env var = no seed (default down-on-first-boot preserved)
|
||||
4. Only literal "running" is honored; other values are ignored
|
||||
5. Symlinked gateway_state.json / auth.json are never written through
|
||||
(path_has_symlink_component guard)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, wait_for_container_ready
|
||||
|
||||
|
||||
def _start_container(
|
||||
built_image: str, name: str, *env: str,
|
||||
) -> str:
|
||||
"""Start a container with given env vars, return its name."""
|
||||
args = ["docker", "run", "-d", "--name", name]
|
||||
for e in env:
|
||||
args.extend(["-e", e])
|
||||
args.extend([built_image, "sleep", "infinity"])
|
||||
subprocess.run(args, check=True, capture_output=True, timeout=60)
|
||||
wait_for_container_ready(name)
|
||||
return name
|
||||
|
||||
|
||||
def test_seeds_running_state_on_blank_volume(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""HERMES_GATEWAY_BOOTSTRAP_STATE=running on a fresh volume must
|
||||
seed gateway_state.json with a valid running state."""
|
||||
_start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_GATEWAY_BOOTSTRAP_STATE=running",
|
||||
)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"cat /opt/data/gateway_state.json 2>/dev/null || echo NONE",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() != "NONE", (
|
||||
f"gateway_state.json not seeded on fresh volume: {r.stdout}"
|
||||
)
|
||||
state = json.loads(r.stdout.strip())
|
||||
assert state.get("gateway_state") == "running", (
|
||||
f"expected gateway_state=running, got: {state}"
|
||||
)
|
||||
|
||||
|
||||
def test_does_not_clobber_existing_state(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""An existing gateway_state.json must never be overwritten by the
|
||||
seed, even when the bootstrap env var says running.
|
||||
|
||||
We use a named volume so we can pre-create the state file before
|
||||
the container boots. The [ ! -f ] guard in stage2 must skip seeding
|
||||
because the file already exists. We check the file immediately after
|
||||
boot — before the gateway service has a chance to write its own
|
||||
state — by reading it as fast as possible after container start.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
volume = f"{container_name}-vol"
|
||||
subprocess.run(
|
||||
["docker", "volume", "create", volume],
|
||||
check=True, capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
# Pre-create the state file via a throwaway container
|
||||
existing = _json.dumps({"gateway_state": "stopped", "pid": 123})
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm", "-v", f"{volume}:/opt/data",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", f"printf '{existing}\\n' > /opt/data/gateway_state.json"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
# Boot with the env var set — stage2 must NOT clobber the existing file
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-v", f"{volume}:/opt/data",
|
||||
"-e", "HERMES_GATEWAY_BOOTSTRAP_STATE=running",
|
||||
built_image, "sleep", "infinity"],
|
||||
check=True, capture_output=True, timeout=60,
|
||||
)
|
||||
# Read the file as quickly as possible — the gateway service may
|
||||
# start and write its own state, but the stage2 [ ! -f ] guard runs
|
||||
# during cont-init (before any service starts), so the file must
|
||||
# still be our "stopped" state at this point.
|
||||
wait_for_container_ready(container_name)
|
||||
r = docker_exec_sh(
|
||||
container_name, "cat /opt/data/gateway_state.json", timeout=10,
|
||||
)
|
||||
state = _json.loads(r.stdout.strip())
|
||||
assert state.get("gateway_state") == "stopped", (
|
||||
f"existing state was clobbered by bootstrap seed: {state}"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_name],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "volume", "rm", "-f", volume],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def test_no_seed_when_env_unset(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""No HERMES_GATEWAY_BOOTSTRAP_STATE = no seed file written."""
|
||||
_start_container(built_image, container_name)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -f /opt/data/gateway_state.json && "
|
||||
"echo EXISTS || echo ABSENT",
|
||||
timeout=10,
|
||||
)
|
||||
assert "ABSENT" in r.stdout, (
|
||||
f"gateway_state.json was seeded without the env var: {r.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_non_running_value_ignored(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Only literal 'running' is honored; any other value is ignored."""
|
||||
for bogus in ("stopped", "Running", "1", "true", "starting"):
|
||||
# Need a fresh container per iteration
|
||||
name = f"{container_name}-{bogus}"
|
||||
_start_container(
|
||||
built_image, name,
|
||||
f"HERMES_GATEWAY_BOOTSTRAP_STATE={bogus}",
|
||||
)
|
||||
r = docker_exec_sh(
|
||||
name,
|
||||
"test -f /opt/data/gateway_state.json && "
|
||||
"echo EXISTS || echo ABSENT",
|
||||
timeout=10,
|
||||
)
|
||||
assert "ABSENT" in r.stdout, (
|
||||
f"bogus value {bogus!r} should not seed a state file: {r.stdout}"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _boot_with_bind_mount(
|
||||
built_image: str, name: str, host_dir: Path, *env: str,
|
||||
) -> None:
|
||||
"""Boot a container with host_dir bind-mounted to /opt/data."""
|
||||
args = ["docker", "run", "-d", "--name", name,
|
||||
"-v", f"{host_dir}:/opt/data"]
|
||||
for e in env:
|
||||
args.extend(["-e", e])
|
||||
args.extend([built_image, "sleep", "infinity"])
|
||||
subprocess.run(args, check=True, capture_output=True, timeout=60)
|
||||
wait_for_container_ready(name)
|
||||
|
||||
|
||||
def _cleanup_bind_mount(built_image: str, container_name: str, host_dir: Path) -> None:
|
||||
"""Remove root/hermes-owned files left in a bind-mounted host dir.
|
||||
|
||||
The stage2 hook chowns /opt/data (and its contents) to UID 10000
|
||||
(hermes), which the host test user cannot delete. We run a throwaway
|
||||
container as root to chown everything back and rm -rf the contents
|
||||
before the temp dir is cleaned up.
|
||||
"""
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_name],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{host_dir}:/clean",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", "chown -R 0:0 /clean 2>/dev/null; rm -rf /clean/* /clean/.* 2>/dev/null; chown 0:0 /clean; true"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
|
||||
|
||||
def test_does_not_seed_gateway_state_through_symlink(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A symlinked gateway_state.json must not become a host write.
|
||||
|
||||
The path_has_symlink_component guard in stage2-hook.sh must detect
|
||||
the symlink and refuse to seed, printing a warning instead. The
|
||||
symlink target file (outside /opt/data) must NOT be created.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp()
|
||||
host_data: Path | None = None
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
host_data = tmp_path / "data"
|
||||
host_data.mkdir()
|
||||
|
||||
# Pre-create the symlink as root via a throwaway container
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{host_data}:/opt/data",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", "ln -s /tmp/outside-gateway-state.json /opt/data/gateway_state.json"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
_boot_with_bind_mount(
|
||||
built_image, container_name, host_data,
|
||||
"HERMES_GATEWAY_BOOTSTRAP_STATE=running",
|
||||
)
|
||||
|
||||
# The symlink itself must still exist (not replaced by a file)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -L /opt/data/gateway_state.json && echo SYMLINK || echo NOT_SYMLINK",
|
||||
timeout=5,
|
||||
)
|
||||
assert "SYMLINK" in r.stdout, (
|
||||
f"gateway_state.json symlink was replaced by a regular file: {r.stdout}"
|
||||
)
|
||||
|
||||
# The refusal warning goes to stdout (docker logs), not
|
||||
# container-boot.log (which is written by container_boot.py).
|
||||
r = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = r.stdout + r.stderr
|
||||
assert "refusing" in combined and "gateway_state.json" in combined, (
|
||||
f"expected symlink refusal warning in docker logs, got: {combined}"
|
||||
)
|
||||
finally:
|
||||
if host_data is not None:
|
||||
_cleanup_bind_mount(built_image, container_name, host_data)
|
||||
try:
|
||||
host_data.rmdir()
|
||||
tmp_path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_does_not_seed_auth_json_through_symlink(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A symlinked auth.json must not become a host write.
|
||||
|
||||
Same guard as gateway_state.json — the auth.json seed must also
|
||||
respect path_has_symlink_component and refuse to write through
|
||||
the symlink.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp()
|
||||
host_data: Path | None = None
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
host_data = tmp_path / "data"
|
||||
host_data.mkdir()
|
||||
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{host_data}:/opt/data",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", "ln -s /tmp/outside-auth.json /opt/data/auth.json"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
_boot_with_bind_mount(
|
||||
built_image, container_name, host_data,
|
||||
'HERMES_AUTH_JSON_BOOTSTRAP={"api_key":"test"}',
|
||||
)
|
||||
|
||||
# The symlink must still exist
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -L /opt/data/auth.json && echo SYMLINK || echo NOT_SYMLINK",
|
||||
timeout=5,
|
||||
)
|
||||
assert "SYMLINK" in r.stdout, (
|
||||
f"auth.json symlink was replaced by a regular file: {r.stdout}"
|
||||
)
|
||||
|
||||
# The refusal warning goes to stdout (docker logs), not
|
||||
# container-boot.log (which is written by container_boot.py).
|
||||
r = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = r.stdout + r.stderr
|
||||
assert "refusing" in combined and "auth.json" in combined, (
|
||||
f"expected symlink refusal warning for auth.json in docker logs: {combined}"
|
||||
)
|
||||
finally:
|
||||
if host_data is not None:
|
||||
_cleanup_bind_mount(built_image, container_name, host_data)
|
||||
try:
|
||||
host_data.rmdir()
|
||||
tmp_path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Harness: `docker run <image> gateway run` redirects to supervised mode.
|
||||
|
||||
Before the s6 migration, ``docker run nousresearch/hermes-agent gateway
|
||||
run`` was the standard pattern — the gateway ran as the container's
|
||||
main process, container exit code matched gateway exit code, no
|
||||
supervision. With s6 as PID 1, the same invocation now auto-redirects
|
||||
to the supervised path (`gateway start`) so users get auto-restart on
|
||||
crash and a supervised dashboard alongside (when ``HERMES_DASHBOARD=1``).
|
||||
|
||||
These tests verify the three load-bearing properties of that redirect:
|
||||
|
||||
1. The default invocation **does** redirect (container stays up via
|
||||
``sleep infinity`` while s6 supervises ``gateway-default``).
|
||||
2. ``--no-supervise`` / ``HERMES_GATEWAY_NO_SUPERVISE=1`` opts out.
|
||||
3. The supervised process itself does NOT recurse — the
|
||||
``HERMES_S6_SUPERVISED_CHILD`` sentinel breaks the loop.
|
||||
|
||||
Every ``docker exec`` runs as ``hermes`` per the conftest module
|
||||
docstring; see ``tests/docker/conftest.py`` for rationale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from tests.docker.conftest import (
|
||||
docker_exec_sh,
|
||||
start_container,
|
||||
wait_for_docker_logs,
|
||||
)
|
||||
|
||||
|
||||
def _svstat(container: str, slot: str = "gateway-default") -> str:
|
||||
r = docker_exec_sh(container, f"/command/s6-svstat /run/service/{slot}")
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def _svstat_wants_up(container: str, slot: str = "gateway-default") -> bool:
|
||||
"""See test_profile_gateway._svstat_wants_up for the format rules."""
|
||||
state = _svstat(container, slot)
|
||||
if not state:
|
||||
return False
|
||||
head = state.split()[0] if state.split() else ""
|
||||
if head == "up":
|
||||
return "want down" not in state
|
||||
return "want up" in state
|
||||
|
||||
|
||||
def _wait_for_gateway_or_exit(
|
||||
container: str,
|
||||
*,
|
||||
deadline_s: float = 60.0,
|
||||
) -> str:
|
||||
"""Poll until the container is either running a foreground gateway
|
||||
process or has exited. Returns the final container status.
|
||||
|
||||
Used by the ``--no-supervise`` tests where the gateway runs as the
|
||||
CMD process (not supervised by s6). Under CI load the gateway can
|
||||
take well over 6s to finish Python imports and reach the gateway
|
||||
entrypoint — a fixed ``time.sleep(6)`` races. Polling for
|
||||
``pgrep -f 'hermes.*gateway'`` (the gateway is running) or
|
||||
``docker inspect`` returning ``exited`` is both faster on quick
|
||||
machines and flake-free on slow ones.
|
||||
"""
|
||||
end = time.monotonic() + deadline_s
|
||||
while time.monotonic() < end:
|
||||
r = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
status = r.stdout.strip()
|
||||
if status == "exited":
|
||||
return "exited"
|
||||
if status == "running":
|
||||
# Check if the gateway process is actually running in the
|
||||
# foreground (the no-supervise path). If it is, we're done.
|
||||
pgrep = docker_exec_sh(
|
||||
container, "pgrep -f 'hermes.*gateway' >/dev/null 2>&1",
|
||||
)
|
||||
if pgrep.returncode == 0:
|
||||
return "running"
|
||||
time.sleep(0.5)
|
||||
return status
|
||||
|
||||
|
||||
def test_gateway_run_redirects_to_supervised(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""``docker run <image> gateway run`` (the historical invocation)
|
||||
should now register and start the ``gateway-default`` s6 slot.
|
||||
|
||||
The CMD process itself shouldn't be the gateway — it should be
|
||||
blocked on ``sleep infinity``, leaving s6 to supervise the actual
|
||||
gateway process. We verify by:
|
||||
|
||||
* Confirming the CMD process is sleeping (not python/gateway).
|
||||
* Confirming ``s6-svstat gateway-default`` reports want-up.
|
||||
"""
|
||||
# Start the container detached using the historical gateway-run
|
||||
# pattern. The redirect should fire and the container should NOT
|
||||
# exit immediately (which is what would happen pre-this-PR on the
|
||||
# s6 image — the foreground gateway would crash without config,
|
||||
# the CMD would exit, /init would shut down).
|
||||
start_container(built_image, container_name, cmd="gateway run")
|
||||
|
||||
# Wait for the redirect breadcrumb to appear in docker logs.
|
||||
# Under heavy parallel load (32-way docker test fan-out), the CMD
|
||||
# process (main-wrapper.sh → python → hermes gateway run) can take
|
||||
# well over 5s to reach the redirect logic. The breadcrumb is the
|
||||
# definitive signal that the redirect fired — polling for it is
|
||||
# both faster on quick machines and flake-free on slow ones.
|
||||
# Under heavy parallel docker load (32-way fan-out), the CMD process
|
||||
# (main-wrapper.sh → python → hermes gateway run) can take well over
|
||||
# 30s to import the codebase, load config, and reach the redirect
|
||||
# logic. 60s matches the deadline other boot-readiness polls use.
|
||||
logs = wait_for_docker_logs(
|
||||
container_name, "s6 supervision", deadline_s=60.0,
|
||||
)
|
||||
assert "s6 supervision" in logs, (
|
||||
f"expected loud breadcrumb in docker logs; got:\n{logs}"
|
||||
)
|
||||
assert "--no-supervise" in logs, (
|
||||
f"breadcrumb missing opt-out hint; got:\n{logs}"
|
||||
)
|
||||
|
||||
# Container should still be running. If the redirect didn't fire,
|
||||
# the foreground gateway would have crashed and the container
|
||||
# would be in `Exited` state by now.
|
||||
r = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.returncode == 0 and r.stdout.strip() == "running", (
|
||||
f"container exited prematurely: {r.stdout!r}; "
|
||||
f"docker logs:\n{logs}"
|
||||
)
|
||||
|
||||
# s6's intent for the default-profile gateway slot should be up.
|
||||
# Same accept-either rule as test_profile_gateway: the supervised
|
||||
# gateway may or may not be currently up depending on whether the
|
||||
# harness profile has a configured model, but the want-intent
|
||||
# contract holds either way.
|
||||
assert _svstat_wants_up(container_name), (
|
||||
f"gateway-default slot want-state not up: {_svstat(container_name)!r}"
|
||||
)
|
||||
|
||||
# The CMD process (PID under /init that the wrapper exec'd into)
|
||||
# should be sleeping, not the gateway. We count `sleep infinity`
|
||||
# processes parented to the CMD wrapper (main-wrapper.sh / rc.init
|
||||
# top), NOT the static main-hermes service's sleep — a bare grep
|
||||
# for `sleep infinity` would false-positive on the main-hermes
|
||||
# sleep and pass even before the redirect fires.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"ps -eo pid,ppid,cmd | grep -v grep | awk "
|
||||
"'/main-wrapper.sh|rc.init top/ { wrapper_pid=$1 } "
|
||||
"$3==\"sleep\" && $4==\"infinity\" && $2==wrapper_pid { c++ } "
|
||||
"END { print c+0 }'",
|
||||
)
|
||||
assert r.returncode == 0
|
||||
redirected_sleeps = int(r.stdout.strip() or 0)
|
||||
assert redirected_sleeps == 1, (
|
||||
f"expected one `sleep infinity` heartbeat parented to the CMD "
|
||||
f"wrapper (the redirect); found {redirected_sleeps}. "
|
||||
f"ps:\n{docker_exec_sh(container_name, 'ps -eo pid,ppid,cmd').stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_run_no_supervise_flag_preserves_legacy_behavior(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""``docker run <image> gateway run --no-supervise`` opts out of
|
||||
the redirect and runs the gateway as the foreground CMD process
|
||||
(pre-s6 semantics).
|
||||
|
||||
With the redirect in place, the container's CMD process would be
|
||||
``sleep infinity`` and the supervised gateway would be a separate
|
||||
process under ``s6-supervise gateway-default``. WITHOUT the
|
||||
redirect (opt-out path), there's no supervised gateway slot at
|
||||
all — the gateway IS the CMD process.
|
||||
|
||||
Three positive assertions confirm we took the pre-s6 path:
|
||||
|
||||
* The CMD process is a python ``hermes gateway run`` invocation
|
||||
(not ``sleep infinity``).
|
||||
* The ``gateway-default`` s6 service slot is NOT created.
|
||||
* No supervision-redirect breadcrumb appears in docker logs.
|
||||
"""
|
||||
start_container(built_image, container_name, cmd="gateway run --no-supervise")
|
||||
|
||||
# Wait for the gateway to start in the foreground or the container
|
||||
# to exit (no-config crash is also valid pre-s6 semantics).
|
||||
# A fixed time.sleep(6) races under CI parallel docker load —
|
||||
# the gateway can take well over 6s to finish Python imports.
|
||||
status = _wait_for_gateway_or_exit(container_name, deadline_s=60.0)
|
||||
|
||||
# No redirect breadcrumb anywhere.
|
||||
logs = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
).stdout + subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
).stderr
|
||||
assert "s6 supervision" not in logs, (
|
||||
f"--no-supervise should have skipped the redirect; "
|
||||
f"breadcrumb in logs:\n{logs}"
|
||||
)
|
||||
|
||||
if status == "running":
|
||||
# Gateway running in foreground — the CMD process should be
|
||||
# the gateway itself, NOT a sleep-infinity heartbeat.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"ps -eo pid,ppid,cmd | grep -v grep | awk '/main-wrapper.sh|rc.init top/ { wrapper_pid=$1 } "
|
||||
"$3==\"sleep\" && $4==\"infinity\" && $2==wrapper_pid { c++ } END { print c+0 }'",
|
||||
)
|
||||
assert r.returncode == 0
|
||||
redirected_sleeps = int(r.stdout.strip() or 0)
|
||||
assert redirected_sleeps == 0, (
|
||||
f"--no-supervise: expected NO `sleep infinity` parented to "
|
||||
f"the CMD wrapper (foreground gateway should be the CMD), "
|
||||
f"found {redirected_sleeps}. "
|
||||
f"ps:\n{docker_exec_sh(container_name, 'ps -eo pid,ppid,cmd').stdout}"
|
||||
)
|
||||
|
||||
# The gateway-default s6 slot exists (the cont-init.d
|
||||
# reconciler creates it on every boot regardless of opt-out)
|
||||
# but should NOT have its want-state set to "up" — the
|
||||
# opt-out path doesn't dispatch `start` to s6.
|
||||
assert not _svstat_wants_up(container_name, "gateway-default"), (
|
||||
"--no-supervise: gateway-default slot has want-state up, "
|
||||
"implying the redirect dispatched `start` despite the "
|
||||
f"opt-out. svstat:\n{_svstat(container_name)!r}"
|
||||
)
|
||||
# If status == "exited" instead, the gateway exited (also valid
|
||||
# pre-s6 semantics). The breadcrumb-absence check above is
|
||||
# already enough to confirm the redirect didn't fire.
|
||||
|
||||
|
||||
def test_gateway_run_no_supervise_env_var(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Env-var opt-out works identically to the CLI flag.
|
||||
|
||||
Useful when users can't easily change their `docker run` args
|
||||
(orchestration templates, K8s manifests) but can set env vars.
|
||||
"""
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_GATEWAY_NO_SUPERVISE=1",
|
||||
cmd="gateway run",
|
||||
)
|
||||
|
||||
# Same as the CLI-flag test: wait for the gateway to start or
|
||||
# the container to exit, instead of a blind time.sleep(6).
|
||||
status = _wait_for_gateway_or_exit(container_name, deadline_s=60.0)
|
||||
|
||||
logs = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = logs.stdout + logs.stderr
|
||||
assert "s6 supervision" not in combined, (
|
||||
f"env-var opt-out should have skipped the redirect; "
|
||||
f"breadcrumb in logs:\n{combined}"
|
||||
)
|
||||
|
||||
# Same as the CLI-flag test: the slot exists (reconciler creates
|
||||
# it) but should not have want-state up.
|
||||
if status == "running":
|
||||
assert not _svstat_wants_up(container_name, "gateway-default"), (
|
||||
"HERMES_GATEWAY_NO_SUPERVISE=1: gateway-default has "
|
||||
"want-state up, implying the redirect dispatched `start` "
|
||||
f"despite the env-var opt-out. svstat:\n{_svstat(container_name)!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_supervised_gateway_does_not_recurse(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The HERMES_S6_SUPERVISED_CHILD sentinel must prevent the
|
||||
supervised ``hermes gateway run`` from re-entering the redirect.
|
||||
|
||||
If recursion happened, every supervised gateway start would itself
|
||||
re-dispatch to s6 and exec ``sleep infinity`` — so the supervised
|
||||
gateway slot would never actually run a python ``hermes gateway
|
||||
run`` process. The slot would oscillate or settle into a state
|
||||
with no python in the supervise tree at all.
|
||||
|
||||
We verify by counting python processes whose argv contains
|
||||
``gateway run``: there should be at most one (the legitimately
|
||||
supervised gateway). Two or more would imply recursive spawning
|
||||
via the redirect → start → run → redirect → ... loop.
|
||||
"""
|
||||
start_container(built_image, container_name, cmd="gateway run")
|
||||
|
||||
# Wait for the redirect to fire by polling for the breadcrumb.
|
||||
# Under CI parallel docker test fan-out, the CMD process
|
||||
# (main-wrapper.sh → python → hermes gateway run) can take well
|
||||
# over 6s to reach the redirect logic. A fixed sleep would race:
|
||||
# if we check too early, the CMD process hasn't exec'd into
|
||||
# `sleep infinity` yet and the s6-supervised gateway hasn't
|
||||
# started either — so we'd see the CMD's `hermes gateway run`
|
||||
# AND the supervised one (2 processes) and falsely conclude
|
||||
# recursion. Polling the breadcrumb is the definitive signal
|
||||
# that the redirect fired and the CMD process is now `sleep`.
|
||||
wait_for_docker_logs(container_name, "s6 supervision")
|
||||
|
||||
# Now that the redirect fired, count python processes running
|
||||
# `hermes gateway run`. If the recursion guard fails, s6 would
|
||||
# respawn fresh `gateway run` processes on every cycle, leaving
|
||||
# multiple Python-process descendants under the gateway-default
|
||||
# supervise tree.
|
||||
r = docker_exec_sh(container_name, "ps -eo pid,cmd | grep -v grep | grep -E 'python.*hermes.*gateway run' | wc -l")
|
||||
assert r.returncode == 0
|
||||
n = int(r.stdout.strip() or 0)
|
||||
assert n <= 1, (
|
||||
f"expected at most one supervised python `hermes gateway run` "
|
||||
f"process (the legitimately-supervised gateway); found {n}. "
|
||||
f"Recursion guard may have failed. "
|
||||
f"ps:\n{docker_exec_sh(container_name, 'ps -eo pid,ppid,cmd').stdout}"
|
||||
)
|
||||
|
||||
# Stronger positive assertion: there should be exactly one
|
||||
# `sleep infinity` process whose parent is the main-wrapper.sh
|
||||
# CMD process (PID 17 typically). The static `main-hermes`
|
||||
# service has its own `sleep infinity` child; THAT one is fine
|
||||
# and unrelated to our redirect.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
# Find PID of the CMD process (main-wrapper.sh or its sh
|
||||
# parent), then count `sleep infinity` children.
|
||||
"ps -eo pid,ppid,cmd | grep -v grep | awk '/main-wrapper.sh|rc.init top/ { wrapper_pid=$1 } "
|
||||
"$3==\"sleep\" && $4==\"infinity\" && $2==wrapper_pid { c++ } END { print c+0 }'",
|
||||
)
|
||||
assert r.returncode == 0
|
||||
redirected = int(r.stdout.strip() or 0)
|
||||
assert redirected == 1, (
|
||||
f"expected exactly one `sleep infinity` parented to the CMD "
|
||||
f"wrapper (the redirect heartbeat); found {redirected}. "
|
||||
f"ps:\n{docker_exec_sh(container_name, 'ps -eo pid,ppid,cmd').stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_supervised_when_env_set(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""When ``HERMES_DASHBOARD=1`` is set, ``docker run <image> gateway
|
||||
run`` should result in BOTH the gateway and the dashboard being
|
||||
supervised by s6 — the dashboard slot was always there but only
|
||||
activates with the env var. This is the headline benefit of the
|
||||
redirect: one container = supervised gateway + supervised
|
||||
dashboard, with zero extra user effort.
|
||||
"""
|
||||
start_container(
|
||||
built_image, container_name,
|
||||
"HERMES_DASHBOARD=1",
|
||||
cmd="gateway run",
|
||||
)
|
||||
|
||||
# Wait for the redirect to fire (the breadcrumb appears in docker
|
||||
# logs when the CMD process reaches the redirect logic). This is
|
||||
# the same signal the other gateway-run tests use.
|
||||
# A fixed time.sleep(5) was racing: start_container returns when
|
||||
# cont-init finishes, but the redirect (which creates the
|
||||
# gateway-default s6 slot) happens later in the CMD process.
|
||||
wait_for_docker_logs(
|
||||
container_name, "s6 supervision", deadline_s=60.0,
|
||||
)
|
||||
|
||||
# Poll for both slots to report want-up, using the same
|
||||
# _svstat_wants_up helper the other tests use. A simple
|
||||
# `grep 'want up'` is wrong: when the service is already up,
|
||||
# s6-svstat output is "up (pid ...) Ns" with no literal "want up"
|
||||
# — the want-up intent is implied by the absence of "want down".
|
||||
ok_gateway = False
|
||||
end = time.monotonic() + 30.0
|
||||
while time.monotonic() < end:
|
||||
if _svstat_wants_up(container_name, "gateway-default"):
|
||||
ok_gateway = True
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert ok_gateway, (
|
||||
f"gateway-default slot not want-up: {_svstat(container_name)!r}"
|
||||
)
|
||||
|
||||
ok_dash = False
|
||||
end = time.monotonic() + 30.0
|
||||
while time.monotonic() < end:
|
||||
if _svstat_wants_up(container_name, "dashboard"):
|
||||
ok_dash = True
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert ok_dash, (
|
||||
f"dashboard slot not want-up: {_svstat(container_name, 'dashboard')!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_supervised_gateway_stdout_reaches_docker_logs(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The supervised gateway's stdout — including the rich-console
|
||||
startup banner — must reach ``docker logs``, not just the rotated
|
||||
log file under ``${HERMES_HOME}/logs/gateways/<profile>/current``.
|
||||
|
||||
Without the ``1`` action directive in ``_render_log_run``, s6-log
|
||||
swallows the gateway's stdout into the file and ``docker logs``
|
||||
only sees stderr (Python ``logging`` defaults to stderr). That's
|
||||
a poor user experience: the iconic "Hermes Gateway Starting…"
|
||||
banner with the ⚕ symbol is the most visible "yes, your gateway
|
||||
started" signal, and forcing users to ``docker exec`` + ``tail``
|
||||
the log file just to see it is friction users don't expect.
|
||||
|
||||
With the ``1`` directive, s6-log forwards every line to its own
|
||||
stdout (which propagates up through the s6-supervise pipeline to
|
||||
/init's stdout = container stdout = ``docker logs``) AND also
|
||||
writes a timestamped copy to the rotated file. Best of both.
|
||||
|
||||
We assert by looking for the literal banner glyph (``⚕``) — a
|
||||
distinctive character that won't appear in stderr-routed
|
||||
Python-logging output, so its presence in ``docker logs`` proves
|
||||
the stdout-tee is working.
|
||||
"""
|
||||
start_container(built_image, container_name, cmd="gateway run")
|
||||
|
||||
# Poll docker logs for the banner glyph (⚕) or "Hermes Gateway
|
||||
# Starting" — the gateway's rich-console startup banner. A fixed
|
||||
# sleep(8) races under CI parallel docker test fan-out: the
|
||||
# supervised gateway can take well over 8s to finish imports +
|
||||
# config-load + banner print under load, and the assertion would
|
||||
# fail not because the stdout-tee is broken but because we checked
|
||||
# too early. Polling with a generous deadline is both faster on
|
||||
# quick machines and flake-free on slow ones.
|
||||
wait_for_docker_logs(container_name, "⚕", deadline_s=60.0)
|
||||
|
||||
logs = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = logs.stdout + logs.stderr
|
||||
|
||||
# The banner ⚕ symbol is the load-bearing assertion — it's unique
|
||||
# to gateway startup stdout output and won't appear in stderr
|
||||
# (Python logging) or s6 boot messages.
|
||||
assert "⚕" in combined or "Hermes Gateway Starting" in combined, (
|
||||
"Supervised gateway's stdout banner did not reach docker logs. "
|
||||
"This means the `1` action directive in _render_log_run isn't "
|
||||
"forwarding stdout to /init. "
|
||||
f"docker logs (last 2000 chars):\n{combined[-2000:]}\n"
|
||||
f"file contents:\n{docker_exec_sh(container_name, 'cat /opt/data/logs/gateways/default/current').stdout}"
|
||||
)
|
||||
|
||||
# Cross-check: the same banner must also be in the rotated log
|
||||
# file (we kept the file destination, just added stdout). The
|
||||
# file version has s6-log's ISO 8601 timestamp prefix; the
|
||||
# docker logs version is raw.
|
||||
file_contents = docker_exec_sh(
|
||||
container_name, "cat /opt/data/logs/gateways/default/current",
|
||||
).stdout
|
||||
assert "⚕" in file_contents or "Hermes Gateway Starting" in file_contents, (
|
||||
"Banner also missing from rotated log file — the file "
|
||||
"destination may have been dropped by the new s6-log script. "
|
||||
f"File contents:\n{file_contents}"
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Runtime smoke tests for Docker HOME overrides and script behavior.
|
||||
|
||||
Build the real image and verify the actual runtime behavior:
|
||||
|
||||
1. main-wrapper preserves the Docker ``-w`` working directory
|
||||
2. dashboard service resets HOME to /opt/data before privilege drop
|
||||
3. dashboard does not auto-add ``--insecure`` from a non-loopback bind host
|
||||
4. stage2 hook repairs profiles/ and cron/ ownership on every boot
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from tests.docker.conftest import docker_exec, docker_exec_sh, start_container, restart_container
|
||||
|
||||
|
||||
def test_main_wrapper_preserves_docker_workdir(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The main-wrapper MUST save and restore the original working directory
|
||||
so the container starts in the Docker ``-w`` directory, not /opt/data.
|
||||
|
||||
Regression test for #35472. We pass ``-w /tmp`` and a command that
|
||||
prints its cwd; the output must be ``/tmp``, proving the wrapper
|
||||
restored the cwd after its internal ``cd /opt/data``.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "-w", "/tmp",
|
||||
built_image, "sh", "-c", "pwd"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, f"container failed: {r.stderr[-1000:]}"
|
||||
# The stage2 hook emits boot logs (config migration, skills sync)
|
||||
# to stdout before the CMD runs. The actual pwd output is the LAST
|
||||
# line of stdout.
|
||||
last_line = r.stdout.strip().split("\n")[-1].strip()
|
||||
assert last_line == "/tmp", (
|
||||
f"expected cwd /tmp, got {last_line!r} — "
|
||||
f"main-wrapper did not preserve the Docker -w directory"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_service_resets_home(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The dashboard run script must export HOME=/opt/data before dropping
|
||||
privileges, so HOME-anchored state (discord lockfile, XDG dirs) doesn't
|
||||
try to write to /root (the /init context's HOME).
|
||||
|
||||
We check this by inspecting the environment of the dashboard service
|
||||
process if it's running, or by verifying the run script sets HOME
|
||||
before the exec. At runtime, the cleanest check is: start the
|
||||
container with HERMES_DASHBOARD=1 and verify the dashboard process
|
||||
(if it starts) has HOME=/opt/data.
|
||||
|
||||
Since the dashboard requires an auth provider on non-loopback binds,
|
||||
we bind to 127.0.0.1 where the auth gate doesn't engage, and check
|
||||
the process env.
|
||||
"""
|
||||
start_container(built_image, container_name, "HERMES_DASHBOARD=1", "HERMES_DASHBOARD_HOST=127.0.0.1")
|
||||
|
||||
# Check if the dashboard process is running and inspect its HOME.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
# Find the dashboard process (hermes dashboard) and read its HOME
|
||||
# from /proc/<pid>/environ. If not running, verify the run script
|
||||
# itself exports HOME=/opt/data by grepping the script source.
|
||||
'pid=$(pgrep -f "hermes dashboard" | head -1); '
|
||||
'if [ -n "$pid" ]; then '
|
||||
' tr "\\0" "\\n" < /proc/$pid/environ | grep "^HOME="; '
|
||||
'else '
|
||||
' grep -q "export HOME=/opt/data" '
|
||||
' /opt/hermes/docker/s6-rc.d/dashboard/run && '
|
||||
' echo "HOME=/opt/data"; '
|
||||
'fi',
|
||||
timeout=15,
|
||||
)
|
||||
assert "HOME=/opt/data" in r.stdout, (
|
||||
f"dashboard process or run script does not set HOME=/opt/data: "
|
||||
f"stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_does_not_auto_insecure_from_host(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The dashboard MUST NOT auto-add ``--insecure`` based on
|
||||
HERMES_DASHBOARD_HOST. The auth gate is the authority now.
|
||||
|
||||
The auth gate is the authority on whether non-loopback binds are
|
||||
safe; ``--insecure`` must never be auto-derived from the bind host.
|
||||
|
||||
We start the container with a non-loopback bind host and verify
|
||||
the dashboard process does NOT receive ``--insecure`` in its
|
||||
command line. If the dashboard fails to start (because the auth
|
||||
gate correctly blocks an unauthenticated non-loopback bind), that's
|
||||
also acceptable — the point is no auto-insecure.
|
||||
"""
|
||||
start_container(built_image, container_name, "HERMES_DASHBOARD=1", "HERMES_DASHBOARD_HOST=0.0.0.0")
|
||||
|
||||
# Check the dashboard process command line for --insecure.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'pid=$(pgrep -f "hermes dashboard" | head -1); '
|
||||
'if [ -n "$pid" ]; then '
|
||||
' tr "\\0" " " < /proc/$pid/cmdline; '
|
||||
'fi',
|
||||
timeout=10,
|
||||
)
|
||||
cmdline = r.stdout.strip()
|
||||
# If the process is running, it must NOT have --insecure.
|
||||
if cmdline:
|
||||
assert "--insecure" not in cmdline, (
|
||||
f"dashboard process has --insecure in cmdline (auto-derived "
|
||||
f"from host): {cmdline!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_stage2_repairs_profiles_and_cron_ownership(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""profiles/ and cron/ must both be reclaimed after root-context writes.
|
||||
|
||||
The stage2 hook chowns these dirs to hermes:hermes on every boot.
|
||||
We simulate a root-owned file in each, then restart the container
|
||||
and verify ownership is repaired.
|
||||
"""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Create root-owned files in profiles/ and cron/ to simulate
|
||||
# docker exec (root) writes.
|
||||
docker_exec(
|
||||
container_name, "mkdir", "-p", "/opt/data/profiles/testprof",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
docker_exec(
|
||||
container_name, "touch", "/opt/data/profiles/testprof/marker",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
docker_exec(
|
||||
container_name, "touch", "/opt/data/cron/root_owned.json",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
|
||||
# Verify they're root-owned before restart.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'stat -c "%U" /opt/data/profiles/testprof/marker '
|
||||
'/opt/data/cron/root_owned.json',
|
||||
timeout=5,
|
||||
)
|
||||
assert "root" in r.stdout, (
|
||||
f"expected root-owned files before restart, got: {r.stdout!r}"
|
||||
)
|
||||
|
||||
# Restart — stage2 hook runs again and repairs ownership.
|
||||
restart_container(container_name)
|
||||
|
||||
# Verify files are now owned by hermes.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'stat -c "%U" /opt/data/profiles/testprof/marker '
|
||||
'/opt/data/cron/root_owned.json',
|
||||
timeout=5,
|
||||
)
|
||||
assert "hermes" in r.stdout, (
|
||||
f"expected hermes-owned files after restart, got: {r.stdout!r} — "
|
||||
f"stage2 hook did not repair profiles/ and cron/ ownership"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Runtime smoke tests for Docker immutable install tree and install-method stamp.
|
||||
|
||||
Build the real image and verify at runtime:
|
||||
|
||||
1. /opt/hermes is not writable by the hermes user (immutable install tree)
|
||||
2. PYTHONDONTWRITEBYTECODE and HERMES_DISABLE_LAZY_INSTALLS are set
|
||||
3. /opt/hermes/.install_method contains "docker" (code-scoped stamp)
|
||||
4. $HERMES_HOME/.install_method is NOT stamped as "docker" by stage2
|
||||
5. A stale "docker" stamp in $HERMES_HOME is healed (removed) on boot
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import (
|
||||
docker_exec,
|
||||
docker_exec_sh,
|
||||
restart_container,
|
||||
start_container,
|
||||
)
|
||||
|
||||
|
||||
def test_install_tree_not_writable_by_hermes(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The hermes user must not be able to modify /opt/hermes.
|
||||
|
||||
The install tree (source, venv, TUI bundle, node_modules) must remain
|
||||
root-owned and non-writable so an agent session cannot self-modify
|
||||
the installation and brick the gateway.
|
||||
"""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
# Try to create a file under /opt/hermes as the hermes user
|
||||
"touch /opt/hermes/test_write 2>&1 && "
|
||||
"echo WRITE_SUCCEEDED || echo WRITE_FAILED",
|
||||
timeout=10,
|
||||
)
|
||||
assert "WRITE_FAILED" in r.stdout, (
|
||||
f"hermes user can write to /opt/hermes (install tree not immutable): "
|
||||
f"{r.stdout}"
|
||||
)
|
||||
|
||||
# Also check a key subdirectory
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"touch /opt/hermes/.venv/test_write 2>&1 && "
|
||||
"echo WRITE_SUCCEEDED || echo WRITE_FAILED",
|
||||
timeout=10,
|
||||
)
|
||||
assert "WRITE_FAILED" in r.stdout, (
|
||||
f"hermes user can write to /opt/hermes/.venv: {r.stdout}"
|
||||
)
|
||||
|
||||
|
||||
def test_hermes_disable_lazy_installs_and_dont_write_bytecode(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The container must set PYTHONDONTWRITEBYTECODE and
|
||||
HERMES_DISABLE_LAZY_INSTALLS=1 so no .pyc files are written to the
|
||||
immutable install tree and no lazy installs attempt to modify it."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'test "$PYTHONDONTWRITEBYTECODE" = "1" && '
|
||||
'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && '
|
||||
'echo ENV_OK || echo ENV_MISSING',
|
||||
timeout=10,
|
||||
)
|
||||
assert "ENV_OK" in r.stdout, (
|
||||
f"expected PYTHONDONTWRITEBYTECODE=1 and "
|
||||
f"HERMES_DISABLE_LAZY_INSTALLS=1, got: {r.stdout} stderr={r.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_install_method_stamp_is_code_scoped(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The 'docker' install-method stamp must be baked at
|
||||
/opt/hermes/.install_method (code-scoped), NOT in $HERMES_HOME."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Code-scoped stamp must exist and say "docker"
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"cat /opt/hermes/.install_method",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"/opt/hermes/.install_method not found: {r.stderr}"
|
||||
)
|
||||
assert r.stdout.strip() == "docker", (
|
||||
f"expected 'docker' stamp, got: {r.stdout.strip()!r}"
|
||||
)
|
||||
|
||||
# $HERMES_HOME must NOT have a 'docker' stamp
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"cat /opt/data/.install_method 2>/dev/null || echo NONE",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() != "docker", (
|
||||
"$HERMES_HOME/.install_method is stamped 'docker' - stage2 must "
|
||||
"not stamp the data volume (shared with host installs)"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_docker_stamp_in_home_is_healed_on_boot(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A stale 'docker' stamp left in $HERMES_HOME by an older image
|
||||
must be removed on boot so shared homes self-heal."""
|
||||
# Start container, write a stale stamp
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Write a stale 'docker' stamp as root
|
||||
docker_exec(
|
||||
container_name, "sh", "-c",
|
||||
"printf 'docker\\n' > /opt/data/.install_method",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
# Verify it exists
|
||||
r = docker_exec_sh(container_name, "cat /opt/data/.install_method", timeout=5)
|
||||
assert r.stdout.strip() == "docker"
|
||||
|
||||
# Restart - stage2 should heal it
|
||||
restart_container(container_name)
|
||||
|
||||
# The stale stamp must be gone
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -f /opt/data/.install_method && "
|
||||
"cat /opt/data/.install_method || echo HEALED",
|
||||
timeout=10,
|
||||
)
|
||||
assert "HEALED" in r.stdout or r.stdout.strip() != "docker", (
|
||||
f"stale 'docker' stamp in $HERMES_HOME was not healed on boot: "
|
||||
f"{r.stdout}"
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Docker smoke tests for immutable install permissions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
|
||||
def test_container_sets_hosted_write_policy_env(built_image: str) -> None:
|
||||
script = (
|
||||
'test "$HERMES_HOME" = "/opt/data" && '
|
||||
'test "$HERMES_WRITE_SAFE_ROOT" = "/opt/data" && '
|
||||
'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && '
|
||||
'test "$PYTHONDONTWRITEBYTECODE" = "1"'
|
||||
)
|
||||
result = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "sh", built_image, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
|
||||
|
||||
def test_hermes_user_cannot_modify_install_but_can_write_data(built_image: str) -> None:
|
||||
script = textwrap.dedent(
|
||||
r"""
|
||||
set -eu
|
||||
/opt/hermes/.venv/bin/python - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
install_file = Path("/opt/hermes/agent/message_sanitization.py")
|
||||
try:
|
||||
with install_file.open("a", encoding="utf-8") as handle:
|
||||
handle.write("\n# unexpected hosted mutation\n")
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
raise SystemExit("install source write unexpectedly succeeded")
|
||||
|
||||
skill_dir = Path("/opt/data/skills/permission-smoke")
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
skill_file.write_text("# Permission smoke\n", encoding="utf-8")
|
||||
if skill_file.read_text(encoding="utf-8") != "# Permission smoke\n":
|
||||
raise SystemExit("data write verification failed")
|
||||
PY
|
||||
"""
|
||||
).strip()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--entrypoint",
|
||||
"su",
|
||||
built_image,
|
||||
"hermes",
|
||||
"-s",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
script,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Runtime smoke test for Docker image license-file presence.
|
||||
|
||||
Build the real image and verify the LICENSE file is present inside the
|
||||
container (PEP 639 license-files metadata must resolve inside the
|
||||
Docker image).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_docker_image_contains_license_file(built_image: str) -> None:
|
||||
"""The LICENSE file must be present inside the built Docker image.
|
||||
|
||||
PEP 639 license-files metadata references LICENSE, and the Docker
|
||||
build context must not exclude it.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "test",
|
||||
built_image, "-f", "/opt/hermes/LICENSE"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"LICENSE file not found at /opt/hermes/LICENSE inside the Docker "
|
||||
f"image: {r.stderr[-500:]}"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Runtime smoke test for Docker $HERMES_HOME/logs/gateways seeding.
|
||||
|
||||
Build the real image and verify logs/ and logs/gateways/ exist and are
|
||||
owned by the hermes user after container boot.
|
||||
|
||||
Regression guard for #45258: if the first gateway log service runs in
|
||||
root context, logs/gateways/ is created root-owned; every profile
|
||||
registered later runs its log service as the dropped hermes user and
|
||||
s6-log crash-loops on mkdir: Permission denied.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, start_container
|
||||
|
||||
|
||||
def test_logs_gateways_seeded_and_hermes_owned(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""logs/ and logs/gateways/ must exist and be owned by hermes after boot."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Both directories must exist
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -d /opt/data/logs && "
|
||||
"test -d /opt/data/logs/gateways && "
|
||||
"echo DIRS_OK || echo DIRS_MISSING",
|
||||
timeout=10,
|
||||
)
|
||||
assert "DIRS_OK" in r.stdout, (
|
||||
f"logs/ or logs/gateways/ not seeded: {r.stdout}"
|
||||
)
|
||||
|
||||
# Both must be owned by hermes
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'logs_owner=$(stat -c "%U" /opt/data/logs); '
|
||||
'gateways_owner=$(stat -c "%U" /opt/data/logs/gateways); '
|
||||
'echo "logs=$logs_owner gateways=$gateways_owner"',
|
||||
timeout=10,
|
||||
)
|
||||
assert "logs=hermes" in r.stdout, (
|
||||
f"logs/ not owned by hermes: {r.stdout}"
|
||||
)
|
||||
assert "gateways=hermes" in r.stdout, (
|
||||
f"logs/gateways/ not owned by hermes: {r.stdout}"
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Harness: docker run <image> [cmd...] invocation patterns.
|
||||
|
||||
These tests MUST pass on the current tini-based image AND continue to
|
||||
pass after the Phase 2 s6 migration. Any behavior drift is a regression.
|
||||
|
||||
The harness expects ``built_image`` and ``container_name`` fixtures from
|
||||
``tests/docker/conftest.py``. When Docker isn't available every test
|
||||
here is skipped at collection time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_no_args_starts_hermes(built_image: str) -> None:
|
||||
"""``docker run <image>`` should start hermes cleanly.
|
||||
|
||||
We invoke ``--version`` so the call exits without needing a configured
|
||||
model. Exit code may be 0 (printed version) or 1 (config bootstrapping
|
||||
failure on a fresh volume), but never a stack trace.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "--version"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode in (0, 1), (
|
||||
f"Unexpected exit {r.returncode}: stderr={r.stderr!r}"
|
||||
)
|
||||
assert "Traceback" not in r.stderr
|
||||
|
||||
|
||||
def test_chat_subcommand_passthrough(built_image: str) -> None:
|
||||
"""``docker run <image> chat --help`` should exec ``hermes chat --help``.
|
||||
|
||||
Uses ``--help`` so the call doesn't need an upstream model configured.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "chat", "--help"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
combined = (r.stdout + r.stderr).lower()
|
||||
assert "chat" in combined or "usage" in combined
|
||||
|
||||
|
||||
def test_bare_executable_passthrough(built_image: str) -> None:
|
||||
"""``docker run <image> sleep 1`` should exec ``sleep`` directly.
|
||||
|
||||
The entrypoint detects that ``sleep`` is on PATH and routes around the
|
||||
hermes wrapper. Useful for long-lived sandbox mode and for testing.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "sleep", "1"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
|
||||
|
||||
def test_bash_pattern(built_image: str) -> None:
|
||||
"""``docker run <image> bash -c 'echo ok'`` should exec bash directly."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "bash", "-c", "echo ok"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert "ok" in r.stdout
|
||||
|
||||
|
||||
def test_container_exit_code_matches_inner_exit(built_image: str) -> None:
|
||||
"""The container exit code must match the inner process's exit code.
|
||||
|
||||
Critical for CI: ``docker run <image> hermes batch ...`` returns a
|
||||
non-zero status when batch fails. Phase 2 (s6) must preserve this.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "sh", "-c", "exit 42"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 42
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Harness: per-profile gateway start/stop inside the container.
|
||||
|
||||
Phase 4 wires `hermes -p <profile> gateway start/stop` through the s6
|
||||
ServiceManager dispatch path inside the container — so the lifecycle
|
||||
commands now bring up an s6-supervised gateway rather than refusing
|
||||
with the pre-Phase-4 informational message.
|
||||
|
||||
These tests were marked ``xfail(strict=True)`` through Phase 0–3 and
|
||||
flip to plain ``test_…`` once Phase 4 lands (now).
|
||||
|
||||
NB: The harness profile has no model/auth configured. Depending on
|
||||
how the gateway run script handles missing config, the supervised
|
||||
process may either spin up successfully (and svstat reports ``up``)
|
||||
or exit fast and get throttled by s6 (and svstat reports ``down …,
|
||||
want up``). Both states are valid "user asked for gateway up" results
|
||||
— what we assert is the *want* intent the lifecycle command set, NOT
|
||||
the supervised process's health. ``s6-svc -u`` records ``want up`` in
|
||||
the supervise/status file regardless of the run-script outcome.
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec_sh` in conftest); see the conftest module
|
||||
docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, start_container
|
||||
|
||||
PROFILE = "test-harness-profile"
|
||||
|
||||
|
||||
def _sh(
|
||||
container: str, command: str, timeout: int = 30,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return docker_exec_sh(container, command, timeout=timeout)
|
||||
|
||||
|
||||
def _svstat(container: str) -> str:
|
||||
"""Returns the raw s6-svstat output for the test profile's slot.
|
||||
/command/s6-svstat is called by absolute path because /command/
|
||||
isn't on PATH for docker-exec sessions."""
|
||||
r = _sh(container, f"/command/s6-svstat /run/service/gateway-{PROFILE}")
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def _svstat_wants_up(container: str) -> bool:
|
||||
"""Read the slot's want-state from s6-svstat output.
|
||||
|
||||
s6-svstat formats the output to elide redundancies — when the
|
||||
service is currently up AND s6 wants it up, the literal token
|
||||
``want up`` doesn't appear (it's implicit from the leading ``up``).
|
||||
When the service is down but s6 wants it back up, ``, want up``
|
||||
appears explicitly. So a comprehensive "is the want-intent set to
|
||||
up" check has to accept both spellings.
|
||||
"""
|
||||
state = _svstat(container)
|
||||
if not state:
|
||||
return False
|
||||
head = state.split()[0] if state.split() else ""
|
||||
if head == "up":
|
||||
# Currently up implies wanted-up unless ``want down`` is set.
|
||||
return "want down" not in state
|
||||
# Currently down — ``want up`` only shows up when explicitly set.
|
||||
return "want up" in state
|
||||
|
||||
|
||||
def test_profile_create_then_gateway_start(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
r = _sh(container_name, f"hermes profile create {PROFILE}")
|
||||
assert r.returncode == 0, f"profile create failed: {r.stderr}"
|
||||
|
||||
# Profile create's s6-register hook should have produced a service slot.
|
||||
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
|
||||
assert r.returncode == 0, "s6 service slot not created on profile create"
|
||||
|
||||
r = _sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
|
||||
assert r.returncode == 0, (
|
||||
f"gateway start failed: stderr={r.stderr!r} stdout={r.stdout!r}"
|
||||
)
|
||||
|
||||
# After start, s6's intent is "up" — even if the supervised gateway
|
||||
# process spin-fails (no model/auth in the test profile), the
|
||||
# supervision-state contract holds. See ``_svstat_wants_up`` for
|
||||
# why we accept both ``up …`` (currently up) and ``down …, want
|
||||
# up`` (down but s6 wants up).
|
||||
time.sleep(2)
|
||||
assert _svstat_wants_up(container_name), (
|
||||
f"slot want-state is not up after gateway start: "
|
||||
f"{_svstat(container_name)!r}"
|
||||
)
|
||||
|
||||
r = _sh(container_name, f"hermes -p {PROFILE} gateway stop", timeout=30)
|
||||
assert r.returncode == 0
|
||||
|
||||
time.sleep(2)
|
||||
assert not _svstat_wants_up(container_name), (
|
||||
f"slot want-state still up after gateway stop: "
|
||||
f"{_svstat(container_name)!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_profile_delete_stops_gateway(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Deleting a profile should stop its gateway and remove the s6
|
||||
service slot."""
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
_sh(container_name, f"hermes profile create {PROFILE}")
|
||||
_sh(container_name, f"hermes -p {PROFILE} gateway start", timeout=60)
|
||||
time.sleep(3)
|
||||
|
||||
r = _sh(
|
||||
container_name,
|
||||
f"hermes profile delete {PROFILE} --yes",
|
||||
timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"profile delete failed: {r.stderr}"
|
||||
|
||||
time.sleep(2)
|
||||
# Service slot should be gone.
|
||||
r = _sh(container_name, f"test -d /run/service/gateway-{PROFILE}")
|
||||
assert r.returncode != 0, "s6 service slot still present after profile delete"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Runtime smoke tests for Docker PUID/PGID and UID/GID remap.
|
||||
|
||||
Build the real image and verify the actual runtime behavior:
|
||||
|
||||
1. PUID/PGID env vars remap the hermes user UID/GID at boot
|
||||
2. HERMES_UID/HERMES_GID take precedence over PUID/PGID aliases
|
||||
3. NAS-style low UIDs (99:100) are accepted and remapped
|
||||
4. Invalid UIDs are rejected
|
||||
5. The remapped user can write to the data volume
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, start_container
|
||||
|
||||
|
||||
def test_puid_pgid_remaps_hermes_user(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""PUID=1000 PGID=1000 must remap the hermes user to UID 1000."""
|
||||
start_container(built_image, container_name, "PUID=1000", "PGID=1000")
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"id -u hermes",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "1000", (
|
||||
f"expected hermes UID 1000 after PUID remap, got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"id -g hermes",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.stdout.strip() == "1000", (
|
||||
f"expected hermes GID 1000 after PGID remap, got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_hermes_uid_gid_take_precedence_over_aliases(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""HERMES_UID/HERMES_GID must win over PUID/PGID when both are set."""
|
||||
start_container(built_image, container_name, "HERMES_UID=2000", "HERMES_GID=2001", "PUID=1000", "PGID=1000")
|
||||
|
||||
r = docker_exec_sh(container_name, "id -u hermes", timeout=10)
|
||||
assert r.stdout.strip() == "2000", (
|
||||
f"expected hermes UID 2000 (HERMES_UID wins), got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
r = docker_exec_sh(container_name, "id -g hermes", timeout=10)
|
||||
assert r.stdout.strip() == "2001", (
|
||||
f"expected hermes GID 2001 (HERMES_GID wins), got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_nas_low_uid_accepted(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""NAS-style low UIDs (99:100, common on Unraid) must be accepted."""
|
||||
start_container(built_image, container_name, "PUID=99", "PGID=100")
|
||||
|
||||
r = docker_exec_sh(container_name, "id -u hermes", timeout=10)
|
||||
assert r.stdout.strip() == "99", (
|
||||
f"expected hermes UID 99, got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
r = docker_exec_sh(container_name, "id -g hermes", timeout=10)
|
||||
assert r.stdout.strip() == "100", (
|
||||
f"expected hermes GID 100, got: {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_remap_enables_data_volume_writes(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""After remap, the hermes user must be able to write to /opt/data."""
|
||||
start_container(built_image, container_name, "PUID=1000", "PGID=1000")
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"touch /opt/data/test_write && echo WRITE_OK || echo WRITE_FAIL",
|
||||
timeout=10,
|
||||
)
|
||||
assert "WRITE_OK" in r.stdout, (
|
||||
f"hermes user cannot write to /opt/data after remap: {r.stdout}"
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Harness: in-container integration tests for S6ServiceManager.
|
||||
|
||||
The unit tests in tests/hermes_cli/test_service_manager.py exercise the
|
||||
class against a tmp-path scandir with a stubbed ``subprocess.run``.
|
||||
These tests run the real class inside a real container against the
|
||||
real s6-svc / s6-svscanctl binaries, validating end-to-end.
|
||||
|
||||
Phase 3 only registers the service slot — it doesn't depend on the
|
||||
gateway actually starting (the binary will refuse to start without a
|
||||
valid profile config). The full register → start → supervised-restart
|
||||
→ unregister cycle is covered by Phase 4 once profile create/delete
|
||||
hooks land.
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec` in conftest); see the conftest module
|
||||
docstring. ``/run/service`` is chowned hermes-writable by the
|
||||
``02-reconcile-profiles`` cont-init.d script, so register/unregister
|
||||
operations work correctly under UID 10000.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import docker_exec, start_container
|
||||
|
||||
|
||||
_REGISTER_SCRIPT = """
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/hermes")
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
S6ServiceManager().register_profile_gateway("phase3test")
|
||||
# Don't worry about whether the gateway actually starts — we only care
|
||||
# that the supervision slot was created. The gateway run script will
|
||||
# likely error out (no profile config exists) but that's expected.
|
||||
print("REGISTERED")
|
||||
"""
|
||||
|
||||
_UNREGISTER_SCRIPT = """
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/hermes")
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
S6ServiceManager().unregister_profile_gateway("phase3test")
|
||||
print("UNREGISTERED")
|
||||
"""
|
||||
|
||||
|
||||
def test_s6_register_creates_service_dir_in_live_container(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""S6ServiceManager.register_profile_gateway must create
|
||||
``/run/service/gateway-<profile>/`` and trigger s6-svscan rescan
|
||||
against the real s6 supervision tree."""
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
r = docker_exec(container_name, "python3", "-c", _REGISTER_SCRIPT, timeout=30)
|
||||
assert "REGISTERED" in r.stdout, (
|
||||
f"register failed: stderr={r.stderr!r} stdout={r.stdout!r}"
|
||||
)
|
||||
|
||||
# Service directory exists with the expected structure.
|
||||
r = docker_exec(container_name, "test", "-d", "/run/service/gateway-phase3test")
|
||||
assert r.returncode == 0, "service directory not created"
|
||||
|
||||
r = docker_exec(container_name, "test", "-f", "/run/service/gateway-phase3test/run")
|
||||
assert r.returncode == 0, "run script not created"
|
||||
|
||||
r = docker_exec(container_name, "test", "-f",
|
||||
"/run/service/gateway-phase3test/log/run")
|
||||
assert r.returncode == 0, "log/run script not created"
|
||||
|
||||
# s6-svscan picked it up — s6-svstat works against the dir.
|
||||
# `docker exec` doesn't put /command/ on PATH (only the supervision
|
||||
# tree does), so call s6-svstat by absolute path.
|
||||
r = docker_exec(container_name, "/command/s6-svstat",
|
||||
"/run/service/gateway-phase3test")
|
||||
assert r.returncode == 0, f"s6-svstat failed: {r.stderr or r.stdout}"
|
||||
|
||||
# list_profile_gateways picks it up.
|
||||
r = docker_exec(container_name, "python3", "-c", (
|
||||
"from hermes_cli.service_manager import S6ServiceManager;"
|
||||
"print(S6ServiceManager().list_profile_gateways())"
|
||||
))
|
||||
assert "phase3test" in r.stdout, f"list output: {r.stdout!r}"
|
||||
|
||||
|
||||
def test_s6_unregister_removes_service_dir_in_live_container(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""unregister_profile_gateway must stop the service, remove the
|
||||
directory, and trigger s6-svscan rescan so the supervise process
|
||||
is dropped."""
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
# First register so we have something to unregister.
|
||||
r = docker_exec(container_name, "python3", "-c", _REGISTER_SCRIPT, timeout=30)
|
||||
assert "REGISTERED" in r.stdout
|
||||
|
||||
# Then unregister.
|
||||
r = docker_exec(container_name, "python3", "-c", _UNREGISTER_SCRIPT, timeout=30)
|
||||
assert "UNREGISTERED" in r.stdout, (
|
||||
f"unregister failed: stderr={r.stderr!r} stdout={r.stdout!r}"
|
||||
)
|
||||
|
||||
# Directory is gone.
|
||||
r = docker_exec(container_name, "test", "-d", "/run/service/gateway-phase3test")
|
||||
assert r.returncode != 0, "service directory still exists after unregister"
|
||||
|
||||
# list_profile_gateways no longer includes it.
|
||||
r = docker_exec(container_name, "python3", "-c", (
|
||||
"from hermes_cli.service_manager import S6ServiceManager;"
|
||||
"print(S6ServiceManager().list_profile_gateways())"
|
||||
))
|
||||
assert "phase3test" not in r.stdout
|
||||
|
||||
|
||||
# Shell probe: build a service-shaped staging dir under the live scandir
|
||||
# with a given NAME, fire a real `s6-svscanctl -a` rescan, wait, and
|
||||
# report whether s6-svscan supervised it (which would create a root-owned
|
||||
# supervise/ dir). Used to prove the dot-prefixed staging name is INVISIBLE
|
||||
# to a concurrent rescan while a non-dotted one is not.
|
||||
#
|
||||
# Echoes one of: SUPERVISED / NOT-SUPERVISED, plus the supervise/ owner.
|
||||
_SVSCAN_PICKUP_PROBE = r"""
|
||||
set -eu
|
||||
NAME="$1"
|
||||
SCANDIR=/run/service
|
||||
DIR="$SCANDIR/$NAME"
|
||||
rm -rf "$DIR"
|
||||
mkdir -p "$DIR"
|
||||
printf 'longrun\n' > "$DIR/type"
|
||||
printf '#!/command/execlineb -P\n/command/s6-sleep 600\n' > "$DIR/run"
|
||||
chmod 755 "$DIR/run"
|
||||
# Trigger a full rescan, exactly as register/reconcile do.
|
||||
/command/s6-svscanctl -a "$SCANDIR"
|
||||
# Give s6-svscan time to act (its scan is async; 200ms is the manager's
|
||||
# own settle delay, use 2s here to be comfortably past it on any arch).
|
||||
/command/s6-sleep 2
|
||||
if [ -d "$DIR/supervise" ]; then
|
||||
owner=$(stat -c '%U' "$DIR/supervise" 2>/dev/null || echo '?')
|
||||
echo "SUPERVISED owner=$owner"
|
||||
else
|
||||
echo "NOT-SUPERVISED"
|
||||
fi
|
||||
# Best-effort teardown so the probe leaves no live supervisor behind.
|
||||
/command/s6-svc -d "$DIR" 2>/dev/null || true
|
||||
/command/s6-svscanctl -an "$SCANDIR" 2>/dev/null || true
|
||||
/command/s6-sleep 1
|
||||
rm -rf "$DIR" 2>/dev/null || true
|
||||
"""
|
||||
|
||||
|
||||
def test_s6_dotfile_staging_dir_is_ignored_by_svscan_rescan(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Regression for the arm64 register-seed race.
|
||||
|
||||
The register path builds the slot in a sibling staging dir and then
|
||||
atomically renames it to the live ``gateway-<profile>`` name. That
|
||||
staging dir lives INSIDE the scandir s6-svscan watches, so its NAME
|
||||
decides whether a concurrent ``s6-svscanctl -a`` rescan (fired by the
|
||||
cont-init reconciler registering ``gateway-default``, or by another
|
||||
register) supervises the half-built slot.
|
||||
|
||||
- A NON-dotted name (the old ``gateway-<p>.tmp``) IS picked up: once it
|
||||
has a valid ``type``/``run``, s6-svscan spawns ``s6-supervise`` AS
|
||||
ROOT, creating a root-owned ``supervise/`` — which makes the in-flight
|
||||
``_seed_supervise_skeleton`` EACCES on ``mkdir supervise/event``. That
|
||||
is the arm64-only flake (the native-arm runner's wider scheduling
|
||||
jitter lets the rescan land inside the seed window).
|
||||
- A DOT-prefixed name (the fix, ``.gateway-<p>.tmp``) is SKIPPED by
|
||||
s6-svscan and never supervised, so no root-owned ``supervise/`` can
|
||||
appear under the staging dir.
|
||||
|
||||
This proves the mechanism directly and is arch-independent (it does not
|
||||
rely on hitting the narrow timing window — it forces the rescan and
|
||||
checks pickup), so it guards the fix on the amd64 job too.
|
||||
"""
|
||||
start_container(built_image, container_name, cmd="sleep 120")
|
||||
|
||||
# Control: a NON-dotted service-shaped dir IS supervised by the rescan
|
||||
# (root-owned supervise/). This is the pre-fix staging-name behaviour and
|
||||
# confirms the probe actually exercises s6-svscan pickup.
|
||||
r = docker_exec(
|
||||
container_name, "sh", "-c", _SVSCAN_PICKUP_PROBE, "probe",
|
||||
"gateway-raceprobe.tmp", user="root", timeout=30,
|
||||
)
|
||||
assert "SUPERVISED" in r.stdout and "NOT-SUPERVISED" not in r.stdout, (
|
||||
"control failed: a non-dotted staging dir should be picked up by "
|
||||
f"s6-svscan. stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||
)
|
||||
|
||||
# The fix: a DOT-prefixed staging dir (the name register/reconcile now
|
||||
# use) must be IGNORED by the same rescan — no supervisor, no root-owned
|
||||
# supervise/, so the in-flight seed can never EACCES.
|
||||
r = docker_exec(
|
||||
container_name, "sh", "-c", _SVSCAN_PICKUP_PROBE, "probe",
|
||||
".gateway-raceprobe.tmp", user="root", timeout=30,
|
||||
)
|
||||
assert "NOT-SUPERVISED" in r.stdout, (
|
||||
"dot-prefixed staging dir was supervised by s6-svscan — the race "
|
||||
f"that EACCESes the seed is still reachable. stdout={r.stdout!r} "
|
||||
f"stderr={r.stderr!r}"
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Runtime smoke tests for the Docker image entrypoint and subcommands.
|
||||
|
||||
Converted from the former ``.github/actions/hermes-smoke-test`` composite
|
||||
action. These tests exercise the image's real ENTRYPOINT (``/init`` +
|
||||
``main-wrapper.sh``) via ``docker run --rm <image> --help`` and
|
||||
``docker run --rm <image> dashboard --help`` to catch basic runtime
|
||||
regressions before publishing.
|
||||
|
||||
The harness expects the ``built_image`` fixture from
|
||||
``tests/docker/conftest.py``. When Docker isn't available every test
|
||||
here is skipped at collection time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_hermes_help(built_image: str) -> None:
|
||||
"""``docker run --rm <image> --help`` must exit 0.
|
||||
|
||||
Uses the image's real ENTRYPOINT (``/init`` + ``main-wrapper.sh``)
|
||||
so this exercises the actual production startup path. PR #30136
|
||||
review caught that an ``--entrypoint`` override in the old composite
|
||||
action had been silently neutered by the s6-overlay migration —
|
||||
``stage2-hook`` ignores CMD args passed after an overridden
|
||||
entrypoint, so the smoke test was a no-op.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "--help"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"hermes --help failed (exit {r.returncode}): "
|
||||
f"stdout={r.stdout[-2000:]!r} stderr={r.stderr[-2000:]!r}"
|
||||
)
|
||||
assert "Traceback" not in r.stderr, (
|
||||
f"hermes --help produced a traceback: {r.stderr[-2000:]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_subcommand_present(built_image: str) -> None:
|
||||
"""``docker run --rm <image> dashboard --help`` must exit 0.
|
||||
|
||||
Regression guard for #9153: the ``dashboard`` subcommand was present
|
||||
in source but missing from the published image. If this fails,
|
||||
something in the Dockerfile is excluding the dashboard subcommand
|
||||
from the installed package.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "dashboard", "--help"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"hermes dashboard --help failed (exit {r.returncode}): "
|
||||
f"stdout={r.stdout[-2000:]!r} stderr={r.stderr[-2000:]!r}"
|
||||
)
|
||||
combined = (r.stdout + r.stderr).lower()
|
||||
assert "dashboard" in combined or "usage" in combined, (
|
||||
f"dashboard --help output unexpected: {combined[-2000:]!r}"
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Runtime smoke tests for Docker stage2 browser executable discovery.
|
||||
|
||||
Build the real image and verify the chromium binary is actually
|
||||
discovered at boot: ``AGENT_BROWSER_EXECUTABLE_PATH`` is set, points to
|
||||
a real executable, and is a browser binary (not a shared library picked
|
||||
up by a broad ``find | grep``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.docker.conftest import docker_exec_sh, start_container
|
||||
|
||||
|
||||
def test_stage2_discovers_chromium_binary(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The stage2 hook must discover the Playwright chromium binary and
|
||||
export AGENT_BROWSER_EXECUTABLE_PATH so the browser tool can find it.
|
||||
|
||||
The discovery uses filename matching, not a broad ``find | grep``:
|
||||
shared libraries (libGLESv2.so etc.) inherit the executable bit from
|
||||
Playwright's tarball but must not be picked up. This test verifies the
|
||||
discovered binary is a real browser, not a .so.
|
||||
"""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# AGENT_BROWSER_EXECUTABLE_PATH must be set via s6 container_environment.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"cat /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"AGENT_BROWSER_EXECUTABLE_PATH not set by stage2 hook: {r.stderr}"
|
||||
)
|
||||
browser_path = r.stdout.strip()
|
||||
assert browser_path, "AGENT_BROWSER_EXECUTABLE_PATH is empty"
|
||||
|
||||
# Must be a real file and executable.
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
f'test -x "{browser_path}"',
|
||||
timeout=5,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"discovered browser path is not executable: {browser_path}"
|
||||
)
|
||||
|
||||
# Must be a browser binary by basename — NOT a shared library.
|
||||
accepted_names = (
|
||||
"chrome", "chromium", "chrome-headless-shell",
|
||||
"headless_shell", "chromium-browser",
|
||||
)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
f'basename "{browser_path}"',
|
||||
timeout=5,
|
||||
)
|
||||
basename = r.stdout.strip()
|
||||
assert basename in accepted_names, (
|
||||
f"discovered binary basename {basename!r} is not a recognized "
|
||||
f"browser name (accepted: {accepted_names}) — the discovery may "
|
||||
f"have picked up a shared library (.so) instead of the real browser"
|
||||
)
|
||||
|
||||
|
||||
def test_stage2_browser_path_accessible_to_hermes_user(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""The discovered browser binary must be accessible to the
|
||||
unprivileged hermes user (UID 10000), since that's who runs
|
||||
agent-browser subprocesses."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
'path="$(cat /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH)" '
|
||||
'&& test -r "$path" && test -x "$path"',
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"browser binary not readable+executable by hermes user: {r.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Runtime smoke test for the Docker tini compatibility shim (#34192).
|
||||
|
||||
Build the real image and verify:
|
||||
|
||||
1. /usr/bin/tini exists and is a symlink to /init (the compat shim
|
||||
for orchestration templates that still reference /usr/bin/tini)
|
||||
2. The actual ENTRYPOINT is /init (s6-overlay), not /usr/bin/tini
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_tini_compat_symlink_exists(built_image: str) -> None:
|
||||
"""/usr/bin/tini must exist as a symlink to /init.
|
||||
|
||||
Regression for #34192: orchestration templates (e.g. Hostinger's
|
||||
'Hermes WebUI' catalog) still pin /usr/bin/tini as the entrypoint.
|
||||
The shim symlinks it to /init so legacy wrappers exec the right
|
||||
PID-1 reaper without behavior change.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "sh",
|
||||
built_image, "-c",
|
||||
'test -L /usr/bin/tini && '
|
||||
'test "$(readlink -f /usr/bin/tini)" = "/init"'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"/usr/bin/tini is not a symlink to /init: {r.stderr[-500:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_entrypoint_is_init_not_tini(built_image: str) -> None:
|
||||
"""The image's actual ENTRYPOINT must be /init (s6-overlay).
|
||||
|
||||
The tini shim is only for legacy external wrappers; the image's own
|
||||
runtime must continue to use the canonical /init.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "inspect", built_image,
|
||||
"--format", "{{json .Config.Entrypoint}}"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"docker inspect failed: {r.stderr}"
|
||||
entrypoint = r.stdout.strip()
|
||||
assert "/init" in entrypoint, (
|
||||
f"ENTRYPOINT is not /init: {entrypoint!r}"
|
||||
)
|
||||
# The entrypoint array should be ["/init", "/opt/hermes/docker/main-wrapper.sh"]
|
||||
# /usr/bin/tini should NOT be in the entrypoint.
|
||||
assert "tini" not in entrypoint.lower(), (
|
||||
f"ENTRYPOINT references tini instead of /init: {entrypoint!r}"
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Runtime smoke tests for Docker top-level state-file ownership repair.
|
||||
|
||||
Build the real image and verify the actual runtime behavior:
|
||||
|
||||
1. Root-owned top-level state files (auth.json, state.db, gateway.lock,
|
||||
gateway_state.json) are chowned to hermes on boot
|
||||
2. Non-allowlisted host-owned files are NOT touched (targeted, not
|
||||
blanket find -user root sweep)
|
||||
3. Symlinked allowlisted files are NOT chowned through the symlink
|
||||
(path_has_symlink_component guard)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import subprocess
|
||||
|
||||
from tests.docker.conftest import (
|
||||
docker_exec,
|
||||
docker_exec_sh,
|
||||
restart_container,
|
||||
start_container,
|
||||
wait_for_container_ready,
|
||||
)
|
||||
|
||||
|
||||
# The files the stage2 hook should repair (mirrors the allowlist in
|
||||
# stage2-hook.sh). We test a representative subset.
|
||||
ALLOWLISTED_FILES = ("auth.json", "state.db", "gateway.lock", "gateway_state.json")
|
||||
|
||||
|
||||
def test_root_owned_state_files_repaired_on_boot(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Root-owned top-level state files must be chowned to hermes on boot."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Create root-owned state files to simulate docker exec (root) writes
|
||||
for f in ALLOWLISTED_FILES:
|
||||
docker_exec(
|
||||
container_name, "touch", f"/opt/data/{f}",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
|
||||
# Verify they're root-owned
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
" ".join(f'stat -c %U /opt/data/{f}' for f in ALLOWLISTED_FILES),
|
||||
timeout=5,
|
||||
)
|
||||
for line in r.stdout.split():
|
||||
assert line == "root", f"expected root-owned, got: {line}"
|
||||
|
||||
# Restart - stage2 should repair ownership
|
||||
restart_container(container_name)
|
||||
|
||||
# Verify files are now hermes-owned
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
" ".join(f'stat -c %U /opt/data/{f}' for f in ALLOWLISTED_FILES),
|
||||
timeout=5,
|
||||
)
|
||||
for line in r.stdout.split():
|
||||
assert line == "hermes", (
|
||||
f"expected hermes-owned after restart, got: {line}"
|
||||
)
|
||||
|
||||
|
||||
def test_non_allowlisted_host_file_not_touched(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A non-allowlisted host-owned file must NOT be chowned, even if
|
||||
root-owned. Regression guard for #19788 / #19795: a bind-mounted
|
||||
$HERMES_HOME may contain host-owned files Hermes does not manage."""
|
||||
start_container(built_image, container_name)
|
||||
|
||||
# Create a non-allowlisted file as root
|
||||
docker_exec(
|
||||
container_name, "touch", "/opt/data/host_secret.json",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
# Make it root-owned explicitly (it already is, but be sure)
|
||||
docker_exec(
|
||||
container_name, "chown", "root:root", "/opt/data/host_secret.json",
|
||||
user="root", timeout=5,
|
||||
)
|
||||
|
||||
# Restart
|
||||
restart_container(container_name)
|
||||
|
||||
# The file must STILL be root-owned (not touched by stage2)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"stat -c %U /opt/data/host_secret.json",
|
||||
timeout=5,
|
||||
)
|
||||
assert r.stdout.strip() == "root", (
|
||||
f"non-allowlisted host file was chowned by stage2 (should be "
|
||||
f"preserved): {r.stdout.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def test_symlinked_allowlisted_file_not_chowned(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""A symlinked allowlisted file (e.g. auth.json -> /tmp/outside.json)
|
||||
must NOT be chowned through the symlink.
|
||||
|
||||
The path_has_symlink_component guard in stage2-hook.sh must detect
|
||||
the symlink and refuse the chown, printing a warning instead. The
|
||||
symlink target must remain untouched and the symlink itself must
|
||||
still be a symlink after restart.
|
||||
"""
|
||||
tmp = tempfile.mkdtemp()
|
||||
host_data: Path | None = None
|
||||
tmp_path = Path(tmp)
|
||||
try:
|
||||
host_data = tmp_path / "data"
|
||||
host_data.mkdir()
|
||||
|
||||
# Pre-create a symlink: auth.json -> /opt/data/.symlink-target
|
||||
# The target must exist so [ -e ] on the symlink returns true and
|
||||
# the chown loop enters the refuse_symlinked_path guard. We create
|
||||
# the target inside the bind mount so it persists across containers.
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{host_data}:/opt/data",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", "touch /opt/data/.symlink-target && ln -s /opt/data/.symlink-target /opt/data/auth.json"],
|
||||
check=True, capture_output=True, timeout=30,
|
||||
)
|
||||
|
||||
# Boot the container with the bind mount
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--name", container_name,
|
||||
"-v", f"{host_data}:/opt/data",
|
||||
built_image, "sleep", "infinity"],
|
||||
check=True, capture_output=True, timeout=60,
|
||||
)
|
||||
# Wait for cont-init to finish (first boot runs stage2)
|
||||
wait_for_container_ready(container_name)
|
||||
|
||||
# The symlink must still exist (not replaced by a regular file)
|
||||
r = docker_exec_sh(
|
||||
container_name,
|
||||
"test -L /opt/data/auth.json && echo SYMLINK || echo NOT_SYMLINK",
|
||||
timeout=5,
|
||||
)
|
||||
assert "SYMLINK" in r.stdout, (
|
||||
f"auth.json symlink was replaced by a regular file: {r.stdout}"
|
||||
)
|
||||
|
||||
# The refusal warning goes to stdout (docker logs), not
|
||||
# container-boot.log (which is written by container_boot.py).
|
||||
r = subprocess.run(
|
||||
["docker", "logs", container_name],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
combined = r.stdout + r.stderr
|
||||
assert "refusing" in combined and "auth.json" in combined, (
|
||||
f"expected symlink refusal warning for auth.json in docker logs: {combined}"
|
||||
)
|
||||
finally:
|
||||
# Clean up root/hermes-owned files left by stage2 chown
|
||||
if host_data is not None:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", container_name],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "run", "--rm",
|
||||
"-v", f"{host_data}:/clean",
|
||||
"--entrypoint", "sh", built_image,
|
||||
"-c", "chown -R 0:0 /clean 2>/dev/null; rm -rf /clean/* /clean/.* 2>/dev/null; chown 0:0 /clean; true"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
try:
|
||||
host_data.rmdir()
|
||||
tmp_path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Harness: interactive TUI TTY passthrough.
|
||||
|
||||
Uses ``script -qc`` on the host to allocate a PTY for the docker client,
|
||||
which then allocates a container-side PTY via ``-t``. The probe inside
|
||||
the container is ``tput cols``, which returns a real column count when
|
||||
stdout is a TTY and either prints ``80`` (the terminfo fallback) or
|
||||
nothing when it is not.
|
||||
|
||||
These tests MUST pass on the current tini-based image AND continue to
|
||||
pass after the Phase 2 s6 migration. Any drift is a regression.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("script") is None,
|
||||
reason="`script` command not available on this host",
|
||||
)
|
||||
|
||||
|
||||
def test_tty_passthrough_to_container(built_image: str) -> None:
|
||||
"""``docker run -t`` must deliver a real TTY to the container process."""
|
||||
# Emit the probe result behind a unique marker. The container's s6 boot
|
||||
# output (cont-init diagnostics, skills-sync summaries like
|
||||
# "Done: 90 new, 0 updated, ...", the preinit "uid=0 ... egid=0" line)
|
||||
# is written to the SAME PTY stream before this runs, so we must NOT
|
||||
# scan the whole stream for "the first number" — that picks up a stray
|
||||
# 0 from the boot log and flips the assertion (assert 0 > 0) whenever
|
||||
# boot output shifts (e.g. a new bundled dep changes the skills-sync
|
||||
# counts). Parse only the value tagged with our marker.
|
||||
marker = "HERMES_TTY_COLS"
|
||||
probe = (
|
||||
f'if [ -t 1 ]; then echo "{marker}=$(tput cols)"; else echo "{marker}=NO_TTY"; fi'
|
||||
)
|
||||
cmd = (
|
||||
f"docker run --rm -t -e COLUMNS=123 {built_image} "
|
||||
f"sh -c {shlex.quote(probe)}"
|
||||
)
|
||||
r = subprocess.run(
|
||||
["script", "-qc", cmd, "/dev/null"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
output = r.stdout
|
||||
matches = re.findall(rf"{marker}=(\S+)", output)
|
||||
assert matches, f"No {marker} marker in output: {output!r}"
|
||||
value = matches[-1].strip()
|
||||
assert value != "NO_TTY", f"TTY passthrough failed: {output!r}"
|
||||
assert value.isdigit(), f"Non-numeric column width {value!r} in: {output!r}"
|
||||
assert int(value) > 0
|
||||
|
||||
|
||||
def test_tui_flag_recognized(built_image: str) -> None:
|
||||
"""``docker run -it <image> --help`` should run without crashing."""
|
||||
cmd = f"docker run --rm -t {built_image} --help"
|
||||
r = subprocess.run(
|
||||
["script", "-qc", cmd, "/dev/null"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.
|
||||
|
||||
Regression guard for the hosted-chat failure where the embedded dashboard
|
||||
Chat tab died with a 502 / "[session ended]". Root cause: the image installs
|
||||
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
|
||||
so the actualized node_modules permanently disagrees with the canonical
|
||||
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
|
||||
through to ``_tui_need_npm_install`` (which returns True forever) and tries a
|
||||
runtime ``npm install`` that can never converge and races itself across
|
||||
concurrent /api/pty connections → ENOTEMPTY.
|
||||
|
||||
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
|
||||
makes the launcher take the prebuilt-bundle fast path (``node --expose-gc
|
||||
.../dist/entry.js``) and skip the install check entirely. These tests assert
|
||||
that invariant holds in the built image.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
def _exec_py(image: str, py: str) -> str:
|
||||
"""Run a Python snippet inside the image as the hermes user, return stdout."""
|
||||
inner = (
|
||||
"source /opt/hermes/.venv/bin/activate && "
|
||||
"cd /opt/hermes && "
|
||||
f"python3 -c {shlex.quote(py)}"
|
||||
)
|
||||
# Drop to the hermes user (UID 10000) so we exercise the same path the
|
||||
# dashboard PTY child runs as — not root.
|
||||
cmd = [
|
||||
"docker", "run", "--rm", "--entrypoint", "su", image,
|
||||
"hermes", "-s", "/bin/bash", "-c", inner,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
|
||||
"""HERMES_TUI_DIR must point at the prebuilt bundle dir in the image."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "sh", built_image,
|
||||
"-c", 'printf "%s" "$HERMES_TUI_DIR"'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-2000:]
|
||||
assert r.stdout.strip() == "/opt/hermes/ui-tui", (
|
||||
f"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)"
|
||||
)
|
||||
|
||||
|
||||
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
|
||||
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
|
||||
npm install — i.e. it takes the same path as a nix/packaged release."""
|
||||
py = (
|
||||
"import json\n"
|
||||
"from pathlib import Path\n"
|
||||
"from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n"
|
||||
"ui = Path('/opt/hermes/ui-tui')\n"
|
||||
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
|
||||
"out = {\n"
|
||||
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
|
||||
" 'need_npm_install': _tui_need_npm_install(ui),\n"
|
||||
" 'argv': argv,\n"
|
||||
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
|
||||
"}\n"
|
||||
"print(json.dumps(out))\n"
|
||||
)
|
||||
out = json.loads(_exec_py(built_image, py))
|
||||
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
|
||||
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
|
||||
# ever reaching the install check — so the resolved argv is what matters.
|
||||
assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}"
|
||||
assert "npm" not in out["argv"][0].lower(), (
|
||||
f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Runtime smoke tests for Docker --user flag guard.
|
||||
|
||||
Build the real image and verify the actual runtime behavior:
|
||||
|
||||
1. docker run --user <arbitrary-uid> is rejected with actionable guidance
|
||||
2. Root start (default) works fine
|
||||
3. --user <hermes-uid> (10000) is allowed (supported non-root start)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_arbitrary_user_uid_rejected(
|
||||
built_image: str,
|
||||
) -> None:
|
||||
"""docker run --user 1000 must be rejected with actionable guidance."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--user", "1000:1000",
|
||||
built_image, "echo", "should_not_reach"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode != 0, (
|
||||
f"container started with arbitrary --user UID unexpectedly: {r.stdout}"
|
||||
)
|
||||
assert "should_not_reach" not in r.stdout, (
|
||||
f"container ran despite --user rejection: {r.stdout}"
|
||||
)
|
||||
combined = r.stdout + r.stderr
|
||||
assert "not supported" in combined.lower(), (
|
||||
f"rejection message missing 'not supported': {combined[-500:]}"
|
||||
)
|
||||
# Must mention the remediation env vars
|
||||
assert "HERMES_UID" in combined or "PUID" in combined, (
|
||||
f"rejection message missing remediation guidance: {combined[-500:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_root_start_works(
|
||||
built_image: str,
|
||||
) -> None:
|
||||
"""Root start (the default) must work without issues."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", built_image, "sh", "-c", "echo OK"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, f"root start failed: {r.stderr[-500:]}"
|
||||
assert "OK" in r.stdout
|
||||
|
||||
|
||||
def test_user_pinned_to_hermes_uid_works(
|
||||
built_image: str,
|
||||
) -> None:
|
||||
"""docker run --user 10000:10000 (the hermes UID) must be allowed.
|
||||
|
||||
This is the supported non-root start from #34648 / #34837.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--user", "10000:10000",
|
||||
built_image, "sh", "-c", "echo OK"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"--user 10000:10000 (hermes UID) was rejected: {r.stderr[-500:]}"
|
||||
)
|
||||
assert "OK" in r.stdout
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Harness: PID 1 must reap orphaned zombie processes.
|
||||
|
||||
tini (current PID 1) reaps zombies via its built-in subreaper behavior.
|
||||
s6-overlay's ``/init`` (Phase 2 PID 1) does the same. This invariant is
|
||||
required for long-running containers spawning subprocesses (subagents,
|
||||
dashboard, dynamic gateways) — otherwise the process table fills with
|
||||
defunct entries and eventually exhausts the kernel PID space.
|
||||
|
||||
Every ``docker exec`` here runs as the unprivileged ``hermes`` user
|
||||
(via :func:`docker_exec_sh` in conftest); see the conftest module
|
||||
docstring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from tests.docker.conftest import docker_exec, docker_exec_sh, start_container, start_container
|
||||
|
||||
|
||||
def test_orphan_zombies_reaped(
|
||||
built_image: str, container_name: str,
|
||||
) -> None:
|
||||
"""Spawn an orphan child that exits immediately. PID 1 must reap it."""
|
||||
start_container(built_image, container_name, cmd="sleep 60")
|
||||
|
||||
# `( ( sleep 0.1 & ) & ); sleep 1` creates a grandchild detached from
|
||||
# the original docker exec session — it becomes an orphan reparented
|
||||
# to PID 1 in the container. When it exits, PID 1 must reap it.
|
||||
docker_exec_sh(
|
||||
container_name, "( ( sleep 0.1 & ) & ); sleep 1", timeout=10,
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
r = docker_exec(container_name, "ps", "axo", "stat,pid,comm")
|
||||
zombies = [
|
||||
line for line in r.stdout.split("\n")
|
||||
if line.strip().startswith("Z")
|
||||
]
|
||||
assert not zombies, f"Zombies not reaped by PID 1: {zombies}"
|
||||
Reference in New Issue
Block a user