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, })