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=<home>/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.
This commit is contained in:
ethernet
2026-09-21 18:45:33 -04:00
parent fea1f34000
commit 2dafdf02b9
3 changed files with 77 additions and 0 deletions

View File

@@ -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
# ``<HERMES_HOME>/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

View File

@@ -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=<home>/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

View File

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