From 2dafdf02b9b1db00cf5b763c62ed15314e42c2d1 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 21 Sep 2026 18:45:33 -0400 Subject: [PATCH] tests: keep the home-io guard off the checkout and Hermes' scratch TMPDIR The default install checks the repo out INSIDE the Hermes home (install.sh INSTALL_DIR=$HERMES_HOME/hermes-agent), so the guard tripped on the checkout's own test data and on traceback source reads from any default-location run. Exempt the project root like the interpreter prefixes: the checkout is not Hermes state. A Hermes-launched shell also hands pytest TMPDIR=/cache/scratch (tagged HERMES_SCRATCH_DIR), and importing hermes_bootstrap re-applies that redirect for a custom HERMES_HOME. Both put the session sandbox, basetemp and every tempfile default inside a guarded root (16 errors in test_update_completion_routing alone under a bare pytest). conftest now strips Hermes' own export before allocating temp space and pins the system default so the import-time hook is a no-op; user-set temp vars are untouched and the parallel runner's own TMPDIR is unaffected. --- tests/conftest.py | 22 +++++++++ tests/hermes_cli/test_real_home_tripwire.py | 49 +++++++++++++++++++++ tests/home_io_guard.py | 6 +++ 3 files changed, 77 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 02e3f2689d..11505d1e11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,6 +97,28 @@ def _hermes_home_points_at_production(value: str) -> bool: return resolved.parent.name == "profiles" and resolved.parent.parent == real_root +# ``import hermes_bootstrap`` (transitively: any entry-point module) runs +# ``export_scratch_tmp_env()``, which points TMPDIR/TMP/TEMP at +# ``/cache/scratch`` unless a temp var is already set — and a +# Hermes-launched shell (agent terminal, ``hermes`` child) arrives with that +# redirect already applied, tagged by HERMES_SCRATCH_DIR. Either way the tmp +# root ends up INSIDE a guarded real home (the operator's, or a custom one +# honored below), so the session sandbox, pytest's basetemp and every +# ``tempfile`` default in the code under test trip the real-home guard. Strip +# Hermes' own export (the marker tells it apart from a user-set var, which is +# left alone) and pin the system default so the import-time hook stays a +# no-op. The parallel runner exports its own disk-backed TMPDIR anyway. +from hermes_constants import SCRATCH_DIR_MARKER_ENV, SCRATCH_TMP_ENV_VARS + +_HERMES_EXPORTED_TMP = os.environ.get(SCRATCH_DIR_MARKER_ENV, "") +if _HERMES_EXPORTED_TMP: + for _key in SCRATCH_TMP_ENV_VARS: + if os.environ.get(_key, "").strip() == _HERMES_EXPORTED_TMP: + del os.environ[_key] + del os.environ[SCRATCH_DIR_MARKER_ENV] + tempfile.tempdir = None # drop the cached redirect so gettempdir() re-resolves +os.environ.setdefault("TMPDIR", tempfile.gettempdir()) + if _hermes_home_points_at_production(os.environ.get("HERMES_HOME", "")): _SESSION_HERMES_HOME = tempfile.mkdtemp(prefix="hermes-test-home-") os.environ["HERMES_HOME"] = _SESSION_HERMES_HOME diff --git a/tests/hermes_cli/test_real_home_tripwire.py b/tests/hermes_cli/test_real_home_tripwire.py index 162debe137..e5d96bc146 100644 --- a/tests/hermes_cli/test_real_home_tripwire.py +++ b/tests/hermes_cli/test_real_home_tripwire.py @@ -6,9 +6,14 @@ import os from pathlib import Path import shutil import sqlite3 +import subprocess +import sys +import textwrap import pytest +PROJECT_ROOT = Path(__file__).resolve().parents[2] + @pytest.fixture def protected_home(tmp_path, monkeypatch): @@ -125,3 +130,47 @@ def test_close_keeps_a_reused_descriptors_new_owner(tmp_path, monkeypatch): finally: for fd in reopened: original_close(fd) + + +def test_checkout_inside_a_guarded_root_is_not_hermes_state(): + """The default install checks the repo out INSIDE the home (install.sh: + INSTALL_DIR=$HERMES_HOME/hermes-agent): the checkout, its .venv and test + data are exempt even when the guarded root contains them; siblings under + that root are still refused.""" + from tests.home_io_guard import HomeIOGuard + + guard = HomeIOGuard(lambda: [PROJECT_ROOT.parent]) + guard.check(PROJECT_ROOT / "tests" / "home_io_guard.py") + guard.check(PROJECT_ROOT / ".venv" / "bin" / "python", metadata=True) + with pytest.raises(AssertionError, match="REAL hermes home"): + guard.check(PROJECT_ROOT.parent / "config.yaml") + + +def test_hermes_exported_scratch_tmp_is_not_the_test_temp_root(tmp_path): + """A Hermes-launched shell hands pytest TMPDIR=/cache/scratch (tagged by + HERMES_SCRATCH_DIR). With that home guarded, honoring it would put the session + sandbox, basetemp and every tempfile default inside the guarded root; the + conftest must drop Hermes' own export before anything allocates temp space.""" + home = tmp_path / "home" + scratch = home / "cache" / "scratch" + scratch.mkdir(parents=True) + probe = tmp_path / "test_probe.py" + probe.write_text(textwrap.dedent(f""" + import tempfile + from pathlib import Path + + def test_temp_root_is_outside_the_honored_home(): + home = Path({str(home)!r}).resolve() + assert not Path(tempfile.gettempdir()).resolve().is_relative_to(home) + with tempfile.TemporaryDirectory() as made: + assert not Path(made).resolve().is_relative_to(home) + """), encoding="utf-8") + env = {k: v for k, v in os.environ.items() + if k not in ("TMPDIR", "TMP", "TEMP", "HERMES_SCRATCH_DIR", "HERMES_TEST_SANDBOX_HOME")} + env.update(HERMES_HOME=str(home), TMPDIR=str(scratch), HERMES_SCRATCH_DIR=str(scratch)) + result = subprocess.run( + [sys.executable, "-m", "pytest", "-p", "tests.conftest", "-p", "no:cacheprovider", "-q", str(probe)], + cwd=PROJECT_ROOT, env=env, capture_output=True, text=True, timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "1 passed" in result.stdout diff --git a/tests/home_io_guard.py b/tests/home_io_guard.py index 830c581021..45a89ca929 100644 --- a/tests/home_io_guard.py +++ b/tests/home_io_guard.py @@ -18,6 +18,12 @@ _INTERPRETER_PREFIXES = tuple({ # whose site-packages sits under the (real) Hermes home; third-party imports from it are the # interpreter's installation, not Hermes state. Path(p).resolve() for p in sys.path if p and Path(p).name in ("site-packages", "dist-packages") +} | { + # The default install checks the repo out INSIDE the home (install.sh: + # INSTALL_DIR=$HERMES_HOME/hermes-agent). Reading test data, sources for tracebacks, or the + # checkout's own .venv is not Hermes state; without this every run from a default install + # trips on its first traceback. + Path(__file__).resolve().parent.parent, })